mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 13:00:44 +00:00
fix(agents): tighten workspace file opens (#66636)
* fix(agents): tighten workspace file opens * fix(agents): clarify symlink rejection tests * fix(agents): surface unsafe identity reads * fix(agents): use non-blocking opens for identity reads and write-mode probes * fix(fssafe): restore symlink read identity check * fix(worklog): append comment resolution status * fix(fssafe): close afterOpen handle leaks * fix(worklog): append comment resolution follow-up * fix(worklog): drop internal user file * fix(agents): rethrow unexpected errors in agents.files.get * changelog: note agents.files fs-safe routing + fd-first realpath (#66636) * fix(agents): rethrow unexpected errors in agents.files.set too Match the narrow-SafeOpenError catch pattern that agents.files.get (commit 633b8f92) and writeWorkspaceFileOrRespond already use, so a real OS error (ENOSPC, EACCES, EBUSY, ...) surfaces through normal gateway error handling instead of being masked as 'unsafe workspace file'. * test(agents): match fsStat/fsLstat mock signatures The mock functions are declared as vi.fn(async (..._args: unknown[]) => Stats | null) so mockImplementation callbacks must accept ...unknown[], not a narrowed (filePath: string) argument. The narrower signature works at runtime but trips tsgo's strict type check; switch to args[0] unpacking so the callbacks match the hoisted mock shape. --------- Co-authored-by: Devin Robison <drobison@nvidia.com>
This commit is contained in:
@@ -25,6 +25,7 @@ Docs: https://docs.openclaw.ai
|
|||||||
- Onboarding/channels: normalize channel setup metadata before discovery and validation so malformed or mixed-shape channel plugin metadata no longer breaks setup and onboarding channel lists. (#66706) Thanks @darkamenosa.
|
- Onboarding/channels: normalize channel setup metadata before discovery and validation so malformed or mixed-shape channel plugin metadata no longer breaks setup and onboarding channel lists. (#66706) Thanks @darkamenosa.
|
||||||
- Slack/native commands: fix option menus for slash commands such as `/verbose` when Slack renders native buttons by giving each button a unique action ID while still routing them through the shared `openclaw_cmdarg*` listener. Thanks @Wangmerlyn.
|
- Slack/native commands: fix option menus for slash commands such as `/verbose` when Slack renders native buttons by giving each button a unique action ID while still routing them through the shared `openclaw_cmdarg*` listener. Thanks @Wangmerlyn.
|
||||||
- Feishu/webhook: harden the webhook transport and card-action replay guards to fail closed on missing `encryptKey` and blank callback tokens — refuse to start the webhook transport without an `encryptKey`, reject unsigned requests when no key is present instead of accepting them, and drop blank card-action tokens before the dedupe claim and dispatcher. Defense-in-depth over the already-closed monitor-account layer. (#66707) Thanks @eleqtrizit.
|
- Feishu/webhook: harden the webhook transport and card-action replay guards to fail closed on missing `encryptKey` and blank callback tokens — refuse to start the webhook transport without an `encryptKey`, reject unsigned requests when no key is present instead of accepting them, and drop blank card-action tokens before the dedupe claim and dispatcher. Defense-in-depth over the already-closed monitor-account layer. (#66707) Thanks @eleqtrizit.
|
||||||
|
- Agents/workspace files: route `agents.files.get`, `agents.files.set`, and workspace listing through the shared `fs-safe` helpers (`openFileWithinRoot`/`readFileWithinRoot`/`writeFileWithinRoot`), reject symlink aliases for allowlisted agent files, and have `fs-safe` resolve opened-file real paths from the file descriptor before falling back to path-based `realpath` so a symlink swap between `open` and `realpath` can no longer redirect the validated path off the intended inode. (#66636) Thanks @eleqtrizit.
|
||||||
|
|
||||||
## 2026.4.14
|
## 2026.4.14
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import path from "node:path";
|
|
||||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||||
|
import { SafeOpenError } from "../../infra/fs-safe.js";
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
/* Mocks */
|
/* Mocks */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
@@ -499,9 +499,8 @@ describe("agents.create", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("does not persist config when IDENTITY.md write fails with SafeOpenError", async () => {
|
it("does not persist config when IDENTITY.md write fails with SafeOpenError", async () => {
|
||||||
const { SafeOpenError: SOE } = await import("../../infra/fs-safe.js");
|
|
||||||
mocks.writeFileWithinRoot.mockRejectedValueOnce(
|
mocks.writeFileWithinRoot.mockRejectedValueOnce(
|
||||||
new SOE("path-mismatch", "path escapes workspace root"),
|
new SafeOpenError("path-mismatch", "path escapes workspace root"),
|
||||||
);
|
);
|
||||||
|
|
||||||
const { respond, promise } = makeCall("agents.create", {
|
const { respond, promise } = makeCall("agents.create", {
|
||||||
@@ -520,24 +519,7 @@ describe("agents.create", () => {
|
|||||||
|
|
||||||
it("does not persist config when IDENTITY.md read fails", async () => {
|
it("does not persist config when IDENTITY.md read fails", async () => {
|
||||||
agentsTesting.setDepsForTests({
|
agentsTesting.setDepsForTests({
|
||||||
resolveAgentWorkspaceFilePath: async ({ workspaceDir, name }) => {
|
readFileWithinRoot: async () => {
|
||||||
const ioPath = `${workspaceDir}/${name}`;
|
|
||||||
if (workspaceDir === "/resolved/tmp/ws") {
|
|
||||||
return {
|
|
||||||
kind: "ready",
|
|
||||||
requestPath: ioPath,
|
|
||||||
ioPath,
|
|
||||||
workspaceReal: workspaceDir,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
kind: "missing",
|
|
||||||
requestPath: ioPath,
|
|
||||||
ioPath,
|
|
||||||
workspaceReal: workspaceDir,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
readLocalFileSafely: async () => {
|
|
||||||
throw createErrnoError("EACCES");
|
throw createErrnoError("EACCES");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -556,6 +538,50 @@ describe("agents.create", () => {
|
|||||||
expect(mocks.writeFileWithinRoot).not.toHaveBeenCalled();
|
expect(mocks.writeFileWithinRoot).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("treats unsafe IDENTITY.md reads as invalid create requests", async () => {
|
||||||
|
agentsTesting.setDepsForTests({
|
||||||
|
readFileWithinRoot: async () => {
|
||||||
|
throw new SafeOpenError("invalid-path", "path is not a regular file under root");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { respond, promise } = makeCall("agents.create", {
|
||||||
|
name: "Unsafe Identity Read",
|
||||||
|
workspace: "/tmp/ws",
|
||||||
|
});
|
||||||
|
await promise;
|
||||||
|
|
||||||
|
expect(respond).toHaveBeenCalledWith(
|
||||||
|
false,
|
||||||
|
undefined,
|
||||||
|
expect.objectContaining({
|
||||||
|
message: expect.stringContaining('unsafe workspace file "IDENTITY.md"'),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(mocks.writeConfigFile).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.writeFileWithinRoot).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses non-blocking reads for IDENTITY.md during agents.create", async () => {
|
||||||
|
const readFileWithinRoot = vi.fn(async () => {
|
||||||
|
throw new SafeOpenError("not-found", "file not found");
|
||||||
|
});
|
||||||
|
agentsTesting.setDepsForTests({ readFileWithinRoot });
|
||||||
|
|
||||||
|
const { promise } = makeCall("agents.create", {
|
||||||
|
name: "NB Agent",
|
||||||
|
workspace: "/tmp/ws",
|
||||||
|
});
|
||||||
|
await promise;
|
||||||
|
|
||||||
|
expect(readFileWithinRoot).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
relativePath: "IDENTITY.md",
|
||||||
|
nonBlockingRead: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("passes model to applyAgentConfig when provided", async () => {
|
it("passes model to applyAgentConfig when provided", async () => {
|
||||||
const { respond, promise } = makeCall("agents.create", {
|
const { respond, promise } = makeCall("agents.create", {
|
||||||
name: "Model Agent",
|
name: "Model Agent",
|
||||||
@@ -728,27 +754,8 @@ describe("agents.update", () => {
|
|||||||
identityPathCreated: true,
|
identityPathCreated: true,
|
||||||
});
|
});
|
||||||
agentsTesting.setDepsForTests({
|
agentsTesting.setDepsForTests({
|
||||||
resolveAgentWorkspaceFilePath: async ({ workspaceDir, name }) => {
|
readFileWithinRoot: async ({ rootDir, relativePath }) => {
|
||||||
const ioPath = `${workspaceDir}/${name}`;
|
const filePath = `${rootDir}/${relativePath}`;
|
||||||
if (
|
|
||||||
workspaceDir === "/workspace/test-agent" ||
|
|
||||||
workspaceDir === "/resolved/new/workspace"
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
kind: "ready",
|
|
||||||
requestPath: ioPath,
|
|
||||||
ioPath,
|
|
||||||
workspaceReal: workspaceDir,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
kind: "missing",
|
|
||||||
requestPath: ioPath,
|
|
||||||
ioPath,
|
|
||||||
workspaceReal: workspaceDir,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
readLocalFileSafely: async ({ filePath }) => {
|
|
||||||
if (filePath === "/workspace/test-agent/IDENTITY.md") {
|
if (filePath === "/workspace/test-agent/IDENTITY.md") {
|
||||||
return {
|
return {
|
||||||
buffer: Buffer.from(
|
buffer: Buffer.from(
|
||||||
@@ -825,27 +832,8 @@ describe("agents.update", () => {
|
|||||||
identityPathCreated: false,
|
identityPathCreated: false,
|
||||||
});
|
});
|
||||||
agentsTesting.setDepsForTests({
|
agentsTesting.setDepsForTests({
|
||||||
resolveAgentWorkspaceFilePath: async ({ workspaceDir, name }) => {
|
readFileWithinRoot: async ({ rootDir, relativePath }) => {
|
||||||
const ioPath = `${workspaceDir}/${name}`;
|
const filePath = `${rootDir}/${relativePath}`;
|
||||||
if (
|
|
||||||
workspaceDir === "/workspace/test-agent" ||
|
|
||||||
workspaceDir === "/resolved/new/workspace"
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
kind: "ready",
|
|
||||||
requestPath: ioPath,
|
|
||||||
ioPath,
|
|
||||||
workspaceReal: workspaceDir,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
kind: "missing",
|
|
||||||
requestPath: ioPath,
|
|
||||||
ioPath,
|
|
||||||
workspaceReal: workspaceDir,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
readLocalFileSafely: async ({ filePath }) => {
|
|
||||||
if (filePath === "/workspace/test-agent/IDENTITY.md") {
|
if (filePath === "/workspace/test-agent/IDENTITY.md") {
|
||||||
return {
|
return {
|
||||||
buffer: Buffer.from(
|
buffer: Buffer.from(
|
||||||
@@ -915,9 +903,8 @@ describe("agents.update", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("does not persist config when IDENTITY.md write fails on update", async () => {
|
it("does not persist config when IDENTITY.md write fails on update", async () => {
|
||||||
const { SafeOpenError: SOE } = await import("../../infra/fs-safe.js");
|
|
||||||
mocks.writeFileWithinRoot.mockRejectedValueOnce(
|
mocks.writeFileWithinRoot.mockRejectedValueOnce(
|
||||||
new SOE("path-mismatch", "path escapes workspace root"),
|
new SafeOpenError("path-mismatch", "path escapes workspace root"),
|
||||||
);
|
);
|
||||||
|
|
||||||
const { respond, promise } = makeCall("agents.update", {
|
const { respond, promise } = makeCall("agents.update", {
|
||||||
@@ -934,6 +921,50 @@ describe("agents.update", () => {
|
|||||||
);
|
);
|
||||||
expect(mocks.writeConfigFile).not.toHaveBeenCalled();
|
expect(mocks.writeConfigFile).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("treats unsafe IDENTITY.md reads as invalid update requests", async () => {
|
||||||
|
agentsTesting.setDepsForTests({
|
||||||
|
readFileWithinRoot: async () => {
|
||||||
|
throw new SafeOpenError("invalid-path", "path is not a regular file under root");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { respond, promise } = makeCall("agents.update", {
|
||||||
|
agentId: "test-agent",
|
||||||
|
avatar: "https://example.com/unsafe.png",
|
||||||
|
});
|
||||||
|
await promise;
|
||||||
|
|
||||||
|
expect(respond).toHaveBeenCalledWith(
|
||||||
|
false,
|
||||||
|
undefined,
|
||||||
|
expect.objectContaining({
|
||||||
|
message: expect.stringContaining('unsafe workspace file "IDENTITY.md"'),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(mocks.writeConfigFile).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.writeFileWithinRoot).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses non-blocking reads for IDENTITY.md during agents.update", async () => {
|
||||||
|
const readFileWithinRoot = vi.fn(async () => {
|
||||||
|
throw new SafeOpenError("not-found", "file not found");
|
||||||
|
});
|
||||||
|
agentsTesting.setDepsForTests({ readFileWithinRoot });
|
||||||
|
|
||||||
|
const { promise } = makeCall("agents.update", {
|
||||||
|
agentId: "test-agent",
|
||||||
|
name: "Updated NB",
|
||||||
|
});
|
||||||
|
await promise;
|
||||||
|
|
||||||
|
expect(readFileWithinRoot).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
relativePath: "IDENTITY.md",
|
||||||
|
nonBlockingRead: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("agents.delete", () => {
|
describe("agents.delete", () => {
|
||||||
@@ -1044,6 +1075,40 @@ describe("agents.files.list", () => {
|
|||||||
const names = await listAgentFileNames();
|
const names = await listAgentFileNames();
|
||||||
expect(names).toContain("BOOTSTRAP.md");
|
expect(names).toContain("BOOTSTRAP.md");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reports unreadable workspace files as present in list responses", async () => {
|
||||||
|
const openFileWithinRoot = vi.fn(async () => {
|
||||||
|
throw createErrnoError("EACCES");
|
||||||
|
});
|
||||||
|
agentsTesting.setDepsForTests({ openFileWithinRoot });
|
||||||
|
mocks.fsLstat.mockImplementation(async (...args: unknown[]) => {
|
||||||
|
if (args[0] === "/workspace/main/AGENTS.md") {
|
||||||
|
return makeFileStat({ size: 17, mtimeMs: 4567 });
|
||||||
|
}
|
||||||
|
throw createEnoentError();
|
||||||
|
});
|
||||||
|
mocks.fsStat.mockImplementation(async (...args: unknown[]) => {
|
||||||
|
if (args[0] === "/workspace/main/AGENTS.md") {
|
||||||
|
return makeFileStat({ size: 17, mtimeMs: 4567 });
|
||||||
|
}
|
||||||
|
throw createEnoentError();
|
||||||
|
});
|
||||||
|
|
||||||
|
const { respond, promise } = makeCall("agents.files.list", { agentId: "main" });
|
||||||
|
await promise;
|
||||||
|
|
||||||
|
const [, result] = respond.mock.calls[0] ?? [];
|
||||||
|
const files = (result as { files: Array<{ name: string; missing: boolean; size?: number }> })
|
||||||
|
.files;
|
||||||
|
expect(files).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
name: "AGENTS.md",
|
||||||
|
missing: false,
|
||||||
|
size: 17,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(openFileWithinRoot).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("agents.files.get/set symlink safety", () => {
|
describe("agents.files.get/set symlink safety", () => {
|
||||||
@@ -1058,14 +1123,32 @@ describe("agents.files.get/set symlink safety", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
function mockWorkspaceEscapeSymlink() {
|
function mockWorkspaceEscapeSymlink() {
|
||||||
const workspace = "/workspace/test-agent";
|
const safeOpenError = new SafeOpenError("invalid-path", "path escapes workspace root");
|
||||||
agentsTesting.setDepsForTests({
|
agentsTesting.setDepsForTests({
|
||||||
resolveAgentWorkspaceFilePath: async ({ name }) => ({
|
openFileWithinRoot: async () => {
|
||||||
kind: "invalid",
|
throw safeOpenError;
|
||||||
requestPath: path.join(workspace, name),
|
},
|
||||||
reason: "path escapes workspace root",
|
readFileWithinRoot: async () => {
|
||||||
}),
|
throw safeOpenError;
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
mocks.writeFileWithinRoot.mockRejectedValue(safeOpenError);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockInWorkspaceSymlinkAlias() {
|
||||||
|
const safeOpenError = new SafeOpenError(
|
||||||
|
"invalid-path",
|
||||||
|
"path is not a regular file under root",
|
||||||
|
);
|
||||||
|
agentsTesting.setDepsForTests({
|
||||||
|
openFileWithinRoot: async () => {
|
||||||
|
throw safeOpenError;
|
||||||
|
},
|
||||||
|
readFileWithinRoot: async () => {
|
||||||
|
throw safeOpenError;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
mocks.writeFileWithinRoot.mockRejectedValue(safeOpenError);
|
||||||
}
|
}
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
@@ -1082,83 +1165,25 @@ describe("agents.files.get/set symlink safety", () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
it("allows in-workspace symlink reads and writes through symlink aliases", async () => {
|
it.each(["agents.files.get", "agents.files.set"] as const)(
|
||||||
const workspace = "/workspace/test-agent";
|
"rejects %s when allowlisted file is an in-workspace symlink alias",
|
||||||
const target = path.resolve(workspace, "policies", "AGENTS.md");
|
async (method) => {
|
||||||
const targetStat = makeFileStat({ size: 7, mtimeMs: 1700, dev: 9, ino: 42 });
|
mockInWorkspaceSymlinkAlias();
|
||||||
|
await expectUnsafeWorkspaceFile(method);
|
||||||
agentsTesting.setDepsForTests({
|
},
|
||||||
readLocalFileSafely: async () => ({
|
|
||||||
buffer: Buffer.from("inside\n"),
|
|
||||||
realPath: target,
|
|
||||||
stat: targetStat,
|
|
||||||
}),
|
|
||||||
resolveAgentWorkspaceFilePath: async ({ name }) => ({
|
|
||||||
kind: "ready",
|
|
||||||
requestPath: path.join(workspace, name),
|
|
||||||
ioPath: target,
|
|
||||||
workspaceReal: workspace,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
mocks.fsLstat.mockImplementation(async (...args: unknown[]) => {
|
|
||||||
const p = typeof args[0] === "string" ? args[0] : "";
|
|
||||||
if (p === target) {
|
|
||||||
return targetStat;
|
|
||||||
}
|
|
||||||
throw createEnoentError();
|
|
||||||
});
|
|
||||||
mocks.fsStat.mockImplementation(async (...args: unknown[]) => {
|
|
||||||
const p = typeof args[0] === "string" ? args[0] : "";
|
|
||||||
if (p === target) {
|
|
||||||
return targetStat;
|
|
||||||
}
|
|
||||||
throw createEnoentError();
|
|
||||||
});
|
|
||||||
|
|
||||||
const getCall = makeCall("agents.files.get", { agentId: "main", name: "AGENTS.md" });
|
|
||||||
await getCall.promise;
|
|
||||||
expect(getCall.respond).toHaveBeenCalledWith(
|
|
||||||
true,
|
|
||||||
expect.objectContaining({
|
|
||||||
file: expect.objectContaining({ missing: false, content: "inside\n" }),
|
|
||||||
}),
|
|
||||||
undefined,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const setCall = makeCall("agents.files.set", {
|
|
||||||
agentId: "main",
|
|
||||||
name: "AGENTS.md",
|
|
||||||
content: "updated\n",
|
|
||||||
});
|
|
||||||
await setCall.promise;
|
|
||||||
expect(setCall.respond).toHaveBeenCalledWith(
|
|
||||||
true,
|
|
||||||
expect.objectContaining({
|
|
||||||
file: expect.objectContaining({
|
|
||||||
missing: false,
|
|
||||||
content: "updated\n",
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
undefined,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
function mockHardlinkedWorkspaceAlias() {
|
function mockHardlinkedWorkspaceAlias() {
|
||||||
const workspace = "/workspace/test-agent";
|
const safeOpenError = new SafeOpenError("invalid-path", "hardlinked path not allowed");
|
||||||
const candidate = path.resolve(workspace, "AGENTS.md");
|
agentsTesting.setDepsForTests({
|
||||||
mocks.fsRealpath.mockImplementation(async (p: string) => {
|
openFileWithinRoot: async () => {
|
||||||
if (p === workspace) {
|
throw safeOpenError;
|
||||||
return workspace;
|
},
|
||||||
}
|
readFileWithinRoot: async () => {
|
||||||
return p;
|
throw safeOpenError;
|
||||||
});
|
},
|
||||||
mocks.fsLstat.mockImplementation(async (...args: unknown[]) => {
|
|
||||||
const p = typeof args[0] === "string" ? args[0] : "";
|
|
||||||
if (p === candidate) {
|
|
||||||
return makeFileStat({ nlink: 2 });
|
|
||||||
}
|
|
||||||
throw createEnoentError();
|
|
||||||
});
|
});
|
||||||
|
mocks.writeFileWithinRoot.mockRejectedValue(safeOpenError);
|
||||||
}
|
}
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
@@ -1174,4 +1199,38 @@ describe("agents.files.get/set symlink safety", () => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
it("uses non-blocking safe reads for agents.files.get", async () => {
|
||||||
|
const readFileWithinRoot = vi.fn(async () => ({
|
||||||
|
buffer: Buffer.from("hello"),
|
||||||
|
realPath: "/workspace/test-agent/AGENTS.md",
|
||||||
|
stat: makeFileStat({ size: 5 }),
|
||||||
|
}));
|
||||||
|
agentsTesting.setDepsForTests({ readFileWithinRoot });
|
||||||
|
|
||||||
|
const { respond, promise } = makeCall("agents.files.get", {
|
||||||
|
agentId: "main",
|
||||||
|
name: "AGENTS.md",
|
||||||
|
});
|
||||||
|
await promise;
|
||||||
|
|
||||||
|
expect(readFileWithinRoot).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
rootDir: "/workspace/test-agent",
|
||||||
|
relativePath: "AGENTS.md",
|
||||||
|
rejectHardlinks: true,
|
||||||
|
nonBlockingRead: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(respond).toHaveBeenCalledWith(
|
||||||
|
true,
|
||||||
|
expect.objectContaining({
|
||||||
|
file: expect.objectContaining({
|
||||||
|
name: "AGENTS.md",
|
||||||
|
content: "hello",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -31,9 +31,12 @@ import { resolveSessionTranscriptsDirForAgent } from "../../config/sessions/path
|
|||||||
import type { IdentityConfig } from "../../config/types.base.js";
|
import type { IdentityConfig } from "../../config/types.base.js";
|
||||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||||
import { sameFileIdentity } from "../../infra/file-identity.js";
|
import { sameFileIdentity } from "../../infra/file-identity.js";
|
||||||
import { SafeOpenError, readLocalFileSafely, writeFileWithinRoot } from "../../infra/fs-safe.js";
|
import {
|
||||||
import { assertNoPathAliasEscape } from "../../infra/path-alias-guards.js";
|
openFileWithinRoot,
|
||||||
import { isNotFoundPathError } from "../../infra/path-guards.js";
|
readFileWithinRoot,
|
||||||
|
SafeOpenError,
|
||||||
|
writeFileWithinRoot,
|
||||||
|
} from "../../infra/fs-safe.js";
|
||||||
import { movePathToTrash } from "../../plugin-sdk/browser-maintenance.js";
|
import { movePathToTrash } from "../../plugin-sdk/browser-maintenance.js";
|
||||||
import { DEFAULT_AGENT_ID, normalizeAgentId } from "../../routing/session-key.js";
|
import { DEFAULT_AGENT_ID, normalizeAgentId } from "../../routing/session-key.js";
|
||||||
import { resolveUserPath } from "../../utils.js";
|
import { resolveUserPath } from "../../utils.js";
|
||||||
@@ -67,8 +70,8 @@ const BOOTSTRAP_FILE_NAMES_POST_ONBOARDING = BOOTSTRAP_FILE_NAMES.filter(
|
|||||||
|
|
||||||
const agentsHandlerDeps = {
|
const agentsHandlerDeps = {
|
||||||
isWorkspaceSetupCompleted,
|
isWorkspaceSetupCompleted,
|
||||||
readLocalFileSafely,
|
openFileWithinRoot,
|
||||||
resolveAgentWorkspaceFilePath,
|
readFileWithinRoot,
|
||||||
writeFileWithinRoot,
|
writeFileWithinRoot,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -76,8 +79,8 @@ export const __testing = {
|
|||||||
setDepsForTests(
|
setDepsForTests(
|
||||||
overrides: Partial<{
|
overrides: Partial<{
|
||||||
isWorkspaceSetupCompleted: typeof isWorkspaceSetupCompleted;
|
isWorkspaceSetupCompleted: typeof isWorkspaceSetupCompleted;
|
||||||
readLocalFileSafely: typeof readLocalFileSafely;
|
openFileWithinRoot: typeof openFileWithinRoot;
|
||||||
resolveAgentWorkspaceFilePath: typeof resolveAgentWorkspaceFilePath;
|
readFileWithinRoot: typeof readFileWithinRoot;
|
||||||
writeFileWithinRoot: typeof writeFileWithinRoot;
|
writeFileWithinRoot: typeof writeFileWithinRoot;
|
||||||
}>,
|
}>,
|
||||||
) {
|
) {
|
||||||
@@ -85,8 +88,8 @@ export const __testing = {
|
|||||||
},
|
},
|
||||||
resetDepsForTests() {
|
resetDepsForTests() {
|
||||||
agentsHandlerDeps.isWorkspaceSetupCompleted = isWorkspaceSetupCompleted;
|
agentsHandlerDeps.isWorkspaceSetupCompleted = isWorkspaceSetupCompleted;
|
||||||
agentsHandlerDeps.readLocalFileSafely = readLocalFileSafely;
|
agentsHandlerDeps.openFileWithinRoot = openFileWithinRoot;
|
||||||
agentsHandlerDeps.resolveAgentWorkspaceFilePath = resolveAgentWorkspaceFilePath;
|
agentsHandlerDeps.readFileWithinRoot = readFileWithinRoot;
|
||||||
agentsHandlerDeps.writeFileWithinRoot = writeFileWithinRoot;
|
agentsHandlerDeps.writeFileWithinRoot = writeFileWithinRoot;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -131,166 +134,40 @@ type FileMeta = {
|
|||||||
updatedAtMs: number;
|
updatedAtMs: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ResolvedAgentWorkspaceFilePath =
|
function isPathInsideDirectory(rootDir: string, candidatePath: string): boolean {
|
||||||
| {
|
const relative = path.relative(rootDir, candidatePath);
|
||||||
kind: "ready";
|
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
||||||
requestPath: string;
|
|
||||||
ioPath: string;
|
|
||||||
workspaceReal: string;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
kind: "missing";
|
|
||||||
requestPath: string;
|
|
||||||
ioPath: string;
|
|
||||||
workspaceReal: string;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
kind: "invalid";
|
|
||||||
requestPath: string;
|
|
||||||
reason: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ResolvedWorkspaceFilePath = Exclude<ResolvedAgentWorkspaceFilePath, { kind: "invalid" }>;
|
|
||||||
|
|
||||||
function resolveNotFoundWorkspaceFilePathResult(params: {
|
|
||||||
error: unknown;
|
|
||||||
allowMissing: boolean;
|
|
||||||
requestPath: string;
|
|
||||||
ioPath: string;
|
|
||||||
workspaceReal: string;
|
|
||||||
}): Extract<ResolvedAgentWorkspaceFilePath, { kind: "missing" | "invalid" }> | undefined {
|
|
||||||
if (!isNotFoundPathError(params.error)) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
if (params.allowMissing) {
|
|
||||||
return {
|
|
||||||
kind: "missing",
|
|
||||||
requestPath: params.requestPath,
|
|
||||||
ioPath: params.ioPath,
|
|
||||||
workspaceReal: params.workspaceReal,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return { kind: "invalid", requestPath: params.requestPath, reason: "file not found" };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveWorkspaceFilePathResultOrThrow(params: {
|
async function statWorkspaceFileSafely(
|
||||||
error: unknown;
|
workspaceDir: string,
|
||||||
allowMissing: boolean;
|
name: string,
|
||||||
requestPath: string;
|
): Promise<FileMeta | null> {
|
||||||
ioPath: string;
|
|
||||||
workspaceReal: string;
|
|
||||||
}): Extract<ResolvedAgentWorkspaceFilePath, { kind: "missing" | "invalid" }> {
|
|
||||||
const notFoundResult = resolveNotFoundWorkspaceFilePathResult(params);
|
|
||||||
if (notFoundResult) {
|
|
||||||
return notFoundResult;
|
|
||||||
}
|
|
||||||
throw params.error;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resolveWorkspaceRealPath(workspaceDir: string): Promise<string> {
|
|
||||||
try {
|
try {
|
||||||
return await fs.realpath(workspaceDir);
|
const workspaceReal = await fs.realpath(workspaceDir);
|
||||||
} catch {
|
const candidatePath = path.resolve(workspaceReal, name);
|
||||||
return path.resolve(workspaceDir);
|
if (!isPathInsideDirectory(workspaceReal, candidatePath)) {
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resolveAgentWorkspaceFilePath(params: {
|
|
||||||
workspaceDir: string;
|
|
||||||
name: string;
|
|
||||||
allowMissing: boolean;
|
|
||||||
}): Promise<ResolvedAgentWorkspaceFilePath> {
|
|
||||||
const requestPath = path.join(params.workspaceDir, params.name);
|
|
||||||
const workspaceReal = await resolveWorkspaceRealPath(params.workspaceDir);
|
|
||||||
const candidatePath = path.resolve(workspaceReal, params.name);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await assertNoPathAliasEscape({
|
|
||||||
absolutePath: candidatePath,
|
|
||||||
rootPath: workspaceReal,
|
|
||||||
boundaryLabel: "workspace root",
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
kind: "invalid",
|
|
||||||
requestPath,
|
|
||||||
reason: error instanceof Error ? error.message : "path escapes workspace root",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const notFoundContext = {
|
|
||||||
allowMissing: params.allowMissing,
|
|
||||||
requestPath,
|
|
||||||
workspaceReal,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
let candidateLstat: Awaited<ReturnType<typeof fs.lstat>>;
|
|
||||||
try {
|
|
||||||
candidateLstat = await fs.lstat(candidatePath);
|
|
||||||
} catch (err) {
|
|
||||||
return resolveWorkspaceFilePathResultOrThrow({
|
|
||||||
error: err,
|
|
||||||
...notFoundContext,
|
|
||||||
ioPath: candidatePath,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (candidateLstat.isSymbolicLink()) {
|
|
||||||
let targetReal: string;
|
|
||||||
try {
|
|
||||||
targetReal = await fs.realpath(candidatePath);
|
|
||||||
} catch (err) {
|
|
||||||
return resolveWorkspaceFilePathResultOrThrow({
|
|
||||||
error: err,
|
|
||||||
...notFoundContext,
|
|
||||||
ioPath: candidatePath,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
let targetStat: Awaited<ReturnType<typeof fs.stat>>;
|
|
||||||
try {
|
|
||||||
targetStat = await fs.stat(targetReal);
|
|
||||||
} catch (err) {
|
|
||||||
return resolveWorkspaceFilePathResultOrThrow({
|
|
||||||
error: err,
|
|
||||||
...notFoundContext,
|
|
||||||
ioPath: targetReal,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (!targetStat.isFile()) {
|
|
||||||
return { kind: "invalid", requestPath, reason: "path is not a regular file" };
|
|
||||||
}
|
|
||||||
if (targetStat.nlink > 1) {
|
|
||||||
return { kind: "invalid", requestPath, reason: "hardlinked file path not allowed" };
|
|
||||||
}
|
|
||||||
return { kind: "ready", requestPath, ioPath: targetReal, workspaceReal };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!candidateLstat.isFile()) {
|
|
||||||
return { kind: "invalid", requestPath, reason: "path is not a regular file" };
|
|
||||||
}
|
|
||||||
if (candidateLstat.nlink > 1) {
|
|
||||||
return { kind: "invalid", requestPath, reason: "hardlinked file path not allowed" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetReal = await fs.realpath(candidatePath).catch(() => candidatePath);
|
|
||||||
return { kind: "ready", requestPath, ioPath: targetReal, workspaceReal };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function statFileSafely(filePath: string): Promise<FileMeta | null> {
|
|
||||||
try {
|
|
||||||
const [stat, lstat] = await Promise.all([fs.stat(filePath), fs.lstat(filePath)]);
|
|
||||||
if (lstat.isSymbolicLink() || !stat.isFile()) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (stat.nlink > 1) {
|
|
||||||
|
const pathStat = await fs.lstat(candidatePath);
|
||||||
|
if (!pathStat.isFile() || pathStat.nlink > 1) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (!sameFileIdentity(stat, lstat)) {
|
|
||||||
|
const realPath = await fs.realpath(candidatePath);
|
||||||
|
if (!isPathInsideDirectory(workspaceReal, realPath)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const realStat = await fs.stat(realPath);
|
||||||
|
if (!realStat.isFile() || realStat.nlink > 1 || !sameFileIdentity(pathStat, realStat)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
size: stat.size,
|
size: realStat.size,
|
||||||
updatedAtMs: Math.floor(stat.mtimeMs),
|
updatedAtMs: Math.floor(realStat.mtimeMs),
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
@@ -310,18 +187,8 @@ async function listAgentFiles(workspaceDir: string, options?: { hideBootstrap?:
|
|||||||
? BOOTSTRAP_FILE_NAMES_POST_ONBOARDING
|
? BOOTSTRAP_FILE_NAMES_POST_ONBOARDING
|
||||||
: BOOTSTRAP_FILE_NAMES;
|
: BOOTSTRAP_FILE_NAMES;
|
||||||
for (const name of bootstrapFileNames) {
|
for (const name of bootstrapFileNames) {
|
||||||
const resolved = await resolveAgentWorkspaceFilePath({
|
const filePath = path.join(workspaceDir, name);
|
||||||
workspaceDir,
|
const meta = await statWorkspaceFileSafely(workspaceDir, name);
|
||||||
name,
|
|
||||||
allowMissing: true,
|
|
||||||
});
|
|
||||||
const filePath = resolved.requestPath;
|
|
||||||
const meta =
|
|
||||||
resolved.kind === "ready"
|
|
||||||
? await statFileSafely(resolved.ioPath)
|
|
||||||
: resolved.kind === "missing"
|
|
||||||
? null
|
|
||||||
: null;
|
|
||||||
if (meta) {
|
if (meta) {
|
||||||
files.push({
|
files.push({
|
||||||
name,
|
name,
|
||||||
@@ -335,33 +202,21 @@ async function listAgentFiles(workspaceDir: string, options?: { hideBootstrap?:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const primaryResolved = await resolveAgentWorkspaceFilePath({
|
const primaryMeta = await statWorkspaceFileSafely(workspaceDir, DEFAULT_MEMORY_FILENAME);
|
||||||
workspaceDir,
|
|
||||||
name: DEFAULT_MEMORY_FILENAME,
|
|
||||||
allowMissing: true,
|
|
||||||
});
|
|
||||||
const primaryMeta =
|
|
||||||
primaryResolved.kind === "ready" ? await statFileSafely(primaryResolved.ioPath) : null;
|
|
||||||
if (primaryMeta) {
|
if (primaryMeta) {
|
||||||
files.push({
|
files.push({
|
||||||
name: DEFAULT_MEMORY_FILENAME,
|
name: DEFAULT_MEMORY_FILENAME,
|
||||||
path: primaryResolved.requestPath,
|
path: path.join(workspaceDir, DEFAULT_MEMORY_FILENAME),
|
||||||
missing: false,
|
missing: false,
|
||||||
size: primaryMeta.size,
|
size: primaryMeta.size,
|
||||||
updatedAtMs: primaryMeta.updatedAtMs,
|
updatedAtMs: primaryMeta.updatedAtMs,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const altMemoryResolved = await resolveAgentWorkspaceFilePath({
|
const altMeta = await statWorkspaceFileSafely(workspaceDir, DEFAULT_MEMORY_ALT_FILENAME);
|
||||||
workspaceDir,
|
|
||||||
name: DEFAULT_MEMORY_ALT_FILENAME,
|
|
||||||
allowMissing: true,
|
|
||||||
});
|
|
||||||
const altMeta =
|
|
||||||
altMemoryResolved.kind === "ready" ? await statFileSafely(altMemoryResolved.ioPath) : null;
|
|
||||||
if (altMeta) {
|
if (altMeta) {
|
||||||
files.push({
|
files.push({
|
||||||
name: DEFAULT_MEMORY_ALT_FILENAME,
|
name: DEFAULT_MEMORY_ALT_FILENAME,
|
||||||
path: altMemoryResolved.requestPath,
|
path: path.join(workspaceDir, DEFAULT_MEMORY_ALT_FILENAME),
|
||||||
missing: false,
|
missing: false,
|
||||||
size: altMeta.size,
|
size: altMeta.size,
|
||||||
updatedAtMs: altMeta.updatedAtMs,
|
updatedAtMs: altMeta.updatedAtMs,
|
||||||
@@ -369,7 +224,7 @@ async function listAgentFiles(workspaceDir: string, options?: { hideBootstrap?:
|
|||||||
} else {
|
} else {
|
||||||
files.push({
|
files.push({
|
||||||
name: DEFAULT_MEMORY_FILENAME,
|
name: DEFAULT_MEMORY_FILENAME,
|
||||||
path: primaryResolved.requestPath,
|
path: path.join(workspaceDir, DEFAULT_MEMORY_FILENAME),
|
||||||
missing: true,
|
missing: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -434,31 +289,6 @@ async function moveToTrashBestEffort(pathname: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function respondWorkspaceFileInvalid(respond: RespondFn, name: string, reason: string): void {
|
|
||||||
respond(
|
|
||||||
false,
|
|
||||||
undefined,
|
|
||||||
errorShape(ErrorCodes.INVALID_REQUEST, `unsafe workspace file "${name}" (${reason})`),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resolveWorkspaceFilePathOrRespond(params: {
|
|
||||||
respond: RespondFn;
|
|
||||||
workspaceDir: string;
|
|
||||||
name: string;
|
|
||||||
}): Promise<ResolvedWorkspaceFilePath | undefined> {
|
|
||||||
const resolvedPath = await agentsHandlerDeps.resolveAgentWorkspaceFilePath({
|
|
||||||
workspaceDir: params.workspaceDir,
|
|
||||||
name: params.name,
|
|
||||||
allowMissing: true,
|
|
||||||
});
|
|
||||||
if (resolvedPath.kind === "invalid") {
|
|
||||||
respondWorkspaceFileInvalid(params.respond, params.name, resolvedPath.reason);
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
return resolvedPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
function respondWorkspaceFileUnsafe(respond: RespondFn, name: string): void {
|
function respondWorkspaceFileUnsafe(respond: RespondFn, name: string): void {
|
||||||
respond(
|
respond(
|
||||||
false,
|
false,
|
||||||
@@ -492,27 +322,10 @@ async function writeWorkspaceFileOrRespond(params: {
|
|||||||
content: string;
|
content: string;
|
||||||
}): Promise<boolean> {
|
}): Promise<boolean> {
|
||||||
await fs.mkdir(params.workspaceDir, { recursive: true });
|
await fs.mkdir(params.workspaceDir, { recursive: true });
|
||||||
const resolvedPath = await resolveWorkspaceFilePathOrRespond({
|
|
||||||
respond: params.respond,
|
|
||||||
workspaceDir: params.workspaceDir,
|
|
||||||
name: params.name,
|
|
||||||
});
|
|
||||||
if (!resolvedPath) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const relativeWritePath = path.relative(resolvedPath.workspaceReal, resolvedPath.ioPath);
|
|
||||||
if (
|
|
||||||
!relativeWritePath ||
|
|
||||||
relativeWritePath.startsWith("..") ||
|
|
||||||
path.isAbsolute(relativeWritePath)
|
|
||||||
) {
|
|
||||||
respondWorkspaceFileUnsafe(params.respond, params.name);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
await agentsHandlerDeps.writeFileWithinRoot({
|
await agentsHandlerDeps.writeFileWithinRoot({
|
||||||
rootDir: resolvedPath.workspaceReal,
|
rootDir: params.workspaceDir,
|
||||||
relativePath: relativeWritePath,
|
relativePath: params.name,
|
||||||
data: params.content,
|
data: params.content,
|
||||||
encoding: "utf8",
|
encoding: "utf8",
|
||||||
});
|
});
|
||||||
@@ -548,16 +361,13 @@ async function readWorkspaceFileContent(
|
|||||||
workspaceDir: string,
|
workspaceDir: string,
|
||||||
name: string,
|
name: string,
|
||||||
): Promise<string | undefined> {
|
): Promise<string | undefined> {
|
||||||
const resolvedPath = await agentsHandlerDeps.resolveAgentWorkspaceFilePath({
|
|
||||||
workspaceDir,
|
|
||||||
name,
|
|
||||||
allowMissing: true,
|
|
||||||
});
|
|
||||||
if (resolvedPath.kind !== "ready") {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const safeRead = await agentsHandlerDeps.readLocalFileSafely({ filePath: resolvedPath.ioPath });
|
const safeRead = await agentsHandlerDeps.readFileWithinRoot({
|
||||||
|
rootDir: workspaceDir,
|
||||||
|
relativePath: name,
|
||||||
|
rejectHardlinks: true,
|
||||||
|
nonBlockingRead: true,
|
||||||
|
});
|
||||||
return safeRead.buffer.toString("utf-8");
|
return safeRead.buffer.toString("utf-8");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof SafeOpenError && err.code === "not-found") {
|
if (err instanceof SafeOpenError && err.code === "not-found") {
|
||||||
@@ -595,6 +405,24 @@ async function buildIdentityMarkdownForWrite(params: {
|
|||||||
return mergeIdentityMarkdownContent(baseContent, params.identity);
|
return mergeIdentityMarkdownContent(baseContent, params.identity);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function buildIdentityMarkdownOrRespondUnsafe(params: {
|
||||||
|
respond: RespondFn;
|
||||||
|
workspaceDir: string;
|
||||||
|
identity: IdentityConfig;
|
||||||
|
fallbackWorkspaceDir?: string;
|
||||||
|
preferFallbackWorkspaceContent?: boolean;
|
||||||
|
}): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
return await buildIdentityMarkdownForWrite(params);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof SafeOpenError) {
|
||||||
|
respondWorkspaceFileUnsafe(params.respond, DEFAULT_IDENTITY_FILENAME);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const agentsHandlers: GatewayRequestHandlers = {
|
export const agentsHandlers: GatewayRequestHandlers = {
|
||||||
"agents.list": ({ params, respond }) => {
|
"agents.list": ({ params, respond }) => {
|
||||||
if (!validateAgentsListParams(params)) {
|
if (!validateAgentsListParams(params)) {
|
||||||
@@ -682,10 +510,14 @@ export const agentsHandlers: GatewayRequestHandlers = {
|
|||||||
|
|
||||||
const persistedIdentity = normalizeIdentityForFile(resolveAgentIdentity(nextConfig, agentId));
|
const persistedIdentity = normalizeIdentityForFile(resolveAgentIdentity(nextConfig, agentId));
|
||||||
if (persistedIdentity) {
|
if (persistedIdentity) {
|
||||||
const identityContent = await buildIdentityMarkdownForWrite({
|
const identityContent = await buildIdentityMarkdownOrRespondUnsafe({
|
||||||
|
respond,
|
||||||
workspaceDir,
|
workspaceDir,
|
||||||
identity: persistedIdentity,
|
identity: persistedIdentity,
|
||||||
});
|
});
|
||||||
|
if (identityContent === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
!(await writeWorkspaceFileOrRespond({
|
!(await writeWorkspaceFileOrRespond({
|
||||||
respond,
|
respond,
|
||||||
@@ -762,13 +594,17 @@ export const agentsHandlers: GatewayRequestHandlers = {
|
|||||||
workspaceDir && identityWorkspaceDir !== previousWorkspaceDir
|
workspaceDir && identityWorkspaceDir !== previousWorkspaceDir
|
||||||
? previousWorkspaceDir
|
? previousWorkspaceDir
|
||||||
: undefined;
|
: undefined;
|
||||||
const identityContent = await buildIdentityMarkdownForWrite({
|
const identityContent = await buildIdentityMarkdownOrRespondUnsafe({
|
||||||
|
respond,
|
||||||
workspaceDir: identityWorkspaceDir,
|
workspaceDir: identityWorkspaceDir,
|
||||||
identity: persistedIdentity,
|
identity: persistedIdentity,
|
||||||
fallbackWorkspaceDir,
|
fallbackWorkspaceDir,
|
||||||
preferFallbackWorkspaceContent:
|
preferFallbackWorkspaceContent:
|
||||||
Boolean(fallbackWorkspaceDir) && ensuredWorkspace?.identityPathCreated === true,
|
Boolean(fallbackWorkspaceDir) && ensuredWorkspace?.identityPathCreated === true,
|
||||||
});
|
});
|
||||||
|
if (identityContent === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
!(await writeWorkspaceFileOrRespond({
|
!(await writeWorkspaceFileOrRespond({
|
||||||
respond,
|
respond,
|
||||||
@@ -865,29 +701,25 @@ export const agentsHandlers: GatewayRequestHandlers = {
|
|||||||
}
|
}
|
||||||
const { agentId, workspaceDir, name } = resolved;
|
const { agentId, workspaceDir, name } = resolved;
|
||||||
const filePath = path.join(workspaceDir, name);
|
const filePath = path.join(workspaceDir, name);
|
||||||
const resolvedPath = await resolveWorkspaceFilePathOrRespond({
|
let safeRead: Awaited<ReturnType<typeof readFileWithinRoot>>;
|
||||||
respond,
|
|
||||||
workspaceDir,
|
|
||||||
name,
|
|
||||||
});
|
|
||||||
if (!resolvedPath) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (resolvedPath.kind === "missing") {
|
|
||||||
respondWorkspaceFileMissing({ respond, agentId, workspaceDir, name, filePath });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let safeRead: Awaited<ReturnType<typeof readLocalFileSafely>>;
|
|
||||||
try {
|
try {
|
||||||
safeRead = await agentsHandlerDeps.readLocalFileSafely({ filePath: resolvedPath.ioPath });
|
safeRead = await agentsHandlerDeps.readFileWithinRoot({
|
||||||
|
rootDir: workspaceDir,
|
||||||
|
relativePath: name,
|
||||||
|
rejectHardlinks: true,
|
||||||
|
nonBlockingRead: true,
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof SafeOpenError && err.code === "not-found") {
|
if (err instanceof SafeOpenError && err.code === "not-found") {
|
||||||
respondWorkspaceFileMissing({ respond, agentId, workspaceDir, name, filePath });
|
respondWorkspaceFileMissing({ respond, agentId, workspaceDir, name, filePath });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (err instanceof SafeOpenError) {
|
||||||
respondWorkspaceFileUnsafe(respond, name);
|
respondWorkspaceFileUnsafe(respond, name);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
respond(
|
respond(
|
||||||
true,
|
true,
|
||||||
{
|
{
|
||||||
@@ -917,36 +749,22 @@ export const agentsHandlers: GatewayRequestHandlers = {
|
|||||||
const { agentId, workspaceDir, name } = resolved;
|
const { agentId, workspaceDir, name } = resolved;
|
||||||
await fs.mkdir(workspaceDir, { recursive: true });
|
await fs.mkdir(workspaceDir, { recursive: true });
|
||||||
const filePath = path.join(workspaceDir, name);
|
const filePath = path.join(workspaceDir, name);
|
||||||
const resolvedPath = await resolveWorkspaceFilePathOrRespond({
|
|
||||||
respond,
|
|
||||||
workspaceDir,
|
|
||||||
name,
|
|
||||||
});
|
|
||||||
if (!resolvedPath) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const content = params.content;
|
const content = params.content;
|
||||||
const relativeWritePath = path.relative(resolvedPath.workspaceReal, resolvedPath.ioPath);
|
|
||||||
if (
|
|
||||||
!relativeWritePath ||
|
|
||||||
relativeWritePath.startsWith("..") ||
|
|
||||||
path.isAbsolute(relativeWritePath)
|
|
||||||
) {
|
|
||||||
respondWorkspaceFileUnsafe(respond, name);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
await agentsHandlerDeps.writeFileWithinRoot({
|
await agentsHandlerDeps.writeFileWithinRoot({
|
||||||
rootDir: resolvedPath.workspaceReal,
|
rootDir: workspaceDir,
|
||||||
relativePath: relativeWritePath,
|
relativePath: name,
|
||||||
data: content,
|
data: content,
|
||||||
encoding: "utf8",
|
encoding: "utf8",
|
||||||
});
|
});
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
if (!(err instanceof SafeOpenError)) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
respondWorkspaceFileUnsafe(respond, name);
|
respondWorkspaceFileUnsafe(respond, name);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const meta = await statFileSafely(resolvedPath.ioPath);
|
const meta = await statWorkspaceFileSafely(workspaceDir, name);
|
||||||
respond(
|
respond(
|
||||||
true,
|
true,
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { FileHandle } from "node:fs/promises";
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
@@ -8,10 +9,12 @@ import {
|
|||||||
import { createTrackedTempDirs } from "../test-utils/tracked-temp-dirs.js";
|
import { createTrackedTempDirs } from "../test-utils/tracked-temp-dirs.js";
|
||||||
import * as pinnedPathHelperModule from "./fs-pinned-path-helper.js";
|
import * as pinnedPathHelperModule from "./fs-pinned-path-helper.js";
|
||||||
import {
|
import {
|
||||||
|
__setFsSafeTestHooksForTest,
|
||||||
appendFileWithinRoot,
|
appendFileWithinRoot,
|
||||||
copyFileWithinRoot,
|
copyFileWithinRoot,
|
||||||
createRootScopedReadFile,
|
createRootScopedReadFile,
|
||||||
mkdirPathWithinRoot,
|
mkdirPathWithinRoot,
|
||||||
|
resolveOpenedFileRealPathForHandle,
|
||||||
SafeOpenError,
|
SafeOpenError,
|
||||||
openFileWithinRoot,
|
openFileWithinRoot,
|
||||||
readFileWithinRoot,
|
readFileWithinRoot,
|
||||||
@@ -25,6 +28,8 @@ import {
|
|||||||
const tempDirs = createTrackedTempDirs();
|
const tempDirs = createTrackedTempDirs();
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
|
__setFsSafeTestHooksForTest(undefined);
|
||||||
|
vi.unstubAllEnvs();
|
||||||
await tempDirs.cleanup();
|
await tempDirs.cleanup();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -149,6 +154,32 @@ describe("fs-safe", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.runIf(process.platform !== "win32")(
|
||||||
|
"resolves opened file real paths from the fd before the current path target",
|
||||||
|
async () => {
|
||||||
|
const root = await tempDirs.make("openclaw-fs-safe-root-");
|
||||||
|
const outside = await tempDirs.make("openclaw-fs-safe-outside-");
|
||||||
|
const originalPath = path.join(root, "inside.txt");
|
||||||
|
const movedPath = path.join(root, "inside-moved.txt");
|
||||||
|
const outsidePath = path.join(outside, "outside.txt");
|
||||||
|
await fs.writeFile(originalPath, "inside");
|
||||||
|
await fs.writeFile(outsidePath, "outside");
|
||||||
|
|
||||||
|
const handle = await fs.open(originalPath, "r");
|
||||||
|
try {
|
||||||
|
await fs.rename(originalPath, movedPath);
|
||||||
|
await fs.symlink(outsidePath, originalPath);
|
||||||
|
|
||||||
|
const resolved = await resolveOpenedFileRealPathForHandle(handle, originalPath);
|
||||||
|
|
||||||
|
expect(resolved).toBe(movedPath);
|
||||||
|
await expect(handle.readFile({ encoding: "utf8" })).resolves.toBe("inside");
|
||||||
|
} finally {
|
||||||
|
await handle.close().catch(() => {});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
it("blocks traversal outside root", async () => {
|
it("blocks traversal outside root", async () => {
|
||||||
const root = await tempDirs.make("openclaw-fs-safe-root-");
|
const root = await tempDirs.make("openclaw-fs-safe-root-");
|
||||||
const outside = await tempDirs.make("openclaw-fs-safe-outside-");
|
const outside = await tempDirs.make("openclaw-fs-safe-outside-");
|
||||||
@@ -221,6 +252,70 @@ describe("fs-safe", () => {
|
|||||||
).rejects.toMatchObject({ code: "invalid-path" });
|
).rejects.toMatchObject({ code: "invalid-path" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.runIf(process.platform !== "win32")(
|
||||||
|
"rejects symlink-target reads when the path target changes after open",
|
||||||
|
async () => {
|
||||||
|
const root = await tempDirs.make("openclaw-fs-safe-root-");
|
||||||
|
const insideA = path.join(root, "inside-a.txt");
|
||||||
|
const insideB = path.join(root, "inside-b.txt");
|
||||||
|
const link = path.join(root, "link.txt");
|
||||||
|
await fs.writeFile(insideA, "inside-a");
|
||||||
|
await fs.writeFile(insideB, "inside-b");
|
||||||
|
await fs.symlink(insideA, link);
|
||||||
|
|
||||||
|
__setFsSafeTestHooksForTest({
|
||||||
|
afterOpen: async () => {
|
||||||
|
await fs.rm(link);
|
||||||
|
await fs.symlink(insideB, link);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
readFileWithinRoot({
|
||||||
|
rootDir: root,
|
||||||
|
relativePath: "link.txt",
|
||||||
|
allowSymlinkTargetWithinRoot: true,
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ code: "invalid-path" });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("closes the opened handle when afterOpen hook throws", async () => {
|
||||||
|
const root = await tempDirs.make("openclaw-fs-safe-root-");
|
||||||
|
const filePath = path.join(root, "inside.txt");
|
||||||
|
await fs.writeFile(filePath, "inside");
|
||||||
|
|
||||||
|
let openedHandle: FileHandle | undefined;
|
||||||
|
__setFsSafeTestHooksForTest({
|
||||||
|
afterOpen: (_target, handle) => {
|
||||||
|
openedHandle = handle;
|
||||||
|
throw new Error("after-open boom");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
openFileWithinRoot({
|
||||||
|
rootDir: root,
|
||||||
|
relativePath: "inside.txt",
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("after-open boom");
|
||||||
|
expect(openedHandle).toBeDefined();
|
||||||
|
await expect(openedHandle?.readFile({ encoding: "utf8" })).rejects.toMatchObject({
|
||||||
|
code: "EBADF",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects setting fs-safe test hooks outside test mode", async () => {
|
||||||
|
vi.stubEnv("NODE_ENV", "production");
|
||||||
|
vi.stubEnv("VITEST", undefined);
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
__setFsSafeTestHooksForTest({
|
||||||
|
afterPreOpenLstat: () => {},
|
||||||
|
}),
|
||||||
|
).toThrow("__setFsSafeTestHooksForTest is only available in tests");
|
||||||
|
});
|
||||||
|
|
||||||
it.runIf(process.platform !== "win32")("blocks hardlink aliases under root", async () => {
|
it.runIf(process.platform !== "win32")("blocks hardlink aliases under root", async () => {
|
||||||
const root = await tempDirs.make("openclaw-fs-safe-root-");
|
const root = await tempDirs.make("openclaw-fs-safe-root-");
|
||||||
const hardlinkPath = path.join(root, "link.txt");
|
const hardlinkPath = path.join(root, "link.txt");
|
||||||
|
|||||||
@@ -53,11 +53,19 @@ export type SafeLocalReadResult = {
|
|||||||
export type FsSafeTestHooks = {
|
export type FsSafeTestHooks = {
|
||||||
afterPreOpenLstat?: (filePath: string) => Promise<void> | void;
|
afterPreOpenLstat?: (filePath: string) => Promise<void> | void;
|
||||||
beforeOpen?: (filePath: string, flags: number) => Promise<void> | void;
|
beforeOpen?: (filePath: string, flags: number) => Promise<void> | void;
|
||||||
|
afterOpen?: (filePath: string, handle: FileHandle) => Promise<void> | void;
|
||||||
};
|
};
|
||||||
|
|
||||||
let fsSafeTestHooks: FsSafeTestHooks | undefined;
|
let fsSafeTestHooks: FsSafeTestHooks | undefined;
|
||||||
|
|
||||||
|
function allowFsSafeTestHooks(): boolean {
|
||||||
|
return process.env.NODE_ENV === "test" || process.env.VITEST === "true";
|
||||||
|
}
|
||||||
|
|
||||||
export function __setFsSafeTestHooksForTest(hooks?: FsSafeTestHooks): void {
|
export function __setFsSafeTestHooksForTest(hooks?: FsSafeTestHooks): void {
|
||||||
|
if (hooks && !allowFsSafeTestHooks()) {
|
||||||
|
throw new Error("__setFsSafeTestHooksForTest is only available in tests");
|
||||||
|
}
|
||||||
fsSafeTestHooks = hooks;
|
fsSafeTestHooks = hooks;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,6 +137,12 @@ async function openVerifiedLocalFile(
|
|||||||
: OPEN_READ_FLAGS;
|
: OPEN_READ_FLAGS;
|
||||||
await fsSafeTestHooks?.beforeOpen?.(filePath, openFlags);
|
await fsSafeTestHooks?.beforeOpen?.(filePath, openFlags);
|
||||||
handle = await fs.open(filePath, openFlags);
|
handle = await fs.open(filePath, openFlags);
|
||||||
|
try {
|
||||||
|
await fsSafeTestHooks?.afterOpen?.(filePath, handle);
|
||||||
|
} catch (err) {
|
||||||
|
await handle.close().catch(() => {});
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (isNotFoundPathError(err)) {
|
if (isNotFoundPathError(err)) {
|
||||||
throw new SafeOpenError("not-found", "file not found");
|
throw new SafeOpenError("not-found", "file not found");
|
||||||
@@ -144,24 +158,30 @@ async function openVerifiedLocalFile(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [stat, pathStat] = await Promise.all([
|
const stat = await handle.stat();
|
||||||
handle.stat(),
|
|
||||||
options?.allowSymlinkTargetWithinRoot ? fs.stat(filePath) : fs.lstat(filePath),
|
|
||||||
]);
|
|
||||||
if (!options?.allowSymlinkTargetWithinRoot && pathStat.isSymbolicLink()) {
|
|
||||||
throw new SafeOpenError("symlink", "symlink not allowed");
|
|
||||||
}
|
|
||||||
if (!stat.isFile()) {
|
if (!stat.isFile()) {
|
||||||
throw new SafeOpenError("not-file", "not a file");
|
throw new SafeOpenError("not-file", "not a file");
|
||||||
}
|
}
|
||||||
if (options?.rejectHardlinks && stat.nlink > 1) {
|
if (options?.rejectHardlinks && stat.nlink > 1) {
|
||||||
throw new SafeOpenError("invalid-path", "hardlinked path not allowed");
|
throw new SafeOpenError("invalid-path", "hardlinked path not allowed");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (options?.allowSymlinkTargetWithinRoot) {
|
||||||
|
const pathStat = await fs.stat(filePath);
|
||||||
if (!sameFileIdentity(stat, pathStat)) {
|
if (!sameFileIdentity(stat, pathStat)) {
|
||||||
throw new SafeOpenError("path-mismatch", "path changed during read");
|
throw new SafeOpenError("path-mismatch", "path changed during read");
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
const pathStat = await fs.lstat(filePath);
|
||||||
|
if (pathStat.isSymbolicLink()) {
|
||||||
|
throw new SafeOpenError("symlink", "symlink not allowed");
|
||||||
|
}
|
||||||
|
if (!sameFileIdentity(stat, pathStat)) {
|
||||||
|
throw new SafeOpenError("path-mismatch", "path changed during read");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const realPath = await fs.realpath(filePath);
|
const realPath = await resolveOpenedFileRealPathForHandle(handle, filePath);
|
||||||
const realStat = await fs.stat(realPath);
|
const realStat = await fs.stat(realPath);
|
||||||
if (options?.rejectHardlinks && realStat.nlink > 1) {
|
if (options?.rejectHardlinks && realStat.nlink > 1) {
|
||||||
throw new SafeOpenError("invalid-path", "hardlinked path not allowed");
|
throw new SafeOpenError("invalid-path", "hardlinked path not allowed");
|
||||||
@@ -397,14 +417,6 @@ export async function resolveOpenedFileRealPathForHandle(
|
|||||||
handle: FileHandle,
|
handle: FileHandle,
|
||||||
ioPath: string,
|
ioPath: string,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
try {
|
|
||||||
return await fs.realpath(ioPath);
|
|
||||||
} catch (err) {
|
|
||||||
if (!isNotFoundPathError(err)) {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const fdCandidates =
|
const fdCandidates =
|
||||||
process.platform === "linux"
|
process.platform === "linux"
|
||||||
? [`/proc/self/fd/${handle.fd}`, `/dev/fd/${handle.fd}`]
|
? [`/proc/self/fd/${handle.fd}`, `/dev/fd/${handle.fd}`]
|
||||||
@@ -418,6 +430,14 @@ export async function resolveOpenedFileRealPathForHandle(
|
|||||||
// try next fd path
|
// try next fd path
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await fs.realpath(ioPath);
|
||||||
|
} catch (err) {
|
||||||
|
if (!isNotFoundPathError(err)) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
throw new SafeOpenError("path-mismatch", "unable to resolve opened file path");
|
throw new SafeOpenError("path-mismatch", "unable to resolve opened file path");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -792,6 +812,7 @@ async function resolvePinnedWriteTargetWithinRoot(params: {
|
|||||||
rootDir: params.rootDir,
|
rootDir: params.rootDir,
|
||||||
relativePath: params.relativePath,
|
relativePath: params.relativePath,
|
||||||
rejectHardlinks: true,
|
rejectHardlinks: true,
|
||||||
|
nonBlockingRead: true,
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
mode = opened.stat.mode & 0o777;
|
mode = opened.stat.mode & 0o777;
|
||||||
|
|||||||
Reference in New Issue
Block a user