mirror of https://github.com/openclaw/openclaw.git
fix: harden workspace-aware HTTP tool loading
This commit is contained in:
parent
3d11ea33ab
commit
f3006d77f7
|
|
@ -106,6 +106,7 @@ Docs: https://docs.openclaw.ai
|
|||
- Plugins/uninstall: remove owned `channels.<id>` config when uninstalling channel plugins, and keep the uninstall preview aligned with explicit channel ownership so built-in channels and shared keys stay intact. (#35915) Thanks @wbxl2000.
|
||||
- Plugins/Matrix: prefer explicit DM signals when choosing outbound direct rooms and routing unmapped verification summaries, so strict 2-person fallback rooms do not outrank the real DM. (#56076) thanks @gumadeiras
|
||||
- Microsoft Teams/proactive DMs: prefer the freshest personal conversation reference for `user:<aadObjectId>` sends when multiple stored references exist, so replies stop targeting stale DM threads. (#54702) Thanks @gumclaw.
|
||||
- Gateway/plugins: reuse the session workspace when building HTTP `/tools/invoke` tool lists and harden tool construction to infer the session agent workspace by default, so workspace plugins do not re-register on repeated HTTP tool calls. (#56101) thanks @neeravmakwana
|
||||
|
||||
## 2026.3.24
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import path from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AnyAgentTool } from "./tools/common.js";
|
||||
|
||||
|
|
@ -59,6 +60,53 @@ describe("createOpenClawTools plugin context", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("infers the default agent workspace for plugin tools when workspaceDir is omitted", () => {
|
||||
const workspaceDir = path.join(process.cwd(), "tmp-main-workspace");
|
||||
createOpenClawTools({
|
||||
config: {
|
||||
agents: {
|
||||
defaults: { workspace: workspaceDir },
|
||||
list: [{ id: "main", default: true }],
|
||||
},
|
||||
} as never,
|
||||
agentSessionKey: "main",
|
||||
});
|
||||
|
||||
expect(resolvePluginToolsMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
context: expect.objectContaining({
|
||||
agentId: "main",
|
||||
workspaceDir,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("infers the session agent workspace for plugin tools when workspaceDir is omitted", () => {
|
||||
const supportWorkspace = path.join(process.cwd(), "tmp-support-workspace");
|
||||
createOpenClawTools({
|
||||
config: {
|
||||
agents: {
|
||||
defaults: { workspace: path.join(process.cwd(), "tmp-default-workspace") },
|
||||
list: [
|
||||
{ id: "main", default: true },
|
||||
{ id: "support", workspace: supportWorkspace },
|
||||
],
|
||||
},
|
||||
} as never,
|
||||
agentSessionKey: "agent:support:main",
|
||||
});
|
||||
|
||||
expect(resolvePluginToolsMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
context: expect.objectContaining({
|
||||
agentId: "support",
|
||||
workspaceDir: supportWorkspace,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("forwards browser session wiring to plugin tool context", () => {
|
||||
createOpenClawTools({
|
||||
config: {} as never,
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { resolvePluginTools } from "../plugins/tools.js";
|
|||
import { getActiveRuntimeWebToolsMetadata } from "../secrets/runtime.js";
|
||||
import { normalizeDeliveryContext } from "../utils/delivery-context.js";
|
||||
import type { GatewayMessageChannel } from "../utils/message-channel.js";
|
||||
import { resolveSessionAgentId } from "./agent-scope.js";
|
||||
import { resolveAgentWorkspaceDir, resolveSessionAgentId } from "./agent-scope.js";
|
||||
import { applyPluginToolDeliveryDefaults } from "./plugin-tool-delivery-defaults.js";
|
||||
import type { SandboxFsBridge } from "./sandbox/fs-bridge.js";
|
||||
import type { SpawnedToolContext } from "./spawned-context.js";
|
||||
|
|
@ -99,9 +99,19 @@ export function createOpenClawTools(
|
|||
} & SpawnedToolContext,
|
||||
): AnyAgentTool[] {
|
||||
const resolvedConfig = options?.config ?? openClawToolsDeps.config;
|
||||
const workspaceDir = resolveWorkspaceRoot(options?.workspaceDir);
|
||||
const sessionAgentId = resolveSessionAgentId({
|
||||
sessionKey: options?.agentSessionKey,
|
||||
config: resolvedConfig,
|
||||
});
|
||||
// Fall back to the session agent workspace so plugin loading stays workspace-stable
|
||||
// even when a caller forgets to thread workspaceDir explicitly.
|
||||
const inferredWorkspaceDir =
|
||||
options?.workspaceDir || !resolvedConfig
|
||||
? undefined
|
||||
: resolveAgentWorkspaceDir(resolvedConfig, sessionAgentId);
|
||||
const workspaceDir = resolveWorkspaceRoot(options?.workspaceDir ?? inferredWorkspaceDir);
|
||||
const spawnWorkspaceDir = resolveWorkspaceRoot(
|
||||
options?.spawnWorkspaceDir ?? options?.workspaceDir,
|
||||
options?.spawnWorkspaceDir ?? options?.workspaceDir ?? inferredWorkspaceDir,
|
||||
);
|
||||
const deliveryContext = normalizeDeliveryContext({
|
||||
channel: options?.agentChannel,
|
||||
|
|
@ -251,10 +261,7 @@ export function createOpenClawTools(
|
|||
config: options?.config,
|
||||
workspaceDir,
|
||||
agentDir: options?.agentDir,
|
||||
agentId: resolveSessionAgentId({
|
||||
sessionKey: options?.agentSessionKey,
|
||||
config: options?.config,
|
||||
}),
|
||||
agentId: sessionAgentId,
|
||||
sessionKey: options?.agentSessionKey,
|
||||
sessionId: options?.sessionId,
|
||||
browser: {
|
||||
|
|
|
|||
|
|
@ -28,6 +28,44 @@ function nextGatewayId(prefix: string): string {
|
|||
return `${prefix}-${process.pid}-${process.env.VITEST_POOL_ID ?? "0"}-${gatewayTestSeq++}`;
|
||||
}
|
||||
|
||||
async function writeWorkspacePlugin(params: {
|
||||
workspaceDir: string;
|
||||
id: string;
|
||||
body: string;
|
||||
}): Promise<void> {
|
||||
const pluginDir = path.join(params.workspaceDir, ".openclaw", "extensions", params.id);
|
||||
await fs.mkdir(pluginDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(pluginDir, "openclaw.plugin.json"),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
id: params.id,
|
||||
configSchema: { type: "object", additionalProperties: false, properties: {} },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
await fs.writeFile(path.join(pluginDir, "index.cjs"), params.body, "utf8");
|
||||
}
|
||||
|
||||
async function readCounterWithRetry(filePath: string): Promise<number> {
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
try {
|
||||
const raw = await fs.readFile(filePath, "utf8");
|
||||
const parsed = Number.parseInt(raw.trim(), 10);
|
||||
if (Number.isFinite(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
} catch {
|
||||
// Wait briefly for gateway startup to finish plugin registration.
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
throw new Error(`timed out waiting for counter file: ${filePath}`);
|
||||
}
|
||||
|
||||
describe("gateway e2e", () => {
|
||||
beforeEach(() => {
|
||||
clearRuntimeConfigSnapshot();
|
||||
|
|
@ -151,6 +189,112 @@ describe("gateway e2e", () => {
|
|||
},
|
||||
);
|
||||
|
||||
it(
|
||||
"does not reload workspace plugins when POST /tools/invoke rebuilds tools for the same workspace",
|
||||
{ timeout: GATEWAY_E2E_TIMEOUT_MS },
|
||||
async () => {
|
||||
const envSnapshot = captureEnv([
|
||||
"HOME",
|
||||
"OPENCLAW_STATE_DIR",
|
||||
"OPENCLAW_CONFIG_PATH",
|
||||
"OPENCLAW_GATEWAY_TOKEN",
|
||||
"OPENCLAW_SKIP_CHANNELS",
|
||||
"OPENCLAW_SKIP_GMAIL_WATCHER",
|
||||
"OPENCLAW_SKIP_CRON",
|
||||
"OPENCLAW_SKIP_CANVAS_HOST",
|
||||
"OPENCLAW_SKIP_BROWSER_CONTROL_SERVER",
|
||||
]);
|
||||
|
||||
const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gw-http-tools-home-"));
|
||||
process.env.HOME = tempHome;
|
||||
process.env.OPENCLAW_STATE_DIR = path.join(tempHome, ".openclaw");
|
||||
delete process.env.OPENCLAW_CONFIG_PATH;
|
||||
process.env.OPENCLAW_SKIP_CHANNELS = "1";
|
||||
process.env.OPENCLAW_SKIP_GMAIL_WATCHER = "1";
|
||||
process.env.OPENCLAW_SKIP_CRON = "1";
|
||||
process.env.OPENCLAW_SKIP_CANVAS_HOST = "1";
|
||||
process.env.OPENCLAW_SKIP_BROWSER_CONTROL_SERVER = "1";
|
||||
|
||||
const token = nextGatewayId("http-tools-token");
|
||||
process.env.OPENCLAW_GATEWAY_TOKEN = token;
|
||||
|
||||
const workspaceDir = path.join(tempHome, "openclaw");
|
||||
await fs.mkdir(workspaceDir, { recursive: true });
|
||||
const registerCountPath = path.join(tempHome, "workspace-plugin-register-count.txt");
|
||||
await writeWorkspacePlugin({
|
||||
workspaceDir,
|
||||
id: "http-probe",
|
||||
body: `
|
||||
const fs = require("node:fs");
|
||||
const counterPath = ${JSON.stringify(registerCountPath)};
|
||||
module.exports = {
|
||||
id: "http-probe",
|
||||
register() {
|
||||
const current = fs.existsSync(counterPath)
|
||||
? Number.parseInt(fs.readFileSync(counterPath, "utf8").trim(), 10) || 0
|
||||
: 0;
|
||||
fs.writeFileSync(counterPath, String(current + 1), "utf8");
|
||||
},
|
||||
};
|
||||
`.trimStart(),
|
||||
});
|
||||
|
||||
const configDir = path.join(tempHome, ".openclaw");
|
||||
await fs.mkdir(configDir, { recursive: true });
|
||||
const configPath = path.join(configDir, "openclaw.json");
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: { workspace: workspaceDir },
|
||||
list: [{ id: "main", default: true, tools: { allow: ["agents_list"] } }],
|
||||
},
|
||||
plugins: {
|
||||
allow: ["http-probe"],
|
||||
},
|
||||
gateway: { auth: { token } },
|
||||
};
|
||||
await fs.writeFile(configPath, `${JSON.stringify(cfg, null, 2)}\n`);
|
||||
process.env.OPENCLAW_CONFIG_PATH = configPath;
|
||||
|
||||
const port = await getFreeGatewayPort();
|
||||
const server = await startGatewayServer(port, {
|
||||
bind: "loopback",
|
||||
auth: { mode: "token", token },
|
||||
controlUiEnabled: false,
|
||||
});
|
||||
|
||||
try {
|
||||
const beforeCount = await readCounterWithRetry(registerCountPath);
|
||||
expect(beforeCount).toBeGreaterThan(0);
|
||||
|
||||
const res = await fetch(`http://127.0.0.1:${port}/tools/invoke`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
"content-type": "application/json",
|
||||
connection: "close",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
tool: "agents_list",
|
||||
action: "json",
|
||||
args: {},
|
||||
sessionKey: "main",
|
||||
}),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.ok).toBe(true);
|
||||
|
||||
const afterCount = await readCounterWithRetry(registerCountPath);
|
||||
expect(afterCount).toBe(beforeCount);
|
||||
} finally {
|
||||
await server.close({ reason: "http tools workspace test complete" });
|
||||
await fs.rm(tempHome, { recursive: true, force: true });
|
||||
envSnapshot.restore();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it(
|
||||
"runs wizard over ws and writes auth token config",
|
||||
{ timeout: GATEWAY_E2E_TIMEOUT_MS },
|
||||
|
|
|
|||
Loading…
Reference in New Issue