diff --git a/extensions/acpx/src/runtime.test.ts b/extensions/acpx/src/runtime.test.ts index 2e3bdd6ff7f..435396e5ed1 100644 --- a/extensions/acpx/src/runtime.test.ts +++ b/extensions/acpx/src/runtime.test.ts @@ -192,6 +192,109 @@ describe("AcpxRuntime fresh reset wrapper", () => { ); }); + it("adds the OpenClaw session key to the managed OpenClaw tools MCP bridge", () => { + const baseStore: TestSessionStore = { + load: vi.fn(async () => undefined), + save: vi.fn(async () => {}), + }; + const { runtime } = makeRuntime(baseStore, { + openclawToolsMcpBridgeEnabled: true, + mcpServers: [ + { + name: "openclaw-tools", + command: "node", + args: ["dist/mcp/openclaw-tools-serve.js"], + env: [], + }, + ], + }); + + const readScopedMcpEnv = (sessionKey: string) => { + const delegate = ( + runtime as unknown as { + resolveOpenClawToolsDelegateForSession(sessionKey: string): unknown; + } + ).resolveOpenClawToolsDelegateForSession(sessionKey) as { + options: { + mcpServers?: Array<{ + env?: Array<{ name: string; value: string }>; + name: string; + }>; + }; + }; + return delegate.options.mcpServers?.find((server) => server.name === "openclaw-tools")?.env; + }; + + expect(readScopedMcpEnv("agent:worker:main")).toContainEqual({ + name: "OPENCLAW_TOOLS_MCP_AGENT_SESSION_KEY", + value: "agent:worker:main", + }); + expect(readScopedMcpEnv("agent:research:main")).toContainEqual({ + name: "OPENCLAW_TOOLS_MCP_AGENT_SESSION_KEY", + value: "agent:research:main", + }); + }); + + it("keeps managed OpenClaw tools MCP delegates reachable for fresh sessions", async () => { + const baseStore: TestSessionStore = { + load: vi.fn(async () => undefined), + save: vi.fn(async () => {}), + }; + const { runtime } = makeRuntime(baseStore, { + openclawToolsMcpBridgeEnabled: true, + mcpServers: [ + { + name: "openclaw-tools", + command: "node", + args: ["dist/mcp/openclaw-tools-serve.js"], + env: [], + }, + ], + }); + const exposedRuntime = runtime as unknown as { + openclawToolsSessionDelegates: Map; + resolveOpenClawToolsDelegateForSession(sessionKey: string): unknown; + }; + + const firstDelegate = + exposedRuntime.resolveOpenClawToolsDelegateForSession("agent:worker:main"); + expect(exposedRuntime.openclawToolsSessionDelegates.has("agent:worker:main")).toBe(true); + + await runtime.prepareFreshSession({ sessionKey: "agent:worker:main" }); + + expect(exposedRuntime.openclawToolsSessionDelegates.has("agent:worker:main")).toBe(true); + expect(exposedRuntime.resolveOpenClawToolsDelegateForSession("agent:worker:main")).toBe( + firstDelegate, + ); + }); + + it("uses the no-MCP delegate for startup probes when the OpenClaw tools bridge is enabled", async () => { + const baseStore: TestSessionStore = { + load: vi.fn(async () => undefined), + save: vi.fn(async () => {}), + }; + const { runtime, delegate, bridgeSafeDelegate } = makeRuntime(baseStore, { + openclawToolsMcpBridgeEnabled: true, + mcpServers: [ + { + name: "openclaw-tools", + command: "node", + args: ["dist/mcp/openclaw-tools-serve.js"], + env: [], + }, + ], + }); + const defaultProbe = vi.spyOn(delegate, "probeAvailability").mockResolvedValue(undefined); + const safeProbe = vi + .spyOn(bridgeSafeDelegate, "probeAvailability") + .mockResolvedValue(undefined); + + await runtime.probeAvailability(); + + expect(safeProbe).toHaveBeenCalledTimes(1); + expect(defaultProbe).not.toHaveBeenCalled(); + }); + it("normalizes OpenClaw Codex model ids for ACP startup", async () => { const baseStore: TestSessionStore = { load: vi.fn(async () => undefined), @@ -1163,6 +1266,46 @@ describe("AcpxRuntime fresh reset wrapper", () => { expect(baseStore["load"]).toHaveBeenCalledOnce(); }); + it("releases managed OpenClaw tools MCP delegates after close", async () => { + const baseStore: TestSessionStore = { + load: vi.fn(async () => undefined), + save: vi.fn(async () => {}), + }; + + const { runtime } = makeRuntime(baseStore, { + openclawToolsMcpBridgeEnabled: true, + mcpServers: [ + { + name: "openclaw-tools", + command: "node", + args: ["dist/mcp/openclaw-tools-serve.js"], + env: [], + }, + ], + }); + const exposedRuntime = runtime as unknown as { + openclawToolsSessionDelegates: Map; + resolveOpenClawToolsDelegateForSession(sessionKey: string): { + close: AcpRuntime["close"]; + }; + }; + const scopedDelegate = + exposedRuntime.resolveOpenClawToolsDelegateForSession("agent:codex:main"); + const close = vi.spyOn(scopedDelegate, "close").mockResolvedValue(undefined); + + await runtime.close({ + handle: { + sessionKey: "agent:codex:main", + backend: "acpx", + runtimeSessionName: "agent:codex:main", + }, + reason: "closed", + }); + + expect(close).toHaveBeenCalledOnce(); + expect(exposedRuntime.openclawToolsSessionDelegates.has("agent:codex:main")).toBe(false); + }); + it("cleans up OpenClaw-owned ACPX process trees after close", async () => { const baseStore: TestSessionStore = { load: vi.fn(async () => ({ diff --git a/extensions/acpx/src/runtime.ts b/extensions/acpx/src/runtime.ts index 2032c1b20b5..469ef89335a 100644 --- a/extensions/acpx/src/runtime.ts +++ b/extensions/acpx/src/runtime.ts @@ -50,6 +50,7 @@ type OpenClawAcpxRuntimeOptions = AcpRuntimeOptions & { openclawWrapperRoot?: string; openclawGatewayInstanceId?: string; openclawProcessLeaseStore?: AcpxProcessLeaseStore; + openclawToolsMcpBridgeEnabled?: boolean; }; type AcpxRuntimeTestOptions = Record & { openclawProcessCleanup?: AcpxProcessCleanupDeps; @@ -57,6 +58,10 @@ type AcpxRuntimeTestOptions = Record & { type OpenClawRuntimeTurnInput = Parameters>[0]; type OpenClawRuntimeEnsureInput = Parameters[0]; type AcpxDelegateEnsureInput = Parameters[0]; +type AcpxMcpServer = NonNullable[number]; + +const ACPX_OPENCLAW_TOOLS_MCP_SERVER_NAME = "openclaw-tools"; +const OPENCLAW_TOOLS_MCP_AGENT_SESSION_KEY_ENV = "OPENCLAW_TOOLS_MCP_AGENT_SESSION_KEY"; type ResetAwareSessionStore = AcpSessionStore & { markFresh: (sessionKey: string) => void; @@ -682,6 +687,33 @@ function shouldUseDistinctBridgeDelegate(options: AcpRuntimeOptions): boolean { return Array.isArray(mcpServers) && mcpServers.length > 0; } +function withOpenClawToolsMcpSessionEnv(params: { + enabled: boolean | undefined; + mcpServers: AcpRuntimeOptions["mcpServers"]; + sessionKey: string; +}): AcpRuntimeOptions["mcpServers"] { + const sessionKey = params.sessionKey.trim(); + if (!params.enabled || !sessionKey || !params.mcpServers?.length) { + return params.mcpServers; + } + let changed = false; + const nextServers = params.mcpServers.map((server): AcpxMcpServer => { + if (server.name !== ACPX_OPENCLAW_TOOLS_MCP_SERVER_NAME || !("command" in server)) { + return server; + } + changed = true; + const env = [ + ...server.env.filter((entry) => entry.name !== OPENCLAW_TOOLS_MCP_AGENT_SESSION_KEY_ENV), + { + name: OPENCLAW_TOOLS_MCP_AGENT_SESSION_KEY_ENV, + value: sessionKey, + }, + ]; + return { ...server, env }; + }); + return changed ? nextServers : params.mcpServers; +} + /** OpenClaw-managed ACP runtime implementation backed by the upstream acpx runtime. */ export class AcpxRuntime implements AcpRuntime { private readonly sessionStore: ResetAwareSessionStore; @@ -693,6 +725,10 @@ export class AcpxRuntime implements AcpRuntime { private readonly delegate: BaseAcpxRuntime; private readonly bridgeSafeDelegate: BaseAcpxRuntime; private readonly probeDelegate: BaseAcpxRuntime; + private readonly delegateOptions: AcpRuntimeOptions; + private readonly delegateTestOptions: BaseAcpxRuntimeTestOptions; + private readonly openclawToolsMcpBridgeEnabled: boolean; + private readonly openclawToolsSessionDelegates = new Map(); private readonly processCleanupDeps: AcpxProcessCleanupDeps | undefined; private readonly wrapperRoot: string | undefined; private readonly gatewayInstanceId: string | undefined; @@ -706,6 +742,7 @@ export class AcpxRuntime implements AcpRuntime { this.wrapperRoot = options.openclawWrapperRoot; this.gatewayInstanceId = options.openclawGatewayInstanceId; this.processLeaseStore = options.openclawProcessLeaseStore; + this.openclawToolsMcpBridgeEnabled = options.openclawToolsMcpBridgeEnabled === true; this.cwd = options.cwd; this.sessionStore = createResetAwareSessionStore(options.sessionStore, { gatewayInstanceId: this.gatewayInstanceId, @@ -723,20 +760,21 @@ export class AcpxRuntime implements AcpRuntime { sessionStore: this.sessionStore, agentRegistry: this.scopedAgentRegistry, }; - this.delegate = new BaseAcpxRuntime( - sharedOptions, - delegateTestOptions as BaseAcpxRuntimeTestOptions, - ); + this.delegateOptions = sharedOptions; + this.delegateTestOptions = delegateTestOptions as BaseAcpxRuntimeTestOptions; + this.delegate = new BaseAcpxRuntime(sharedOptions, this.delegateTestOptions); this.bridgeSafeDelegate = shouldUseDistinctBridgeDelegate(options) ? new BaseAcpxRuntime( { ...sharedOptions, mcpServers: [], }, - delegateTestOptions as BaseAcpxRuntimeTestOptions, + this.delegateTestOptions, ) : this.delegate; - this.probeDelegate = this.resolveDelegateForAgent(resolveProbeAgentName(options)); + this.probeDelegate = this.openclawToolsMcpBridgeEnabled + ? this.bridgeSafeDelegate + : this.resolveDelegateForAgent(resolveProbeAgentName(options)); } private resolveDelegateForAgent(agentName: string | undefined): BaseAcpxRuntime { @@ -751,6 +789,57 @@ export class AcpxRuntime implements AcpRuntime { return shouldUseBridgeSafeDelegateForCommand(command) ? this.bridgeSafeDelegate : this.delegate; } + private resolveDelegateForSession(params: { + command: string | undefined; + sessionKey: string; + }): BaseAcpxRuntime { + if (shouldUseBridgeSafeDelegateForCommand(params.command)) { + return this.bridgeSafeDelegate; + } + return this.resolveOpenClawToolsDelegateForSession(params.sessionKey); + } + + private resolveOpenClawToolsDelegateForSession(sessionKey: string): BaseAcpxRuntime { + if (!this.openclawToolsMcpBridgeEnabled) { + return this.delegate; + } + const normalizedSessionKey = sessionKey.trim(); + if (!normalizedSessionKey) { + return this.delegate; + } + const cached = this.openclawToolsSessionDelegates.get(normalizedSessionKey); + if (cached) { + return cached; + } + // Upstream acpx captures mcpServers at runtime construction. The managed + // OpenClaw tools bridge needs per-session identity, so cache one delegate + // per session with the scoped MCP env already embedded. + const delegate = new BaseAcpxRuntime( + { + ...this.delegateOptions, + mcpServers: withOpenClawToolsMcpSessionEnv({ + enabled: this.openclawToolsMcpBridgeEnabled, + mcpServers: this.delegateOptions.mcpServers, + sessionKey: normalizedSessionKey, + }), + }, + this.delegateTestOptions, + ); + this.openclawToolsSessionDelegates.set(normalizedSessionKey, delegate); + return delegate; + } + + private releaseOpenClawToolsDelegateForSession(sessionKey: string): void { + if (!this.openclawToolsMcpBridgeEnabled) { + return; + } + const normalizedSessionKey = sessionKey.trim(); + if (!normalizedSessionKey) { + return; + } + this.openclawToolsSessionDelegates.delete(normalizedSessionKey); + } + private async resolveDelegateForHandle(handle: AcpRuntimeHandle): Promise { const record = await this.sessionStore.load(handle.acpxRecordId ?? handle.sessionKey); return this.resolveDelegateForLoadedRecord(handle, record); @@ -762,9 +851,17 @@ export class AcpxRuntime implements AcpRuntime { ): BaseAcpxRuntime { const recordCommand = readAgentCommandFromRecord(record); if (recordCommand) { - return this.resolveDelegateForCommand(recordCommand); + return this.resolveDelegateForSession({ + command: recordCommand, + sessionKey: handle.sessionKey, + }); } - return this.resolveDelegateForAgent(readAgentFromHandle(handle)); + const agentName = readAgentFromHandle(handle); + const command = resolveAgentCommandForName({ + agentName, + agentRegistry: this.agentRegistry, + }); + return this.resolveDelegateForSession({ command, sessionKey: handle.sessionKey }); } private async resolveCommandForHandle(handle: AcpRuntimeHandle): Promise { @@ -980,7 +1077,7 @@ export class AcpxRuntime implements AcpRuntime { agentName: input.agent, agentRegistry: this.agentRegistry, }); - const delegate = this.resolveDelegateForCommand(command); + const delegate = this.resolveDelegateForSession({ command, sessionKey: input.sessionKey }); const claudeModelOverride = isClaudeAcpCommand(command) ? normalizeClaudeAcpModelOverride(input.model) : undefined; @@ -1264,6 +1361,9 @@ export class AcpxRuntime implements AcpRuntime { } async prepareFreshSession(input: { sessionKey: string }): Promise { + // Fresh reset has no ACP handle to close the delegate's upstream client. + // Keep the scoped delegate reachable so the next ensure can replace it; + // close() owns cache release when the session lifecycle ends. this.sessionStore.markFresh(input.sessionKey); } @@ -1272,8 +1372,9 @@ export class AcpxRuntime implements AcpRuntime { input.handle.acpxRecordId ?? input.handle.sessionKey, ); let closeSucceeded; + const delegate = this.resolveDelegateForLoadedRecord(input.handle, record); try { - await this.resolveDelegateForLoadedRecord(input.handle, record).close({ + await delegate.close({ handle: input.handle, reason: input.reason, discardPersistentState: input.discardPersistentState, @@ -1282,6 +1383,9 @@ export class AcpxRuntime implements AcpRuntime { } finally { await this.cleanupProcessTreeForRecord(input.handle, record); } + if (closeSucceeded) { + this.releaseOpenClawToolsDelegateForSession(input.handle.sessionKey); + } if (closeSucceeded && input.discardPersistentState) { this.sessionStore.markFresh(input.handle.sessionKey); } diff --git a/extensions/acpx/src/service.ts b/extensions/acpx/src/service.ts index 96306152eb4..94510ccc369 100644 --- a/extensions/acpx/src/service.ts +++ b/extensions/acpx/src/service.ts @@ -111,6 +111,7 @@ function createLazyDefaultRuntime(params: AcpxRuntimeFactoryParams): AcpxRuntime }), probeAgent: params.pluginConfig.probeAgent, mcpServers: toAcpMcpServers(params.pluginConfig.mcpServers), + openclawToolsMcpBridgeEnabled: params.pluginConfig.openClawToolsMcpBridge, permissionMode: params.pluginConfig.permissionMode, nonInteractivePermissions: params.pluginConfig.nonInteractivePermissions, timeoutMs: resolveAcpxTimerTimeoutMs(params.pluginConfig.timeoutSeconds), diff --git a/package.json b/package.json index 41c456f14c7..414a2517948 100644 --- a/package.json +++ b/package.json @@ -1778,6 +1778,7 @@ "test:docker:crestodian-first-run": "bash scripts/e2e/crestodian-first-run-docker.sh", "test:docker:crestodian-planner": "bash scripts/e2e/crestodian-planner-docker.sh", "test:docker:crestodian-rescue": "bash scripts/e2e/crestodian-rescue-docker.sh", + "test:docker:cron-cli": "bash scripts/e2e/cron-cli-docker.sh", "test:docker:cron-mcp-cleanup": "bash scripts/e2e/cron-mcp-cleanup-docker.sh", "test:docker:codex-media-path": "bash scripts/e2e/codex-media-path-docker.sh", "test:docker:doctor-switch": "bash scripts/e2e/doctor-install-switch-docker.sh", diff --git a/packages/gateway-client/src/client.ts b/packages/gateway-client/src/client.ts index 970c9f34154..113083d8b62 100644 --- a/packages/gateway-client/src/client.ts +++ b/packages/gateway-client/src/client.ts @@ -328,6 +328,7 @@ type SelectedConnectAuth = { authDeviceToken?: string; authPassword?: string; authApprovalRuntimeToken?: string; + authAgentRuntimeIdentityToken?: string; signatureToken?: string; resolvedDeviceToken?: string; storedToken?: string; @@ -343,6 +344,7 @@ type StoredDeviceAuth = { type AssembledConnect = { params: ConnectParams; authApprovalRuntimeToken: string | undefined; + authAgentRuntimeIdentityToken: string | undefined; resolvedDeviceToken: string | undefined; storedToken: string | undefined; usingStoredDeviceToken: boolean | undefined; @@ -430,6 +432,7 @@ export type GatewayClientOptions = { deviceToken?: string; password?: string; approvalRuntimeToken?: string; + agentRuntimeIdentityToken?: string; instanceId?: string; clientName?: GatewayClientName; clientDisplayName?: string; @@ -969,6 +972,24 @@ export class GatewayClient { this.ws?.close(1013, "gateway starting"); return; } + if ( + this.shouldFailClosedForUnsupportedAgentRuntimeIdentity({ + error: err, + authAgentRuntimeIdentityToken: assembled.authAgentRuntimeIdentityToken, + }) + ) { + const unsupportedIdentityError = new Error( + "gateway rejected required agent runtime identity auth field; refusing to retry without it", + ); + this.notifyConnectError(unsupportedIdentityError); + this.logError(`gateway connect failed: ${unsupportedIdentityError.message}`); + // This identity scopes model-mediated cron calls. Retrying without it + // would turn an old/new mismatch into an unscoped operator call. + this.closed = true; + this.clearReconnectTimer(); + this.ws?.close(1008, "connect failed"); + return; + } if ( this.shouldRetryWithoutApprovalRuntimeToken({ error: err, @@ -1004,6 +1025,7 @@ export class GatewayClient { authDeviceToken, authPassword, authApprovalRuntimeToken, + authAgentRuntimeIdentityToken, signatureToken, resolvedDeviceToken, storedToken, @@ -1020,13 +1042,15 @@ export class GatewayClient { authBootstrapToken || authPassword || resolvedDeviceToken || - authApprovalRuntimeToken + authApprovalRuntimeToken || + authAgentRuntimeIdentityToken ? { token: authToken, bootstrapToken: authBootstrapToken, deviceToken: authDeviceToken ?? resolvedDeviceToken, password: authPassword, approvalRuntimeToken: authApprovalRuntimeToken, + agentRuntimeIdentityToken: authAgentRuntimeIdentityToken, } : undefined; const signedAtMs = Date.now(); @@ -1069,6 +1093,7 @@ export class GatewayClient { }), }, authApprovalRuntimeToken, + authAgentRuntimeIdentityToken, resolvedDeviceToken, storedToken, usingStoredDeviceToken, @@ -1294,6 +1319,25 @@ export class GatewayClient { return message.includes("invalid connect params") && message.includes("approvalruntimetoken"); } + private shouldFailClosedForUnsupportedAgentRuntimeIdentity(params: { + error: unknown; + authAgentRuntimeIdentityToken?: string; + }): boolean { + if (!params.authAgentRuntimeIdentityToken) { + return false; + } + if (!(params.error instanceof GatewayClientRequestError)) { + return false; + } + if (params.error.gatewayCode !== "INVALID_REQUEST") { + return false; + } + const message = normalizeLowercaseStringOrEmpty(params.error.message); + return ( + message.includes("invalid connect params") && message.includes("agentruntimeidentitytoken") + ); + } + private isTrustedDeviceRetryEndpoint(): boolean { const rawUrl = this.opts.url ?? "ws://127.0.0.1:18789"; try { @@ -1321,6 +1365,9 @@ export class GatewayClient { const authApprovalRuntimeToken = this.approvalRuntimeTokenCompatibilityDisabled ? undefined : normalizeOptionalString(this.opts.approvalRuntimeToken); + const authAgentRuntimeIdentityToken = normalizeOptionalString( + this.opts.agentRuntimeIdentityToken, + ); const storedAuth = this.loadStoredDeviceAuth(role); const storedToken = storedAuth?.token ?? null; const storedScopes = storedAuth?.scopes; @@ -1354,6 +1401,7 @@ export class GatewayClient { authDeviceToken: shouldUseDeviceRetryToken ? (storedToken ?? undefined) : undefined, authPassword, authApprovalRuntimeToken, + authAgentRuntimeIdentityToken, signatureToken: authToken ?? authBootstrapToken ?? undefined, resolvedDeviceToken, storedToken: storedToken ?? undefined, diff --git a/packages/gateway-protocol/src/cron-validators.test.ts b/packages/gateway-protocol/src/cron-validators.test.ts index 1be0444316f..075096b55f3 100644 --- a/packages/gateway-protocol/src/cron-validators.test.ts +++ b/packages/gateway-protocol/src/cron-validators.test.ts @@ -26,11 +26,36 @@ const minimalAddParams = { payload: { kind: "systemEvent", text: "tick" }, } as const; +const agentToolCallerScope = { + kind: "agentTool", + agentId: "ops", +} as const; + describe("cron protocol validators", () => { it("accepts minimal add params", () => { expect(validateCronAddParams(minimalAddParams)).toBe(true); }); + it("rejects public caller scope on cron admin params", () => { + expect(validateCronListParams({ callerScope: agentToolCallerScope })).toBe(false); + expect(validateCronGetParams({ id: "job-1", callerScope: agentToolCallerScope })).toBe(false); + expect(validateCronAddParams({ ...minimalAddParams, callerScope: agentToolCallerScope })).toBe( + false, + ); + expect( + validateCronUpdateParams({ + id: "job-1", + patch: { enabled: false }, + callerScope: agentToolCallerScope, + }), + ).toBe(false); + expect(validateCronRemoveParams({ jobId: "job-1", callerScope: agentToolCallerScope })).toBe( + false, + ); + expect(validateCronRunParams({ id: "job-1", callerScope: agentToolCallerScope })).toBe(false); + expect(validateCronRunsParams({ id: "job-1", callerScope: agentToolCallerScope })).toBe(false); + }); + it("accepts current and custom session targets", () => { expect( validateCronAddParams({ diff --git a/packages/gateway-protocol/src/schema/frames.ts b/packages/gateway-protocol/src/schema/frames.ts index 4b3c36bc4fd..7035883045d 100644 --- a/packages/gateway-protocol/src/schema/frames.ts +++ b/packages/gateway-protocol/src/schema/frames.ts @@ -70,6 +70,7 @@ export const ConnectParamsSchema = Type.Object( deviceToken: Type.Optional(Type.String()), password: Type.Optional(Type.String()), approvalRuntimeToken: Type.Optional(Type.String()), + agentRuntimeIdentityToken: Type.Optional(Type.String()), }, { additionalProperties: false }, ), diff --git a/scripts/e2e/cron-cli-docker.sh b/scripts/e2e/cron-cli-docker.sh new file mode 100755 index 00000000000..bfa87f8b1e2 --- /dev/null +++ b/scripts/e2e/cron-cli-docker.sh @@ -0,0 +1,251 @@ +#!/usr/bin/env bash +# Starts a packaged Gateway in Docker and verifies public cron CLI CRUD/run flows. +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +source "$ROOT_DIR/scripts/lib/docker-e2e-image.sh" + +IMAGE_NAME="$(docker_e2e_resolve_image "openclaw-cron-cli-e2e" OPENCLAW_IMAGE)" +PORT="18789" +TOKEN="cron-cli-e2e-$(date +%s)-$$" +CONTAINER_NAME="openclaw-cron-cli-e2e-$$" +CLIENT_LOG="$(mktemp -t openclaw-cron-cli-log.XXXXXX)" + +cleanup() { + docker_e2e_docker_cmd rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true + rm -f "$CLIENT_LOG" +} +trap cleanup EXIT + +docker_e2e_build_or_reuse "$IMAGE_NAME" cron-cli +OPENCLAW_TEST_STATE_SCRIPT_B64="$(docker_e2e_test_state_shell_b64 cron-cli empty)" + +echo "Running in-container Gateway + cron CLI smoke..." +set +e +docker_e2e_run_with_harness \ + --name "$CONTAINER_NAME" \ + -e "OPENCLAW_GATEWAY_TOKEN=$TOKEN" \ + -e "OPENCLAW_SKIP_CHANNELS=1" \ + -e "OPENCLAW_SKIP_GMAIL_WATCHER=1" \ + -e "OPENCLAW_SKIP_CANVAS_HOST=1" \ + -e "OPENCLAW_SKIP_ACPX_RUNTIME=1" \ + -e "OPENCLAW_SKIP_ACPX_RUNTIME_PROBE=1" \ + -e "OPENCLAW_TEST_STATE_SCRIPT_B64=$OPENCLAW_TEST_STATE_SCRIPT_B64" \ + -e "GW_TOKEN=$TOKEN" \ + -e "OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1" \ + -i \ + "$IMAGE_NAME" \ + bash -s >"$CLIENT_LOG" 2>&1 <<'INNER' +set -euo pipefail + +source scripts/lib/openclaw-e2e-instance.sh +openclaw_e2e_eval_test_state_from_b64 "${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}" + +entry="$(openclaw_e2e_resolve_entrypoint)" +gateway_pid= + +cleanup_inner() { + openclaw_e2e_stop_process "${gateway_pid:-}" +} + +dump_logs_on_error() { + status=$? + if [ "$status" -ne 0 ]; then + openclaw_e2e_dump_logs \ + /tmp/cron-cli-gateway.log \ + /tmp/cron-cli-device-seed.json \ + /tmp/cron-cli-status.json \ + /tmp/cron-cli-add.json \ + /tmp/cron-cli-list.json \ + /tmp/cron-cli-show.json \ + /tmp/cron-cli-disable.json \ + /tmp/cron-cli-enable.json \ + /tmp/cron-cli-run.json \ + /tmp/cron-cli-runs.json \ + /tmp/cron-cli-remove.json + fi + cleanup_inner + exit "$status" +} + +trap cleanup_inner EXIT +trap dump_logs_on_error ERR + +cron_cli() { + node "$entry" cron "$@" --token "${GW_TOKEN:?missing GW_TOKEN}" +} + +seed_paired_cli_device() { + node --input-type=module <<'NODE' +import { readdir, readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; + +async function importDistChunk(prefix, marker) { + const distDir = join(process.cwd(), "dist"); + const entries = await readdir(distDir); + for (const entry of entries) { + if (!entry.startsWith(prefix) || !entry.endsWith(".js")) { + continue; + } + const fullPath = join(distDir, entry); + if ((await readFile(fullPath, "utf8")).includes(marker)) { + return await import(pathToFileURL(fullPath).href); + } + } + throw new Error(`missing dist chunk ${prefix} containing ${marker}`); +} + +const identityModule = await importDistChunk("device-identity-", "loadOrCreateDeviceIdentity"); +const pairingModule = await importDistChunk("device-pairing-", "requestDevicePairing"); +const loadOrCreateDeviceIdentity = + identityModule.loadOrCreateDeviceIdentity ?? identityModule.r; +const publicKeyRawBase64UrlFromPem = + identityModule.publicKeyRawBase64UrlFromPem ?? identityModule.a; +const approveDevicePairing = pairingModule.approveDevicePairing ?? pairingModule.n; +const getPairedDevice = pairingModule.getPairedDevice ?? pairingModule.a; +const requestDevicePairing = pairingModule.requestDevicePairing ?? pairingModule.m; + +if ( + typeof loadOrCreateDeviceIdentity !== "function" || + typeof publicKeyRawBase64UrlFromPem !== "function" || + typeof approveDevicePairing !== "function" || + typeof getPairedDevice !== "function" || + typeof requestDevicePairing !== "function" +) { + throw new Error("missing device pairing exports in dist chunks"); +} + +const identity = loadOrCreateDeviceIdentity(); +const publicKey = publicKeyRawBase64UrlFromPem(identity.publicKeyPem); +const requiredScopes = ["operator.admin"]; +const paired = await getPairedDevice(identity.deviceId); +const pairedScopes = Array.isArray(paired?.approvedScopes) + ? paired.approvedScopes + : Array.isArray(paired?.scopes) + ? paired.scopes + : []; + +if (paired?.publicKey !== publicKey || !requiredScopes.every((scope) => pairedScopes.includes(scope))) { + const pairing = await requestDevicePairing({ + deviceId: identity.deviceId, + publicKey, + displayName: "cron cli docker smoke", + platform: process.platform, + clientId: "cli", + clientMode: "cli", + role: "operator", + scopes: requiredScopes, + silent: true, + }); + const approved = await approveDevicePairing(pairing.request.requestId, { + callerScopes: requiredScopes, + }); + if (approved?.status !== "approved") { + throw new Error(`failed to seed paired CLI device: ${approved?.status ?? "missing-result"}`); + } +} + +process.stdout.write(JSON.stringify({ ok: true, deviceId: identity.deviceId }) + "\n"); +NODE +} + +read_json_field() { + local file="$1" + local field="$2" + node --input-type=module -e ' + const fs = await import("node:fs/promises"); + const [file, field] = process.argv.slice(1); + const value = JSON.parse(await fs.readFile(file, "utf8"))[field]; + if (typeof value !== "string" || value.length === 0) { + throw new Error(`missing string field ${field} in ${file}`); + } + process.stdout.write(value); + ' "$file" "$field" +} + +seed_paired_cli_device > /tmp/cron-cli-device-seed.json +gateway_pid="$(openclaw_e2e_start_gateway "$entry" 18789 /tmp/cron-cli-gateway.log)" +openclaw_e2e_wait_gateway_ready "$gateway_pid" /tmp/cron-cli-gateway.log 300 18789 + +cron_cli status --json > /tmp/cron-cli-status.json +cron_add_args=( + "cli cron smoke" + --every 1m + --command "printf openclaw-cli-cron-ok" + --no-deliver + --timeout-seconds 15 + --json +) +cron_cli add "${cron_add_args[@]}" > /tmp/cron-cli-add.json + +job_id="$(read_json_field /tmp/cron-cli-add.json id)" + +cron_cli list --all --json > /tmp/cron-cli-list.json +node --input-type=module -e ' + const fs = await import("node:fs/promises"); + const jobId = process.argv[1]; + const value = JSON.parse(await fs.readFile("/tmp/cron-cli-list.json", "utf8")); + if (!Array.isArray(value.jobs) || !value.jobs.some((job) => job.id === jobId && job.name === "cli cron smoke")) { + throw new Error("created job missing from cron list"); + } +' "$job_id" + +cron_cli show "$job_id" --json > /tmp/cron-cli-show.json +node --input-type=module -e ' + const fs = await import("node:fs/promises"); + const jobId = process.argv[1]; + const value = JSON.parse(await fs.readFile("/tmp/cron-cli-show.json", "utf8")); + if (value.id !== jobId || value.name !== "cli cron smoke") { + throw new Error("cron show returned the wrong job"); + } +' "$job_id" + +cron_cli disable "$job_id" > /tmp/cron-cli-disable.json +cron_cli enable "$job_id" > /tmp/cron-cli-enable.json + +cron_cli run "$job_id" --wait --wait-timeout 120s --poll-interval 500ms > /tmp/cron-cli-run.json +node --input-type=module -e ' + const fs = await import("node:fs/promises"); + const value = JSON.parse(await fs.readFile("/tmp/cron-cli-run.json", "utf8")); + if (value.completed !== true || value.status !== "ok") { + throw new Error(`cron run did not complete ok: ${JSON.stringify(value)}`); + } +' + +cron_cli runs --id "$job_id" --limit 5 > /tmp/cron-cli-runs.json +node --input-type=module -e ' + const fs = await import("node:fs/promises"); + const value = JSON.parse(await fs.readFile("/tmp/cron-cli-runs.json", "utf8")); + const matching = Array.isArray(value.entries) + ? value.entries.find((entry) => entry.status === "ok" && entry.summary === "openclaw-cli-cron-ok") + : undefined; + if (!matching) { + throw new Error("cron runs missing successful command summary"); + } +' + +cron_cli rm "$job_id" --json > /tmp/cron-cli-remove.json +node --input-type=module -e ' + const fs = await import("node:fs/promises"); + const value = JSON.parse(await fs.readFile("/tmp/cron-cli-remove.json", "utf8")); + if (value.ok !== true) { + throw new Error("cron remove failed"); + } +' + +node --input-type=module -e ' + process.stdout.write(JSON.stringify({ ok: true, jobId: process.argv[1] }) + "\n"); +' "$job_id" +INNER +status=${PIPESTATUS[0]} +set -e + +if [ "$status" -ne 0 ]; then + echo "Docker cron CLI smoke failed" + docker_e2e_print_log "$CLIENT_LOG" + exit "$status" +fi + +docker_e2e_print_log "$CLIENT_LOG" +echo "OK" diff --git a/scripts/e2e/lib/upgrade-survivor/assertions.mjs b/scripts/e2e/lib/upgrade-survivor/assertions.mjs index d8115a7602d..9e479250e14 100644 --- a/scripts/e2e/lib/upgrade-survivor/assertions.mjs +++ b/scripts/e2e/lib/upgrade-survivor/assertions.mjs @@ -7,6 +7,7 @@ import { readPluginInstallIndex } from "../plugin-index-sqlite.mjs"; const command = process.argv[2]; const SCENARIOS = new Set([ "base", + "acpx-openclaw-tools-bridge", "feishu-channel", "bootstrap-persona", "channel-post-core-restore", @@ -312,6 +313,16 @@ function assertConfigSurvived() { } } + if (hasCoverage(coverage) && acceptsIntent(coverage, "acpx-openclaw-tools-bridge")) { + const pluginAllow = config.plugins?.allow ?? []; + assert(pluginAllow.includes("acpx"), "ACPX plugin allow entry missing"); + assert(config.plugins?.entries?.acpx?.enabled === true, "ACPX plugin entry changed"); + assert( + config.plugins?.entries?.acpx?.config?.openClawToolsMcpBridge === true, + "ACPX OpenClaw tools bridge config changed", + ); + } + if (hasCoverage(coverage) && acceptsIntent(coverage, "configured-plugin-installs")) { const pluginAllow = config.plugins?.allow ?? []; assert(pluginAllow.includes("discord"), "configured install discord allow entry missing"); diff --git a/scripts/e2e/lib/upgrade-survivor/config-recipe.mjs b/scripts/e2e/lib/upgrade-survivor/config-recipe.mjs index 9c90dac1ee3..51a8394956c 100644 --- a/scripts/e2e/lib/upgrade-survivor/config-recipe.mjs +++ b/scripts/e2e/lib/upgrade-survivor/config-recipe.mjs @@ -108,6 +108,17 @@ const representativeConfigSteps = [ ]; const scenarioConfigSteps = new Map([ + [ + "acpx-openclaw-tools-bridge", + [ + configSetJsonFile( + "plugins-acpx-openclaw-tools-bridge", + "acpx-openclaw-tools-bridge", + "plugins", + "plugins-acpx-openclaw-tools-bridge.json", + ), + ], + ], [ "feishu-channel", [ @@ -174,6 +185,15 @@ function selectedScenario() { } function adaptStepForBaseline(step, baselineVersion, summary) { + if ( + step.intent === "acpx-openclaw-tools-bridge" && + isReleaseBefore(baselineVersion, "2026.4.22") + ) { + if (!summary.skippedIntents.includes("acpx-openclaw-tools-bridge")) { + summary.skippedIntents.push("acpx-openclaw-tools-bridge"); + } + return null; + } if (!isReleaseBefore(baselineVersion, "2026.4.0")) { return step; } diff --git a/scripts/e2e/lib/upgrade-survivor/config-recipe/plugins-acpx-openclaw-tools-bridge.json b/scripts/e2e/lib/upgrade-survivor/config-recipe/plugins-acpx-openclaw-tools-bridge.json new file mode 100644 index 00000000000..c24b6aae07b --- /dev/null +++ b/scripts/e2e/lib/upgrade-survivor/config-recipe/plugins-acpx-openclaw-tools-bridge.json @@ -0,0 +1,21 @@ +{ + "enabled": true, + "allow": ["acpx", "discord", "memory", "telegram", "whatsapp"], + "entries": { + "acpx": { + "enabled": true, + "config": { + "openClawToolsMcpBridge": true + } + }, + "discord": { + "enabled": true + }, + "telegram": { + "enabled": true + }, + "whatsapp": { + "enabled": true + } + } +} diff --git a/scripts/install-cli.sh b/scripts/install-cli.sh index 7641a48f138..fe6ce6a5816 100755 --- a/scripts/install-cli.sh +++ b/scripts/install-cli.sh @@ -1168,7 +1168,7 @@ refresh_gateway_service_if_loaded() { if ! "$claw" gateway restart >/dev/null 2>&1; then emit_json '{"event":"step","name":"gateway-service","status":"warn","reason":"restart-failed"}' - log "Warning: gateway service restart failed; continuing." + log "Warning: gateway service restart failed; continuing. Run: openclaw gateway restart" return 0 fi diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 7ecf8234715..9a55003b5ed 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -1401,7 +1401,7 @@ function Refresh-GatewayServiceIfLoaded { Invoke-OpenClawCommand gateway status --json | Out-Null Write-Host "[OK] Gateway service refreshed" -ForegroundColor Green } catch { - Write-Host "[!] Gateway service restart failed; continuing." -ForegroundColor Yellow + Write-Host "[!] Gateway service restart failed; continuing. Run: openclaw gateway restart" -ForegroundColor Yellow } } diff --git a/scripts/install.sh b/scripts/install.sh index e662a82d780..8f1fdd6b151 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -3000,7 +3000,7 @@ refresh_gateway_service_if_loaded() { if run_quiet_step "Restarting gateway service" "$claw" gateway restart; then ui_success "Gateway service restarted" else - ui_warn "Gateway service restart failed; continuing" + ui_warn "Gateway service restart failed; continuing. Run: openclaw gateway restart" return 0 fi diff --git a/scripts/lib/docker-e2e-plan.mjs b/scripts/lib/docker-e2e-plan.mjs index 3dc5c6d205b..db8c9c3d7bd 100644 --- a/scripts/lib/docker-e2e-plan.mjs +++ b/scripts/lib/docker-e2e-plan.mjs @@ -75,6 +75,7 @@ function sanitizeLaneNameSuffix(value) { const UPGRADE_SURVIVOR_SCENARIOS = [ "base", + "acpx-openclaw-tools-bridge", "feishu-channel", "bootstrap-persona", "channel-post-core-restore", @@ -183,6 +184,17 @@ function supportsUpgradeSurvivorPluginDependencyCleanup(baselineSpec) { return comparePublishedReleaseVersion(version, { year: 2026, month: 4, patch: 23 }) >= 0; } +function supportsUpgradeSurvivorAcpToolsBridge(baselineSpec) { + if (!baselineSpec) { + return true; + } + const version = parsePublishedReleaseVersion(baselineSpec); + if (!version) { + return true; + } + return comparePublishedReleaseVersion(version, { year: 2026, month: 4, patch: 22 }) >= 0; +} + function expandUpgradeSurvivorBaselineLanes(poolLanes, rawBaselineSpecs, rawScenarios = "") { const baselineSpecs = parseUpgradeSurvivorBaselineSpecs(rawBaselineSpecs); const scenarios = parseUpgradeSurvivorScenarios(rawScenarios); @@ -199,8 +211,10 @@ function expandUpgradeSurvivorBaselineLanes(poolLanes, rawBaselineSpecs, rawScen matrixScenarios .filter( (scenario) => - scenario !== "plugin-deps-cleanup" || - supportsUpgradeSurvivorPluginDependencyCleanup(baselineSpec), + (scenario !== "plugin-deps-cleanup" || + supportsUpgradeSurvivorPluginDependencyCleanup(baselineSpec)) && + (scenario !== "acpx-openclaw-tools-bridge" || + supportsUpgradeSurvivorAcpToolsBridge(baselineSpec)), ) .map((scenario) => { const suffixParts = [ diff --git a/scripts/test-live-acp-bind-docker.sh b/scripts/test-live-acp-bind-docker.sh index b9e18278512..385ef6c254e 100644 --- a/scripts/test-live-acp-bind-docker.sh +++ b/scripts/test-live-acp-bind-docker.sh @@ -422,6 +422,7 @@ for ACP_AGENT in "${ACP_AGENTS[@]}"; do -e OPENCLAW_LIVE_TEST=1 \ -e OPENCLAW_LIVE_ACP_BIND=1 \ -e OPENCLAW_LIVE_ACP_BIND_AGENT="$ACP_AGENT" \ + -e OPENCLAW_LIVE_ACP_BIND_REQUIRE_CRON="${OPENCLAW_LIVE_ACP_BIND_REQUIRE_CRON:-}" \ -e OPENCLAW_LIVE_ACP_BIND_TEST_FILES="${OPENCLAW_LIVE_ACP_BIND_TEST_FILES:-}" \ -e OPENCLAW_LIVE_ACP_BIND_CODEX_MODEL="${OPENCLAW_LIVE_ACP_BIND_CODEX_MODEL:-}" \ -e OPENCLAW_LIVE_ACP_BIND_SETUP_TIMEOUT_SECONDS="$ACP_SETUP_TIMEOUT_SECONDS" \ diff --git a/scripts/test-projects.mjs b/scripts/test-projects.mjs index 7dd7d6da80f..c9c68ddd3bb 100644 --- a/scripts/test-projects.mjs +++ b/scripts/test-projects.mjs @@ -28,6 +28,7 @@ import { createVitestRunSpecs, findUnmatchedExplicitTestTargets, formatFailedShardDigest, + formatNoChangedTestTargetLines, listFullExtensionVitestProjectConfigs, orderFullSuiteSpecsForParallelRun, parseTestProjectsArgs, @@ -182,21 +183,9 @@ function isFullExtensionsProjectRun(specs) { function printNoChangedTestTargets(args, cwd, baseEnv) { const plan = resolveChangedTestTargetPlanForArgs(args, cwd, undefined, { env: baseEnv }); const skippedBroadFallbackPaths = plan?.skippedBroadFallbackPaths ?? []; - if (skippedBroadFallbackPaths.length === 0) { - console.error("[test] no changed test targets; skipping Vitest."); - return; + for (const line of formatNoChangedTestTargetLines(skippedBroadFallbackPaths)) { + console.error(line); } - - console.error("[test] no precise changed test targets; skipping Vitest."); - console.error( - `[test] ${skippedBroadFallbackPaths.length} changed path${ - skippedBroadFallbackPaths.length === 1 ? "" : "s" - } require broad Vitest fallback:`, - ); - for (const changedPath of skippedBroadFallbackPaths) { - console.error(`[test] ${changedPath}`); - } - console.error("[test] run `OPENCLAW_TEST_CHANGED_BROAD=1 pnpm test:changed` for broad coverage."); } async function runVitestSpecsParallel(specs, concurrency) { diff --git a/scripts/test-projects.test-support.mjs b/scripts/test-projects.test-support.mjs index 78134e8103e..f98585c4f16 100644 --- a/scripts/test-projects.test-support.mjs +++ b/scripts/test-projects.test-support.mjs @@ -926,6 +926,10 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([ ["test/scripts/docker-e2e-seeds.test.ts", "test/scripts/mcp-code-mode-gateway-client.test.ts"], ], ["scripts/e2e/mcp-client-temp-state.ts", ["test/scripts/mcp-channels-harness.test.ts"]], + [ + "scripts/e2e/cron-cli-docker.sh", + ["test/scripts/docker-build-helper.test.ts", "test/scripts/docker-e2e-observability.test.ts"], + ], [ "scripts/e2e/cron-mcp-cleanup-docker.sh", [ @@ -2156,6 +2160,22 @@ export const DEFAULT_TEST_PROJECTS_VITEST_NO_OUTPUT_TIMEOUT_MS = String(900_000) export const DEFAULT_TEST_PROJECTS_VITEST_NO_OUTPUT_HEARTBEAT_MS = String( DEFAULT_VITEST_NO_OUTPUT_HEARTBEAT_MS, ); + +export function formatNoChangedTestTargetLines(skippedBroadFallbackPaths) { + if (skippedBroadFallbackPaths.length === 0) { + return ["[test] no changed test targets; skipping Vitest."]; + } + + return [ + "[test] no precise changed test targets; skipping Vitest.", + `[test] ${skippedBroadFallbackPaths.length} changed path${ + skippedBroadFallbackPaths.length === 1 ? "" : "s" + } require broad Vitest fallback:`, + ...skippedBroadFallbackPaths.map((changedPath) => `[test] ${changedPath}`), + "[test] run `OPENCLAW_TEST_CHANGED_BROAD=1 pnpm test:changed` for broad coverage.", + ]; +} + const EXPLICIT_SOURCE_FULL_IMPORT_GRAPH_THRESHOLD = 12; const GATEWAY_SERVER_FULL_SUITE_TARGET_CHUNK_COUNT = 4; const GATEWAY_SERVER_BACKED_HTTP_TEST_TARGETS = new Set([ diff --git a/src/agents/agent-tools.abort.ts b/src/agents/agent-tools.abort.ts index 816e3576888..ba943529b15 100644 --- a/src/agents/agent-tools.abort.ts +++ b/src/agents/agent-tools.abort.ts @@ -5,8 +5,8 @@ */ import { copyPluginToolMeta } from "../plugins/tools.js"; import { bindAbortRelay } from "../utils/fetch-timeout.js"; -import { copyBeforeToolCallHookMarker } from "./agent-tools.before-tool-call.js"; import type { AnyAgentTool } from "./agent-tools.types.js"; +import { copyBeforeToolCallHookMarker } from "./before-tool-call-metadata.js"; import { copyChannelAgentToolMeta } from "./channel-tools.js"; function throwAbortError(): never { diff --git a/src/agents/agent-tools.before-tool-call.ts b/src/agents/agent-tools.before-tool-call.ts index 9c9ad52a42b..e3f5c828d17 100644 --- a/src/agents/agent-tools.before-tool-call.ts +++ b/src/agents/agent-tools.before-tool-call.ts @@ -73,6 +73,18 @@ export { consumePreExecutionBlockedToolCall, peekAdjustedParamsForToolCall, } from "./agent-tools.before-tool-call.state.js"; +import { + BEFORE_TOOL_CALL_DIAGNOSTIC_OPTIONS, + BEFORE_TOOL_CALL_HOOK_CONTEXT, + BEFORE_TOOL_CALL_SOURCE_TOOL, + BEFORE_TOOL_CALL_WRAPPED, + type BeforeToolCallDiagnosticOptions, +} from "./before-tool-call-metadata.js"; +export { + copyBeforeToolCallHookMarker, + isToolWrappedWithBeforeToolCallHook, + setBeforeToolCallDiagnosticsEnabled, +} from "./before-tool-call-metadata.js"; import { copyChannelAgentToolMeta, getChannelAgentToolMeta } from "./channel-tools.js"; import { getCodeModeExecBeforeHookMetadata, @@ -83,6 +95,7 @@ import { } from "./code-mode-control-tools.js"; import type { SandboxFsBridge } from "./sandbox/fs-bridge.js"; import { normalizeToolName } from "./tool-policy.js"; +import { copyToolTerminalPresentation } from "./tool-terminal-presentation.js"; import { getToolTerminalPresentation } from "./tool-terminal-presentation.js"; import type { AnyAgentTool } from "./tools/common.js"; import { callGatewayTool } from "./tools/gateway.js"; @@ -212,10 +225,6 @@ export function hasBeforeToolCallPolicy(): boolean { } const log = createSubsystemLogger("agents/tools"); -const BEFORE_TOOL_CALL_WRAPPED = Symbol("beforeToolCallWrapped"); -const BEFORE_TOOL_CALL_DIAGNOSTIC_OPTIONS = Symbol("beforeToolCallDiagnosticOptions"); -const BEFORE_TOOL_CALL_SOURCE_TOOL = Symbol("beforeToolCallSourceTool"); -const BEFORE_TOOL_CALL_HOOK_CONTEXT = Symbol("beforeToolCallHookContext"); const BEFORE_TOOL_CALL_HOOK_FAILURE_REASON = "Tool call blocked because before_tool_call hook failed"; const MAX_TRACKED_ADJUSTED_PARAMS = 1024; @@ -1558,12 +1567,13 @@ export function wrapToolWithBeforeToolCallHook( }; copyPluginToolMeta(tool, wrappedTool); copyChannelAgentToolMeta(tool as never, wrappedTool as never); + copyToolTerminalPresentation(tool, wrappedTool); Object.defineProperty(wrappedTool, BEFORE_TOOL_CALL_WRAPPED, { value: true, enumerable: true, }); Object.defineProperty(wrappedTool, BEFORE_TOOL_CALL_DIAGNOSTIC_OPTIONS, { - value: hookOptions, + value: hookOptions satisfies BeforeToolCallDiagnosticOptions, enumerable: false, }); Object.defineProperty(wrappedTool, BEFORE_TOOL_CALL_SOURCE_TOOL, { @@ -1577,21 +1587,6 @@ export function wrapToolWithBeforeToolCallHook( return wrappedTool; } -/** Return true when a tool already carries the before_tool_call wrapper marker. */ -export function isToolWrappedWithBeforeToolCallHook(tool: AnyAgentTool): boolean { - const taggedTool = tool as unknown as Record; - return taggedTool[BEFORE_TOOL_CALL_WRAPPED] === true; -} - -/** Toggle diagnostic event emission on an existing before_tool_call wrapper. */ -export function setBeforeToolCallDiagnosticsEnabled(tool: AnyAgentTool, enabled: boolean): void { - const taggedTool = tool as unknown as Record; - const options = taggedTool[BEFORE_TOOL_CALL_DIAGNOSTIC_OPTIONS]; - if (options && typeof options === "object" && "emitDiagnostics" in options) { - (options as { emitDiagnostics: boolean }).emitDiagnostics = enabled; - } -} - /** Rebuild a before_tool_call wrapper while preserving the original source tool. */ export function rewrapToolWithBeforeToolCallHook( tool: AnyAgentTool, @@ -1618,33 +1613,10 @@ export function rewrapToolWithBeforeToolCallHook( delete (rewrapSource as unknown as Record)[BEFORE_TOOL_CALL_WRAPPED]; copyPluginToolMeta(tool, rewrapSource); copyChannelAgentToolMeta(tool as never, rewrapSource as never); + copyToolTerminalPresentation(tool, rewrapSource); return wrapToolWithBeforeToolCallHook(rewrapSource, ctx ?? preservedContext, options); } -/** Copy before_tool_call marker metadata when another wrapper replaces a tool. */ -export function copyBeforeToolCallHookMarker(source: AnyAgentTool, target: AnyAgentTool): void { - if (!isToolWrappedWithBeforeToolCallHook(source)) { - return; - } - Object.defineProperty(target, BEFORE_TOOL_CALL_WRAPPED, { - value: true, - enumerable: true, - }); - const taggedSource = source as unknown as Record; - const sourceTool = taggedSource[BEFORE_TOOL_CALL_SOURCE_TOOL]; - if (sourceTool && typeof sourceTool === "object") { - Object.defineProperty(target, BEFORE_TOOL_CALL_SOURCE_TOOL, { - value: sourceTool, - enumerable: false, - }); - } - const hookContext = taggedSource[BEFORE_TOOL_CALL_HOOK_CONTEXT]; - Object.defineProperty(target, BEFORE_TOOL_CALL_HOOK_CONTEXT, { - value: hookContext, - enumerable: false, - }); -} - function recordPreExecutionBlockedToolCall(toolCallId?: string, runId?: string): void { if (!toolCallId) { return; diff --git a/src/agents/agent-tools.deferred-followup.ts b/src/agents/agent-tools.deferred-followup.ts index fda48b26043..0c4590b0284 100644 --- a/src/agents/agent-tools.deferred-followup.ts +++ b/src/agents/agent-tools.deferred-followup.ts @@ -1,5 +1,4 @@ import { copyPluginToolMeta } from "../plugins/tools.js"; -import { copyBeforeToolCallHookMarker } from "./agent-tools.before-tool-call.js"; /** * Adjusts exec/process tool descriptions for long-running follow-up behavior. * Cron-aware runs can point models at scheduled follow-ups; cronless runs keep @@ -7,6 +6,7 @@ import { copyBeforeToolCallHookMarker } from "./agent-tools.before-tool-call.js" */ import type { AnyAgentTool } from "./agent-tools.types.js"; import { describeExecTool, describeProcessTool } from "./bash-tools.descriptions.js"; +import { copyBeforeToolCallHookMarker } from "./before-tool-call-metadata.js"; import { copyChannelAgentToolMeta } from "./channel-tools.js"; import { copyToolTerminalPresentation } from "./tool-terminal-presentation.js"; diff --git a/src/agents/agent-tools.schema.ts b/src/agents/agent-tools.schema.ts index f8f26b645e3..d6652a89df6 100644 --- a/src/agents/agent-tools.schema.ts +++ b/src/agents/agent-tools.schema.ts @@ -8,8 +8,8 @@ import { normalizeToolParameterSchema, type ToolParameterSchemaOptions, } from "./agent-tools-parameter-schema.js"; -import { copyBeforeToolCallHookMarker } from "./agent-tools.before-tool-call.js"; import type { AnyAgentTool } from "./agent-tools.types.js"; +import { copyBeforeToolCallHookMarker } from "./before-tool-call-metadata.js"; import { copyChannelAgentToolMeta } from "./channel-tools.js"; import { copyToolTerminalPresentation } from "./tool-terminal-presentation.js"; diff --git a/src/agents/before-tool-call-metadata.ts b/src/agents/before-tool-call-metadata.ts new file mode 100644 index 00000000000..a69ebbfa3bb --- /dev/null +++ b/src/agents/before-tool-call-metadata.ts @@ -0,0 +1,49 @@ +import type { AnyAgentTool } from "./tools/common.js"; + +export type BeforeToolCallDiagnosticOptions = { + emitDiagnostics: boolean; +}; + +export const BEFORE_TOOL_CALL_WRAPPED = Symbol("beforeToolCallWrapped"); +export const BEFORE_TOOL_CALL_DIAGNOSTIC_OPTIONS = Symbol("beforeToolCallDiagnosticOptions"); +export const BEFORE_TOOL_CALL_SOURCE_TOOL = Symbol("beforeToolCallSourceTool"); +export const BEFORE_TOOL_CALL_HOOK_CONTEXT = Symbol("beforeToolCallHookContext"); + +/** Return true when a tool already carries the before_tool_call wrapper marker. */ +export function isToolWrappedWithBeforeToolCallHook(tool: AnyAgentTool): boolean { + const taggedTool = tool as unknown as Record; + return taggedTool[BEFORE_TOOL_CALL_WRAPPED] === true; +} + +/** Toggle diagnostic event emission on an existing before_tool_call wrapper. */ +export function setBeforeToolCallDiagnosticsEnabled(tool: AnyAgentTool, enabled: boolean): void { + const taggedTool = tool as unknown as Record; + const options = taggedTool[BEFORE_TOOL_CALL_DIAGNOSTIC_OPTIONS]; + if (options && typeof options === "object" && "emitDiagnostics" in options) { + (options as BeforeToolCallDiagnosticOptions).emitDiagnostics = enabled; + } +} + +/** Copy before_tool_call marker metadata when another wrapper replaces a tool. */ +export function copyBeforeToolCallHookMarker(source: AnyAgentTool, target: AnyAgentTool): void { + if (!isToolWrappedWithBeforeToolCallHook(source)) { + return; + } + Object.defineProperty(target, BEFORE_TOOL_CALL_WRAPPED, { + value: true, + enumerable: true, + }); + const taggedSource = source as unknown as Record; + const sourceTool = taggedSource[BEFORE_TOOL_CALL_SOURCE_TOOL]; + if (sourceTool && typeof sourceTool === "object") { + Object.defineProperty(target, BEFORE_TOOL_CALL_SOURCE_TOOL, { + value: sourceTool, + enumerable: false, + }); + } + const hookContext = taggedSource[BEFORE_TOOL_CALL_HOOK_CONTEXT]; + Object.defineProperty(target, BEFORE_TOOL_CALL_HOOK_CONTEXT, { + value: hookContext, + enumerable: false, + }); +} diff --git a/src/agents/openclaw-tools.gateway-caller-identity.test.ts b/src/agents/openclaw-tools.gateway-caller-identity.test.ts new file mode 100644 index 00000000000..2f636df0fae --- /dev/null +++ b/src/agents/openclaw-tools.gateway-caller-identity.test.ts @@ -0,0 +1,54 @@ +// Verifies plugin tools inherit the agent Gateway caller identity from tool assembly. +import { describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + observedIdentities: [] as Array, +})); + +vi.mock("./openclaw-plugin-tools.js", () => ({ + resolveOpenClawPluginToolsForOptions: () => [ + { + name: "synthetic_direct_cron_plugin", + label: "Synthetic direct cron plugin", + description: "Calls Gateway cron directly like plugin-owned reminder tools.", + parameters: { type: "object", properties: {} }, + execute: async () => { + const { getGatewayToolCallerIdentity } = await import("./tools/gateway-caller-context.js"); + mocks.observedIdentities.push(getGatewayToolCallerIdentity()); + return { content: [{ type: "text", text: "ok" }] }; + }, + }, + ], +})); + +import { createOpenClawTools } from "./openclaw-tools.js"; + +function requireTool(name: string) { + const tool = createOpenClawTools({ + agentSessionKey: "agent:main:discord:channel:123", + disableMessageTool: true, + pluginToolAllowlist: [name], + requesterAgentIdOverride: "main", + wrapBeforeToolCallHook: false, + }).find((candidate) => candidate.name === name); + if (!tool?.execute) { + throw new Error(`Expected executable tool ${name}`); + } + return tool; +} + +describe("createOpenClawTools Gateway caller identity", () => { + it("wraps plugin tools so direct cron Gateway calls inherit the agent identity", async () => { + mocks.observedIdentities.length = 0; + + const tool = requireTool("synthetic_direct_cron_plugin"); + await tool.execute("tool-call-1", {}); + + expect(mocks.observedIdentities).toEqual([ + { + agentId: "main", + sessionKey: "agent:main:discord:channel:123", + }, + ]); + }); +}); diff --git a/src/agents/openclaw-tools.ts b/src/agents/openclaw-tools.ts index 195643799f3..1b886b2258c 100644 --- a/src/agents/openclaw-tools.ts +++ b/src/agents/openclaw-tools.ts @@ -43,6 +43,7 @@ import { createAgentsListTool } from "./tools/agents-list-tool.js"; import type { AnyAgentTool } from "./tools/common.js"; import { createCronTool, type CronCreatorToolAllowlistEntry } from "./tools/cron-tool.js"; import { createEmbeddedCallGateway } from "./tools/embedded-gateway-stub.js"; +import { wrapToolWithGatewayCallerIdentity } from "./tools/gateway-caller-context.js"; import { createGatewayTool } from "./tools/gateway-tool.js"; import { createCreateGoalTool, @@ -576,10 +577,17 @@ export function createOpenClawTools( options?.recordToolPrepStage?.("openclaw-tools:plugin-tools"); } - if (options?.wrapBeforeToolCallHook === false) { - return allTools; - } const hookAgentId = options?.requesterAgentIdOverride ?? sessionAgentId; + const gatewayCallerIdentity = + hookAgentId && options?.agentSessionKey?.trim() + ? { agentId: hookAgentId, sessionKey: options.agentSessionKey.trim() } + : undefined; + const wrapGatewayCallerIdentity = (tool: AnyAgentTool) => + wrapToolWithGatewayCallerIdentity(tool, gatewayCallerIdentity); + + if (options?.wrapBeforeToolCallHook === false) { + return allTools.map(wrapGatewayCallerIdentity); + } const defaultHookContext: HookContext = { ...(hookAgentId ? { agentId: hookAgentId } : {}), ...(resolvedConfig ? { config: resolvedConfig } : {}), @@ -593,11 +601,13 @@ export function createOpenClawTools( ...options?.beforeToolCallHookContext, }; options?.recordToolPrepStage?.("openclaw-tools:tool-hooks"); - return allTools.map((tool) => - isToolWrappedWithBeforeToolCallHook(tool) - ? tool - : wrapToolWithBeforeToolCallHook(tool, hookContext), - ); + return allTools + .map((tool) => + isToolWrappedWithBeforeToolCallHook(tool) + ? tool + : wrapToolWithBeforeToolCallHook(tool, hookContext), + ) + .map(wrapGatewayCallerIdentity); } export const testing = { diff --git a/src/agents/runtime-plan/tools.ts b/src/agents/runtime-plan/tools.ts index b76a7003e6f..1f2116ab2c5 100644 --- a/src/agents/runtime-plan/tools.ts +++ b/src/agents/runtime-plan/tools.ts @@ -8,7 +8,7 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { ProviderRuntimePluginHandle } from "../../plugins/provider-hook-runtime.js"; import type { ProviderRuntimeModel } from "../../plugins/provider-runtime-model.types.js"; import { copyPluginToolMeta } from "../../plugins/tools.js"; -import { copyBeforeToolCallHookMarker } from "../agent-tools.before-tool-call.js"; +import { copyBeforeToolCallHookMarker } from "../before-tool-call-metadata.js"; import { copyChannelAgentToolMeta } from "../channel-tools.js"; import { logProviderToolSchemaDiagnostics, diff --git a/src/agents/tools/cron-tool.flat-params.test.ts b/src/agents/tools/cron-tool.flat-params.test.ts index bfd8334258d..f2d64a7ee58 100644 --- a/src/agents/tools/cron-tool.flat-params.test.ts +++ b/src/agents/tools/cron-tool.flat-params.test.ts @@ -6,9 +6,13 @@ const { callGatewayToolMock } = vi.hoisted(() => ({ callGatewayToolMock: vi.fn(), })); -vi.mock("../agent-scope.js", () => ({ - resolveSessionAgentId: () => "agent-123", -})); +vi.mock("../agent-scope.js", async () => { + const actual = await vi.importActual("../agent-scope.js"); + return { + ...actual, + resolveSessionAgentId: actual.resolveSessionAgentId, + }; +}); import { getToolTerminalPresentation } from "../tool-terminal-presentation.js"; import { createCronTool } from "./cron-tool.js"; diff --git a/src/agents/tools/cron-tool.test.ts b/src/agents/tools/cron-tool.test.ts index e2c3c724ffd..989f394474d 100644 --- a/src/agents/tools/cron-tool.test.ts +++ b/src/agents/tools/cron-tool.test.ts @@ -11,7 +11,7 @@ vi.mock("../agent-scope.js", async () => { const actual = await vi.importActual("../agent-scope.js"); return { ...actual, - resolveSessionAgentId: () => "agent-123", + resolveSessionAgentId: actual.resolveSessionAgentId, }; }); @@ -182,7 +182,10 @@ describe("cron tool", () => { it("allows scoped isolated cron runs to remove the current job", async () => { // Self-removal scope lets a cron-triggered run clean up its own schedule // without granting broad cron mutation access. - const tool = createTestCronTool({ selfRemoveOnlyJobId: "job-current" }); + const tool = createTestCronTool({ + agentSessionKey: "main", + selfRemoveOnlyJobId: "job-current", + }); await tool.execute("call-self-remove", { action: "remove", @@ -194,7 +197,10 @@ describe("cron tool", () => { }); it("denies scoped isolated cron runs from removing another job", async () => { - const tool = createTestCronTool({ selfRemoveOnlyJobId: "job-current" }); + const tool = createTestCronTool({ + agentSessionKey: "main", + selfRemoveOnlyJobId: "job-current", + }); await expect( tool.execute("call-remove-other", { @@ -215,7 +221,10 @@ describe("cron tool", () => { hasMore: false, nextOffset: null, }); - const tool = createTestCronTool({ selfRemoveOnlyJobId: "job-current" }); + const tool = createTestCronTool({ + agentSessionKey: "main", + selfRemoveOnlyJobId: "job-current", + }); const result = await tool.execute("call-self-runs", { action: "runs", @@ -238,7 +247,10 @@ describe("cron tool", () => { ["another job", { action: "runs", jobId: "job-other" }], ["missing job id", { action: "runs" }], ])("denies scoped isolated cron runs from reading %s run history", async (_label, args) => { - const tool = createTestCronTool({ selfRemoveOnlyJobId: "job-current" }); + const tool = createTestCronTool({ + agentSessionKey: "main", + selfRemoveOnlyJobId: "job-current", + }); await expect(tool.execute("call-runs-denied", args)).rejects.toThrow( "Cron tool is restricted to the current cron job.", @@ -281,7 +293,10 @@ describe("cron tool", () => { it("allows scoped isolated cron runs to get the current job", async () => { callGatewayMock.mockResolvedValueOnce({ id: "job-current", name: "current" }); - const tool = createTestCronTool({ selfRemoveOnlyJobId: "job-current" }); + const tool = createTestCronTool({ + agentSessionKey: "main", + selfRemoveOnlyJobId: "job-current", + }); const result = await tool.execute("call-get", { action: "get", @@ -329,7 +344,6 @@ describe("cron tool", () => { const result = await tool.execute("call-list", { action: "list", - agentId: "other-agent", includeDisabled: true, }); @@ -448,22 +462,44 @@ describe("cron tool", () => { }); const params = expectSingleGatewayCallMethod("cron.list"); - expect(params).toEqual({ includeDisabled: false, compact: true, agentId: "agent-123" }); + expect(params).toEqual({ + includeDisabled: false, + compact: true, + agentId: "agent-123", + }); }); - it("prefers explicit cron list agent id over the requester session", async () => { + it("rejects explicit cron list agent id outside the requester session", async () => { const tool = createTestCronTool({ agentSessionKey: "agent:agent-123:telegram:direct:channing", }); - await tool.execute("call-list-explicit", { + await expect( + tool.execute("call-list-explicit", { + action: "list", + agentId: "ops", + includeDisabled: true, + }), + ).rejects.toThrow("cron list agentId must match the calling agent"); + + expect(callGatewayMock).not.toHaveBeenCalled(); + }); + + it("preserves explicit agentId for sessionless cron list callers", async () => { + const tool = createTestCronTool(); + + await tool.execute("call-sessionless-list", { action: "list", - agentId: "ops", + agentId: "worker", includeDisabled: true, }); const params = expectSingleGatewayCallMethod("cron.list"); - expect(params).toEqual({ includeDisabled: true, compact: true, agentId: "ops" }); + expect(params).toEqual({ + includeDisabled: true, + compact: true, + agentId: "worker", + }); }); it("retries cron.list without compact for older gateways", async () => { @@ -483,11 +519,18 @@ describe("cron tool", () => { expect(readGatewayCall(0)).toEqual({ method: "cron.list", - params: { includeDisabled: false, compact: true, agentId: "agent-123" }, + params: { + includeDisabled: false, + compact: true, + agentId: "agent-123", + }, }); expect(readGatewayCall(1)).toEqual({ method: "cron.list", - params: { includeDisabled: false, agentId: "agent-123" }, + params: { + includeDisabled: false, + agentId: "agent-123", + }, }); }); @@ -744,7 +787,10 @@ describe("cron tool", () => { id: "job-legacy", }); - expect(readGatewayCall().params).toEqual({ id: "job-primary", mode: "due" }); + expect(readGatewayCall().params).toEqual({ + id: "job-primary", + mode: "due", + }); }); it("supports due-only run mode", async () => { @@ -755,7 +801,10 @@ describe("cron tool", () => { runMode: "due", }); - expect(readGatewayCall().params).toEqual({ id: "job-due", mode: "due" }); + expect(readGatewayCall().params).toEqual({ + id: "job-due", + mode: "due", + }); }); it("supports force run mode", async () => { @@ -766,7 +815,10 @@ describe("cron tool", () => { runMode: "force", }); - expect(readGatewayCall().params).toEqual({ id: "job-force", mode: "force" }); + expect(readGatewayCall().params).toEqual({ + id: "job-force", + mode: "force", + }); }); it("normalizes cron.add job payloads", async () => { @@ -794,18 +846,43 @@ describe("cron tool", () => { }); }); - it("does not default agentId when job.agentId is null", async () => { + it("rejects null agentId on add from the scoped agent cron tool", async () => { const tool = createTestCronTool({ agentSessionKey: "main" }); - await tool.execute("call-null", { + await expect( + tool.execute("call-null", { + action: "add", + job: { + name: "wake-up", + schedule: { at: new Date(123).toISOString() }, + payload: { kind: "systemEvent", text: "hello" }, + agentId: null, + }, + }), + ).rejects.toThrow("cron job agentId must match the calling agent"); + + expect(callGatewayMock).not.toHaveBeenCalled(); + }); + + it("preserves explicit agentId for sessionless cron add callers", async () => { + const tool = createTestCronTool(); + + await tool.execute("call-sessionless-add", { action: "add", job: { - name: "wake-up", + name: "worker job", schedule: { at: new Date(123).toISOString() }, - agentId: null, + payload: { kind: "agentTurn", message: "hello" }, + agentId: "worker", }, }); - expect(readGatewayCall().params?.agentId).toBeNull(); + const params = expectSingleGatewayCallMethod("cron.add"); + expect(params).toMatchObject({ + name: "worker job", + agentId: "worker", + payload: { kind: "agentTurn", message: "hello" }, + }); + expect(params).not.toHaveProperty("callerScope"); }); it("infers session agentId when job.agentId is omitted", async () => { @@ -828,6 +905,71 @@ describe("cron tool", () => { ).resolves.toBe("agent-123"); }); + it("accepts matching explicit agentId on add", async () => { + await expect( + executeAddAndReadAgentId({ + callId: "call-matching-agent-id", + agentSessionKey: "agent:agent-123:telegram:direct:channing", + includeAgentId: true, + agentId: "agent-123", + }), + ).resolves.toBe("agent-123"); + }); + + it("rejects foreign explicit agentId on add", async () => { + const tool = createTestCronTool({ + agentSessionKey: "agent:agent-123:telegram:direct:channing", + }); + + await expect( + tool.execute("call-foreign-agent-id", { + action: "add", + job: { + name: "foreign", + schedule: { at: new Date(123).toISOString() }, + payload: { kind: "agentTurn", message: "hello" }, + agentId: "worker", + }, + }), + ).rejects.toThrow("cron job agentId must match the calling agent"); + expect(callGatewayMock).not.toHaveBeenCalled(); + }); + + it("rejects foreign agent-prefixed session refs on add", async () => { + const tool = createTestCronTool({ + agentSessionKey: "agent:agent-123:telegram:direct:channing", + }); + + await expect( + tool.execute("call-foreign-session-ref", { + action: "add", + job: { + name: "foreign session", + schedule: { at: new Date(123).toISOString() }, + payload: { kind: "agentTurn", message: "hello" }, + sessionTarget: "session:agent:worker:telegram:direct:alice", + }, + }), + ).rejects.toThrow("cron sessionTarget must match the calling agent"); + expect(callGatewayMock).not.toHaveBeenCalled(); + }); + + it("does not forward model-supplied callerScope", async () => { + const tool = createTestCronTool({ + agentSessionKey: "agent:agent-123:telegram:direct:channing", + }); + + await tool.execute("call-spoofed-caller-scope", { + action: "remove", + jobId: "job-1", + callerScope: { kind: "agentTool", agentId: "worker" }, + }); + + expect(readGatewayCall().params).toEqual({ + id: "job-1", + }); + }); + it("passes through failureAlert=false for add", async () => { const tool = createTestCronTool(); await tool.execute("call-disable-alerts-add", { @@ -1231,23 +1373,23 @@ describe("cron tool", () => { expect(text).not.toContain("Recent context:"); }); - it("preserves explicit agentId null on add", async () => { + it("rejects explicit agentId null on add", async () => { callGatewayMock.mockResolvedValueOnce({ ok: true }); const tool = createTestCronTool({ agentSessionKey: "main" }); - await tool.execute("call6", { - action: "add", - job: { - name: "reminder", - schedule: { at: new Date(123).toISOString() }, - agentId: null, - payload: { kind: "systemEvent", text: "Reminder: the thing." }, - }, - }); + await expect( + tool.execute("call6", { + action: "add", + job: { + name: "reminder", + schedule: { at: new Date(123).toISOString() }, + agentId: null, + payload: { kind: "systemEvent", text: "Reminder: the thing." }, + }, + }), + ).rejects.toThrow("cron job agentId must match the calling agent"); - const call = readGatewayCall(); - expect(call.method).toBe("cron.add"); - expect(call.params?.agentId).toBeNull(); + expect(callGatewayMock).not.toHaveBeenCalled(); }); it("does not infer delivery from raw session-key fragments without delivery context", async () => { @@ -1767,6 +1909,55 @@ describe("cron tool", () => { }); }); + it("rejects agentId retargeting on update", async () => { + const tool = createTestCronTool({ + agentSessionKey: "agent:agent-123:telegram:direct:channing", + }); + + await expect( + tool.execute("call-update-agent-id", { + action: "update", + id: "job-1", + patch: { agentId: "worker" }, + }), + ).rejects.toThrow("cron patch agentId cannot be changed"); + expect(callGatewayMock).not.toHaveBeenCalled(); + }); + + it("allows unscoped operator cron.update agentId retargeting", async () => { + callGatewayMock.mockResolvedValueOnce({ ok: true }); + const tool = createTestCronTool(); + + await tool.execute("call-unscoped-update-agent-id", { + action: "update", + id: "job-1", + patch: { agentId: "worker" }, + }); + + const params = expectSingleGatewayCallMethod("cron.update") as + | { id?: string; patch?: { agentId?: string } } + | undefined; + expect(params).toEqual({ + id: "job-1", + patch: { agentId: "worker" }, + }); + }); + + it("rejects foreign sessionTarget retargeting on update", async () => { + const tool = createTestCronTool({ + agentSessionKey: "agent:agent-123:telegram:direct:channing", + }); + + await expect( + tool.execute("call-update-session-target", { + action: "update", + id: "job-1", + patch: { sessionTarget: "session:agent:worker:telegram:direct:alice" }, + }), + ).rejects.toThrow("cron sessionTarget must match the calling agent"); + expect(callGatewayMock).not.toHaveBeenCalled(); + }); + it("recovers additional flat patch params for update action", async () => { callGatewayMock.mockResolvedValueOnce({ ok: true }); diff --git a/src/agents/tools/cron-tool.ts b/src/agents/tools/cron-tool.ts index aaaeff31591..b7fc818c519 100644 --- a/src/agents/tools/cron-tool.ts +++ b/src/agents/tools/cron-tool.ts @@ -5,13 +5,14 @@ */ import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; import { Type, type TSchema } from "typebox"; -import { getRuntimeConfig } from "../../config/config.js"; +import { getRuntimeConfig, type OpenClawConfig } from "../../config/config.js"; import { resolveCronCreationDelivery } from "../../cron/delivery-context.js"; import { assertCronDeliveryInputNonBlankFields } from "../../cron/delivery-target-validation.js"; import { normalizeCronJobCreate, normalizeCronJobPatch } from "../../cron/normalize.js"; import type { CronDelivery } from "../../cron/types.js"; import { normalizeHttpWebhookUrl } from "../../cron/webhook-url.js"; import { GatewayClientRequestError } from "../../gateway/client.js"; +import { normalizeAgentId } from "../../routing/session-key.js"; import { parseAgentSessionKey } from "../../sessions/session-key-utils.js"; import { extractTextFromChatContent } from "../../shared/chat-content.js"; import { isRecord, truncateUtf16Safe } from "../../utils.js"; @@ -45,6 +46,7 @@ import { isEmptyRecoveredCronPatch, recoverCronObjectFromFlatParams, } from "./cron-tool-canonicalize.js"; +import { withGatewayToolCallerIdentity } from "./gateway-caller-context.js"; import { gatewayCallOptionSchemaProperties } from "./gateway-schema.js"; import { callGatewayTool, readGatewayCallOptions, type GatewayCallOptions } from "./gateway.js"; import { resolveInternalSessionKey, resolveMainSessionAlias } from "./sessions-helpers.js"; @@ -350,6 +352,11 @@ type CronToolOptions = { selfRemoveOnlyJobId?: string; }; +type CronToolCallerScope = { + kind: "agentTool"; + agentId: string; +}; + export type CronCreatorToolAllowlistEntry = | string | { @@ -541,7 +548,9 @@ async function capCronAgentTurnUpdatePatchToolsAllow(params: { return; } - const existing = await params.callGateway("cron.get", params.gatewayOpts, { id: params.id }); + const existing = await params.callGateway("cron.get", params.gatewayOpts, { + id: params.id, + }); const existingPayload = isRecord(existing) ? existing.payload : undefined; const existingPayloadKind = readCronPayloadKind(existingPayload); if (!patchRequestsAgentTurn && existingPayloadKind !== "agentTurn") { @@ -576,6 +585,70 @@ function readCronJobIdParam(params: Record) { return readStringParam(params, "jobId") ?? readStringParam(params, "id"); } +function resolveCronToolCallerScope( + opts: CronToolOptions | undefined, + cfg: OpenClawConfig, +): CronToolCallerScope | undefined { + const sessionKey = opts?.agentSessionKey?.trim(); + if (!sessionKey) { + return undefined; + } + return { + kind: "agentTool", + agentId: resolveSessionAgentId({ sessionKey, config: cfg }), + }; +} + +function readCronToolAgentId(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? normalizeAgentId(value) : undefined; +} + +function readAgentIdFromCronToolSessionRef(value: unknown): string | undefined { + return typeof value === "string" && value.trim() + ? parseAgentSessionKey(value.trim())?.agentId + : undefined; +} + +function readAgentIdFromCronToolSessionTarget(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + if (!trimmed.startsWith("session:")) { + return undefined; + } + return readAgentIdFromCronToolSessionRef(trimmed.slice("session:".length)); +} + +function assertCronToolAgentFieldMatchesScope(params: { + value: unknown; + field: string; + callerScope: CronToolCallerScope; +}): void { + if (params.value === undefined) { + return; + } + const agentId = readCronToolAgentId(params.value); + if (agentId && agentId === params.callerScope.agentId) { + return; + } + throw new Error(`${params.field} must match the calling agent`); +} + +function assertCronToolSessionRefsMatchScope( + value: Record, + callerScope: CronToolCallerScope, +): void { + const sessionAgentId = readAgentIdFromCronToolSessionRef(value.sessionKey); + if (sessionAgentId && normalizeAgentId(sessionAgentId) !== callerScope.agentId) { + throw new Error("cron sessionKey must match the calling agent"); + } + const sessionTargetAgentId = readAgentIdFromCronToolSessionTarget(value.sessionTarget); + if (sessionTargetAgentId && normalizeAgentId(sessionTargetAgentId) !== callerScope.agentId) { + throw new Error("cron sessionTarget must match the calling agent"); + } +} + const CRON_SELF_REMOVE_SCOPE_ERROR = "Cron tool is restricted to the current cron job."; function readCronSelfRemoveOnlyJobId(opts: CronToolOptions | undefined) { @@ -859,325 +932,360 @@ Use jobId canonical; id accepted compat. contextMessages (0-10) adds previous me ...parsedGatewayOpts, timeoutMs: parsedGatewayOpts.timeoutMs ?? 60_000, }; + const runtimeConfig = getRuntimeConfig(); + const callerScope = resolveCronToolCallerScope(opts, runtimeConfig); + const callerIdentity = + callerScope && opts?.agentSessionKey?.trim() + ? { agentId: callerScope.agentId, sessionKey: opts.agentSessionKey.trim() } + : undefined; - switch (action) { - case "status": { - const result = await callGateway("cron.status", gatewayOpts, {}); - return jsonResult( - readCronSelfRemoveOnlyJobId(opts) ? filterCronStatusResultForSelfScope(result) : result, - ); - } - case "list": { - const cfg = getRuntimeConfig(); - const selfRemoveOnlyJobId = readCronSelfRemoveOnlyJobId(opts); - const listAgentId = selfRemoveOnlyJobId - ? opts?.agentSessionKey?.trim() - ? resolveSessionAgentId({ sessionKey: opts.agentSessionKey, config: cfg }) - : undefined - : typeof params.agentId === "string" && params.agentId.trim() - ? params.agentId.trim() - : opts?.agentSessionKey - ? resolveSessionAgentId({ sessionKey: opts.agentSessionKey, config: cfg }) - : undefined; - const includeDisabled = Boolean(params.includeDisabled); - let offset = 0; - let result: unknown; - let shouldContinue = true; - let useCompactList = true; - while (shouldContinue) { - try { - result = await callGateway("cron.list", gatewayOpts, { - includeDisabled, - ...(useCompactList ? { compact: true } : {}), - agentId: listAgentId, - ...(selfRemoveOnlyJobId ? { limit: 200, offset } : {}), - }); - } catch (error) { - if (!useCompactList || !isOlderGatewayWithoutCompactCronList(error)) { - throw error; - } - // Protocol v4 gateways predating compact reject the additive field. - // Retry without it for mixed-version correctness; remove at the next protocol break. - useCompactList = false; - continue; + return await withGatewayToolCallerIdentity(callerIdentity, async () => { + switch (action) { + case "status": { + const result = await callGateway("cron.status", gatewayOpts, {}); + return jsonResult( + readCronSelfRemoveOnlyJobId(opts) + ? filterCronStatusResultForSelfScope(result) + : result, + ); + } + case "list": { + const selfRemoveOnlyJobId = readCronSelfRemoveOnlyJobId(opts); + const explicitAgentId = readCronToolAgentId(params.agentId); + if (callerScope && explicitAgentId && explicitAgentId !== callerScope.agentId) { + throw new Error("cron list agentId must match the calling agent"); } - if (!selfRemoveOnlyJobId || cronListResultHasJob(result, selfRemoveOnlyJobId)) { - shouldContinue = false; - } else { - const nextOffset = readCronListNextOffset(result, offset); - if (nextOffset === undefined) { + const listAgentId = callerScope?.agentId ?? explicitAgentId; + const includeDisabled = Boolean(params.includeDisabled); + let offset = 0; + let result: unknown; + let shouldContinue = true; + let useCompactList = true; + while (shouldContinue) { + try { + result = await callGateway("cron.list", gatewayOpts, { + includeDisabled, + ...(useCompactList ? { compact: true } : {}), + ...(listAgentId ? { agentId: listAgentId } : {}), + ...(selfRemoveOnlyJobId ? { limit: 200, offset } : {}), + }); + } catch (error) { + if (!useCompactList || !isOlderGatewayWithoutCompactCronList(error)) { + throw error; + } + // Protocol v4 gateways predating compact reject the additive field. + // Retry without it for mixed-version correctness; remove at the next protocol break. + useCompactList = false; + continue; + } + if (!selfRemoveOnlyJobId || cronListResultHasJob(result, selfRemoveOnlyJobId)) { shouldContinue = false; } else { - offset = nextOffset; + const nextOffset = readCronListNextOffset(result, offset); + if (nextOffset === undefined) { + shouldContinue = false; + } else { + offset = nextOffset; + } } } - } - return jsonResult( - selfRemoveOnlyJobId ? filterCronListResultToJobId(result, selfRemoveOnlyJobId) : result, - ); - } - case "get": { - const id = readCronJobIdParam(params); - if (!id) { - throw new Error("jobId required (id accepted for backward compatibility)"); - } - return jsonResult(await callGateway("cron.get", gatewayOpts, { id })); - } - case "add": { - // Flat-params recovery: non-frontier models (e.g. Grok) sometimes flatten - // job properties to the top level alongside `action` instead of nesting - // them inside `job`. When `params.job` is missing or empty, reconstruct - // a synthetic job object from any recognised top-level job fields. - // See: https://github.com/openclaw/openclaw/issues/11310 - if (isMissingOrEmptyObject(params.job)) { - const synthetic = recoverCronObjectFromFlatParams(params); - // Only use the synthetic job if at least one meaningful field is present - // (schedule, payload, message, or text are the minimum signals that the - // LLM intended to create a job). - if (synthetic.found && hasCronCreateSignal(synthetic.value)) { - params.job = synthetic.value; - } - } - - if (!params.job || typeof params.job !== "object") { - throw new Error("job required"); - } - const canonicalJob = canonicalizeCronToolObject(params.job as Record); - assertNoCronCommandPayload(canonicalJob); - assertCronDeliveryInputNonBlankFields(canonicalJob.delivery); - const job = - normalizeCronJobCreate(canonicalJob, { - sessionContext: { sessionKey: opts?.agentSessionKey }, - }) ?? canonicalJob; - capCronAgentTurnJobToolsAllow(job, opts?.creatorToolAllowlist); - const cfg = getRuntimeConfig(); - if (job && typeof job === "object") { - const { mainKey, alias } = resolveMainSessionAlias(cfg); - const resolvedSessionKey = opts?.agentSessionKey - ? resolveInternalSessionKey({ key: opts.agentSessionKey, alias, mainKey }) - : undefined; - if (!("agentId" in job) || (job as { agentId?: unknown }).agentId === undefined) { - const agentId = opts?.agentSessionKey - ? resolveSessionAgentId({ sessionKey: opts.agentSessionKey, config: cfg }) - : undefined; - if (agentId) { - (job as { agentId?: string }).agentId = agentId; - } - } - const sessionTarget = normalizeLowercaseStringOrEmpty( - (job as { sessionTarget?: unknown }).sessionTarget, + return jsonResult( + selfRemoveOnlyJobId + ? filterCronListResultToJobId(result, selfRemoveOnlyJobId) + : result, ); - if (!("sessionKey" in job) && resolvedSessionKey && sessionTarget !== "isolated") { - (job as { sessionKey?: string }).sessionKey = resolvedSessionKey; - } } - - if ( - (opts?.agentSessionKey || opts?.currentDeliveryContext) && - job && - typeof job === "object" && - "payload" in job && - (job as { payload?: { kind?: string } }).payload?.kind === "agentTurn" - ) { - const deliveryValue = (job as { delivery?: unknown }).delivery; - const delivery = isRecord(deliveryValue) ? deliveryValue : undefined; - const modeRaw = typeof delivery?.mode === "string" ? delivery.mode : ""; - const mode = normalizeLowercaseStringOrEmpty(modeRaw); - if (mode === "webhook") { - const webhookUrl = normalizeHttpWebhookUrl(delivery?.to); - if (!webhookUrl) { - throw new Error( - 'delivery.mode="webhook" requires delivery.to to be a valid http(s) URL', - ); - } - if (delivery) { - delivery.to = webhookUrl; + case "get": { + const id = readCronJobIdParam(params); + if (!id) { + throw new Error("jobId required (id accepted for backward compatibility)"); + } + return jsonResult( + await callGateway("cron.get", gatewayOpts, { + id, + }), + ); + } + case "add": { + // Flat-params recovery: non-frontier models (e.g. Grok) sometimes flatten + // job properties to the top level alongside `action` instead of nesting + // them inside `job`. When `params.job` is missing or empty, reconstruct + // a synthetic job object from any recognised top-level job fields. + // See: https://github.com/openclaw/openclaw/issues/11310 + if (isMissingOrEmptyObject(params.job)) { + const synthetic = recoverCronObjectFromFlatParams(params); + // Only use the synthetic job if at least one meaningful field is present + // (schedule, payload, message, or text are the minimum signals that the + // LLM intended to create a job). + if (synthetic.found && hasCronCreateSignal(synthetic.value)) { + params.job = synthetic.value; } } - const hasTarget = - (typeof delivery?.channel === "string" && delivery.channel.trim()) || - (typeof delivery?.to === "string" && delivery.to.trim()); - const shouldInfer = - (deliveryValue == null || delivery) && - (mode === "" || mode === "announce") && - !hasTarget; - if (shouldInfer) { - const inferred = resolveCronCreationDelivery({ - cfg, - currentDeliveryContext: opts.currentDeliveryContext, - agentSessionKey: opts.agentSessionKey, - }); - if (inferred) { - (job as { delivery?: unknown }).delivery = { - ...inferred, - ...delivery, - } satisfies CronDelivery; + if (!params.job || typeof params.job !== "object") { + throw new Error("job required"); + } + const canonicalJob = canonicalizeCronToolObject(params.job as Record); + assertNoCronCommandPayload(canonicalJob); + assertCronDeliveryInputNonBlankFields(canonicalJob.delivery); + const job = + normalizeCronJobCreate(canonicalJob, { + sessionContext: { sessionKey: opts?.agentSessionKey }, + }) ?? canonicalJob; + capCronAgentTurnJobToolsAllow(job, opts?.creatorToolAllowlist); + if (job && typeof job === "object") { + const { mainKey, alias } = resolveMainSessionAlias(runtimeConfig); + const resolvedSessionKey = opts?.agentSessionKey + ? resolveInternalSessionKey({ key: opts.agentSessionKey, alias, mainKey }) + : undefined; + if (callerScope) { + assertCronToolAgentFieldMatchesScope({ + value: (job as { agentId?: unknown }).agentId, + field: "cron job agentId", + callerScope, + }); + (job as { agentId?: string }).agentId = callerScope.agentId; + assertCronToolSessionRefsMatchScope(job as Record, callerScope); + } + const sessionTarget = normalizeLowercaseStringOrEmpty( + (job as { sessionTarget?: unknown }).sessionTarget, + ); + if (!("sessionKey" in job) && resolvedSessionKey && sessionTarget !== "isolated") { + (job as { sessionKey?: string }).sessionKey = resolvedSessionKey; } } - } - const contextMessages = readNonNegativeIntegerParam(params, "contextMessages") ?? 0; - if ( - job && - typeof job === "object" && - "payload" in job && - (job as { payload?: { kind?: string; text?: string } }).payload?.kind === "systemEvent" - ) { - const payload = (job as { payload: { kind: string; text: string } }).payload; - if (typeof payload.text === "string" && payload.text.trim()) { - const contextLines = await buildReminderContextLines({ - agentSessionKey: opts?.agentSessionKey, - gatewayOpts, - contextMessages, - callGatewayTool: callGateway, - }); - if (contextLines.length > 0) { - const baseText = stripExistingContext(payload.text); - payload.text = `${baseText}${REMINDER_CONTEXT_MARKER}${contextLines.join("\n")}`; + if ( + (opts?.agentSessionKey || opts?.currentDeliveryContext) && + job && + typeof job === "object" && + "payload" in job && + (job as { payload?: { kind?: string } }).payload?.kind === "agentTurn" + ) { + const deliveryValue = (job as { delivery?: unknown }).delivery; + const delivery = isRecord(deliveryValue) ? deliveryValue : undefined; + const modeRaw = typeof delivery?.mode === "string" ? delivery.mode : ""; + const mode = normalizeLowercaseStringOrEmpty(modeRaw); + if (mode === "webhook") { + const webhookUrl = normalizeHttpWebhookUrl(delivery?.to); + if (!webhookUrl) { + throw new Error( + 'delivery.mode="webhook" requires delivery.to to be a valid http(s) URL', + ); + } + if (delivery) { + delivery.to = webhookUrl; + } + } + + const hasTarget = + (typeof delivery?.channel === "string" && delivery.channel.trim()) || + (typeof delivery?.to === "string" && delivery.to.trim()); + const shouldInfer = + (deliveryValue == null || delivery) && + (mode === "" || mode === "announce") && + !hasTarget; + if (shouldInfer) { + const inferred = resolveCronCreationDelivery({ + cfg: runtimeConfig, + currentDeliveryContext: opts.currentDeliveryContext, + agentSessionKey: opts.agentSessionKey, + }); + if (inferred) { + (job as { delivery?: unknown }).delivery = { + ...inferred, + ...delivery, + } satisfies CronDelivery; + } } } - } - return jsonResult(await callGateway("cron.add", gatewayOpts, job)); - } - case "update": { - const id = readCronJobIdParam(params); - if (!id) { - throw new Error("jobId required (id accepted for backward compatibility)"); - } - // Flat-params recovery for patch - let recoveredFlatPatch = false; - if (isMissingOrEmptyObject(params.patch)) { - const synthetic = recoverCronObjectFromFlatParams(params); - if (synthetic.found) { - params.patch = synthetic.value; - recoveredFlatPatch = true; + const contextMessages = readNonNegativeIntegerParam(params, "contextMessages") ?? 0; + if ( + job && + typeof job === "object" && + "payload" in job && + (job as { payload?: { kind?: string; text?: string } }).payload?.kind === + "systemEvent" + ) { + const payload = (job as { payload: { kind: string; text: string } }).payload; + if (typeof payload.text === "string" && payload.text.trim()) { + const contextLines = await buildReminderContextLines({ + agentSessionKey: opts?.agentSessionKey, + gatewayOpts, + contextMessages, + callGatewayTool: callGateway, + }); + if (contextLines.length > 0) { + const baseText = stripExistingContext(payload.text); + payload.text = `${baseText}${REMINDER_CONTEXT_MARKER}${contextLines.join("\n")}`; + } + } } + return jsonResult( + await callGateway("cron.add", gatewayOpts, { + ...job, + }), + ); } + case "update": { + const id = readCronJobIdParam(params); + if (!id) { + throw new Error("jobId required (id accepted for backward compatibility)"); + } - if (!params.patch || typeof params.patch !== "object") { - throw new Error("patch required"); - } - const canonicalPatch = canonicalizeCronToolObject( - params.patch as Record, - ); - assertNoCronCommandPayload(canonicalPatch); - assertCronDeliveryInputNonBlankFields(canonicalPatch.delivery); - const patch = normalizeCronJobPatch(canonicalPatch) ?? canonicalPatch; - if (recoveredFlatPatch && isEmptyRecoveredCronPatch(patch)) { - throw new Error("patch required"); - } - await capCronAgentTurnUpdatePatchToolsAllow({ - id, - patch, - creatorToolAllowlist: opts?.creatorToolAllowlist, - gatewayOpts, - callGateway, - }); - return jsonResult( - await callGateway("cron.update", gatewayOpts, { + // Flat-params recovery for patch + let recoveredFlatPatch = false; + if (isMissingOrEmptyObject(params.patch)) { + const synthetic = recoverCronObjectFromFlatParams(params); + if (synthetic.found) { + params.patch = synthetic.value; + recoveredFlatPatch = true; + } + } + + if (!params.patch || typeof params.patch !== "object") { + throw new Error("patch required"); + } + const canonicalPatch = canonicalizeCronToolObject( + params.patch as Record, + ); + assertNoCronCommandPayload(canonicalPatch); + assertCronDeliveryInputNonBlankFields(canonicalPatch.delivery); + const patch = normalizeCronJobPatch(canonicalPatch) ?? canonicalPatch; + if (recoveredFlatPatch && isEmptyRecoveredCronPatch(patch)) { + throw new Error("patch required"); + } + if (callerScope && "agentId" in patch) { + throw new Error("cron patch agentId cannot be changed by the agent cron tool"); + } + if (callerScope) { + assertCronToolSessionRefsMatchScope(patch, callerScope); + } + await capCronAgentTurnUpdatePatchToolsAllow({ id, patch, - }), - ); - } - case "remove": { - const id = readCronJobIdParam(params); - if (!id) { - throw new Error("jobId required (id accepted for backward compatibility)"); - } - return jsonResult(await callGateway("cron.remove", gatewayOpts, { id })); - } - case "run": { - const id = readCronJobIdParam(params); - if (!id) { - throw new Error("jobId required (id accepted for backward compatibility)"); - } - const runMode = - params.runMode === "due" || params.runMode === "force" ? params.runMode : "due"; - return jsonResult(await callGateway("cron.run", gatewayOpts, { id, mode: runMode })); - } - case "runs": { - const id = readCronJobIdParam(params); - if (!id) { - throw new Error("jobId required (id accepted for backward compatibility)"); - } - return jsonResult(await callGateway("cron.runs", gatewayOpts, { id })); - } - case "wake": { - const text = readStringParam(params, "text", { required: true }); - const mode = - params.mode === "now" || params.mode === "next-heartbeat" - ? params.mode - : "next-heartbeat"; - // Resolve the calling agent's session key into the internal form - // the cron service routes by (mirrors the `add` action above). - // Without this, the wake gateway call goes through with no session - // key and the system event lands on the heartbeat / main default - // rather than the originating conversation lane. Closes the - // upstream half of openclaw/openclaw#46886 (#64556 — agentId/ - // sessionKey silently ignored for `action: "wake"`). Explicit - // params on the tool call still take precedence over the inferred - // value, so call sites that want to wake a different session can - // pass `sessionKey` / `agentId` directly. - const cfg = getRuntimeConfig(); - const { mainKey, alias } = resolveMainSessionAlias(cfg); - const explicitSessionKey = readStringParam(params, "sessionKey"); - const explicitAgentId = readStringParam(params, "agentId"); - const inferredSessionKey = opts?.agentSessionKey - ? resolveInternalSessionKey({ key: opts.agentSessionKey, alias, mainKey }) - : undefined; - const inferredAgentId = opts?.agentSessionKey - ? resolveSessionAgentId({ sessionKey: opts.agentSessionKey, config: cfg }) - : undefined; - const sessionKey = explicitSessionKey ?? inferredSessionKey; - // When a caller supplies an explicit cross-agent sessionKey without - // an explicit agentId, the gateway target resolver treats agentId as - // authoritative — pairing the caller's inferred agentId with a - // foreign session key would canonicalize the wake back to the - // caller's main lane. Derive the agentId from the explicit canonical - // session key instead; only fall through to the inferred - // caller-agent when no explicit sessionKey was supplied. - const agentIdFromExplicitSessionKey = explicitSessionKey - ? parseAgentSessionKey(explicitSessionKey)?.agentId - : undefined; - // A contradictory explicit pair (agentId X + a sessionKey owned by - // agent Y) is ambiguous: the gateway target resolver treats agentId - // as authoritative and would silently canonicalize the wake onto a - // session under X that the caller never named. Reject instead of - // guessing one canonical owner. - if ( - explicitAgentId && - agentIdFromExplicitSessionKey && - normalizeLowercaseStringOrEmpty(explicitAgentId) !== - normalizeLowercaseStringOrEmpty(agentIdFromExplicitSessionKey) - ) { - throw new Error( - `wake agentId "${explicitAgentId}" contradicts the agent that owns sessionKey ` + - `("${agentIdFromExplicitSessionKey}"); pass a single canonical wake target`, + creatorToolAllowlist: opts?.creatorToolAllowlist, + gatewayOpts, + callGateway, + }); + return jsonResult( + await callGateway("cron.update", gatewayOpts, { + id, + patch, + }), ); } - const agentId = - explicitAgentId ?? - (explicitSessionKey ? agentIdFromExplicitSessionKey : inferredAgentId); - return jsonResult( - await callGateway( - "wake", - gatewayOpts, - { - mode, - text, - ...(sessionKey ? { sessionKey } : {}), - ...(agentId ? { agentId } : {}), - }, - { expectFinal: false }, - ), - ); + case "remove": { + const id = readCronJobIdParam(params); + if (!id) { + throw new Error("jobId required (id accepted for backward compatibility)"); + } + return jsonResult( + await callGateway("cron.remove", gatewayOpts, { + id, + }), + ); + } + case "run": { + const id = readCronJobIdParam(params); + if (!id) { + throw new Error("jobId required (id accepted for backward compatibility)"); + } + const runMode = + params.runMode === "due" || params.runMode === "force" ? params.runMode : "due"; + return jsonResult( + await callGateway("cron.run", gatewayOpts, { + id, + mode: runMode, + }), + ); + } + case "runs": { + const id = readCronJobIdParam(params); + if (!id) { + throw new Error("jobId required (id accepted for backward compatibility)"); + } + return jsonResult( + await callGateway("cron.runs", gatewayOpts, { + id, + }), + ); + } + case "wake": { + const text = readStringParam(params, "text", { required: true }); + const mode = + params.mode === "now" || params.mode === "next-heartbeat" + ? params.mode + : "next-heartbeat"; + // Resolve the calling agent's session key into the internal form + // the cron service routes by (mirrors the `add` action above). + // Without this, the wake gateway call goes through with no session + // key and the system event lands on the heartbeat / main default + // rather than the originating conversation lane. Closes the + // upstream half of openclaw/openclaw#46886 (#64556 — agentId/ + // sessionKey silently ignored for `action: "wake"`). Explicit + // params on the tool call still take precedence over the inferred + // value, so call sites that want to wake a different session can + // pass `sessionKey` / `agentId` directly. + const cfg = getRuntimeConfig(); + const { mainKey, alias } = resolveMainSessionAlias(cfg); + const explicitSessionKey = readStringParam(params, "sessionKey"); + const explicitAgentId = readStringParam(params, "agentId"); + const inferredSessionKey = opts?.agentSessionKey + ? resolveInternalSessionKey({ key: opts.agentSessionKey, alias, mainKey }) + : undefined; + const inferredAgentId = opts?.agentSessionKey + ? resolveSessionAgentId({ sessionKey: opts.agentSessionKey, config: cfg }) + : undefined; + const sessionKey = explicitSessionKey ?? inferredSessionKey; + // When a caller supplies an explicit cross-agent sessionKey without + // an explicit agentId, the gateway target resolver treats agentId as + // authoritative — pairing the caller's inferred agentId with a + // foreign session key would canonicalize the wake back to the + // caller's main lane. Derive the agentId from the explicit canonical + // session key instead; only fall through to the inferred + // caller-agent when no explicit sessionKey was supplied. + const agentIdFromExplicitSessionKey = explicitSessionKey + ? parseAgentSessionKey(explicitSessionKey)?.agentId + : undefined; + // A contradictory explicit pair (agentId X + a sessionKey owned by + // agent Y) is ambiguous: the gateway target resolver treats agentId + // as authoritative and would silently canonicalize the wake onto a + // session under X that the caller never named. Reject instead of + // guessing one canonical owner. + if ( + explicitAgentId && + agentIdFromExplicitSessionKey && + normalizeLowercaseStringOrEmpty(explicitAgentId) !== + normalizeLowercaseStringOrEmpty(agentIdFromExplicitSessionKey) + ) { + throw new Error( + `wake agentId "${explicitAgentId}" contradicts the agent that owns sessionKey ` + + `("${agentIdFromExplicitSessionKey}"); pass a single canonical wake target`, + ); + } + const agentId = + explicitAgentId ?? + (explicitSessionKey ? agentIdFromExplicitSessionKey : inferredAgentId); + return jsonResult( + await callGateway( + "wake", + gatewayOpts, + { + mode, + text, + ...(sessionKey ? { sessionKey } : {}), + ...(agentId ? { agentId } : {}), + }, + { expectFinal: false }, + ), + ); + } + default: + throw new Error(`Unknown action: ${action}`); } - default: - throw new Error(`Unknown action: ${action}`); - } + }); }, }; return setToolTerminalPresentation(tool, formatCronTerminalPresentation); diff --git a/src/agents/tools/gateway-caller-context.test.ts b/src/agents/tools/gateway-caller-context.test.ts new file mode 100644 index 00000000000..77b6db38d7d --- /dev/null +++ b/src/agents/tools/gateway-caller-context.test.ts @@ -0,0 +1,43 @@ +import { Type } from "typebox"; +import { describe, expect, it, vi } from "vitest"; +import { getPluginToolMeta, setPluginToolMeta } from "../../plugins/tools.js"; +import { + isToolWrappedWithBeforeToolCallHook, + wrapToolWithBeforeToolCallHook, +} from "../agent-tools.before-tool-call.js"; +import { getChannelAgentToolMeta, setChannelAgentToolMeta } from "../channel-tool-metadata.js"; +import { + getToolTerminalPresentation, + setToolTerminalPresentation, +} from "../tool-terminal-presentation.js"; +import type { AnyAgentTool } from "./common.js"; +import { wrapToolWithGatewayCallerIdentity } from "./gateway-caller-context.js"; + +describe("gateway caller context wrapper", () => { + it("preserves tool metadata used by policy and presentation layers", () => { + const tool: AnyAgentTool = { + name: "plugin_tool", + label: "Plugin tool", + description: "plugin tool", + parameters: Type.Object({}), + execute: vi.fn(async () => ({ + content: [{ type: "text" as const, text: "ok" }], + details: {}, + })), + }; + setPluginToolMeta(tool, { pluginId: "plugin-a", optional: false }); + setChannelAgentToolMeta(tool as never, { channelId: "telegram" }); + setToolTerminalPresentation(tool, () => ({ text: "done" })); + + const beforeWrapped = wrapToolWithBeforeToolCallHook(tool); + const wrapped = wrapToolWithGatewayCallerIdentity(beforeWrapped, { + agentId: "agent-a", + sessionKey: "agent-a:session", + }); + + expect(getPluginToolMeta(wrapped)).toEqual({ pluginId: "plugin-a", optional: false }); + expect(getChannelAgentToolMeta(wrapped as never)).toEqual({ channelId: "telegram" }); + expect(getToolTerminalPresentation(wrapped)).toBe(getToolTerminalPresentation(tool)); + expect(isToolWrappedWithBeforeToolCallHook(wrapped)).toBe(true); + }); +}); diff --git a/src/agents/tools/gateway-caller-context.ts b/src/agents/tools/gateway-caller-context.ts new file mode 100644 index 00000000000..816546b94df --- /dev/null +++ b/src/agents/tools/gateway-caller-context.ts @@ -0,0 +1,53 @@ +// Ambient trusted caller context for model-mediated Gateway tool calls. +import { AsyncLocalStorage } from "node:async_hooks"; +import { copyPluginToolMeta } from "../../plugins/tools.js"; +import { copyBeforeToolCallHookMarker } from "../before-tool-call-metadata.js"; +import { copyChannelAgentToolMeta } from "../channel-tools.js"; +import { copyToolTerminalPresentation } from "../tool-terminal-presentation.js"; +import type { AnyAgentTool } from "./common.js"; + +export type GatewayToolCallerIdentity = { + agentId: string; + sessionKey: string; +}; + +const gatewayToolCallerStorage = new AsyncLocalStorage(); + +export function getGatewayToolCallerIdentity(): GatewayToolCallerIdentity | undefined { + return gatewayToolCallerStorage.getStore(); +} + +export async function withGatewayToolCallerIdentity( + identity: GatewayToolCallerIdentity | undefined, + run: () => Promise | T, +): Promise { + if (!identity?.agentId?.trim() || !identity.sessionKey?.trim()) { + return await run(); + } + return await gatewayToolCallerStorage.run( + { + agentId: identity.agentId.trim(), + sessionKey: identity.sessionKey.trim(), + }, + run, + ); +} + +export function wrapToolWithGatewayCallerIdentity( + tool: AnyAgentTool, + identity: GatewayToolCallerIdentity | undefined, +): AnyAgentTool { + if (!identity?.agentId?.trim() || !identity.sessionKey?.trim() || !tool.execute) { + return tool; + } + const wrapped: AnyAgentTool = { + ...tool, + execute: async (...args) => + await withGatewayToolCallerIdentity(identity, async () => await tool.execute?.(...args)), + }; + copyPluginToolMeta(tool, wrapped); + copyChannelAgentToolMeta(tool as never, wrapped as never); + copyBeforeToolCallHookMarker(tool, wrapped); + copyToolTerminalPresentation(tool, wrapped); + return wrapped; +} diff --git a/src/agents/tools/gateway.test.ts b/src/agents/tools/gateway.test.ts index ace99c492c4..1d9ad5d0e5d 100644 --- a/src/agents/tools/gateway.test.ts +++ b/src/agents/tools/gateway.test.ts @@ -4,6 +4,7 @@ import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { CallGatewayOptions } from "../../gateway/call.js"; import { createEmptyPluginRegistry } from "../../plugins/registry-empty.js"; import { setActivePluginRegistry } from "../../plugins/runtime.js"; +import { withGatewayToolCallerIdentity } from "./gateway-caller-context.js"; import { callGatewayTool, readGatewayCallOptions, resolveGatewayOptions } from "./gateway.js"; const mocks = vi.hoisted(() => ({ @@ -318,6 +319,136 @@ describe("gateway tool defaults", () => { expect(call.deviceIdentity).toEqual(mocks.deviceIdentity); }); + it("does not mark direct cron helper calls with agent runtime identity", async () => { + mocks.callGateway.mockResolvedValueOnce({ id: "job-1" }); + + await callGatewayTool("cron.remove", {}, { id: "job-1" }); + + const call = capturedGatewayCall(); + expect(call.method).toBe("cron.remove"); + expect(call.params).toEqual({ id: "job-1" }); + expect(call).not.toHaveProperty("agentRuntimeIdentityToken"); + }); + + it("marks local cron calls from trusted tool context with agent runtime identity", async () => { + mocks.callGateway.mockResolvedValueOnce({ id: "job-1" }); + + await withGatewayToolCallerIdentity( + { agentId: "ops", sessionKey: "agent:ops:telegram:direct:alice" }, + async () => { + await callGatewayTool("cron.remove", {}, { id: "job-1" }); + }, + ); + + const call = capturedGatewayCall(); + expect(call.method).toBe("cron.remove"); + expect(call.params).toEqual({ id: "job-1" }); + expect(call.agentRuntimeIdentityToken).toEqual(expect.any(String)); + }); + + it("explains stale gateway cron connection metadata rejections", async () => { + mocks.callGateway.mockRejectedValueOnce( + new Error( + "invalid connect params: at /auth: unexpected property 'agentRuntimeIdentityToken'", + ), + ); + + await expect( + withGatewayToolCallerIdentity( + { agentId: "ops", sessionKey: "agent:ops:telegram:direct:alice" }, + async () => { + await callGatewayTool("cron.remove", {}, { id: "job-1" }); + }, + ), + ).rejects.toThrow( + "The running Gateway is from an older OpenClaw build and rejected current agent cron connection metadata. Restart the Gateway with `openclaw gateway restart`, then retry.", + ); + + const call = capturedGatewayCall(); + expect(call.agentRuntimeIdentityToken).toEqual(expect.any(String)); + }); + + it("explains fail-closed stale gateway cron identity rejections", async () => { + mocks.callGateway.mockRejectedValueOnce( + new Error( + "gateway rejected required agent runtime identity auth field; refusing to retry without it", + ), + ); + + await expect( + withGatewayToolCallerIdentity( + { agentId: "ops", sessionKey: "agent:ops:telegram:direct:alice" }, + async () => { + await callGatewayTool("cron.remove", {}, { id: "job-1" }); + }, + ), + ).rejects.toThrow( + "The running Gateway is from an older OpenClaw build and rejected current agent cron connection metadata. Restart the Gateway with `openclaw gateway restart`, then retry.", + ); + + const call = capturedGatewayCall(); + expect(call.agentRuntimeIdentityToken).toEqual(expect.any(String)); + }); + + it("does not rewrite stale gateway validation errors for unscoped cron calls", async () => { + const originalError = new Error( + "invalid connect params: at /auth: unexpected property 'agentRuntimeIdentityToken'", + ); + mocks.callGateway.mockRejectedValueOnce(originalError); + + await expect(callGatewayTool("cron.remove", {}, { id: "job-1" })).rejects.toBe(originalError); + }); + + it("fails contextual cron calls closed for gatewayUrl overrides", async () => { + await expect( + withGatewayToolCallerIdentity( + { agentId: "ops", sessionKey: "agent:ops:telegram:direct:alice" }, + async () => { + await callGatewayTool( + "cron.remove", + { gatewayUrl: "ws://127.0.0.1:18789" }, + { id: "job-1" }, + ); + }, + ), + ).rejects.toThrow("agent cron gateway calls require the trusted local gateway context"); + expect(mocks.callGateway).not.toHaveBeenCalled(); + }); + + it("fails contextual cron calls closed for explicit gateway tokens", async () => { + await expect( + withGatewayToolCallerIdentity( + { agentId: "ops", sessionKey: "agent:ops:telegram:direct:alice" }, + async () => { + await callGatewayTool("cron.remove", { gatewayToken: "token" }, { id: "job-1" }); + }, + ), + ).rejects.toThrow("agent cron gateway calls require the trusted local gateway context"); + expect(mocks.callGateway).not.toHaveBeenCalled(); + }); + + it("fails contextual cron calls closed for configured remote gateways", async () => { + mocks.configState.value = { + gateway: { + mode: "remote", + remote: { + url: "wss://gateway.example", + token: "remote-token", + }, + }, + }; + + await expect( + withGatewayToolCallerIdentity( + { agentId: "ops", sessionKey: "agent:ops:telegram:direct:alice" }, + async () => { + await callGatewayTool("cron.remove", {}, { id: "job-1" }); + }, + ), + ).rejects.toThrow("agent cron gateway calls require the trusted local gateway context"); + expect(mocks.callGateway).not.toHaveBeenCalled(); + }); + it("marks local approval wait calls as approval runtime calls", async () => { mocks.callGateway.mockResolvedValueOnce({ decision: "allow-once" }); diff --git a/src/agents/tools/gateway.ts b/src/agents/tools/gateway.ts index aafe7bcb820..c8f5421b2bb 100644 --- a/src/agents/tools/gateway.ts +++ b/src/agents/tools/gateway.ts @@ -13,6 +13,7 @@ import { } from "../../../packages/gateway-protocol/src/client-info.js"; import { getRuntimeConfig, resolveGatewayPort } from "../../config/config.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { mintAgentRuntimeIdentityToken } from "../../gateway/agent-runtime-identity-token.js"; import { callGateway } from "../../gateway/call.js"; import { resolveGatewayCredentialsFromConfig, trimToUndefined } from "../../gateway/credentials.js"; import { @@ -27,6 +28,7 @@ import { } from "../../infra/device-identity.js"; import { formatErrorMessage } from "../../infra/errors.js"; import { readPositiveIntegerParam, readStringParam } from "./common.js"; +import { getGatewayToolCallerIdentity } from "./gateway-caller-context.js"; /** Optional gateway connection overrides accepted by agent tools. */ export type GatewayCallOptions = { @@ -208,6 +210,16 @@ const APPROVAL_RUNTIME_METHODS = new Set([ "plugin.approval.waitDecision", ]); +const AGENT_RUNTIME_IDENTITY_METHODS = new Set([ + "cron.list", + "cron.get", + "cron.add", + "cron.update", + "cron.remove", + "cron.run", + "cron.runs", +]); + function resolveApprovalRuntimeTokenForGatewayTool(params: { method: string; opts: GatewayCallOptions; @@ -263,6 +275,52 @@ function resolveApprovalRequesterDeviceIdentityForGatewayTool(params: { } } +function resolveAgentRuntimeIdentityTokenForGatewayTool(params: { + method: string; + opts: GatewayCallOptions; + target: GatewayOverrideTarget; +}): string | undefined { + if (!AGENT_RUNTIME_IDENTITY_METHODS.has(params.method)) { + return undefined; + } + const identity = getGatewayToolCallerIdentity(); + if (!identity) { + return undefined; + } + const hasGatewayUrlOverride = trimToUndefined(params.opts.gatewayUrl) !== undefined; + const hasGatewayTokenOverride = trimToUndefined(params.opts.gatewayToken) !== undefined; + if (hasGatewayUrlOverride || hasGatewayTokenOverride || params.target !== "local") { + throw new Error("agent cron gateway calls require the trusted local gateway context"); + } + return mintAgentRuntimeIdentityToken(identity); +} + +function isStaleGatewayAgentRuntimeIdentityRejection(error: unknown): boolean { + const message = formatErrorMessage(error); + if ( + message.includes( + "gateway rejected required agent runtime identity auth field; refusing to retry without it", + ) + ) { + return true; + } + return ( + message.includes("invalid connect params") && + message.includes("/auth") && + message.includes("unexpected property 'agentRuntimeIdentityToken'") + ); +} + +function staleGatewayAgentRuntimeIdentityError(cause: unknown): Error { + return new Error( + [ + "The running Gateway is from an older OpenClaw build and rejected current agent cron connection metadata.", + "Restart the Gateway with `openclaw gateway restart`, then retry.", + ].join(" "), + { cause }, + ); +} + /** * Calls a gateway method as the agent-tool backend client with least-privilege scopes. */ @@ -281,23 +339,36 @@ export async function callGatewayTool>( opts, target: gateway.target, }); + const agentRuntimeIdentityToken = resolveAgentRuntimeIdentityTokenForGatewayTool({ + method, + opts, + target: gateway.target, + }); const deviceIdentity = resolveApprovalRequesterDeviceIdentityForGatewayTool({ method, opts, target: gateway.target, }); - return await callGateway({ - url: gateway.url, - token: gateway.token, - method, - params, - timeoutMs: gateway.timeoutMs, - expectFinal: extra?.expectFinal, - clientName: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT, - clientDisplayName: "agent", - mode: GATEWAY_CLIENT_MODES.BACKEND, - ...(approvalRuntimeToken ? { approvalRuntimeToken } : {}), - ...(deviceIdentity ? { deviceIdentity } : {}), - scopes, - }); + try { + return await callGateway({ + url: gateway.url, + token: gateway.token, + method, + params, + timeoutMs: gateway.timeoutMs, + expectFinal: extra?.expectFinal, + clientName: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT, + clientDisplayName: "agent", + mode: GATEWAY_CLIENT_MODES.BACKEND, + ...(approvalRuntimeToken ? { approvalRuntimeToken } : {}), + ...(agentRuntimeIdentityToken ? { agentRuntimeIdentityToken } : {}), + ...(deviceIdentity ? { deviceIdentity } : {}), + scopes, + }); + } catch (error) { + if (agentRuntimeIdentityToken && isStaleGatewayAgentRuntimeIdentityRejection(error)) { + throw staleGatewayAgentRuntimeIdentityError(error); + } + throw error; + } } diff --git a/src/gateway/agent-runtime-identity-token.test.ts b/src/gateway/agent-runtime-identity-token.test.ts new file mode 100644 index 00000000000..6e0a8ac0ea6 --- /dev/null +++ b/src/gateway/agent-runtime-identity-token.test.ts @@ -0,0 +1,96 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { captureEnv, setTestEnvValue } from "../test-utils/env.js"; + +const envSnapshot = captureEnv(["HOME", "OPENCLAW_HOME", "OPENCLAW_STATE_DIR"]); + +const tempHomes: string[] = []; + +function useTempHome(): string { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-agent-runtime-")); + tempHomes.push(home); + setTestEnvValue("HOME", home); + setTestEnvValue("OPENCLAW_HOME", home); + setTestEnvValue("OPENCLAW_STATE_DIR", ""); + return home; +} + +function execApprovalsPath(home: string): string { + return path.join(home, ".openclaw", "exec-approvals.json"); +} + +function readExecApprovals(home: string): { + socket?: { token?: string }; +} { + return JSON.parse(fs.readFileSync(execApprovalsPath(home), "utf8")) as { + socket?: { token?: string }; + }; +} + +async function importRuntimeTokenModule(): Promise< + typeof import("./agent-runtime-identity-token.js") +> { + vi.resetModules(); + return await import("./agent-runtime-identity-token.js"); +} + +afterEach(() => { + vi.resetModules(); + envSnapshot.restore(); + for (const home of tempHomes.splice(0)) { + fs.rmSync(home, { recursive: true, force: true }); + } +}); + +describe("agent runtime identity token", () => { + it("persists the local signing secret so tokens verify across processes", async () => { + const home = useTempHome(); + const firstProcess = await importRuntimeTokenModule(); + + const token = firstProcess.mintAgentRuntimeIdentityToken({ + agentId: "main", + sessionKey: "session-1", + }); + + const persistedToken = readExecApprovals(home).socket?.token; + expect(persistedToken).toEqual(expect.any(String)); + expect(persistedToken).not.toHaveLength(0); + + const secondProcess = await importRuntimeTokenModule(); + expect(secondProcess.verifyAgentRuntimeIdentityToken(token)).toEqual({ + kind: "agentRuntime", + agentId: "main", + sessionKey: "session-1", + }); + }); + + it("does not mint local credentials while rejecting invalid presented tokens", async () => { + const home = useTempHome(); + const runtimeToken = await importRuntimeTokenModule(); + + expect(runtimeToken.verifyAgentRuntimeIdentityToken("not-a-valid-token")).toBeUndefined(); + expect(fs.existsSync(execApprovalsPath(home))).toBe(false); + }); + + it("rejects tokens minted from a different local state directory", async () => { + const firstHome = useTempHome(); + const firstProcess = await importRuntimeTokenModule(); + const token = firstProcess.mintAgentRuntimeIdentityToken({ + agentId: "main", + sessionKey: "session-1", + }); + expect(fs.existsSync(execApprovalsPath(firstHome))).toBe(true); + + useTempHome(); + const secondProcess = await importRuntimeTokenModule(); + const secondToken = secondProcess.mintAgentRuntimeIdentityToken({ + agentId: "main", + sessionKey: "session-1", + }); + + expect(secondToken).not.toBe(token); + expect(secondProcess.verifyAgentRuntimeIdentityToken(token)).toBeUndefined(); + }); +}); diff --git a/src/gateway/agent-runtime-identity-token.ts b/src/gateway/agent-runtime-identity-token.ts new file mode 100644 index 00000000000..33d0ae856c6 --- /dev/null +++ b/src/gateway/agent-runtime-identity-token.ts @@ -0,0 +1,121 @@ +// Purpose-scoped local agent runtime identity token for Gateway clients. +import { createHmac, timingSafeEqual } from "node:crypto"; +import { ensureExecApprovals, loadExecApprovals } from "../infra/exec-approvals.js"; +import { normalizeAgentId } from "../routing/session-key.js"; + +const AGENT_RUNTIME_IDENTITY_TOKEN_CONTEXT = "openclaw:gateway-agent-runtime-identity-token:v1"; +const AGENT_RUNTIME_IDENTITY_TOKEN_KIND = "agent-runtime"; + +export type AgentRuntimeIdentity = { + kind: "agentRuntime"; + agentId: string; + sessionKey: string; +}; + +type AgentRuntimeIdentityTokenPayload = { + kind: typeof AGENT_RUNTIME_IDENTITY_TOKEN_KIND; + agentId: string; + sessionKey: string; +}; + +function readSharedAgentRuntimeIdentitySecret(): string | null { + return loadExecApprovals().socket?.token?.trim() || null; +} + +function requireSharedAgentRuntimeIdentitySecret(): string { + const token = ensureExecApprovals().socket?.token?.trim(); + if (!token) { + throw new Error( + "Unable to mint agent runtime identity token without local socket credentials.", + ); + } + return token; +} + +function signPayload(secret: string, payload: string): string { + return createHmac("sha256", secret) + .update(AGENT_RUNTIME_IDENTITY_TOKEN_CONTEXT) + .update("\0") + .update(payload) + .digest("base64url"); +} + +function signatureMatches(value: string, expected: string): boolean { + const valueBytes = Buffer.from(value); + const expectedBytes = Buffer.from(expected); + return valueBytes.length === expectedBytes.length && timingSafeEqual(valueBytes, expectedBytes); +} + +function encodePayload(payload: AgentRuntimeIdentityTokenPayload): string { + return Buffer.from(JSON.stringify(payload), "utf8").toString("base64url"); +} + +function decodePayload(value: string): AgentRuntimeIdentityTokenPayload | undefined { + try { + const parsed = JSON.parse(Buffer.from(value, "base64url").toString("utf8")) as unknown; + if (!parsed || typeof parsed !== "object") { + return undefined; + } + const raw = parsed as { + kind?: unknown; + agentId?: unknown; + sessionKey?: unknown; + }; + if ( + raw.kind !== AGENT_RUNTIME_IDENTITY_TOKEN_KIND || + typeof raw.agentId !== "string" || + typeof raw.sessionKey !== "string" + ) { + return undefined; + } + const agentId = normalizeAgentId(raw.agentId); + const sessionKey = raw.sessionKey.trim(); + if (!agentId || !sessionKey) { + return undefined; + } + return { kind: AGENT_RUNTIME_IDENTITY_TOKEN_KIND, agentId, sessionKey }; + } catch { + return undefined; + } +} + +/** Mint an opaque token that lets trusted local agent-tool clients identify their agent. */ +export function mintAgentRuntimeIdentityToken(params: { + agentId: string; + sessionKey: string; +}): string { + const payload = encodePayload({ + kind: AGENT_RUNTIME_IDENTITY_TOKEN_KIND, + agentId: normalizeAgentId(params.agentId), + sessionKey: params.sessionKey.trim(), + }); + const signature = signPayload(requireSharedAgentRuntimeIdentitySecret(), payload); + return `${payload}.${signature}`; +} + +/** Validate a presented agent runtime token and return the internal caller identity. */ +export function verifyAgentRuntimeIdentityToken( + value: string | null | undefined, +): AgentRuntimeIdentity | undefined { + const token = value?.trim(); + if (!token) { + return undefined; + } + const [payloadPart, signature, ...extra] = token.split("."); + if (!payloadPart || !signature || extra.length > 0) { + return undefined; + } + const payload = decodePayload(payloadPart); + if (!payload) { + return undefined; + } + const sharedSecret = readSharedAgentRuntimeIdentitySecret(); + if (!sharedSecret || !signatureMatches(signature, signPayload(sharedSecret, payloadPart))) { + return undefined; + } + return { + kind: "agentRuntime", + agentId: payload.agentId, + sessionKey: payload.sessionKey, + }; +} diff --git a/src/gateway/call.test.ts b/src/gateway/call.test.ts index 69b7c8e99c0..3998d17d008 100644 --- a/src/gateway/call.test.ts +++ b/src/gateway/call.test.ts @@ -72,6 +72,7 @@ let lastClientOptions: { clientDisplayName?: string; mode?: string; approvalRuntimeToken?: string; + agentRuntimeIdentityToken?: string; scopes?: string[]; deviceIdentity?: unknown; onHelloOk?: (hello: { features?: { methods?: string[] } }) => void | Promise; @@ -170,6 +171,7 @@ vi.mock("./client.js", () => ({ clientDisplayName?: string; mode?: string; approvalRuntimeToken?: string; + agentRuntimeIdentityToken?: string; scopes?: string[]; onHelloOk?: (hello: { features?: { methods?: string[] } }) => void | Promise; onClose?: (code: number, reason: string, info?: StubGatewayClientCloseInfo) => void; @@ -222,6 +224,8 @@ class StubGatewayClient { clientName?: string; clientDisplayName?: string; mode?: string; + approvalRuntimeToken?: string; + agentRuntimeIdentityToken?: string; scopes?: string[]; onHelloOk?: (hello: { features?: { methods?: string[] } }) => void | Promise; onClose?: (code: number, reason: string, info?: StubGatewayClientCloseInfo) => void; @@ -1512,6 +1516,27 @@ describe("callGateway error details", () => { expect(lastRequestOptions).toBeNull(); }); + it("surfaces agent runtime identity connect request errors", async () => { + startMode = "connect-error"; + connectError = new Error( + "gateway rejected required agent runtime identity auth field; refusing to retry without it", + ); + setLocalLoopbackGatewayConfig(); + + await expect( + callGateway({ + method: "cron.remove", + token: "explicit-token", + agentRuntimeIdentityToken: "identity-token", + }), + ).rejects.toThrow( + "gateway rejected required agent runtime identity auth field; refusing to retry without it", + ); + + expect(lastClientOptions?.agentRuntimeIdentityToken).toBe("identity-token"); + expect(lastRequestOptions).toBeNull(); + }); + it("surfaces stored device auth handshake failures for credential fallback", async () => { startMode = "connect-error"; connectError = Object.assign(new Error("unauthorized: device token mismatch"), { diff --git a/src/gateway/call.ts b/src/gateway/call.ts index 8c20b012a72..3bb635b5939 100644 --- a/src/gateway/call.ts +++ b/src/gateway/call.ts @@ -85,6 +85,7 @@ type CallGatewayBaseOptions = { platform?: string; mode?: GatewayClientMode; approvalRuntimeToken?: string; + agentRuntimeIdentityToken?: string; useStoredDeviceAuth?: boolean; requiredStoredDeviceAuthScopes?: OperatorScope[]; requireLocalBackendSharedAuth?: boolean; @@ -882,6 +883,12 @@ function ensureGatewaySupportsRequiredMethods(params: { } } +function isRequiredAgentRuntimeIdentityConnectError(err: Error): boolean { + return err.message.includes( + "gateway rejected required agent runtime identity auth field; refusing to retry without it", + ); +} + async function executeGatewayRequestWithScopes(params: { opts: CallGatewayBaseOptions; scopes: OperatorScope[] | undefined; @@ -989,6 +996,9 @@ async function executeGatewayRequestWithScopes(params: { platform: opts.platform, mode: opts.mode ?? GATEWAY_CLIENT_MODES.CLI, ...(opts.approvalRuntimeToken ? { approvalRuntimeToken: opts.approvalRuntimeToken } : {}), + ...(opts.agentRuntimeIdentityToken + ? { agentRuntimeIdentityToken: opts.agentRuntimeIdentityToken } + : {}), role: "operator", ...(Array.isArray(scopes) ? { scopes } : {}), deviceIdentity, @@ -1044,8 +1054,12 @@ async function executeGatewayRequestWithScopes(params: { }, onConnectError: (err) => { const isGatewayClientRequestError = err.name === "GatewayClientRequestError"; + const isAgentRuntimeIdentityConnectError = + Boolean(opts.agentRuntimeIdentityToken) && + isRequiredAgentRuntimeIdentityConnectError(err); const shouldSurface = isGatewayConnectAssemblyError(err) || + isAgentRuntimeIdentityConnectError || (surfaceGatewayClientRequestErrors && isGatewayClientRequestError); if (settled || !shouldSurface) { return; @@ -1201,7 +1215,9 @@ async function callGatewayWithScopes>( connectionDetails, deviceIdentity, surfaceGatewayClientRequestErrors: - useStoredDeviceAuth || opts.requireLocalBackendSharedAuth === true, + useStoredDeviceAuth || + opts.requireLocalBackendSharedAuth === true || + Boolean(opts.agentRuntimeIdentityToken), }); } diff --git a/src/gateway/client.test.ts b/src/gateway/client.test.ts index 25cf4ff4f4c..22d554f6606 100644 --- a/src/gateway/client.test.ts +++ b/src/gateway/client.test.ts @@ -1174,6 +1174,7 @@ describe("GatewayClient connect auth payload", () => { deviceToken?: string; password?: string; approvalRuntimeToken?: string; + agentRuntimeIdentityToken?: string; }; }; }; @@ -1470,6 +1471,44 @@ describe("GatewayClient connect auth payload", () => { client.stop(); }); + it("fails closed when a gateway rejects the required agent runtime identity auth field", async () => { + const onConnectError = vi.fn(); + const client = new GatewayClient({ + url: "ws://127.0.0.1:18789", + token: "shared-token", + agentRuntimeIdentityToken: "identity-token", + deviceIdentity: null, + onConnectError, + }); + + const { ws, connect } = startClientAndConnect({ client }); + expectRecordFields( + connect.params?.auth ?? {}, + { + token: "shared-token", + agentRuntimeIdentityToken: "identity-token", + }, + "initial connect auth", + ); + + await expectNoReconnectAfterConnectFailure({ + client, + firstWs: ws, + connectId: connect.id, + failureDetails: {}, + failureMessage: + "invalid connect params: at /auth: unexpected property 'agentRuntimeIdentityToken'", + }); + const error = firstMockArg(onConnectError, "connect error") as Error; + expect(error.message).toBe( + "gateway rejected required agent runtime identity auth field; refusing to retry without it", + ); + expect(ws.lastClose).toEqual({ code: 1008, reason: "connect failed" }); + expect(logErrorMock).toHaveBeenCalledWith( + "gateway connect failed: gateway rejected required agent runtime identity auth field; refusing to retry without it", + ); + }); + it("waits for socket open before sending connect after an early challenge", () => { const client = new GatewayClient({ url: "ws://127.0.0.1:18789", diff --git a/src/gateway/client.ts b/src/gateway/client.ts index 05465341a95..555311fcc2f 100644 --- a/src/gateway/client.ts +++ b/src/gateway/client.ts @@ -131,6 +131,7 @@ export type GatewayClientOptions = { deviceToken?: string; password?: string; approvalRuntimeToken?: string; + agentRuntimeIdentityToken?: string; instanceId?: string; clientName?: GatewayClientName; clientDisplayName?: string; diff --git a/src/gateway/server-methods/cron.ts b/src/gateway/server-methods/cron.ts index 26a9e7a0197..8c9ef4934d5 100644 --- a/src/gateway/server-methods/cron.ts +++ b/src/gateway/server-methods/cron.ts @@ -23,6 +23,10 @@ import { readCronRunLogEntriesPageAll, } from "../../cron/run-log.js"; import { applyJobPatch } from "../../cron/service/jobs.js"; +import type { + CronListPageOptions, + CronListPageResult, +} from "../../cron/service/list-page-types.js"; import { isInvalidCronSessionTargetIdError } from "../../cron/session-target.js"; import type { CronDelivery, CronJob, CronJobCreate, CronJobPatch } from "../../cron/types.js"; import { validateScheduleTimestamp } from "../../cron/validate-timestamp.js"; @@ -32,13 +36,22 @@ import { resolveTargetPrefixedChannel, validateTargetProviderPrefix, } from "../../infra/outbound/channel-target-prefix.js"; -import { isSubagentSessionKey } from "../../routing/session-key.js"; +import { + DEFAULT_AGENT_ID, + isSubagentSessionKey, + normalizeAgentId, +} from "../../routing/session-key.js"; import { parseAgentSessionKey } from "../../sessions/session-key-utils.js"; import { isDeliverableMessageChannel, normalizeMessageChannel, } from "../../utils/message-channel.js"; -import type { GatewayRequestHandlers, RespondFn } from "./types.js"; +import type { GatewayClient, GatewayRequestHandlers, RespondFn } from "./types.js"; + +type CronCallerScope = { + kind: "agentTool"; + agentId: string; +}; type CronJobIdParams = { id?: string; jobId?: string }; @@ -55,6 +68,13 @@ type CronRunsRequestParams = CronJobIdParams & { sortDir?: "asc" | "desc"; }; +type CronListCallerScopeContext = { + cron: { + getDefaultAgentId(): string | undefined; + listPage(opts?: CronListPageOptions): Promise; + }; +}; + function compactCronListJob(job: CronJob) { return { id: job.id, @@ -66,6 +86,171 @@ function compactCronListJob(job: CronJob) { }; } +function readCronCallerScope( + client: GatewayClient | null | undefined, +): CronCallerScope | undefined { + const identity = client?.internal?.agentRuntimeIdentity; + if (!identity?.agentId) { + return undefined; + } + return { kind: "agentTool", agentId: normalizeAgentId(identity.agentId) }; +} + +function resolveCronJobEffectiveAgentId(job: CronJob, defaultAgentId?: string): string { + return normalizeAgentId(job.agentId ?? defaultAgentId ?? DEFAULT_AGENT_ID); +} + +function parseAgentIdFromSessionRef(value: string | undefined | null): string | undefined { + const trimmed = value?.trim(); + if (!trimmed) { + return undefined; + } + return parseAgentSessionKey(trimmed)?.agentId; +} + +function parseAgentIdFromCronSessionTarget(value: string | undefined | null): string | undefined { + const trimmed = value?.trim(); + if (!trimmed?.startsWith("session:")) { + return undefined; + } + return parseAgentIdFromSessionRef(trimmed.slice("session:".length)); +} + +function cronJobSessionRefsMatchCaller(job: CronJob, callerScope: CronCallerScope): boolean { + const sessionAgentId = parseAgentIdFromSessionRef(job.sessionKey); + if (sessionAgentId && normalizeAgentId(sessionAgentId) !== callerScope.agentId) { + return false; + } + const sessionTargetAgentId = parseAgentIdFromCronSessionTarget(job.sessionTarget); + return !sessionTargetAgentId || normalizeAgentId(sessionTargetAgentId) === callerScope.agentId; +} + +function cronJobMatchesCallerScope(params: { + job: CronJob; + callerScope: CronCallerScope | undefined; + defaultAgentId?: string; +}): boolean { + if (!params.callerScope) { + return true; + } + if ( + resolveCronJobEffectiveAgentId(params.job, params.defaultAgentId) !== params.callerScope.agentId + ) { + return false; + } + return cronJobSessionRefsMatchCaller(params.job, params.callerScope); +} + +function cronCreateMatchesCallerScope(params: { + job: CronJobCreate; + callerScope: CronCallerScope | undefined; + defaultAgentId?: string; +}): boolean { + if (!params.callerScope) { + return true; + } + const effectiveAgentId = normalizeAgentId( + params.job.agentId ?? params.defaultAgentId ?? DEFAULT_AGENT_ID, + ); + if (effectiveAgentId !== params.callerScope.agentId) { + return false; + } + const sessionAgentId = parseAgentIdFromSessionRef(params.job.sessionKey); + if (sessionAgentId && normalizeAgentId(sessionAgentId) !== params.callerScope.agentId) { + return false; + } + const sessionTargetAgentId = parseAgentIdFromCronSessionTarget(params.job.sessionTarget); + return ( + !sessionTargetAgentId || normalizeAgentId(sessionTargetAgentId) === params.callerScope.agentId + ); +} + +function applyCronCreateCallerScopeDefault( + job: CronJobCreate, + callerScope: CronCallerScope | undefined, +): CronJobCreate { + if (!callerScope || "agentId" in job) { + return job; + } + return { + ...job, + agentId: callerScope.agentId, + }; +} + +function cronPatchSessionRefsMatchCaller( + patch: CronJobPatch, + callerScope: CronCallerScope | undefined, +): boolean { + if (!callerScope) { + return true; + } + const sessionAgentId = + "sessionKey" in patch && typeof patch.sessionKey === "string" + ? parseAgentIdFromSessionRef(patch.sessionKey) + : undefined; + if (sessionAgentId && normalizeAgentId(sessionAgentId) !== callerScope.agentId) { + return false; + } + const sessionTargetAgentId = + "sessionTarget" in patch && typeof patch.sessionTarget === "string" + ? parseAgentIdFromCronSessionTarget(patch.sessionTarget) + : undefined; + return !sessionTargetAgentId || normalizeAgentId(sessionTargetAgentId) === callerScope.agentId; +} + +async function listCronPageForCallerScope({ + callerScope, + context, + options, +}: { + callerScope: CronCallerScope; + context: CronListCallerScopeContext; + options: CronListPageOptions; +}): Promise { + const scopedJobs: CronJob[] = []; + let offset = 0; + + for (;;) { + const sourcePage = await context.cron.listPage({ + ...options, + agentId: callerScope.agentId, + limit: 200, + offset, + }); + + scopedJobs.push( + ...sourcePage.jobs.filter((job) => + cronJobMatchesCallerScope({ + job, + callerScope, + defaultAgentId: context.cron.getDefaultAgentId(), + }), + ), + ); + + if (!sourcePage.hasMore || sourcePage.nextOffset === null || sourcePage.nextOffset <= offset) { + break; + } + offset = sourcePage.nextOffset; + } + + const total = scopedJobs.length; + const pageOffset = Math.max(0, Math.min(total, Math.floor(options.offset ?? 0))); + const defaultLimit = total === 0 ? 50 : total; + const limit = Math.max(1, Math.min(200, Math.floor(options.limit ?? defaultLimit))); + const jobs = scopedJobs.slice(pageOffset, pageOffset + limit); + const nextOffset = pageOffset + jobs.length; + return { + jobs, + total, + offset: pageOffset, + limit, + hasMore: nextOffset < total, + nextOffset: nextOffset < total ? nextOffset : null, + }; +} + async function listConfiguredAnnounceChannelIds(cfg: OpenClawConfig): Promise { return await listConfiguredMessageChannels(cfg); } @@ -350,7 +535,7 @@ export const cronHandlers: GatewayRequestHandlers = { }); respond(true, result, undefined); }, - "cron.list": async ({ params, respond, context }) => { + "cron.list": async ({ params, respond, context, client }) => { if (!validateCronListParams(params)) { respond( false, @@ -375,7 +560,13 @@ export const cronHandlers: GatewayRequestHandlers = { agentId?: string; compact?: boolean; }; - const page = await context.cron.listPage({ + const callerScope = readCronCallerScope(client); + const requestedAgentId = p.agentId ? normalizeAgentId(p.agentId) : undefined; + if (callerScope && requestedAgentId && requestedAgentId !== callerScope.agentId) { + respondInvalidCronParams(respond, "cron.list", "agentId outside caller scope"); + return; + } + const listOptions = { includeDisabled: p.includeDisabled, limit: p.limit, offset: p.offset, @@ -385,8 +576,15 @@ export const cronHandlers: GatewayRequestHandlers = { lastRunStatus: p.lastRunStatus, sortBy: p.sortBy, sortDir: p.sortDir, - agentId: p.agentId, - }); + agentId: callerScope?.agentId ?? p.agentId, + }; + const page = callerScope + ? await listCronPageForCallerScope({ + callerScope, + context, + options: listOptions, + }) + : await context.cron.listPage(listOptions); if (p.compact === true) { respond(true, { ...page, jobs: page.jobs.map(compactCronListJob) }, undefined); return; @@ -413,7 +611,7 @@ export const cronHandlers: GatewayRequestHandlers = { const status = await context.cron.status(); respond(true, status, undefined); }, - "cron.get": async ({ params, respond, context }) => { + "cron.get": async ({ params, respond, context, client }) => { if (!validateCronGetParams(params)) { respondInvalidCronParams( respond, @@ -427,8 +625,16 @@ export const cronHandlers: GatewayRequestHandlers = { respondMissingCronJobId(respond, "cron.get"); return; } + const callerScope = readCronCallerScope(client); const job = await context.cron.readJob(jobId); - if (!job) { + if ( + !job || + !cronJobMatchesCallerScope({ + job, + callerScope, + defaultAgentId: context.cron.getDefaultAgentId(), + }) + ) { respond( false, undefined, @@ -438,7 +644,7 @@ export const cronHandlers: GatewayRequestHandlers = { } respond(true, job, undefined); }, - "cron.add": async ({ params, respond, context }) => { + "cron.add": async ({ params, respond, context, client }) => { const sessionKey = typeof (params as { sessionKey?: unknown } | null)?.sessionKey === "string" ? (params as { sessionKey: string }).sessionKey @@ -461,7 +667,8 @@ export const cronHandlers: GatewayRequestHandlers = { ); return; } - if (!validateCronAddParams(normalized)) { + const candidate = normalized; + if (!validateCronAddParams(candidate)) { respond( false, undefined, @@ -472,8 +679,19 @@ export const cronHandlers: GatewayRequestHandlers = { ); return; } - const jobCreate = normalized as unknown as CronJobCreate; + const callerScope = readCronCallerScope(client); + const jobCreate = applyCronCreateCallerScopeDefault(candidate as CronJobCreate, callerScope); const cfg = context.getRuntimeConfig(); + if ( + !cronCreateMatchesCallerScope({ + job: jobCreate, + callerScope, + defaultAgentId: context.cron.getDefaultAgentId(), + }) + ) { + respondInvalidCronParams(respond, "cron.add", "job agentId outside caller scope"); + return; + } const timestampValidation = validateScheduleTimestamp(jobCreate.schedule); if (!timestampValidation.ok) { respond( @@ -520,7 +738,7 @@ export const cronHandlers: GatewayRequestHandlers = { context.logGateway.info("cron: job created", { jobId: job.id, schedule: jobCreate.schedule }); respond(true, job, undefined); }, - "cron.update": async ({ params, respond, context }) => { + "cron.update": async ({ params, respond, context, client }) => { let normalizedPatch: ReturnType; try { const rawPatch = (params as { patch?: unknown } | null)?.patch; @@ -561,6 +779,7 @@ export const cronHandlers: GatewayRequestHandlers = { jobId?: string; patch: Record; }; + const callerScope = readCronCallerScope(client); const jobId = p.id ?? p.jobId; if (!jobId) { respond( @@ -573,10 +792,25 @@ export const cronHandlers: GatewayRequestHandlers = { const patch = p.patch as unknown as CronJobPatch; const cfg = context.getRuntimeConfig(); const currentJob = await context.cron.readJob(jobId); - if (!currentJob) { + if ( + !currentJob || + !cronJobMatchesCallerScope({ + job: currentJob, + callerScope, + defaultAgentId: context.cron.getDefaultAgentId(), + }) + ) { respondInvalidCronParams(respond, "cron.update", "id not found"); return; } + if (callerScope && "agentId" in patch) { + respondInvalidCronParams(respond, "cron.update", "agentId cannot be changed by caller scope"); + return; + } + if (!cronPatchSessionRefsMatchCaller(patch, callerScope)) { + respondInvalidCronParams(respond, "cron.update", "session target outside caller scope"); + return; + } if (patch.schedule) { const timestampValidation = validateScheduleTimestamp(patch.schedule); if (!timestampValidation.ok) { @@ -630,7 +864,7 @@ export const cronHandlers: GatewayRequestHandlers = { context.logGateway.info("cron: job updated", { jobId }); respond(true, job, undefined); }, - "cron.remove": async ({ params, respond, context }) => { + "cron.remove": async ({ params, respond, context, client }) => { if (!validateCronRemoveParams(params)) { respondInvalidCronParams( respond, @@ -644,6 +878,23 @@ export const cronHandlers: GatewayRequestHandlers = { respondMissingCronJobId(respond, "cron.remove"); return; } + const callerScope = readCronCallerScope(client); + const job = await context.cron.readJob(jobId); + if ( + !job || + !cronJobMatchesCallerScope({ + job, + callerScope, + defaultAgentId: context.cron.getDefaultAgentId(), + }) + ) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, "invalid cron.remove params: id not found"), + ); + return; + } const result = await context.cron.remove(jobId); if (!result.removed) { respond( @@ -656,7 +907,7 @@ export const cronHandlers: GatewayRequestHandlers = { context.logGateway.info("cron: job removed", { jobId }); respond(true, result, undefined); }, - "cron.run": async ({ params, respond, context }) => { + "cron.run": async ({ params, respond, context, client }) => { if (!validateCronRunParams(params)) { respondInvalidCronParams( respond, @@ -666,11 +917,24 @@ export const cronHandlers: GatewayRequestHandlers = { return; } const p = params as CronJobIdParams & { mode?: "due" | "force" }; + const callerScope = readCronCallerScope(client); const jobId = resolveCronJobId(p); if (!jobId) { respondMissingCronJobId(respond, "cron.run"); return; } + const job = await context.cron.readJob(jobId); + if ( + !job || + !cronJobMatchesCallerScope({ + job, + callerScope, + defaultAgentId: context.cron.getDefaultAgentId(), + }) + ) { + respondInvalidCronParams(respond, "cron.run", "id not found"); + return; + } let result: Awaited>; try { result = await context.cron.enqueueRun(jobId, p.mode ?? "force"); @@ -687,7 +951,7 @@ export const cronHandlers: GatewayRequestHandlers = { } respond(true, result, undefined); }, - "cron.runs": async ({ params, respond, context }) => { + "cron.runs": async ({ params, respond, context, client }) => { if (!validateCronRunsParams(params)) { respondInvalidCronParams( respond, @@ -697,6 +961,7 @@ export const cronHandlers: GatewayRequestHandlers = { return; } const p = params as CronRunsRequestParams; + const callerScope = readCronCallerScope(client); const explicitScope = p.scope; const jobId = resolveCronJobId(p); const scope: "job" | "all" = explicitScope ?? (jobId ? "job" : "all"); @@ -705,6 +970,10 @@ export const cronHandlers: GatewayRequestHandlers = { return; } if (scope === "all") { + if (callerScope) { + respondInvalidCronParams(respond, "cron.runs", "scope all is not allowed by caller scope"); + return; + } const jobs = await context.cron.list({ includeDisabled: true }); const jobNameById = Object.fromEntries( jobs @@ -721,7 +990,19 @@ export const cronHandlers: GatewayRequestHandlers = { } try { const jobs = await context.cron.list({ includeDisabled: true }); - const matchedJob = jobs.find((job) => job.id === jobId); + const matchedJob = jobs.find( + (job) => + job.id === jobId && + cronJobMatchesCallerScope({ + job, + callerScope, + defaultAgentId: context.cron.getDefaultAgentId(), + }), + ); + if (callerScope && !matchedJob) { + respondInvalidCronParams(respond, "cron.runs", "id not found"); + return; + } const jobNameById = matchedJob && typeof matchedJob.name === "string" ? { [jobId as string]: matchedJob.name } diff --git a/src/gateway/server-methods/cron.validation.test.ts b/src/gateway/server-methods/cron.validation.test.ts index 6a11979172e..6498b015d45 100644 --- a/src/gateway/server-methods/cron.validation.test.ts +++ b/src/gateway/server-methods/cron.validation.test.ts @@ -9,6 +9,7 @@ import { createChannelTestPluginBase, createTestRegistry, } from "../../test-utils/channel-plugins.js"; +import type { GatewayClient } from "./types.js"; const getRuntimeConfig = vi.hoisted(() => vi.fn<() => OpenClawConfig>(() => ({}) as OpenClawConfig), @@ -80,7 +81,8 @@ function setCronValidationTestRegistry(): void { ); } -function createCronContext(currentJob?: CronJob) { +function createCronContext(currentJobs?: CronJob | CronJob[]) { + const jobs = currentJobs ? (Array.isArray(currentJobs) ? currentJobs : [currentJobs]) : []; return { cron: { add: vi.fn(async () => ({ id: "cron-1" })), @@ -88,9 +90,30 @@ function createCronContext(currentJob?: CronJob) { remove: vi.fn(async () => ({ ok: true, removed: true })), enqueueRun: vi.fn(async () => ({ ok: true, enqueued: true, runId: "run-1" })), getDefaultAgentId: vi.fn(() => "main"), - getJob: vi.fn(() => currentJob), + getJob: vi.fn((id: string) => jobs.find((job) => job.id === id)), wake: vi.fn(() => ({ ok: true }) as const), - readJob: vi.fn(async (id: string) => (id === currentJob?.id ? currentJob : undefined)), + readJob: vi.fn(async (id: string) => jobs.find((job) => job.id === id)), + list: vi.fn(async () => jobs), + listPage: vi.fn(async (opts?: { agentId?: string; limit?: number; offset?: number }) => { + const requestedAgentId = opts?.agentId?.trim().toLowerCase(); + const filteredJobs = requestedAgentId + ? jobs.filter((job) => (job.agentId ?? "main").trim().toLowerCase() === requestedAgentId) + : jobs; + const total = filteredJobs.length; + const offset = Math.max(0, Math.min(total, Math.floor(opts?.offset ?? 0))); + const defaultLimit = total === 0 ? 50 : total; + const limit = Math.max(1, Math.min(200, Math.floor(opts?.limit ?? defaultLimit))); + const pageJobs = filteredJobs.slice(offset, offset + limit); + const nextOffset = offset + pageJobs.length; + return { + jobs: pageJobs, + total, + offset, + limit, + hasMore: nextOffset < total, + nextOffset: nextOffset < total ? nextOffset : null, + }; + }), }, logGateway: { info: vi.fn(), @@ -104,7 +127,11 @@ type CronMethod = keyof typeof cronHandlers; async function invokeCron( method: CronMethod, params: Record, - options: { currentJob?: CronJob; context?: ReturnType } = {}, + options: { + currentJob?: CronJob; + context?: ReturnType; + client?: GatewayClient; + } = {}, ) { const context = options.context ?? createCronContext(options.currentJob); const respond = vi.fn(); @@ -113,22 +140,33 @@ async function invokeCron( params: params as never, respond: respond as never, context: context as never, - client: null, + client: options.client ?? null, isWebchatConnect: () => false, }); return { context, respond }; } -async function invokeCronAdd(params: Record) { - return await invokeCron("cron.add", params); +async function invokeCronAdd( + params: Record, + options?: { client?: GatewayClient }, +) { + return await invokeCron("cron.add", params, options); } -async function invokeCronGet(params: Record, currentJob?: CronJob) { - return await invokeCron("cron.get", params, { currentJob }); +async function invokeCronGet( + params: Record, + currentJob?: CronJob, + options?: { client?: GatewayClient }, +) { + return await invokeCron("cron.get", params, { currentJob, ...options }); } -async function invokeCronUpdate(params: Record, currentJob?: CronJob) { - return await invokeCron("cron.update", params, { currentJob }); +async function invokeCronUpdate( + params: Record, + currentJob?: CronJob, + options?: { client?: GatewayClient }, +) { + return await invokeCron("cron.update", params, { currentJob, ...options }); } async function invokeCronUpdateDelivery( @@ -146,13 +184,13 @@ async function invokeCronUpdateDelivery( async function invokeCronRemove( params: Record, - options?: { removeResult?: { ok: boolean; removed: boolean } }, + options?: { removeResult?: { ok: boolean; removed: boolean }; client?: GatewayClient }, ) { const context = createCronContext(); if (options?.removeResult) { context.cron.remove.mockResolvedValueOnce(options.removeResult); } - return await invokeCron("cron.remove", params, { context }); + return await invokeCron("cron.remove", params, { context, client: options?.client }); } async function invokeWake(params: Record) { @@ -176,6 +214,19 @@ function createCronJob(overrides: Partial = {}): CronJob { }; } +function callerClient(agentId: string): GatewayClient { + return { + connect: {} as GatewayClient["connect"], + internal: { + agentRuntimeIdentity: { + kind: "agentRuntime", + agentId, + sessionKey: `agent:${agentId}:main`, + }, + }, + }; +} + function telegramDeliveryWithSlackFailure(overrides: Partial = {}): CronDelivery { return { mode: "announce", @@ -392,6 +443,35 @@ describe("cron method validation", () => { }); }); + it("allows caller-scoped cron.remove for the same agent", async () => { + const context = createCronContext(createCronJob({ id: "cron-1", agentId: "ops" })); + + const { respond } = await invokeCron( + "cron.remove", + { id: "cron-1" }, + { context, client: callerClient("ops") }, + ); + + expect(context.cron.remove).toHaveBeenCalledWith("cron-1"); + expect(respond).toHaveBeenCalledWith(true, { ok: true, removed: true }, undefined); + }); + + it("hides caller-scoped cron.remove for a foreign agent", async () => { + const context = createCronContext(createCronJob({ id: "cron-1", agentId: "worker" })); + + const { respond } = await invokeCron( + "cron.remove", + { jobId: "cron-1" }, + { context, client: callerClient("ops") }, + ); + + expect(context.cron.remove).not.toHaveBeenCalled(); + expectResponseError(respond, { + code: "INVALID_REQUEST", + messageIncludes: "invalid cron.remove params: id not found", + }); + }); + it("returns a single cron job for cron.get", async () => { const job = createCronJob({ id: "cron-42", name: "single job" }); @@ -401,6 +481,46 @@ describe("cron method validation", () => { expect(respond).toHaveBeenCalledWith(true, job, undefined); }); + it("allows caller-scoped cron.get for the same agent", async () => { + const job = createCronJob({ id: "cron-42", agentId: "ops" }); + + const { respond } = await invokeCronGet({ id: "cron-42" }, job, { + client: callerClient("ops"), + }); + + expect(respond).toHaveBeenCalledWith(true, job, undefined); + }); + + it("hides caller-scoped cron.get for a foreign agent", async () => { + const job = createCronJob({ id: "cron-42", agentId: "ops" }); + + const { respond } = await invokeCronGet({ jobId: "cron-42" }, job, { + client: callerClient("worker"), + }); + + expectResponseError(respond, { + code: "INVALID_REQUEST", + messageIncludes: "cron job not found: cron-42", + }); + }); + + it("hides caller-scoped cron.get when stored sessionTarget points at a foreign agent", async () => { + const job = createCronJob({ + id: "cron-42", + agentId: "ops", + sessionTarget: "session:agent:worker:telegram:direct:alice", + }); + + const { respond } = await invokeCronGet({ id: "cron-42" }, job, { + client: callerClient("ops"), + }); + + expectResponseError(respond, { + code: "INVALID_REQUEST", + messageIncludes: "cron job not found: cron-42", + }); + }); + it("returns INVALID_REQUEST when cron.get cannot find the job", async () => { const { respond } = await invokeCronGet({ jobId: "missing" }); @@ -410,6 +530,150 @@ describe("cron method validation", () => { }); }); + it("scopes cron.list to the caller agent", async () => { + const context = createCronContext(createCronJob({ agentId: "ops" })); + + const { respond } = await invokeCron( + "cron.list", + { includeDisabled: true, compact: true }, + { context, client: callerClient("ops") }, + ); + + expect(context.cron.listPage).toHaveBeenCalledWith( + expect.objectContaining({ includeDisabled: true, agentId: "ops" }), + ); + expect(respond).toHaveBeenCalledWith( + true, + expect.objectContaining({ total: 1, jobs: expect.any(Array) }), + undefined, + ); + }); + + it("rejects caller-scoped cron.list for a foreign explicit agentId", async () => { + const context = createCronContext(createCronJob({ agentId: "ops" })); + + const { respond } = await invokeCron( + "cron.list", + { agentId: "worker" }, + { context, client: callerClient("ops") }, + ); + + expect(context.cron.listPage).not.toHaveBeenCalled(); + expectResponseError(respond, { + code: "INVALID_REQUEST", + messageIncludes: "agentId outside caller scope", + }); + }); + + it("keeps unscoped cron.list agentId filtering global for operator callers", async () => { + const context = createCronContext(createCronJob({ agentId: "worker" })); + + const { respond } = await invokeCron("cron.list", { agentId: "worker" }, { context }); + + expect(context.cron.listPage).toHaveBeenCalledWith( + expect.objectContaining({ agentId: "worker" }), + ); + expect(respond).toHaveBeenCalledWith( + true, + expect.objectContaining({ total: 1, jobs: expect.any(Array) }), + undefined, + ); + }); + + it("filters caller-scoped cron.list jobs with foreign session targets before pagination", async () => { + const foreignSessionJob = createCronJob({ + id: "cron-foreign", + agentId: "ops", + sessionTarget: "session:agent:worker:telegram:direct:alice", + }); + const firstSafeJob = createCronJob({ + id: "cron-safe-1", + agentId: "ops", + sessionTarget: "session:agent:ops:telegram:direct:bob", + }); + const secondSafeJob = createCronJob({ + id: "cron-safe-2", + agentId: "ops", + }); + const context = createCronContext([foreignSessionJob, firstSafeJob, secondSafeJob]); + + const { respond } = await invokeCron( + "cron.list", + { compact: true, limit: 1 }, + { context, client: callerClient("ops") }, + ); + + expect(context.cron.listPage).toHaveBeenCalledWith(expect.objectContaining({ agentId: "ops" })); + expect(respond).toHaveBeenCalledWith( + true, + expect.objectContaining({ + total: 2, + offset: 0, + limit: 1, + hasMore: true, + nextOffset: 1, + jobs: [expect.objectContaining({ id: "cron-safe-1" })], + }), + undefined, + ); + }); + + it("allows internally scoped cron.add for the same agent without persisting caller identity", async () => { + const { context, respond } = await invokeCronAdd( + agentTurnCronParams({ + agentId: "ops", + }), + { client: callerClient("ops") }, + ); + + const payload = requireCronAddPayload(context); + expect(payload.agentId).toBe("ops"); + expect(payload).not.toHaveProperty("callerScope"); + expectCronSuccess(respond); + }); + + it("defaults scoped cron.add ownership to the trusted caller when agentId is omitted", async () => { + const { context, respond } = await invokeCronAdd(agentTurnCronParams(), { + client: callerClient("ops"), + }); + + const payload = requireCronAddPayload(context); + expect(payload.agentId).toBe("ops"); + expect(payload).not.toHaveProperty("callerScope"); + expectCronSuccess(respond); + }); + + it("rejects caller-scoped cron.add for a foreign agent", async () => { + const { context, respond } = await invokeCronAdd( + agentTurnCronParams({ + agentId: "worker", + }), + { client: callerClient("ops") }, + ); + + expect(context.cron.add).not.toHaveBeenCalled(); + expectResponseError(respond, { + code: "INVALID_REQUEST", + messageIncludes: "outside caller scope", + }); + }); + + it("rejects caller-scoped cron.add with a foreign agent-prefixed session target", async () => { + const { context, respond } = await invokeCronAdd( + agentTurnCronParams({ + agentId: "ops", + sessionTarget: "session:agent:worker:telegram:direct:alice", + }), + { client: callerClient("ops") }, + ); + + expect(context.cron.add).not.toHaveBeenCalled(); + expectResponseError(respond, { + code: "INVALID_REQUEST", + messageIncludes: "outside caller scope", + }); + }); + it("accepts threadId on announce delivery update params", async () => { setRuntimeConfig(telegramConfig()); @@ -440,6 +704,84 @@ describe("cron method validation", () => { expectCronSuccess(respond); }); + it("allows caller-scoped cron.update for the same agent", async () => { + const { context, respond } = await invokeCronUpdate( + { + id: "cron-1", + patch: { enabled: false }, + }, + createCronJob({ agentId: "ops" }), + { client: callerClient("ops") }, + ); + + expect(context.cron.update).toHaveBeenCalledWith("cron-1", { enabled: false }); + expectCronSuccess(respond); + }); + + it("hides caller-scoped cron.update for a foreign agent", async () => { + const { context, respond } = await invokeCronUpdate( + { + id: "cron-1", + patch: { enabled: false }, + }, + createCronJob({ agentId: "worker" }), + { client: callerClient("ops") }, + ); + + expect(context.cron.update).not.toHaveBeenCalled(); + expectResponseError(respond, { + code: "INVALID_REQUEST", + messageIncludes: "invalid cron.update params: id not found", + }); + }); + + it("rejects caller-scoped cron.update agentId retargeting", async () => { + const { context, respond } = await invokeCronUpdate( + { + id: "cron-1", + patch: { agentId: "worker" }, + }, + createCronJob({ agentId: "ops" }), + { client: callerClient("ops") }, + ); + + expect(context.cron.update).not.toHaveBeenCalled(); + expectResponseError(respond, { + code: "INVALID_REQUEST", + messageIncludes: "agentId cannot be changed", + }); + }); + + it("rejects caller-scoped cron.update with a foreign sessionTarget", async () => { + const { context, respond } = await invokeCronUpdate( + { + id: "cron-1", + patch: { sessionTarget: "session:agent:worker:telegram:direct:alice" }, + }, + createCronJob({ agentId: "ops" }), + { client: callerClient("ops") }, + ); + + expect(context.cron.update).not.toHaveBeenCalled(); + expectResponseError(respond, { + code: "INVALID_REQUEST", + messageIncludes: "session target outside caller scope", + }); + }); + + it("keeps unscoped cron.update agentId retargeting available for operator callers", async () => { + const { context, respond } = await invokeCronUpdate( + { + id: "cron-1", + patch: { agentId: "worker" }, + }, + createCronJob({ agentId: "ops" }), + ); + + expect(context.cron.update).toHaveBeenCalledWith("cron-1", { agentId: "worker" }); + expectCronSuccess(respond); + }); + it("rejects execution-derived diagnostics in cron.update state patches", async () => { const { context, respond } = await invokeCronUpdate( { @@ -1071,9 +1413,74 @@ describe("cron method validation", () => { context.cron.enqueueRun.mockRejectedValueOnce(new Error("unknown cron job id: missing")); const { respond } = await invokeCron("cron.run", { id: "missing" }, { context }); + expect(context.cron.enqueueRun).not.toHaveBeenCalled(); expectResponseError(respond, { code: "INVALID_REQUEST", - messageIncludes: "unknown cron job id: missing", + messageIncludes: "invalid cron.run params: id not found", + }); + }); + + it("allows caller-scoped cron.run for the same agent", async () => { + const context = createCronContext(createCronJob({ id: "cron-1", agentId: "ops" })); + + const { respond } = await invokeCron( + "cron.run", + { id: "cron-1", mode: "due" }, + { context, client: callerClient("ops") }, + ); + + expect(context.cron.enqueueRun).toHaveBeenCalledWith("cron-1", "due"); + expect(respond).toHaveBeenCalledWith( + true, + { ok: true, enqueued: true, runId: "run-1" }, + undefined, + ); + }); + + it("hides caller-scoped cron.run for a foreign agent", async () => { + const context = createCronContext(createCronJob({ id: "cron-1", agentId: "worker" })); + + const { respond } = await invokeCron( + "cron.run", + { jobId: "cron-1" }, + { context, client: callerClient("ops") }, + ); + + expect(context.cron.enqueueRun).not.toHaveBeenCalled(); + expectResponseError(respond, { + code: "INVALID_REQUEST", + messageIncludes: "invalid cron.run params: id not found", + }); + }); + + it("rejects caller-scoped cron.runs all-scope history", async () => { + const context = createCronContext(createCronJob({ id: "cron-1", agentId: "ops" })); + + const { respond } = await invokeCron( + "cron.runs", + { scope: "all" }, + { context, client: callerClient("ops") }, + ); + + expect(context.cron.list).not.toHaveBeenCalled(); + expectResponseError(respond, { + code: "INVALID_REQUEST", + messageIncludes: "scope all is not allowed by caller scope", + }); + }); + + it("hides caller-scoped cron.runs for a foreign job", async () => { + const context = createCronContext(createCronJob({ id: "cron-1", agentId: "worker" })); + + const { respond } = await invokeCron( + "cron.runs", + { id: "cron-1" }, + { context, client: callerClient("ops") }, + ); + + expectResponseError(respond, { + code: "INVALID_REQUEST", + messageIncludes: "invalid cron.runs params: id not found", }); }); diff --git a/src/gateway/server-methods/shared-types.ts b/src/gateway/server-methods/shared-types.ts index 935d133fea2..8aa65378daa 100644 --- a/src/gateway/server-methods/shared-types.ts +++ b/src/gateway/server-methods/shared-types.ts @@ -13,6 +13,7 @@ import type { CronServiceContract } from "../../cron/service-contract.js"; import type { PluginApprovalRequestPayload } from "../../infra/plugin-approvals.js"; import type { createSubsystemLogger } from "../../logging/subsystem.js"; import type { WizardSession } from "../../wizard/session.js"; +import type { AgentRuntimeIdentity } from "../agent-runtime-identity-token.js"; import type { ChatAbortControllerEntry } from "../chat-abort.js"; import type { ExecApprovalManager, ExecApprovalRecord } from "../exec-approval-manager.js"; import type { GatewayMethodRegistryView } from "../methods/descriptor.js"; @@ -46,6 +47,7 @@ export type GatewayClient = { internal?: { allowModelOverride?: boolean; approvalRuntime?: boolean; + agentRuntimeIdentity?: AgentRuntimeIdentity; pluginRuntimeOwnerId?: string; agentRunTracking?: "plugin_subagent"; }; diff --git a/src/gateway/server/ws-connection/auth-context.ts b/src/gateway/server/ws-connection/auth-context.ts index 512d010b0de..6bfc29c0fd4 100644 --- a/src/gateway/server/ws-connection/auth-context.ts +++ b/src/gateway/server/ws-connection/auth-context.ts @@ -21,6 +21,7 @@ type HandshakeConnectAuth = { deviceToken?: string; password?: string; approvalRuntimeToken?: string; + agentRuntimeIdentityToken?: string; }; type DeviceTokenCandidateSource = "explicit-device-token" | "shared-token-fallback"; diff --git a/src/gateway/server/ws-connection/handshake-auth-helpers.ts b/src/gateway/server/ws-connection/handshake-auth-helpers.ts index 839f6f78f5f..79314af7cbc 100644 --- a/src/gateway/server/ws-connection/handshake-auth-helpers.ts +++ b/src/gateway/server/ws-connection/handshake-auth-helpers.ts @@ -40,6 +40,7 @@ type HandshakeConnectAuth = { deviceToken?: string; password?: string; approvalRuntimeToken?: string; + agentRuntimeIdentityToken?: string; }; function resolveBrowserOriginRateLimitKey(requestOrigin?: string): string { diff --git a/src/gateway/server/ws-connection/message-handler.post-connect-health.test.ts b/src/gateway/server/ws-connection/message-handler.post-connect-health.test.ts index 888e856a44b..d1c09a30da6 100644 --- a/src/gateway/server/ws-connection/message-handler.post-connect-health.test.ts +++ b/src/gateway/server/ws-connection/message-handler.post-connect-health.test.ts @@ -9,6 +9,7 @@ import { resetDiagnosticEventsForTest, type DiagnosticSecurityEvent, } from "../../../infra/diagnostic-events.js"; +import { mintAgentRuntimeIdentityToken } from "../../agent-runtime-identity-token.js"; import type { ResolvedGatewayAuth } from "../../auth.js"; import { getOperatorApprovalRuntimeToken } from "../../operator-approval-runtime-token.js"; import { handleGatewayRequest } from "../../server-methods.js"; @@ -697,6 +698,134 @@ describe("attachGatewayWsMessageHandler post-connect health refresh", () => { } | null; expect(connectedClient?.internal?.approvalRuntime).not.toBe(true); }); + + it("marks local backend clients with a valid agent runtime identity token", async () => { + const refreshHealthSnapshot = vi.fn(async () => + createHealthSummary(), + ); + const harness = attachGatewayHarness({ + connId: "conn-agent-runtime-token", + connectNonce: "nonce-agent-runtime-token", + refreshHealthSnapshot, + }); + + harness.sendConnect("connect-agent-runtime-token", { + minProtocol: PROTOCOL_VERSION, + maxProtocol: PROTOCOL_VERSION, + client: { + id: "gateway-client", + version: "dev", + platform: "test", + mode: "backend", + }, + role: "operator", + scopes: ["operator.write"], + caps: [], + auth: { + agentRuntimeIdentityToken: mintAgentRuntimeIdentityToken({ + agentId: "ops", + sessionKey: "agent:ops:telegram:direct:alice", + }), + }, + }); + + await vi.waitFor(() => { + expect(harness.socketSend).toHaveBeenCalled(); + }); + const connectedClient = harness.client as { + internal?: { + agentRuntimeIdentity?: { agentId?: string; sessionKey?: string }; + }; + } | null; + expect(connectedClient?.internal?.agentRuntimeIdentity).toMatchObject({ + agentId: "ops", + sessionKey: "agent:ops:telegram:direct:alice", + }); + }); + + it("rejects agent runtime identity tokens from remote clients", async () => { + const refreshHealthSnapshot = vi.fn(async () => + createHealthSummary(), + ); + const close = createCloseMock(); + const harness = attachGatewayHarness({ + connId: "conn-remote-agent-runtime-token", + connectNonce: "nonce-remote-agent-runtime-token", + requestHost: "gateway.example.com:18789", + remoteAddr: "203.0.113.50", + resolvedAuth: { + mode: "token", + token: "gateway-token", + allowTailscale: false, + }, + refreshHealthSnapshot, + close, + }); + + harness.sendConnect("connect-remote-agent-runtime-token", { + minProtocol: PROTOCOL_VERSION, + maxProtocol: PROTOCOL_VERSION, + client: { + id: "gateway-client", + version: "dev", + platform: "test", + mode: "backend", + }, + role: "operator", + scopes: ["operator.write"], + caps: [], + auth: { + token: "gateway-token", + agentRuntimeIdentityToken: mintAgentRuntimeIdentityToken({ + agentId: "ops", + sessionKey: "agent:ops:telegram:direct:alice", + }), + }, + }); + + await vi.waitFor(() => { + expect(close).toHaveBeenCalledWith( + 1008, + "agent runtime identity token is only accepted from local backend gateway clients", + ); + }); + expect(harness.client).toBeNull(); + }); + + it("rejects invalid local agent runtime identity tokens", async () => { + const refreshHealthSnapshot = vi.fn(async () => + createHealthSummary(), + ); + const close = createCloseMock(); + const harness = attachGatewayHarness({ + connId: "conn-invalid-agent-runtime-token", + connectNonce: "nonce-invalid-agent-runtime-token", + refreshHealthSnapshot, + close, + }); + + harness.sendConnect("connect-invalid-agent-runtime-token", { + minProtocol: PROTOCOL_VERSION, + maxProtocol: PROTOCOL_VERSION, + client: { + id: "gateway-client", + version: "dev", + platform: "test", + mode: "backend", + }, + role: "operator", + scopes: ["operator.write"], + caps: [], + auth: { + agentRuntimeIdentityToken: "not-a-valid-token", + }, + }); + + await vi.waitFor(() => { + expect(close).toHaveBeenCalledWith(1008, "invalid agent runtime identity token"); + }); + expect(harness.client).toBeNull(); + }); }); describe("resolvePinnedClientMetadata", () => { diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index 694ac1af75e..cab7dea36ed 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -105,6 +105,7 @@ import { isWebchatClient, } from "../../../utils/message-channel.js"; import { resolveRuntimeServiceVersion } from "../../../version.js"; +import { verifyAgentRuntimeIdentityToken } from "../../agent-runtime-identity-token.js"; import { AUTH_RATE_LIMIT_SCOPE_NODE_PAIRING, type AuthRateLimiter } from "../../auth-rate-limit.js"; import type { GatewayAuthResult, ResolvedGatewayAuth } from "../../auth.js"; import { hasForwardedRequestHeaders, isLocalDirectRequest } from "../../auth.js"; @@ -1865,6 +1866,49 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar connectParams.client.id === GATEWAY_CLIENT_IDS.GATEWAY_CLIENT && connectParams.client.mode === GATEWAY_CLIENT_MODES.BACKEND && isOperatorApprovalRuntimeToken(connectParams.auth?.approvalRuntimeToken); + const agentRuntimeIdentityToken = connectParams.auth?.agentRuntimeIdentityToken; + const canAcceptAgentRuntimeIdentity = + pairingLocality !== "remote" && + connectParams.client.id === GATEWAY_CLIENT_IDS.GATEWAY_CLIENT && + connectParams.client.mode === GATEWAY_CLIENT_MODES.BACKEND; + let trustedAgentRuntimeIdentity: + | ReturnType + | undefined; + if (typeof agentRuntimeIdentityToken === "string") { + if (!canAcceptAgentRuntimeIdentity) { + const message = + "agent runtime identity token is only accepted from local backend gateway clients"; + markHandshakeFailure("agent-runtime-identity-untrusted-client", { + client: connectParams.client.id, + mode: connectParams.client.mode, + pairingLocality, + }); + sendHandshakeErrorResponse(ErrorCodes.INVALID_REQUEST, message); + close(1008, truncateCloseReason(message)); + return; + } + trustedAgentRuntimeIdentity = verifyAgentRuntimeIdentityToken(agentRuntimeIdentityToken); + if (!trustedAgentRuntimeIdentity) { + const message = "invalid agent runtime identity token"; + markHandshakeFailure("agent-runtime-identity-invalid", { + client: connectParams.client.id, + mode: connectParams.client.mode, + pairingLocality, + }); + sendHandshakeErrorResponse(ErrorCodes.INVALID_REQUEST, message); + close(1008, message); + return; + } + } + const internal = + isTrustedApprovalRuntime || trustedAgentRuntimeIdentity + ? { + ...(isTrustedApprovalRuntime ? { approvalRuntime: true } : {}), + ...(trustedAgentRuntimeIdentity + ? { agentRuntimeIdentity: trustedAgentRuntimeIdentity } + : {}), + } + : undefined; clearHandshakeTimer(); const nextClient: GatewayWsClient = { socket, @@ -1875,7 +1919,7 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar sharedGatewaySessionGeneration: sessionSharedGatewaySessionGeneration, presenceKey, clientIp: reportedClientIp, - ...(isTrustedApprovalRuntime ? { internal: { approvalRuntime: true } } : {}), + ...(internal ? { internal } : {}), ...(Object.keys(pluginSurfaceUrls).length > 0 ? { pluginSurfaceUrls } : {}), ...(Object.keys(pluginNodeCapabilitySurfaces).length > 0 ? { pluginNodeCapabilitySurfaces } diff --git a/src/gateway/server/ws-types.ts b/src/gateway/server/ws-types.ts index 1000984f5a9..fc32bea0dcc 100644 --- a/src/gateway/server/ws-types.ts +++ b/src/gateway/server/ws-types.ts @@ -1,6 +1,7 @@ // Gateway WebSocket client types describe authenticated client state retained by the server. import type { WebSocket } from "ws"; import type { ConnectParams } from "../../../packages/gateway-protocol/src/index.js"; +import type { AgentRuntimeIdentity } from "../agent-runtime-identity-token.js"; import type { PluginNodeCapabilityClient } from "../plugin-node-capability.js"; /** @@ -17,6 +18,7 @@ export type GatewayWsClient = PluginNodeCapabilityClient & { clientIp?: string; internal?: { approvalRuntime?: boolean; + agentRuntimeIdentity?: AgentRuntimeIdentity; }; canvasHostUrl?: string; canvasCapability?: string; diff --git a/src/infra/tsdown-config.test.ts b/src/infra/tsdown-config.test.ts index f237156e571..102ff19054e 100644 --- a/src/infra/tsdown-config.test.ts +++ b/src/infra/tsdown-config.test.ts @@ -116,6 +116,8 @@ describe("tsdown config", () => { "plugins/runtime/index", "plugins/synthetic-auth.runtime", "web-fetch/runtime", + "mcp/openclaw-tools-serve", + "mcp/plugin-tools-serve", "plugin-sdk/compat", "plugin-sdk/index", bundledEntry("active-memory"), diff --git a/src/mcp/openclaw-tools-serve.test.ts b/src/mcp/openclaw-tools-serve.test.ts index 121b3ad9118..506ba452b41 100644 --- a/src/mcp/openclaw-tools-serve.test.ts +++ b/src/mcp/openclaw-tools-serve.test.ts @@ -1,13 +1,33 @@ // OpenClaw MCP tools tests cover core tool server startup and registration. import { describe, expect, it } from "vitest"; -import { resolveOpenClawToolsForMcp } from "./openclaw-tools-serve.js"; +import { + OPENCLAW_TOOLS_MCP_AGENT_SESSION_KEY_ENV, + resolveOpenClawToolsForMcp, + resolveOpenClawToolsMcpAgentSessionKey, +} from "./openclaw-tools-serve.js"; import { createPluginToolsMcpHandlers } from "./plugin-tools-handlers.js"; describe("OpenClaw tools MCP server", () => { it("exposes cron", async () => { - const handlers = createPluginToolsMcpHandlers(resolveOpenClawToolsForMcp()); + const handlers = createPluginToolsMcpHandlers( + resolveOpenClawToolsForMcp({ agentSessionKey: "agent:worker:main" }), + ); const listed = await handlers.listTools(); expect(listed.tools.map((tool) => tool.name)).toContain("cron"); }); + + it("requires the managed bridge to pass a real agent session key", () => { + expect(() => resolveOpenClawToolsForMcp({ agentSessionKey: "" })).toThrow( + OPENCLAW_TOOLS_MCP_AGENT_SESSION_KEY_ENV, + ); + }); + + it("reads the managed bridge agent session key from env", () => { + expect( + resolveOpenClawToolsMcpAgentSessionKey({ + [OPENCLAW_TOOLS_MCP_AGENT_SESSION_KEY_ENV]: " agent:worker:main ", + }), + ).toBe("agent:worker:main"); + }); }); diff --git a/src/mcp/openclaw-tools-serve.ts b/src/mcp/openclaw-tools-serve.ts index 9e22073056a..43a05197718 100644 --- a/src/mcp/openclaw-tools-serve.ts +++ b/src/mcp/openclaw-tools-serve.ts @@ -11,8 +11,26 @@ import { createCronTool } from "../agents/tools/cron-tool.js"; import { formatErrorMessage } from "../infra/errors.js"; import { connectToolsMcpServerToStdio, createToolsMcpServer } from "./tools-stdio-server.js"; -export function resolveOpenClawToolsForMcp(): AnyAgentTool[] { - return [createCronTool({ creatorToolAllowlist: [{ name: "cron" }] })]; +export const OPENCLAW_TOOLS_MCP_AGENT_SESSION_KEY_ENV = "OPENCLAW_TOOLS_MCP_AGENT_SESSION_KEY"; + +export function resolveOpenClawToolsMcpAgentSessionKey( + env: NodeJS.ProcessEnv = process.env, +): string | undefined { + return env[OPENCLAW_TOOLS_MCP_AGENT_SESSION_KEY_ENV]?.trim() || undefined; +} + +export function resolveOpenClawToolsForMcp( + params: { + agentSessionKey?: string; + } = {}, +): AnyAgentTool[] { + const agentSessionKey = ( + params.agentSessionKey ?? resolveOpenClawToolsMcpAgentSessionKey() + )?.trim(); + if (!agentSessionKey) { + throw new Error(`${OPENCLAW_TOOLS_MCP_AGENT_SESSION_KEY_ENV} is required`); + } + return [createCronTool({ agentSessionKey, creatorToolAllowlist: [{ name: "cron" }] })]; } function createOpenClawToolsMcpServer( diff --git a/test/scripts/docker-e2e-observability.test.ts b/test/scripts/docker-e2e-observability.test.ts index f9e95e256e6..ae2a9b773bb 100644 --- a/test/scripts/docker-e2e-observability.test.ts +++ b/test/scripts/docker-e2e-observability.test.ts @@ -46,7 +46,19 @@ function runSuccessTail(scriptPath: string) { } describe("Docker E2E observability", () => { - it.each(["scripts/e2e/mcp-channels-docker.sh", "scripts/e2e/cron-mcp-cleanup-docker.sh"])( + it("feeds the cron CLI Docker proof body through container stdin", () => { + const script = readFileSync("scripts/e2e/cron-cli-docker.sh", "utf8"); + + expect(script).toMatch( + /docker_e2e_run_with_harness[\s\S]*\n -i \\\n "\$IMAGE_NAME" \\\n bash -s >"\$CLIENT_LOG" 2>&1 <<'INNER'/u, + ); + }); + + it.each([ + "scripts/e2e/mcp-channels-docker.sh", + "scripts/e2e/cron-cli-docker.sh", + "scripts/e2e/cron-mcp-cleanup-docker.sh", + ])( "prints successful MCP client proof logs from %s", (scriptPath) => { const result = runSuccessTail(scriptPath); diff --git a/test/scripts/docker-e2e-plan.test.ts b/test/scripts/docker-e2e-plan.test.ts index a9adeb52fa9..5e1c86b6399 100644 --- a/test/scripts/docker-e2e-plan.test.ts +++ b/test/scripts/docker-e2e-plan.test.ts @@ -623,6 +623,7 @@ describe("scripts/lib/docker-e2e-plan", () => { expect(plan.lanes.map((lane) => lane.name)).toEqual([ "published-upgrade-survivor-2026.4.29", + "published-upgrade-survivor-2026.4.29-acpx-openclaw-tools-bridge", "published-upgrade-survivor-2026.4.29-feishu-channel", "published-upgrade-survivor-2026.4.29-bootstrap-persona", "published-upgrade-survivor-2026.4.29-channel-post-core-restore", @@ -637,12 +638,13 @@ describe("scripts/lib/docker-e2e-plan", () => { it("skips plugin dependency cleanup for baselines without packaged plugin dirs", () => { const plan = planFor({ selectedLaneNames: ["published-upgrade-survivor"], - upgradeSurvivorBaselines: "2026.4.29 2026.3.13", + upgradeSurvivorBaselines: "2026.4.29 2026.4.22 2026.4.21 2026.3.13", upgradeSurvivorScenarios: "reported-issues", }); expect(plan.lanes.map((lane) => lane.name)).toEqual([ "published-upgrade-survivor-2026.4.29", + "published-upgrade-survivor-2026.4.29-acpx-openclaw-tools-bridge", "published-upgrade-survivor-2026.4.29-feishu-channel", "published-upgrade-survivor-2026.4.29-bootstrap-persona", "published-upgrade-survivor-2026.4.29-channel-post-core-restore", @@ -651,6 +653,23 @@ describe("scripts/lib/docker-e2e-plan", () => { "published-upgrade-survivor-2026.4.29-stale-source-plugin-shadow", "published-upgrade-survivor-2026.4.29-tilde-log-path", "published-upgrade-survivor-2026.4.29-versioned-runtime-deps", + "published-upgrade-survivor-2026.4.22", + "published-upgrade-survivor-2026.4.22-acpx-openclaw-tools-bridge", + "published-upgrade-survivor-2026.4.22-feishu-channel", + "published-upgrade-survivor-2026.4.22-bootstrap-persona", + "published-upgrade-survivor-2026.4.22-channel-post-core-restore", + "published-upgrade-survivor-2026.4.22-configured-plugin-installs", + "published-upgrade-survivor-2026.4.22-stale-source-plugin-shadow", + "published-upgrade-survivor-2026.4.22-tilde-log-path", + "published-upgrade-survivor-2026.4.22-versioned-runtime-deps", + "published-upgrade-survivor-2026.4.21", + "published-upgrade-survivor-2026.4.21-feishu-channel", + "published-upgrade-survivor-2026.4.21-bootstrap-persona", + "published-upgrade-survivor-2026.4.21-channel-post-core-restore", + "published-upgrade-survivor-2026.4.21-configured-plugin-installs", + "published-upgrade-survivor-2026.4.21-stale-source-plugin-shadow", + "published-upgrade-survivor-2026.4.21-tilde-log-path", + "published-upgrade-survivor-2026.4.21-versioned-runtime-deps", "published-upgrade-survivor-2026.3.13", "published-upgrade-survivor-2026.3.13-feishu-channel", "published-upgrade-survivor-2026.3.13-bootstrap-persona", diff --git a/test/scripts/package-acceptance-workflow.test.ts b/test/scripts/package-acceptance-workflow.test.ts index 620b064fae1..9b71c597c97 100644 --- a/test/scripts/package-acceptance-workflow.test.ts +++ b/test/scripts/package-acceptance-workflow.test.ts @@ -1080,6 +1080,9 @@ describe("package artifact reuse", () => { expect(readFileSync("scripts/test-live-acp-bind-docker.sh", "utf8")).toContain( '-e OPENCLAW_LIVE_ACP_BIND_SETUP_TIMEOUT_SECONDS="$ACP_SETUP_TIMEOUT_SECONDS"', ); + expect(readFileSync("scripts/test-live-acp-bind-docker.sh", "utf8")).toContain( + '-e OPENCLAW_LIVE_ACP_BIND_REQUIRE_CRON="${OPENCLAW_LIVE_ACP_BIND_REQUIRE_CRON:-}"', + ); expect(readFileSync("scripts/test-live-acp-bind-docker.sh", "utf8")).toContain( 'echo "timeout command not found; cannot bound live ACP bind setup after ${timeout_value}"', ); diff --git a/test/scripts/test-projects.test.ts b/test/scripts/test-projects.test.ts index ac1766fe642..ef2bee910eb 100644 --- a/test/scripts/test-projects.test.ts +++ b/test/scripts/test-projects.test.ts @@ -17,6 +17,7 @@ import { createVitestRunSpecs, findUnmatchedExplicitTestTargets, formatFailedShardDigest, + formatNoChangedTestTargetLines, listFullExtensionVitestProjectConfigs, orderFullSuiteSpecsForParallelRun, shouldAcquireLocalHeavyCheckLock, @@ -2441,7 +2442,7 @@ describe("scripts/test-projects changed-target routing", () => { } }); - it("routes MCP Docker E2E script targets instead of skipping changed tests", () => { + it("routes MCP and cron Docker E2E script targets instead of skipping changed tests", () => { const targets = [ "scripts/e2e/mcp-channels-docker.sh", "scripts/e2e/mcp-channels-docker-client.ts", @@ -2454,6 +2455,7 @@ describe("scripts/test-projects changed-target routing", () => { "scripts/e2e/agent-bundle-mcp-tools-docker.sh", "scripts/e2e/agent-bundle-mcp-tools-docker-client.ts", "scripts/mcp-code-mode-gateway-e2e.ts", + "scripts/e2e/cron-cli-docker.sh", "scripts/e2e/cron-mcp-cleanup-docker.sh", "scripts/e2e/cron-mcp-cleanup-docker-client.ts", "scripts/e2e/cron-mcp-cleanup-seed.ts", @@ -2991,26 +2993,12 @@ describe("scripts/test-projects changed-target routing", () => { }); it("explains changed paths that need explicit broad fallback before skipping", () => { - withTinyGitRepo({ "package.json": '{"scripts":{}}\n' }, (cwd) => { - commitTinyGitRepo(cwd); - fs.writeFileSync(path.join(cwd, "package.json"), '{"scripts":{"test":"node"}}\n'); - - const result = spawnSync( - process.execPath, - [path.resolve(process.cwd(), "scripts/test-projects.mjs"), "--changed", "HEAD"], - { - cwd, - encoding: "utf8", - }, - ); - - expect(result.status).toBe(0); - expect(result.stderr).toContain("[test] no precise changed test targets; skipping Vitest."); - expect(result.stderr).toContain("[test] package.json"); - expect(result.stderr).toContain( - "[test] run `OPENCLAW_TEST_CHANGED_BROAD=1 pnpm test:changed` for broad coverage.", - ); - }); + expect(formatNoChangedTestTargetLines(["unknown-root-surface.txt"])).toEqual([ + "[test] no precise changed test targets; skipping Vitest.", + "[test] 1 changed path require broad Vitest fallback:", + "[test] unknown-root-surface.txt", + "[test] run `OPENCLAW_TEST_CHANGED_BROAD=1 pnpm test:changed` for broad coverage.", + ]); }); it("keeps the broad changed run available for unknown root surfaces", () => { diff --git a/test/scripts/upgrade-survivor-assertions.test.ts b/test/scripts/upgrade-survivor-assertions.test.ts index 674c701d9dd..48770bdec27 100644 --- a/test/scripts/upgrade-survivor-assertions.test.ts +++ b/test/scripts/upgrade-survivor-assertions.test.ts @@ -146,6 +146,65 @@ function assertConfiguredPluginState(params: { installPath?: string } = {}): voi } describe("upgrade survivor assertions", () => { + it("accepts the ACPX OpenClaw tools bridge scenario during seed", () => { + const root = mkdtempSync(join(tmpdir(), "openclaw-upgrade-survivor-acpx-")); + try { + const stateDir = join(root, "state"); + const workspace = join(root, "workspace"); + mkdirSync(stateDir, { recursive: true }); + mkdirSync(workspace, { recursive: true }); + + execFileSync(process.execPath, [ASSERTIONS_PATH, "seed"], { + env: { + ...process.env, + OPENCLAW_STATE_DIR: stateDir, + OPENCLAW_TEST_WORKSPACE_DIR: workspace, + OPENCLAW_UPGRADE_SURVIVOR_SCENARIO: "acpx-openclaw-tools-bridge", + }, + stdio: "pipe", + }); + } finally { + rmSync(root, { force: true, recursive: true }); + } + }); + + it("asserts the ACPX OpenClaw tools bridge config survived", () => { + const root = mkdtempSync(join(tmpdir(), "openclaw-upgrade-survivor-acpx-config-")); + try { + const configPath = join(root, "openclaw.json"); + const coveragePath = join(root, "coverage.json"); + writeJson(configPath, { + plugins: { + allow: ["acpx"], + entries: { + acpx: { + enabled: true, + config: { + openClawToolsMcpBridge: true, + }, + }, + }, + }, + }); + writeJson(coveragePath, { + acceptedIntents: ["acpx-openclaw-tools-bridge"], + skippedIntents: [], + }); + + execFileSync(process.execPath, [ASSERTIONS_PATH, "assert-config"], { + env: { + ...process.env, + OPENCLAW_CONFIG_PATH: configPath, + OPENCLAW_UPGRADE_SURVIVOR_CONFIG_COVERAGE_JSON: coveragePath, + OPENCLAW_UPGRADE_SURVIVOR_SCENARIO: "acpx-openclaw-tools-bridge", + }, + stdio: "pipe", + }); + } finally { + rmSync(root, { force: true, recursive: true }); + } + }); + it("accepts official ClawHub npm-pack installs for configured external plugins", () => { expect(() => assertConfiguredPluginState()).not.toThrow(); }); diff --git a/test/scripts/upgrade-survivor-config-recipe.test.ts b/test/scripts/upgrade-survivor-config-recipe.test.ts index a456b738d3d..63cdac6bbd8 100644 --- a/test/scripts/upgrade-survivor-config-recipe.test.ts +++ b/test/scripts/upgrade-survivor-config-recipe.test.ts @@ -1,4 +1,8 @@ // Upgrade Survivor Config Recipe tests cover upgrade survivor config recipe script behavior. +import { execFileSync } from "node:child_process"; +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { CONFIG_COMMAND_MAX_BUFFER_BYTES, @@ -8,6 +12,8 @@ import { runUpgradeSurvivorOpenClawStep, } from "../../scripts/e2e/lib/upgrade-survivor/config-recipe.mjs"; +const RECIPE_PATH = "scripts/e2e/lib/upgrade-survivor/config-recipe.mjs"; + describe("upgrade survivor config recipe command resolution", () => { it("compares baseline versions with the shared release parser", () => { expect(isReleaseBefore("2026.3.31", "2026.4.0")).toBe(true); @@ -102,4 +108,56 @@ describe("upgrade survivor config recipe command resolution", () => { stdout: "partial output", }); }); + + it("skips ACPX bridge config on baselines before the bridge field existed", () => { + const root = mkdtempSync(join(tmpdir(), "openclaw-upgrade-recipe-acpx-")); + try { + const binDir = join(root, "bin"); + const logPath = join(root, "openclaw-argv.jsonl"); + const summaryPath = join(root, "summary.json"); + mkdirSync(binDir, { recursive: true }); + const openclawLogPath = join(binDir, "openclaw-log.js"); + const openclawPath = join(binDir, "openclaw"); + const openclawCmdPath = join(binDir, "openclaw.cmd"); + writeFileSync( + openclawLogPath, + ` +const fs = require("node:fs"); +fs.appendFileSync(${JSON.stringify(logPath)}, JSON.stringify(process.argv.slice(2)) + "\\n"); +process.exit(0); +`, + ); + writeFileSync(openclawPath, `#!/usr/bin/env node\nrequire("./openclaw-log.js");\n`); + chmodSync(openclawPath, 0o755); + writeFileSync( + openclawCmdPath, + `@echo off\r\n"${process.execPath}" "%~dp0openclaw-log.js" %*\r\n`, + ); + + execFileSync( + process.execPath, + [RECIPE_PATH, "apply", "--summary", summaryPath, "--baseline-version", "2026.4.21"], + { + env: { + ...process.env, + OPENCLAW_UPGRADE_SURVIVOR_SCENARIO: "acpx-openclaw-tools-bridge", + PATH: `${binDir}${process.platform === "win32" ? ";" : ":"}${process.env.PATH ?? ""}`, + }, + stdio: "pipe", + }, + ); + + const summary = JSON.parse(readFileSync(summaryPath, "utf8")); + const loggedArgs = readFileSync(logPath, "utf8") + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + expect(summary.skippedIntents).toContain("acpx-openclaw-tools-bridge"); + expect(loggedArgs).not.toContainEqual( + expect.arrayContaining(["set", "plugins", expect.stringContaining("openClawToolsMcpBridge")]), + ); + } finally { + rmSync(root, { force: true, recursive: true }); + } + }); }); diff --git a/tsdown.config.ts b/tsdown.config.ts index 9418df0141b..5e1b2669cd1 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -297,6 +297,7 @@ function buildCoreDistEntries(): Record { "plugins/runtime/index": "src/plugins/runtime/index.ts", "llm-slug-generator": "src/hooks/llm-slug-generator.ts", "mcp/plugin-tools-serve": "src/mcp/plugin-tools-serve.ts", + "mcp/openclaw-tools-serve": "src/mcp/openclaw-tools-serve.ts", }; } diff --git a/ui/src/ui/controllers/config.test.ts b/ui/src/ui/controllers/config.test.ts index 53582711a75..dbc585a6eea 100644 --- a/ui/src/ui/controllers/config.test.ts +++ b/ui/src/ui/controllers/config.test.ts @@ -1143,6 +1143,29 @@ describe("runUpdate", () => { }); }); + it("surfaces managed-service handoff command when the gateway cannot start it", async () => { + const request = vi.fn().mockResolvedValue({ + ok: false, + result: { status: "skipped", reason: "managed-service-handoff-unavailable" }, + handoff: { + status: "unavailable", + command: "openclaw update --yes", + message: + "OpenClaw updates cannot safely run inside the live gateway process without a managed-service handoff.", + }, + }); + const state = createState(); + state.connected = true; + state.client = { request } as unknown as ConfigState["client"]; + + await runUpdate(state); + + expect(state.updateStatusBanner).toEqual({ + tone: "warn", + text: "Update skipped: managed-service-handoff-unavailable. Run `openclaw update --yes` from a shell outside the Gateway process.", + }); + }); + it("stores the expected post-update version when update.run succeeds", async () => { const request = vi.fn().mockResolvedValue({ ok: true, diff --git a/ui/src/ui/controllers/config.ts b/ui/src/ui/controllers/config.ts index 8471a16fdb0..e8ca95bbfc3 100644 --- a/ui/src/ui/controllers/config.ts +++ b/ui/src/ui/controllers/config.ts @@ -185,13 +185,23 @@ function serializeFormForSubmit(state: ConfigState): string { type ConfigSubmitMethod = "config.set" | "config.apply"; type ConfigSubmitBusyKey = "configSaving" | "configApplying"; -function resolveUpdateStatusBanner(params: { status?: string; reason?: string }): { +function resolveUpdateStatusBanner(params: { + status?: string; + reason?: string; + handoff?: { command?: string; message?: string }; +}): { tone: "danger" | "warn" | "info"; text: string; } { const status = (params.status ?? "error").trim() || "error"; const reason = (params.reason ?? "unexpected-error").trim() || "unexpected-error"; const tone = status === "skipped" ? "warn" : "danger"; + const handoffCommand = params.handoff?.command?.trim(); + const handoffMessage = params.handoff?.message?.trim(); + const handoffUnavailableGuidance = handoffCommand + ? `Run \`${handoffCommand}\` from a shell outside the Gateway process.` + : (handoffMessage ?? + "OpenClaw could not find a safe supervisor handoff. Run `openclaw update` from a shell outside the Gateway process."); const guidance = { dirty: "Commit or stash changes, then retry.", @@ -209,6 +219,7 @@ function resolveUpdateStatusBanner(params: { status?: string; reason?: string }) "The update was not applied because gateway restarts are disabled. Enable restarts in config, then retry — or run `openclaw update` from the CLI.", "restart-unavailable": "This global install cannot be safely replaced while restarts are disabled and no supervisor is present.", + "managed-service-handoff-unavailable": handoffUnavailableGuidance, "restart-unhealthy": "The replacement process never became healthy. The previous process stayed up so you can recover.", "doctor-failed": "Doctor repair failed. Run `openclaw doctor --non-interactive` and retry.", @@ -285,7 +296,7 @@ export async function runUpdate(state: ConfigState) { const res = await state.client.request<{ ok?: boolean; result?: { status?: string; reason?: string; after?: { version?: string | null } }; - handoff?: { status?: string }; + handoff?: { status?: string; command?: string; message?: string }; }>("update.run", { sessionKey: state.applySessionKey, }); @@ -310,6 +321,7 @@ export async function runUpdate(state: ConfigState) { state.updateStatusBanner = resolveUpdateStatusBanner({ status, reason: res.result?.reason, + handoff: res.handoff, }); } catch (err) { state.lastError = String(err);