From 45849c0dfd82e61ae58f50d0ff6be31a342af896 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 18 Sep 2026 12:59:57 -0700 Subject: [PATCH] refactor(agents): unify tool availability and plugin work ownership (#152073) * refactor(agents): unify tool and plugin work ownership * test(agents): align migrated fixtures with host contracts * test(codex): avoid shadowing side-question options --- docs/gateway/config-tools/github-identity.md | 2 + .../sdk-agent-harness/core-ownership.md | 6 + .../attempt-startup-lifetime.test.ts | 4 +- .../app-server/dynamic-tool-build-state.ts | 7 - .../dynamic-tool-build.prompt.test.ts | 18 +- .../dynamic-tool-build.test-support.ts | 11 - .../src/app-server/dynamic-tool-build.test.ts | 223 +++++++++--------- .../src/app-server/dynamic-tool-build.ts | 9 - .../host-capability.test-support.ts | 38 ++- .../app-server/run-attempt-test-harness.ts | 21 +- .../run-attempt.agent-end-context.test.ts | 6 +- .../run-attempt.durable-context.test.ts | 4 +- .../run-attempt.dynamic-tools.test.ts | 15 +- .../run-attempt.native-config.test.ts | 8 +- .../run-attempt.plugin-refresh.test.ts | 5 +- .../run-attempt.question-refresh.test.ts | 4 +- .../codex/src/app-server/run-attempt.test.ts | 96 ++++---- .../run-attempt.workspace-snapshot.test.ts | 6 +- .../settled-turn-finalizer.native.test.ts | 4 +- .../side-question.execution.test.ts | 9 +- .../app-server/side-question.test-support.ts | 25 +- .../tool-bridge.github-publication.test.ts | 74 ++++++ extensions/copilot/src/tool-bridge.ts | 50 +--- .../run/attempt-gateway-tools.ts | 71 ++++++ .../run/attempt-tool-run-context.ts | 70 ------ .../run/run-attempt-dispatch.ts | 2 +- src/agents/harness/host-capability.test.ts | 26 ++ src/agents/harness/host-capability.ts | 3 + src/agents/prepared-model-runtime.build.ts | 20 +- src/agents/prepared-model-runtime.facts.ts | 31 +-- ...prepared-model-runtime.inbound-registry.ts | 7 +- src/agents/prepared-model-runtime.owner.ts | 4 +- .../prepared-model-runtime.plugin-lifetime.ts | 15 +- ...ared-model-runtime.registry-borrow.test.ts | 168 ++++++++++++- .../prepared-model-runtime.resources.ts | 73 +++--- src/agents/runtime-plugin-work.ts | 4 +- ...edia-generate-tool.donor-resources.test.ts | 13 +- ...quester-cron-authority.integration.test.ts | 2 +- src/gateway/test-helpers.listener.test.ts | 78 ++++++ src/gateway/test-helpers.listener.ts | 71 ++++-- .../agent-runtime-test-contracts.ts | 1 + .../test-helpers/agents/host-tool-factory.ts | 26 ++ 42 files changed, 864 insertions(+), 466 deletions(-) delete mode 100644 extensions/codex/src/app-server/dynamic-tool-build-state.ts create mode 100644 extensions/copilot/src/tool-bridge.github-publication.test.ts create mode 100644 src/agents/embedded-agent-runner/run/attempt-gateway-tools.ts create mode 100644 src/gateway/test-helpers.listener.test.ts create mode 100644 src/plugin-sdk/test-helpers/agents/host-tool-factory.ts diff --git a/docs/gateway/config-tools/github-identity.md b/docs/gateway/config-tools/github-identity.md index ec2fec62bc15..aac6e8e15ce9 100644 --- a/docs/gateway/config-tools/github-identity.md +++ b/docs/gateway/config-tools/github-identity.md @@ -65,6 +65,8 @@ OpenClaw sandboxes, ordinary node-host exec, and Codex `remote-exec` placements Gateway-hosted agents check publication availability for ordinary messages and internal continuations, including when a subagent finishes after the requester yields. The check uses the current session workspace and GitHub identity. If publication is unavailable, `github_identity_status` remains available to explain identity setup or reconnection needs, subject to the session's tool policy. Standalone local runs and runs with tools disabled do not expose these managed publication tools. +The built-in, Codex, and Copilot tool surfaces use this same host-prepared availability. Harness options cannot replace the host's decision; tool profiles and Gateway authorization still apply. + Publication stages workspace changes with ordinary Git attribute conversion. It preserves unchanged committed file bytes, including existing CRLF line endings, rather than renormalizing unrelated tracked files. Local session-owned worktrees can use the same **Publish PR** action in the Control UI. The Gateway derives the managed worktree, repository, branch, base, and head from current session ownership. It never accepts those authority facts from the browser or model. Publication retries use a durable request ID, an exact commit marker, remote branch observation, and pull-request lookup by head branch so a Gateway restart or lost response does not create duplicate commits, pushes, or pull requests. diff --git a/docs/plugins/sdk-agent-harness/core-ownership.md b/docs/plugins/sdk-agent-harness/core-ownership.md index 088df7beb785..a05aebbb6bd7 100644 --- a/docs/plugins/sdk-agent-harness/core-ownership.md +++ b/docs/plugins/sdk-agent-harness/core-ownership.md @@ -30,6 +30,12 @@ model discovery, auth preparation, or Responses parameters. An explicit observation, not a native ownership claim. Bound native sessions use the separate ownership contract below. +Use `params.hostCapabilities.createToolSurface(options)` to construct OpenClaw +tools. The host captures publication availability for the admitted attempt and +applies it when building the surface; harnesses do not need to forward that fact, +and plugin-supplied options cannot replace it. Tool profiles still filter the +catalog, and each executable remains bound to the host's live authority. + ### Native tool-policy enforcement Set `conversationToolPolicySupport: "exact"` only when `runAttempt` enforces every diff --git a/extensions/codex/src/app-server/attempt-startup-lifetime.test.ts b/extensions/codex/src/app-server/attempt-startup-lifetime.test.ts index acbcd46512b2..bf140a86aff5 100644 --- a/extensions/codex/src/app-server/attempt-startup-lifetime.test.ts +++ b/extensions/codex/src/app-server/attempt-startup-lifetime.test.ts @@ -15,7 +15,7 @@ import { import { CodexAppServerClient } from "./client.js"; import { threadStartResult as createThreadStartResult } from "./codex-app-server.test-fixtures.js"; import { type CodexPluginConfig, resolveCodexAppServerRuntimeOptions } from "./config.js"; -import { dynamicToolBuildState } from "./dynamic-tool-build-state.js"; +import { setCodexTestToolFactory } from "./host-capability.test-support.js"; import { setManagedCodexPluginRoot } from "./managed-binary.js"; import { codexNativeSubagentMonitorRuntime } from "./native-subagent-monitor.js"; import { defaultCodexPluginMetadataCache } from "./plugin-metadata-cache.js"; @@ -177,7 +177,7 @@ describe("Codex runtime startup resource lifetime", () => { params.sandbox = createSandboxContext({}); params.runtimePlan = createCodexRuntimePlanFixture(); setCodexTestModelSupportsTools(params, true); - dynamicToolBuildState.openClawCodingToolsFactory = () => [createRuntimeDynamicTool("message")]; + setCodexTestToolFactory(params, () => [createRuntimeDynamicTool("message")]); const resourcesSpy = vi.spyOn(runAttemptResources, "prepareCodexAttemptResources"); const releaseSandbox = vi.spyOn(sandboxExecServer, "releaseCodexSandboxExecServerEnvironment"); const allocated: Array< diff --git a/extensions/codex/src/app-server/dynamic-tool-build-state.ts b/extensions/codex/src/app-server/dynamic-tool-build-state.ts deleted file mode 100644 index 9cddd94f31ba..000000000000 --- a/extensions/codex/src/app-server/dynamic-tool-build-state.ts +++ /dev/null @@ -1,7 +0,0 @@ -type OpenClawCodingToolsFactory = - (typeof import("openclaw/plugin-sdk/agent-harness"))["createOpenClawCodingTools"]; - -/** Mutable dependency seam shared by dynamic-tool construction and its behavioral tests. */ -export const dynamicToolBuildState: { - openClawCodingToolsFactory?: OpenClawCodingToolsFactory; -} = {}; diff --git a/extensions/codex/src/app-server/dynamic-tool-build.prompt.test.ts b/extensions/codex/src/app-server/dynamic-tool-build.prompt.test.ts index af74644df09a..7bd95e499a1b 100644 --- a/extensions/codex/src/app-server/dynamic-tool-build.prompt.test.ts +++ b/extensions/codex/src/app-server/dynamic-tool-build.prompt.test.ts @@ -1,20 +1,15 @@ -import "./dynamic-tool-build.test-support.js"; import fs from "node:fs/promises"; +import "./dynamic-tool-build.test-support.js"; import os from "node:os"; import path from "node:path"; import { expectDefined } from "@openclaw/normalization-core"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { setCodexTestToolFactory } from "./host-capability.test-support.js"; -const { - buildDynamicToolsForTest, - createCodexRuntimePlanFixture, - createParams, - hoisted, - resetOpenClawCodingToolsFactoryForTests, - setOpenClawCodingToolsFactoryForTests, -} = await import("./dynamic-tool-build.test-support.js"); +const { buildDynamicToolsForTest, createCodexRuntimePlanFixture, createParams, hoisted } = + await import("./dynamic-tool-build.test-support.js"); type OpenClawCodingToolsOptionsForTest = NonNullable< - Parameters[0]>[0] + Parameters[1]>[0] >; describe("Codex app-server dynamic tool question prompts", () => { @@ -29,7 +24,6 @@ describe("Codex app-server dynamic tool question prompts", () => { }); afterEach(async () => { - resetOpenClawCodingToolsFactoryForTests(); vi.restoreAllMocks(); vi.unstubAllEnvs(); await fs.rm(tempDir, { recursive: true, force: true }); @@ -60,7 +54,7 @@ describe("Codex app-server dynamic tool question prompts", () => { const onToolResult = vi.fn(); params.onToolResult = hasCallback ? onToolResult : undefined; let capturedQuestionPrompt: OpenClawCodingToolsOptionsForTest["questionPrompt"]; - setOpenClawCodingToolsFactoryForTests((options) => { + setCodexTestToolFactory(params, (options) => { capturedQuestionPrompt = options?.questionPrompt; return []; }); diff --git a/extensions/codex/src/app-server/dynamic-tool-build.test-support.ts b/extensions/codex/src/app-server/dynamic-tool-build.test-support.ts index c9b1f7d74286..cd01ec6875ff 100644 --- a/extensions/codex/src/app-server/dynamic-tool-build.test-support.ts +++ b/extensions/codex/src/app-server/dynamic-tool-build.test-support.ts @@ -1,6 +1,5 @@ import type { EmbeddedRunAttemptParamsV2 as EmbeddedRunAttemptParams } from "openclaw/plugin-sdk/agent-harness-runtime"; import { vi } from "vitest"; -import { dynamicToolBuildState } from "./dynamic-tool-build-state.js"; import { buildDynamicTools } from "./dynamic-tool-build.js"; import { createCodexTestHostCapabilities } from "./host-capability.test-support.js"; import { createCodexTestModel } from "./test-support.js"; @@ -44,16 +43,6 @@ vi.mock("openclaw/plugin-sdk/node-selection-runtime", async (importOriginal) => return { ...actual, loadNodeExecAvailability: hoisted.loadNodeExecAvailability }; }); -export function setOpenClawCodingToolsFactoryForTests( - factory: NonNullable, -): void { - dynamicToolBuildState.openClawCodingToolsFactory = factory; -} - -export function resetOpenClawCodingToolsFactoryForTests(): void { - dynamicToolBuildState.openClawCodingToolsFactory = undefined; -} - export function createParams(sessionFile: string, workspaceDir: string): EmbeddedRunAttemptParams { return { hostCapabilities: createCodexTestHostCapabilities(), diff --git a/extensions/codex/src/app-server/dynamic-tool-build.test.ts b/extensions/codex/src/app-server/dynamic-tool-build.test.ts index c8a03e1b1039..fc54736097c2 100644 --- a/extensions/codex/src/app-server/dynamic-tool-build.test.ts +++ b/extensions/codex/src/app-server/dynamic-tool-build.test.ts @@ -1,6 +1,6 @@ -import "./dynamic-tool-build.test-support.js"; // Codex tests cover dynamic tool build plugin behavior. import fs from "node:fs/promises"; +import "./dynamic-tool-build.test-support.js"; import os from "node:os"; import path from "node:path"; import { expectDefined } from "@openclaw/normalization-core"; @@ -46,6 +46,10 @@ import { resolveCodexDynamicToolsLoadingForRuntime, } from "./dynamic-tool-profile.js"; import { createCodexDynamicToolBridge } from "./dynamic-tools.js"; +import { + createCodexTestHostCapabilities, + setCodexTestToolFactory, +} from "./host-capability.test-support.js"; import * as nativeExecutionPolicy from "./native-execution-policy.js"; import { CODEX_OPENCLAW_DIRECT_DYNAMIC_TOOL_NAMESPACE, @@ -54,14 +58,8 @@ import { import { resolveCodexDynamicToolDirectNames } from "./run-attempt-tools.js"; import { createCodexTestModel } from "./test-support.js"; -const { - buildDynamicToolsForTest, - createCodexRuntimePlanFixture, - createParams, - hoisted, - resetOpenClawCodingToolsFactoryForTests, - setOpenClawCodingToolsFactoryForTests, -} = await import("./dynamic-tool-build.test-support.js"); +const { buildDynamicToolsForTest, createCodexRuntimePlanFixture, createParams, hoisted } = + await import("./dynamic-tool-build.test-support.js"); let tempDir: string; const hostCapabilityClosers: Array<() => void> = []; @@ -111,7 +109,7 @@ describe("Codex app-server dynamic tool build", () => { let capturedOnYield: | ((message: string, acknowledgment?: string) => Promise | void) | undefined; - setOpenClawCodingToolsFactoryForTests((options) => { + setCodexTestToolFactory(params, (options) => { capturedOnYield = (options as { onYield?: typeof capturedOnYield }).onYield; return []; }); @@ -138,12 +136,9 @@ describe("Codex app-server dynamic tool build", () => { params.disableTools = false; params.runtimePlan = createCodexRuntimePlanFixture(); const bindToolSurface = vi.fn(params.hostCapabilities.bindToolSurface); - params.hostCapabilities = Object.freeze({ - ...params.hostCapabilities, - bindToolSurface, - }); + params.hostCapabilities = createCodexTestHostCapabilities({ bindToolSurface }); const factory = vi.fn(() => [createRuntimeDynamicTool("read")]); - setOpenClawCodingToolsFactoryForTests(factory); + setCodexTestToolFactory(params, factory); const resolveCronCreatorToolAuthority = vi.fn(async () => ({ tools: ["read"], provenance: { version: 1 as const, source: "final-executable-surface" as const }, @@ -173,7 +168,7 @@ describe("Codex app-server dynamic tool build", () => { ...createOpenClawCodingTools(options).filter((tool) => tool.name === "message"), createRuntimeDynamicTool("paired_host_plugin"), ]); - setOpenClawCodingToolsFactoryForTests(factory); + setCodexTestToolFactory(params, factory); const tools = await buildDynamicToolsForTest(params, workspaceDir, { sandbox: { @@ -211,7 +206,7 @@ describe("Codex app-server dynamic tool build", () => { params.disableTools = false; params.runtimePlan = createCodexRuntimePlanFixture(); const factory = vi.fn(() => [createRuntimeDynamicTool("exec")]); - setOpenClawCodingToolsFactoryForTests(factory); + setCodexTestToolFactory(params, factory); await expect( buildDynamicToolsForTest(params, workspaceDir, { @@ -341,11 +336,7 @@ describe("Codex app-server dynamic tool build", () => { ); try { await bindProductionCodexHostCapabilities(params); - setOpenClawCodingToolsFactoryForTests((options) => - createOpenClawCodingTools(options).filter((tool) => - ["read", "write", "edit", "apply_patch", "exec", "process"].includes(tool.name), - ), - ); + const tools = await buildDynamicToolsForTest(params, workspaceDir, { sandboxSessionKey: params.sandboxSessionKey ?? params.sessionKey, nativeToolSurfaceEnabled: false, @@ -374,7 +365,10 @@ describe("Codex app-server dynamic tool build", () => { await expect(fs.readFile(targetPath, "utf8")).rejects.toMatchObject({ code: "ENOENT" }); expect(result.success).toBe(false); expect(beforeToolCall).not.toHaveBeenCalled(); - expect(tools.map((tool) => tool.name).toSorted()).toEqual( + const codingTools = tools.filter(({ name }) => + ["read", "write", "edit", "apply_patch", "exec", "process"].includes(name), + ); + expect(codingTools.map((tool) => tool.name).toSorted()).toEqual( source === "intersection" ? ["read"] : ["apply_patch", "read"], ); const nativeTools = await buildDynamicToolsForTest(params, workspaceDir, { @@ -428,7 +422,7 @@ describe("Codex app-server dynamic tool build", () => { for (const close of hostCapabilityClosers.splice(0)) { close(); } - resetOpenClawCodingToolsFactoryForTests(); + vi.restoreAllMocks(); vi.unstubAllEnvs(); await fs.rm(tempDir, { recursive: true, force: true }); @@ -537,7 +531,7 @@ describe("Codex app-server dynamic tool build", () => { params.disableTools = false; params.model = createCodexTestModel("codex", ["text", "image"]); params.runtimePlan = createCodexRuntimePlanFixture(); - setOpenClawCodingToolsFactoryForTests(() => [ + setCodexTestToolFactory(params, () => [ createRuntimeDynamicTool("view_image"), createRuntimeDynamicTool("message"), ]); @@ -559,7 +553,7 @@ describe("Codex app-server dynamic tool build", () => { params.execOverrides = { host: "gateway", mode: "full" }; params.runtimePlan = createCodexRuntimePlanFixture(); const factoryOptions: unknown[] = []; - setOpenClawCodingToolsFactoryForTests((options) => { + setCodexTestToolFactory(params, (options) => { factoryOptions.push(options); return []; }); @@ -634,7 +628,7 @@ describe("Codex app-server dynamic tool build", () => { tools: { exec: { safeBins: ["echo"], safeBinProfiles: { echo: { maxPositional: 1 } } } }, }; params.runtimePlan = createCodexRuntimePlanFixture(); - setOpenClawCodingToolsFactoryForTests((options) => + setCodexTestToolFactory(params, (options) => createOpenClawCodingTools(options).filter((tool) => ["exec", "process"].includes(tool.name)), ); @@ -673,7 +667,7 @@ describe("Codex app-server dynamic tool build", () => { }, } as never; let receivedOptions: Record | undefined; - setOpenClawCodingToolsFactoryForTests((options) => { + setCodexTestToolFactory(params, (options) => { receivedOptions = options as Record; return [ createRuntimeDynamicTool("web_search"), @@ -720,7 +714,7 @@ describe("Codex app-server dynamic tool build", () => { params.toolsAllow = toolsAllow; params.config = { tools: { web: { search } } } as never; let receivedOptions: Record | undefined; - setOpenClawCodingToolsFactoryForTests((options) => { + setCodexTestToolFactory(params, (options) => { receivedOptions = options as Record; return [createRuntimeDynamicTool("web_search"), createRuntimeDynamicTool("web_fetch")]; }); @@ -800,7 +794,7 @@ describe("Codex app-server dynamic tool build", () => { params.chatType = "direct"; params.messageActionTurnCapability = "turn-capability-1"; let receivedOptions: unknown; - setOpenClawCodingToolsFactoryForTests((options) => { + setCodexTestToolFactory(params, (options) => { receivedOptions = options; return [createRuntimeDynamicTool("message")]; }); @@ -827,7 +821,7 @@ describe("Codex app-server dynamic tool build", () => { params.runtimePlan = createCodexRuntimePlanFixture(); params.taskSuggestionDeliveryMode = "gateway"; let receivedOptions: unknown; - setOpenClawCodingToolsFactoryForTests((options) => { + setCodexTestToolFactory(params, (options) => { receivedOptions = options; return [createRuntimeDynamicTool("message")]; }); @@ -858,7 +852,7 @@ describe("Codex app-server dynamic tool build", () => { catalogMode: "direct-only" as const, }; const factory = vi.fn(() => [createRuntimeDynamicTool("read"), output]); - setOpenClawCodingToolsFactoryForTests(factory); + setCodexTestToolFactory(params, factory); const tools = await buildDynamicToolsForTest(params, workspaceDir, { sandbox: null, nativeToolSurfaceEnabled: shouldEnableCodexAppServerNativeToolSurface(params), @@ -915,7 +909,7 @@ describe("Codex app-server dynamic tool build", () => { params.disableTools = false; params.runtimePlan = createCodexRuntimePlanFixture(); params.config = { tools: { exec: { mode: "ask" } } }; - setOpenClawCodingToolsFactoryForTests((options) => + setCodexTestToolFactory(params, (options) => createOpenClawCodingTools(options).filter((tool) => ["openclaw", "message", "session_status"].includes(tool.name), ), @@ -969,7 +963,7 @@ describe("Codex app-server dynamic tool build", () => { params.runtimePlan = createCodexRuntimePlanFixture(); params.config = policy === "core policy" ? { tools: { deny: ["openclaw"] } } : {}; params.toolsAllow = policy === "turn allowlist" ? ["message"] : ["openclaw", "message"]; - setOpenClawCodingToolsFactoryForTests((options) => + setCodexTestToolFactory(params, (options) => createOpenClawCodingTools(options).filter((tool) => ["openclaw", "message"].includes(tool.name), ), @@ -991,7 +985,7 @@ describe("Codex app-server dynamic tool build", () => { params.disableTools = false; params.runtimePlan = createCodexRuntimePlanFixture(); params.toolsAllow = ["openclaw"]; - setOpenClawCodingToolsFactoryForTests(() => [ + setCodexTestToolFactory(params, () => [ { ...createRuntimeDynamicTool("openclaw"), catalogMode: "direct-only" }, ]); @@ -1016,7 +1010,7 @@ describe("Codex app-server dynamic tool build", () => { params.disableTools = false; params.runtimePlan = createCodexRuntimePlanFixture(); params.toolsAllow = toolsAllow; - setOpenClawCodingToolsFactoryForTests(() => [ + setCodexTestToolFactory(params, () => [ { ...createRuntimeDynamicTool("openclaw"), catalogMode: "direct-only" }, ]); @@ -1035,7 +1029,7 @@ describe("Codex app-server dynamic tool build", () => { params.runtimePlan = createCodexRuntimePlanFixture(); const computerContextEpoch = { value: 0 }; let receivedEpoch: { value: number } | undefined; - setOpenClawCodingToolsFactoryForTests((options) => { + setCodexTestToolFactory(params, (options) => { receivedEpoch = (options as { computerContextEpoch?: { value: number } }) .computerContextEpoch; return [createRuntimeDynamicTool("message")]; @@ -1051,7 +1045,7 @@ describe("Codex app-server dynamic tool build", () => { const params = createParams(path.join(tempDir, "session.jsonl"), workspaceDir); params.disableTools = false; params.runtimePlan = createCodexRuntimePlanFixture(); - setOpenClawCodingToolsFactoryForTests(() => [createRuntimeDynamicTool("message")]); + setCodexTestToolFactory(params, () => [createRuntimeDynamicTool("message")]); let webSearchAllowed = true; const tools = await buildDynamicToolsForTest(params, workspaceDir, { @@ -1070,7 +1064,7 @@ describe("Codex app-server dynamic tool build", () => { params.disableTools = false; params.runtimePlan = createCodexRuntimePlanFixture(); params.toolsAllow = ["message"]; - setOpenClawCodingToolsFactoryForTests(() => [ + setCodexTestToolFactory(params, () => [ createRuntimeDynamicTool("web_search"), createRuntimeDynamicTool("message"), ]); @@ -1098,7 +1092,7 @@ describe("Codex app-server dynamic tool build", () => { params.disableTools = false; params.runtimePlan = createCodexRuntimePlanFixture(); params.toolsAllow = ["message"]; - setOpenClawCodingToolsFactoryForTests(() => [createRuntimeDynamicTool("message")]); + setCodexTestToolFactory(params, () => [createRuntimeDynamicTool("message")]); let persistentWebSearchAllowed = true; let webSearchAllowed = true; @@ -1129,7 +1123,7 @@ describe("Codex app-server dynamic tool build", () => { }, }, } as never; - setOpenClawCodingToolsFactoryForTests(() => [createRuntimeDynamicTool("message")]); + setCodexTestToolFactory(params, () => [createRuntimeDynamicTool("message")]); let persistentWebSearchAllowed = false; let webSearchAllowed = true; @@ -1175,7 +1169,7 @@ describe("Codex app-server dynamic tool build", () => { ownerAccountId: "default", }; let receivedOptions: unknown; - setOpenClawCodingToolsFactoryForTests((options) => { + setCodexTestToolFactory(params, (options) => { receivedOptions = options; return [createRuntimeDynamicTool("message")]; }); @@ -1214,7 +1208,7 @@ describe("Codex app-server dynamic tool build", () => { }, }, } as never; - setOpenClawCodingToolsFactoryForTests(() => [createRuntimeDynamicTool("message")]); + setCodexTestToolFactory(params, () => [createRuntimeDynamicTool("message")]); let persistentWebSearchAllowed = true; await buildDynamicToolsForTest(params, workspaceDir, { @@ -1238,7 +1232,7 @@ describe("Codex app-server dynamic tool build", () => { }, }, } as never; - setOpenClawCodingToolsFactoryForTests(() => [ + setCodexTestToolFactory(params, () => [ createRuntimeDynamicTool("web_search"), createRuntimeDynamicTool("message"), ]); @@ -1253,7 +1247,7 @@ describe("Codex app-server dynamic tool build", () => { const params = createParams(path.join(tempDir, "session.jsonl"), workspaceDir); params.disableTools = false; params.runtimePlan = createCodexRuntimePlanFixture(); - setOpenClawCodingToolsFactoryForTests(() => [ + setCodexTestToolFactory(params, () => [ createRuntimeDynamicTool("web_search"), createRuntimeDynamicTool("message"), ]); @@ -1353,10 +1347,11 @@ describe("Codex app-server dynamic tool build", () => { return Reflect.get(target, property, receiver); }, }); - setOpenClawCodingToolsFactoryForTests(() => sourceTools); + const sessionFile = path.join(tempDir, "session.jsonl"); const workspaceDir = path.join(tempDir, "workspace"); const params = createParams(sessionFile, workspaceDir); + setCodexTestToolFactory(params, () => sourceTools); params.disableTools = false; params.runtimePlan = createCodexRuntimePlanFixture(); @@ -1370,10 +1365,11 @@ describe("Codex app-server dynamic tool build", () => { ...createRuntimeDynamicTool("dofbot_move_angles"), parameters: { type: "array", items: { type: "number" } }, }; - setOpenClawCodingToolsFactoryForTests(() => [brokenTool, messageTool]); + const sessionFile = path.join(tempDir, "session.jsonl"); const workspaceDir = path.join(tempDir, "workspace"); const params = createParams(sessionFile, workspaceDir); + setCodexTestToolFactory(params, () => [brokenTool, messageTool]); params.disableTools = false; params.runtimePlan = createCodexRuntimePlanFixture(); @@ -1397,7 +1393,11 @@ describe("Codex app-server dynamic tool build", () => { it("limits Codex memory flush runs to managed read and write tools", async () => { const factoryOptions: unknown[] = []; - setOpenClawCodingToolsFactoryForTests((options) => { + + const sessionFile = path.join(tempDir, "session.jsonl"); + const workspaceDir = path.join(tempDir, "workspace"); + const params = createParams(sessionFile, workspaceDir); + setCodexTestToolFactory(params, (options) => { factoryOptions.push(options); return [ createRuntimeDynamicTool("read"), @@ -1409,9 +1409,6 @@ describe("Codex app-server dynamic tool build", () => { createRuntimeDynamicTool("web_search"), ]; }); - const sessionFile = path.join(tempDir, "session.jsonl"); - const workspaceDir = path.join(tempDir, "workspace"); - const params = createParams(sessionFile, workspaceDir); params.disableTools = false; params.runtimePlan = createCodexRuntimePlanFixture(); params.trigger = "memory"; @@ -1444,14 +1441,14 @@ describe("Codex app-server dynamic tool build", () => { }); it("keeps persistent search disabled during a memory flush when config disables it", async () => { - setOpenClawCodingToolsFactoryForTests(() => [ + const sessionFile = path.join(tempDir, "session.jsonl"); + const workspaceDir = path.join(tempDir, "workspace"); + const params = createParams(sessionFile, workspaceDir); + setCodexTestToolFactory(params, () => [ createRuntimeDynamicTool("read"), createRuntimeDynamicTool("write"), createRuntimeDynamicTool("web_search"), ]); - const sessionFile = path.join(tempDir, "session.jsonl"); - const workspaceDir = path.join(tempDir, "workspace"); - const params = createParams(sessionFile, workspaceDir); params.disableTools = false; params.runtimePlan = createCodexRuntimePlanFixture(); params.trigger = "memory"; @@ -1491,7 +1488,11 @@ describe("Codex app-server dynamic tool build", () => { createOpenClawCodingTools({ workspaceDir: tempDir }).find((tool) => tool.name === "exec"), "assembled exec tool", ); - setOpenClawCodingToolsFactoryForTests(() => [ + + const sessionFile = path.join(tempDir, "session.jsonl"); + const workspaceDir = path.join(tempDir, "workspace"); + const params = createParams(sessionFile, workspaceDir); + setCodexTestToolFactory(params, () => [ createRuntimeDynamicTool("read"), createRuntimeDynamicTool("write"), createRuntimeDynamicTool("edit"), @@ -1500,9 +1501,6 @@ describe("Codex app-server dynamic tool build", () => { createRuntimeDynamicTool("process"), createRuntimeDynamicTool("message"), ]); - const sessionFile = path.join(tempDir, "session.jsonl"); - const workspaceDir = path.join(tempDir, "workspace"); - const params = createParams(sessionFile, workspaceDir); params.disableTools = false; params.runtimePlan = createCodexRuntimePlanFixture(); @@ -1532,14 +1530,14 @@ describe("Codex app-server dynamic tool build", () => { }); it("exposes Docker sandbox shell tools when OpenClaw sandboxing disables native Code Mode", async () => { - setOpenClawCodingToolsFactoryForTests(() => [ + const sessionFile = path.join(tempDir, "session.jsonl"); + const workspaceDir = path.join(tempDir, "workspace"); + const params = createParams(sessionFile, workspaceDir); + setCodexTestToolFactory(params, () => [ createRuntimeDynamicTool("exec"), createRuntimeDynamicTool("process"), createRuntimeDynamicTool("message"), ]); - const sessionFile = path.join(tempDir, "session.jsonl"); - const workspaceDir = path.join(tempDir, "workspace"); - const params = createParams(sessionFile, workspaceDir); params.disableTools = false; params.runtimePlan = createCodexRuntimePlanFixture(); const sandbox = { enabled: true, backendId: "docker" } as never; @@ -1604,13 +1602,13 @@ describe("Codex app-server dynamic tool build", () => { pluginConfig: {}, }, ])("does not expose the Gateway shell path under $label", async (testCase) => { - setOpenClawCodingToolsFactoryForTests(() => [ + const workspaceDir = path.join(tempDir, "workspace"); + const params = createParams(path.join(tempDir, "gateway-policy-session.jsonl"), workspaceDir); + setCodexTestToolFactory(params, () => [ createRuntimeDynamicTool("exec"), createRuntimeDynamicTool("process"), createRuntimeDynamicTool("message"), ]); - const workspaceDir = path.join(tempDir, "workspace"); - const params = createParams(path.join(tempDir, "gateway-policy-session.jsonl"), workspaceDir); params.disableTools = false; params.runtimePlan = createCodexRuntimePlanFixture(); Object.assign(params, testCase.params); @@ -1651,12 +1649,13 @@ describe("Codex app-server dynamic tool build", () => { cacheKey: String(available), isAvailable: () => available, }); - setOpenClawCodingToolsFactoryForTests(() => [ + + const workspaceDir = path.join(tempDir, "workspace"); + const params = createParams(path.join(tempDir, "eligibility.jsonl"), workspaceDir); + setCodexTestToolFactory(params, () => [ createRuntimeDynamicTool("exec"), createRuntimeDynamicTool("message"), ]); - const workspaceDir = path.join(tempDir, "workspace"); - const params = createParams(path.join(tempDir, "eligibility.jsonl"), workspaceDir); params.disableTools = false; params.runtimePlan = createCodexRuntimePlanFixture(); for (const host of ["auto", "node"] as const) { @@ -1671,9 +1670,9 @@ describe("Codex app-server dynamic tool build", () => { }); it("shares discovery across attempt catalogs but refreshes the next attempt", async () => { - setOpenClawCodingToolsFactoryForTests(() => [createRuntimeDynamicTool("exec")]); const workspaceDir = path.join(tempDir, "workspace"); const params = createParams(path.join(tempDir, "catalog-discovery.jsonl"), workspaceDir); + setCodexTestToolFactory(params, () => [createRuntimeDynamicTool("exec")]); params.disableTools = false; params.runtimePlan = createCodexRuntimePlanFixture(); hoisted.loadNodeExecAvailability.mockClear(); @@ -1695,9 +1694,9 @@ describe("Codex app-server dynamic tool build", () => { }); it("propagates cancellation during node discovery without publishing tools", async () => { - setOpenClawCodingToolsFactoryForTests(() => [createRuntimeDynamicTool("exec")]); const workspaceDir = path.join(tempDir, "workspace"); const params = createParams(path.join(tempDir, "cancel-discovery.jsonl"), workspaceDir); + setCodexTestToolFactory(params, () => [createRuntimeDynamicTool("exec")]); params.disableTools = false; params.runtimePlan = createCodexRuntimePlanFixture(); const runAbortController = new AbortController(); @@ -1742,10 +1741,11 @@ describe("Codex app-server dynamic tool build", () => { ], details: { status: "running" }, }); - setOpenClawCodingToolsFactoryForTests(() => [execTool, createRuntimeDynamicTool("message")]); + const sessionFile = path.join(tempDir, "session.jsonl"); const workspaceDir = path.join(tempDir, "workspace"); const params = createParams(sessionFile, workspaceDir); + setCodexTestToolFactory(params, () => [execTool, createRuntimeDynamicTool("message")]); params.disableTools = false; params.runtimePlan = createCodexRuntimePlanFixture(); params.execOverrides = { @@ -1808,6 +1808,10 @@ describe("Codex app-server dynamic tool build", () => { const runtimePolicySessionFile = path.join(tempDir, "runtime-policy-session.jsonl"); const runtimePolicyParams = createParams(runtimePolicySessionFile, workspaceDir); + setCodexTestToolFactory(runtimePolicyParams, () => [ + execTool, + createRuntimeDynamicTool("message"), + ]); runtimePolicyParams.disableTools = false; runtimePolicyParams.runtimePlan = createCodexRuntimePlanFixture(); runtimePolicyParams.sessionKey = "agent:main:session-1"; @@ -1945,9 +1949,10 @@ describe("Codex app-server dynamic tool build", () => { const execTool = createRuntimeDynamicTool("exec"); const processTool = createRuntimeDynamicTool("process"); const messageTool = createRuntimeDynamicTool("message"); - setOpenClawCodingToolsFactoryForTests(() => [execTool, processTool, messageTool]); + const workspaceDir = path.join(tempDir, "workspace"); const params = createParams(path.join(tempDir, "restricted-session.jsonl"), workspaceDir); + setCodexTestToolFactory(params, () => [execTool, processTool, messageTool]); params.disableTools = false; params.runtimePlan = createCodexRuntimePlanFixture(); params.toolsAllow = ["exec", "process", "message"]; @@ -2017,7 +2022,9 @@ describe("Codex app-server dynamic tool build", () => { ])( "preserves shared runtime selectors in Codex dynamic tools: $allow", async ({ allow, expected }) => { - setOpenClawCodingToolsFactoryForTests(() => + const workspaceDir = path.join(tempDir, "workspace"); + const params = createParams(path.join(tempDir, "selector-session.jsonl"), workspaceDir); + setCodexTestToolFactory(params, () => [ "automations", "read", @@ -2031,8 +2038,6 @@ describe("Codex app-server dynamic tool build", () => { "message", ].map(createRuntimeDynamicTool), ); - const workspaceDir = path.join(tempDir, "workspace"); - const params = createParams(path.join(tempDir, "selector-session.jsonl"), workspaceDir); params.disableTools = false; params.runtimePlan = createCodexRuntimePlanFixture(); params.execOverrides = { host: "gateway" }; @@ -2055,14 +2060,14 @@ describe("Codex app-server dynamic tool build", () => { ])( "keeps Docker shell projections pinned for runtime selectors $allow restricted by $restrictWith", async ({ allow, restrictWith, expected }) => { - setOpenClawCodingToolsFactoryForTests(() => [ + const sessionFile = path.join(tempDir, "session.jsonl"); + const workspaceDir = path.join(tempDir, "workspace"); + const params = createParams(sessionFile, workspaceDir); + setCodexTestToolFactory(params, () => [ createRuntimeDynamicTool("exec"), createRuntimeDynamicTool("process"), createRuntimeDynamicTool("message"), ]); - const sessionFile = path.join(tempDir, "session.jsonl"); - const workspaceDir = path.join(tempDir, "workspace"); - const params = createParams(sessionFile, workspaceDir); params.disableTools = false; params.runtimePlan = createCodexRuntimePlanFixture(); params.toolsAllow = allow; @@ -2112,14 +2117,14 @@ describe("Codex app-server dynamic tool build", () => { ); it("exposes node shell but not sandbox shell tools when sandbox routing is disabled", async () => { - setOpenClawCodingToolsFactoryForTests(() => [ + const sessionFile = path.join(tempDir, "session.jsonl"); + const workspaceDir = path.join(tempDir, "workspace"); + const params = createParams(sessionFile, workspaceDir); + setCodexTestToolFactory(params, () => [ createRuntimeDynamicTool("exec"), createRuntimeDynamicTool("process"), createRuntimeDynamicTool("message"), ]); - const sessionFile = path.join(tempDir, "session.jsonl"); - const workspaceDir = path.join(tempDir, "workspace"); - const params = createParams(sessionFile, workspaceDir); params.disableTools = false; params.runtimePlan = createCodexRuntimePlanFixture(); @@ -2137,13 +2142,13 @@ describe("Codex app-server dynamic tool build", () => { }); it("does not expose sandbox_exec without a matching process follow-up tool", async () => { - setOpenClawCodingToolsFactoryForTests(() => [ - createRuntimeDynamicTool("exec"), - createRuntimeDynamicTool("message"), - ]); const sessionFile = path.join(tempDir, "session.jsonl"); const workspaceDir = path.join(tempDir, "workspace"); const params = createParams(sessionFile, workspaceDir); + setCodexTestToolFactory(params, () => [ + createRuntimeDynamicTool("exec"), + createRuntimeDynamicTool("message"), + ]); params.disableTools = false; params.runtimePlan = createCodexRuntimePlanFixture(); @@ -2156,14 +2161,14 @@ describe("Codex app-server dynamic tool build", () => { }); it("honors Codex dynamic tool excludes for sandbox shell exposure", async () => { - setOpenClawCodingToolsFactoryForTests(() => [ + const sessionFile = path.join(tempDir, "session.jsonl"); + const workspaceDir = path.join(tempDir, "workspace"); + const params = createParams(sessionFile, workspaceDir); + setCodexTestToolFactory(params, () => [ createRuntimeDynamicTool("exec"), createRuntimeDynamicTool("process"), createRuntimeDynamicTool("message"), ]); - const sessionFile = path.join(tempDir, "session.jsonl"); - const workspaceDir = path.join(tempDir, "workspace"); - const params = createParams(sessionFile, workspaceDir); params.disableTools = false; params.runtimePlan = createCodexRuntimePlanFixture(); @@ -2197,7 +2202,7 @@ describe("Codex app-server dynamic tool build", () => { params.messageActionTurnCapability = "turn-capability-1"; params.runtimePlan = createCodexRuntimePlanFixture(); const factoryOptions: unknown[] = []; - setOpenClawCodingToolsFactoryForTests((options) => { + setCodexTestToolFactory(params, (options) => { factoryOptions.push(options); return []; }); @@ -2222,7 +2227,7 @@ describe("Codex app-server dynamic tool build", () => { params.preparedModelRuntime = { metadataSnapshot: { plugins: [] } } as never; params.runtimePlan = createCodexRuntimePlanFixture(); const factoryOptions: unknown[] = []; - setOpenClawCodingToolsFactoryForTests((options) => { + setCodexTestToolFactory(params, (options) => { factoryOptions.push(options); return options?.senderIsOwner && options.preparedModelRuntime ? [createRuntimeDynamicTool("intent")] @@ -2249,7 +2254,7 @@ describe("Codex app-server dynamic tool build", () => { params.currentMessagingTarget = "user:U123"; params.runtimePlan = createCodexRuntimePlanFixture(); const factoryOptions: unknown[] = []; - setOpenClawCodingToolsFactoryForTests((options) => { + setCodexTestToolFactory(params, (options) => { factoryOptions.push(options); return []; }); @@ -2272,7 +2277,7 @@ describe("Codex app-server dynamic tool build", () => { params.approvalReviewerDeviceId = "device-ios-reviewer"; params.runtimePlan = createCodexRuntimePlanFixture(); const factoryOptions: unknown[] = []; - setOpenClawCodingToolsFactoryForTests((options) => { + setCodexTestToolFactory(params, (options) => { factoryOptions.push(options); return []; }); @@ -2295,7 +2300,7 @@ describe("Codex app-server dynamic tool build", () => { params.allocateToolOutcomeOrdinal = allocateToolOutcomeOrdinal; params.runtimePlan = createCodexRuntimePlanFixture(); const factoryOptions: unknown[] = []; - setOpenClawCodingToolsFactoryForTests((options) => { + setCodexTestToolFactory(params, (options) => { factoryOptions.push(options); return []; }); @@ -2328,7 +2333,7 @@ describe("Codex app-server dynamic tool build", () => { } }); params.isTurnTainted = () => turnTainted; - setOpenClawCodingToolsFactoryForTests((options) => { + setCodexTestToolFactory(params, (options) => { const filesystemTools = createOpenClawCodingTools(options).filter((tool) => ["write", "edit"].includes(tool.name), ); @@ -2427,7 +2432,7 @@ describe("Codex app-server dynamic tool build", () => { agentId: "main", sessionId: params.sessionId, }); - setOpenClawCodingToolsFactoryForTests(() => [wrappedTool]); + setCodexTestToolFactory(params, () => [wrappedTool]); const tools = await buildDynamicToolsForTest(params, workspaceDir, { sandbox: null as never }); @@ -2454,7 +2459,7 @@ describe("Codex app-server dynamic tool build", () => { ...createRuntimeDynamicTool("invalid_registered_tool"), parameters: { type: "array", items: { type: "string" } }, }; - setOpenClawCodingToolsFactoryForTests((options) => [ + setCodexTestToolFactory(params, (options) => [ messageTool, ...(options?.enableHeartbeatTool === true ? [heartbeatTool, invalidTool] : []), ]); @@ -2509,7 +2514,7 @@ describe("Codex app-server dynamic tool build", () => { params.config = runtimeConfig; params.runtimePlan = createCodexRuntimePlanFixture(); const factoryOptions: unknown[] = []; - setOpenClawCodingToolsFactoryForTests((options) => { + setCodexTestToolFactory(params, (options) => { factoryOptions.push(options); return []; }); @@ -2543,7 +2548,7 @@ describe("Codex app-server dynamic tool build", () => { params.sessionRoot = workspaceDir; params.runtimePlan = createCodexRuntimePlanFixture(); const factoryOptions: unknown[] = []; - setOpenClawCodingToolsFactoryForTests((options) => { + setCodexTestToolFactory(params, (options) => { factoryOptions.push(options); return []; }); @@ -2571,7 +2576,7 @@ describe("Codex app-server dynamic tool build", () => { params.model = { ...params.model, provider: "openai", id: "gpt-5.6-sol" }; params.runtimePlan = createCodexRuntimePlanFixture(); const factoryOptions: unknown[] = []; - setOpenClawCodingToolsFactoryForTests((options) => { + setCodexTestToolFactory(params, (options) => { factoryOptions.push(options); return []; }); @@ -2627,7 +2632,7 @@ describe("Codex app-server dynamic tool build", () => { params.toolAuthProfileStore = toolAuthProfileStore; params.runtimePlan = createCodexRuntimePlanFixture(); const factoryOptions: unknown[] = []; - setOpenClawCodingToolsFactoryForTests((options) => { + setCodexTestToolFactory(params, (options) => { factoryOptions.push(options); return []; }); @@ -2663,7 +2668,7 @@ describe("Codex app-server dynamic tool build", () => { }, }; const factoryOptions: unknown[] = []; - setOpenClawCodingToolsFactoryForTests((options) => { + setCodexTestToolFactory(params, (options) => { factoryOptions.push(options); return []; }); @@ -2684,7 +2689,7 @@ describe("Codex app-server dynamic tool build", () => { params.disableTools = false; params.runtimePlan = createCodexRuntimePlanFixture(); const factoryOptions: unknown[] = []; - setOpenClawCodingToolsFactoryForTests((options) => { + setCodexTestToolFactory(params, (options) => { factoryOptions.push(options); return [createRuntimeDynamicTool("sessions_spawn")]; }); @@ -2970,7 +2975,7 @@ describe("Codex app-server dynamic tool build", () => { const replyOperation = { acceptedSteeredInboundAudio: false }; params.replyOperation = replyOperation as EmbeddedRunAttemptParams["replyOperation"]; params.sourceReplyDeliveryMode = "message_tool_only"; - setOpenClawCodingToolsFactoryForTests((options) => + setCodexTestToolFactory(params, (options) => createOpenClawCodingTools(options).filter((tool) => tool.name === "message"), ); const tools = await buildDynamicToolsForTest(params, workspaceDir, { @@ -3043,7 +3048,7 @@ describe("Codex app-server dynamic tool build", () => { params.requireExplicitMessageTarget = required; const factory = vi.fn((_options: Parameters[0]) => []); const onMessageToolTargetResolved = vi.fn(); - setOpenClawCodingToolsFactoryForTests(factory); + setCodexTestToolFactory(params, factory); await buildDynamicToolsForTest(params, workspaceDir, { onMessageToolTargetResolved }); expect(factory.mock.calls[0]?.[0]?.requireExplicitMessageTarget).toBe(expected); expect(onMessageToolTargetResolved).toHaveBeenCalledExactlyOnceWith(expected); @@ -3055,7 +3060,7 @@ describe("Codex app-server dynamic tool build", () => { const params = createParams(path.join(tempDir, "session.jsonl"), workspaceDir); params.disableTools = false; params.runtimePlan = createCodexRuntimePlanFixture(); - setOpenClawCodingToolsFactoryForTests((options) => + setCodexTestToolFactory(params, (options) => createOpenClawCodingTools(options).filter((tool) => tool.name === "message"), ); diff --git a/extensions/codex/src/app-server/dynamic-tool-build.ts b/extensions/codex/src/app-server/dynamic-tool-build.ts index 96557d82194a..f36122026941 100644 --- a/extensions/codex/src/app-server/dynamic-tool-build.ts +++ b/extensions/codex/src/app-server/dynamic-tool-build.ts @@ -37,7 +37,6 @@ import { readCodexPluginConfig, type CodexPluginConfig, } from "./config.js"; -import { dynamicToolBuildState } from "./dynamic-tool-build-state.js"; import { filterCodexDynamicTools, filterCodexDynamicToolsForDisabledNativeSurface, @@ -243,7 +242,6 @@ export async function buildDynamicTools( const toolBuildStages = createCodexDynamicToolBuildStageTracker(); const modelHasVision = params.model.input?.includes("image") ?? false; const agentDir = params.agentDir ?? resolveAgentDir(params.config ?? {}, input.sessionAgentId); - const injectedOpenClawCodingToolsFactory = dynamicToolBuildState.openClawCodingToolsFactory; const nativeExecutionPolicy = resolveCodexNativeExecutionPolicyForRun(params, { agentId: input.policyAgentId, runtimeSessionKey: input.sandboxSessionKey, @@ -313,7 +311,6 @@ export async function buildDynamicTools( ...(params.skillLibraryAuthoring ? { skillWorkshop: { libraryAuthoring: params.skillLibraryAuthoring } } : {}), - githubPublicationAvailable: params.githubPublicationAvailable, authProfileStore: params.toolAuthProfileStore ?? params.authProfileStore, abortSignal: input.runAbortController.signal, emitBeforeToolCallDiagnostics: false, @@ -369,12 +366,6 @@ export async function buildDynamicTools( input.onMessageToolTargetResolved?.(options.requireExplicitMessageTarget === true); const buildOpenClawCodingTools = () => { const bindingOptions = { cwd: input.effectiveCwd ?? input.effectiveWorkspace }; - if (injectedOpenClawCodingToolsFactory) { - return params.hostCapabilities.bindToolSurface( - injectedOpenClawCodingToolsFactory(options), - bindingOptions, - ); - } const createToolSurface = params.hostCapabilities.createToolSurface; if (!createToolSurface) { throw new Error("Codex tool construction requires a current host capability"); diff --git a/extensions/codex/src/app-server/host-capability.test-support.ts b/extensions/codex/src/app-server/host-capability.test-support.ts index 67364deb6f04..1f7fd3cc5774 100644 --- a/extensions/codex/src/app-server/host-capability.test-support.ts +++ b/extensions/codex/src/app-server/host-capability.test-support.ts @@ -1,14 +1,48 @@ +import { createOpenClawCodingTools } from "openclaw/plugin-sdk/agent-harness"; import type { EmbeddedRunAttemptParamsV2 as EmbeddedRunAttemptParams } from "openclaw/plugin-sdk/agent-harness-runtime"; +type ToolsFactory = typeof createOpenClawCodingTools; +type HostCapabilities = EmbeddedRunAttemptParams["hostCapabilities"]; +const toolFactories = new WeakMap(); + +export function setCodexTestToolFactory( + params: Pick, + factory: ToolsFactory, +): void { + if (!toolFactories.has(params.hostCapabilities)) { + throw new Error( + "Synthetic tools require a lightweight test host; keep real host identity intact.", + ); + } + toolFactories.set(params.hostCapabilities, factory); + toolFactories.set(params, factory); +} + +export function getCodexTestToolFactory( + params: Pick, +): ToolsFactory | undefined { + return toolFactories.get(params) ?? toolFactories.get(params.hostCapabilities); +} + /** Minimal host authority for tests that do not exercise host policy or approvals. */ -export function createCodexTestHostCapabilities(): EmbeddedRunAttemptParams["hostCapabilities"] { - return Object.freeze({ +export function createCodexTestHostCapabilities( + overrides: Partial> = {}, +): HostCapabilities { + const host: HostCapabilities = Object.freeze({ kind: "agent-harness-host-capability", version: 1, assertActive: () => {}, bindToolSurface: (tools) => tools, + createToolSurface: (options, bindingOptions) => + host.bindToolSurface( + (toolFactories.get(host) ?? createOpenClawCodingTools)(options), + bindingOptions, + ), runBeforeToolCall: async (request) => ({ blocked: false, params: request.params }), requestApproval: async () => undefined, waitForApproval: async () => undefined, + ...overrides, }); + toolFactories.set(host, undefined); + return host; } diff --git a/extensions/codex/src/app-server/run-attempt-test-harness.ts b/extensions/codex/src/app-server/run-attempt-test-harness.ts index 47ac4b5a86c8..8fe7e96bebea 100644 --- a/extensions/codex/src/app-server/run-attempt-test-harness.ts +++ b/extensions/codex/src/app-server/run-attempt-test-harness.ts @@ -2,7 +2,6 @@ import fs from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { createOpenClawCodingTools } from "openclaw/plugin-sdk/agent-harness"; import { abortAndDrainAgentHarnessRun, nativeHookRelayTesting, @@ -12,6 +11,7 @@ import { type EmbeddedRunAttemptParamsV2 as EmbeddedRunAttemptParams, } from "openclaw/plugin-sdk/agent-harness-runtime"; import { clearRuntimeAuthProfileStoreSnapshots } from "openclaw/plugin-sdk/agent-runtime"; +import { setHostToolFactoryForTest } from "openclaw/plugin-sdk/agent-runtime-test-contracts"; import { resetDiagnosticEventsForTest } from "openclaw/plugin-sdk/diagnostic-runtime"; import type { ExecApprovalsFile } from "openclaw/plugin-sdk/exec-approvals-runtime"; import { clearInternalHooks, resetGlobalHookRunner } from "openclaw/plugin-sdk/hook-runtime"; @@ -39,8 +39,11 @@ import { turnStartResult, } from "./codex-app-server.test-fixtures.js"; import * as codexRequirements from "./config-requirements.js"; -import { dynamicToolBuildState } from "./dynamic-tool-build-state.js"; import { createCodexDynamicToolBridge } from "./dynamic-tools.js"; +import { + createCodexTestHostCapabilities, + getCodexTestToolFactory, +} from "./host-capability.test-support.js"; import { setManagedCodexPluginRoot } from "./managed-binary.js"; import { nativeHookRelayUnregisterQueue } from "./native-hook-relay-state.js"; import { defaultCodexPluginMetadataCache } from "./plugin-metadata-cache.js"; @@ -75,12 +78,7 @@ const execApprovalsRuntimeMocks = vi.hoisted(() => ({ function createHarnessHostCapabilities( params: EmbeddedRunAttemptParams, ): EmbeddedRunAttemptParams["hostCapabilities"] { - return Object.freeze({ - kind: "agent-harness-host-capability", - version: 1, - assertActive: () => {}, - bindToolSurface: (tools) => tools, - createToolSurface: (options) => createOpenClawCodingTools(options), + return createCodexTestHostCapabilities({ runBeforeToolCall: async ({ nativeOperation: _nativeOperation, approvalMode, ...request }) => await runBeforeToolCallHook({ ...request, @@ -102,8 +100,6 @@ function createHarnessHostCapabilities( turnSourceThreadId: params.currentThreadTs, }), }), - requestApproval: async () => undefined, - waitForApproval: async () => undefined, }); } @@ -350,6 +346,10 @@ export function createNativeRunParams( export async function bindProductionHarnessHostCapabilitiesForTest( params: EmbeddedRunAttemptParams, ): Promise<() => void> { + const factory = getCodexTestToolFactory(params); + if (factory) { + await setHostToolFactoryForTest(params, factory); + } const { hostCapabilities: _hostCapabilities, ...attempt } = params; const host = await createAgentHarnessHostCapabilitiesForTest({ attempt, pluginId: "codex" }); params.hostCapabilities = host.capabilities; @@ -729,7 +729,6 @@ export function setupRunAttemptTestHooks(): void { resetCodexAppServerClientFactoryForTest(); setManagedCodexPluginRoot(undefined); clearRuntimeAuthProfileStoreSnapshots(); - dynamicToolBuildState.openClawCodingToolsFactory = undefined; codexWorkspaceDirCache.clear(); await nativeHookRelayUnregisterQueue.clear(); await nativeHookRelayTesting.clearNativeHookRelaysForTests(); diff --git a/extensions/codex/src/app-server/run-attempt.agent-end-context.test.ts b/extensions/codex/src/app-server/run-attempt.agent-end-context.test.ts index 0c109dc7c719..f36a41b3da51 100644 --- a/extensions/codex/src/app-server/run-attempt.agent-end-context.test.ts +++ b/extensions/codex/src/app-server/run-attempt.agent-end-context.test.ts @@ -4,7 +4,7 @@ import { createDeferred } from "openclaw/plugin-sdk/extension-shared"; import { upsertSessionEntry } from "openclaw/plugin-sdk/session-store-runtime"; import { formatSqliteSessionFileMarker } from "openclaw/plugin-sdk/sqlite-runtime-testing"; import { describe, expect, it, vi } from "vitest"; -import { dynamicToolBuildState } from "./dynamic-tool-build-state.js"; +import { setCodexTestToolFactory } from "./host-capability.test-support.js"; import { createCodexRuntimePlanFixture, createParams, @@ -58,9 +58,7 @@ describe("runCodexAppServerAttempt agent-end context", () => { params.messageChannel = "discord"; params.memberRoleIds = ["maintainer-role"]; setCodexTestModelSupportsTools(params, true); - dynamicToolBuildState.openClawCodingToolsFactory = () => [ - createRuntimeDynamicTool("skill_workshop"), - ]; + setCodexTestToolFactory(params, () => [createRuntimeDynamicTool("skill_workshop")]); // Protocol events drive these cases; host load must not spend the execution budget. vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"] }); diff --git a/extensions/codex/src/app-server/run-attempt.durable-context.test.ts b/extensions/codex/src/app-server/run-attempt.durable-context.test.ts index edb58c236301..2855f86f826c 100644 --- a/extensions/codex/src/app-server/run-attempt.durable-context.test.ts +++ b/extensions/codex/src/app-server/run-attempt.durable-context.test.ts @@ -3,7 +3,7 @@ import { SessionManager } from "openclaw/plugin-sdk/agent-sessions"; import { appendSessionTranscriptMessageByIdentity } from "openclaw/plugin-sdk/session-transcript-runtime"; import { expect, it, vi } from "vitest"; import { projectContextEngineAssemblyForCodex } from "./context-engine-projection.js"; -import { dynamicToolBuildState } from "./dynamic-tool-build-state.js"; +import { setCodexTestToolFactory } from "./host-capability.test-support.js"; import { assistantMessage, bindProductionHarnessHostCapabilitiesForTest, @@ -180,9 +180,9 @@ it("does not replay covered history on the same thread after local message-tool content: [{ type: "text" as const, text: "Sent." }], details: { messageId: "telegram-123" }, })); - dynamicToolBuildState.openClawCodingToolsFactory = () => [messageTool]; const params = createParams(sessionFile, workspaceDir); + setCodexTestToolFactory(params, () => [messageTool]); await attachSqliteSessionTarget( params, path.join(tempDir, "local-source-reply-sessions.json"), diff --git a/extensions/codex/src/app-server/run-attempt.dynamic-tools.test.ts b/extensions/codex/src/app-server/run-attempt.dynamic-tools.test.ts index 7145b00bc517..77fe2ec319e6 100644 --- a/extensions/codex/src/app-server/run-attempt.dynamic-tools.test.ts +++ b/extensions/codex/src/app-server/run-attempt.dynamic-tools.test.ts @@ -12,12 +12,12 @@ import { initializeGlobalHookRunner } from "openclaw/plugin-sdk/hook-runtime"; import { createMockPluginRegistry } from "openclaw/plugin-sdk/plugin-test-runtime"; import { describe, expect, it, vi } from "vitest"; import { readAttemptTerminal } from "./attempt-terminal.test-helper.js"; -import { dynamicToolBuildState } from "./dynamic-tool-build-state.js"; import { emitDynamicToolStartedDiagnostic, emitDynamicToolTerminalDiagnostic, } from "./dynamic-tool-diagnostics.js"; import { hasPendingDynamicToolTerminalDiagnostic } from "./dynamic-tool-execution.js"; +import { setCodexTestToolFactory } from "./host-capability.test-support.js"; import type { CodexDynamicToolCallParams } from "./protocol.js"; import { bindProductionHarnessHostCapabilitiesForTest, @@ -63,14 +63,13 @@ setupRunAttemptTestHooks(); describe("runCodexAppServerAttempt dynamic tools", () => { it("acknowledges a terminal sandbox process poll only after Codex accepts its exact result", async () => { const process = createProcessPollDeliveryContract("codex-result-delivery"); - dynamicToolBuildState.openClawCodingToolsFactory = () => [ - { ...process.tool, name: "sandbox_process" }, - ]; + const harness = createStartedThreadHarness(); const params = createParams( path.join(tempDir, "session.jsonl"), path.join(tempDir, "workspace"), ); + setCodexTestToolFactory(params, () => [{ ...process.tool, name: "sandbox_process" }]); params.runtimePlan = createCodexRuntimePlanFixture(); setCodexTestModelSupportsTools(params, true); const closeHostCapabilities = await bindProductionHarnessHostCapabilitiesForTest(params); @@ -153,12 +152,13 @@ describe("runCodexAppServerAttempt dynamic tools", () => { details: { status: "no_answer" }, }; }); - dynamicToolBuildState.openClawCodingToolsFactory = () => [tool]; + const harness = createStartedThreadHarness(); const params = createParams( path.join(tempDir, "session.jsonl"), path.join(tempDir, "workspace"), ); + setCodexTestToolFactory(params, () => [tool]); params.runtimePlan = createCodexRuntimePlanFixture(); params.timeoutMs = waitMs + 120_000; setCodexTestModelSupportsTools(params, true); @@ -229,7 +229,7 @@ describe("runCodexAppServerAttempt dynamic tools", () => { }; }); tool.execute = execute; - dynamicToolBuildState.openClawCodingToolsFactory = () => [tool]; + const harness = createStartedThreadHarness(); let closeHostCapabilities: (() => void) | undefined; const unsubscribeDiagnostics = onInternalDiagnosticEvent((event) => { @@ -242,6 +242,7 @@ describe("runCodexAppServerAttempt dynamic tools", () => { path.join(tempDir, "session.jsonl"), path.join(tempDir, "workspace"), ); + setCodexTestToolFactory(params, () => [tool]); setCodexTestModelSupportsTools(params, true); closeHostCapabilities = await bindProductionHarnessHostCapabilitiesForTest(params); const runtimePlan = createCodexRuntimePlanFixture(); @@ -882,7 +883,7 @@ describe("runCodexAppServerAttempt dynamic tools", () => { params.sandboxSessionKey = "agent:main:policy"; params.runtimePlan = createCodexRuntimePlanFixture(); setCodexTestModelSupportsTools(params, true); - dynamicToolBuildState.openClawCodingToolsFactory = () => [createRuntimeDynamicTool("echo")]; + setCodexTestToolFactory(params, () => [createRuntimeDynamicTool("echo")]); const harness = createStartedThreadHarness(); const closeHostCapabilities = await bindProductionHarnessHostCapabilitiesForTest(params); const run = runCodexAppServerAttempt(params); diff --git a/extensions/codex/src/app-server/run-attempt.native-config.test.ts b/extensions/codex/src/app-server/run-attempt.native-config.test.ts index 524be1531197..b106c1053055 100644 --- a/extensions/codex/src/app-server/run-attempt.native-config.test.ts +++ b/extensions/codex/src/app-server/run-attempt.native-config.test.ts @@ -10,7 +10,7 @@ import { createMockPluginRegistry } from "openclaw/plugin-sdk/plugin-test-runtim import { describe, expect, it, vi } from "vitest"; import { CodexAppServerClient } from "./client.js"; import { resolveCodexSupervisionAppServerRuntimeOptions } from "./config.js"; -import { dynamicToolBuildState } from "./dynamic-tool-build-state.js"; +import { setCodexTestToolFactory } from "./host-capability.test-support.js"; import { buildCodexAppServerConnectionFingerprint } from "./plugin-app-cache-key.js"; import { isJsonObject } from "./protocol.js"; import { @@ -192,11 +192,13 @@ describe("Codex native configuration", () => { }); const start = vi.spyOn(CodexAppServerClient, "start").mockResolvedValue(harness.client); const clientFactory = vi.fn(sharedClientModule.getLeasedSharedCodexAppServerClient); - dynamicToolBuildState.openClawCodingToolsFactory = () => - nativeSearchEnabled ? [createRuntimeDynamicTool("web_search")] : []; + // This test owns review-policy projection, not requester-scoped MCP discovery. agentHarnessRuntimeMocks.forceModelToolsUnsupported = !nativeSearchEnabled; const params = createParams(sessionFile, workspaceDir); + setCodexTestToolFactory(params, () => + nativeSearchEnabled ? [createRuntimeDynamicTool("web_search")] : [], + ); params.registerPluginRuntimeRefreshConsumer = vi.fn(); params.agentDir = agentDir; params.provider = "anthropic"; diff --git a/extensions/codex/src/app-server/run-attempt.plugin-refresh.test.ts b/extensions/codex/src/app-server/run-attempt.plugin-refresh.test.ts index cf98c6c30700..153695a87ffe 100644 --- a/extensions/codex/src/app-server/run-attempt.plugin-refresh.test.ts +++ b/extensions/codex/src/app-server/run-attempt.plugin-refresh.test.ts @@ -7,9 +7,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { readAttemptTerminal } from "./attempt-terminal.test-helper.js"; import { resolveCodexAppServerHomeDir } from "./auth-start-options.js"; import { CodexAppServerClient } from "./client.js"; -import { dynamicToolBuildState } from "./dynamic-tool-build-state.js"; import { CodexAppServerEventProjector } from "./event-projector.js"; import { buildEmptyToolTelemetry } from "./event-projector.test-harness.js"; +import { setCodexTestToolFactory } from "./host-capability.test-support.js"; import { isJsonObject } from "./protocol.js"; import { bindProductionHarnessHostCapabilitiesForTest, @@ -118,11 +118,12 @@ describe("managed Codex plugin refresh", () => { details: {}, }; }); - dynamicToolBuildState.openClawCodingToolsFactory = () => [slow, reload]; + const params = createParams( path.join(tempDir, "session.jsonl"), path.join(tempDir, "workspace"), ); + setCodexTestToolFactory(params, () => [slow, reload]); const originalTask = "Reload the plugin and verify the changed behavior."; const receipt = "already-committed-effect-42"; const prior = new CodexAppServerEventProjector( diff --git a/extensions/codex/src/app-server/run-attempt.question-refresh.test.ts b/extensions/codex/src/app-server/run-attempt.question-refresh.test.ts index bbc6e01fcc46..265be2711c5d 100644 --- a/extensions/codex/src/app-server/run-attempt.question-refresh.test.ts +++ b/extensions/codex/src/app-server/run-attempt.question-refresh.test.ts @@ -5,7 +5,7 @@ import { loadUserTurnTranscriptRecorderFactoryForTest } from "openclaw/plugin-sd import { upsertSessionEntry } from "openclaw/plugin-sdk/session-store-runtime"; import { describe, expect, it, vi } from "vitest"; import { projectContextEngineAssemblyForCodex } from "./context-engine-projection.js"; -import { dynamicToolBuildState } from "./dynamic-tool-build-state.js"; +import { setCodexTestToolFactory } from "./host-capability.test-support.js"; import type { CodexServerNotification } from "./protocol.js"; import { bindProductionHarnessHostCapabilitiesForTest, @@ -131,7 +131,7 @@ describe("runCodexAppServerAttempt question refresh", () => { pendingRefresh = true; return { content: [{ type: "text" as const, text: "generation changed" }], details: {} }; }); - dynamicToolBuildState.openClawCodingToolsFactory = () => [reload]; + setCodexTestToolFactory(params, () => [reload]); params.pluginRuntimeRefreshPending = () => pendingRefresh; if (!params.sessionKey) { throw new Error("Expected the fixture's managed session key"); diff --git a/extensions/codex/src/app-server/run-attempt.test.ts b/extensions/codex/src/app-server/run-attempt.test.ts index 7a14398ee10a..9a3655cf9f8d 100644 --- a/extensions/codex/src/app-server/run-attempt.test.ts +++ b/extensions/codex/src/app-server/run-attempt.test.ts @@ -54,7 +54,6 @@ import { resolveCodexAppServerRuntimeOptions, resolveCodexSupervisionAppServerRuntimeOptions, } from "./config.js"; -import { dynamicToolBuildState } from "./dynamic-tool-build-state.js"; import { buildDynamicTools, shouldEnableCodexAppServerNativeToolSurface, @@ -63,6 +62,7 @@ import { filterCodexDynamicTools } from "./dynamic-tool-profile.js"; import { createCodexDynamicToolBridge } from "./dynamic-tools.js"; import * as elicitationBridge from "./elicitation-bridge.js"; import { CodexAppServerEventProjector } from "./event-projector.js"; +import { setCodexTestToolFactory } from "./host-capability.test-support.js"; import { buildCodexRuntimeModelParams } from "./model-runtime.js"; import { buildCodexAppServerConnectionFingerprint, @@ -175,11 +175,6 @@ const testing = { buildDynamicTools, filterCodexDynamicTools, resolveCodexDynamicToolDirectNames, - setOpenClawCodingToolsFactoryForTests( - factory: NonNullable, - ): void { - dynamicToolBuildState.openClawCodingToolsFactory = factory; - }, shouldEnableCodexAppServerNativeToolSurface, withCodexStartupTimeout, }; @@ -1026,7 +1021,9 @@ describe("runCodexAppServerAttempt", () => { const cleanup = vi.fn(async (_reason: string) => undefined); const cleanupOwners: boolean[] = []; const preparationError = new Error(`failed during ${outcome}`); - testing.setOpenClawCodingToolsFactoryForTests((options) => { + const { sessionFile, workspaceDir } = createRunPaths(); + const params = createParams(sessionFile, workspaceDir); + setCodexTestToolFactory(params, (options) => { cleanupOwners.push(options?.registerRunCleanup !== undefined); options?.registerRunCleanup?.(cleanup); if ( @@ -1037,8 +1034,6 @@ describe("runCodexAppServerAttempt", () => { } return [createRuntimeDynamicTool("message")]; }); - const { sessionFile, workspaceDir } = createRunPaths(); - const params = createParams(sessionFile, workspaceDir); params.disableTools = false; params.runtimePlan = createCodexRuntimePlanFixture(); setCodexTestModelSupportsTools(params, true); @@ -1074,13 +1069,13 @@ describe("runCodexAppServerAttempt", () => { const cleanup = vi.fn(async () => { throw cleanupError; }); - testing.setOpenClawCodingToolsFactoryForTests((options) => { + const { sessionFile, workspaceDir } = createRunPaths(); + const params = createParams(sessionFile, workspaceDir); + setCodexTestToolFactory(params, (options) => { options?.registerRunCleanup?.(cleanup); throw preparationError; }); const warning = vi.spyOn(embeddedAgentLog, "warn"); - const { sessionFile, workspaceDir } = createRunPaths(); - const params = createParams(sessionFile, workspaceDir); params.oneShotCliRun = true; params.disableTools = false; params.runtimePlan = createCodexRuntimePlanFixture(); @@ -1156,13 +1151,13 @@ describe("runCodexAppServerAttempt", () => { expect(authProfileStore.profiles[authProfileId]).toHaveProperty("keyRef"); }); it("starts active OpenClaw sandbox threads with Codex native execution disabled", async () => { - testing.setOpenClawCodingToolsFactoryForTests(() => [ + const { sessionFile, workspaceDir } = createRunPaths(); + const params = createParams(sessionFile, workspaceDir); + setCodexTestToolFactory(params, () => [ createRuntimeDynamicTool("exec"), createRuntimeDynamicTool("process"), createRuntimeDynamicTool("message"), ]); - const { sessionFile, workspaceDir } = createRunPaths(); - const params = createParams(sessionFile, workspaceDir); params.disableTools = false; setCodexTestModelSupportsTools(params, true); params.runtimePlan = createCodexRuntimePlanFixture(); @@ -1259,14 +1254,14 @@ describe("runCodexAppServerAttempt", () => { request, }; try { - testing.setOpenClawCodingToolsFactoryForTests(() => [ + const sessionFile = path.join(tempDir, "session.jsonl"); + const workspaceDir = path.join(tempDir, "workspace"); + const params = createParams(sessionFile, workspaceDir); + setCodexTestToolFactory(params, () => [ createRuntimeDynamicTool("exec"), createRuntimeDynamicTool("process"), createRuntimeDynamicTool("message"), ]); - const sessionFile = path.join(tempDir, "session.jsonl"); - const workspaceDir = path.join(tempDir, "workspace"); - const params = createParams(sessionFile, workspaceDir); params.disableTools = false; setCodexTestModelSupportsTools(params, true); params.runtimePlan = createCodexRuntimePlanFixture(); @@ -2391,12 +2386,6 @@ describe("runCodexAppServerAttempt", () => { }); it("keeps the heartbeat schema deferred and stable across normal and heartbeat turns", async () => { - testing.setOpenClawCodingToolsFactoryForTests((options) => [ - createRuntimeDynamicTool("message"), - ...(options?.enableHeartbeatTool === true - ? [createRuntimeDynamicTool("heartbeat_respond")] - : []), - ]); const { sessionFile, workspaceDir } = createRunPaths(); const createHeartbeatRunParams = (trigger?: EmbeddedRunAttemptParams["trigger"]) => { const params = createParams(sessionFile, workspaceDir); @@ -2561,13 +2550,6 @@ describe("runCodexAppServerAttempt", () => { }); it("keeps the persistent dynamic schema stable across heartbeat-only turns", async () => { - testing.setOpenClawCodingToolsFactoryForTests((options) => [ - createRuntimeDynamicTool("message"), - createRuntimeDynamicTool("web_search"), - ...(options?.enableHeartbeatTool === true - ? [createRuntimeDynamicTool("heartbeat_respond")] - : []), - ]); const { sessionFile, workspaceDir } = createRunPaths(); const createHeartbeatRunParams = (trigger?: EmbeddedRunAttemptParams["trigger"]) => { const params = createParams(sessionFile, workspaceDir); @@ -2613,11 +2595,11 @@ describe("runCodexAppServerAttempt", () => { expect(specNames(nextNormalBridge.specs)).toEqual(specNames(normalBridge.specs)); }); it("disables Codex native tool surfaces when runtime toolsAllow is empty", async () => { - testing.setOpenClawCodingToolsFactoryForTests(() => [ + const params = createRunParams(); + setCodexTestToolFactory(params, () => [ createRuntimeDynamicTool("message"), createRuntimeDynamicTool("web_search"), ]); - const params = createRunParams(); params.disableTools = false; setCodexTestModelSupportsTools(params, true); params.runtimePlan = createCodexRuntimePlanFixture(); @@ -2771,7 +2753,9 @@ describe("runCodexAppServerAttempt", () => { details: {}, }; }); - testing.setOpenClawCodingToolsFactoryForTests((options) => { + + const params = createRunParams(); + setCodexTestToolFactory(params, (options) => { const tools = createOpenClawCodingTools(options).filter((tool) => ["read", "write", "edit", "apply_patch", "exec", "process", "progress_card"].includes( tool.name, @@ -2783,7 +2767,6 @@ describe("runCodexAppServerAttempt", () => { } return tools; }); - const params = createRunParams(); params.disableTools = false; setCodexTestModelSupportsTools(params, true); params.runtimePlan = createCodexRuntimePlanFixture(); @@ -3127,8 +3110,8 @@ describe("runCodexAppServerAttempt", () => { }); it("fails closed for Codex app defaults when restricted native tools have no plugin config", async () => { - testing.setOpenClawCodingToolsFactoryForTests(() => [createRuntimeDynamicTool("message")]); const params = createRunParams(); + setCodexTestToolFactory(params, () => [createRuntimeDynamicTool("message")]); params.disableTools = false; params.runtimePlan = createCodexRuntimePlanFixture(); params.toolsAllow = []; @@ -4523,11 +4506,12 @@ describe("runCodexAppServerAttempt", () => { await fs.writeFile(path.join(workspaceDir, "USER.md"), userProfile); await fs.writeFile(path.join(workspaceDir, "MEMORY.md"), memorySummary); registerMemoryPromptForTest(); - testing.setOpenClawCodingToolsFactoryForTests(() => [ + + const params = createParams(sessionFile, workspaceDir); + setCodexTestToolFactory(params, () => [ createRuntimeDynamicTool("memory_search"), createRuntimeDynamicTool("memory_get"), ]); - const params = createParams(sessionFile, workspaceDir); params.disableTools = false; setCodexTestModelSupportsTools(params, true); params.runtimePlan = createCodexRuntimePlanFixture(); @@ -4620,11 +4604,12 @@ describe("runCodexAppServerAttempt", () => { await fs.mkdir(path.join(workspaceDir, "memory"), { recursive: true }); await fs.writeFile(path.join(workspaceDir, "memory/2026-06-09.md"), datedMemory); registerMemoryPromptForTest(); - testing.setOpenClawCodingToolsFactoryForTests(() => [ + + const params = createParams(sessionFile, workspaceDir); + setCodexTestToolFactory(params, () => [ createRuntimeDynamicTool("memory_search"), createRuntimeDynamicTool("memory_get"), ]); - const params = createParams(sessionFile, workspaceDir); params.disableTools = false; setCodexTestModelSupportsTools(params, true); params.runtimePlan = createCodexRuntimePlanFixture(); @@ -4648,11 +4633,12 @@ describe("runCodexAppServerAttempt", () => { const memorySummary = "User avoids Chase cards while over 5/24."; await fs.mkdir(workspaceDir, { recursive: true }); await fs.writeFile(path.join(workspaceDir, "MEMORY.md"), memorySummary); - testing.setOpenClawCodingToolsFactoryForTests(() => [ + + const params = createParams(sessionFile, workspaceDir); + setCodexTestToolFactory(params, () => [ createRuntimeDynamicTool("memory_search"), createRuntimeDynamicTool("memory_get"), ]); - const params = createParams(sessionFile, workspaceDir); params.disableTools = false; setCodexTestModelSupportsTools(params, true); params.runtimePlan = createCodexRuntimePlanFixture(); @@ -4890,8 +4876,9 @@ describe("runCodexAppServerAttempt", () => { await fs.mkdir(workspaceDir, { recursive: true }); await fs.writeFile(path.join(workspaceDir, "MEMORY.md"), memorySummary); registerMemoryPromptForTest(); - testing.setOpenClawCodingToolsFactoryForTests(() => [createRuntimeDynamicTool("memory_get")]); + const params = createParams(sessionFile, workspaceDir); + setCodexTestToolFactory(params, () => [createRuntimeDynamicTool("memory_get")]); params.disableTools = false; setCodexTestModelSupportsTools(params, true); params.runtimePlan = createCodexRuntimePlanFixture(); @@ -4984,7 +4971,7 @@ describe("runCodexAppServerAttempt", () => { }, }, } as EmbeddedRunAttemptParams["config"]; - testing.setOpenClawCodingToolsFactoryForTests(() => [ + setCodexTestToolFactory(params, () => [ createRuntimeDynamicTool("memory_search"), createRuntimeDynamicTool("memory_get"), ]); @@ -5032,11 +5019,12 @@ describe("runCodexAppServerAttempt", () => { }, ]; }); - testing.setOpenClawCodingToolsFactoryForTests(() => [ + + const params = createParams(sessionFile, workspaceDir); + setCodexTestToolFactory(params, () => [ createRuntimeDynamicTool("memory_search"), createRuntimeDynamicTool("memory_get"), ]); - const params = createParams(sessionFile, workspaceDir); params.disableTools = false; setCodexTestModelSupportsTools(params, true); params.runtimePlan = createCodexRuntimePlanFixture(); @@ -5071,11 +5059,12 @@ describe("runCodexAppServerAttempt", () => { await fs.mkdir(workspaceDir, { recursive: true }); await fs.writeFile(path.join(workspaceDir, "MEMORY.md"), memorySummary); registerMemoryPromptForTest(); - testing.setOpenClawCodingToolsFactoryForTests(() => [ + + const params = createParams(sessionFile, workspaceDir); + setCodexTestToolFactory(params, () => [ createRuntimeDynamicTool("memory_search"), createRuntimeDynamicTool("memory_get"), ]); - const params = createParams(sessionFile, workspaceDir); params.disableTools = false; params.runtimePlan = createCodexRuntimePlanFixture(); setAgentWorkspaceForTest(params, path.join(tempDir, "memory-workspace")); @@ -5295,16 +5284,17 @@ describe("runCodexAppServerAttempt", () => { content: [{ type: "text" as const, text: "file contents" }], details: {}, })); - testing.setOpenClawCodingToolsFactoryForTests((options) => { + + const { sessionFile, workspaceDir } = createRunPaths(); + const harness = createStartedThreadHarness(); + const params = createParams(sessionFile, workspaceDir); + setCodexTestToolFactory(params, (options) => { const tools = createOpenClawCodingTools(options).filter((tool) => tool.name === "read"); for (const tool of tools) { tool.execute = executeRead; } return tools; }); - const { sessionFile, workspaceDir } = createRunPaths(); - const harness = createStartedThreadHarness(); - const params = createParams(sessionFile, workspaceDir); setCodexTestModelSupportsTools(params, true); params.runtimePlan = createCodexRuntimePlanFixture(); params.toolsAllow = ["read"]; diff --git a/extensions/codex/src/app-server/run-attempt.workspace-snapshot.test.ts b/extensions/codex/src/app-server/run-attempt.workspace-snapshot.test.ts index 86a741714b17..762efd084029 100644 --- a/extensions/codex/src/app-server/run-attempt.workspace-snapshot.test.ts +++ b/extensions/codex/src/app-server/run-attempt.workspace-snapshot.test.ts @@ -11,7 +11,7 @@ import { } from "openclaw/plugin-sdk/plugin-test-runtime"; import { describe, expect, it, vi } from "vitest"; import { readAttemptTerminal } from "./attempt-terminal.test-helper.js"; -import { dynamicToolBuildState } from "./dynamic-tool-build-state.js"; +import { setCodexTestToolFactory } from "./host-capability.test-support.js"; import { createCodexRuntimePlanFixture, createParams, @@ -42,10 +42,8 @@ describe("Codex workspace instruction snapshots", () => { const updatedGuidance = "Later workspace changes wait for a new session."; await fs.mkdir(agentWorkspaceDir, { recursive: true }); await fs.writeFile(path.join(agentWorkspaceDir, "AGENTS.md"), initialGuidance); - dynamicToolBuildState.openClawCodingToolsFactory = () => [ - createRuntimeDynamicTool("memory_get"), - ]; const params = createParams(sessionFile, executionDir); + setCodexTestToolFactory(params, () => [createRuntimeDynamicTool("memory_get")]); params.disableTools = false; params.runtimePlan = createCodexRuntimePlanFixture(); params.bootstrapWorkspaceDir = agentWorkspaceDir; diff --git a/extensions/codex/src/app-server/settled-turn-finalizer.native.test.ts b/extensions/codex/src/app-server/settled-turn-finalizer.native.test.ts index 5db3b3684c9a..184bc4597de2 100644 --- a/extensions/codex/src/app-server/settled-turn-finalizer.native.test.ts +++ b/extensions/codex/src/app-server/settled-turn-finalizer.native.test.ts @@ -13,7 +13,7 @@ import * as authBridge from "./auth-bridge.js"; import { runBoundedCodexAppServerTurn } from "./bounded-turn.js"; import { CodexAppServerClient } from "./client.js"; import { resolveCodexSupervisionAppServerRuntimeOptions } from "./config.js"; -import { dynamicToolBuildState } from "./dynamic-tool-build-state.js"; +import { setCodexTestToolFactory } from "./host-capability.test-support.js"; import { createCodexNativeTestState } from "./native-app-server.test-support.js"; import { buildCodexAppServerConnectionFingerprint } from "./plugin-app-cache-key.js"; import { assertCodexThreadStartResponse } from "./protocol-validators.js"; @@ -342,7 +342,7 @@ async function createRunParams(fixture: NativeFixture) { params.permissionMode = "full"; params.timeoutMs = 20_000; params.config = { tools: { web: { search: { enabled: false } } } }; - dynamicToolBuildState.openClawCodingToolsFactory = () => []; + setCodexTestToolFactory(params, () => []); registerCodexTestSessionIdentity(params.sessionFile, params.sessionId, params.sessionKey); return params; } diff --git a/extensions/codex/src/app-server/side-question.execution.test.ts b/extensions/codex/src/app-server/side-question.execution.test.ts index 6c4aee774445..e4dd3964cdb6 100644 --- a/extensions/codex/src/app-server/side-question.execution.test.ts +++ b/extensions/codex/src/app-server/side-question.execution.test.ts @@ -4,9 +4,9 @@ import { createAdmittedHostCapabilityTestFixture } from "openclaw/plugin-sdk/plu import { afterEach, describe, expect, it, vi } from "vitest"; import * as clientCleanup from "./attempt-client-cleanup.js"; import { codexTestTurnIds } from "./codex-app-server.test-fixtures.js"; -import { dynamicToolBuildState } from "./dynamic-tool-build-state.js"; import { CodexEphemeralTurn } from "./ephemeral-turn.js"; import { CodexNativeToolLifecycleProjector } from "./event-projector-native-tool-lifecycle.js"; +import { createCodexTestBindingStore } from "./session-binding.test-helpers.js"; import { createClientHarness, createCodexTestModel, @@ -14,10 +14,10 @@ import { } from "./test-support.js"; const { - readCodexAppServerBindingMock, getSharedCodexAppServerClientMock, retireSharedCodexAppServerClientIfCurrentMock, runCodexAppServerSideQuestion, + runCodexAppServerSideQuestionImpl, createFakeClient, threadResult, turnStartResult, @@ -32,7 +32,6 @@ describe("runCodexAppServerSideQuestion", () => { useSideQuestionTestSetup(); it("executes inherited Gateway shell tools through the side run's host authority", async () => { - dynamicToolBuildState.openClawCodingToolsFactory = undefined; const workspaceDir = tempDirs.make("codex-side-gateway-shell-"); const config = { tools: { exec: { host: "gateway" as const, mode: "full" as const } } }; const runId = "side-gateway-shell"; @@ -51,8 +50,7 @@ describe("runCodexAppServerSideQuestion", () => { const client = createFakeClient({ completeTurn: false, onTurnStart: turnStarted.resolve }); getSharedCodexAppServerClientMock.mockResolvedValue(client); const parent = { threadId: "parent-thread", cwd: workspaceDir, model: "gpt-5.5" }; - readCodexAppServerBindingMock.mockReturnValue(parent); - const run = runCodexAppServerSideQuestion( + const run = runCodexAppServerSideQuestionImpl( sideParams({ cfg: config, runtimeModel: createCodexTestModel("openai"), @@ -70,6 +68,7 @@ describe("runCodexAppServerSideQuestion", () => { hostCapabilities: host.hostCapabilities, opts: { runId }, }), + { bindingStore: { ...createCodexTestBindingStore(), read: () => parent } }, ); try { await Promise.race([ diff --git a/extensions/codex/src/app-server/side-question.test-support.ts b/extensions/codex/src/app-server/side-question.test-support.ts index fca091b83c69..f8430d7e63ae 100644 --- a/extensions/codex/src/app-server/side-question.test-support.ts +++ b/extensions/codex/src/app-server/side-question.test-support.ts @@ -1,4 +1,5 @@ import { nativeHookRelayTesting } from "openclaw/plugin-sdk/agent-harness-runtime"; +import { setHostToolFactoryForTest } from "openclaw/plugin-sdk/agent-runtime-test-contracts"; import { resetDiagnosticEventsForTest } from "openclaw/plugin-sdk/diagnostic-runtime"; import { resetGlobalHookRunner } from "openclaw/plugin-sdk/hook-runtime"; import { afterEach, beforeEach, expect, vi } from "vitest"; @@ -6,7 +7,11 @@ import { codexTestTurnIds, createFakeCodexAppServerClient, } from "./codex-app-server.test-fixtures.js"; -import { dynamicToolBuildState } from "./dynamic-tool-build-state.js"; +import { + createCodexTestHostCapabilities, + getCodexTestToolFactory, + setCodexTestToolFactory, +} from "./host-capability.test-support.js"; import { isJsonObject, type CodexServerNotification, type JsonObject } from "./protocol.js"; import { createCodexTestBindingStore, @@ -77,10 +82,16 @@ const bindingStore: CodexAppServerBindingStore = { read: (...args) => readCodexAppServerBindingMock(...args), }; -function runCodexAppServerSideQuestion( +async function runCodexAppServerSideQuestion( params: Parameters[0], options: Omit[1], "bindingStore"> = {}, ) { + const runId = params.opts?.runId; + if (runId && !getCodexTestToolFactory(params)) { + await setHostToolFactoryForTest({ runId }, (toolOptions) => + createOpenClawCodingToolsMock(toolOptions), + ); + } return runCodexAppServerSideQuestionImpl(params, { ...options, bindingStore }); } @@ -269,6 +280,11 @@ const TEST_HOST_CAPABILITIES: SideQuestionParams["hostCapabilities"] = Object.fr }); function sideParams(overrides: Partial = {}): SideQuestionParams { + let hostCapabilities = overrides.hostCapabilities ?? TEST_HOST_CAPABILITIES; + if (!hostCapabilities.createToolSurface) { + hostCapabilities = createCodexTestHostCapabilities(hostCapabilities); + setCodexTestToolFactory({ hostCapabilities }, createOpenClawCodingToolsMock); + } const authProfileId = Object.hasOwn(overrides, "authProfileId") ? overrides.authProfileId : "openai:work"; @@ -330,7 +346,7 @@ function sideParams(overrides: Partial = {}): SideQuestionPa modelRegistry: {} as never, }, ...overrides, - hostCapabilities: overrides.hostCapabilities ?? TEST_HOST_CAPABILITIES, + hostCapabilities, }; } @@ -342,8 +358,6 @@ export function useSideQuestionTestSetup() { getSharedCodexAppServerClientMock.mockReset(); retireSharedCodexAppServerClientIfCurrentMock.mockReset(); createOpenClawCodingToolsMock.mockReset(); - dynamicToolBuildState.openClawCodingToolsFactory = (...args) => - createOpenClawCodingToolsMock(...args); toolExecuteMock.mockReset(); handleCodexAppServerApprovalRequestMock.mockReset(); resolveCodexProviderWebSearchSupportForClientMock.mockReset(); @@ -393,7 +407,6 @@ export function useSideQuestionTestSetup() { }); afterEach(async () => { - dynamicToolBuildState.openClawCodingToolsFactory = undefined; await nativeHookRelayTesting.clearNativeHookRelaysForTests(); resetDiagnosticEventsForTest(); resetGlobalHookRunner(); diff --git a/extensions/copilot/src/tool-bridge.github-publication.test.ts b/extensions/copilot/src/tool-bridge.github-publication.test.ts new file mode 100644 index 000000000000..c80a476c2cb0 --- /dev/null +++ b/extensions/copilot/src/tool-bridge.github-publication.test.ts @@ -0,0 +1,74 @@ +import path from "node:path"; +import { AuthStorage, ModelRegistry } from "openclaw/plugin-sdk/agent-sessions"; +import { createAgentHarnessHostCapabilitiesForTest } from "openclaw/plugin-sdk/plugin-test-runtime"; +import { withTempDir } from "openclaw/plugin-sdk/test-env"; +import { describe, expect, it } from "vitest"; +import { createCopilotToolBridge } from "./tool-bridge.js"; + +describe("Copilot GitHub publication tools", () => { + it.each([ + { available: undefined, profile: "coding", expected: [] }, + { available: false, profile: "coding", expected: ["github_identity_status"] }, + { available: true, profile: "coding", expected: ["github_identity_status", "github_publish"] }, + { available: true, profile: "messaging", expected: [] }, + ] as const)( + "exposes host-prepared GitHub tools: $available / $profile", + async ({ available, profile, expected }) => { + await withTempDir("openclaw-copilot-github-tools-", async (workspaceDir) => { + const authStorage = AuthStorage.inMemory(); + const attempt: Parameters[0]["attempt"] = + { + agentId: "main", + sessionId: "session-1", + sessionKey: "agent:main:session-1", + sessionFile: path.join(workspaceDir, "session.jsonl"), + runId: "copilot-github-tools", + workspaceDir, + prompt: "Inspect the repository", + timeoutMs: 5_000, + provider: "github-copilot", + modelId: "gpt-4o", + model: { + id: "gpt-4o", + name: "GPT-4o", + api: "openai-completions", + provider: "github-copilot", + baseUrl: "https://example.com", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 4_096, + }, + thinkLevel: "off", + authStorage, + modelRegistry: ModelRegistry.inMemory(authStorage), + authProfileStore: { version: 1, profiles: {} }, + config: { tools: { profile } }, + githubPublicationAvailable: available, + }; + const host = await createAgentHarnessHostCapabilitiesForTest({ + attempt, + pluginId: "copilot", + }); + let bridge: Awaited> | undefined; + try { + bridge = await createCopilotToolBridge({ + agentId: "main", + sessionId: "session-1", + modelId: "gpt-4o", + modelProvider: "github-copilot", + spawnWorkspaceDir: undefined, + workspaceDir, + attemptParams: { ...attempt, hostCapabilities: host.capabilities }, + }); + const tools = bridge.promptToolPolicy.apply().callableToolNames; + expect(tools.filter((name) => name.startsWith("github_"))).toEqual(expected); + } finally { + bridge?.cleanup?.(); + host.close(); + } + }); + }, + ); +}); diff --git a/extensions/copilot/src/tool-bridge.ts b/extensions/copilot/src/tool-bridge.ts index 4f0b5fc44df0..51be071842f5 100644 --- a/extensions/copilot/src/tool-bridge.ts +++ b/extensions/copilot/src/tool-bridge.ts @@ -1,4 +1,3 @@ -// Copilot plugin module implements tool bridge behavior. import { convertMcpCallToolResult, type Tool as SdkTool, @@ -53,20 +52,6 @@ interface CopilotSessionHolder { current: { abort?: () => unknown } | undefined; } -/** - * Structural subset of `EmbeddedRunAttemptParamsV2` carried into the tool - * bridge for PI-parity tool context (see - * `src/agents/pi-embedded-runner/run/attempt.ts:1029-1117` — the - * authoritative `createOpenClawCodingTools({...})` call shape). - * - * Declared from `EmbeddedRunAttemptParamsV2` (imported from the - * `openclaw/plugin-sdk/agent-harness-runtime` boundary, *not* from - * `attempt.ts` in this extension) to avoid an `attempt.ts` ↔ - * `tool-bridge.ts` import cycle while keeping the field shapes - * authoritative. Production callers pass the live attempt params; test - * fixtures can use the flat fields below for minimal-config wiring, but every - * constructed tool surface still requires the host-bound capability. - */ type CopilotToolAttemptParams = Partial> & Pick; @@ -110,15 +95,6 @@ interface CopilotToolBridgeInput { */ spawnWorkspaceDir: string | undefined; abortSignal?: AbortSignal; - /** - * Full PI-parity attempt parameters. When set, the bridge forwards - * identity, channel, owner/policy, auth-profile, message-routing, - * model, and run-trace fields to `createOpenClawCodingTools` so the - * wrapped-tool enforcement layer - * (`src/agents/pi-tools.before-tool-call.ts`) receives the same - * context the in-tree PI runner provides. See - * `src/agents/pi-embedded-runner/run/attempt.ts:1029-1117`. - */ attemptParams: CopilotToolAttemptParams; /** * Mutable session holder used to wire `onYield` to the live @@ -132,10 +108,7 @@ interface CopilotToolBridgeInput { * the in-flight SDK session; this callback lets the caller track * the yield so the final attempt result can carry * `yieldDetected: true` (the parent runner uses it to mark - * liveness as paused and stop_reason as `end_turn`). Mirrors - * the PI/codex contract — see - * `src/agents/pi-embedded-runner/run/attempt.ts:1107-1113` and - * `extensions/codex/src/app-server/run-attempt.ts:539-541`. + * liveness as paused and stop_reason as `end_turn`). */ onYieldDetected?: (message?: string, acknowledgment?: string) => void; onToolCompleted?: (completion: CopilotToolCompletion) => void | Promise; @@ -361,17 +334,11 @@ function buildOpenClawCodingToolsOptions( ): OpenClawCodingToolsOptions { const a = input.attemptParams; - // Mirror PI's `sandboxSessionKey` derivation (attempt.ts:873-874) so - // wrapped tools see the same policy key PI uses. When the attempt - // exposes neither sandboxSessionKey nor sessionKey, fall back to the - // flat input.sessionKey/sessionId. + // Sandbox policy may belong to a different key than the live session. const sandboxSessionKey = a.sandboxSessionKey?.trim() || a.sessionKey?.trim() || input.sessionKey || input.sessionId; - // When sandboxSessionKey differs from the real run session key (e.g. - // Telegram direct peer key vs `agent:main:main`), pass the live key - // so `session_status: "current"` resolves to the active run session, - // not the stale sandbox key. Mirrors PI attempt.ts:1057-1060. + // Keep current-session reads on the live run rather than its sandbox policy key. const liveSessionKey = a.sessionKey ?? input.sessionKey; const runSessionKey = liveSessionKey && liveSessionKey !== sandboxSessionKey ? liveSessionKey : undefined; @@ -451,18 +418,11 @@ function buildOpenClawCodingToolsOptions( forceHeartbeatTool: a.forceHeartbeatTool, authProfileStore: a.toolAuthProfileStore ?? a.authProfileStore, computerContextEpoch: input.computerContextEpoch, - // recordToolPrepStage intentionally omitted: copilot does not - // surface attempt-stage telemetry yet. Codex omits this too. onToolOutcome: a.onToolOutcome, isTurnTainted: a.isTurnTainted, onYield: (message, acknowledgment) => { - // Notify the caller first so the final attempt result can carry - // yieldDetected even if the abort below races a concurrent - // settle path. Errors thrown by the caller's handler must not - // skip the abort, so wrap defensively. Mirrors PI (`attempt.ts` - // sets `yieldDetected = true; yieldMessage = message;` before - // calling abort) and codex (`onYieldDetected()` runs before the - // run-abort controller fires). + // Record the yield before abort can settle the attempt; a callback failure + // must not prevent interruption of the native session. try { input.onYieldDetected?.(message, acknowledgment); } catch (error) { diff --git a/src/agents/embedded-agent-runner/run/attempt-gateway-tools.ts b/src/agents/embedded-agent-runner/run/attempt-gateway-tools.ts new file mode 100644 index 000000000000..6c0a3ce4d55a --- /dev/null +++ b/src/agents/embedded-agent-runner/run/attempt-gateway-tools.ts @@ -0,0 +1,71 @@ +import { prepareGitHubPublicationAvailability } from "../../../gateway/github-publication-availability.js"; +import { getGatewayContextResolver } from "../../../plugins/runtime/gateway-request-scope.js"; +import { agentHarnessExposesOpenClawTools } from "../../harness/tool-surface.js"; +import { + createAdmittedGatewayToolCallerIdentity, + withGatewayToolCallerIdentity, +} from "../../tools/gateway-caller-context.js"; +import type { EmbeddedRunAttemptParams } from "./types.js"; + +/** Prepare Gateway tools once at the shared dispatch boundary, including internal continuations. */ +export async function withPreparedEmbeddedGatewayTools( + attempt: Pick< + EmbeddedRunAttemptParams, + | "admittedRunContext" + | "cronCreatorAuthorityCapability" + | "messageChannel" + | "messageProvider" + | "currentMessagingTarget" + | "currentChannelId" + | "agentAccountId" + | "currentThreadTs" + | "sessionId" + | "disableTools" + | "sessionPersistence" + | "githubPublicationAvailable" + > & { agentId: string; sessionKey: string; agentHarnessId: string }, + isAttemptCurrent: () => boolean, + run: () => Promise, +): Promise { + const callerIdentity = createAdmittedGatewayToolCallerIdentity({ + admittedRunContext: attempt.admittedRunContext, + cronAuthorityCheck: attempt.cronCreatorAuthorityCapability?.isCurrent, + agentId: attempt.agentId, + sessionKey: attempt.sessionKey, + turnSourceChannel: attempt.messageChannel ?? attempt.messageProvider, + turnSourceLocal: + !attempt.messageChannel && + !attempt.messageProvider && + attempt.cronCreatorAuthorityCapability?.callerOrigin.kind === "local" + ? true + : undefined, + turnSourceTo: attempt.currentMessagingTarget ?? attempt.currentChannelId, + turnSourceAccountId: attempt.agentAccountId, + turnSourceThreadId: attempt.currentThreadTs, + }); + return withGatewayToolCallerIdentity(callerIdentity, async () => { + const resolveGatewayContext = getGatewayContextResolver(attempt.admittedRunContext); + const gateway = resolveGatewayContext?.(); + if ( + !attempt.disableTools && + attempt.sessionPersistence !== "detached" && + agentHarnessExposesOpenClawTools(attempt.agentHarnessId) && + gateway && + !gateway.localEmbedded + ) { + // Yield, compaction, and retries recheck the current session and exact live host; + // an earlier attempt's availability must not determine its successor's tool catalog. + const isCurrent = () => isAttemptCurrent() && resolveGatewayContext?.() === gateway; + attempt.githubPublicationAvailable = await prepareGitHubPublicationAvailability({ + sessionId: attempt.sessionId, + sessionKey: attempt.sessionKey, + agentId: attempt.agentId, + assertCurrent: isCurrent, + }); + if (!isCurrent()) { + throw new Error("GitHub tool preparation outlived its admitted Gateway run"); + } + } + return run(); + }); +} diff --git a/src/agents/embedded-agent-runner/run/attempt-tool-run-context.ts b/src/agents/embedded-agent-runner/run/attempt-tool-run-context.ts index 9280ff8e706e..fd03c0373460 100644 --- a/src/agents/embedded-agent-runner/run/attempt-tool-run-context.ts +++ b/src/agents/embedded-agent-runner/run/attempt-tool-run-context.ts @@ -1,16 +1,9 @@ import type { ThinkLevel } from "../../../auto-reply/thinking.js"; import type { GroupToolPolicyConfig } from "../../../config/types.tools.js"; -import { prepareGitHubPublicationAvailability } from "../../../gateway/github-publication-availability.js"; import { freezeDiagnosticTraceContext, type DiagnosticTraceContext, } from "../../../infra/diagnostic-trace-context.js"; -import { getGatewayContextResolver } from "../../../plugins/runtime/gateway-request-scope.js"; -import { agentHarnessExposesOpenClawTools } from "../../harness/tool-surface.js"; -import { - createAdmittedGatewayToolCallerIdentity, - withGatewayToolCallerIdentity, -} from "../../tools/gateway-caller-context.js"; import { mergeForcedEmbeddedAttemptToolsAllow } from "./attempt-tool-construction-plan.js"; import type { EmbeddedRunTrigger, RunEmbeddedAgentParams } from "./params.js"; import type { EmbeddedRunAttemptParams } from "./types.js"; @@ -130,66 +123,3 @@ export function buildEmbeddedAttemptToolRunContext( ...(params.trace ? { trace: freezeDiagnosticTraceContext(params.trace) } : {}), }; } - -/** Prepare Gateway tools once at the shared dispatch boundary, including internal continuations. */ -export async function withPreparedEmbeddedGatewayTools( - attempt: Pick< - EmbeddedRunAttemptParams, - | "admittedRunContext" - | "cronCreatorAuthorityCapability" - | "messageChannel" - | "messageProvider" - | "currentMessagingTarget" - | "currentChannelId" - | "agentAccountId" - | "currentThreadTs" - | "sessionId" - | "disableTools" - | "sessionPersistence" - | "githubPublicationAvailable" - > & { agentId: string; sessionKey: string; agentHarnessId: string }, - isAttemptCurrent: () => boolean, - run: () => Promise, -): Promise { - const callerIdentity = createAdmittedGatewayToolCallerIdentity({ - admittedRunContext: attempt.admittedRunContext, - cronAuthorityCheck: attempt.cronCreatorAuthorityCapability?.isCurrent, - agentId: attempt.agentId, - sessionKey: attempt.sessionKey, - turnSourceChannel: attempt.messageChannel ?? attempt.messageProvider, - turnSourceLocal: - !attempt.messageChannel && - !attempt.messageProvider && - attempt.cronCreatorAuthorityCapability?.callerOrigin.kind === "local" - ? true - : undefined, - turnSourceTo: attempt.currentMessagingTarget ?? attempt.currentChannelId, - turnSourceAccountId: attempt.agentAccountId, - turnSourceThreadId: attempt.currentThreadTs, - }); - return withGatewayToolCallerIdentity(callerIdentity, async () => { - const resolveGatewayContext = getGatewayContextResolver(attempt.admittedRunContext); - const gateway = resolveGatewayContext?.(); - if ( - !attempt.disableTools && - attempt.sessionPersistence !== "detached" && - agentHarnessExposesOpenClawTools(attempt.agentHarnessId) && - gateway && - !gateway.localEmbedded - ) { - // Yield, compaction, and retries recheck the current session and exact live host; - // an earlier attempt's availability must not determine its successor's tool catalog. - const isCurrent = () => isAttemptCurrent() && resolveGatewayContext?.() === gateway; - attempt.githubPublicationAvailable = await prepareGitHubPublicationAvailability({ - sessionId: attempt.sessionId, - sessionKey: attempt.sessionKey, - agentId: attempt.agentId, - assertCurrent: isCurrent, - }); - if (!isCurrent()) { - throw new Error("GitHub tool preparation outlived its admitted Gateway run"); - } - } - return run(); - }); -} diff --git a/src/agents/embedded-agent-runner/run/run-attempt-dispatch.ts b/src/agents/embedded-agent-runner/run/run-attempt-dispatch.ts index b5d44ac97e84..16d7ebcd7fa5 100644 --- a/src/agents/embedded-agent-runner/run/run-attempt-dispatch.ts +++ b/src/agents/embedded-agent-runner/run/run-attempt-dispatch.ts @@ -28,9 +28,9 @@ import { remapSkillReferencePaths } from "../sandbox-skills.js"; import { prepareEmbeddedSkills } from "../skill-runtime.js"; import { mapThinkingLevelForProvider } from "../utils.js"; import { prepareExecApprovalContinuationForAttempt } from "./attempt-exec-approval-continuation.js"; +import { withPreparedEmbeddedGatewayTools } from "./attempt-gateway-tools.js"; import { applyResolvedToolPromptFinalizer } from "./attempt-prompt-support.js"; import { EMBEDDED_RUN_ATTEMPT_DISPATCH_STAGE } from "./attempt-stage-timing.js"; -import { withPreparedEmbeddedGatewayTools } from "./attempt-tool-run-context.js"; import { resolveAttemptDispatchApiKey } from "./auth-store.js"; import { runEmbeddedAttemptWithBackend } from "./backend.js"; import type { PreparedEmbeddedRunInput } from "./execution-context.js"; diff --git a/src/agents/harness/host-capability.test.ts b/src/agents/harness/host-capability.test.ts index 08d619da8281..effac11f8e91 100644 --- a/src/agents/harness/host-capability.test.ts +++ b/src/agents/harness/host-capability.test.ts @@ -156,6 +156,32 @@ afterEach(() => { }); describe("agent harness host capability", () => { + it.each([ + { available: undefined, expected: [] }, + { available: false, expected: ["github_identity_status"] }, + { available: true, expected: ["github_identity_status", "github_publish"] }, + ])( + "captures GitHub availability independently of plugin inputs: $available", + async ({ available, expected }) => { + const { attempt } = await admittedAttempt("github-tools", { + githubPublicationAvailable: available, + }); + const host = createAgentHarnessHostCapabilities({ attempt, pluginId: "copilot" }); + attempt.githubPublicationAvailable = available !== true; + try { + const tools = host.capabilities.createToolSurface?.({ + githubPublicationAvailable: available !== true, + config: { tools: { profile: "coding" } }, + }); + expect( + tools?.filter((tool) => tool.name.startsWith("github_")).map((tool) => tool.name), + ).toEqual(expected); + } finally { + host.close(); + } + }, + ); + it.each(["restart", "unrelated scope", "user abort", "timeout"] as const)( "preserves the original cancellation when a startup capability closes: %s", async (reason) => { diff --git a/src/agents/harness/host-capability.ts b/src/agents/harness/host-capability.ts index 284d97308782..b1ca68d7b7e9 100644 --- a/src/agents/harness/host-capability.ts +++ b/src/agents/harness/host-capability.ts @@ -182,6 +182,7 @@ export function createAgentHarnessHostCapabilities(params: { runWithScope: (run: () => Promise) => Promise; } { const attempt = params.attempt; + const githubPublicationAvailable = attempt.githubPublicationAvailable; const workSignal = getAsyncWorkSignal(); const attemptSignal = attempt.abortSignal; const installationTarget = getInstallationTarget(); @@ -513,6 +514,8 @@ export function createAgentHarnessHostCapabilities(params: { createOpenClawCodingToolsInternal( { ...options, + // Availability belongs to this prepared host, not mutable plugin inputs. + githubPublicationAvailable, skillsSnapshot: options?.skillsSnapshot ?? skillsSnapshot, skillUsagePaths: options?.skillUsagePaths ?? skillUsagePaths, operationalRunInstance, diff --git a/src/agents/prepared-model-runtime.build.ts b/src/agents/prepared-model-runtime.build.ts index e873bbfb0674..daa885a00236 100644 --- a/src/agents/prepared-model-runtime.build.ts +++ b/src/agents/prepared-model-runtime.build.ts @@ -38,6 +38,7 @@ import { registerPreparedModelRuntimeClose } from "./prepared-model-runtime.life import { discardPreparedPluginGeneration, registerPreparedPluginLifetime, + retainPreparedPluginRegistry, } from "./prepared-model-runtime.plugin-lifetime.js"; import { PreparedModelRuntimeBuildResources } from "./prepared-model-runtime.resources.js"; import { prepareAgentCatalogSource } from "./prepared-model-runtime.scoped-catalog.js"; @@ -60,7 +61,7 @@ export type PreparedModelRuntimeBuildCandidate = Readonly<{ isGenerationCurrent?: () => boolean; isBuildCurrent?: () => boolean; onBeforeAuthCapture?: () => void; - ownsRegistryResources?: boolean; + inspectRegistry?: boolean; }>; export type PreparedModelRuntimeBuildResult = Readonly<{ @@ -84,12 +85,12 @@ function groupBuildCandidates( async function buildSnapshotBatch( requestedCandidates: readonly PreparedModelRuntimeBuildCandidate[], + registryResources: PreparedModelRuntimeBuildResources, catalogMode: PreparedModelRuntimeCatalogMode, pluginMetadataSnapshot?: PreparedModelRuntimePluginGeneration["pluginMetadataSnapshot"], onBuildStats?: (stats: PreparedModelRuntimeBuildStats) => void, includeCredentialProviders = catalogMode === "live", onStage?: (stage: string) => void, - registryResources?: PreparedModelRuntimeBuildResources, onPrepared?: (input: PreparedModelRuntimeInput, result: PreparedModelRuntimeBuildResult) => void, signal?: AbortSignal, ): Promise { @@ -160,8 +161,8 @@ async function buildSnapshotBatch( [ ...groupBuildCandidates(generationCandidates, (candidate) => { const workspace = preparedModelRuntimeWorkspaceFactsKey(candidate.input); - if (candidate.ownsRegistryResources) { - return `owned\0${workspace}`; + if (candidate.inspectRegistry) { + return `inspection\0${workspace}`; } const kind = candidate.prepareInboundPluginRegistry ? "configured" : "dynamic"; return pluginGeneration ? workspace : `${kind}\0${workspace}`; @@ -229,8 +230,9 @@ async function buildSnapshotBatch( onBeforeAuthCapture: (input) => candidateByInput.get(input)!.onBeforeAuthCapture?.(), onStage, signal, - ...(groupCandidates.some((candidate) => candidate.ownsRegistryResources) - ? { registryResources } + registryResources, + ...(groupCandidates.some((candidate) => candidate.inspectRegistry) + ? { loadRuntimeRegistry: registryResources.load.bind(registryResources) } : {}), }, prepareInboundPluginRegistry ? loadInboundPluginRegistry : undefined, @@ -462,7 +464,9 @@ export function startSerializedSnapshotBuildBatch( const startBuild = (async () => { // Register before waiting: shutdown also owns resources from unfinished builds. registerPreparedPluginLifetime(); - await using registryResources = new PreparedModelRuntimeBuildResources(); + await using registryResources = new PreparedModelRuntimeBuildResources( + retainPreparedPluginRegistry, + ); if (previousBuildCompletions.length > 0) { await Promise.all(previousBuildCompletions); // Queued publications register while the prior build settles. Recheck them here so a @@ -472,6 +476,7 @@ export function startSerializedSnapshotBuildBatch( signal.throwIfAborted(); return await buildSnapshotBatch( candidates, + registryResources, catalogMode, pluginMetadataSnapshot, onBuildStats, @@ -480,7 +485,6 @@ export function startSerializedSnapshotBuildBatch( stage = nextStage; progress?.onStage(nextStage); }, - registryResources, progress ? (input, result) => { progress.onPrepared(input, result); diff --git a/src/agents/prepared-model-runtime.facts.ts b/src/agents/prepared-model-runtime.facts.ts index f21c7a5b5058..767ea4db3357 100644 --- a/src/agents/prepared-model-runtime.facts.ts +++ b/src/agents/prepared-model-runtime.facts.ts @@ -20,7 +20,6 @@ import { resolvePreparedProviderStaticConfigs } from "../plugins/provider-discov import type { ProviderRuntimeModel } from "../plugins/provider-runtime-model.types.js"; import { getPluginRegistryInspectionResources } from "../plugins/registry-inspection-resources.js"; import { capturePluginLifecycleAuthority } from "../plugins/registry-lifecycle.js"; -import type { PluginRegistry } from "../plugins/registry-types.js"; import { withPluginRuntimeGenerationScope } from "../plugins/runtime/generation-scope.js"; import { resolveRuntimeSyntheticAuthProviderRefs } from "../plugins/synthetic-auth.runtime.js"; import { prepareAmbientAgentCredentialsForDiscovery } from "./agent-auth-discovery.js"; @@ -71,7 +70,7 @@ import { discardPreparedPluginGeneration, retainPreparedPluginRegistry, } from "./prepared-model-runtime.plugin-lifetime.js"; -import type { PreparedModelRuntimeBuildResources } from "./prepared-model-runtime.resources.js"; +import { PreparedModelRuntimeBuildResources } from "./prepared-model-runtime.resources.js"; import { listPreparedSyntheticAuthProviderRefs, prepareSyntheticAuth, @@ -83,7 +82,6 @@ import type { PreparedModelRuntimeInput, PreparedModelRuntimePluginGeneration, } from "./prepared-model-runtime.types.js"; -import { releaseRuntimePluginWork, retainRuntimePluginWork } from "./runtime-plugin-work.js"; import { AuthStorage } from "./sessions/auth-storage.js"; type PreparedConfiguredRegistryGroup = { @@ -107,6 +105,7 @@ export async function prepareWorkspaceBuildGroup( assertCurrent?: (input: PreparedModelRuntimeInput) => void; onBeforeAuthCapture?: (input: PreparedModelRuntimeInput) => void; registryResources?: PreparedModelRuntimeBuildResources; + loadRuntimeRegistry?: PreparedModelRuntimeBuildResources["load"]; purpose?: RuntimePluginLoadPurpose; } = {}, loadInboundPluginRegistry?: PreparedInboundRegistryLoader, @@ -160,32 +159,22 @@ export async function prepareWorkspaceBuildGroup( const preferBuiltPluginArtifacts = reusablePluginGeneration?.preferBuiltPluginArtifacts ?? options.preferBuiltPluginArtifacts === true; - options.registryResources?.retainGeneration(reusablePluginGeneration); - const retainedRegistries = new Set(); - await using registryBorrows = new AsyncDisposableStack(); + await using localResources = new AsyncDisposableStack(); + const registryResources = + options.registryResources ?? + localResources.use(new PreparedModelRuntimeBuildResources(retainPreparedPluginRegistry)); + registryResources.retainGeneration(reusablePluginGeneration); + // Borrowing spans the caller's construction; registry acquisition is selected independently. const preparingRegistries = prepareWorkspacePluginRegistries( input, pluginMetadataSnapshot, - (registry) => { - // Initial run admission can inspect a new selection before its caller holds - // a generation lease. Borrow the selected source before that async load. - if (!retainedRegistries.has(registry)) { - retainedRegistries.add(registry); - const release = retainPreparedPluginRegistry(registry); - if (release) { - let releaseWork = () => {}; - // Record physical cleanup before replacement admission can refuse this build's work. - registryBorrows.defer(() => releaseRuntimePluginWork(release, releaseWork)); - releaseWork = retainRuntimePluginWork([registry]); - } - } - }, + (registry) => registryResources.retainRegistry(registry), loadInboundPluginRegistry, preferBuiltPluginArtifacts, reusablePluginGeneration, options.getConfiguredHarnessRuntimes, options.basePluginIds, - options.registryResources, + options.loadRuntimeRegistry, options.purpose, ); const { inboundPluginRegistry, runtimePluginRegistry, primaryRegistry } = diff --git a/src/agents/prepared-model-runtime.inbound-registry.ts b/src/agents/prepared-model-runtime.inbound-registry.ts index 6b8887d2093a..da8a524c50ec 100644 --- a/src/agents/prepared-model-runtime.inbound-registry.ts +++ b/src/agents/prepared-model-runtime.inbound-registry.ts @@ -156,7 +156,9 @@ export function prepareWorkspacePluginRegistries( reusableGeneration?: PreparedModelRuntimePluginGeneration, getConfiguredHarnessRuntimes?: () => readonly string[], basePluginIds?: readonly string[], - registryResources?: PreparedModelRuntimeBuildResources, + loadRuntimeRegistry: + | PreparedModelRuntimeBuildResources["load"] + | typeof loadAgentRuntimePluginRegistryHandle = loadAgentRuntimePluginRegistryHandle, purpose?: RuntimePluginLoadPurpose, ): PreparedWorkspacePluginRegistries | Promise { // Passive reads stay runtime-free; catalog workers and executable probes carry explicit scope. @@ -190,9 +192,6 @@ export function prepareWorkspacePluginRegistries( } primaryRegistry ??= reusableGeneration?.mediaCapabilityProviderSource?.registry ?? baseRegistry; let loadedPrimaryRegistry: PluginRegistry | undefined; - const loadRuntimeRegistry = registryResources - ? registryResources.load.bind(registryResources) - : loadAgentRuntimePluginRegistryHandle; const runtimePluginRegistry = purpose === "model-catalog" || input.runtimePluginSelections || !baseRegistry ? loadRuntimeRegistry( diff --git a/src/agents/prepared-model-runtime.owner.ts b/src/agents/prepared-model-runtime.owner.ts index 2e7b16f2d9ed..a441e12b4229 100644 --- a/src/agents/prepared-model-runtime.owner.ts +++ b/src/agents/prepared-model-runtime.owner.ts @@ -465,7 +465,7 @@ export async function publishPreparedModelRuntimeOwnerBatch(params: { inventoryOwner: owner, pluginGeneration: owner.pendingPluginGeneration, prepareInboundPluginRegistry: owner.provenance === "configured", - ownsRegistryResources: + inspectRegistry: owner.provenance === "run" || (owner.provenance === "ephemeral" && input.readOnly === true), isGenerationCurrent, isBuildCurrent: params.isBuildCurrent ?? isCurrent, @@ -681,7 +681,7 @@ export async function publishModelRuntimeSnapshot( } }, prepareInboundPluginRegistry: provenance === "configured", - ownsRegistryResources: + inspectRegistry: provenance === "run" || (provenance === "ephemeral" && input.readOnly === true), pluginGeneration: reusablePluginGeneration, }, diff --git a/src/agents/prepared-model-runtime.plugin-lifetime.ts b/src/agents/prepared-model-runtime.plugin-lifetime.ts index 86a45552dbb2..37b10e040867 100644 --- a/src/agents/prepared-model-runtime.plugin-lifetime.ts +++ b/src/agents/prepared-model-runtime.plugin-lifetime.ts @@ -166,7 +166,11 @@ export function ownPreparedPluginGeneration( getPluginMetadataSnapshotCache(generation.pluginMetadataSnapshot), ); const releases: Array<() => void | Promise> = []; - const retainedRegistries: PluginRegistry[] = []; + const selectedRegistries = new Set( + [generation.pluginRegistry, generation.inboundPluginRegistry].filter( + (registry) => registry !== undefined, + ), + ); const acquisitionFailures: unknown[] = []; const lifetime = createLifetime( async () => { @@ -182,14 +186,13 @@ export function ownPreparedPluginGeneration( ); } }, - () => retainRuntimePluginWork(retainedRegistries), + () => retainRuntimePluginWork(selectedRegistries), ); try { - for (const registry of new Set([generation.pluginRegistry, generation.inboundPluginRegistry])) { - const release = registry && retainPreparedPluginRegistry(registry); - if (registry && release) { + for (const registry of selectedRegistries) { + const release = retainPreparedPluginRegistry(registry); + if (release) { releases.push(release); - retainedRegistries.push(registry); } } } catch (error) { diff --git a/src/agents/prepared-model-runtime.registry-borrow.test.ts b/src/agents/prepared-model-runtime.registry-borrow.test.ts index f4d3f8931799..93706aca252e 100644 --- a/src/agents/prepared-model-runtime.registry-borrow.test.ts +++ b/src/agents/prepared-model-runtime.registry-borrow.test.ts @@ -7,21 +7,33 @@ import { } from "./prepared-model-runtime.test-harness.js"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createDeferred } from "../../test/helpers/promise.js"; +import { + createPluginMetadataSnapshot, + makeRegistry, +} from "../config/plugin-auto-enable.test-helpers.js"; import { PluginInstance } from "../plugins/plugin-instance.js"; +import { bindPluginRuntimeArtifactSelection } from "../plugins/plugin-runtime-artifact-binding.js"; +import { resolvePluginRuntimeArtifactSelection } from "../plugins/plugin-runtime-artifact-selection.js"; import { createEmptyPluginRegistry } from "../plugins/registry-empty.js"; import { isPluginRegistryRetired } from "../plugins/registry-lifecycle.js"; +import { clearActivePluginRegistry, setActivePluginRegistry } from "../plugins/runtime.js"; +import { setPluginRuntimeLoadContext } from "../plugins/runtime/load-context.js"; import { createPluginRecord } from "../plugins/status.test-helpers.js"; import { createOpenClawTestState, type OpenClawTestState, } from "../test-utils/openclaw-test-state.js"; +import { loadPreparedInboundPluginRegistry } from "./prepared-model-runtime.inbound-registry.js"; import { acquireAgentRunPreparedModelRuntime, acquirePublishedPreparedModelRuntime, markPreparedModelRuntimeSnapshotsStale, prepareModelRuntimeSnapshot, + publishPreparedModelRuntimeSnapshot, refreshPreparedModelRuntimeSnapshots, } from "./prepared-model-runtime.js"; +import { retainPreparedPluginRegistry } from "./prepared-model-runtime.plugin-lifetime.js"; +import { PreparedModelRuntimeBuildResources } from "./prepared-model-runtime.resources.js"; import * as runtimePlugins from "./runtime-plugins.js"; const mocks = getPreparedModelRuntimeMocks(); @@ -36,31 +48,101 @@ afterEach(async ({ task }) => { await cleanupPreparedModelRuntimeHarness(state, task.result?.state === "fail"); }); -async function acquireConfiguredRegistryBorrower() { +async function acquireConfiguredRegistryBorrower(source: "owned" | "gateway" = "owned") { mocks.configuredAgentIds = ["default"]; + const config = {}; + const workspaceDir = "/tmp/unused-workspace"; + const metadata = createPluginMetadataSnapshot({ + config, + workspaceDir, + manifestRegistry: makeRegistry([ + { id: "prepared-registry-borrow", origin: "config", channels: [] }, + ]), + }); const registry = createEmptyPluginRegistry(); - const record = createPluginRecord({ id: "prepared-registry-borrow" }); + const manifest = metadata.plugins[0]!; + const record = createPluginRecord({ + id: manifest.id, + rootDir: manifest.rootDir, + source: manifest.source, + origin: manifest.origin, + }); registry.plugins.push(record); const instance = new PluginInstance(record.id, { record, registry }); mocks.loadAgentRuntimePluginRegistryHandle.mockReturnValue(registry); - const config = {}; const input = { agentId: "default", config, agentDir: state.agentDir("default"), inheritedAuthDir: state.agentDir("default"), - workspaceDir: "/tmp/unused-workspace", + workspaceDir, + ...(source === "gateway" ? { allowGatewaySubagentBinding: true } : {}), }; + if (source === "gateway") { + bindPluginRuntimeArtifactSelection(record, { + runtimeEntry: resolvePluginRuntimeArtifactSelection({ + ...manifest, + entryKind: "runtime", + preferBuiltPluginArtifacts: false, + }), + }); + setPluginRuntimeLoadContext(registry, { + rawConfig: config, + config, + activationSourceConfig: config, + autoEnabledReasons: {}, + workspaceDir, + env: process.env, + metadataSnapshot: metadata, + manifestRegistry: metadata.manifestRegistry, + logger: { info() {}, warn() {}, error() {}, debug() {} }, + }); + setActivePluginRegistry(registry, undefined, "gateway-bindable", workspaceDir); + expect(loadPreparedInboundPluginRegistry(input, metadata)).toBe(registry); + expect(mocks.loadAgentRuntimePluginRegistryHandle).not.toHaveBeenCalled(); + } await refreshPreparedModelRuntimeSnapshots(config, { gatewayLifecycle: true, catalogMode: "static", + ...(source === "gateway" + ? { allowGatewaySubagentBinding: true, pluginMetadataSnapshot: metadata } + : {}), }); const borrower = await acquirePublishedPreparedModelRuntime(input); await borrower.snapshot.loadFullModelCatalog?.(); - return { registry, config, input, borrower, instance }; + return { registry, config, input, borrower, instance, metadata }; } describe("prepared registry construction borrows", () => { + it("reacquires a refused source and rejects admission after construction closes", async () => { + const { registry, borrower, instance } = await acquireConfiguredRegistryBorrower(); + const construction = new PreparedModelRuntimeBuildResources(retainPreparedPluginRegistry); + await borrower[Symbol.asyncDispose](); + const releaseReplacement = instance.reserveReplacement(); + try { + expect(() => construction.retainRegistry(registry)).toThrow("replacement is in progress"); + releaseReplacement(); + construction.retainRegistry(registry); + expect(() => instance.reserveReplacement()()).toThrow("active retained work"); + await construction[Symbol.asyncDispose](); + instance.reserveReplacement()(); + expect(() => construction.retainRegistry(registry)).toThrow( + "construction resources have been released", + ); + const loads = mocks.loadAgentRuntimePluginRegistryHandle.mock.calls.length; + await expect(construction.load({ config: {} }, () => {})).rejects.toThrow( + "construction resources have been released", + ); + expect(mocks.loadAgentRuntimePluginRegistryHandle).toHaveBeenCalledTimes(loads); + markPreparedModelRuntimeSnapshotsStale("construction owner closed"); + expect(isPluginRegistryRetired(registry)).toBe(true); + } finally { + releaseReplacement(); + await construction[Symbol.asyncDispose](); + await borrower[Symbol.asyncDispose](); + } + }); + it("retains selected inbound resources until a cancelled initial run inspection settles", async () => { const { registry, input, borrower, instance } = await acquireConfiguredRegistryBorrower(); const inspecting = createDeferred(); @@ -162,4 +244,80 @@ describe("prepared registry construction borrows", () => { } expect(isPluginRegistryRetired(registry)).toBe(true); }); + + it.each( + (["run", "configured", "explicit"] as const).flatMap((owner) => + (["owned", "gateway"] as const).map((source) => ({ owner, source })), + ), + )( + "keeps $owner/$source busy through post-facts projection, then permits idle replacement", + async ({ owner, source }) => { + const { registry, config, input, borrower, instance, metadata } = + await acquireConfiguredRegistryBorrower(source); + const projecting = createDeferred(); + const finishProjection = createDeferred(); + mocks.buildPreparedModelCatalogSnapshot.mockImplementationOnce(async () => { + projecting.resolve(); + await finishProjection.promise; + return { entries: [], routeVariants: [] }; + }); + let lease: Awaited> | undefined; + const pending = + owner === "run" + ? acquireAgentRunPreparedModelRuntime( + { + ...input, + runtimePluginSelections: [ + { provider: "custom", modelId: "selected", runtime: "openclaw" }, + ], + }, + { catalogMode: "live", pluginGeneration: borrower.pluginGeneration }, + ).then((acquired) => { + lease = acquired; + return acquired.snapshot; + }) + : owner === "configured" + ? refreshPreparedModelRuntimeSnapshots(config, { + gatewayLifecycle: true, + catalogMode: "live", + ...(source === "gateway" + ? { allowGatewaySubagentBinding: true, pluginMetadataSnapshot: metadata } + : {}), + }).then(() => prepareModelRuntimeSnapshot(input)) + : publishPreparedModelRuntimeSnapshot(input, { + force: true, + provenance: "explicit", + catalogMode: "live", + }); + const settled = Promise.allSettled([pending]); + try { + await Promise.race([ + projecting.promise, + pending.then(() => { + throw new Error("Preparation skipped live catalog projection"); + }), + ]); + await borrower[Symbol.asyncDispose](); + expect(isPluginRegistryRetired(registry)).toBe(false); + expect(() => instance.reserveReplacement()()).toThrow("active retained work"); + finishProjection.resolve(); + const snapshot = await pending; + expect(snapshot.pluginRegistry).toBe(registry); + if (lease) { + expect(() => instance.reserveReplacement()()).toThrow("active retained work"); + await lease[Symbol.asyncDispose](); + } + expect(isPluginRegistryRetired(registry)).toBe(false); + instance.reserveReplacement()(); + } finally { + finishProjection.resolve(); + await settled; + await lease?.[Symbol.asyncDispose](); + await borrower[Symbol.asyncDispose](); + if (source === "gateway") { + await clearActivePluginRegistry(registry); + } + } + }, + ); }); diff --git a/src/agents/prepared-model-runtime.resources.ts b/src/agents/prepared-model-runtime.resources.ts index e89289b09c3e..2bfae19d0308 100644 --- a/src/agents/prepared-model-runtime.resources.ts +++ b/src/agents/prepared-model-runtime.resources.ts @@ -52,23 +52,16 @@ class PreparedRegistryResources { } } - retain(work = false): PreparedModelRuntimeResourceClaim { + retain(): PreparedModelRuntimeResourceClaim { this.assertOpen(); - const releaseWork = work ? retainRuntimePluginWork([this.acquired.registry]) : () => {}; - let claim: PreparedModelRuntimeResourceClaim; - try { - claim = this.acquired.resources.retain(); - } catch (error) { - releaseWork(); - throw error; - } + const claim = this.acquired.resources.retain(); this.claims++; let release: Promise | undefined; return { release: () => { if (!release) { const completion = createDeferredCore(); - release = releaseRuntimePluginWork(() => completion.promise, releaseWork); + release = completion.promise; const pending = this.trackRelease(claim.release); this.claims--; // The final generation lease joins original-view and donor cleanup as well. @@ -123,19 +116,49 @@ class PreparedRegistryResources { } } -/** Construction holds every exact registry until publication has taken its own claim. */ +/** Batch scopes retain selected sources through later catalog work; idle publication owns custody only. */ export class PreparedModelRuntimeBuildResources { - private readonly claims = new Map(); + private readonly registries = new Set(); + private readonly releases = new AsyncDisposableStack(); - private retain(resources: PreparedRegistryResources | undefined): void { - if (resources && !this.claims.has(resources)) { - this.claims.set(resources, resources.retain(true)); + constructor( + private readonly retainPhysicalRegistry: ( + registry: PluginRegistry, + ) => (() => void | Promise) | undefined, + ) {} + + private assertOpen(): void { + if (this.releases.disposed) { + throw new Error("Prepared registry construction resources have been released"); } } + retainRegistry(registry: PluginRegistry): void { + this.assertOpen(); + if (this.registries.has(registry)) { + return; + } + const release = this.retainPhysicalRegistry(registry); + let releaseWork = () => {}; + let completion: Promise | undefined; + const releaseClaim = () => (completion ??= releaseRuntimePluginWork(release, releaseWork)); + // External registry owners keep physical custody, but construction still owns finite work. + this.releases.defer(releaseClaim); + try { + releaseWork = retainRuntimePluginWork([registry]); + } catch (error) { + // Acquisition cleanup may wait for this claim; start it now and let the stack join it. + void releaseClaim().catch(() => {}); + throw error; + } + this.registries.add(registry); + } + retainGeneration(generation: PreparedModelRuntimePluginGeneration | undefined): void { for (const registry of [generation?.pluginRegistry, generation?.inboundPluginRegistry]) { - this.retain(registry && state.registries.get(registry)); + if (registry) { + this.retainRegistry(registry); + } } } @@ -143,13 +166,14 @@ export class PreparedModelRuntimeBuildResources { params: Parameters[0], onPrimaryRegistry: (registry: PluginRegistry) => void, ): Promise { + this.assertOpen(); const assertLifetime = capturePreparedModelRuntimeLifetime(); const acquired = await acquireAgentRuntimePluginRegistry(params); if ("resources" in acquired) { const resources = new PreparedRegistryResources(acquired); try { assertLifetime(); - this.retain(resources); + this.retainRegistry(acquired.registry); // The build claim now owns finite work before producer custody crosses another await. acquired.releaseWork(); } catch (error) { @@ -157,7 +181,7 @@ export class PreparedModelRuntimeBuildResources { throw error; } } else { - this.retain(state.registries.get(acquired.registry)); + this.retainRegistry(acquired.registry); } onPrimaryRegistry( state.registries.get(acquired.registry)?.primaryRegistry ?? acquired.primaryRegistry, @@ -165,16 +189,9 @@ export class PreparedModelRuntimeBuildResources { return acquired.registry; } - async [Symbol.asyncDispose](): Promise { - const claims = [...this.claims.values()]; - this.claims.clear(); - const results = await Promise.allSettled(claims.map((claim) => claim.release())); - const failures = results.flatMap((result) => - result.status === "rejected" ? [result.reason] : [], - ); - if (failures.length > 0) { - throw new AggregateError(failures, "Prepared registry construction cleanup failed"); - } + [Symbol.asyncDispose](): Promise { + this.registries.clear(); + return this.releases.disposeAsync(); } } diff --git a/src/agents/runtime-plugin-work.ts b/src/agents/runtime-plugin-work.ts index e08a5b79245f..70c5aa788dcd 100644 --- a/src/agents/runtime-plugin-work.ts +++ b/src/agents/runtime-plugin-work.ts @@ -27,11 +27,11 @@ export function retainRuntimePluginWork(registries: Iterable): ( /** Keep replacement blocked through physical cleanup, including retained cleanup failures. */ export async function releaseRuntimePluginWork( - release: () => void | Promise, + release: (() => void | Promise) | undefined, releaseWork: () => void, ): Promise { try { - await release(); + await release?.(); } catch (error) { if (!hasRetainedPluginRuntimeCloseError(error)) { releaseWork(); diff --git a/src/agents/tools/media-generate-tool.donor-resources.test.ts b/src/agents/tools/media-generate-tool.donor-resources.test.ts index 2dbe1803ed9a..79184d54db16 100644 --- a/src/agents/tools/media-generate-tool.donor-resources.test.ts +++ b/src/agents/tools/media-generate-tool.donor-resources.test.ts @@ -23,7 +23,10 @@ import { prepareConfiguredRuntimeFacts } from "../prepared-model-runtime.configu import { prepareWorkspaceBuildGroup } from "../prepared-model-runtime.facts.js"; import { createPreparedModelRuntimeSnapshot } from "../prepared-model-runtime.full-catalog.js"; import { closePreparedModelRuntimeSnapshots } from "../prepared-model-runtime.lifecycle.js"; -import { retainPreparedPluginGeneration } from "../prepared-model-runtime.plugin-lifetime.js"; +import { + retainPreparedPluginGeneration, + retainPreparedPluginRegistry, +} from "../prepared-model-runtime.plugin-lifetime.js"; import { closeEphemeralPreparedModelRuntimeResources, PreparedModelRuntimeBuildResources, @@ -181,7 +184,7 @@ module.exports = { id: '${id}', register(api) { } setActivePluginRegistry(donor.registry); const donorCurrent = capturePluginLifecycleAuthority(donor.registry); - const construction = new PreparedModelRuntimeBuildResources(); + const construction = new PreparedModelRuntimeBuildResources(retainPreparedPluginRegistry); let releasePublication: ReturnType | undefined; let releasingOwners: Promise[]> | undefined; let closeDonor: Promise | undefined; @@ -198,7 +201,11 @@ module.exports = { id: '${id}', register(api) { }, ], "static", - { includeCredentialProviders: false, registryResources: construction }, + { + includeCredentialProviders: false, + registryResources: construction, + loadRuntimeRegistry: construction.load.bind(construction), + }, ); if (mode === "rollback") { const source = getPluginRegistryInspectionResources( diff --git a/src/gateway/server-methods/requester-cron-authority.integration.test.ts b/src/gateway/server-methods/requester-cron-authority.integration.test.ts index f98657a0f8df..91ecdcaeebd6 100644 --- a/src/gateway/server-methods/requester-cron-authority.integration.test.ts +++ b/src/gateway/server-methods/requester-cron-authority.integration.test.ts @@ -22,7 +22,7 @@ import { runWithCronCreatorAuthorityCapability, type CronCreatorAuthorityCapability, } from "../../agents/cron-creator-authority-context.js"; -import { withPreparedEmbeddedGatewayTools } from "../../agents/embedded-agent-runner/run/attempt-tool-run-context.js"; +import { withPreparedEmbeddedGatewayTools } from "../../agents/embedded-agent-runner/run/attempt-gateway-tools.js"; import * as hostFileWrite from "../../agents/host-file-write.js"; import { makeSettledChild } from "../../agents/subagents/announce/subagent-announce.requester-settle-wake.test-support.js"; import { diff --git a/src/gateway/test-helpers.listener.test.ts b/src/gateway/test-helpers.listener.test.ts new file mode 100644 index 000000000000..9a73884df7c1 --- /dev/null +++ b/src/gateway/test-helpers.listener.test.ts @@ -0,0 +1,78 @@ +import type { Server } from "node:http"; +import { describe, expect, it, vi } from "vitest"; +import { createDeferred } from "../../test/helpers/promise.js"; +import { reserveGatewayTestListener } from "./test-helpers.listener.js"; + +vi.mock("./server-runtime-state.js", () => ({ + createGatewayHttpTransport: async (params: { port: number; testListener?: Server }) => + params.testListener, +})); + +function createTestTransport(transport: typeof import("./server-runtime-state.js"), port: number) { + return transport.createGatewayHttpTransport({ + port, + } as Parameters[0]); +} + +describe("reserved Gateway test listeners", () => { + it("adopts a single reservation through the transport dispatcher", async () => { + const transport = await import("./server-runtime-state.js"); + const reservation = await reserveGatewayTestListener(); + try { + await expect( + reservation.start(() => createTestTransport(transport, reservation.port)), + ).resolves.toBe(reservation.listener); + } finally { + await new Promise((resolve, reject) => { + reservation.listener.close((error) => (error ? reject(error) : resolve())); + }); + } + }); + + it.each(["first", "second"] as const)( + "adopts overlapping reservations when %s startup settles first", + async (firstToSettle) => { + const transport = await import("./server-runtime-state.js"); + const first = await reserveGatewayTestListener(); + const second = await reserveGatewayTestListener(); + const reservations = [first, second]; + const entered = reservations.map(() => createDeferred()); + const release = reservations.map(() => createDeferred()); + const runs = reservations.map((reservation, index) => + reservation.start(async () => { + entered[index]!.resolve(); + await release[index]!.promise; + return createTestTransport(transport, reservation.port); + }), + ); + // Observe both rejections immediately, including the pre-fix recursive spy failure. + const settled = Promise.allSettled(runs); + try { + await Promise.race([ + Promise.all(entered.map(({ promise }) => promise)), + ...runs.map(async (run) => { + await run; + throw new Error("Startup settled before both reservation callbacks entered"); + }), + ]); + const order = firstToSettle === "first" ? [0, 1] : [1, 0]; + for (const index of order) { + release[index]!.resolve(); + await expect(runs[index]).resolves.toBe(reservations[index]!.listener); + } + } finally { + release.forEach((gate) => gate.resolve()); + await settled; + // The synthetic transport returns the listener but does not own its close. + await Promise.all( + reservations.map( + ({ listener }) => + new Promise((resolve, reject) => { + listener.close((error) => (error ? reject(error) : resolve())); + }), + ), + ); + } + }, + ); +}); diff --git a/src/gateway/test-helpers.listener.ts b/src/gateway/test-helpers.listener.ts index a8252f01d7e8..d5c76c8fc1c2 100644 --- a/src/gateway/test-helpers.listener.ts +++ b/src/gateway/test-helpers.listener.ts @@ -2,6 +2,35 @@ import assert from "node:assert/strict"; import { createServer } from "node:http"; import type { Socket } from "node:net"; +type ListenerReservation = { + listener: ReturnType; + adopt: () => void; +}; + +async function createListenerDispatcher() { + const { vi } = await import("vitest"); + const transport = await import("./server-runtime-state.js"); + const createTransport = transport.createGatewayHttpTransport; + const listeners = new Map(); + const spy = vi + .spyOn(transport, "createGatewayHttpTransport") + .mockImplementation(async (params) => { + const reservation = listeners.get(params.port); + const runtime = await createTransport( + reservation ? { ...params, testListener: reservation.listener } : params, + ); + reservation?.adopt(); + return runtime; + }); + return { listeners, restore: () => spy.mockRestore() }; +} + +// Overlapping startups share the original constructor and its import. Recapturing +// an installed spy would replace that same mock and make it call itself. +let activeDispatcher: + | { ready: ReturnType; pending: number } + | undefined; + /** Retain the exact loopback listener until the real Gateway transport adopts it. */ export async function reserveGatewayTestListener(port = 0) { const listener = createServer(); @@ -30,25 +59,33 @@ export async function reserveGatewayTestListener(port = 0) { listener, closeUnadopted, async start(run: () => Promise): Promise { - const { vi } = await import("vitest"); - const transport = await import("./server-runtime-state.js"); - const createTransport = transport.createGatewayHttpTransport; - const spy = vi - .spyOn(transport, "createGatewayHttpTransport") - .mockImplementation(async (params) => { - if (params.port !== address.port) { - return createTransport(params); - } - const runtime = await createTransport({ ...params, testListener: listener }); - adopted = true; - return runtime; - }); + const owner = (activeDispatcher ??= { ready: createListenerDispatcher(), pending: 0 }); + owner.pending++; + let dispatcher: Awaited | undefined; try { - const result = await run(); - listener.off("connection", rejectEarlyConnection); - return result; + dispatcher = await owner.ready; + assert( + !dispatcher.listeners.has(address.port), + "Reserved Gateway listener is already starting", + ); + dispatcher.listeners.set(address.port, { + listener, + adopt: () => { + adopted = true; + }, + }); + try { + const result = await run(); + listener.off("connection", rejectEarlyConnection); + return result; + } finally { + dispatcher.listeners.delete(address.port); + } } finally { - spy.mockRestore(); + if (--owner.pending === 0) { + activeDispatcher = undefined; + dispatcher?.restore(); + } } }, }; diff --git a/src/plugin-sdk/agent-runtime-test-contracts.ts b/src/plugin-sdk/agent-runtime-test-contracts.ts index 3767e7e78cb8..3ec844005f7f 100644 --- a/src/plugin-sdk/agent-runtime-test-contracts.ts +++ b/src/plugin-sdk/agent-runtime-test-contracts.ts @@ -1,5 +1,6 @@ // Focused public test contracts for native agent-runtime adapters. +export { setHostToolFactoryForTest } from "./test-helpers/agents/host-tool-factory.js"; export { AUTH_PROFILE_RUNTIME_CONTRACT, createAuthAliasManifestRegistry, diff --git a/src/plugin-sdk/test-helpers/agents/host-tool-factory.ts b/src/plugin-sdk/test-helpers/agents/host-tool-factory.ts new file mode 100644 index 000000000000..945885396def --- /dev/null +++ b/src/plugin-sdk/test-helpers/agents/host-tool-factory.ts @@ -0,0 +1,26 @@ +import { onTestFinished, vi } from "vitest"; +import type { createOpenClawCodingToolsInternal } from "../../../agents/agent-tools.js"; + +type ToolsFactory = typeof createOpenClawCodingToolsInternal; +let createTools: ToolsFactory | undefined; +const factories = new Map(); + +/** Substitutes construction while preserving the real host's private authority and bindings. */ +export async function setHostToolFactoryForTest( + params: { runId: string }, + factory: ToolsFactory, +): Promise { + const agentTools = await import("../../../agents/agent-tools.js"); + const actual = (createTools ??= agentTools.createOpenClawCodingToolsInternal); + factories.set(params.runId, factory); + const spy = vi + .spyOn(agentTools, "createOpenClawCodingToolsInternal") + .mockImplementation((...args) => { + const runFactory = args[0]?.runId ? factories.get(args[0].runId) : undefined; + return (runFactory ?? actual)(...args); + }); + onTestFinished(() => { + factories.clear(); + spy.mockRestore(); + }); +}