mirror of
https://github.com/moltbot/moltbot.git
synced 2026-04-23 14:45:46 +00:00
fix(feishu): enforce workspace-only localRoots in docx upload actions [AI-assisted] (#62369)
* fix: address issue * docs(changelog): add feishu workspace-only docx entry --------- Co-authored-by: Devin Robison <drobison@nvidia.com>
This commit is contained in:
@@ -48,6 +48,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Nodes/exec approvals: keep Windows `cmd.exe /c` wrapper runs approval-gated even when `env` carriers, including env-assignment carriers, wrap the shell invocation. (#62439) Thanks @ngutman.
|
||||
- Agents/context overflow: combine oversized and aggregate tool-result recovery in one pass and restore a total-context overflow backstop so recoverable sessions retry instead of failing early. (#61651) Thanks @Takhoffman.
|
||||
- Agents/exec: preserve explicit `host=node` routing under elevated defaults when `tools.exec.host=auto`, fail loud on invalid elevated cross-host overrides, and keep `strictInlineEval` commands blocked after approval timeouts instead of falling through to automatic execution. (#61739) Thanks @obviyus.
|
||||
- Feishu/docx uploads: honor `tools.fs.workspaceOnly` for local `upload_file` and `upload_image` paths by forwarding workspace-constrained `localRoots` into the media loader, so docx uploads can no longer read host-local files outside the workspace when workspace-only mode is active. (#62369) Thanks @pgondhi987.
|
||||
- Providers/Ollama: honor the selected provider's `baseUrl` during streaming so multi-Ollama setups stop routing every stream to the first configured Ollama endpoint. (#61678)
|
||||
- Browser/remote CDP: retry the DevTools websocket once after remote browser restarts so healthy remote browser profiles do not fail availability checks during CDP warm-up. (#57397) Thanks @ThanhNguyxn07.
|
||||
- Gateway/auth: invalidate existing shared-token and password WebSocket sessions when the configured secret rotates, so stale authenticated sockets cannot stay attached after token or password changes. (#62350) Thanks @pgondhi987.
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createToolFactoryHarness, type ToolLike } from "./tool-factory-test-harness.js";
|
||||
|
||||
@@ -471,11 +473,11 @@ describe("feishu_doc image fetch hardening", () => {
|
||||
expect(result.details.file_token).toBe("token_1");
|
||||
expect(result.details.file_name).toBe("test-local.txt");
|
||||
|
||||
// localRoots is not passed — loadWebMedia uses default roots (tmp, media,
|
||||
// workspace, sandboxes) plus workspace-profile auto-discovery.
|
||||
// Without workspace-only policy, localRoots stays undefined so loadWebMedia
|
||||
// applies its default managed-root access behavior.
|
||||
expect(loadWebMediaMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining("test-local.txt"),
|
||||
expect.objectContaining({ optimizeImages: false }),
|
||||
expect.objectContaining({ optimizeImages: false, localRoots: undefined }),
|
||||
);
|
||||
|
||||
expect(driveUploadAllMock).toHaveBeenCalledWith(
|
||||
@@ -489,6 +491,124 @@ describe("feishu_doc image fetch hardening", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("passes workspace localRoots for upload_file when workspace-only policy is active", async () => {
|
||||
blockChildrenCreateMock.mockResolvedValueOnce({
|
||||
code: 0,
|
||||
data: {
|
||||
children: [{ block_type: 23, block_id: "file_block_1" }],
|
||||
},
|
||||
});
|
||||
|
||||
loadWebMediaMock.mockResolvedValueOnce({
|
||||
buffer: Buffer.from("hello from local file", "utf8"),
|
||||
fileName: "test-local.txt",
|
||||
});
|
||||
|
||||
const feishuDocTool = resolveFeishuDocTool({
|
||||
workspaceDir: "/workspace",
|
||||
fsPolicy: { workspaceOnly: true },
|
||||
});
|
||||
|
||||
await executeFeishuDocTool(feishuDocTool, {
|
||||
action: "upload_file",
|
||||
doc_token: "doc_1",
|
||||
file_path: "/tmp/openclaw-1000/test-local.txt",
|
||||
filename: "test-local.txt",
|
||||
});
|
||||
|
||||
expect(loadWebMediaMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining("test-local.txt"),
|
||||
expect.objectContaining({ optimizeImages: false, localRoots: ["/workspace"] }),
|
||||
);
|
||||
});
|
||||
|
||||
it("passes empty localRoots when workspace-only policy is active without workspaceDir", async () => {
|
||||
blockChildrenCreateMock.mockResolvedValueOnce({
|
||||
code: 0,
|
||||
data: {
|
||||
children: [{ block_type: 23, block_id: "file_block_1" }],
|
||||
},
|
||||
});
|
||||
|
||||
loadWebMediaMock.mockResolvedValueOnce({
|
||||
buffer: Buffer.from("hello from local file", "utf8"),
|
||||
fileName: "test-local.txt",
|
||||
});
|
||||
|
||||
const feishuDocTool = resolveFeishuDocTool({
|
||||
fsPolicy: { workspaceOnly: true },
|
||||
});
|
||||
|
||||
await executeFeishuDocTool(feishuDocTool, {
|
||||
action: "upload_file",
|
||||
doc_token: "doc_1",
|
||||
file_path: "/tmp/openclaw-1000/test-local.txt",
|
||||
filename: "test-local.txt",
|
||||
});
|
||||
|
||||
expect(loadWebMediaMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining("test-local.txt"),
|
||||
expect.objectContaining({ optimizeImages: false, localRoots: [] }),
|
||||
);
|
||||
});
|
||||
|
||||
it("passes workspace localRoots for upload_image local paths when workspace-only policy is active", async () => {
|
||||
loadWebMediaMock.mockResolvedValueOnce({
|
||||
buffer: Buffer.from("hello from local file", "utf8"),
|
||||
fileName: "test-local.png",
|
||||
});
|
||||
|
||||
const feishuDocTool = resolveFeishuDocTool({
|
||||
workspaceDir: "/workspace",
|
||||
fsPolicy: { workspaceOnly: true },
|
||||
});
|
||||
|
||||
await executeFeishuDocTool(feishuDocTool, {
|
||||
action: "upload_image",
|
||||
doc_token: "doc_1",
|
||||
image: "./test-local.png",
|
||||
filename: "test-local.png",
|
||||
});
|
||||
|
||||
expect(loadWebMediaMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining("test-local.png"),
|
||||
expect.objectContaining({ optimizeImages: false, localRoots: ["/workspace"] }),
|
||||
);
|
||||
});
|
||||
|
||||
it("passes workspace localRoots for upload_image absolute local paths when workspace-only policy is active", async () => {
|
||||
const fixtureDir = path.join(process.cwd(), ".tmp-docx-upload-image-absolute");
|
||||
const absoluteImagePath = path.join(fixtureDir, "absolute-image.png");
|
||||
mkdirSync(fixtureDir, { recursive: true });
|
||||
writeFileSync(absoluteImagePath, "not-real-image");
|
||||
|
||||
loadWebMediaMock.mockResolvedValueOnce({
|
||||
buffer: Buffer.from("hello from local file", "utf8"),
|
||||
fileName: "absolute-image.png",
|
||||
});
|
||||
|
||||
const feishuDocTool = resolveFeishuDocTool({
|
||||
workspaceDir: "/workspace",
|
||||
fsPolicy: { workspaceOnly: true },
|
||||
});
|
||||
|
||||
try {
|
||||
await executeFeishuDocTool(feishuDocTool, {
|
||||
action: "upload_image",
|
||||
doc_token: "doc_1",
|
||||
image: absoluteImagePath,
|
||||
filename: "absolute-image.png",
|
||||
});
|
||||
|
||||
expect(loadWebMediaMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining("absolute-image.png"),
|
||||
expect.objectContaining({ optimizeImages: false, localRoots: ["/workspace"] }),
|
||||
);
|
||||
} finally {
|
||||
rmSync(fixtureDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("returns an error when upload_file cannot list placeholder siblings", async () => {
|
||||
blockChildrenCreateMock.mockResolvedValueOnce({
|
||||
code: 0,
|
||||
|
||||
@@ -36,6 +36,24 @@ function json(data: unknown) {
|
||||
};
|
||||
}
|
||||
|
||||
function resolveDocToolLocalRoots(ctx: {
|
||||
workspaceDir?: string;
|
||||
fsPolicy?: { workspaceOnly: boolean };
|
||||
}): string[] | undefined {
|
||||
if (ctx.fsPolicy?.workspaceOnly !== true) {
|
||||
return undefined;
|
||||
}
|
||||
const workspaceDir = ctx.workspaceDir?.trim();
|
||||
// Fail closed: workspace-only with no resolved workspace must not fall back
|
||||
// to default managed roots.
|
||||
if (!workspaceDir) {
|
||||
return [];
|
||||
}
|
||||
// Workspace paths are expected to be absolute; resolve() normalizes any
|
||||
// accidental relative input before passing roots to loadWebMedia.
|
||||
return [resolve(workspaceDir)];
|
||||
}
|
||||
|
||||
/** Extract image URLs from markdown content */
|
||||
function extractImageUrls(markdown: string): string[] {
|
||||
const regex = /!\[[^\]]*\]\(([^)]+)\)/g;
|
||||
@@ -520,6 +538,7 @@ async function resolveUploadInput(
|
||||
url: string | undefined,
|
||||
filePath: string | undefined,
|
||||
maxBytes: number,
|
||||
localRoots?: readonly string[],
|
||||
explicitFileName?: string,
|
||||
imageInput?: string, // data URI, plain base64, or local path
|
||||
): Promise<{ buffer: Buffer; fileName: string }> {
|
||||
@@ -585,12 +604,11 @@ async function resolveUploadInput(
|
||||
|
||||
if (unambiguousPath || (absolutePath && existsSync(candidate))) {
|
||||
// Use loadWebMedia to enforce localRoots sandbox (same as sendMediaFeishu).
|
||||
// localRoots left undefined so loadWebMedia uses default roots (tmp, media,
|
||||
// workspace, sandboxes) plus workspace-profile auto-discovery.
|
||||
const resolvedPath = resolve(candidate);
|
||||
const loaded = await getFeishuRuntime().media.loadWebMedia(resolvedPath, {
|
||||
maxBytes,
|
||||
optimizeImages: false,
|
||||
localRoots,
|
||||
});
|
||||
return { buffer: loaded.buffer, fileName: explicitFileName ?? basename(candidate) };
|
||||
}
|
||||
@@ -647,11 +665,11 @@ async function resolveUploadInput(
|
||||
}
|
||||
|
||||
// Use loadWebMedia to enforce localRoots sandbox (same as sendMediaFeishu).
|
||||
// localRoots left undefined — see comment above.
|
||||
const resolvedFilePath = resolve(filePath!);
|
||||
const loaded = await getFeishuRuntime().media.loadWebMedia(resolvedFilePath, {
|
||||
maxBytes,
|
||||
optimizeImages: false,
|
||||
localRoots,
|
||||
});
|
||||
return {
|
||||
buffer: loaded.buffer,
|
||||
@@ -707,6 +725,7 @@ async function uploadImageBlock(
|
||||
client: Lark.Client,
|
||||
docToken: string,
|
||||
maxBytes: number,
|
||||
localRoots?: readonly string[],
|
||||
url?: string,
|
||||
filePath?: string,
|
||||
parentBlockId?: string,
|
||||
@@ -730,7 +749,14 @@ async function uploadImageBlock(
|
||||
}
|
||||
|
||||
// Step 2: Resolve and upload the image buffer.
|
||||
const upload = await resolveUploadInput(url, filePath, maxBytes, filename, imageInput);
|
||||
const upload = await resolveUploadInput(
|
||||
url,
|
||||
filePath,
|
||||
maxBytes,
|
||||
localRoots,
|
||||
filename,
|
||||
imageInput,
|
||||
);
|
||||
const fileToken = await uploadImageToDocx(
|
||||
client,
|
||||
imageBlockId,
|
||||
@@ -761,6 +787,7 @@ async function uploadFileBlock(
|
||||
client: Lark.Client,
|
||||
docToken: string,
|
||||
maxBytes: number,
|
||||
localRoots?: readonly string[],
|
||||
url?: string,
|
||||
filePath?: string,
|
||||
parentBlockId?: string,
|
||||
@@ -771,7 +798,7 @@ async function uploadFileBlock(
|
||||
// Feishu API does not allow creating empty file blocks (block_type 23).
|
||||
// Workaround: create a placeholder text block, then replace it with file content.
|
||||
// Actually, file blocks need a different approach: use markdown link as placeholder.
|
||||
const upload = await resolveUploadInput(url, filePath, maxBytes, filename);
|
||||
const upload = await resolveUploadInput(url, filePath, maxBytes, localRoots, filename);
|
||||
|
||||
// Create a placeholder text block first
|
||||
const placeholderMd = `[${upload.fileName}](https://example.com/placeholder)`;
|
||||
@@ -1393,6 +1420,7 @@ export function registerFeishuDocTools(api: OpenClawPluginApi) {
|
||||
api.registerTool(
|
||||
(ctx) => {
|
||||
const defaultAccountId = ctx.agentAccountId;
|
||||
const mediaLocalRoots = resolveDocToolLocalRoots(ctx);
|
||||
const trustedRequesterOpenId =
|
||||
ctx.messageChannel === "feishu"
|
||||
? normalizeOptionalString(ctx.requesterSenderId)
|
||||
@@ -1489,6 +1517,7 @@ export function registerFeishuDocTools(api: OpenClawPluginApi) {
|
||||
client,
|
||||
p.doc_token,
|
||||
getMediaMaxBytes(p, defaultAccountId),
|
||||
mediaLocalRoots,
|
||||
p.url,
|
||||
p.file_path,
|
||||
p.parent_block_id,
|
||||
@@ -1503,6 +1532,7 @@ export function registerFeishuDocTools(api: OpenClawPluginApi) {
|
||||
client,
|
||||
p.doc_token,
|
||||
getMediaMaxBytes(p, defaultAccountId),
|
||||
mediaLocalRoots,
|
||||
p.url,
|
||||
p.file_path,
|
||||
p.parent_block_id,
|
||||
|
||||
@@ -2,29 +2,18 @@ import type { OpenClawConfig } from "../config/config.js";
|
||||
import { resolvePluginTools } from "../plugins/tools.js";
|
||||
import { getActiveSecretsRuntimeSnapshot } from "../secrets/runtime.js";
|
||||
import { normalizeDeliveryContext } from "../utils/delivery-context.js";
|
||||
import type { GatewayMessageChannel } from "../utils/message-channel.js";
|
||||
import { resolveOpenClawPluginToolInputs } from "./openclaw-tools.plugin-context.js";
|
||||
import {
|
||||
resolveOpenClawPluginToolInputs,
|
||||
type OpenClawPluginToolOptions,
|
||||
} from "./openclaw-tools.plugin-context.js";
|
||||
import { applyPluginToolDeliveryDefaults } from "./plugin-tool-delivery-defaults.js";
|
||||
import type { AnyAgentTool } from "./tools/common.js";
|
||||
|
||||
type ResolveOpenClawPluginToolsOptions = {
|
||||
config?: OpenClawConfig;
|
||||
type ResolveOpenClawPluginToolsOptions = OpenClawPluginToolOptions & {
|
||||
pluginToolAllowlist?: string[];
|
||||
agentChannel?: GatewayMessageChannel;
|
||||
agentAccountId?: string;
|
||||
agentTo?: string;
|
||||
agentThreadId?: string | number;
|
||||
requesterSenderId?: string | null;
|
||||
senderIsOwner?: boolean;
|
||||
allowGatewaySubagentBinding?: boolean;
|
||||
sandboxed?: boolean;
|
||||
agentSessionKey?: string;
|
||||
sessionId?: string;
|
||||
currentChannelId?: string;
|
||||
currentThreadTs?: string;
|
||||
currentMessageId?: string | number;
|
||||
workspaceDir?: string;
|
||||
agentDir?: string;
|
||||
sandboxRoot?: string;
|
||||
modelHasVision?: boolean;
|
||||
modelProvider?: string;
|
||||
|
||||
@@ -67,4 +67,54 @@ describe("createOpenClawTools browser plugin integration", () => {
|
||||
|
||||
expect(tools.map((tool) => tool.name)).not.toContain("browser");
|
||||
});
|
||||
|
||||
it("forwards fsPolicy into plugin tool context", async () => {
|
||||
let capturedContext: { fsPolicy?: { workspaceOnly: boolean } } | undefined;
|
||||
hoisted.resolvePluginTools.mockImplementation((params: unknown) => {
|
||||
const resolvedParams = params as { context?: { fsPolicy?: { workspaceOnly: boolean } } };
|
||||
capturedContext = resolvedParams.context;
|
||||
return [
|
||||
{
|
||||
name: "browser",
|
||||
description: "browser fixture tool",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
},
|
||||
async execute() {
|
||||
return {
|
||||
content: [{ type: "text", text: "ok" }],
|
||||
details: { workspaceOnly: capturedContext?.fsPolicy?.workspaceOnly ?? null },
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const tools = resolveOpenClawPluginToolsForOptions({
|
||||
options: {
|
||||
config: {
|
||||
plugins: {
|
||||
allow: ["browser"],
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
fsPolicy: { workspaceOnly: true },
|
||||
},
|
||||
resolvedConfig: {
|
||||
plugins: {
|
||||
allow: ["browser"],
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
});
|
||||
|
||||
const browserTool = tools.find((tool) => tool.name === "browser");
|
||||
expect(browserTool).toBeDefined();
|
||||
if (!browserTool) {
|
||||
throw new Error("expected browser tool");
|
||||
}
|
||||
|
||||
const result = await browserTool.execute("tool-call", {});
|
||||
const details = (result.details ?? {}) as { workspaceOnly?: boolean | null };
|
||||
expect(details.workspaceOnly).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,6 +22,21 @@ describe("openclaw plugin tool context", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("forwards fs policy for plugin tool sandbox enforcement", () => {
|
||||
const result = resolveOpenClawPluginToolInputs({
|
||||
options: {
|
||||
config: {} as never,
|
||||
fsPolicy: { workspaceOnly: true },
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.context).toEqual(
|
||||
expect.objectContaining({
|
||||
fsPolicy: { workspaceOnly: true },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("forwards ephemeral sessionId", () => {
|
||||
const result = resolveOpenClawPluginToolInputs({
|
||||
options: {
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { OpenClawConfig } from "../config/config.js";
|
||||
import { normalizeDeliveryContext } from "../utils/delivery-context.js";
|
||||
import type { GatewayMessageChannel } from "../utils/message-channel.js";
|
||||
import { resolveAgentWorkspaceDir, resolveSessionAgentId } from "./agent-scope.js";
|
||||
import type { ToolFsPolicy } from "./tool-fs-policy.js";
|
||||
import { resolveWorkspaceRoot } from "./workspace-dir.js";
|
||||
|
||||
export type OpenClawPluginToolOptions = {
|
||||
@@ -13,6 +14,7 @@ export type OpenClawPluginToolOptions = {
|
||||
agentDir?: string;
|
||||
workspaceDir?: string;
|
||||
config?: OpenClawConfig;
|
||||
fsPolicy?: ToolFsPolicy;
|
||||
requesterSenderId?: string | null;
|
||||
senderIsOwner?: boolean;
|
||||
sessionId?: string;
|
||||
@@ -48,6 +50,7 @@ export function resolveOpenClawPluginToolInputs(params: {
|
||||
context: {
|
||||
config: options?.config,
|
||||
runtimeConfig,
|
||||
fsPolicy: options?.fsPolicy,
|
||||
workspaceDir,
|
||||
agentDir: options?.agentDir,
|
||||
agentId: sessionAgentId,
|
||||
|
||||
@@ -15,6 +15,7 @@ import type { FailoverReason } from "../agents/pi-embedded-helpers/types.js";
|
||||
import type { ProviderRequestTransportOverrides } from "../agents/provider-request-config.js";
|
||||
import type { ProviderSystemPromptContribution } from "../agents/system-prompt-contribution.js";
|
||||
import type { PromptMode } from "../agents/system-prompt.js";
|
||||
import type { ToolFsPolicy } from "../agents/tool-fs-policy.js";
|
||||
import type { AnyAgentTool } from "../agents/tools/common.js";
|
||||
import type { ReplyDispatchKind, ReplyDispatcher } from "../auto-reply/reply/reply-dispatcher.js";
|
||||
import type { FinalizedMsgContext } from "../auto-reply/templating.js";
|
||||
@@ -141,6 +142,8 @@ export type OpenClawPluginToolContext = {
|
||||
config?: OpenClawConfig;
|
||||
/** Active runtime-resolved config snapshot when one is available. */
|
||||
runtimeConfig?: OpenClawConfig;
|
||||
/** Effective filesystem policy for the active tool run. */
|
||||
fsPolicy?: ToolFsPolicy;
|
||||
workspaceDir?: string;
|
||||
agentDir?: string;
|
||||
agentId?: string;
|
||||
|
||||
@@ -22,7 +22,7 @@ const BROWSER_FIXTURE_ENTRY = `module.exports = {
|
||||
properties: {},
|
||||
},
|
||||
register(api) {
|
||||
api.registerTool(() => ({
|
||||
api.registerTool((ctx) => ({
|
||||
name: "browser",
|
||||
label: "browser",
|
||||
description: "browser fixture tool",
|
||||
@@ -33,7 +33,9 @@ const BROWSER_FIXTURE_ENTRY = `module.exports = {
|
||||
async execute() {
|
||||
return {
|
||||
content: [{ type: "text", text: "ok" }],
|
||||
details: {},
|
||||
details: {
|
||||
workspaceOnly: ctx.fsPolicy?.workspaceOnly ?? null,
|
||||
},
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user