@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.
- package/AGENTS.md +54 -0
- package/README.md +33 -0
- package/actagent.plugin.json +174 -0
- package/index.test.ts +120 -0
- package/index.ts +23 -0
- package/npm-shrinkwrap.json +2246 -0
- package/package.json +50 -0
- package/register.runtime.test.ts +168 -0
- package/register.runtime.ts +108 -0
- package/runtime-api.ts +53 -0
- package/setup-api.ts +22 -0
- package/skills/acp-router/SKILL.md +245 -0
- package/src/claude-agent-acp-completion.test.ts +167 -0
- package/src/codex-auth-bridge.test.ts +799 -0
- package/src/codex-auth-bridge.ts +764 -0
- package/src/codex-trust-config.ts +304 -0
- package/src/command-line.ts +56 -0
- package/src/config-schema.ts +130 -0
- package/src/config.test.ts +296 -0
- package/src/config.ts +290 -0
- package/src/manifest.test.ts +22 -0
- package/src/process-lease.test.ts +90 -0
- package/src/process-lease.ts +194 -0
- package/src/process-reaper.test.ts +337 -0
- package/src/process-reaper.ts +427 -0
- package/src/runtime-internals/mcp-command-line.mjs +128 -0
- package/src/runtime-internals/mcp-command-line.test.ts +60 -0
- package/src/runtime-internals/mcp-proxy.mjs +132 -0
- package/src/runtime-internals/mcp-proxy.test.ts +131 -0
- package/src/runtime-proxy.ts +47 -0
- package/src/runtime-turn.ts +200 -0
- package/src/runtime.test.ts +1830 -0
- package/src/runtime.ts +1254 -0
- package/src/service.test.ts +839 -0
- package/src/service.ts +439 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persistent lease store for ACPX wrapper processes. Leases let ACTAgent attach
|
|
3
|
+
* gateway/session identity to spawned ACP processes and clean them up later.
|
|
4
|
+
*/
|
|
5
|
+
import { randomUUID, createHash } from "node:crypto";
|
|
6
|
+
import fs from "node:fs/promises";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { readJsonFileWithFallback, writeJsonFileAtomically } from "actagent/plugin-sdk/json-store";
|
|
9
|
+
|
|
10
|
+
/** Environment variable carrying the ACPX process lease id. */
|
|
11
|
+
export const ACTAGENT_ACPX_LEASE_ID_ENV = "ACTAGENT_ACPX_LEASE_ID";
|
|
12
|
+
/** Environment variable carrying the owning gateway instance id. */
|
|
13
|
+
export const ACTAGENT_GATEWAY_INSTANCE_ID_ENV = "ACTAGENT_GATEWAY_INSTANCE_ID";
|
|
14
|
+
/** CLI argument carrying the ACPX process lease id for platforms without env wrapping. */
|
|
15
|
+
export const ACTAGENT_ACPX_LEASE_ID_ARG = "--actagent-acpx-lease-id";
|
|
16
|
+
/** CLI argument carrying the owning gateway instance id. */
|
|
17
|
+
export const ACTAGENT_GATEWAY_INSTANCE_ID_ARG = "--actagent-gateway-instance-id";
|
|
18
|
+
|
|
19
|
+
/** Lifecycle state for a tracked ACPX wrapper process. */
|
|
20
|
+
export type AcpxProcessLeaseState = "open" | "closing" | "closed" | "lost";
|
|
21
|
+
|
|
22
|
+
/** Persisted identity and command metadata for one ACPX wrapper process. */
|
|
23
|
+
export type AcpxProcessLease = {
|
|
24
|
+
leaseId: string;
|
|
25
|
+
gatewayInstanceId: string;
|
|
26
|
+
sessionKey: string;
|
|
27
|
+
wrapperRoot: string;
|
|
28
|
+
wrapperPath: string;
|
|
29
|
+
rootPid: number;
|
|
30
|
+
processGroupId?: number;
|
|
31
|
+
commandHash: string;
|
|
32
|
+
startedAt: number;
|
|
33
|
+
state: AcpxProcessLeaseState;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** Async lease store used by runtime sessions and cleanup routines. */
|
|
37
|
+
export type AcpxProcessLeaseStore = {
|
|
38
|
+
load(leaseId: string): Promise<AcpxProcessLease | undefined>;
|
|
39
|
+
listOpen(gatewayInstanceId?: string): Promise<AcpxProcessLease[]>;
|
|
40
|
+
save(lease: AcpxProcessLease): Promise<void>;
|
|
41
|
+
markState(leaseId: string, state: AcpxProcessLeaseState): Promise<void>;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
type LeaseFile = {
|
|
45
|
+
version: 1;
|
|
46
|
+
leases: AcpxProcessLease[];
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const LEASE_FILE = "process-leases.json";
|
|
50
|
+
|
|
51
|
+
function normalizeLease(value: unknown): AcpxProcessLease | undefined {
|
|
52
|
+
if (typeof value !== "object" || value === null) {
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
const record = value as Record<string, unknown>;
|
|
56
|
+
if (
|
|
57
|
+
typeof record.leaseId !== "string" ||
|
|
58
|
+
typeof record.gatewayInstanceId !== "string" ||
|
|
59
|
+
typeof record.sessionKey !== "string" ||
|
|
60
|
+
typeof record.wrapperRoot !== "string" ||
|
|
61
|
+
typeof record.wrapperPath !== "string" ||
|
|
62
|
+
typeof record.rootPid !== "number" ||
|
|
63
|
+
typeof record.commandHash !== "string" ||
|
|
64
|
+
typeof record.startedAt !== "number" ||
|
|
65
|
+
!["open", "closing", "closed", "lost"].includes(String(record.state))
|
|
66
|
+
) {
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
leaseId: record.leaseId,
|
|
71
|
+
gatewayInstanceId: record.gatewayInstanceId,
|
|
72
|
+
sessionKey: record.sessionKey,
|
|
73
|
+
wrapperRoot: record.wrapperRoot,
|
|
74
|
+
wrapperPath: record.wrapperPath,
|
|
75
|
+
rootPid: record.rootPid,
|
|
76
|
+
...(typeof record.processGroupId === "number" ? { processGroupId: record.processGroupId } : {}),
|
|
77
|
+
commandHash: record.commandHash,
|
|
78
|
+
startedAt: record.startedAt,
|
|
79
|
+
state: record.state as AcpxProcessLeaseState,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function readLeaseFile(filePath: string): Promise<LeaseFile> {
|
|
84
|
+
const { value } = await readJsonFileWithFallback<Partial<LeaseFile>>(filePath, {
|
|
85
|
+
version: 1,
|
|
86
|
+
leases: [],
|
|
87
|
+
});
|
|
88
|
+
const leases = Array.isArray(value.leases)
|
|
89
|
+
? value.leases.map(normalizeLease).filter((lease): lease is AcpxProcessLease => Boolean(lease))
|
|
90
|
+
: [];
|
|
91
|
+
return { version: 1, leases };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function writeLeaseFile(filePath: string, value: LeaseFile): Promise<void> {
|
|
95
|
+
return writeJsonFileAtomically(filePath, value);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Create a serialized JSON-backed ACPX process lease store. */
|
|
99
|
+
export function createAcpxProcessLeaseStore(params: { stateDir: string }): AcpxProcessLeaseStore {
|
|
100
|
+
const filePath = path.join(params.stateDir, LEASE_FILE);
|
|
101
|
+
let updateQueue: Promise<void> = Promise.resolve();
|
|
102
|
+
|
|
103
|
+
async function update(
|
|
104
|
+
mutator: (leases: AcpxProcessLease[]) => AcpxProcessLease[],
|
|
105
|
+
): Promise<void> {
|
|
106
|
+
const run = updateQueue.then(async () => {
|
|
107
|
+
await fs.mkdir(params.stateDir, { recursive: true });
|
|
108
|
+
const current = await readLeaseFile(filePath);
|
|
109
|
+
await writeLeaseFile(filePath, {
|
|
110
|
+
version: 1,
|
|
111
|
+
leases: mutator(current.leases),
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
updateQueue = run.catch(() => {});
|
|
115
|
+
await run;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function readCurrent(): Promise<LeaseFile> {
|
|
119
|
+
await updateQueue;
|
|
120
|
+
return await readLeaseFile(filePath);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
async load(leaseId) {
|
|
125
|
+
const current = await readCurrent();
|
|
126
|
+
return current.leases.find((lease) => lease.leaseId === leaseId);
|
|
127
|
+
},
|
|
128
|
+
async listOpen(gatewayInstanceId) {
|
|
129
|
+
const current = await readCurrent();
|
|
130
|
+
return current.leases.filter(
|
|
131
|
+
(lease) =>
|
|
132
|
+
(lease.state === "open" || lease.state === "closing") &&
|
|
133
|
+
(!gatewayInstanceId || lease.gatewayInstanceId === gatewayInstanceId),
|
|
134
|
+
);
|
|
135
|
+
},
|
|
136
|
+
async save(lease) {
|
|
137
|
+
await update((leases) => [
|
|
138
|
+
...leases.filter((entry) => entry.leaseId !== lease.leaseId),
|
|
139
|
+
lease,
|
|
140
|
+
]);
|
|
141
|
+
},
|
|
142
|
+
async markState(leaseId, state) {
|
|
143
|
+
await update((leases) =>
|
|
144
|
+
leases.map((lease) => (lease.leaseId === leaseId ? { ...lease, state } : lease)),
|
|
145
|
+
);
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Create a unique lease id for one ACPX wrapper process. */
|
|
151
|
+
export function createAcpxProcessLeaseId(): string {
|
|
152
|
+
return randomUUID();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Hash a wrapper command so process leases can detect command drift. */
|
|
156
|
+
export function hashAcpxProcessCommand(command: string): string {
|
|
157
|
+
return createHash("sha256").update(command).digest("hex");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function quoteEnvValue(value: string): string {
|
|
161
|
+
return /^[A-Za-z0-9_./:=@+-]+$/.test(value) ? value : `'${value.replace(/'/g, "'\\''")}'`;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function appendAcpxLeaseArgs(params: {
|
|
165
|
+
command: string;
|
|
166
|
+
leaseId: string;
|
|
167
|
+
gatewayInstanceId: string;
|
|
168
|
+
}): string {
|
|
169
|
+
return [
|
|
170
|
+
params.command,
|
|
171
|
+
ACTAGENT_ACPX_LEASE_ID_ARG,
|
|
172
|
+
quoteEnvValue(params.leaseId),
|
|
173
|
+
ACTAGENT_GATEWAY_INSTANCE_ID_ARG,
|
|
174
|
+
quoteEnvValue(params.gatewayInstanceId),
|
|
175
|
+
].join(" ");
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Add ACPX lease identity to a command through env vars and portable args. */
|
|
179
|
+
export function withAcpxLeaseEnvironment(params: {
|
|
180
|
+
command: string;
|
|
181
|
+
leaseId: string;
|
|
182
|
+
gatewayInstanceId: string;
|
|
183
|
+
platform?: NodeJS.Platform;
|
|
184
|
+
}): string {
|
|
185
|
+
if ((params.platform ?? process.platform) === "win32") {
|
|
186
|
+
return appendAcpxLeaseArgs(params);
|
|
187
|
+
}
|
|
188
|
+
return [
|
|
189
|
+
"env",
|
|
190
|
+
`${ACTAGENT_ACPX_LEASE_ID_ENV}=${quoteEnvValue(params.leaseId)}`,
|
|
191
|
+
`${ACTAGENT_GATEWAY_INSTANCE_ID_ENV}=${quoteEnvValue(params.gatewayInstanceId)}`,
|
|
192
|
+
appendAcpxLeaseArgs(params),
|
|
193
|
+
].join(" ");
|
|
194
|
+
}
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
// ACPX tests cover process reaper plugin behavior.
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { ACTAGENT_ACPX_LEASE_ID_ARG, ACTAGENT_GATEWAY_INSTANCE_ID_ARG } from "./process-lease.js";
|
|
5
|
+
import {
|
|
6
|
+
cleanupACTAgentOwnedAcpxProcessTree,
|
|
7
|
+
isACTAgentLeaseAwareAcpxProcessCommand,
|
|
8
|
+
isACTAgentOwnedAcpxProcessCommand,
|
|
9
|
+
reapStaleACTAgentOwnedAcpxOrphans,
|
|
10
|
+
type AcpxProcessInfo,
|
|
11
|
+
} from "./process-reaper.js";
|
|
12
|
+
|
|
13
|
+
const WRAPPER_ROOT = "/tmp/actagent-state/acpx";
|
|
14
|
+
const CODEX_WRAPPER_COMMAND = `node ${WRAPPER_ROOT}/codex-acp-wrapper.mjs`;
|
|
15
|
+
const CODEX_WRAPPER_COMMAND_WITH_LEASE = `${CODEX_WRAPPER_COMMAND} ${ACTAGENT_ACPX_LEASE_ID_ARG} lease-1 ${ACTAGENT_GATEWAY_INSTANCE_ID_ARG} gateway-1`;
|
|
16
|
+
const CLAUDE_WRAPPER_COMMAND = `node ${WRAPPER_ROOT}/claude-agent-acp-wrapper.mjs`;
|
|
17
|
+
const PLUGIN_DEPS_CODEX_COMMAND =
|
|
18
|
+
"node /tmp/actagent/plugin-runtime-deps/node_modules/@zed-industries/codex-acp/bin/codex-acp.js";
|
|
19
|
+
const LOCAL_NODE_MODULES_CODEX_COMMAND = `node ${path.resolve(
|
|
20
|
+
"node_modules/@zed-industries/codex-acp/bin/codex-acp.js",
|
|
21
|
+
)}`;
|
|
22
|
+
const LOCAL_NODE_MODULES_CODEX_PLATFORM_COMMAND = path.resolve(
|
|
23
|
+
"node_modules/@zed-industries/codex-acp-linux-x64/bin/codex-acp",
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
function cleanupDeps(processes: AcpxProcessInfo[]) {
|
|
27
|
+
const killed: Array<{ pid: number; signal: NodeJS.Signals }> = [];
|
|
28
|
+
return {
|
|
29
|
+
killed,
|
|
30
|
+
deps: {
|
|
31
|
+
listProcesses: vi.fn(async () => processes),
|
|
32
|
+
killProcess: vi.fn((pid: number, signal: NodeJS.Signals) => {
|
|
33
|
+
killed.push({ pid, signal });
|
|
34
|
+
}),
|
|
35
|
+
sleep: vi.fn(async () => {}),
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function collectMatching<T, U>(
|
|
41
|
+
items: readonly T[],
|
|
42
|
+
predicate: (item: T) => boolean,
|
|
43
|
+
map: (item: T) => U,
|
|
44
|
+
): U[] {
|
|
45
|
+
const matches: U[] = [];
|
|
46
|
+
for (const item of items) {
|
|
47
|
+
if (predicate(item)) {
|
|
48
|
+
matches.push(map(item));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return matches;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
describe("process reaper", () => {
|
|
55
|
+
it("recognizes generated Codex and Claude wrappers only under the configured root", () => {
|
|
56
|
+
expect(
|
|
57
|
+
isACTAgentOwnedAcpxProcessCommand({
|
|
58
|
+
command: CODEX_WRAPPER_COMMAND,
|
|
59
|
+
wrapperRoot: WRAPPER_ROOT,
|
|
60
|
+
}),
|
|
61
|
+
).toBe(true);
|
|
62
|
+
expect(
|
|
63
|
+
isACTAgentOwnedAcpxProcessCommand({
|
|
64
|
+
command: CLAUDE_WRAPPER_COMMAND,
|
|
65
|
+
wrapperRoot: WRAPPER_ROOT,
|
|
66
|
+
}),
|
|
67
|
+
).toBe(true);
|
|
68
|
+
expect(
|
|
69
|
+
isACTAgentOwnedAcpxProcessCommand({
|
|
70
|
+
command: "node /tmp/other/codex-acp-wrapper.mjs",
|
|
71
|
+
wrapperRoot: WRAPPER_ROOT,
|
|
72
|
+
}),
|
|
73
|
+
).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("only treats generated wrappers as launch-lease aware", () => {
|
|
77
|
+
expect(
|
|
78
|
+
isACTAgentLeaseAwareAcpxProcessCommand({
|
|
79
|
+
command: CODEX_WRAPPER_COMMAND,
|
|
80
|
+
wrapperRoot: WRAPPER_ROOT,
|
|
81
|
+
}),
|
|
82
|
+
).toBe(true);
|
|
83
|
+
expect(
|
|
84
|
+
isACTAgentLeaseAwareAcpxProcessCommand({ command: LOCAL_NODE_MODULES_CODEX_COMMAND }),
|
|
85
|
+
).toBe(false);
|
|
86
|
+
expect(isACTAgentLeaseAwareAcpxProcessCommand({ command: PLUGIN_DEPS_CODEX_COMMAND })).toBe(
|
|
87
|
+
false,
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("recognizes ACTAgent plugin-runtime-deps ACP adapter children", () => {
|
|
92
|
+
expect(isACTAgentOwnedAcpxProcessCommand({ command: PLUGIN_DEPS_CODEX_COMMAND })).toBe(true);
|
|
93
|
+
expect(isACTAgentOwnedAcpxProcessCommand({ command: "npx @zed-industries/codex-acp" })).toBe(
|
|
94
|
+
false,
|
|
95
|
+
);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("recognizes plugin-local ACP adapter package paths without trusting arbitrary installs", () => {
|
|
99
|
+
expect(isACTAgentOwnedAcpxProcessCommand({ command: LOCAL_NODE_MODULES_CODEX_COMMAND })).toBe(
|
|
100
|
+
true,
|
|
101
|
+
);
|
|
102
|
+
expect(
|
|
103
|
+
isACTAgentOwnedAcpxProcessCommand({
|
|
104
|
+
command: "node /tmp/other-project/node_modules/@zed-industries/codex-acp/bin/codex-acp.js",
|
|
105
|
+
}),
|
|
106
|
+
).toBe(false);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("kills an owned recorded process tree children first", async () => {
|
|
110
|
+
const { deps, killed } = cleanupDeps([
|
|
111
|
+
{ pid: 100, ppid: 1, command: CODEX_WRAPPER_COMMAND },
|
|
112
|
+
{ pid: 101, ppid: 100, command: PLUGIN_DEPS_CODEX_COMMAND },
|
|
113
|
+
{ pid: 102, ppid: 101, command: "node child.js" },
|
|
114
|
+
]);
|
|
115
|
+
|
|
116
|
+
const result = await cleanupACTAgentOwnedAcpxProcessTree({
|
|
117
|
+
rootPid: 100,
|
|
118
|
+
rootCommand: CODEX_WRAPPER_COMMAND,
|
|
119
|
+
wrapperRoot: WRAPPER_ROOT,
|
|
120
|
+
deps,
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
expect(result.skippedReason).toBeUndefined();
|
|
124
|
+
expect(result.inspectedPids).toEqual([100, 101, 102]);
|
|
125
|
+
expect(killed.slice(0, 3)).toEqual([
|
|
126
|
+
{ pid: 102, signal: "SIGTERM" },
|
|
127
|
+
{ pid: 101, signal: "SIGTERM" },
|
|
128
|
+
{ pid: 100, signal: "SIGTERM" },
|
|
129
|
+
]);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("allows wrapper-root verification when stored wrapper commands are shell-quoted", async () => {
|
|
133
|
+
const { deps, killed } = cleanupDeps([{ pid: 110, ppid: 1, command: CODEX_WRAPPER_COMMAND }]);
|
|
134
|
+
|
|
135
|
+
const result = await cleanupACTAgentOwnedAcpxProcessTree({
|
|
136
|
+
rootPid: 110,
|
|
137
|
+
rootCommand: `"/usr/local/bin/node" "${WRAPPER_ROOT}/codex-acp-wrapper.mjs"`,
|
|
138
|
+
wrapperRoot: WRAPPER_ROOT,
|
|
139
|
+
deps,
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
expect(result.skippedReason).toBeUndefined();
|
|
143
|
+
expect(killed[0]).toEqual({ pid: 110, signal: "SIGTERM" });
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("requires matching lease identity before killing a leased process tree", async () => {
|
|
147
|
+
const { deps, killed } = cleanupDeps([
|
|
148
|
+
{ pid: 112, ppid: 1, command: CODEX_WRAPPER_COMMAND_WITH_LEASE },
|
|
149
|
+
]);
|
|
150
|
+
|
|
151
|
+
const result = await cleanupACTAgentOwnedAcpxProcessTree({
|
|
152
|
+
rootPid: 112,
|
|
153
|
+
rootCommand: CODEX_WRAPPER_COMMAND,
|
|
154
|
+
expectedLeaseId: "lease-1",
|
|
155
|
+
expectedGatewayInstanceId: "gateway-1",
|
|
156
|
+
wrapperRoot: WRAPPER_ROOT,
|
|
157
|
+
deps,
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
expect(result.skippedReason).toBeUndefined();
|
|
161
|
+
expect(killed[0]).toEqual({ pid: 112, signal: "SIGTERM" });
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("does not kill a reused same-root wrapper pid with a different lease identity", async () => {
|
|
165
|
+
const { deps, killed } = cleanupDeps([
|
|
166
|
+
{
|
|
167
|
+
pid: 113,
|
|
168
|
+
ppid: 1,
|
|
169
|
+
command: `${CODEX_WRAPPER_COMMAND} ${ACTAGENT_ACPX_LEASE_ID_ARG} other-lease ${ACTAGENT_GATEWAY_INSTANCE_ID_ARG} gateway-1`,
|
|
170
|
+
},
|
|
171
|
+
]);
|
|
172
|
+
|
|
173
|
+
const result = await cleanupACTAgentOwnedAcpxProcessTree({
|
|
174
|
+
rootPid: 113,
|
|
175
|
+
rootCommand: CODEX_WRAPPER_COMMAND,
|
|
176
|
+
expectedLeaseId: "lease-1",
|
|
177
|
+
expectedGatewayInstanceId: "gateway-1",
|
|
178
|
+
wrapperRoot: WRAPPER_ROOT,
|
|
179
|
+
deps,
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
expect(result).toEqual({
|
|
183
|
+
inspectedPids: [113],
|
|
184
|
+
terminatedPids: [],
|
|
185
|
+
skippedReason: "not-actagent-owned",
|
|
186
|
+
});
|
|
187
|
+
expect(killed).toStrictEqual([]);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("skips recorded pid cleanup when process listing is unavailable", async () => {
|
|
191
|
+
const killed: Array<{ pid: number; signal: NodeJS.Signals }> = [];
|
|
192
|
+
const result = await cleanupACTAgentOwnedAcpxProcessTree({
|
|
193
|
+
rootPid: 200,
|
|
194
|
+
rootCommand: CODEX_WRAPPER_COMMAND,
|
|
195
|
+
wrapperRoot: WRAPPER_ROOT,
|
|
196
|
+
deps: {
|
|
197
|
+
listProcesses: vi.fn(async () => {
|
|
198
|
+
throw new Error("ps unavailable");
|
|
199
|
+
}),
|
|
200
|
+
killProcess: vi.fn((pid, signal) => {
|
|
201
|
+
killed.push({ pid, signal });
|
|
202
|
+
}),
|
|
203
|
+
sleep: vi.fn(async () => {}),
|
|
204
|
+
},
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
expect(result).toEqual({
|
|
208
|
+
inspectedPids: [],
|
|
209
|
+
terminatedPids: [],
|
|
210
|
+
skippedReason: "unverified-root",
|
|
211
|
+
});
|
|
212
|
+
expect(killed).toStrictEqual([]);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("does not kill a reused pid when the live command is not ACTAgent-owned", async () => {
|
|
216
|
+
const { deps, killed } = cleanupDeps([{ pid: 250, ppid: 1, command: "node unrelated.js" }]);
|
|
217
|
+
|
|
218
|
+
const result = await cleanupACTAgentOwnedAcpxProcessTree({
|
|
219
|
+
rootPid: 250,
|
|
220
|
+
rootCommand: CODEX_WRAPPER_COMMAND,
|
|
221
|
+
wrapperRoot: WRAPPER_ROOT,
|
|
222
|
+
deps,
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
expect(result).toEqual({
|
|
226
|
+
inspectedPids: [250],
|
|
227
|
+
terminatedPids: [],
|
|
228
|
+
skippedReason: "not-actagent-owned",
|
|
229
|
+
});
|
|
230
|
+
expect(killed).toStrictEqual([]);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("does not kill a reused adapter pid when the stored root was a generated wrapper", async () => {
|
|
234
|
+
const { deps, killed } = cleanupDeps([
|
|
235
|
+
{
|
|
236
|
+
pid: 260,
|
|
237
|
+
ppid: 1,
|
|
238
|
+
command: PLUGIN_DEPS_CODEX_COMMAND,
|
|
239
|
+
},
|
|
240
|
+
]);
|
|
241
|
+
|
|
242
|
+
const result = await cleanupACTAgentOwnedAcpxProcessTree({
|
|
243
|
+
rootPid: 260,
|
|
244
|
+
rootCommand: CODEX_WRAPPER_COMMAND,
|
|
245
|
+
wrapperRoot: WRAPPER_ROOT,
|
|
246
|
+
deps,
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
expect(result).toEqual({
|
|
250
|
+
inspectedPids: [260],
|
|
251
|
+
terminatedPids: [],
|
|
252
|
+
skippedReason: "not-actagent-owned",
|
|
253
|
+
});
|
|
254
|
+
expect(killed).toStrictEqual([]);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("skips non-owned recorded process trees", async () => {
|
|
258
|
+
const { deps, killed } = cleanupDeps([{ pid: 300, ppid: 1, command: "node server.js" }]);
|
|
259
|
+
|
|
260
|
+
const result = await cleanupACTAgentOwnedAcpxProcessTree({
|
|
261
|
+
rootPid: 300,
|
|
262
|
+
rootCommand: "node server.js",
|
|
263
|
+
wrapperRoot: WRAPPER_ROOT,
|
|
264
|
+
deps,
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
expect(result.skippedReason).toBe("not-actagent-owned");
|
|
268
|
+
expect(killed).toStrictEqual([]);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("reaps stale ACTAgent-owned wrapper and adapter orphans on startup", async () => {
|
|
272
|
+
const { deps, killed } = cleanupDeps([
|
|
273
|
+
{ pid: 400, ppid: 1, command: CODEX_WRAPPER_COMMAND },
|
|
274
|
+
{ pid: 401, ppid: 400, command: PLUGIN_DEPS_CODEX_COMMAND },
|
|
275
|
+
{ pid: 402, ppid: 401, command: "node child.js" },
|
|
276
|
+
{ pid: 403, ppid: 1, command: CLAUDE_WRAPPER_COMMAND },
|
|
277
|
+
{ pid: 404, ppid: 403, command: "node claude-child.js" },
|
|
278
|
+
{ pid: 405, ppid: 1, command: PLUGIN_DEPS_CODEX_COMMAND },
|
|
279
|
+
{ pid: 406, ppid: 1, command: "node /tmp/other/codex-acp-wrapper.mjs" },
|
|
280
|
+
]);
|
|
281
|
+
|
|
282
|
+
const result = await reapStaleACTAgentOwnedAcpxOrphans({
|
|
283
|
+
wrapperRoot: WRAPPER_ROOT,
|
|
284
|
+
deps,
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
expect(result.skippedReason).toBeUndefined();
|
|
288
|
+
expect(result.inspectedPids).toEqual([400, 401, 402, 403, 404, 405]);
|
|
289
|
+
expect(
|
|
290
|
+
collectMatching(
|
|
291
|
+
killed,
|
|
292
|
+
(entry) => entry.signal === "SIGTERM",
|
|
293
|
+
(entry) => entry.pid,
|
|
294
|
+
),
|
|
295
|
+
).toEqual([402, 401, 400, 404, 403, 405]);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it("reaps plugin-local Codex ACP adapter orphans when the generated wrapper is already gone", async () => {
|
|
299
|
+
const { deps, killed } = cleanupDeps([
|
|
300
|
+
{ pid: 500, ppid: 1, command: LOCAL_NODE_MODULES_CODEX_COMMAND },
|
|
301
|
+
{ pid: 501, ppid: 500, command: LOCAL_NODE_MODULES_CODEX_PLATFORM_COMMAND },
|
|
302
|
+
]);
|
|
303
|
+
|
|
304
|
+
const result = await reapStaleACTAgentOwnedAcpxOrphans({
|
|
305
|
+
wrapperRoot: WRAPPER_ROOT,
|
|
306
|
+
deps,
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
expect(result.skippedReason).toBeUndefined();
|
|
310
|
+
expect(result.inspectedPids).toEqual([500, 501]);
|
|
311
|
+
expect(
|
|
312
|
+
collectMatching(
|
|
313
|
+
killed,
|
|
314
|
+
(entry) => entry.signal === "SIGTERM",
|
|
315
|
+
(entry) => entry.pid,
|
|
316
|
+
),
|
|
317
|
+
).toEqual([501, 500]);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it("keeps startup scans quiet when process listing is unavailable", async () => {
|
|
321
|
+
const result = await reapStaleACTAgentOwnedAcpxOrphans({
|
|
322
|
+
wrapperRoot: WRAPPER_ROOT,
|
|
323
|
+
deps: {
|
|
324
|
+
listProcesses: vi.fn(async () => {
|
|
325
|
+
throw new Error("ps unavailable");
|
|
326
|
+
}),
|
|
327
|
+
sleep: vi.fn(async () => {}),
|
|
328
|
+
},
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
expect(result).toEqual({
|
|
332
|
+
inspectedPids: [],
|
|
333
|
+
terminatedPids: [],
|
|
334
|
+
skippedReason: "process-list-unavailable",
|
|
335
|
+
});
|
|
336
|
+
});
|
|
337
|
+
});
|