Skip to content

Commit cee2aca

Browse files
authored
Scope agent cron operations to the calling agent (#96883)
* Scope agent cron operations to caller * Scope OpenClaw tools MCP cron by session * Address cron scope review feedback * Preserve unscoped cron update retargeting * Move cron caller identity into gateway context * Clarify Gateway restart guidance * Add cron caller identity regression proof
1 parent 5625960 commit cee2aca

59 files changed

Lines changed: 3201 additions & 485 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

extensions/acpx/src/runtime.test.ts

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,109 @@ describe("AcpxRuntime fresh reset wrapper", () => {
192192
);
193193
});
194194

195+
it("adds the OpenClaw session key to the managed OpenClaw tools MCP bridge", () => {
196+
const baseStore: TestSessionStore = {
197+
load: vi.fn(async () => undefined),
198+
save: vi.fn(async () => {}),
199+
};
200+
const { runtime } = makeRuntime(baseStore, {
201+
openclawToolsMcpBridgeEnabled: true,
202+
mcpServers: [
203+
{
204+
name: "openclaw-tools",
205+
command: "node",
206+
args: ["dist/mcp/openclaw-tools-serve.js"],
207+
env: [],
208+
},
209+
],
210+
});
211+
212+
const readScopedMcpEnv = (sessionKey: string) => {
213+
const delegate = (
214+
runtime as unknown as {
215+
resolveOpenClawToolsDelegateForSession(sessionKey: string): unknown;
216+
}
217+
).resolveOpenClawToolsDelegateForSession(sessionKey) as {
218+
options: {
219+
mcpServers?: Array<{
220+
env?: Array<{ name: string; value: string }>;
221+
name: string;
222+
}>;
223+
};
224+
};
225+
return delegate.options.mcpServers?.find((server) => server.name === "openclaw-tools")?.env;
226+
};
227+
228+
expect(readScopedMcpEnv("agent:worker:main")).toContainEqual({
229+
name: "OPENCLAW_TOOLS_MCP_AGENT_SESSION_KEY",
230+
value: "agent:worker:main",
231+
});
232+
expect(readScopedMcpEnv("agent:research:main")).toContainEqual({
233+
name: "OPENCLAW_TOOLS_MCP_AGENT_SESSION_KEY",
234+
value: "agent:research:main",
235+
});
236+
});
237+
238+
it("keeps managed OpenClaw tools MCP delegates reachable for fresh sessions", async () => {
239+
const baseStore: TestSessionStore = {
240+
load: vi.fn(async () => undefined),
241+
save: vi.fn(async () => {}),
242+
};
243+
const { runtime } = makeRuntime(baseStore, {
244+
openclawToolsMcpBridgeEnabled: true,
245+
mcpServers: [
246+
{
247+
name: "openclaw-tools",
248+
command: "node",
249+
args: ["dist/mcp/openclaw-tools-serve.js"],
250+
env: [],
251+
},
252+
],
253+
});
254+
const exposedRuntime = runtime as unknown as {
255+
openclawToolsSessionDelegates: Map<string, unknown>;
256+
resolveOpenClawToolsDelegateForSession(sessionKey: string): unknown;
257+
};
258+
259+
const firstDelegate =
260+
exposedRuntime.resolveOpenClawToolsDelegateForSession("agent:worker:main");
261+
expect(exposedRuntime.openclawToolsSessionDelegates.has("agent:worker:main")).toBe(true);
262+
263+
await runtime.prepareFreshSession({ sessionKey: "agent:worker:main" });
264+
265+
expect(exposedRuntime.openclawToolsSessionDelegates.has("agent:worker:main")).toBe(true);
266+
expect(exposedRuntime.resolveOpenClawToolsDelegateForSession("agent:worker:main")).toBe(
267+
firstDelegate,
268+
);
269+
});
270+
271+
it("uses the no-MCP delegate for startup probes when the OpenClaw tools bridge is enabled", async () => {
272+
const baseStore: TestSessionStore = {
273+
load: vi.fn(async () => undefined),
274+
save: vi.fn(async () => {}),
275+
};
276+
const { runtime, delegate, bridgeSafeDelegate } = makeRuntime(baseStore, {
277+
openclawToolsMcpBridgeEnabled: true,
278+
mcpServers: [
279+
{
280+
name: "openclaw-tools",
281+
command: "node",
282+
args: ["dist/mcp/openclaw-tools-serve.js"],
283+
env: [],
284+
},
285+
],
286+
});
287+
const defaultProbe = vi.spyOn(delegate, "probeAvailability").mockResolvedValue(undefined);
288+
const safeProbe = vi
289+
.spyOn(bridgeSafeDelegate, "probeAvailability")
290+
.mockResolvedValue(undefined);
291+
292+
await runtime.probeAvailability();
293+
294+
expect(safeProbe).toHaveBeenCalledTimes(1);
295+
expect(defaultProbe).not.toHaveBeenCalled();
296+
});
297+
195298
it("normalizes OpenClaw Codex model ids for ACP startup", async () => {
196299
const baseStore: TestSessionStore = {
197300
load: vi.fn(async () => undefined),
@@ -1163,6 +1266,46 @@ describe("AcpxRuntime fresh reset wrapper", () => {
11631266
expect(baseStore["load"]).toHaveBeenCalledOnce();
11641267
});
11651268

1269+
it("releases managed OpenClaw tools MCP delegates after close", async () => {
1270+
const baseStore: TestSessionStore = {
1271+
load: vi.fn(async () => undefined),
1272+
save: vi.fn(async () => {}),
1273+
};
1274+
1275+
const { runtime } = makeRuntime(baseStore, {
1276+
openclawToolsMcpBridgeEnabled: true,
1277+
mcpServers: [
1278+
{
1279+
name: "openclaw-tools",
1280+
command: "node",
1281+
args: ["dist/mcp/openclaw-tools-serve.js"],
1282+
env: [],
1283+
},
1284+
],
1285+
});
1286+
const exposedRuntime = runtime as unknown as {
1287+
openclawToolsSessionDelegates: Map<string, { close: AcpRuntime["close"] }>;
1288+
resolveOpenClawToolsDelegateForSession(sessionKey: string): {
1289+
close: AcpRuntime["close"];
1290+
};
1291+
};
1292+
const scopedDelegate =
1293+
exposedRuntime.resolveOpenClawToolsDelegateForSession("agent:codex:main");
1294+
const close = vi.spyOn(scopedDelegate, "close").mockResolvedValue(undefined);
1295+
1296+
await runtime.close({
1297+
handle: {
1298+
sessionKey: "agent:codex:main",
1299+
backend: "acpx",
1300+
runtimeSessionName: "agent:codex:main",
1301+
},
1302+
reason: "closed",
1303+
});
1304+
1305+
expect(close).toHaveBeenCalledOnce();
1306+
expect(exposedRuntime.openclawToolsSessionDelegates.has("agent:codex:main")).toBe(false);
1307+
});
1308+
11661309
it("cleans up OpenClaw-owned ACPX process trees after close", async () => {
11671310
const baseStore: TestSessionStore = {
11681311
load: vi.fn(async () => ({

extensions/acpx/src/runtime.ts

Lines changed: 114 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -50,13 +50,18 @@ type OpenClawAcpxRuntimeOptions = AcpRuntimeOptions & {
5050
openclawWrapperRoot?: string;
5151
openclawGatewayInstanceId?: string;
5252
openclawProcessLeaseStore?: AcpxProcessLeaseStore;
53+
openclawToolsMcpBridgeEnabled?: boolean;
5354
};
5455
type AcpxRuntimeTestOptions = Record<string, unknown> & {
5556
openclawProcessCleanup?: AcpxProcessCleanupDeps;
5657
};
5758
type OpenClawRuntimeTurnInput = Parameters<NonNullable<AcpRuntime["startTurn"]>>[0];
5859
type OpenClawRuntimeEnsureInput = Parameters<AcpRuntime["ensureSession"]>[0];
5960
type AcpxDelegateEnsureInput = Parameters<BaseAcpxRuntime["ensureSession"]>[0];
61+
type AcpxMcpServer = NonNullable<AcpRuntimeOptions["mcpServers"]>[number];
62+
63+
const ACPX_OPENCLAW_TOOLS_MCP_SERVER_NAME = "openclaw-tools";
64+
const OPENCLAW_TOOLS_MCP_AGENT_SESSION_KEY_ENV = "OPENCLAW_TOOLS_MCP_AGENT_SESSION_KEY";
6065

6166
type ResetAwareSessionStore = AcpSessionStore & {
6267
markFresh: (sessionKey: string) => void;
@@ -682,6 +687,33 @@ function shouldUseDistinctBridgeDelegate(options: AcpRuntimeOptions): boolean {
682687
return Array.isArray(mcpServers) && mcpServers.length > 0;
683688
}
684689

690+
function withOpenClawToolsMcpSessionEnv(params: {
691+
enabled: boolean | undefined;
692+
mcpServers: AcpRuntimeOptions["mcpServers"];
693+
sessionKey: string;
694+
}): AcpRuntimeOptions["mcpServers"] {
695+
const sessionKey = params.sessionKey.trim();
696+
if (!params.enabled || !sessionKey || !params.mcpServers?.length) {
697+
return params.mcpServers;
698+
}
699+
let changed = false;
700+
const nextServers = params.mcpServers.map((server): AcpxMcpServer => {
701+
if (server.name !== ACPX_OPENCLAW_TOOLS_MCP_SERVER_NAME || !("command" in server)) {
702+
return server;
703+
}
704+
changed = true;
705+
const env = [
706+
...server.env.filter((entry) => entry.name !== OPENCLAW_TOOLS_MCP_AGENT_SESSION_KEY_ENV),
707+
{
708+
name: OPENCLAW_TOOLS_MCP_AGENT_SESSION_KEY_ENV,
709+
value: sessionKey,
710+
},
711+
];
712+
return { ...server, env };
713+
});
714+
return changed ? nextServers : params.mcpServers;
715+
}
716+
685717
/** OpenClaw-managed ACP runtime implementation backed by the upstream acpx runtime. */
686718
export class AcpxRuntime implements AcpRuntime {
687719
private readonly sessionStore: ResetAwareSessionStore;
@@ -693,6 +725,10 @@ export class AcpxRuntime implements AcpRuntime {
693725
private readonly delegate: BaseAcpxRuntime;
694726
private readonly bridgeSafeDelegate: BaseAcpxRuntime;
695727
private readonly probeDelegate: BaseAcpxRuntime;
728+
private readonly delegateOptions: AcpRuntimeOptions;
729+
private readonly delegateTestOptions: BaseAcpxRuntimeTestOptions;
730+
private readonly openclawToolsMcpBridgeEnabled: boolean;
731+
private readonly openclawToolsSessionDelegates = new Map<string, BaseAcpxRuntime>();
696732
private readonly processCleanupDeps: AcpxProcessCleanupDeps | undefined;
697733
private readonly wrapperRoot: string | undefined;
698734
private readonly gatewayInstanceId: string | undefined;
@@ -706,6 +742,7 @@ export class AcpxRuntime implements AcpRuntime {
706742
this.wrapperRoot = options.openclawWrapperRoot;
707743
this.gatewayInstanceId = options.openclawGatewayInstanceId;
708744
this.processLeaseStore = options.openclawProcessLeaseStore;
745+
this.openclawToolsMcpBridgeEnabled = options.openclawToolsMcpBridgeEnabled === true;
709746
this.cwd = options.cwd;
710747
this.sessionStore = createResetAwareSessionStore(options.sessionStore, {
711748
gatewayInstanceId: this.gatewayInstanceId,
@@ -723,20 +760,21 @@ export class AcpxRuntime implements AcpRuntime {
723760
sessionStore: this.sessionStore,
724761
agentRegistry: this.scopedAgentRegistry,
725762
};
726-
this.delegate = new BaseAcpxRuntime(
727-
sharedOptions,
728-
delegateTestOptions as BaseAcpxRuntimeTestOptions,
729-
);
763+
this.delegateOptions = sharedOptions;
764+
this.delegateTestOptions = delegateTestOptions as BaseAcpxRuntimeTestOptions;
765+
this.delegate = new BaseAcpxRuntime(sharedOptions, this.delegateTestOptions);
730766
this.bridgeSafeDelegate = shouldUseDistinctBridgeDelegate(options)
731767
? new BaseAcpxRuntime(
732768
{
733769
...sharedOptions,
734770
mcpServers: [],
735771
},
736-
delegateTestOptions as BaseAcpxRuntimeTestOptions,
772+
this.delegateTestOptions,
737773
)
738774
: this.delegate;
739-
this.probeDelegate = this.resolveDelegateForAgent(resolveProbeAgentName(options));
775+
this.probeDelegate = this.openclawToolsMcpBridgeEnabled
776+
? this.bridgeSafeDelegate
777+
: this.resolveDelegateForAgent(resolveProbeAgentName(options));
740778
}
741779

742780
private resolveDelegateForAgent(agentName: string | undefined): BaseAcpxRuntime {
@@ -751,6 +789,57 @@ export class AcpxRuntime implements AcpRuntime {
751789
return shouldUseBridgeSafeDelegateForCommand(command) ? this.bridgeSafeDelegate : this.delegate;
752790
}
753791

792+
private resolveDelegateForSession(params: {
793+
command: string | undefined;
794+
sessionKey: string;
795+
}): BaseAcpxRuntime {
796+
if (shouldUseBridgeSafeDelegateForCommand(params.command)) {
797+
return this.bridgeSafeDelegate;
798+
}
799+
return this.resolveOpenClawToolsDelegateForSession(params.sessionKey);
800+
}
801+
802+
private resolveOpenClawToolsDelegateForSession(sessionKey: string): BaseAcpxRuntime {
803+
if (!this.openclawToolsMcpBridgeEnabled) {
804+
return this.delegate;
805+
}
806+
const normalizedSessionKey = sessionKey.trim();
807+
if (!normalizedSessionKey) {
808+
return this.delegate;
809+
}
810+
const cached = this.openclawToolsSessionDelegates.get(normalizedSessionKey);
811+
if (cached) {
812+
return cached;
813+
}
814+
// Upstream acpx captures mcpServers at runtime construction. The managed
815+
// OpenClaw tools bridge needs per-session identity, so cache one delegate
816+
// per session with the scoped MCP env already embedded.
817+
const delegate = new BaseAcpxRuntime(
818+
{
819+
...this.delegateOptions,
820+
mcpServers: withOpenClawToolsMcpSessionEnv({
821+
enabled: this.openclawToolsMcpBridgeEnabled,
822+
mcpServers: this.delegateOptions.mcpServers,
823+
sessionKey: normalizedSessionKey,
824+
}),
825+
},
826+
this.delegateTestOptions,
827+
);
828+
this.openclawToolsSessionDelegates.set(normalizedSessionKey, delegate);
829+
return delegate;
830+
}
831+
832+
private releaseOpenClawToolsDelegateForSession(sessionKey: string): void {
833+
if (!this.openclawToolsMcpBridgeEnabled) {
834+
return;
835+
}
836+
const normalizedSessionKey = sessionKey.trim();
837+
if (!normalizedSessionKey) {
838+
return;
839+
}
840+
this.openclawToolsSessionDelegates.delete(normalizedSessionKey);
841+
}
842+
754843
private async resolveDelegateForHandle(handle: AcpRuntimeHandle): Promise<BaseAcpxRuntime> {
755844
const record = await this.sessionStore.load(handle.acpxRecordId ?? handle.sessionKey);
756845
return this.resolveDelegateForLoadedRecord(handle, record);
@@ -762,9 +851,17 @@ export class AcpxRuntime implements AcpRuntime {
762851
): BaseAcpxRuntime {
763852
const recordCommand = readAgentCommandFromRecord(record);
764853
if (recordCommand) {
765-
return this.resolveDelegateForCommand(recordCommand);
854+
return this.resolveDelegateForSession({
855+
command: recordCommand,
856+
sessionKey: handle.sessionKey,
857+
});
766858
}
767-
return this.resolveDelegateForAgent(readAgentFromHandle(handle));
859+
const agentName = readAgentFromHandle(handle);
860+
const command = resolveAgentCommandForName({
861+
agentName,
862+
agentRegistry: this.agentRegistry,
863+
});
864+
return this.resolveDelegateForSession({ command, sessionKey: handle.sessionKey });
768865
}
769866

770867
private async resolveCommandForHandle(handle: AcpRuntimeHandle): Promise<string | undefined> {
@@ -980,7 +1077,7 @@ export class AcpxRuntime implements AcpRuntime {
9801077
agentName: input.agent,
9811078
agentRegistry: this.agentRegistry,
9821079
});
983-
const delegate = this.resolveDelegateForCommand(command);
1080+
const delegate = this.resolveDelegateForSession({ command, sessionKey: input.sessionKey });
9841081
const claudeModelOverride = isClaudeAcpCommand(command)
9851082
? normalizeClaudeAcpModelOverride(input.model)
9861083
: undefined;
@@ -1264,6 +1361,9 @@ export class AcpxRuntime implements AcpRuntime {
12641361
}
12651362

12661363
async prepareFreshSession(input: { sessionKey: string }): Promise<void> {
1364+
// Fresh reset has no ACP handle to close the delegate's upstream client.
1365+
// Keep the scoped delegate reachable so the next ensure can replace it;
1366+
// close() owns cache release when the session lifecycle ends.
12671367
this.sessionStore.markFresh(input.sessionKey);
12681368
}
12691369

@@ -1272,8 +1372,9 @@ export class AcpxRuntime implements AcpRuntime {
12721372
input.handle.acpxRecordId ?? input.handle.sessionKey,
12731373
);
12741374
let closeSucceeded;
1375+
const delegate = this.resolveDelegateForLoadedRecord(input.handle, record);
12751376
try {
1276-
await this.resolveDelegateForLoadedRecord(input.handle, record).close({
1377+
await delegate.close({
12771378
handle: input.handle,
12781379
reason: input.reason,
12791380
discardPersistentState: input.discardPersistentState,
@@ -1282,6 +1383,9 @@ export class AcpxRuntime implements AcpRuntime {
12821383
} finally {
12831384
await this.cleanupProcessTreeForRecord(input.handle, record);
12841385
}
1386+
if (closeSucceeded) {
1387+
this.releaseOpenClawToolsDelegateForSession(input.handle.sessionKey);
1388+
}
12851389
if (closeSucceeded && input.discardPersistentState) {
12861390
this.sessionStore.markFresh(input.handle.sessionKey);
12871391
}

extensions/acpx/src/service.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ function createLazyDefaultRuntime(params: AcpxRuntimeFactoryParams): AcpxRuntime
111111
}),
112112
probeAgent: params.pluginConfig.probeAgent,
113113
mcpServers: toAcpMcpServers(params.pluginConfig.mcpServers),
114+
openclawToolsMcpBridgeEnabled: params.pluginConfig.openClawToolsMcpBridge,
114115
permissionMode: params.pluginConfig.permissionMode,
115116
nonInteractivePermissions: params.pluginConfig.nonInteractivePermissions,
116117
timeoutMs: resolveAcpxTimerTimeoutMs(params.pluginConfig.timeoutSeconds),

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1778,6 +1778,7 @@
17781778
"test:docker:crestodian-first-run": "bash scripts/e2e/crestodian-first-run-docker.sh",
17791779
"test:docker:crestodian-planner": "bash scripts/e2e/crestodian-planner-docker.sh",
17801780
"test:docker:crestodian-rescue": "bash scripts/e2e/crestodian-rescue-docker.sh",
1781+
"test:docker:cron-cli": "bash scripts/e2e/cron-cli-docker.sh",
17811782
"test:docker:cron-mcp-cleanup": "bash scripts/e2e/cron-mcp-cleanup-docker.sh",
17821783
"test:docker:codex-media-path": "bash scripts/e2e/codex-media-path-docker.sh",
17831784
"test:docker:doctor-switch": "bash scripts/e2e/doctor-install-switch-docker.sh",

0 commit comments

Comments
 (0)