fix: prevent plugin metadata rescans from stalling Gateway (#125596)

* fix(plugins): preserve gateway metadata ownership

Generic runtime contexts no longer displace the Gateway lifecycle snapshot, preventing repeated synchronous plugin inventory validation on session hot paths.

* fix(gateway): reuse lifecycle plugin generation for turns

* test(agents): align static runtime lifecycle expectations

* test(gateway): refresh minimal runtime fixtures

* test(runtime): pass lifecycle metadata explicitly

* fix(skills): reuse gateway metadata for commands

* test(gateway): prepare reply runtime for agent RPCs

* style(agents): format rebased runtime preparation

* test(gateway): prepare runtime for direct agent RPCs

* test(gateway): provide direct agent runtime fixture

* test(gateway): prepare chat send reply runtime

* test(qa): await descendant process exit
This commit is contained in:
Peter Steinberger
2026-08-18 10:47:16 -07:00
committed by GitHub
parent f95b5a0062
commit fd216cb550
75 changed files with 898 additions and 658 deletions
-1
View File
@@ -2012,7 +2012,6 @@ src/agents/run-timeout-attribution.ts 1
src/agents/run-wait.ts 8
src/agents/runtime-plan/build.ts 4
src/agents/runtime-plan/tools.ts 5
src/agents/runtime-plugins.ts 1
src/agents/runtime/proxy.ts 7
src/agents/sandbox-paths.ts 1
src/agents/sandbox-tool-policy.ts 1
@@ -153,7 +153,7 @@ describe.skipIf(process.platform === "win32")("qa scenario command real POSIX li
if (descendantPid === undefined) {
throw new Error("scenario command descendant did not expose its pid");
}
expect(isProcessRunning(descendantPid)).toBe(false);
await waitForProcessExit(descendantPid);
} finally {
if (descendantPid && isProcessRunning(descendantPid)) {
process.kill(descendantPid, "SIGKILL");
@@ -112,6 +112,9 @@ const state = vi.hoisted(() => ({
resolveSupportedThinkingLevelMock: vi.fn(({ level }: { level?: string }) => level),
resolveThinkingDefaultMock: vi.fn((_args: unknown) => "low"),
loadManifestModelCatalogMock: vi.fn(() => []),
manifestMetadataSnapshot: { plugins: [] },
resolvePluginMetadataSnapshotMock: vi.fn(),
listSkillCommandsForWorkspaceMock: vi.fn((_params: unknown) => []),
loadProviderScopedThinkingCatalogMock: vi.fn(
async (_params: unknown): Promise<ModelCatalogSnapshot["entries"] | undefined> => undefined,
),
@@ -374,7 +377,16 @@ vi.mock("./agent-runtime-config.js", () => {
vi.mock("../plugins/plugin-metadata-snapshot.js", () => ({
isPluginMetadataSnapshotCompatible: () => false,
resolvePluginMetadataSnapshot: () => ({ plugins: [] }),
resolvePluginMetadataSnapshot: (...args: unknown[]) =>
state.resolvePluginMetadataSnapshotMock(...args),
}));
vi.mock("../skills/discovery/chat-commands.runtime.js", () => ({
expandExplicitSkillReferences: ({ text }: { text: string }) => ({ body: text, skills: [] }),
hasSkillReferenceCandidate: () => true,
listSkillCommandsForWorkspace: (params: unknown) =>
state.listSkillCommandsForWorkspaceMock(params),
resolveEffectiveAgentSkillFilter: () => undefined,
}));
vi.mock("../config/runtime-snapshot.js", () => ({
@@ -727,11 +739,13 @@ vi.mock("../acp/control-plane/manager.js", () => ({
let agentCommand: typeof import("./agent-command.js").agentCommand;
let agentCommandFromSystem: typeof import("./agent-command.js").agentCommandFromSystem;
let prepareAgentCommandExecution: typeof import("./command/prepare.js").prepareAgentCommandExecution;
beforeAll(async () => {
const mod = await import("./agent-command.js");
agentCommand ??= mod.agentCommand;
agentCommandFromSystem ??= mod.agentCommandFromSystem;
({ prepareAgentCommandExecution } = await import("./command/prepare.js"));
});
type FallbackRunnerParams = {
@@ -950,6 +964,7 @@ describe("agentCommand LiveSessionModelSwitchError retry", () => {
state.resolveThinkingDefaultMock.mockReturnValue("low");
state.resolveAgentSkillsFilterMock.mockReturnValue(undefined);
state.loadManifestModelCatalogMock.mockReturnValue([]);
state.resolvePluginMetadataSnapshotMock.mockReturnValue(state.manifestMetadataSnapshot);
state.loadProviderScopedThinkingCatalogMock.mockReset().mockResolvedValue(undefined);
state.loadFullModelCatalogMock.mockClear();
state.loadPreparedModelCatalogSnapshotMock.mockResolvedValue({
@@ -1120,6 +1135,25 @@ describe("agentCommand LiveSessionModelSwitchError retry", () => {
vi.restoreAllMocks();
});
it("uses Gateway command metadata without resolving the agent workspace", async () => {
const pluginGeneration = {
pluginMetadataSnapshot: state.manifestMetadataSnapshot,
} as never;
const prepared = await prepareAgentCommandExecution(
{ message: "/demo", to: "+1234567890" },
{} as never,
{ config: {}, pluginGeneration },
);
expect(prepared.manifestMetadataSnapshot).toBe(state.manifestMetadataSnapshot);
expect(prepared.commandRuntimeContext?.pluginGeneration).toBe(pluginGeneration);
expect(state.listSkillCommandsForWorkspaceMock).toHaveBeenCalledWith(
expect.objectContaining({ pluginMetadataSnapshot: state.manifestMetadataSnapshot }),
);
expect(state.resolvePluginMetadataSnapshotMock).not.toHaveBeenCalled();
});
it("retries with the switched provider/model when LiveSessionModelSwitchError is thrown", async () => {
setupModelSwitchRetry({
provider: "openai",
@@ -3706,6 +3740,9 @@ describe("agentCommand LiveSessionModelSwitchError retry", () => {
if (allowlisted) {
expect(state.loadManifestModelCatalogMock).toHaveBeenCalledTimes(1);
expect(state.loadManifestModelCatalogMock).toHaveBeenCalledWith(
expect.objectContaining({ metadataSnapshot: state.manifestMetadataSnapshot }),
);
}
const thinkingArgs = requireRecord(
mockCallArg(state.isThinkingLevelSupportedMock),
+54 -29
View File
@@ -19,6 +19,7 @@ import {
import { clearAgentRunContext } from "../infra/agent-run-registry.js";
import { formatErrorMessage } from "../infra/errors.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { withPluginRuntimeGenerationScope } from "../plugins/runtime/generation-scope.js";
import { isSubagentSessionKey } from "../routing/session-key.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { isAgentMediatedCompletionSourceTool } from "../sessions/input-provenance.js";
@@ -46,7 +47,10 @@ import { persistAgentSession } from "./command/attempt-execution.shared.js";
import { emitIngressModelUsageDiagnostic } from "./command/ingress-diagnostics.js";
import { resolveEmbeddedModelSelection } from "./command/model-selection.js";
import { finalizeEmbeddedAgentCommand } from "./command/post-run.js";
import { prepareAgentCommandExecution } from "./command/prepare.js";
import {
prepareAgentCommandExecution,
type PreparedAgentCommandRuntimeContext,
} from "./command/prepare.js";
import { runEmbeddedAgentAttempt } from "./command/run-embedded-attempt.js";
import { loadSessionStoreRuntime, resolveAgentCommandDeps } from "./command/runtime-loaders.js";
import { prepareCurrentRunDelivery } from "./command/session-helpers.js";
@@ -457,6 +461,9 @@ async function agentCommandInternal(
persistedVerbose,
verboseDefault: agentCfg?.verboseDefault as VerboseLevel | undefined,
sessionStateActor,
...(manifestMetadataSnapshot
? { pluginMetadataSnapshot: manifestMetadataSnapshot }
: {}),
}),
{ config: cfg },
);
@@ -634,47 +641,64 @@ async function agentCommandFromIngressInternal(
recovery?: {
restoreAdmittedRecovery?: () => Promise<MainSessionRecoveryPendingTarget | undefined>;
},
runtimeContext?: PreparedAgentCommandRuntimeContext,
) {
if (typeof opts.allowModelOverride !== "boolean") {
throw new Error("allowModelOverride must be explicitly set for ingress agent runs.");
}
const lifecycleGeneration =
opts.lifecycleGeneration ?? captureAgentRunLifecycleGeneration(opts.runId ?? "");
return await withAgentRunLifecycleGeneration(lifecycleGeneration, async () => {
let preparedAgentDir: string | undefined;
const result = await runWithAgentCommandRecoveryOwner({
lifecycleGeneration,
mode: "claim",
opts: {
...opts,
const generation = runtimeContext?.pluginGeneration;
const executeIngress = () =>
withAgentRunLifecycleGeneration(lifecycleGeneration, async () => {
let preparedAgentDir: string | undefined;
const result = await runWithAgentCommandRecoveryOwner({
lifecycleGeneration,
senderIsOwner: opts.senderIsOwner === true,
},
prepare: async (preparedOpts) => await prepareAgentCommandExecution(preparedOpts, runtime),
restoreAdmittedRecovery: recovery?.restoreAdmittedRecovery,
run: async (prepared) => {
preparedAgentDir = prepared.agentDir;
return await withAgentPluginRegistry({
config: prepared.cfg,
workspaceDir: prepared.workspaceDir,
run: async () =>
mode: "claim",
opts: {
...opts,
lifecycleGeneration,
senderIsOwner: opts.senderIsOwner === true,
},
prepare: async (preparedOpts) =>
await prepareAgentCommandExecution(preparedOpts, runtime, runtimeContext),
restoreAdmittedRecovery: recovery?.restoreAdmittedRecovery,
run: async (prepared) => {
preparedAgentDir = prepared.agentDir;
const run = async () =>
await agentCommandInternal(
prepared,
prepared.opts,
{ kind: "api", boundary: "agent-command.from-ingress", state: "unknown" },
runtime,
deps,
),
});
},
);
return generation
? await run()
: await withAgentPluginRegistry({
config: prepared.cfg,
workspaceDir: prepared.workspaceDir,
run,
});
},
});
if (result && preparedAgentDir) {
emitIngressModelUsageDiagnostic(result, opts, preparedAgentDir);
}
return result;
});
if (result && preparedAgentDir) {
emitIngressModelUsageDiagnostic(result, opts, preparedAgentDir);
}
return result;
});
return generation && runtimeContext
? await withPluginRuntimeGenerationScope(
{
config: runtimeContext.config,
metadataSnapshot: generation.pluginMetadataSnapshot,
pluginRegistry: generation.pluginRegistry,
},
executeIngress,
)
: await executeIngress();
}
/** Runs an agent turn from an inbound channel/gateway ingress context. */
@@ -700,6 +724,7 @@ export async function agentCommandFromGatewayIngress(
recovery: {
restoreAdmittedRecovery?: () => Promise<MainSessionRecoveryPendingTarget | undefined>;
},
runtimeContext?: PreparedAgentCommandRuntimeContext,
) {
return await agentCommandFromIngressInternal(opts, runtime, deps, recovery);
return await agentCommandFromIngressInternal(opts, runtime, deps, recovery, runtimeContext);
}
+4 -45
View File
@@ -9,9 +9,7 @@ import {
normalizePluginsConfigWithResolver,
resolvePolicyPluginActivationState,
} from "../plugins/config-policy.js";
import { getCurrentPluginMetadataSnapshot } from "../plugins/current-plugin-metadata-snapshot.js";
import {
isPluginMetadataSnapshotCompatible,
loadPluginMetadataSnapshot,
type PluginMetadataSnapshot,
} from "../plugins/plugin-metadata-snapshot.js";
@@ -47,28 +45,6 @@ function sanitizeProjectSettings(settings: AgentSettingsSnapshot): AgentSettings
return sanitizeAgentSettingsSnapshot(settings);
}
function canReuseUnscopedCurrentPluginMetadataSnapshot(config: OpenClawConfig): boolean {
// Unscoped snapshots are only reusable when config does not introduce
// workspace-local plugin load paths that would change the registry contents.
return normalizePluginsConfigWithResolver(config.plugins).loadPaths.length === 0;
}
function resolveUnscopedCurrentPluginMetadataSnapshot(params: {
config: OpenClawConfig;
env: NodeJS.ProcessEnv;
workspaceDir?: string;
}): PluginMetadataSnapshot | undefined {
if (!canReuseUnscopedCurrentPluginMetadataSnapshot(params.config)) {
return undefined;
}
return getCurrentPluginMetadataSnapshot({
env: params.env,
workspaceDir: params.workspaceDir,
allowWorkspaceScopedSnapshot: true,
requireDefaultDiscoveryContext: true,
});
}
function loadBundleSettingsFile(params: {
rootDir: string;
relativePath: string;
@@ -111,29 +87,12 @@ export function loadEnabledBundleAgentSettingsSnapshot(params: {
const env = params.env ?? process.env;
const providedSnapshot = params.pluginMetadataSnapshot;
const metadataSnapshot =
providedSnapshot &&
isPluginMetadataSnapshotCompatible({
snapshot: providedSnapshot,
providedSnapshot ??
loadPluginMetadataSnapshot({
workspaceDir,
config,
env,
workspaceDir,
})
? providedSnapshot
: (getCurrentPluginMetadataSnapshot({
config,
env,
workspaceDir,
}) ??
resolveUnscopedCurrentPluginMetadataSnapshot({
config,
env,
workspaceDir,
}) ??
loadPluginMetadataSnapshot({
workspaceDir,
config,
env,
}));
});
const registry = metadataSnapshot.manifestRegistry;
if (registry.plugins.length === 0) {
return {};
+17 -142
View File
@@ -194,11 +194,10 @@ describe("loadEnabledBundleAgentSettingsSnapshot", () => {
});
expect(snapshot.hideThinkingBlock).toBe(true);
expect(pluginMetadataSnapshotMocks.isPluginMetadataSnapshotCompatible).toHaveBeenCalledOnce();
expect(pluginMetadataSnapshotMocks.loadPluginMetadataSnapshot).not.toHaveBeenCalled();
});
it("falls back to a fresh plugin metadata load for an incompatible snapshot", async () => {
it("treats a supplied lifecycle snapshot as authoritative across workspaces", async () => {
const workspaceDir = await tempDirs.make("openclaw-workspace-");
const pluginRoot = await createWorkspaceBundle({ workspaceDir });
await fs.writeFile(
@@ -207,7 +206,7 @@ describe("loadEnabledBundleAgentSettingsSnapshot", () => {
"utf-8",
);
pluginMetadataSnapshotMocks.isPluginMetadataSnapshotCompatible.mockReturnValueOnce(false);
pluginMetadataSnapshotMocks.getCurrentPluginMetadataSnapshot.mockClear();
pluginMetadataSnapshotMocks.loadPluginMetadataSnapshot.mockClear();
const snapshot = loadEnabledBundleAgentSettingsSnapshot({
@@ -220,7 +219,20 @@ describe("loadEnabledBundleAgentSettingsSnapshot", () => {
},
},
pluginMetadataSnapshot: {
manifestRegistry: { diagnostics: [], plugins: [] },
workspaceDir: "/tmp/gateway-plugin-workspace",
manifestRegistry: {
diagnostics: [],
plugins: [
{
id: "claude-bundle",
format: "bundle",
origin: "global",
enabledByDefault: true,
settingsFiles: ["settings.json"],
rootDir: pluginRoot,
},
],
},
normalizePluginId: (id: string) => id.trim(),
} as unknown as Parameters<
typeof loadEnabledBundleAgentSettingsSnapshot
@@ -228,147 +240,10 @@ describe("loadEnabledBundleAgentSettingsSnapshot", () => {
});
expect(snapshot.hideThinkingBlock).toBe(true);
expect(pluginMetadataSnapshotMocks.isPluginMetadataSnapshotCompatible).toHaveBeenCalledOnce();
expect(pluginMetadataSnapshotMocks.loadPluginMetadataSnapshot).toHaveBeenCalledOnce();
});
it("reuses the current plugin metadata snapshot for bundle settings", async () => {
const workspaceDir = await tempDirs.make("openclaw-workspace-");
const pluginRoot = await createWorkspaceBundle({ workspaceDir });
const resolvedPluginRoot = await fs.realpath(pluginRoot);
await fs.writeFile(
path.join(pluginRoot, "settings.json"),
JSON.stringify({ hideThinkingBlock: true }),
"utf-8",
);
pluginMetadataSnapshotMocks.getCurrentPluginMetadataSnapshot.mockReturnValueOnce({
manifestRegistry: {
diagnostics: [],
plugins: [
{
id: "claude-bundle",
origin: "workspace",
format: "bundle",
bundleFormat: "claude",
settingsFiles: ["settings.json"],
rootDir: resolvedPluginRoot,
},
],
},
normalizePluginId: (id: string) => id.trim(),
});
pluginMetadataSnapshotMocks.loadPluginMetadataSnapshot.mockClear();
const snapshot = loadEnabledBundleAgentSettingsSnapshot({
cwd: workspaceDir,
cfg: {
plugins: {
entries: {
"claude-bundle": { enabled: true },
},
},
},
});
expect(snapshot.hideThinkingBlock).toBe(true);
expect(pluginMetadataSnapshotMocks.getCurrentPluginMetadataSnapshot).not.toHaveBeenCalled();
expect(pluginMetadataSnapshotMocks.loadPluginMetadataSnapshot).not.toHaveBeenCalled();
});
it("does not reuse an unscoped current snapshot when plugin load paths change", async () => {
const workspaceDir = await tempDirs.make("openclaw-workspace-");
const pluginRoot = await createWorkspaceBundle({ workspaceDir });
await fs.writeFile(
path.join(pluginRoot, "settings.json"),
JSON.stringify({ hideThinkingBlock: true }),
"utf-8",
);
pluginMetadataSnapshotMocks.getCurrentPluginMetadataSnapshot.mockReturnValueOnce(undefined);
pluginMetadataSnapshotMocks.loadPluginMetadataSnapshot.mockClear();
const snapshot = loadEnabledBundleAgentSettingsSnapshot({
cwd: workspaceDir,
cfg: {
plugins: {
load: { paths: ["/tmp/changed-plugin-root"] },
entries: {
"claude-bundle": { enabled: true },
},
},
},
});
expect(snapshot.hideThinkingBlock).toBe(true);
expect(pluginMetadataSnapshotMocks.getCurrentPluginMetadataSnapshot).toHaveBeenCalledOnce();
const [snapshotLookup] =
pluginMetadataSnapshotMocks.getCurrentPluginMetadataSnapshot.mock.calls[0] ?? [];
expect(snapshotLookup?.config?.plugins?.load).toEqual({
paths: ["/tmp/changed-plugin-root"],
});
expect(snapshotLookup?.env).toBe(process.env);
expect(snapshotLookup?.workspaceDir).toBe(workspaceDir);
expect(pluginMetadataSnapshotMocks.loadPluginMetadataSnapshot).toHaveBeenCalledOnce();
});
it("does not reuse a load-path current snapshot for a config with default load paths", async () => {
const workspaceDir = await tempDirs.make("openclaw-workspace-");
const pluginRoot = await createWorkspaceBundle({ workspaceDir });
const resolvedPluginRoot = await fs.realpath(pluginRoot);
await fs.writeFile(
path.join(pluginRoot, "settings.json"),
JSON.stringify({ hideThinkingBlock: true }),
"utf-8",
);
const staleSnapshot = {
policyHash: "policy",
manifestRegistry: {
diagnostics: [],
plugins: [
{
id: "claude-bundle",
origin: "workspace",
format: "bundle",
bundleFormat: "claude",
settingsFiles: ["settings.json"],
rootDir: resolvedPluginRoot,
},
],
},
normalizePluginId: (id: string) => id.trim(),
};
pluginMetadataSnapshotMocks.getCurrentPluginMetadataSnapshot.mockImplementation(
(params: { config?: unknown; requireDefaultDiscoveryContext?: boolean }) => {
if (params.config || params.requireDefaultDiscoveryContext) {
return undefined;
}
return staleSnapshot;
},
);
pluginMetadataSnapshotMocks.loadPluginMetadataSnapshot.mockClear();
const snapshot = loadEnabledBundleAgentSettingsSnapshot({
cwd: workspaceDir,
cfg: {
plugins: {
entries: {
"claude-bundle": { enabled: true },
},
},
},
});
expect(snapshot.hideThinkingBlock).toBe(true);
expect(pluginMetadataSnapshotMocks.getCurrentPluginMetadataSnapshot).toHaveBeenCalledTimes(2);
expect(pluginMetadataSnapshotMocks.getCurrentPluginMetadataSnapshot).toHaveBeenLastCalledWith({
env: process.env,
workspaceDir,
allowWorkspaceScopedSnapshot: true,
requireDefaultDiscoveryContext: true,
});
expect(pluginMetadataSnapshotMocks.loadPluginMetadataSnapshot).toHaveBeenCalledOnce();
});
it("loads sanitized settings and MCP defaults from enabled bundle plugins", async () => {
const workspaceDir = await tempDirs.make("openclaw-workspace-");
const pluginRoot = await createWorkspaceBundle({ workspaceDir });
@@ -317,6 +317,7 @@ function makeRunAgentAttemptParams(overrides: RunAgentAttemptOverrides): RunAgen
authProfileProvider: provider,
sessionHasHistory: false,
...overrides,
pluginGeneration: overrides.pluginGeneration,
preparedRunAdmission: overrides.preparedRunAdmission ?? createTestPreparedRunAdmission(runId),
lifecycleGeneration: overrides.lifecycleGeneration ?? "test-generation",
opts: { ...overrides.opts } as RunAgentAttemptParams["opts"],
+5 -1
View File
@@ -76,6 +76,7 @@ import {
} from "../cli-session.js";
import { resolveConversationCapabilityProfile } from "../conversation-capability-profile.js";
import { resolveConversationToolPolicies } from "../conversation-tool-policy-pipeline.js";
import type { RunEmbeddedAgentInternalParams } from "../embedded-agent-runner/run/internal-params.js";
import { runEmbeddedAgent, type EmbeddedAgentRunResult } from "../embedded-agent.js";
import { appendGitCoauthorContext } from "../git-coauthor-attribution.js";
import type { ContextEngineLogicalTurnLease } from "../harness/context-engine-logical-turn.js";
@@ -85,6 +86,7 @@ import { resolveAvailableAgentHarnessPolicy } from "../harness/selection.js";
import { resolveCliRuntimeExecutionProvider } from "../model-runtime-aliases.js";
import { isCliProvider } from "../model-selection.js";
import { resolveOpenAIRuntimeProvider } from "../openai-routing.js";
import type { PreparedModelRuntimePluginGeneration } from "../prepared-model-runtime.types.js";
import { hasVerifiedRequesterCompletionHandoff } from "../requester-tool-policy.js";
import { resolveAgentRunAbortLifecycleFields } from "../run-termination.js";
import { buildAgentRuntimeAuthPlan } from "../runtime-plan/auth.js";
@@ -531,6 +533,7 @@ export function runAgentAttempt(params: {
storePath?: string;
pluginsEnabled?: boolean;
metadataSnapshot?: PluginMetadataSnapshot;
pluginGeneration: PreparedModelRuntimePluginGeneration | undefined;
allowTransientCooldownProbe?: boolean;
modelFallbacksOverride?: string[];
sessionHasHistory?: boolean;
@@ -1126,7 +1129,7 @@ export function runAgentAttempt(params: {
const embeddedPersistencePrompt = params.opts.gitCoauthorAttribution
? (continuationTranscriptBody ?? effectivePrompt)
: continuationTranscriptBody;
const embeddedRunParams: Parameters<typeof runEmbeddedAgent>[0] = {
const embeddedRunParams: RunEmbeddedAgentInternalParams = {
preparedRunAdmission: params.preparedRunAdmission,
sessionId: params.sessionId,
sessionKey: params.sessionKey,
@@ -1159,6 +1162,7 @@ export function runAgentAttempt(params: {
permissionMode: params.sessionEntry?.permissionMode,
sessionRoot: params.sessionEntry?.sessionRoot,
config: params.cfg,
...(params.pluginGeneration ? { pluginGeneration: params.pluginGeneration } : {}),
agentHarnessId: embeddedAgentHarnessOverride,
modelSelectionLocked: !isRawModelRun && params.sessionEntry?.modelSelectionLocked === true,
agentHarnessRuntimeOverride: embeddedAgentHarnessOverride,
+7 -4
View File
@@ -8,6 +8,7 @@ import {
import { resolveChannelModelOverride } from "../../channels/model-overrides.js";
import type { SessionEntry } from "../../config/sessions/types.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { PluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.types.js";
import { requireActivePluginRegistry } from "../../plugins/runtime.js";
import { isSubagentSessionKey } from "../../routing/session-key.js";
import { isValidAgentHarnessSessionStoreEntry } from "../../sessions/agent-harness-session-key.js";
@@ -83,9 +84,7 @@ export async function resolveEmbeddedModelSelection(params: {
sessionAgentId: string;
workspaceDir: string;
pluginsEnabled: boolean;
manifestMetadataSnapshot?: NonNullable<
Parameters<typeof resolveProviderIdForAuth>[1]
>["metadataSnapshot"];
manifestMetadataSnapshot?: PluginMetadataSnapshot;
modelManifestContext: ModelManifestNormalizationContext;
configuredThinkingCatalog: ReturnType<typeof loadManifestModelCatalog>;
requestedThinkLevel?: ThinkLevel;
@@ -155,7 +154,11 @@ export async function resolveEmbeddedModelSelection(params: {
Object.keys(agentModels ?? {}).length > 0;
if (hasAllowlist || hasConfiguredModels) {
modelCatalog = params.pluginsEnabled
? loadManifestModelCatalog({ config: params.cfg, workspaceDir: params.workspaceDir })
? loadManifestModelCatalog({
config: params.cfg,
workspaceDir: params.workspaceDir,
metadataSnapshot: params.manifestMetadataSnapshot,
})
: [];
visibilityPolicy = createModelVisibilityPolicy({
cfg: params.cfg,
+3 -1
View File
@@ -24,7 +24,7 @@ import { throwAgentRunRestartAbortReason } from "../run-termination.js";
import { persistAssistantTranscriptRepairRecord } from "./assistant-transcript-repair.js";
import { persistAgentSession } from "./attempt-execution.shared.js";
import type { PreparedAgentCommandExecution } from "./prepare.js";
import type { EmbeddedAgentAttempt } from "./run-embedded-attempt.js";
import type { runEmbeddedAgentAttempt } from "./run-embedded-attempt.js";
import {
loadAgentRunnerMemoryRuntime,
loadCliCompactionRuntime,
@@ -35,6 +35,8 @@ import { clearPendingFinalDelivery } from "./session-helpers.js";
import type { EmbeddedSessionState } from "./session-preparation.js";
import type { AgentCommandOpts } from "./types.js";
type EmbeddedAgentAttempt = Awaited<ReturnType<typeof runEmbeddedAgentAttempt>>;
const log = createSubsystemLogger("agents/agent-command");
export async function finalizeEmbeddedAgentCommand(params: {
+19 -16
View File
@@ -8,13 +8,11 @@ import {
normalizeVerboseLevel,
} from "../../auto-reply/thinking.js";
import { formatCliCommand } from "../../cli/command-format.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { resolveAgentExplicitRecipientSession } from "../../infra/outbound/agent-delivery.js";
import { buildOutboundSessionContext } from "../../infra/outbound/session-context.js";
import { normalizePluginsConfig } from "../../plugins/config-state.js";
import {
isPluginMetadataSnapshotCompatible,
resolvePluginMetadataSnapshot,
} from "../../plugins/plugin-metadata-snapshot.js";
import { resolvePluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.js";
import {
classifySessionKeyShape,
isUnscopedSessionKeySentinel,
@@ -39,6 +37,7 @@ import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../defaults.js";
import { AGENT_LANE_SUBAGENT } from "../lanes.js";
import type { ModelManifestNormalizationContext } from "../model-ref-shared.js";
import { buildConfiguredModelCatalog, resolveConfiguredModelRef } from "../model-selection.js";
import type { PreparedModelRuntimePluginGeneration } from "../prepared-model-runtime.types.js";
import { normalizeSpawnedRunMetadata } from "../spawned-context.js";
import { resolveEffectiveAgentRuntime } from "../thinking-runtime.js";
import { resolveAgentTimeoutMs } from "../timeout.js";
@@ -85,7 +84,16 @@ export function normalizeExplicitOverrideInput(raw: string, kind: "provider" | "
return trimmed;
}
export async function prepareAgentCommandExecution(opts: AgentCommandOpts, runtime: RuntimeEnv) {
export type PreparedAgentCommandRuntimeContext = Readonly<{
config: OpenClawConfig;
pluginGeneration: PreparedModelRuntimePluginGeneration;
}>;
export async function prepareAgentCommandExecution(
opts: AgentCommandOpts,
runtime: RuntimeEnv,
runtimeContext?: PreparedAgentCommandRuntimeContext,
) {
const isRawModelRun = opts.modelRun === true || opts.promptMode === "none";
const message = opts.message ?? "";
if (!message.trim()) {
@@ -114,7 +122,7 @@ export async function prepareAgentCommandExecution(opts: AgentCommandOpts, runti
);
}
const { cfg, pluginMetadataSnapshot } = await resolveAgentRuntimeConfig(runtime, {
const { cfg } = await resolveAgentRuntimeConfig(runtime, {
runtimeTargetsChannelSecrets: opts.deliver === true,
runtimeChannelSecretScope:
opts.deliver !== true && shouldResolveExplicitRecipientSession && recipientChannel
@@ -271,17 +279,10 @@ export async function prepareAgentCommandExecution(opts: AgentCommandOpts, runti
normalizeOptionalString(opts.cwd) ?? normalizeOptionalString(sessionEntryRaw?.spawnedCwd);
const agentDir = resolveAgentDir(cfg, sessionAgentId);
const pluginsEnabled = normalizePluginsConfig(cfg.plugins).enabled;
const preparedMetadataSnapshot = runtimeContext?.pluginGeneration.pluginMetadataSnapshot;
const manifestMetadataSnapshot = pluginsEnabled
? pluginMetadataSnapshot &&
pluginMetadataSnapshot.pluginIds === undefined &&
isPluginMetadataSnapshotCompatible({
snapshot: pluginMetadataSnapshot,
config: cfg,
env: process.env,
workspaceDir,
})
? pluginMetadataSnapshot
: resolvePluginMetadataSnapshot({ config: cfg, env: process.env, workspaceDir })
? (preparedMetadataSnapshot ??
resolvePluginMetadataSnapshot({ config: cfg, env: process.env, workspaceDir }))
: undefined;
const modelManifestContext = {
manifestPlugins: manifestMetadataSnapshot?.plugins ?? [],
@@ -376,6 +377,7 @@ export async function prepareAgentCommandExecution(opts: AgentCommandOpts, runti
agentId: sessionAgentId,
sessionEntry: sessionEntryRaw,
sessionKey,
...(preparedMetadataSnapshot ? { pluginMetadataSnapshot: preparedMetadataSnapshot } : {}),
...(skillFilter ? { skillFilter } : {}),
};
const skillCommands = listSkillCommandsForWorkspace(commandParams);
@@ -429,6 +431,7 @@ export async function prepareAgentCommandExecution(opts: AgentCommandOpts, runti
agentDir,
pluginsEnabled,
manifestMetadataSnapshot,
...(runtimeContext ? { commandRuntimeContext: runtimeContext } : {}),
modelManifestContext,
runId,
isSubagentLane,
+1 -4
View File
@@ -61,7 +61,6 @@ import { loadAttemptExecutionRuntime, type AgentAttemptResult } from "./runtime-
import { resolveInternalSessionEffectsSource } from "./session-helpers.js";
import type { EmbeddedSessionState } from "./session-preparation.js";
import type { AgentCommandOpts } from "./types.js";
const log = createSubsystemLogger("agents/agent-command");
const MAX_LIVE_SWITCH_RETRIES = 5;
@@ -495,6 +494,7 @@ export async function runEmbeddedAgentAttempt(params: {
storePath: params.suppressVisibleSessionEffects ? undefined : storePath,
pluginsEnabled,
...(manifestMetadataSnapshot ? { metadataSnapshot: manifestMetadataSnapshot } : {}),
pluginGeneration: params.prepared.commandRuntimeContext?.pluginGeneration,
allowTransientCooldownProbe: runOptions?.allowTransientCooldownProbe,
sessionHasHistory:
!isNewSession ||
@@ -511,7 +511,6 @@ export async function runEmbeddedAgentAttempt(params: {
onUserMessagePersisted: attemptLifecycleCallbacks.onUserMessagePersisted,
onLifecycleGenerationChanged: (nextLifecycleGeneration) => {
lifecycleGeneration = nextLifecycleGeneration;
// Outer cleanup owns the run context, so publish before the attempt can reject.
params.onLifecycleGenerationChanged(nextLifecycleGeneration);
},
onAgentEvent: attemptLifecycleCallbacks.onAgentEvent,
@@ -705,5 +704,3 @@ export async function runEmbeddedAgentAttempt(params: {
terminal,
};
}
export type EmbeddedAgentAttempt = Awaited<ReturnType<typeof runEmbeddedAgentAttempt>>;
@@ -3,6 +3,7 @@ import type { SessionEntry } from "../../config/sessions/types.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { assertAgentRunLifecycleGenerationCurrent } from "../../infra/agent-events.js";
import { registerAgentRunContext } from "../../infra/agent-run-registry.js";
import type { PluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.types.js";
import { applyVerboseOverride } from "../../sessions/level-overrides.js";
import { recordSessionHumanDirectMessage } from "../../sessions/session-state-events.js";
import { resolveEffectiveAgentSkillFilter } from "../../skills/discovery/agent-filter.js";
@@ -34,6 +35,7 @@ export async function prepareEmbeddedSessionState(params: {
persistedVerbose?: VerboseLevel;
verboseDefault?: VerboseLevel;
sessionStateActor: Parameters<typeof recordSessionHumanDirectMessage>[0]["actor"];
pluginMetadataSnapshot?: PluginMetadataSnapshot;
}) {
const requestedThinkLevel = params.thinkOnce ?? params.thinkOverride ?? params.persistedThinking;
const resolvedVerboseLevel =
@@ -77,6 +79,9 @@ export async function prepareEmbeddedSessionState(params: {
}),
},
watch: false,
...(params.pluginMetadataSnapshot
? { pluginMetadataSnapshot: params.pluginMetadataSnapshot }
: {}),
});
const needsSkillsSnapshot =
params.isNewSession || !currentSkillsSnapshot || skillSnapshotState.shouldRefresh;
@@ -365,9 +365,6 @@ function createCompactHooksRuntimePlan(params: BuildAgentRuntimePlanParams): Age
transformSystemPrompt: vi.fn((context: { systemPrompt: string }) => context.systemPrompt),
},
tools: {
preparedPlanning: {
loadMetadataSnapshot: () => ({}),
},
normalize: vi.fn((tools) => tools),
logDiagnostics: vi.fn(),
},
@@ -15,12 +15,12 @@ import {
buildHandledBeforeAgentReplyPayloads,
runBeforeAgentReplyForTurn,
} from "../../plugins/before-agent-reply.js";
import { getCurrentPluginMetadataSnapshot } from "../../plugins/current-plugin-metadata-snapshot.js";
import {
buildAgentHookContextChannelFields,
buildAgentHookContextIdentityFields,
} from "../../plugins/hook-agent-context.js";
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
import { loadPluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.js";
import { withPluginRuntimeGenerationScope } from "../../plugins/runtime/generation-scope.js";
import { resolveUserPath } from "../../utils.js";
import { isMarkdownCapableMessageChannel } from "../../utils/message-channel.js";
@@ -230,15 +230,16 @@ async function runEmbeddedAgentInternal(
agentId: requestedWorkspaceResolution.agentId,
sessionKey: params.sessionKey,
});
const currentPluginMetadataSnapshot = getCurrentPluginMetadataSnapshot({
config,
workspaceDir: requestedWorkspaceResolution.workspaceDir,
env: process.env,
allowWorkspaceScopedSnapshot: true,
});
const pluginMetadataSnapshot =
params.pluginGeneration?.pluginMetadataSnapshot ??
loadPluginMetadataSnapshot({
config,
workspaceDir: requestedWorkspaceResolution.workspaceDir,
env: process.env,
});
const runtimePluginSelections = resolveModelCandidateChain({
cfg: config,
manifestPlugins: currentPluginMetadataSnapshot?.plugins ?? [],
manifestPlugins: pluginMetadataSnapshot.plugins,
provider: requestedRuntimeSelection.provider,
model: requestedRuntimeSelection.modelId,
requestedRouteResolution: "resolved",
@@ -287,11 +288,19 @@ async function runEmbeddedAgentInternal(
// Turns need only configured admission facts. Full live model inventory remains
// available through the snapshot's lazy control-plane loader.
catalogMode: "static",
...(params.pluginGeneration ? { pluginGeneration: params.pluginGeneration } : {}),
}),
);
startupStages.mark("prepared-runtime");
const preparedModelRuntimeOwnerSnapshot = preparedModelRuntimeLease.snapshot;
try {
if (
params.pluginGeneration &&
preparedModelRuntimeOwnerSnapshot.metadataSnapshot !==
params.pluginGeneration.pluginMetadataSnapshot
) {
throw new Error("prepared model runtime replaced the admitted plugin generation");
}
// A reload may complete while admission waits. The committed generation owns config,
// directories, model selection, hooks, fallbacks, and every later run projection.
const rebound = bindRunToPreparedModelRuntime({
@@ -195,12 +195,13 @@ describe("prepareEmbeddedAttemptSetup", () => {
expect(resolveProviderRuntimePluginHandle).not.toHaveBeenCalled();
});
it("resolves partial handles without trusting scoped metadata", async () => {
it("resolves partial handles with the exact lifecycle metadata", async () => {
const resolvedHandle: ProviderRuntimePluginHandle = {
provider: "openai",
modelId: "gpt-5.4",
};
resolveProviderRuntimePluginHandle.mockReturnValue(resolvedHandle);
const metadataSnapshot = { pluginIds: ["other"] };
const setup = await prepareEmbeddedAttemptSetup({
config: {},
modelId: "gpt-5.4",
@@ -211,7 +212,7 @@ describe("prepareEmbeddedAttemptSetup", () => {
timeoutMs: 30_000,
workspaceDir: path.join(os.tmpdir(), "openclaw-attempt-setup-partial"),
preparedModelRuntime: {
metadataSnapshot: { pluginIds: ["other"] },
metadataSnapshot,
} as never,
runtimePlan: { providerRuntimeHandle: { provider: "openai" } } as never,
} as unknown as EmbeddedRunAttemptParams);
@@ -223,7 +224,7 @@ describe("prepareEmbeddedAttemptSetup", () => {
expect(resolveProviderRuntimePluginHandle).toHaveBeenCalledOnce();
const call = resolveProviderRuntimePluginHandle.mock.calls[0]?.[0];
expect(call).toMatchObject({ provider: "openai", modelId: "gpt-5.4" });
expect(call).not.toHaveProperty("pluginMetadataSnapshot");
expect(call?.pluginMetadataSnapshot).toBe(metadataSnapshot);
});
});
@@ -23,7 +23,6 @@ import {
getActiveDiagnosticTraceContext,
} from "../../../infra/diagnostic-trace-context.js";
import { getAgentScopedMediaLocalRoots } from "../../../media/local-roots.js";
import { isPluginMetadataSnapshotCompatible } from "../../../plugins/plugin-metadata-snapshot.js";
import type { PluginMetadataSnapshot } from "../../../plugins/plugin-metadata-snapshot.types.js";
import {
resolveProviderRuntimePluginHandle,
@@ -224,17 +223,6 @@ export async function prepareEmbeddedAttemptSetup(params: EmbeddedRunAttemptPara
return providerRuntimeHandle;
}
const pluginMetadataSnapshot = getCurrentAttemptPluginMetadataSnapshot();
const compatibleMetadataSnapshot =
pluginMetadataSnapshot &&
pluginMetadataSnapshot.pluginIds === undefined &&
isPluginMetadataSnapshotCompatible({
snapshot: pluginMetadataSnapshot,
config: params.config,
env: process.env,
workspaceDir: effectiveWorkspace,
})
? pluginMetadataSnapshot
: undefined;
providerRuntimeHandle = {
...resolveProviderRuntimePluginHandle({
provider: params.provider,
@@ -242,9 +230,7 @@ export async function prepareEmbeddedAttemptSetup(params: EmbeddedRunAttemptPara
config: params.config,
workspaceDir: effectiveWorkspace,
env: process.env,
...(compatibleMetadataSnapshot
? { pluginMetadataSnapshot: compatibleMetadataSnapshot }
: {}),
...(pluginMetadataSnapshot ? { pluginMetadataSnapshot } : {}),
}),
provider: params.provider,
modelId: params.modelId,
@@ -1,4 +1,5 @@
import type { AgentExecutionAuthBinding } from "../../execution-auth-binding.js";
import type { PreparedModelRuntimePluginGeneration } from "../../prepared-model-runtime.types.js";
import type { SystemAgentToolOptions } from "../../tools/system-agent-tool.js";
import type { RunEmbeddedAgentParams } from "./params.js";
@@ -9,6 +10,8 @@ export type RunEmbeddedAgentInternalParams = RunEmbeddedAgentParams & {
preparedModelRuntimeMode?: "isolated-read-only";
/** Ring-zero tool override, supplied only by the OpenClaw orchestrator. */
systemAgentTool?: SystemAgentToolOptions;
/** Gateway-private lifecycle generation selected before command admission. */
pluginGeneration?: PreparedModelRuntimePluginGeneration;
};
export type RunEmbeddedAgentParamsWithSessionFile = RunEmbeddedAgentInternalParams & {
@@ -1,6 +1,5 @@
import { readSourceReplyDeliveryRuntime } from "../../../auto-reply/reply/source-reply-delivery-runtime.js";
import type { ThinkLevel } from "../../../auto-reply/thinking.js";
import { isPluginMetadataSnapshotCompatible } from "../../../plugins/plugin-metadata-snapshot.js";
import { resolveProviderRuntimePluginHandle } from "../../../plugins/provider-hook-runtime.js";
import { resolvePreparedRunAdmission } from "../../admitted-run-context.js";
import type { AuthProfileStore } from "../../auth-profiles.js";
@@ -484,22 +483,10 @@ export async function prepareEmbeddedRunRuntime(input: {
}
input.markStartupStage("auth");
input.notifyExecutionPhase("auth", { provider, model: modelId });
const compatibleMetadataSnapshot =
pluginMetadataSnapshot &&
pluginMetadataSnapshot.pluginIds === undefined &&
isPluginMetadataSnapshotCompatible({
snapshot: pluginMetadataSnapshot,
config: params.config,
env: process.env,
workspaceDir: input.workspaceDir,
})
? pluginMetadataSnapshot
: undefined;
const routeFacts = getModelProviderRequestRouteFacts(effectiveModel);
const fallbackEndpointClass = routeFacts
? undefined
: resolveProviderEndpoint(effectiveModel.baseUrl, compatibleMetadataSnapshot?.owners)
.endpointClass;
: resolveProviderEndpoint(effectiveModel.baseUrl, pluginMetadataSnapshot?.owners).endpointClass;
const providerOwner =
routeFacts?.providerOwner ??
(fallbackEndpointClass &&
@@ -514,7 +501,7 @@ export async function prepareEmbeddedRunRuntime(input: {
config: params.config,
workspaceDir: input.workspaceDir,
env: process.env,
...(compatibleMetadataSnapshot ? { pluginMetadataSnapshot: compatibleMetadataSnapshot } : {}),
...(pluginMetadataSnapshot ? { pluginMetadataSnapshot } : {}),
}),
modelId,
prepared: true as const,
+21 -1
View File
@@ -8,6 +8,7 @@ import {
resolveSelectedContextEnginePluginId,
} from "../../plugins/config-state.js";
import { isPluginEnabledByDefaultForPlatform } from "../../plugins/default-enablement.js";
import type { PluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.types.js";
import {
loadPluginRegistrySnapshot,
normalizePluginsConfigWithRegistry,
@@ -51,12 +52,17 @@ function restrictiveAllowlistOmitsPlugin(config: OpenClawConfig | undefined, plu
function resolveSelectedMemoryPluginIds(params: {
config: OpenClawConfig | undefined;
workspaceDir: string;
metadataSnapshot?: PluginMetadataSnapshot;
}): string[] {
// Honor config-owned test defaults before discovery forces an implicit memory owner.
if (isTestDefaultMemorySlotDisabled(params.config ?? {})) {
return [];
}
const registry = loadPluginRegistrySnapshot(params);
const registry = loadPluginRegistrySnapshot({
config: params.config,
workspaceDir: params.metadataSnapshot?.workspaceDir ?? params.workspaceDir,
...(params.metadataSnapshot ? { index: params.metadataSnapshot.index } : {}),
});
const plugins = normalizePluginsConfigWithRegistry(params.config?.plugins, registry);
const memorySlot = plugins.slots.memory;
if (
@@ -86,6 +92,7 @@ function resolveSelectedProviderOwnerPluginIds(params: {
provider: string;
config?: OpenClawConfig;
workspaceDir: string;
metadataSnapshot?: PluginMetadataSnapshot;
}): string[] {
const providerOwnerPluginIds = dedupePluginIds(
resolveOwningPluginIdsForProviderRef(params) ?? [],
@@ -98,11 +105,18 @@ function resolveSelectedProviderOwnerPluginIds(params: {
config: params.config,
workspaceDir: params.workspaceDir,
onlyPluginIds: providerOwnerPluginIds,
manifestRegistry: params.metadataSnapshot?.manifestRegistry,
}),
...resolveActivatableProviderOwnerPluginIds({
pluginIds: providerOwnerPluginIds,
config: params.config,
workspaceDir: params.workspaceDir,
...(params.metadataSnapshot
? {
registry: params.metadataSnapshot.index,
manifestRegistry: params.metadataSnapshot.manifestRegistry,
}
: {}),
}),
]);
return providerOwnerPluginIds.filter((pluginId) => safeProviderOwnerPluginIds.includes(pluginId));
@@ -115,12 +129,14 @@ export function resolveAgentHarnessOwnerPluginIds(params: {
config?: OpenClawConfig;
workspaceDir: string;
providerOwnerPluginIds?: readonly string[];
metadataSnapshot?: PluginMetadataSnapshot;
}): string[] {
const harnessPluginIds = resolveManifestActivationPlan({
trigger: { kind: "agentHarness", runtime: params.runtime },
config: params.config,
workspaceDir: params.workspaceDir,
requireExplicitManifestOwnerTrust: true,
manifestRecords: params.metadataSnapshot?.plugins,
}).entries.map((entry) => entry.pluginId);
if (
harnessPluginIds.length === 0 ||
@@ -197,11 +213,13 @@ export function resolveAgentRuntimePluginLoadPlan(params: {
workspaceDir: string;
basePluginIds?: readonly string[];
selections: readonly AgentHarnessPluginSelection[];
metadataSnapshot?: PluginMetadataSnapshot;
}): { config?: OpenClawConfig; pluginIds?: string[] } {
let config = params.config;
const memoryPluginIds = resolveSelectedMemoryPluginIds({
config: params.config,
workspaceDir: params.workspaceDir,
metadataSnapshot: params.metadataSnapshot,
});
const contextEnginePluginId = resolveSelectedContextEnginePluginId(params.config);
const contextEnginePluginIds = contextEnginePluginId ? [contextEnginePluginId] : [];
@@ -216,6 +234,7 @@ export function resolveAgentRuntimePluginLoadPlan(params: {
provider: selection.provider,
config,
workspaceDir: params.workspaceDir,
metadataSnapshot: params.metadataSnapshot,
});
pluginIds.push(...providerOwnerPluginIds);
forceActivatedPluginIds.push(...providerOwnerPluginIds);
@@ -228,6 +247,7 @@ export function resolveAgentRuntimePluginLoadPlan(params: {
config,
workspaceDir: params.workspaceDir,
providerOwnerPluginIds,
metadataSnapshot: params.metadataSnapshot,
});
pluginIds.push(...harnessPluginIds);
const allowedHarnessPluginIds =
@@ -8,7 +8,6 @@ import {
createPluginMetadataSnapshot,
makeRegistry,
} from "../config/plugin-auto-enable.test-helpers.js";
import * as pluginMetadata from "../plugins/plugin-metadata-snapshot.js";
import { createEmptyPluginRegistry } from "../plugins/registry-empty.js";
import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../plugins/runtime.js";
import { activateSecretsRuntimeSnapshot, clearSecretsRuntimeSnapshot } from "../secrets/runtime.js";
@@ -191,20 +190,14 @@ describe("createOpenClawTools browser plugin integration", () => {
manifestRegistry: makeRegistry([]),
workspaceDir: "/tmp",
});
const resolveMetadata = vi
.spyOn(pluginMetadata, "resolvePluginMetadataSnapshot")
.mockReturnValue(metadataSnapshot);
try {
expect(
prepareOwnedPluginLoadContext(
{ agentDir: "/tmp/agent", config, workspaceDir: "/tmp" },
process.env,
pluginRegistry,
),
).toBe(metadataSnapshot);
} finally {
resolveMetadata.mockRestore();
}
expect(
prepareOwnedPluginLoadContext(
{ agentDir: "/tmp/agent", config, workspaceDir: "/tmp" },
process.env,
pluginRegistry,
metadataSnapshot,
),
).toBe(metadataSnapshot);
const loadContext = getPreparedPluginRuntimeLoadContext(pluginRegistry);
if (!loadContext) {
throw new Error("expected prepared plugin load context");
@@ -61,7 +61,6 @@ function refreshAuthStore(params: {
config: params.config,
metadataSnapshot: params.pluginGeneration.pluginMetadataSnapshot,
pluginRegistry: params.pluginGeneration.pluginRegistry,
workspaceDir: params.pluginGeneration.pluginMetadataSnapshot.workspaceDir,
},
() =>
overlayExternalAuthProfiles(prepared, {
@@ -141,7 +140,6 @@ export async function runPreparedModelCatalogWorkerRequest(
config: value.input.config,
metadataSnapshot: prepared.pluginGeneration.pluginMetadataSnapshot,
pluginRegistry: prepared.pluginGeneration.pluginRegistry,
workspaceDir: value.input.workspaceDir,
},
() =>
resolveAmbientAgentCredentialsForDiscovery({
+7 -6
View File
@@ -99,6 +99,7 @@ export async function acquirePreparedModelRuntimeLeaseFromOwners(
options: {
retainIdleRunOwner?: boolean;
catalogMode?: PreparedModelRuntimeCatalogMode;
pluginGeneration?: PreparedModelRuntimeOwner["pluginGeneration"];
} = {},
): Promise<PreparedModelRuntimeLease> {
let normalizedInput = normalizePreparedModelRuntimeInput({
@@ -109,6 +110,7 @@ export async function acquirePreparedModelRuntimeLeaseFromOwners(
if (
provenance === "run" &&
context.getGatewayLifecycleActive() &&
!options.pluginGeneration &&
!context.getPendingReplacement()
) {
try {
@@ -143,7 +145,7 @@ export async function acquirePreparedModelRuntimeLeaseFromOwners(
if (context.getPendingReplacement()) {
continue;
}
if (provenance === "run") {
if (provenance === "run" && !options.pluginGeneration) {
input = rebindInputToCommittedConfiguredOwner(context.owners, input);
key = ownerKey(input);
}
@@ -157,6 +159,7 @@ export async function acquirePreparedModelRuntimeLeaseFromOwners(
if (
context.getGatewayLifecycleActive() &&
provenance === "run" &&
!options.pluginGeneration &&
(!existing || staleDynamicOwner)
) {
// Dynamic workspaces still inherit the committed agent/config generation. Only their
@@ -184,11 +187,9 @@ export async function acquirePreparedModelRuntimeLeaseFromOwners(
}
}
try {
const reusablePluginGeneration = resolveReusableConfiguredPluginGeneration(
input,
workspacePluginRootPresent,
context,
);
const reusablePluginGeneration =
options.pluginGeneration ??
resolveReusableConfiguredPluginGeneration(input, workspacePluginRootPresent, context);
if (existing && !staleDynamicOwner) {
snapshot = await context.prepareSnapshot(input);
} else {
+7 -8
View File
@@ -34,7 +34,6 @@ import {
createPreparedInboundRegistryLoader,
preparedModelRuntimeWorkspaceFactsKey,
} from "./prepared-model-runtime.inbound-registry.js";
import { projectPreparedPluginGeneration } from "./prepared-model-runtime.plugin-generation.js";
import type {
PreparedModelRuntimeBuildStats,
PreparedModelRuntimeCatalogMode,
@@ -281,6 +280,7 @@ async function buildSnapshotBatch(
PreparedModelRuntimeInput,
PreparedModelRuntimePluginGeneration
>,
pluginMetadataSnapshot?: PreparedModelRuntimePluginGeneration["pluginMetadataSnapshot"],
onBuildStats?: (stats: PreparedModelRuntimeBuildStats) => void,
): Promise<PreparedModelRuntimeBuildResult[]> {
const freshGroups = new Map<string, PreparedModelRuntimeInput[]>();
@@ -346,6 +346,7 @@ async function buildSnapshotBatch(
{},
prepareInboundPluginRegistry ? loadInboundPluginRegistry : undefined,
pluginGeneration,
pluginMetadataSnapshot,
);
assertPreparedModelRuntimeInputsCurrent(groupInputs, buildGuards);
runtimePluginMs += prepared.buildStats.runtimePluginMs;
@@ -356,13 +357,7 @@ async function buildSnapshotBatch(
configuredProjectionMs += prepared.buildStats.configuredProjectionMs;
for (const agentFacts of prepared.agentFacts) {
preparedInputs.set(agentFacts.input, agentFacts);
pluginGenerations.set(
agentFacts.input,
projectPreparedPluginGeneration({
input: agentFacts.input,
pluginGeneration: prepared.pluginGeneration,
}),
);
pluginGenerations.set(agentFacts.input, prepared.pluginGeneration);
}
}
const workspaceFactsMs = performance.now() - workspaceFactsStartedAt;
@@ -552,6 +547,7 @@ export function startSerializedSnapshotBuildBatch(
PreparedModelRuntimeInput,
PreparedModelRuntimePluginGeneration
> = new Map(),
pluginMetadataSnapshot?: PreparedModelRuntimePluginGeneration["pluginMetadataSnapshot"],
): {
pending: Promise<PreparedModelRuntimeBuildResult[]>;
completion: Promise<void>;
@@ -579,6 +575,7 @@ export function startSerializedSnapshotBuildBatch(
buildGuards,
inboundPluginRegistryInputs,
reusablePluginGenerations,
pluginMetadataSnapshot,
onBuildStats,
),
};
@@ -618,6 +615,7 @@ export function startSerializedSnapshotBuild(
generationGuard: () => boolean = () => true,
prepareInboundPluginRegistry = false,
reusablePluginGeneration?: PreparedModelRuntimePluginGeneration,
pluginMetadataSnapshot?: PreparedModelRuntimePluginGeneration["pluginMetadataSnapshot"],
): {
pending: Promise<PreparedModelRuntimeBuildResult>;
completion: Promise<void>;
@@ -632,6 +630,7 @@ export function startSerializedSnapshotBuild(
undefined,
prepareInboundPluginRegistry ? new Set([input]) : undefined,
reusablePluginGeneration ? new Map([[input, reusablePluginGeneration]]) : undefined,
pluginMetadataSnapshot,
);
return {
pending: build.pending.then((results) => results[0]!),
+19 -26
View File
@@ -175,6 +175,7 @@ export async function prepareWorkspaceBuildGroup(
options: { providerDiscoveryProviderIds?: readonly string[] } = {},
loadInboundPluginRegistry?: PreparedInboundRegistryLoader,
reusablePluginGeneration?: PreparedModelRuntimePluginGeneration,
preparedPluginMetadataSnapshot?: PreparedModelRuntimePluginGeneration["pluginMetadataSnapshot"],
): Promise<{
agentFacts: PreparedModelRuntimeAgentFacts[];
pluginGeneration: PreparedModelRuntimePluginGeneration;
@@ -193,23 +194,24 @@ export async function prepareWorkspaceBuildGroup(
throw new Error("prepared model runtime workspace group is empty");
}
const env = input.env ?? process.env;
const pluginMetadataStartedAt = performance.now();
const pluginMetadataSnapshot =
preparedPluginMetadataSnapshot ??
reusablePluginGeneration?.pluginMetadataSnapshot ??
prepareOwnedPluginLoadContext(input, env, undefined);
const pluginMetadataMs = reusablePluginGeneration
? 0
: performance.now() - pluginMetadataStartedAt;
const runtimePluginStartedAt = performance.now();
const { inboundPluginRegistry, runtimePluginRegistry } = reusablePluginGeneration
? {
inboundPluginRegistry: reusablePluginGeneration.inboundPluginRegistry,
runtimePluginRegistry: reusablePluginGeneration.pluginRegistry,
}
: prepareWorkspacePluginRegistries(input, loadInboundPluginRegistry);
: prepareWorkspacePluginRegistries(input, pluginMetadataSnapshot, loadInboundPluginRegistry);
const runtimePluginMs = reusablePluginGeneration ? 0 : performance.now() - runtimePluginStartedAt;
const prepare = async (
preparedMetadataSnapshot?: PreparedModelRuntimePluginGeneration["pluginMetadataSnapshot"],
) => {
const pluginMetadataStartedAt = performance.now();
const pluginMetadataSnapshot =
preparedMetadataSnapshot ?? prepareOwnedPluginLoadContext(input, env, runtimePluginRegistry);
const pluginMetadataMs = reusablePluginGeneration
? 0
: performance.now() - pluginMetadataStartedAt;
prepareOwnedPluginLoadContext(input, env, runtimePluginRegistry, pluginMetadataSnapshot);
const prepare = async () => {
const matchesStaticModelId = createStaticModelIdMatcher({
manifestPlugins: pluginMetadataSnapshot.plugins,
});
@@ -421,7 +423,7 @@ export async function prepareWorkspaceBuildGroup(
return reusablePluginGeneration
? await withPreparedPluginGenerationScope(
{ input, pluginGeneration: reusablePluginGeneration },
prepare,
() => prepare(),
)
: await withPluginRuntimeRegistryScope(runtimePluginRegistry, prepare);
}
@@ -489,18 +491,14 @@ export async function prepareFullCatalogFacts(
inlineProviderModels: pluginGeneration.inlineProviderModels,
};
}
/** Reports whether a catalog came from the complete prepared-catalog build path. */
export function isPreparedModelCatalogFull(snapshot: ModelCatalogSnapshot): boolean {
return fullModelCatalogSnapshots.has(snapshot);
}
export const isPreparedModelCatalogFull = (snapshot: ModelCatalogSnapshot): boolean =>
fullModelCatalogSnapshots.has(snapshot);
/** Restores process-local provenance after a complete catalog crosses a worker boundary. */
export function markPreparedModelCatalogFull(snapshot: ModelCatalogSnapshot): ModelCatalogSnapshot {
fullModelCatalogSnapshots.add(snapshot);
return snapshot;
}
function captureModelsJsonContents(agentDir: string): string | null {
try {
return fs.readFileSync(path.join(agentDir, "models.json"), "utf8");
@@ -511,17 +509,13 @@ function captureModelsJsonContents(agentDir: string): string | null {
throw error;
}
}
export function fingerprintPreparedRuntimeFacts(value: unknown): string {
return sha256Base64Url(stableStringify(value));
}
export const fingerprintPreparedRuntimeFacts = (value: unknown): string =>
sha256Base64Url(stableStringify(value));
function hasSameOAuthProviderGeneration(
left: ReturnType<AuthStorage["getOAuthProviders"]>,
right: ReturnType<AuthStorage["getOAuthProviders"]>,
): boolean {
// OAuth descriptors carry executable hooks. Match those hooks by identity so equivalent
// AuthStorage instances share built-ins without merging distinct closure generations.
// Match executable hooks by identity so distinct AuthStorage closure generations never merge.
return (
left.length === right.length &&
left.every((provider, index) => {
@@ -547,8 +541,7 @@ function groupConfiguredRegistrySources(
for (const facts of agentFacts) {
const modelsJsonContents = captureModelsJsonContents(facts.input.agentDir);
const oauthProviders = facts.templateAuthStorage.getOAuthProviders();
// Generated catalogs are agent-owned. Capture only plugins needed by unresolved configured
// refs, then group exact bytes and OAuth behavior so publication never mixes generations.
// Capture only unresolved configured catalogs, then group exact bytes and OAuth behavior.
const pluginCatalogs = loadPersistedPluginModelCatalogsReadOnly(
facts.input.agentDir,
facts.configuredGeneratedCatalogPluginIds,
@@ -48,6 +48,7 @@ describe("prepared reply dispatch runtime", () => {
gatewayLifecycle: true,
catalogMode: "static",
allowGatewaySubagentBinding: true,
pluginMetadataSnapshot: mocks.pluginMetadataSnapshot as never,
});
const input = {
agentId: "default",
@@ -59,7 +60,7 @@ describe("prepared reply dispatch runtime", () => {
};
const firstSnapshot = getPreparedModelRuntimeSnapshot(input);
const firstRuntime = await loadPublishedGatewayReplyDispatchRuntime({ agentId: "default" });
expect(firstRuntime).toEqual({
expect(firstRuntime).toMatchObject({
agentId: "default",
agentDir: "/tmp/unused-agent",
workspaceDir: "/tmp/unused-workspace",
@@ -67,6 +68,10 @@ describe("prepared reply dispatch runtime", () => {
modelCatalog: firstSnapshot?.modelCatalog,
inboundPluginRegistry: firstRegistry,
});
expect(firstRuntime?.pluginGeneration?.pluginMetadataSnapshot).toBe(
mocks.pluginMetadataSnapshot,
);
expect(firstSnapshot?.metadataSnapshot).toBe(mocks.pluginMetadataSnapshot);
expect(Object.isFrozen(firstRuntime)).toBe(true);
const replacementCatalog = createDeferred<{ entries: [] }>();
@@ -74,6 +79,7 @@ describe("prepared reply dispatch runtime", () => {
const refresh = refreshPreparedModelRuntimeSnapshots(replacementConfig, {
catalogMode: "static",
allowGatewaySubagentBinding: true,
pluginMetadataSnapshot: mocks.pluginMetadataSnapshot as never,
});
await vi.waitFor(() =>
expect(mocks.loadAgentRuntimePluginRegistryHandle).toHaveBeenCalledTimes(4),
@@ -138,28 +144,6 @@ describe("prepared reply dispatch runtime", () => {
expect(mocks.loadAgentRuntimePluginRegistryHandle).toHaveBeenCalledTimes(publicationLoadCount);
});
it("projects shared plugin metadata into each configured runtime workspace", async () => {
mocks.configuredAgentIds = ["default"];
const workspaceDir = "/tmp/configured-metadata-workspace";
const config = retainLegacyDefaultAgentId({ agents: { entries: { default: {} } } }, "default");
await refreshPreparedModelRuntimeSnapshots(config, {
gatewayLifecycle: true,
catalogMode: "static",
defaultWorkspaceDir: workspaceDir,
});
const snapshot = getPreparedModelRuntimeSnapshot({
agentId: "default",
agentDir: "/tmp/unused-agent",
inheritedAuthDir: "/tmp/unused-agent",
config,
workspaceDir,
});
expect(snapshot?.metadataSnapshot.workspaceDir).toBe(workspaceDir);
expect(snapshot?.metadataSnapshot.index.workspaceDir).toBe(workspaceDir);
});
it("reuses configured and retained dynamic plugin generations during auth refresh", async () => {
mocks.configuredAgentIds = ["default"];
const workspaceDir = "/tmp/dynamic-auth-workspace";
@@ -1,9 +1,13 @@
import { hashRuntimeConfigValue } from "../config/runtime-snapshot.js";
import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.types.js";
import type { PluginRegistry } from "../plugins/registry-types.js";
import type { PreparedModelRuntimeInput } from "./prepared-model-runtime.types.js";
import { loadAgentRuntimePluginRegistryHandle } from "./runtime-plugins.js";
export type PreparedInboundRegistryLoader = (input: PreparedModelRuntimeInput) => PluginRegistry;
export type PreparedInboundRegistryLoader = (
input: PreparedModelRuntimeInput,
metadataSnapshot: PluginMetadataSnapshot,
) => PluginRegistry;
function inboundRegistryIdentity(input: PreparedModelRuntimeInput): string {
return JSON.stringify({
@@ -30,7 +34,7 @@ export function preparedModelRuntimeWorkspaceFactsKey(input: PreparedModelRuntim
/** Creates one lifecycle-batch loader that shares exact generic registry identities. */
export function createPreparedInboundRegistryLoader(): PreparedInboundRegistryLoader {
const registries = new Map<string, PluginRegistry>();
return (input) => {
return (input, metadataSnapshot) => {
const key = inboundRegistryIdentity(input);
const existing = registries.get(key);
if (existing) {
@@ -41,6 +45,7 @@ export function createPreparedInboundRegistryLoader(): PreparedInboundRegistryLo
env: input.env ?? process.env,
...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}),
...(input.allowGatewaySubagentBinding ? { allowGatewaySubagentBinding: true } : {}),
metadataSnapshot,
});
registries.set(key, registry);
return registry;
@@ -50,6 +55,7 @@ export function createPreparedInboundRegistryLoader(): PreparedInboundRegistryLo
/** Prepares distinct generic-inbound and model-selected registries for one workspace generation. */
export function prepareWorkspacePluginRegistries(
input: PreparedModelRuntimeInput,
metadataSnapshot: PluginMetadataSnapshot,
loadInboundRegistry?: PreparedInboundRegistryLoader,
): {
runtimePluginRegistry?: PluginRegistry;
@@ -60,7 +66,9 @@ export function prepareWorkspacePluginRegistries(
if (input.readOnly && !input.loadRuntimePlugins && !input.runtimePluginSelections) {
return {};
}
const inboundPluginRegistry = input.readOnly ? undefined : loadInboundRegistry?.(input);
const inboundPluginRegistry = input.readOnly
? undefined
: loadInboundRegistry?.(input, metadataSnapshot);
const runtimePluginRegistry =
input.runtimePluginSelections || !inboundPluginRegistry
? loadAgentRuntimePluginRegistryHandle({
@@ -68,6 +76,7 @@ export function prepareWorkspacePluginRegistries(
env: input.env ?? process.env,
...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}),
...(input.allowGatewaySubagentBinding ? { allowGatewaySubagentBinding: true } : {}),
metadataSnapshot,
selections: input.runtimePluginSelections,
})
: inboundPluginRegistry;
@@ -405,6 +405,7 @@ export async function publishPreparedModelRuntimeOwnerBatch(params: {
onBuildStats?: (stats: PreparedModelRuntimeBuildStats) => void;
registerEntriesAfterBuildStart?: boolean;
reusePluginGenerations?: boolean;
pluginMetadataSnapshot?: PreparedModelRuntimePluginGeneration["pluginMetadataSnapshot"];
}): Promise<void> {
const candidates = params.entries.map(({ owner, input }) => {
owner.input = input;
@@ -486,6 +487,7 @@ export async function publishPreparedModelRuntimeOwnerBatch(params: {
),
)
: undefined,
params.pluginMetadataSnapshot,
);
for (const candidate of currentGroup) {
if (params.registerEntriesAfterBuildStart === true) {
@@ -575,6 +577,7 @@ export async function publishModelRuntimeSnapshot(
provenance: PreparedModelRuntimeOwner["provenance"] = "explicit",
catalogMode: PreparedModelRuntimeCatalogMode = existing?.catalogMode ?? "live",
reusablePluginGeneration?: PreparedModelRuntimePluginGeneration,
pluginMetadataSnapshot?: PreparedModelRuntimePluginGeneration["pluginMetadataSnapshot"],
): Promise<PreparedModelRuntimeSnapshot> {
const key = ownerKey(input);
const owner = existing ?? createPreparedModelRuntimeOwner(input, provenance, catalogMode);
@@ -595,6 +598,7 @@ export async function publishModelRuntimeSnapshot(
() => owner.generation === generation && owners.get(key) === owner,
provenance === "configured",
reusablePluginGeneration,
pluginMetadataSnapshot,
);
owner.buildCompletion = build.completion;
void build.completion.then(() => {
@@ -0,0 +1,98 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
createPluginMetadataSnapshot,
makeRegistry,
} from "../config/plugin-auto-enable.test-helpers.js";
import * as currentPluginMetadata from "../plugins/current-plugin-metadata-snapshot.js";
import { clearPluginMetadataLifecycleCaches } from "../plugins/plugin-metadata-lifecycle.js";
import * as pluginMetadata from "../plugins/plugin-metadata-snapshot.js";
import { createEmptyPluginRegistry } from "../plugins/registry-empty.js";
import {
getPreparedPluginRuntimeLoadContext,
prepareOwnedPluginLoadContext,
} from "./prepared-model-runtime.plugin-context.js";
import { withPreparedPluginGenerationScope } from "./prepared-model-runtime.plugin-generation.js";
describe("prepared model runtime plugin metadata ownership", () => {
afterEach(() => {
clearPluginMetadataLifecycleCaches();
});
it("uses one explicit Gateway metadata generation across agent workspaces", () => {
const config = { plugins: { allow: ["synthetic"] } };
const gatewayWorkspace = "/tmp/gateway-plugin-workspace";
const gatewaySnapshot = createPluginMetadataSnapshot({
config,
manifestRegistry: makeRegistry([{ id: "synthetic", channels: [] }]),
workspaceDir: gatewayWorkspace,
});
const inputs = ["first", "second"].map((name) => ({
agentDir: `/tmp/${name}-agent`,
config,
workspaceDir: `/tmp/${name}-workspace`,
workspacePluginRootPresent: false,
}));
const pluginGeneration = {
configuredCatalogEntries: [],
inlineProviderModels: [],
pluginMetadataSnapshot: gatewaySnapshot,
};
const resolveMetadata = vi.spyOn(pluginMetadata, "loadPluginMetadataSnapshot");
const getCurrentMetadata = vi.spyOn(currentPluginMetadata, "getCurrentPluginMetadataSnapshot");
try {
for (const input of inputs) {
const registry = createEmptyPluginRegistry();
expect(prepareOwnedPluginLoadContext(input, process.env, registry, gatewaySnapshot)).toBe(
gatewaySnapshot,
);
expect(getPreparedPluginRuntimeLoadContext(registry)?.metadataSnapshot).toBe(
gatewaySnapshot,
);
expect(
withPreparedPluginGenerationScope({ input, pluginGeneration }, (snapshot) => snapshot),
).toBe(gatewaySnapshot);
}
expect(getCurrentMetadata).not.toHaveBeenCalled();
expect(resolveMetadata).not.toHaveBeenCalled();
} finally {
getCurrentMetadata.mockRestore();
resolveMetadata.mockRestore();
}
});
it("keeps direct no-current preparation on the requested workspace", () => {
const config = { plugins: { allow: ["synthetic"] } };
const workspaceDir = "/tmp/direct-plugin-workspace";
const directSnapshot = createPluginMetadataSnapshot({
config,
manifestRegistry: makeRegistry([{ id: "synthetic", channels: [] }]),
workspaceDir,
});
const resolveMetadata = vi
.spyOn(pluginMetadata, "loadPluginMetadataSnapshot")
.mockReturnValue(directSnapshot);
try {
expect(
prepareOwnedPluginLoadContext(
{
agentDir: "/tmp/direct-agent",
config,
workspaceDir,
workspacePluginRootPresent: false,
},
process.env,
undefined,
),
).toBe(directSnapshot);
expect(resolveMetadata).toHaveBeenCalledWith({
config,
env: process.env,
workspaceDir,
});
} finally {
resolveMetadata.mockRestore();
}
});
});
@@ -1,9 +1,6 @@
import type { PluginDiscoveryResult } from "../plugins/discovery.js";
import { extractPluginInstallRecordsFromInstalledPluginIndex } from "../plugins/installed-plugin-index-install-records.js";
import {
projectPluginMetadataSnapshotWorkspace,
resolvePluginMetadataSnapshot,
} from "../plugins/plugin-metadata-snapshot.js";
import { loadPluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.js";
import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.types.js";
import type { PluginRegistry } from "../plugins/registry-types.js";
import {
@@ -32,9 +29,10 @@ function preparePluginLoadContext(
registry: PluginRegistry | undefined,
metadataSnapshot: PluginMetadataSnapshot,
): PluginRuntimeLoadContext & { metadataSnapshot: PluginMetadataSnapshot } {
const { config, workspaceDir } = input;
// The prepared owner already resolved metadata for this exact config/env/workspace tuple.
// Missing discovery facts stay empty here instead of reopening cold channel discovery.
const { config } = input;
const workspaceDir = metadataSnapshot.workspaceDir ?? input.workspaceDir;
// The prepared owner already selected the exact metadata generation for this runtime.
// Missing discovery facts stay empty here instead of reopening cold plugin discovery.
const preparedMetadataSnapshot = metadataSnapshot.discovery
? metadataSnapshot
: { ...metadataSnapshot, discovery: emptyPluginDiscovery };
@@ -61,25 +59,23 @@ export function prepareOwnedPluginLoadContext(
input: PreparedModelRuntimeInput,
env: NodeJS.ProcessEnv,
registry: PluginRegistry | undefined,
preparedMetadataSnapshot?: PluginMetadataSnapshot,
): PluginMetadataSnapshot {
const resolvedMetadataSnapshot = resolvePluginMetadataSnapshot({
const metadataSnapshot = preparedMetadataSnapshot ?? resolveColdMetadataSnapshot(input, env);
preparePluginLoadContext(input, env, registry, metadataSnapshot);
return metadataSnapshot;
}
function resolveColdMetadataSnapshot(
input: PreparedModelRuntimeInput,
env: NodeJS.ProcessEnv,
): PluginMetadataSnapshot {
const resolvedMetadataSnapshot = loadPluginMetadataSnapshot({
config: input.config,
env,
...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}),
...(input.workspacePluginRootPresent === undefined
? {}
: { workspacePluginRootPresent: input.workspacePluginRootPresent }),
});
const metadataSnapshot = input.workspaceDir
? projectPluginMetadataSnapshotWorkspace({
snapshot: resolvedMetadataSnapshot,
config: input.config,
env,
workspaceDir: input.workspaceDir,
})
: resolvedMetadataSnapshot;
preparePluginLoadContext(input, env, registry, metadataSnapshot);
return metadataSnapshot;
return resolvedMetadataSnapshot;
}
/** Reads plugin facts carried by a lifecycle-owned prepared runtime snapshot. */
@@ -1,4 +1,3 @@
import { projectPluginMetadataSnapshotWorkspace } from "../plugins/plugin-metadata-snapshot.js";
import { withPluginRuntimeGenerationScope } from "../plugins/runtime/generation-scope.js";
import { augmentPreparedModelCatalogWithAgentHarness } from "./harness/model-catalog.js";
import { buildPreparedModelCatalogSnapshot } from "./model-catalog.js";
@@ -48,25 +47,6 @@ export function createPreparedPluginGeneration(params: {
});
}
export function projectPreparedPluginGeneration(params: {
input: PreparedModelRuntimeInput;
pluginGeneration: PreparedModelRuntimePluginGeneration;
}): PreparedModelRuntimePluginGeneration {
const { input, pluginGeneration } = params;
if (!input.workspaceDir) {
return pluginGeneration;
}
const pluginMetadataSnapshot = projectPluginMetadataSnapshotWorkspace({
snapshot: pluginGeneration.pluginMetadataSnapshot,
config: input.config,
env: input.env ?? process.env,
workspaceDir: input.workspaceDir,
});
return pluginMetadataSnapshot === pluginGeneration.pluginMetadataSnapshot
? pluginGeneration
: Object.freeze({ ...pluginGeneration, pluginMetadataSnapshot });
}
export async function buildPreparedPluginModelCatalog(params: {
agentFacts: {
credentials: Parameters<typeof buildPreparedModelCatalogSnapshot>[0]["authCredentials"];
@@ -110,15 +90,13 @@ export function withPreparedPluginGenerationScope<T>(
},
run: (metadataSnapshot: PreparedModelRuntimePluginGeneration["pluginMetadataSnapshot"]) => T,
): T {
const { input } = params;
const pluginGeneration = projectPreparedPluginGeneration(params);
const { input, pluginGeneration } = params;
const metadataSnapshot = pluginGeneration.pluginMetadataSnapshot;
return withPluginRuntimeGenerationScope(
{
config: input.config,
metadataSnapshot,
pluginRegistry: pluginGeneration.pluginRegistry,
...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}),
},
() => run(metadataSnapshot),
);
@@ -395,23 +395,17 @@ describe("prepared model runtime Gateway catalog mode", () => {
expect(mocks.discoverModels).toHaveBeenLastCalledWith(
mocks.authStorage,
expect.objectContaining({
config,
includePluginCatalogs: true,
modelsJsonContents: null,
pluginCatalogs: [],
pluginMetadataSnapshot: expect.objectContaining({
...mocks.metadataSnapshot,
index: expect.objectContaining({
...mocks.metadataSnapshot.index,
workspaceDir: "/tmp/prepared-static-workspace",
}),
workspaceDir: "/tmp/prepared-static-workspace",
}),
pluginMetadataSnapshot: mocks.metadataSnapshot,
workspaceDir: "/tmp/prepared-static-workspace",
}),
);
expect(mocks.buildPreparedModelCatalogSnapshot).not.toHaveBeenCalled();
expect(mocks.loadStaticCatalog).not.toHaveBeenCalled();
expect(mocks.resolvePluginMetadataSnapshot).toHaveBeenCalledTimes(2);
expect(mocks.resolvePluginMetadataSnapshot).toHaveBeenCalledOnce();
expect(configuredRuntimeModelCount).toBe(1);
expect(generatedCatalogReadCount).toBe(0);
const snapshot = getPreparedModelRuntimeSnapshot({
@@ -85,13 +85,6 @@ const preparedModelRuntimeMocks = vi.hoisted(() => ({
vi.mock("../plugins/plugin-metadata-snapshot.js", () => ({
isPluginMetadataSnapshotCompatible: () => true,
loadPluginMetadataSnapshot: () => preparedModelRuntimeMocks.pluginMetadataSnapshot,
projectPluginMetadataSnapshotWorkspace: ({
snapshot,
workspaceDir,
}: {
snapshot: typeof preparedModelRuntimeMocks.pluginMetadataSnapshot & { workspaceDir?: string };
workspaceDir: string;
}) => ({ ...snapshot, index: { ...snapshot.index, workspaceDir }, workspaceDir }),
resolvePluginMetadataSnapshot: () => preparedModelRuntimeMocks.pluginMetadataSnapshot,
}));
+10 -6
View File
@@ -111,12 +111,15 @@ describe("prepared model runtime snapshots", () => {
mocks.loadAgentRuntimePluginRegistryHandle.mockReturnValue(pluginRegistry);
expect(
prepareWorkspacePluginRegistries({
config: {},
agentDir: "/tmp/native-provider-probe",
readOnly: true,
loadRuntimePlugins: true,
}).runtimePluginRegistry,
prepareWorkspacePluginRegistries(
{
config: {},
agentDir: "/tmp/native-provider-probe",
readOnly: true,
loadRuntimePlugins: true,
},
mocks.pluginMetadataSnapshot as never,
).runtimePluginRegistry,
).toBe(pluginRegistry);
expect(mocks.loadAgentRuntimePluginRegistryHandle).toHaveBeenCalledWith(
expect.objectContaining({ selections: undefined }),
@@ -180,6 +183,7 @@ describe("prepared model runtime snapshots", () => {
expect(mocks.loadAgentRuntimePluginRegistryHandle).toHaveBeenCalledWith({
config: {},
env: process.env,
metadataSnapshot: mocks.pluginMetadataSnapshot,
workspaceDir: "/tmp/prepared-model-runtime-plugin-workspace",
selections: undefined,
});
+2
View File
@@ -290,6 +290,7 @@ export async function acquireAgentRunPreparedModelRuntime(
options: {
retainIdleRunOwner?: boolean;
catalogMode?: PreparedModelRuntimeCatalogMode;
pluginGeneration?: PreparedModelRuntimeOwner["pluginGeneration"];
} = {},
): Promise<PreparedModelRuntimeLease> {
return await acquirePreparedModelRuntimeLeaseFromOwners(
@@ -490,6 +491,7 @@ async function refreshPreparedModelRuntimeSnapshotsNow(
// candidates, while a newer config epoch stops every remaining build in this publication.
isBuildCurrent: () => publicationEpoch === refreshRequestEpoch,
onBuildStats: options.onBuildStats,
pluginMetadataSnapshot: options.pluginMetadataSnapshot,
registerEntriesAfterBuildStart: true,
});
}
@@ -72,6 +72,7 @@ export type PreparedReplyDispatchRuntime = Readonly<{
config: OpenClawConfig;
modelCatalog: ModelCatalogSnapshot;
inboundPluginRegistry: PluginRegistry;
pluginGeneration?: PreparedModelRuntimePluginGeneration;
}>;
export type PreparedModelRuntimeStores = {
@@ -114,6 +115,7 @@ export type PreparedModelRuntimeRefreshOptions = {
catalogMode?: PreparedModelRuntimeCatalogMode;
onBuildStats?: (stats: PreparedModelRuntimeBuildStats) => void;
allowGatewaySubagentBinding?: boolean;
pluginMetadataSnapshot?: PluginMetadataSnapshot;
};
export type PreparedModelRuntimeBuildStats = Readonly<{
@@ -18,8 +18,9 @@ function createReplyDispatchRuntime(
): PreparedReplyDispatchRuntime {
const snapshot = runtimeOwner.snapshot!;
const owner = resolvePublishedModelCatalogOwner(snapshot);
const inboundPluginRegistry = runtimeOwner.pluginGeneration?.inboundPluginRegistry;
if (!inboundPluginRegistry) {
const pluginGeneration = runtimeOwner.pluginGeneration;
const inboundPluginRegistry = pluginGeneration?.inboundPluginRegistry;
if (!pluginGeneration || !inboundPluginRegistry) {
throw new PreparedModelRuntimeOwnerNotPublishedError(
`prepared inbound plugin registry was not published for ${snapshot.agentDir}`,
);
@@ -31,6 +32,7 @@ function createReplyDispatchRuntime(
config: owner.config,
modelCatalog: owner.modelCatalog,
inboundPluginRegistry,
pluginGeneration,
});
}
+1 -27
View File
@@ -2,7 +2,7 @@
// auth, transport, tools, prompt, delivery, transcript, and observability.
import { createParameterFreeTool } from "openclaw/plugin-sdk/agent-runtime-test-contracts";
import { afterEach, describe, expect, it, vi } from "vitest";
import { resetConfigRuntimeState, setRuntimeConfigSnapshot } from "../../config/config.js";
import { resetConfigRuntimeState } from "../../config/config.js";
import {
prepareProviderExtraParams,
resolveProviderFollowupFallbackRoute,
@@ -11,7 +11,6 @@ import {
} from "../../plugins/provider-hook-runtime.js";
import { buildAgentRuntimeDeliveryPlan, buildAgentRuntimePlan } from "./build.js";
const isPluginMetadataSnapshotCompatible = vi.hoisted(() => vi.fn(() => true));
const resolveProviderIdForAuth = vi.hoisted(() => vi.fn((provider: string) => provider));
vi.mock("../provider-auth-aliases.js", async (importOriginal) => ({
@@ -37,11 +36,6 @@ vi.mock("../../plugins/provider-hook-runtime.js", () => ({
wrapProviderStreamFn: vi.fn(() => undefined),
}));
vi.mock("../../plugins/plugin-metadata-snapshot.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../../plugins/plugin-metadata-snapshot.js")>()),
isPluginMetadataSnapshotCompatible,
}));
const gpt54Model = {
id: "gpt-5.4",
name: "GPT-5.4",
@@ -419,24 +413,4 @@ describe("AgentRuntimePlan", () => {
expect.objectContaining({ metadataSnapshot }),
);
});
it("validates threaded tool metadata against the source config projection", () => {
const runtimeConfig = { plugins: { entries: { runtimeOnly: { enabled: true } } } };
const sourceConfig = { plugins: { entries: {} } };
const metadataSnapshot = { plugins: [] };
setRuntimeConfigSnapshot(runtimeConfig, sourceConfig);
isPluginMetadataSnapshotCompatible.mockClear();
buildAgentRuntimePlan({
provider: "openai",
modelId: "gpt-5.4",
config: runtimeConfig,
metadataSnapshot,
});
expect(isPluginMetadataSnapshotCompatible).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ config: sourceConfig }),
);
});
});
+6 -33
View File
@@ -6,13 +6,8 @@ import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce";
*/
import type { TSchema } from "typebox";
import { isSilentReplyPayloadText, SILENT_REPLY_TOKEN } from "../../auto-reply/tokens.js";
import { projectConfigOntoRuntimeSourceSnapshot } from "../../config/config.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { hasReplyPayloadContent } from "../../interactive/payload.js";
import {
isPluginMetadataSnapshotCompatible,
resolvePluginMetadataSnapshot,
} from "../../plugins/plugin-metadata-snapshot.js";
import type { PluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.types.js";
import {
resolveProviderRuntimePluginHandle,
@@ -60,21 +55,10 @@ type RuntimePlanMetadataParams = BuildAgentRuntimeDeliveryPlanParams & {
metadataSnapshot?: BuildAgentRuntimePlanParams["metadataSnapshot"];
};
function resolveCompatibleMetadataSnapshot(
function resolvePreparedMetadataSnapshot(
params: RuntimePlanMetadataParams,
config: OpenClawConfig | undefined = asOpenClawConfig(params.config),
): PluginMetadataSnapshot | undefined {
const metadataSnapshot = params.metadataSnapshot as PluginMetadataSnapshot | undefined;
return metadataSnapshot &&
metadataSnapshot.pluginIds === undefined &&
isPluginMetadataSnapshotCompatible({
snapshot: metadataSnapshot,
config,
env: process.env,
workspaceDir: params.workspaceDir,
})
? metadataSnapshot
: undefined;
return params.metadataSnapshot as PluginMetadataSnapshot | undefined;
}
function resolvePreparedProviderRuntimeHandle(
@@ -91,7 +75,7 @@ function resolvePreparedProviderRuntimeHandle(
prepared: true;
};
}
const compatibleMetadataSnapshot = resolveCompatibleMetadataSnapshot(params);
const metadataSnapshot = resolvePreparedMetadataSnapshot(params);
return {
...resolveProviderRuntimePluginHandle({
provider: params.provider,
@@ -99,7 +83,7 @@ function resolvePreparedProviderRuntimeHandle(
config: asOpenClawConfig(params.config),
workspaceDir: params.workspaceDir,
env: process.env,
...(compatibleMetadataSnapshot ? { pluginMetadataSnapshot: compatibleMetadataSnapshot } : {}),
...(metadataSnapshot ? { pluginMetadataSnapshot: metadataSnapshot } : {}),
}),
modelId: params.modelId,
prepared: true,
@@ -155,21 +139,10 @@ export function buildAgentRuntimePlan(params: BuildAgentRuntimePlanParams): Agen
const model = asProviderRuntimeModel(params.model);
const modelApi = params.modelApi ?? params.model?.api ?? undefined;
const transport = params.resolvedTransport;
const toolPlanningConfig = config ? projectConfigOntoRuntimeSourceSnapshot(config) : undefined;
const toolPlanningMetadataSnapshot = resolveCompatibleMetadataSnapshot(
params,
toolPlanningConfig,
);
const toolPlanningMetadataSnapshot = resolvePreparedMetadataSnapshot(params);
const preparedPlanning = toolPlanningMetadataSnapshot
? { metadataSnapshot: toolPlanningMetadataSnapshot }
: {
loadMetadataSnapshot: () =>
resolvePluginMetadataSnapshot({
config: toolPlanningConfig,
...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}),
env: process.env,
}),
};
: undefined;
const providerRuntimeHandleForPlugins = resolvePreparedProviderRuntimeHandle(params);
const auth =
params.preparedAuthPlan ??
-1
View File
@@ -434,7 +434,6 @@ type AgentRuntimePreparedMetadataSnapshot = object;
/** Prepared metadata loader used by tool planning without eager manifest reads. */
type PreparedOpenClawToolPlanning = {
metadataSnapshot?: AgentRuntimePreparedMetadataSnapshot;
loadMetadataSnapshot?: () => AgentRuntimePreparedMetadataSnapshot;
};
/** Tool normalization and diagnostics hooks for one runtime attempt. */
+82 -17
View File
@@ -2,7 +2,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const hoisted = vi.hoisted(() => ({
getCurrentPluginMetadataSnapshot: vi.fn(),
loadPluginMetadataSnapshot: vi.fn(),
getActivePluginRegistry: vi.fn(),
loadPluginRegistryHandle: vi.fn(),
adoptRuntimeContextEngineRegistrations: vi.fn((target: unknown) => target),
@@ -17,8 +17,8 @@ vi.mock("../plugins/runtime.js", () => ({
getActivePluginRegistry: hoisted.getActivePluginRegistry,
}));
vi.mock("../plugins/current-plugin-metadata-snapshot.js", () => ({
getCurrentPluginMetadataSnapshot: hoisted.getCurrentPluginMetadataSnapshot,
vi.mock("../plugins/plugin-metadata-snapshot.js", () => ({
loadPluginMetadataSnapshot: hoisted.loadPluginMetadataSnapshot,
}));
vi.mock("../plugins/loader.js", () => ({
@@ -38,9 +38,27 @@ import {
withAgentPluginRegistry,
} from "./runtime-plugins.js";
function createMetadataSnapshot(
workspaceDir = "/tmp/gateway-workspace",
pluginIds: string[] | undefined = ["telegram", "memory-core"],
) {
return {
workspaceDir,
index: { installRecords: {}, plugins: [] },
manifestRegistry: { diagnostics: [], plugins: [] },
discovery: { candidates: [], diagnostics: [] },
pluginIds,
};
}
describe("agent runtime plugin registries", () => {
beforeEach(() => {
hoisted.getCurrentPluginMetadataSnapshot.mockReset().mockReturnValue(undefined);
hoisted.loadPluginMetadataSnapshot
.mockReset()
.mockImplementation((params: { workspaceDir?: string }) => ({
...createMetadataSnapshot(params.workspaceDir),
pluginIds: undefined,
}));
hoisted.getActivePluginRegistry.mockReset().mockReturnValue(undefined);
hoisted.loadPluginRegistryHandle.mockReset().mockReturnValue({ handle: true });
hoisted.adoptRuntimeContextEngineRegistrations
@@ -67,7 +85,7 @@ describe("agent runtime plugin registries", () => {
);
});
it("returns a non-activating handle for a prepared runtime", () => {
it("keeps direct no-current loads on the requested workspace", () => {
const config = {} as never;
const env = { OPENCLAW_STATE_DIR: "/tmp/openclaw-state" };
const selections = [{ provider: "openai", modelId: "gpt-5.5", runtime: "codex" }];
@@ -81,7 +99,8 @@ describe("agent runtime plugin registries", () => {
selections,
}),
).toEqual({ handle: true });
expect(hoisted.getCurrentPluginMetadataSnapshot).toHaveBeenCalledWith({
const metadataSnapshot = hoisted.loadPluginMetadataSnapshot.mock.results[0]?.value;
expect(hoisted.loadPluginMetadataSnapshot).toHaveBeenCalledWith({
config,
env,
workspaceDir: "/tmp/workspace",
@@ -90,12 +109,16 @@ describe("agent runtime plugin registries", () => {
config,
workspaceDir: "/tmp/workspace",
selections,
metadataSnapshot,
});
expect(hoisted.loadPluginRegistryHandle).toHaveBeenCalledWith({
activate: false,
config,
activationSourceConfig: config,
env,
discovery: metadataSnapshot.discovery,
installRecords: {},
manifestRegistry: metadataSnapshot.manifestRegistry,
workspaceDir: "/tmp/workspace",
runtimeOptions: { allowGatewaySubagentBinding: true },
});
@@ -120,17 +143,20 @@ describe("agent runtime plugin registries", () => {
it("preserves the gateway startup scope and ordering", () => {
const config = {} as never;
hoisted.getCurrentPluginMetadataSnapshot.mockReturnValue({
startup: { pluginIds: ["telegram", "memory-core"] },
});
const metadataSnapshot = createMetadataSnapshot();
loadAgentRuntimePluginRegistryHandle({ config, workspaceDir: "/tmp/workspace" });
loadAgentRuntimePluginRegistryHandle({
config,
workspaceDir: "/tmp/workspace",
metadataSnapshot: metadataSnapshot as never,
});
expect(hoisted.resolveAgentRuntimePluginLoadPlan).toHaveBeenCalledWith({
config,
workspaceDir: "/tmp/workspace",
workspaceDir: "/tmp/gateway-workspace",
basePluginIds: ["telegram", "memory-core"],
selections: [],
metadataSnapshot,
});
expect(hoisted.loadPluginRegistryHandle).toHaveBeenCalledWith(
expect.objectContaining({
@@ -141,9 +167,7 @@ describe("agent runtime plugin registries", () => {
it("inherits the current request registry before process-wide startup metadata", () => {
const config = {} as never;
hoisted.getCurrentPluginMetadataSnapshot.mockReturnValue({
startup: { pluginIds: ["telegram", "memory-core"] },
});
const metadataSnapshot = createMetadataSnapshot();
const requestRegistry = {
plugins: [
{ id: "memory-core", status: "loaded" },
@@ -152,14 +176,19 @@ describe("agent runtime plugin registries", () => {
} as never;
withPluginRuntimeRegistryScope(requestRegistry, () =>
loadAgentRuntimePluginRegistryHandle({ config, workspaceDir: "/tmp/workspace" }),
loadAgentRuntimePluginRegistryHandle({
config,
workspaceDir: "/tmp/workspace",
metadataSnapshot: metadataSnapshot as never,
}),
);
expect(hoisted.resolveAgentRuntimePluginLoadPlan).toHaveBeenCalledWith({
config,
workspaceDir: "/tmp/workspace",
workspaceDir: "/tmp/gateway-workspace",
basePluginIds: ["memory-core"],
selections: [],
metadataSnapshot,
});
});
@@ -172,12 +201,47 @@ describe("agent runtime plugin registries", () => {
workspaceDir: "/tmp/workspace",
});
expect(hoisted.getCurrentPluginMetadataSnapshot).not.toHaveBeenCalled();
const metadataSnapshot = hoisted.loadPluginMetadataSnapshot.mock.results[0]?.value;
expect(hoisted.resolveAgentRuntimePluginLoadPlan).toHaveBeenCalledWith({
config,
workspaceDir: "/tmp/workspace",
basePluginIds: [],
selections: [],
metadataSnapshot,
});
});
it("loads selected runtimes from the Gateway metadata workspace", () => {
const config = {} as never;
const env = { OPENCLAW_STATE_DIR: "/tmp/openclaw-state" };
const snapshot = createMetadataSnapshot();
loadAgentRuntimePluginRegistryHandle({
config,
env,
workspaceDir: "/tmp/agent-workspace",
metadataSnapshot: snapshot as never,
});
expect(hoisted.resolveAgentRuntimePluginLoadPlan).toHaveBeenCalledWith({
config,
workspaceDir: snapshot.workspaceDir,
basePluginIds: ["telegram", "memory-core"],
selections: [],
metadataSnapshot: snapshot,
});
expect(hoisted.loadPluginRegistryHandle).toHaveBeenCalledWith({
activate: false,
activationSourceConfig: config,
channelPluginLoadIntent: "full",
config,
discovery: snapshot.discovery,
env,
installRecords: {},
manifestRegistry: snapshot.manifestRegistry,
onlyPluginIds: ["codex", "memory-core"],
runtimeOptions: undefined,
workspaceDir: snapshot.workspaceDir,
});
});
@@ -200,6 +264,7 @@ describe("agent runtime plugin registries", () => {
workspaceDir: "/tmp/workspace",
basePluginIds: [],
selections: [],
metadataSnapshot: expect.any(Object),
});
});
+29 -36
View File
@@ -2,8 +2,10 @@ import type { OpenClawConfig } from "../config/types.openclaw.js";
import { adoptRuntimeContextEngineRegistrations } from "../context-engine/registry.js";
import { listRuntimePluginIdsFromRegistry } from "../plugins/active-runtime-registry.js";
import { normalizePluginsConfig } from "../plugins/config-state.js";
import { getCurrentPluginMetadataSnapshot } from "../plugins/current-plugin-metadata-snapshot.js";
import { extractPluginInstallRecordsFromInstalledPluginIndex } from "../plugins/installed-plugin-index-install-records.js";
import { loadPluginRegistryHandle } from "../plugins/loader.js";
import { loadPluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.js";
import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.types.js";
import type { PluginRegistry } from "../plugins/registry-types.js";
import { getActivePluginRegistry } from "../plugins/runtime.js";
import {
@@ -17,31 +19,6 @@ import {
type AgentHarnessPluginSelection,
} from "./harness/runtime-plugin-load-plan.js";
type StartupScopedPluginSnapshot = NonNullable<
ReturnType<typeof getCurrentPluginMetadataSnapshot>
> & {
startup?: {
pluginIds?: readonly unknown[];
};
};
function resolveStartupPluginIdsFromCurrentSnapshot(params: {
config?: OpenClawConfig;
env?: NodeJS.ProcessEnv;
workspaceDir?: string;
}): string[] | undefined {
const snapshot = getCurrentPluginMetadataSnapshot({
config: params.config,
env: params.env,
workspaceDir: params.workspaceDir,
}) as StartupScopedPluginSnapshot | undefined;
const pluginIds = snapshot?.startup?.pluginIds;
if (!Array.isArray(pluginIds)) {
return undefined;
}
return pluginIds.filter((pluginId): pluginId is string => typeof pluginId === "string");
}
type AgentRuntimePluginRegistryParams = {
config?: OpenClawConfig;
env?: NodeJS.ProcessEnv;
@@ -50,10 +27,12 @@ type AgentRuntimePluginRegistryParams = {
/** Explicit base scope for hosts without a Gateway startup registry. */
basePluginIds?: readonly string[];
selections?: readonly AgentHarnessPluginSelection[];
/** Lifecycle-selected metadata. Omission selects one standalone cold generation. */
metadataSnapshot?: PluginMetadataSnapshot;
};
function resolveAgentRuntimePluginRegistryLoad(params: AgentRuntimePluginRegistryParams) {
const workspaceDir =
const requestedWorkspaceDir =
typeof params.workspaceDir === "string" && params.workspaceDir.trim()
? resolveUserPath(params.workspaceDir)
: undefined;
@@ -63,7 +42,7 @@ function resolveAgentRuntimePluginRegistryLoad(params: AgentRuntimePluginRegistr
config: params.config,
activationSourceConfig: params.config,
...(params.env ? { env: params.env } : {}),
workspaceDir,
workspaceDir: requestedWorkspaceDir,
onlyPluginIds: [],
runtimeOptions: params.allowGatewaySubagentBinding
? { allowGatewaySubagentBinding: true }
@@ -71,18 +50,30 @@ function resolveAgentRuntimePluginRegistryLoad(params: AgentRuntimePluginRegistr
},
};
}
const metadataSnapshot =
params.metadataSnapshot ??
loadPluginMetadataSnapshot({
config: params.config ?? {},
env: params.env ?? process.env,
...(requestedWorkspaceDir ? { workspaceDir: requestedWorkspaceDir } : {}),
});
const workspaceDir = metadataSnapshot.workspaceDir ?? requestedWorkspaceDir;
const metadataLoadOptions = {
...(metadataSnapshot.discovery ? { discovery: metadataSnapshot.discovery } : {}),
installRecords: extractPluginInstallRecordsFromInstalledPluginIndex(metadataSnapshot.index),
manifestRegistry: metadataSnapshot.manifestRegistry,
...(workspaceDir ? { workspaceDir } : {}),
};
const requestPluginRegistry = getPluginRuntimeGatewayRequestScope()?.pluginRegistry;
const startupPluginIds =
params.basePluginIds !== undefined
? [...params.basePluginIds]
: requestPluginRegistry
? listRuntimePluginIdsFromRegistry(requestPluginRegistry)
: resolveStartupPluginIdsFromCurrentSnapshot({
config: params.config,
env: params.env,
workspaceDir,
});
const plan = resolveAgentRuntimePluginLoadPlan({
: metadataSnapshot.pluginIds
? [...metadataSnapshot.pluginIds]
: undefined;
const planParams = {
config: params.config,
workspaceDir: workspaceDir ?? process.cwd(),
...(startupPluginIds === undefined ? {} : { basePluginIds: startupPluginIds }),
@@ -94,13 +85,15 @@ function resolveAgentRuntimePluginRegistryLoad(params: AgentRuntimePluginRegistr
})),
...(params.selections ?? []),
],
});
metadataSnapshot,
};
const plan = resolveAgentRuntimePluginLoadPlan(planParams);
return {
loadOptions: {
config: plan.config,
...(plan.config ? { activationSourceConfig: plan.config } : {}),
...(params.env ? { env: params.env } : {}),
workspaceDir,
...metadataLoadOptions,
...(startupPluginIds === undefined || plan.pluginIds === undefined
? {}
: { onlyPluginIds: plan.pluginIds }),
@@ -5,6 +5,7 @@ import {
classifyAgentRunTerminalOutcome,
type AgentRunTerminalOutcome,
} from "../../agents/agent-run-terminal-outcome.js";
import type { PreparedAgentCommandRuntimeContext } from "../../agents/command/prepare.js";
import {
createCronCreatorAuthorityCapability,
runWithCronCreatorAuthorityCapability,
@@ -127,6 +128,7 @@ export function dispatchAgentRunFromGateway(params: {
taskTrackingMode: Exclude<GatewayAgentTaskTrackingMode, "plugin_subagent">;
canonicalSkillWorkspaceDir?: string;
restoreAdmittedRecovery?: () => Promise<MainSessionRecoveryPendingTarget | undefined>;
commandRuntimeContext?: PreparedAgentCommandRuntimeContext;
onSettled?: (outcome: {
terminalOutcome: AgentRunTerminalOutcome;
onRecovered?: () => void;
@@ -197,6 +199,7 @@ export function dispatchAgentRunFromGateway(params: {
{
restoreAdmittedRecovery: params.restoreAdmittedRecovery,
},
params.commandRuntimeContext,
),
);
const agentRun = cronCreatorAuthorityCapability
@@ -10,6 +10,7 @@ import {
type MainSessionRecoveryPendingTarget,
type MainSessionRecoveryOwnerLease,
} from "../../agents/main-session-recovery/main-session-recovery-store.js";
import { loadPublishedGatewayReplyDispatchRuntime } from "../../agents/prepared-model-runtime.js";
import { resolveScheduledToolPolicyContext } from "../../agents/scheduled-tool-policy.js";
import { resolveIngressWorkspaceOverrideForSessionRun } from "../../agents/spawned-context.js";
import { isExecutionIdentityCollectionEnabled } from "../../audit/audit-config.js";
@@ -196,6 +197,14 @@ export function startAgentRunExecution(params: {
const ingressAgentId = params.resolvedSessionKey
? params.activeSessionAgentId
: params.agentId;
const replyDispatchRuntime = await loadPublishedGatewayReplyDispatchRuntime({
agentId: params.activeSessionAgentId,
});
if (!replyDispatchRuntime?.pluginGeneration) {
throw new Error(
`prepared reply dispatch runtime was not published for ${params.activeSessionAgentId}`,
);
}
// Plugin-owned additive grants stay internal to the authenticated in-process run.
// Public agent params cannot supply them, and normal tool policy still filters them.
const runtimePluginToolGrant =
@@ -252,6 +261,10 @@ export function startAgentRunExecution(params: {
dispatchAgentRunFromGateway(
withAgentRunDispatchExecutionIdentity(
{
commandRuntimeContext: {
config: replyDispatchRuntime.config,
pluginGeneration: replyDispatchRuntime.pluginGeneration,
},
cronCreatorAuthority: prepared.cronCreatorAuthority,
ingressOpts: {
message,
+3
View File
@@ -153,6 +153,7 @@ export async function startGatewayCoreRuntime(input: {
residentRegistry,
shutdownRuntime,
} = runtime;
let currentPluginMetadataSnapshot = runtime.pluginMetadataSnapshot;
if (desktopSessionRegistry) {
kernel.addGatewayLifetimeSidecar({ stop: () => desktopSessionRegistry.stopAll() });
}
@@ -612,6 +613,7 @@ export async function startGatewayCoreRuntime(input: {
env: params.env,
workspaceDir: pluginWorkspaceDir,
});
currentPluginMetadataSnapshot = nextPluginMetadataSnapshot;
replaceAttachedPluginRuntime(loaded);
kernel.setPluginServices(null);
if (previousPluginServices) {
@@ -673,5 +675,6 @@ export async function startGatewayCoreRuntime(input: {
loadGatewayModelCatalog,
loadGatewayModelCatalogSnapshot,
readPreparedGatewayModelCatalog,
getPluginMetadataSnapshot: () => currentPluginMetadataSnapshot,
};
}
@@ -151,6 +151,16 @@ vi.mock("../../commands/agent.js", () => ({
agentCommandFromIngress: mocks.agentCommand,
}));
vi.mock("../../agents/prepared-model-runtime.js", () => ({
// Direct handler tests bypass Gateway startup, so provide the lifecycle fact
// that production publishes before admitting agent RPCs.
loadPublishedGatewayReplyDispatchRuntime: async ({ agentId }: { agentId: string }) => ({
agentId,
config: mocks.loadConfigReturn,
pluginGeneration: { pluginMetadataSnapshot: {} },
}),
}));
vi.mock("../../acp/runtime/session-meta.js", async () => {
const actual = await vi.importActual<typeof import("../../acp/runtime/session-meta.js")>(
"../../acp/runtime/session-meta.js",
+2
View File
@@ -2,6 +2,7 @@ import type { CliDeps } from "../cli/deps.types.js";
import type { ConfigFileSnapshot, OpenClawConfig } from "../config/types.openclaw.js";
import type { HeartbeatRunner } from "../infra/heartbeat-runner.js";
import type { GatewayRestartEmitter } from "../infra/restart.js";
import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.types.js";
import type { ChannelHealthMonitor } from "./channel-health-monitor.js";
import type { ChannelKind } from "./config-reload-plan.js";
import type { GatewayReloadPlan } from "./config-reload.js";
@@ -143,6 +144,7 @@ export type GatewayReloadHandlerParams = {
broadcast: (event: string, payload: unknown, opts?: { dropIfSlow?: boolean }) => void;
getState: () => GatewayHotReloadState;
setState: (state: GatewayHotReloadState) => void;
getPluginMetadataSnapshot?: () => PluginMetadataSnapshot | undefined;
startChannel: GatewayChannelManager["startChannel"];
stopChannel: GatewayChannelManager["stopChannel"];
getChannelAutostartSuppression?: GatewayChannelManager["getAutostartSuppression"];
+2
View File
@@ -576,9 +576,11 @@ export function createGatewayReloadHandlers(params: GatewayReloadHandlerParams)
}
try {
const pluginMetadataSnapshot = params.getPluginMetadataSnapshot?.();
await refreshPreparedModelRuntimeSnapshots(nextConfig, {
catalogMode: "static",
allowGatewaySubagentBinding: true,
...(pluginMetadataSnapshot ? { pluginMetadataSnapshot } : {}),
});
} catch (err) {
scheduleRecoveryRestart("prepared model runtime reload", err);
+1
View File
@@ -135,6 +135,7 @@ export function startManagedGatewayConfigReloader(
broadcast: params.broadcast,
getState: params.getState,
setState: params.setState,
getPluginMetadataSnapshot: params.getPluginMetadataSnapshot,
startChannel: params.startChannel,
stopChannel: params.stopChannel,
getChannelAutostartSuppression: params.getChannelAutostartSuppression,
+1 -1
View File
@@ -551,7 +551,7 @@ export async function prepareGatewayServerBootstrap(input: {
pluginWorkspaceDir,
startupPluginIds,
pluginManifestRecords,
pluginMetadataSnapshot,
pluginMetadataSnapshot: currentPluginMetadataSnapshot,
pluginLookUpTable,
baseMethods,
ambientAutostartSuppressedChannelIds,
+4
View File
@@ -91,6 +91,7 @@ export async function finishGatewayStartup(params: {
baseMethods,
startupPluginIds,
pluginManifestRecords,
pluginMetadataSnapshot,
pluginLookUpTable,
ambientEnvTriggers,
replaceAttachedPluginRuntime,
@@ -126,6 +127,7 @@ export async function finishGatewayStartup(params: {
gatewayRequestContext,
gatewayInstanceRuntime,
residentRegistry,
getPluginMetadataSnapshot,
} = runtime;
const unregisterGatewayLifetimeSidecar = (sidecar: GatewayPostReadySidecarHandle) => {
kernel.setGatewayLifetimeSidecars(
@@ -255,6 +257,7 @@ export async function finishGatewayStartup(params: {
gatewayPluginConfigAtStart,
activationSourceConfig: startupActivationSourceConfig,
pluginManifestRecords,
...(pluginMetadataSnapshot ? { pluginMetadataSnapshot } : {}),
ambientEnvTriggers,
pluginRegistry: pluginRuntime.registry,
defaultWorkspaceDir,
@@ -393,6 +396,7 @@ export async function finishGatewayStartup(params: {
cronStartState.handled = true;
}
},
getPluginMetadataSnapshot,
startChannel,
stopChannel,
getChannelAutostartSuppression: channelManager.getAutostartSuppression,
+15
View File
@@ -17,6 +17,7 @@ import type { getGlobalHookRunner } from "../plugins/hook-runner-global.js";
import type { PluginHookGatewayCronService } from "../plugins/hook-types.js";
import type { loadOpenClawPlugins } from "../plugins/loader.js";
import type { PluginManifestRecord } from "../plugins/manifest-registry.js";
import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.types.js";
import { getPluginModuleLoaderStats } from "../plugins/plugin-module-loader-cache.js";
import type { PluginRegistry } from "../plugins/registry.js";
import type { PluginServicesHandle } from "../plugins/services.js";
@@ -429,6 +430,7 @@ async function waitForAcpRuntimeBackendReady(params: {
async function prewarmConfiguredPrimaryModel(params: {
cfg: OpenClawConfig;
pluginMetadataSnapshot?: PluginMetadataSnapshot;
workspaceDir?: string;
log: { warn: (msg: string) => void };
startupTrace?: GatewayStartupTrace;
@@ -499,6 +501,7 @@ async function hydrateConfiguredExternalCliAuth(params: {
async function publishConfiguredModelRuntimeSnapshots(params: {
cfg: OpenClawConfig;
pluginMetadataSnapshot?: PluginMetadataSnapshot;
workspaceDir?: string;
log: { warn: (msg: string) => void };
startupTrace?: GatewayStartupTrace;
@@ -509,6 +512,9 @@ async function publishConfiguredModelRuntimeSnapshots(params: {
gatewayLifecycle: true,
catalogMode: "static",
allowGatewaySubagentBinding: true,
...(params.pluginMetadataSnapshot
? { pluginMetadataSnapshot: params.pluginMetadataSnapshot }
: {}),
...(params.workspaceDir ? { defaultWorkspaceDir: params.workspaceDir } : {}),
...(params.startupTrace
? {
@@ -544,6 +550,7 @@ async function publishConfiguredModelRuntimeSnapshots(params: {
async function publishStartupModelRuntime(
params: {
cfg: OpenClawConfig;
pluginMetadataSnapshot?: PluginMetadataSnapshot;
workspaceDir?: string;
log: { warn: (msg: string) => void };
startupTrace?: GatewayStartupTrace;
@@ -559,6 +566,7 @@ async function publishStartupModelRuntime(
/** Start post-ready sidecars such as channels, hooks, plugin services, and cleanup tasks. */
export async function startGatewaySidecars(params: {
cfg: OpenClawConfig;
pluginMetadataSnapshot?: PluginMetadataSnapshot;
pluginRegistry: ReturnType<typeof loadOpenClawPlugins>;
defaultWorkspaceDir: string;
deps: CliDeps;
@@ -648,6 +656,9 @@ export async function startGatewaySidecars(params: {
publishStartupModelRuntime(
{
cfg: params.cfg,
...(params.pluginMetadataSnapshot
? { pluginMetadataSnapshot: params.pluginMetadataSnapshot }
: {}),
workspaceDir: params.defaultWorkspaceDir,
log: params.log,
startupTrace: params.startupTrace,
@@ -1097,6 +1108,7 @@ export async function startGatewayPostAttachRuntime(
gatewayPluginConfigAtStart: OpenClawConfig;
activationSourceConfig: OpenClawConfig;
pluginManifestRecords: readonly PluginManifestRecord[];
pluginMetadataSnapshot?: PluginMetadataSnapshot;
ambientEnvTriggers?: AmbientEnvTriggerPolicy;
pluginRegistry: ReturnType<typeof loadOpenClawPlugins>;
defaultWorkspaceDir: string;
@@ -1339,6 +1351,9 @@ export async function startGatewayPostAttachRuntime(
return await measureStartup(params.startupTrace, "sidecars.total", () =>
runtimeDeps.startGatewaySidecars({
cfg: params.gatewayPluginConfigAtStart,
...(params.pluginMetadataSnapshot
? { pluginMetadataSnapshot: params.pluginMetadataSnapshot }
: {}),
pluginRegistry,
defaultWorkspaceDir: params.defaultWorkspaceDir,
deps: params.deps,
@@ -26,6 +26,7 @@ import {
connectWebchatClient,
installGatewayTestHooks,
onceMessage,
prepareGatewayReplyRuntimeForTest,
rpcReq,
startConnectedServerWithClient,
startServerWithClient,
@@ -153,10 +154,11 @@ async function writeMainSessionEntry(params: {
});
}
function sendAgentWsRequest(
async function sendAgentWsRequest(
socket: WebSocket,
params: { reqId: string; message: string; idempotencyKey: string; sessionKey?: string },
) {
await prepareGatewayReplyRuntimeForTest();
socket.send(
JSON.stringify({
type: "req",
@@ -180,7 +182,7 @@ async function sendAgentWsRequestAndWaitFinal(
(o) => o.type === "res" && o.id === params.reqId && o.payload?.status !== "accepted",
params.timeoutMs,
);
sendAgentWsRequest(socket, params);
await sendAgentWsRequest(socket, params);
return await finalP;
}
@@ -473,7 +475,7 @@ describe("gateway server agent", () => {
ws,
(o) => o.type === "res" && o.id === "ag1" && o.payload?.status !== "accepted",
);
sendAgentWsRequest(ws, {
await sendAgentWsRequest(ws, {
reqId: "ag1",
message: "hi",
idempotencyKey: "idem-ag",
@@ -508,7 +510,7 @@ describe("gateway server agent", () => {
message.type === "res" && message.id === runId && message.payload?.status !== "accepted",
);
sendAgentWsRequest(ws, {
await sendAgentWsRequest(ws, {
reqId: runId,
message: "persist this agent turn before ACK",
sessionKey: "main",
@@ -569,7 +571,7 @@ describe("gateway server agent", () => {
message.type === "res" && message.id === runId && message.payload?.status !== "accepted",
);
sendAgentWsRequest(ws, {
await sendAgentWsRequest(ws, {
reqId: runId,
message: "keep this aborted agent turn queryable",
sessionKey: "main",
@@ -666,7 +668,7 @@ describe("gateway server agent", () => {
});
const secondP = onceMessage(ws, (o) => o.type === "res" && o.id === "ag2");
sendAgentWsRequest(ws, {
await sendAgentWsRequest(ws, {
reqId: "ag2",
message: "hi again",
idempotencyKey: "same-agent",
@@ -4,7 +4,12 @@ import { afterAll, beforeAll, beforeEach, describe, expect, test, vi } from "vit
import type { RawData, WebSocket } from "ws";
import { createDeferred } from "../../test/helpers/promise.js";
import { startGatewayServerHarness, type GatewayServerHarness } from "./server.e2e-ws-harness.js";
import { agentCommandMock, installGatewayTestHooks, onceMessage } from "./test-helpers.js";
import {
agentCommandMock,
installGatewayTestHooks,
onceMessage,
prepareGatewayReplyRuntimeForTest,
} from "./test-helpers.js";
installGatewayTestHooks({ scope: "suite" });
@@ -32,8 +37,9 @@ beforeAll(async () => {
harness = await startGatewayServerHarness();
});
beforeEach(() => {
beforeEach(async () => {
vi.mocked(agentCommandMock).mockReset();
await prepareGatewayReplyRuntimeForTest();
});
afterAll(async () => {
+5 -1
View File
@@ -30,6 +30,7 @@ import {
agentCommandMock,
getGatewayTestPort,
installGatewayTestHooks,
prepareGatewayReplyRuntimeForTest,
startTestGatewayServer,
setTestPluginRegistry,
testState,
@@ -154,10 +155,11 @@ beforeAll(async () => {
server = await startTestGatewayServer(gatewayPort);
});
beforeEach(() => {
beforeEach(async () => {
testState.gatewayAuth = { mode: "token", token: gatewayToken };
process.env.OPENCLAW_GATEWAY_PORT = String(gatewayPort);
process.env.OPENCLAW_GATEWAY_TOKEN = gatewayToken;
await prepareGatewayReplyRuntimeForTest();
});
afterAll(async () => {
@@ -664,6 +666,7 @@ describe("sessions_send agent targeting", () => {
},
},
});
await prepareGatewayReplyRuntimeForTest({ force: true });
const spy = agentCommandMock as unknown as Mock<(opts: unknown) => Promise<void>>;
spy.mockImplementation(async (opts: unknown) =>
@@ -803,6 +806,7 @@ describe("sessions_send direct-message requester routing", () => {
[targetSessionKey]: { sessionId: "dm-scope-orion", updatedAt: Date.now() },
},
});
await prepareGatewayReplyRuntimeForTest({ force: true });
const spy = agentCommandMock as unknown as Mock<(opts: unknown) => Promise<void>>;
spy.mockReset();
+27
View File
@@ -103,6 +103,7 @@ let tempControlUiRoot: string | undefined;
let suiteConfigRootSeq = 0;
let lastSyncedSessionStorePath: string | undefined;
let lastSyncedSessionConfigJson: string | undefined;
let gatewayReplyRuntimePrepared = false;
let activeSuiteGatewayServerCount = 0;
let activeSuiteHookScopeCount = 0;
// Gateway tests exercise RPC/server behavior, not production bind auto-detection by default.
@@ -478,6 +479,7 @@ async function resetGatewayTestState(options: { uniqueConfigRoot: boolean }) {
resetAgentEventsForTest();
const mod = await getServerModule();
await mod.resetPreparedModelCatalogForTest();
gatewayReplyRuntimePrepared = false;
agentDiscoveryMock.enabled = false;
agentDiscoveryMock.discoverCalls = 0;
agentDiscoveryMock.models = [];
@@ -525,6 +527,28 @@ async function resetGatewayTestRuntimeOnly() {
drainSystemEvents(sessionKey);
}
resetAgentEventsForTest({ preserveListeners: true });
gatewayReplyRuntimePrepared = false;
}
export async function prepareGatewayReplyRuntimeForTest(options?: {
force?: boolean;
}): Promise<void> {
if (
process.env.OPENCLAW_TEST_MINIMAL_GATEWAY !== "1" ||
(!options?.force && gatewayReplyRuntimePrepared)
) {
return;
}
const [preparedRuntime, configRuntime] = await Promise.all([
import("../agents/prepared-model-runtime.js"),
import("../config/io.js"),
]);
await preparedRuntime.refreshPreparedModelRuntimeSnapshots(configRuntime.getRuntimeConfig(), {
gatewayLifecycle: true,
catalogMode: "static",
allowGatewaySubagentBinding: true,
});
gatewayReplyRuntimePrepared = true;
}
export function installGatewayTestHooks(options?: { scope?: "test" | "suite" }) {
@@ -1229,6 +1253,9 @@ export async function rpcReq<T extends Record<string, unknown>>(
// observes the updated test fixture state.
resetConfigRuntimeState();
clearSessionStoreCacheForTest();
if (method === "agent" || method === "chat.send") {
await prepareGatewayReplyRuntimeForTest();
}
const { randomUUID } = await import("node:crypto");
const id = randomUUID();
const responsePromise = onceMessage<{
+1
View File
@@ -22,6 +22,7 @@ export {
getTrackedConnectChallengeNonce,
installGatewayTestHooks,
onceMessage,
prepareGatewayReplyRuntimeForTest,
readConnectChallengeNonce,
rpcReq,
startConnectedServerWithClient,
@@ -7,6 +7,7 @@ import { describe, expect, it } from "vitest";
import {
getCurrentPluginMetadataSnapshot,
installTemporaryCurrentPluginMetadataSnapshot,
isCurrentPluginMetadataSnapshotRuntimeGeneration,
setCurrentPluginMetadataSnapshot,
withPluginMetadataSnapshotScope,
} from "./current-plugin-metadata-snapshot.js";
@@ -166,23 +167,39 @@ describe("current plugin metadata snapshot", () => {
).toBe(globalSnapshot);
});
it("carries prepared metadata and registry as one runtime generation", async () => {
it("carries prepared metadata and registry across nested agent workspaces", async () => {
const config = { plugins: { allow: ["scoped"] } };
const workspaceDir = "/workspace/scoped";
const metadataSnapshot = createSnapshot({ config, workspaceDir });
const pluginWorkspaceDir = "/workspace/plugins";
const agentWorkspaceDir = "/workspace/agent-run";
const metadataSnapshot = createSnapshot({ config, workspaceDir: pluginWorkspaceDir });
const pluginRegistry = createEmptyPluginRegistry();
setCurrentPluginMetadataSnapshot(undefined);
await withPluginRuntimeGenerationScope(
{ config, metadataSnapshot, pluginRegistry, workspaceDir },
{ config, metadataSnapshot, pluginRegistry },
async () => {
await Promise.resolve();
expect(getCurrentPluginMetadataSnapshot({ config, workspaceDir })).toBe(metadataSnapshot);
expect(getCurrentPluginMetadataSnapshot({ config, workspaceDir: agentWorkspaceDir })).toBe(
metadataSnapshot,
);
expect(getCurrentPluginMetadataSnapshot({ config, workspaceDir: pluginWorkspaceDir })).toBe(
metadataSnapshot,
);
expect(
getCurrentPluginMetadataSnapshot({
config: { plugins: { allow: ["derived-run-policy"] } },
workspaceDir: agentWorkspaceDir,
}),
).toBe(metadataSnapshot);
expect(isCurrentPluginMetadataSnapshotRuntimeGeneration(metadataSnapshot)).toBe(true);
expect(getPluginRuntimeGatewayRequestScope()?.pluginRegistry).toBe(pluginRegistry);
},
);
expect(getCurrentPluginMetadataSnapshot({ config, workspaceDir })).toBeUndefined();
expect(isCurrentPluginMetadataSnapshotRuntimeGeneration(metadataSnapshot)).toBe(false);
expect(
getCurrentPluginMetadataSnapshot({ config, workspaceDir: agentWorkspaceDir }),
).toBeUndefined();
expect(getPluginRuntimeGatewayRequestScope()).toBeUndefined();
});
@@ -217,7 +234,6 @@ describe("current plugin metadata snapshot", () => {
config: outerConfig,
metadataSnapshot: outerSnapshot,
pluginRegistry: outerRegistry,
workspaceDir: "/workspace/outer",
},
async () => {
await expect(
@@ -225,7 +241,6 @@ describe("current plugin metadata snapshot", () => {
{
config: innerConfig,
metadataSnapshot: innerSnapshot,
workspaceDir: "/workspace/inner",
},
async () => {
await Promise.resolve();
@@ -414,14 +429,11 @@ describe("current plugin metadata snapshot", () => {
const workspaceDir = "/workspace";
const snapshot = createSnapshot({ config: sourceConfig, workspaceDir });
withPluginRuntimeGenerationScope(
{ config: runtimeConfig, metadataSnapshot: snapshot, workspaceDir },
() => {
expect(getCurrentPluginMetadataSnapshot({ config: runtimeConfig, workspaceDir })).toBe(
snapshot,
);
},
);
withPluginRuntimeGenerationScope({ config: runtimeConfig, metadataSnapshot: snapshot }, () => {
expect(getCurrentPluginMetadataSnapshot({ config: runtimeConfig, workspaceDir })).toBe(
snapshot,
);
});
});
it("rejects a workspace-scoped snapshot when the caller does not provide workspace scope", () => {
@@ -63,6 +63,7 @@ type PluginMetadataSnapshotCandidate = {
compatiblePolicyHashes?: readonly string[];
compatibleConfigFingerprints?: readonly string[];
hasConfigIdentity?: (config: OpenClawConfig) => boolean;
immutableRuntimeGeneration?: boolean;
};
type ScopedPluginMetadataSnapshot = PluginMetadataSnapshotCandidate & {
@@ -300,6 +301,7 @@ export function withPluginMetadataSnapshotScope<T>(
compatiblePolicyHashes,
compatibleConfigFingerprints,
hasConfigIdentity: (config) => configIdentities.has(config),
immutableRuntimeGeneration: options.trustConfigIdentity === true,
parent: scopedPluginMetadataSnapshot.getStore(),
},
run,
@@ -333,6 +335,11 @@ function resolveCompatiblePluginMetadataSnapshot(
) {
return undefined;
}
// Immutable runtime generations already selected their executable plugin graph. Nested config
// and workspace projections are run data, not authority to reopen lifecycle-owned discovery.
if (candidate.immutableRuntimeGeneration) {
return snapshot;
}
const requestedWorkspaceDir =
params.workspaceDir ??
(params.allowWorkspaceScopedSnapshot === true || options.scopedOwnerContext === true
@@ -398,6 +405,17 @@ function resolveCompatiblePluginMetadataSnapshot(
return snapshot;
}
export function isCurrentPluginMetadataSnapshotRuntimeGeneration(
snapshot: PluginMetadataSnapshot,
): boolean {
for (let scoped = scopedPluginMetadataSnapshot.getStore(); scoped; scoped = scoped.parent) {
if (scoped.snapshot === snapshot && scoped.immutableRuntimeGeneration === true) {
return true;
}
}
return false;
}
export function getCurrentPluginMetadataSnapshot(
params: CurrentPluginMetadataSnapshotParams = {},
): PluginMetadataSnapshot | undefined {
+6 -3
View File
@@ -4,7 +4,10 @@ import {
getActiveDiagnosticsTimelineSpan,
measureDiagnosticsTimelineSpanSync,
} from "../infra/diagnostics-timeline.js";
import { getCurrentPluginMetadataSnapshot } from "./current-plugin-metadata-snapshot.js";
import {
getCurrentPluginMetadataSnapshot,
isCurrentPluginMetadataSnapshotRuntimeGeneration,
} from "./current-plugin-metadata-snapshot.js";
import { resolveActivePluginInstallRoots } from "./install-root-context.js";
import { hashJson } from "./installed-plugin-index-hash.js";
import { resolveInstalledPluginIndexPolicyHash } from "./installed-plugin-index-policy.js";
@@ -335,7 +338,7 @@ export function completePluginMetadataSnapshot(params: {
}
/** Reuses process-stable plugin facts for a workspace proven to have no plugin root. */
export function projectPluginMetadataSnapshotWorkspace(params: {
function projectPluginMetadataSnapshotWorkspace(params: {
snapshot: PluginMetadataSnapshot;
config: OpenClawConfig;
env?: NodeJS.ProcessEnv;
@@ -418,7 +421,7 @@ export function resolvePluginMetadataSnapshot(
}
return loadPluginMetadataSnapshot(params);
}
if (!params.index) {
if (!params.index || isCurrentPluginMetadataSnapshotRuntimeGeneration(current)) {
return current;
}
if (
+3 -2
View File
@@ -21,7 +21,6 @@ export function withPluginRuntimeGenerationScope<T>(
config: OpenClawConfig;
metadataSnapshot: PluginMetadataSnapshot;
pluginRegistry?: PluginRegistry;
workspaceDir?: string;
},
run: () => T,
): T {
@@ -35,7 +34,9 @@ export function withPluginRuntimeGenerationScope<T>(
{
config: generation.config,
trustConfigIdentity: true,
...(generation.workspaceDir ? { workspaceDir: generation.workspaceDir } : {}),
...(generation.metadataSnapshot.workspaceDir
? { workspaceDir: generation.metadataSnapshot.workspaceDir }
: {}),
},
);
}
@@ -0,0 +1,110 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import {
getCurrentPluginMetadataSnapshot,
setCurrentPluginMetadataSnapshot,
} from "../current-plugin-metadata-snapshot.js";
import { resolveInstalledPluginIndexPolicyHash } from "../installed-plugin-index-policy.js";
import { clearPluginMetadataLifecycleCaches } from "../plugin-metadata-lifecycle.js";
import type { PluginMetadataSnapshot } from "../plugin-metadata-snapshot.types.js";
import { resolvePluginRuntimeLoadContext } from "./load-context.js";
const resolvePluginMetadataSnapshotMock = vi.hoisted(() => vi.fn());
vi.mock("../plugin-metadata-snapshot.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../plugin-metadata-snapshot.js")>()),
resolvePluginMetadataSnapshot: resolvePluginMetadataSnapshotMock,
}));
function createSnapshot(params: {
config: OpenClawConfig;
workspaceDir: string;
}): PluginMetadataSnapshot {
const policyHash = resolveInstalledPluginIndexPolicyHash(params.config);
return {
policyHash,
workspaceDir: params.workspaceDir,
index: {
version: 1,
hostContractVersion: "test",
compatRegistryVersion: "test",
migrationVersion: 1,
policyHash,
generatedAtMs: 1,
installRecords: {},
plugins: [],
diagnostics: [],
},
registryDiagnostics: [],
manifestRegistry: { plugins: [], diagnostics: [] },
plugins: [],
diagnostics: [],
byPluginId: new Map(),
normalizePluginId: (pluginId) => pluginId,
owners: {
channels: new Map(),
channelConfigs: new Map(),
providers: new Map(),
modelCatalogProviders: new Map(),
cliBackends: new Map(),
setupProviders: new Map(),
commandAliases: new Map(),
contracts: new Map(),
},
metrics: {
registrySnapshotMs: 0,
manifestRegistryMs: 0,
ownerMapsMs: 0,
totalMs: 0,
indexPluginCount: 0,
manifestPluginCount: 0,
},
discovery: { candidates: [], diagnostics: [] },
};
}
describe("plugin runtime load context current snapshot ownership", () => {
afterEach(() => {
resolvePluginMetadataSnapshotMock.mockReset();
clearPluginMetadataLifecycleCaches();
});
it("keeps operation-local metadata from replacing the Gateway lifecycle snapshot", () => {
const lifecycleConfig = { plugins: { allow: ["lifecycle"] } };
const operationConfig = { plugins: { allow: ["operation"] } };
const lifecycleWorkspace = "/workspace/lifecycle";
const operationWorkspace = "/workspace/operation";
const lifecycleSnapshot = createSnapshot({
config: lifecycleConfig,
workspaceDir: lifecycleWorkspace,
});
const operationSnapshot = createSnapshot({
config: operationConfig,
workspaceDir: operationWorkspace,
});
setCurrentPluginMetadataSnapshot(lifecycleSnapshot, {
config: lifecycleConfig,
workspaceDir: lifecycleWorkspace,
});
resolvePluginMetadataSnapshotMock.mockReturnValue(operationSnapshot);
const context = resolvePluginRuntimeLoadContext({
config: operationConfig,
workspaceDir: operationWorkspace,
});
expect(context.metadataSnapshot).toBe(operationSnapshot);
expect(
getCurrentPluginMetadataSnapshot({
config: lifecycleConfig,
workspaceDir: lifecycleWorkspace,
}),
).toBe(lifecycleSnapshot);
expect(
getCurrentPluginMetadataSnapshot({
config: operationConfig,
workspaceDir: operationWorkspace,
}),
).toBeUndefined();
});
});
+3 -26
View File
@@ -32,8 +32,6 @@ const rebasePluginMetadataSnapshotManifestRegistryMock = vi.fn(
);
const resolveConfigWidePluginManifestRegistryMock = vi.fn(() => manifestRegistry);
const isPluginMetadataSnapshotCompatibleMock = vi.fn(() => true);
const getCurrentPluginMetadataSnapshotMock = vi.fn(() => undefined);
const setCurrentPluginMetadataSnapshotMock = vi.fn();
let resolvePluginRuntimeLoadContext: typeof import("./load-context.js").resolvePluginRuntimeLoadContext;
let buildPluginRuntimeLoadOptions: typeof import("./load-context.js").buildPluginRuntimeLoadOptions;
@@ -70,11 +68,6 @@ vi.mock("../plugin-metadata-snapshot.js", () => ({
resolvePluginMetadataSnapshot: loadPluginMetadataSnapshotMock,
}));
vi.mock("../current-plugin-metadata-snapshot.js", () => ({
getCurrentPluginMetadataSnapshot: getCurrentPluginMetadataSnapshotMock,
setCurrentPluginMetadataSnapshot: setCurrentPluginMetadataSnapshotMock,
}));
describe("resolvePluginRuntimeLoadContext", () => {
beforeEach(async () => {
vi.resetModules();
@@ -87,15 +80,11 @@ describe("resolvePluginRuntimeLoadContext", () => {
applyPluginAutoEnableMock.mockReset();
fingerprintPluginAutoEnableConfigMock.mockClear();
fingerprintPluginAutoEnableEnvMock.mockClear();
getCurrentPluginMetadataSnapshotMock.mockReset();
getCurrentPluginMetadataSnapshotMock.mockReturnValue(undefined);
isPluginMetadataSnapshotCompatibleMock.mockReset();
isPluginMetadataSnapshotCompatibleMock.mockReturnValue(true);
loadPluginMetadataSnapshotMock.mockClear();
rebasePluginMetadataSnapshotManifestRegistryMock.mockClear();
resolveConfigWidePluginManifestRegistryMock.mockClear();
getCurrentPluginMetadataSnapshotMock.mockClear();
setCurrentPluginMetadataSnapshotMock.mockClear();
resolvePluginControlPlaneWorkspaceMock.mockClear();
loadConfigMock.mockReturnValue({ plugins: {} });
@@ -156,12 +145,6 @@ describe("resolvePluginRuntimeLoadContext", () => {
env,
manifestRegistry,
});
expect(setCurrentPluginMetadataSnapshotMock).toHaveBeenCalledWith(metadataSnapshot, {
config: rawConfig,
compatibleConfigs: [resolvedConfig, rawConfig],
env,
workspaceDir: "/resolved-workspace",
});
expect(resolvePluginControlPlaneWorkspaceMock).toHaveBeenNthCalledWith(1, {
config: rawConfig,
env,
@@ -193,24 +176,19 @@ describe("resolvePluginRuntimeLoadContext", () => {
expect(loadPluginMetadataSnapshotMock).not.toHaveBeenCalled();
});
it("stores derived metadata as the reusable runtime snapshot", () => {
it("keeps derived metadata operation-local", () => {
const derivedSnapshot = { ...metadataSnapshot } as typeof metadataSnapshot & {
registrySource: "derived";
};
derivedSnapshot.registrySource = "derived";
loadPluginMetadataSnapshotMock.mockReturnValueOnce(derivedSnapshot);
resolvePluginRuntimeLoadContext({
const context = resolvePluginRuntimeLoadContext({
config: { plugins: {} },
env: { HOME: "/tmp/openclaw-home" } as NodeJS.ProcessEnv,
});
expect(setCurrentPluginMetadataSnapshotMock).toHaveBeenCalledWith(derivedSnapshot, {
config: { plugins: {} },
compatibleConfigs: [{ plugins: {} }, { plugins: {} }],
env: { HOME: "/tmp/openclaw-home" },
workspaceDir: "/resolved-workspace",
});
expect(context.metadataSnapshot).toBe(derivedSnapshot);
});
it("uses the source runtime snapshot for plugin activation source config", () => {
@@ -327,7 +305,6 @@ describe("resolvePluginRuntimeLoadContext", () => {
pluginIds,
workspaceDir: "/resolved-workspace",
});
expect(setCurrentPluginMetadataSnapshotMock).not.toHaveBeenCalled();
});
it("builds plugin load options from the shared runtime context", () => {
-11
View File
@@ -11,7 +11,6 @@ import type { PluginInstallRecord } from "../../config/types.plugins.js";
import { createSubsystemLogger } from "../../logging.js";
import { resolvePluginActivationSourceConfig } from "../activation-source-config.js";
import { resolvePluginControlPlaneWorkspace } from "../control-plane-workspace.js";
import { setCurrentPluginMetadataSnapshot } from "../current-plugin-metadata-snapshot.js";
import { extractPluginInstallRecordsFromInstalledPluginIndex } from "../installed-plugin-index-install-records.js";
import type { PluginLoadOptions } from "../loader.js";
import type { PluginManifestRegistry } from "../manifest-registry.js";
@@ -252,16 +251,6 @@ export function resolvePluginRuntimeLoadContext(
const installRecords = metadataSnapshot
? extractPluginInstallRecordsFromInstalledPluginIndex(metadataSnapshot.index)
: undefined;
if (metadataSnapshot && metadataSnapshot.pluginIds === undefined) {
// Scoped graphs are request-local; publishing one would hide other installed
// providers from process-wide model normalization and later runtime loads.
setCurrentPluginMetadataSnapshot(metadataSnapshot, {
config: rawConfig,
compatibleConfigs: [config, activationSourceConfig],
env,
workspaceDir,
});
}
return {
rawConfig,
config,
+5 -4
View File
@@ -1035,6 +1035,7 @@ describe("resolvePluginTools optional tools", () => {
const context = createContext();
const config = context.config;
const registry = createToolRegistry([createOptionalDemoEntry()]);
const preparedConfig = structuredClone(config);
const metadataSnapshot = installToolManifestSnapshots({
config,
plugins: [
@@ -1048,11 +1049,11 @@ describe("resolvePluginTools optional tools", () => {
...createResolveToolsParams({ context, toolAllowlist: ["optional_tool"] }),
preparedRuntime: {
loadContext: {
rawConfig: config,
config,
activationSourceConfig: config,
rawConfig: preparedConfig,
config: preparedConfig,
activationSourceConfig: preparedConfig,
autoEnabledReasons: {},
workspaceDir: "/tmp",
workspaceDir: "/gateway/plugin-runtime",
env: process.env,
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
manifestRegistry: metadataSnapshot.manifestRegistry as never,
+3 -5
View File
@@ -1177,11 +1177,9 @@ function resolvePluginToolLoadState(params: {
const env = params.env ?? process.env;
const baseConfig = applyTestPluginDefaults(params.context.config ?? {}, env);
const preparedLoadContext = params.preparedRuntime?.loadContext;
const usePreparedRuntime =
preparedLoadContext !== undefined &&
(baseConfig === preparedLoadContext.rawConfig || baseConfig === preparedLoadContext.config) &&
env === preparedLoadContext.env &&
params.context.workspaceDir === preparedLoadContext.workspaceDir;
// The prepared runtime already owns one immutable Gateway plugin generation. Per-turn config
// and workspace projections cannot invalidate that executable graph or reopen discovery.
const usePreparedRuntime = preparedLoadContext !== undefined && env === preparedLoadContext.env;
const context = usePreparedRuntime
? preparedLoadContext
: resolvePluginRuntimeLoadContext({
@@ -8,6 +8,7 @@ let listSkillCommandsForAgents: typeof import("./chat-commands.js").listSkillCom
let listSkillCommandsForWorkspace: typeof import("./chat-commands.js").listSkillCommandsForWorkspace;
let expandExplicitSkillReferences: typeof import("./chat-commands.js").expandExplicitSkillReferences;
let resolveSkillCommandInvocation: typeof import("./chat-commands.js").resolveSkillCommandInvocation;
let lastPluginMetadataSnapshot: unknown;
function resolveSkillReferenceInvocations(
params: Parameters<typeof expandExplicitSkillReferences>[0],
@@ -95,6 +96,7 @@ function buildWorkspaceSkillCommandSpecs(
reservedNames?: Set<string>;
skillFilter?: string[];
agentId?: string;
pluginMetadataSnapshot?: unknown;
config?: {
agents?: {
defaults?: { skills?: string[] };
@@ -103,6 +105,7 @@ function buildWorkspaceSkillCommandSpecs(
};
},
) {
lastPluginMetadataSnapshot = opts?.pluginMetadataSnapshot;
const used = new Set<string>();
for (const reserved of opts?.reservedNames ?? []) {
used.add(reserved.toLowerCase());
@@ -176,6 +179,7 @@ afterAll(() => {
beforeEach(() => {
vi.clearAllMocks();
lastPluginMetadataSnapshot = undefined;
resolveNodeExecEligibilityMock.mockReturnValue({ canExec: false });
});
@@ -645,4 +649,18 @@ describe("listSkillCommandsForWorkspace", () => {
}),
);
});
it("keeps explicit command discovery on the admitted plugin generation", async () => {
const baseDir = tempDirs.make("openclaw-skills-workspace-generation-");
const workspaceDir = await createWorkspace(baseDir, "main");
const pluginMetadataSnapshot = { generation: "gateway" } as never;
listSkillCommandsForWorkspace({
workspaceDir,
cfg: {},
pluginMetadataSnapshot,
});
expect(lastPluginMetadataSnapshot).toBe(pluginMetadataSnapshot);
});
});
+8 -1
View File
@@ -12,6 +12,7 @@ import {
} from "../../agents/exec-defaults.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { logVerbose } from "../../globals.js";
import type { PluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.types.js";
import { loadWorkspaceSkills } from "../loading/workspace-skill-loader.js";
import { getRemoteSkillEligibility } from "../runtime/remote.js";
import type { SkillCommandSpec } from "../types.js";
@@ -35,6 +36,7 @@ export function listSkillCommandsForWorkspace(params: {
sessionKey?: string;
execOverrides?: ExecPolicyOverrides;
includeAllowlistHidden?: boolean;
pluginMetadataSnapshot?: PluginMetadataSnapshot;
}): SkillCommandSpec[] {
const nodeSkills = resolveNodeExecEligibility({
cfg: params.cfg,
@@ -48,7 +50,11 @@ export function listSkillCommandsForWorkspace(params: {
remote: getRemoteSkillEligibility({ advertiseExecNode: nodeSkills.canExec }),
};
const entries = params.includeAllowlistHidden
? loadWorkspaceSkills(params.workspaceDir, { config: params.cfg, eligibility })
? loadWorkspaceSkills(params.workspaceDir, {
config: params.cfg,
eligibility,
pluginMetadataSnapshot: params.pluginMetadataSnapshot,
})
: undefined;
return buildWorkspaceSkillCommandSpecs(params.workspaceDir, {
config: params.cfg,
@@ -56,6 +62,7 @@ export function listSkillCommandsForWorkspace(params: {
skillFilter: params.skillFilter,
includeAllowlistHidden: params.includeAllowlistHidden,
eligibility,
pluginMetadataSnapshot: params.pluginMetadataSnapshot,
...(entries ? { entries } : {}),
reservedNames: listReservedChatSlashCommandNames(),
});
+3
View File
@@ -8,6 +8,7 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { createDedupeCache } from "../../infra/dedupe.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import { loadEnabledClaudeBundleCommands } from "../../plugins/bundle-commands.js";
import type { PluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.types.js";
import { resolveSkillTelemetrySource } from "../loading/source.js";
import { filterWorkspaceSkills, loadVisibleSkills } from "../loading/workspace-skill-loader.js";
import type { SkillEligibilityContext, SkillCommandSpec, SkillEntry } from "../types.js";
@@ -81,6 +82,7 @@ export function buildWorkspaceSkillCommandSpecs(
skillFilter?: string[];
includeAllowlistHidden?: boolean;
eligibility?: SkillEligibilityContext;
pluginMetadataSnapshot?: PluginMetadataSnapshot;
reservedNames?: Set<string>;
},
): SkillCommandSpec[] {
@@ -99,6 +101,7 @@ export function buildWorkspaceSkillCommandSpecs(
bundledSkillsDir: opts?.bundledSkillsDir,
skillFilter: effectiveSkillFilter,
eligibility: opts?.eligibility,
pluginMetadataSnapshot: opts?.pluginMetadataSnapshot,
});
const userInvocable = filterUserInvocableSkillEntries(eligible);
const used = new Set<string>();
+24 -1
View File
@@ -47,6 +47,7 @@ vi.mock("../../plugins/plugin-metadata-snapshot.js", () => ({
}));
let resolvePluginSkillDirs: typeof import("./plugin-skills.js").resolvePluginSkillDirs;
let resolvePluginSkillDirsFromMetadata: typeof import("./plugin-skills.js").resolvePluginSkillDirsFromMetadata;
const tempDirs = createTrackedTempDirs();
@@ -188,7 +189,29 @@ afterEach(async () => {
describe("resolvePluginSkillDirs", () => {
beforeAll(async () => {
({ resolvePluginSkillDirs } = await import("./plugin-skills.js"));
({ resolvePluginSkillDirs, resolvePluginSkillDirsFromMetadata } =
await import("./plugin-skills.js"));
});
it("uses supplied lifecycle metadata without a cold load", async () => {
const { workspaceDir, acpxRoot, helperRoot } = await setupAcpxAndHelperRegistry();
registerHealthyAcpBackend();
const manifestRegistry = buildRegistry({ acpxRoot, helperRoot });
const dirs = resolvePluginSkillDirsFromMetadata({
workspaceDir,
config: {
acp: { enabled: true },
plugins: { entries: { acpx: { enabled: true }, helper: { enabled: true } } },
} as OpenClawConfig,
metadataSnapshot: {
manifestRegistry,
normalizePluginId: (pluginId: string) => pluginId,
} as never,
});
expect(dirs).toEqual([path.resolve(acpxRoot, "skills"), path.resolve(helperRoot, "skills")]);
expect(hoisted.loadPluginMetadataSnapshot).not.toHaveBeenCalled();
});
beforeEach(() => {
+19 -3
View File
@@ -12,7 +12,8 @@ import {
} from "../../plugins/config-policy.js";
import { resolveMemorySlotDecision } from "../../plugins/config-state.js";
import { registerPluginMetadataProcessMemoLifecycleClear } from "../../plugins/plugin-metadata-lifecycle.js";
import { resolvePluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.js";
import { loadPluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.js";
import type { PluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.types.js";
import { hasKind } from "../../plugins/slots.js";
import { isPathInsideWithRealpath } from "../../security/scan-paths.js";
import { CONFIG_DIR } from "../../utils.js";
@@ -50,12 +51,27 @@ export function resolvePluginSkillDirs(params: {
return [];
}
const config = params.config ?? {};
const metadataSnapshot = resolvePluginMetadataSnapshot({
const metadataSnapshot = loadPluginMetadataSnapshot({
workspaceDir,
config,
env: process.env,
allowWorkspaceScopedCurrent: true,
});
return resolvePluginSkillDirsFromMetadata({ ...params, metadataSnapshot });
}
export function resolvePluginSkillDirsFromMetadata(params: {
workspaceDir: string | undefined;
config?: OpenClawConfig;
pluginSkillsDir?: string;
metadataSnapshot: PluginMetadataSnapshot;
}): string[] {
const workspaceDir = (params.workspaceDir ?? "").trim();
if (!workspaceDir) {
publishPluginSkills([], { pluginSkillsDir: params.pluginSkillsDir });
return [];
}
const config = params.config ?? {};
const metadataSnapshot = params.metadataSnapshot;
const registry = metadataSnapshot.manifestRegistry;
if (registry.plugins.length === 0) {
publishPluginSkills([], {
@@ -7,13 +7,11 @@ import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { resetLogger, setLoggerOverride } from "../../logging/logger.js";
import { loggingState } from "../../logging/state.js";
import { setCurrentPluginMetadataSnapshot } from "../../plugins/current-plugin-metadata-snapshot.js";
import { resolveInstalledPluginIndexPolicyHash } from "../../plugins/installed-plugin-index-policy.js";
import type {
PluginManifestRecord,
PluginManifestRegistry,
} from "../../plugins/manifest-registry.js";
import { clearPluginMetadataLifecycleCaches } from "../../plugins/plugin-metadata-lifecycle.js";
import type { PluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.js";
import { writeSkill, writeWorkspaceSkills } from "../test-support/e2e-test-helpers.js";
import {
@@ -145,21 +143,6 @@ function createWorkspacePluginMetadataSnapshot(params: {
};
}
function setWorkspacePluginMetadataSnapshot(workspaceDir: string, config?: OpenClawConfig): void {
const manifestRegistry = createWorkspacePluginRegistry(workspaceDir);
setCurrentPluginMetadataSnapshot(
createWorkspacePluginMetadataSnapshot({
workspaceDir,
manifestRegistry,
...(config === undefined ? {} : { config }),
}),
{
workspaceDir,
...(config === undefined ? {} : { config }),
},
);
}
async function expectMissingPath(pathToCheck: string) {
let thrown: unknown;
try {
@@ -192,12 +175,17 @@ function loadTestWorkspaceSkills(
workspaceDir: string,
opts?: Parameters<typeof loadWorkspaceSkills>[1],
) {
setWorkspacePluginMetadataSnapshot(workspaceDir, opts?.config);
const pluginMetadataSnapshot = createWorkspacePluginMetadataSnapshot({
workspaceDir,
manifestRegistry: createWorkspacePluginRegistry(workspaceDir),
...(opts?.config === undefined ? {} : { config: opts.config }),
});
return loadWorkspaceSkills(workspaceDir, {
managedSkillsDir: path.join(workspaceDir, ".managed"),
bundledSkillsDir: "",
pluginSkillsDir: path.join(workspaceDir, ".plugin-skills"),
...opts,
pluginMetadataSnapshot: opts?.pluginMetadataSnapshot ?? pluginMetadataSnapshot,
});
}
@@ -209,7 +197,6 @@ beforeAll(async () => {
});
afterEach(async () => {
clearPluginMetadataLifecycleCaches();
setLoggerOverride(null);
loggingState.rawConsole = null;
resetLogger();
+14 -2
View File
@@ -8,6 +8,7 @@ import { isDefaultStateDir } from "../../config/paths.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { isPathInside } from "../../infra/path-guards.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import type { PluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.types.js";
import { CONFIG_DIR, resolveUserPath } from "../../utils.js";
import {
isSessionSkillEnabled,
@@ -34,7 +35,7 @@ import {
readSkillFrontmatterSafe,
type LocalSkillLoadDiagnostic,
} from "./local-loader.js";
import { resolvePluginSkillDirs } from "./plugin-skills.js";
import { resolvePluginSkillDirs, resolvePluginSkillDirsFromMetadata } from "./plugin-skills.js";
import type { Skill } from "./skill-contract.js";
import { compactSkillPath, resolveSkillsUserHomeDir } from "./skill-paths.js";
import {
@@ -74,6 +75,7 @@ type WorkspaceSkillLoadOptions = {
eligibility?: SkillEligibilityContext;
workspaceOnly?: boolean;
includeArchived?: boolean;
pluginMetadataSnapshot?: PluginMetadataSnapshot;
};
export function normalizeWorkspaceSkillRoots(roots: WorkspaceSkillRoots): WorkspaceSkillRoots {
@@ -334,6 +336,7 @@ function loadSkillEntries(
workspaceSkillsDir?: string;
workspaceOnly?: boolean;
includeArchived?: boolean;
pluginMetadataSnapshot?: PluginMetadataSnapshot;
},
): SkillEntry[] {
const limits = resolveSkillDiscoveryLimits(opts?.config);
@@ -352,7 +355,14 @@ function loadSkillEntries(
const extraDirs = normalizeTrimmedStringList(extraDirsRaw);
const pluginSkillDirs = workspaceOnly
? []
: resolvePluginSkillDirs({ workspaceDir, config: opts?.config, pluginSkillsDir });
: opts?.pluginMetadataSnapshot
? resolvePluginSkillDirsFromMetadata({
workspaceDir,
config: opts.config,
pluginSkillsDir,
metadataSnapshot: opts.pluginMetadataSnapshot,
})
: resolvePluginSkillDirs({ workspaceDir, config: opts?.config, pluginSkillsDir });
const mergedExtraDirs = [...extraDirs, ...pluginSkillDirs];
const bundledSkills = bundledSkillsDir
@@ -481,6 +491,7 @@ export function resolveWorkspaceSkillPromptEntries(
skillFilter?: string[];
skillOverrides?: Record<string, boolean>;
eligibility?: SkillEligibilityContext;
pluginMetadataSnapshot?: PluginMetadataSnapshot;
},
): { eligible: SkillEntry[]; skillFilter: string[] | undefined } {
const skillFilter = resolveEffectiveWorkspaceSkillFilter(opts);
@@ -573,6 +584,7 @@ export function loadVisibleSkills(
skillOverrides?: Record<string, boolean>;
agentId?: string;
eligibility?: SkillEligibilityContext;
pluginMetadataSnapshot?: PluginMetadataSnapshot;
},
): SkillEntry[] {
const entries = mergeRemoteNodeSkillEntries(loadSkillEntries(workspaceDir, opts), {
@@ -1,6 +1,7 @@
// Workspace skill prompt helpers render bounded catalogs and reusable snapshots.
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import type { PluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.types.js";
import { resolveEffectiveAgentSkillsLimits } from "../discovery/agent-filter.js";
import { filterPromptVisibleSkillEntries } from "../discovery/skill-index.js";
import type { SkillEligibilityContext, SkillEntry, SkillSnapshot } from "../types.js";
@@ -24,6 +25,7 @@ type WorkspaceSkillBuildOptions = {
skillOverrides?: Record<string, boolean>;
eligibility?: SkillEligibilityContext;
preserveEntryOrder?: boolean;
pluginMetadataSnapshot?: PluginMetadataSnapshot;
};
function resolveWorkspaceSkillPromptState(
+3
View File
@@ -2,6 +2,7 @@
import { stableStringify } from "@openclaw/normalization-core";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { pruneMapToMaxSize } from "../../infra/map-size.js";
import type { PluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.types.js";
import { matchesSkillFilter } from "../discovery/filter.js";
import {
loadMergedWorkspaceSkills,
@@ -33,6 +34,7 @@ type ReusableSkillSnapshotParams = {
snapshotVersion?: number;
watch?: boolean;
hydrateExisting?: boolean;
pluginMetadataSnapshot?: PluginMetadataSnapshot;
};
type ReusableSkillSnapshotResult = {
@@ -108,6 +110,7 @@ export function resolveReusableWorkspaceSkillSnapshot(
skillFilter: params.skillFilter,
skillOverrides: params.skillOverrides,
eligibility: params.eligibility,
pluginMetadataSnapshot: params.pluginMetadataSnapshot,
snapshotVersion,
});
return skillRoots ? { ...snapshot, skillRoots } : snapshot;