mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-07 11:11:23 +00:00
fix: protect bootstrap files during memory flush (#38574)
Merged via squash.
Prepared head SHA: a0b9a02e2e
Co-authored-by: frankekn <4488090+frankekn@users.noreply.github.com>
Co-authored-by: frankekn <4488090+frankekn@users.noreply.github.com>
Reviewed-by: @frankekn
This commit is contained in:
@@ -468,6 +468,7 @@ Docs: https://docs.openclaw.ai
|
|||||||
- Control UI/Telegram sender labels: preserve inbound sender labels in sanitized chat history so dashboard user-message groups split correctly and show real group-member names instead of `You`. (#39414) Thanks @obviyus.
|
- Control UI/Telegram sender labels: preserve inbound sender labels in sanitized chat history so dashboard user-message groups split correctly and show real group-member names instead of `You`. (#39414) Thanks @obviyus.
|
||||||
- Agents/failover 402 recovery: keep temporary spend-limit `402` payloads retryable, preserve explicit insufficient-credit billing detection even in long provider payloads, and allow throttled billing-cooldown probes so single-provider setups can recover instead of staying locked out. (#38533) Thanks @xialonglee.
|
- Agents/failover 402 recovery: keep temporary spend-limit `402` payloads retryable, preserve explicit insufficient-credit billing detection even in long provider payloads, and allow throttled billing-cooldown probes so single-provider setups can recover instead of staying locked out. (#38533) Thanks @xialonglee.
|
||||||
- Browser/config schema: accept `browser.profiles.*.driver: "openclaw"` while preserving legacy `"clawd"` compatibility in validated config. (#39374; based on #35621) Thanks @gambletan and @ingyukoh.
|
- Browser/config schema: accept `browser.profiles.*.driver: "openclaw"` while preserving legacy `"clawd"` compatibility in validated config. (#39374; based on #35621) Thanks @gambletan and @ingyukoh.
|
||||||
|
- Memory flush/bootstrap file protection: restrict memory-flush runs to append-only `read`/`write` tools and route host-side memory appends through root-enforced safe file handles so flush turns cannot overwrite bootstrap files via `exec` or unsafe raw rewrites. (#38574) Thanks @frankekn.
|
||||||
|
|
||||||
## 2026.3.2
|
## 2026.3.2
|
||||||
|
|
||||||
|
|||||||
@@ -870,6 +870,8 @@ export async function runEmbeddedAttempt(
|
|||||||
agentDir,
|
agentDir,
|
||||||
workspaceDir: effectiveWorkspace,
|
workspaceDir: effectiveWorkspace,
|
||||||
config: params.config,
|
config: params.config,
|
||||||
|
trigger: params.trigger,
|
||||||
|
memoryFlushWritePath: params.memoryFlushWritePath,
|
||||||
abortSignal: runAbortController.signal,
|
abortSignal: runAbortController.signal,
|
||||||
modelProvider: params.model.provider,
|
modelProvider: params.model.provider,
|
||||||
modelId: params.modelId,
|
modelId: params.modelId,
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ export type RunEmbeddedPiAgentParams = {
|
|||||||
agentAccountId?: string;
|
agentAccountId?: string;
|
||||||
/** What initiated this agent run: "user", "heartbeat", "cron", or "memory". */
|
/** What initiated this agent run: "user", "heartbeat", "cron", or "memory". */
|
||||||
trigger?: string;
|
trigger?: string;
|
||||||
|
/** Relative workspace path that memory-triggered writes are allowed to append to. */
|
||||||
|
memoryFlushWritePath?: string;
|
||||||
/** Delivery target (e.g. telegram:group:123:topic:456) for topic/thread routing. */
|
/** Delivery target (e.g. telegram:group:123:topic:456) for topic/thread routing. */
|
||||||
messageTo?: string;
|
messageTo?: string;
|
||||||
/** Thread/topic identifier for routing replies to the originating thread. */
|
/** Thread/topic identifier for routing replies to the originating thread. */
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { fileURLToPath } from "node:url";
|
|||||||
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||||
import { createEditTool, createReadTool, createWriteTool } from "@mariozechner/pi-coding-agent";
|
import { createEditTool, createReadTool, createWriteTool } from "@mariozechner/pi-coding-agent";
|
||||||
import {
|
import {
|
||||||
|
appendFileWithinRoot,
|
||||||
SafeOpenError,
|
SafeOpenError,
|
||||||
openFileWithinRoot,
|
openFileWithinRoot,
|
||||||
readFileWithinRoot,
|
readFileWithinRoot,
|
||||||
@@ -406,6 +407,161 @@ function mapContainerPathToWorkspaceRoot(params: {
|
|||||||
return path.resolve(params.root, ...relative.split("/").filter(Boolean));
|
return path.resolve(params.root, ...relative.split("/").filter(Boolean));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveToolPathAgainstWorkspaceRoot(params: {
|
||||||
|
filePath: string;
|
||||||
|
root: string;
|
||||||
|
containerWorkdir?: string;
|
||||||
|
}): string {
|
||||||
|
const mapped = mapContainerPathToWorkspaceRoot(params);
|
||||||
|
const candidate = mapped.startsWith("@") ? mapped.slice(1) : mapped;
|
||||||
|
return path.isAbsolute(candidate)
|
||||||
|
? path.resolve(candidate)
|
||||||
|
: path.resolve(params.root, candidate || ".");
|
||||||
|
}
|
||||||
|
|
||||||
|
type MemoryFlushAppendOnlyWriteOptions = {
|
||||||
|
root: string;
|
||||||
|
relativePath: string;
|
||||||
|
containerWorkdir?: string;
|
||||||
|
sandbox?: {
|
||||||
|
root: string;
|
||||||
|
bridge: SandboxFsBridge;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
async function readOptionalUtf8File(params: {
|
||||||
|
absolutePath: string;
|
||||||
|
relativePath: string;
|
||||||
|
sandbox?: MemoryFlushAppendOnlyWriteOptions["sandbox"];
|
||||||
|
signal?: AbortSignal;
|
||||||
|
}): Promise<string> {
|
||||||
|
try {
|
||||||
|
if (params.sandbox) {
|
||||||
|
const stat = await params.sandbox.bridge.stat({
|
||||||
|
filePath: params.relativePath,
|
||||||
|
cwd: params.sandbox.root,
|
||||||
|
signal: params.signal,
|
||||||
|
});
|
||||||
|
if (!stat) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const buffer = await params.sandbox.bridge.readFile({
|
||||||
|
filePath: params.relativePath,
|
||||||
|
cwd: params.sandbox.root,
|
||||||
|
signal: params.signal,
|
||||||
|
});
|
||||||
|
return buffer.toString("utf-8");
|
||||||
|
}
|
||||||
|
return await fs.readFile(params.absolutePath, "utf-8");
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException | undefined)?.code === "ENOENT") {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function appendMemoryFlushContent(params: {
|
||||||
|
absolutePath: string;
|
||||||
|
root: string;
|
||||||
|
relativePath: string;
|
||||||
|
content: string;
|
||||||
|
sandbox?: MemoryFlushAppendOnlyWriteOptions["sandbox"];
|
||||||
|
signal?: AbortSignal;
|
||||||
|
}) {
|
||||||
|
if (!params.sandbox) {
|
||||||
|
await appendFileWithinRoot({
|
||||||
|
rootDir: params.root,
|
||||||
|
relativePath: params.relativePath,
|
||||||
|
data: params.content,
|
||||||
|
mkdir: true,
|
||||||
|
prependNewlineIfNeeded: true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await readOptionalUtf8File({
|
||||||
|
absolutePath: params.absolutePath,
|
||||||
|
relativePath: params.relativePath,
|
||||||
|
sandbox: params.sandbox,
|
||||||
|
signal: params.signal,
|
||||||
|
});
|
||||||
|
const separator =
|
||||||
|
existing.length > 0 && !existing.endsWith("\n") && !params.content.startsWith("\n") ? "\n" : "";
|
||||||
|
const next = `${existing}${separator}${params.content}`;
|
||||||
|
if (params.sandbox) {
|
||||||
|
const parent = path.posix.dirname(params.relativePath);
|
||||||
|
if (parent && parent !== ".") {
|
||||||
|
await params.sandbox.bridge.mkdirp({
|
||||||
|
filePath: parent,
|
||||||
|
cwd: params.sandbox.root,
|
||||||
|
signal: params.signal,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await params.sandbox.bridge.writeFile({
|
||||||
|
filePath: params.relativePath,
|
||||||
|
cwd: params.sandbox.root,
|
||||||
|
data: next,
|
||||||
|
mkdir: true,
|
||||||
|
signal: params.signal,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await fs.mkdir(path.dirname(params.absolutePath), { recursive: true });
|
||||||
|
await fs.writeFile(params.absolutePath, next, "utf-8");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wrapToolMemoryFlushAppendOnlyWrite(
|
||||||
|
tool: AnyAgentTool,
|
||||||
|
options: MemoryFlushAppendOnlyWriteOptions,
|
||||||
|
): AnyAgentTool {
|
||||||
|
const allowedAbsolutePath = path.resolve(options.root, options.relativePath);
|
||||||
|
return {
|
||||||
|
...tool,
|
||||||
|
description: `${tool.description} During memory flush, this tool may only append to ${options.relativePath}.`,
|
||||||
|
execute: async (toolCallId, args, signal, onUpdate) => {
|
||||||
|
const normalized = normalizeToolParams(args);
|
||||||
|
const record =
|
||||||
|
normalized ??
|
||||||
|
(args && typeof args === "object" ? (args as Record<string, unknown>) : undefined);
|
||||||
|
assertRequiredParams(record, CLAUDE_PARAM_GROUPS.write, tool.name);
|
||||||
|
const filePath =
|
||||||
|
typeof record?.path === "string" && record.path.trim() ? record.path : undefined;
|
||||||
|
const content = typeof record?.content === "string" ? record.content : undefined;
|
||||||
|
if (!filePath || content === undefined) {
|
||||||
|
return tool.execute(toolCallId, normalized ?? args, signal, onUpdate);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedPath = resolveToolPathAgainstWorkspaceRoot({
|
||||||
|
filePath,
|
||||||
|
root: options.root,
|
||||||
|
containerWorkdir: options.containerWorkdir,
|
||||||
|
});
|
||||||
|
if (resolvedPath !== allowedAbsolutePath) {
|
||||||
|
throw new Error(
|
||||||
|
`Memory flush writes are restricted to ${options.relativePath}; use that path only.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await appendMemoryFlushContent({
|
||||||
|
absolutePath: allowedAbsolutePath,
|
||||||
|
root: options.root,
|
||||||
|
relativePath: options.relativePath,
|
||||||
|
content,
|
||||||
|
sandbox: options.sandbox,
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: `Appended content to ${options.relativePath}.` }],
|
||||||
|
details: {
|
||||||
|
path: options.relativePath,
|
||||||
|
appendOnly: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function wrapToolWorkspaceRootGuardWithOptions(
|
export function wrapToolWorkspaceRootGuardWithOptions(
|
||||||
tool: AnyAgentTool,
|
tool: AnyAgentTool,
|
||||||
root: string,
|
root: string,
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import {
|
|||||||
createSandboxedWriteTool,
|
createSandboxedWriteTool,
|
||||||
normalizeToolParams,
|
normalizeToolParams,
|
||||||
patchToolSchemaForClaudeCompatibility,
|
patchToolSchemaForClaudeCompatibility,
|
||||||
|
wrapToolMemoryFlushAppendOnlyWrite,
|
||||||
wrapToolWorkspaceRootGuard,
|
wrapToolWorkspaceRootGuard,
|
||||||
wrapToolWorkspaceRootGuardWithOptions,
|
wrapToolWorkspaceRootGuardWithOptions,
|
||||||
wrapToolParamNormalization,
|
wrapToolParamNormalization,
|
||||||
@@ -67,6 +68,7 @@ const TOOL_DENY_BY_MESSAGE_PROVIDER: Readonly<Record<string, readonly string[]>>
|
|||||||
voice: ["tts"],
|
voice: ["tts"],
|
||||||
};
|
};
|
||||||
const TOOL_DENY_FOR_XAI_PROVIDERS = new Set(["web_search"]);
|
const TOOL_DENY_FOR_XAI_PROVIDERS = new Set(["web_search"]);
|
||||||
|
const MEMORY_FLUSH_ALLOWED_TOOL_NAMES = new Set(["read", "write"]);
|
||||||
|
|
||||||
function normalizeMessageProvider(messageProvider?: string): string | undefined {
|
function normalizeMessageProvider(messageProvider?: string): string | undefined {
|
||||||
const normalized = messageProvider?.trim().toLowerCase();
|
const normalized = messageProvider?.trim().toLowerCase();
|
||||||
@@ -207,6 +209,10 @@ export function createOpenClawCodingTools(options?: {
|
|||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
/** Stable run identifier for this agent invocation. */
|
/** Stable run identifier for this agent invocation. */
|
||||||
runId?: string;
|
runId?: string;
|
||||||
|
/** What initiated this run (for trigger-specific tool restrictions). */
|
||||||
|
trigger?: string;
|
||||||
|
/** Relative workspace path that memory-triggered writes may append to. */
|
||||||
|
memoryFlushWritePath?: string;
|
||||||
agentDir?: string;
|
agentDir?: string;
|
||||||
workspaceDir?: string;
|
workspaceDir?: string;
|
||||||
config?: OpenClawConfig;
|
config?: OpenClawConfig;
|
||||||
@@ -258,6 +264,11 @@ export function createOpenClawCodingTools(options?: {
|
|||||||
}): AnyAgentTool[] {
|
}): AnyAgentTool[] {
|
||||||
const execToolName = "exec";
|
const execToolName = "exec";
|
||||||
const sandbox = options?.sandbox?.enabled ? options.sandbox : undefined;
|
const sandbox = options?.sandbox?.enabled ? options.sandbox : undefined;
|
||||||
|
const isMemoryFlushRun = options?.trigger === "memory";
|
||||||
|
if (isMemoryFlushRun && !options?.memoryFlushWritePath) {
|
||||||
|
throw new Error("memoryFlushWritePath required for memory-triggered tool runs");
|
||||||
|
}
|
||||||
|
const memoryFlushWritePath = isMemoryFlushRun ? options.memoryFlushWritePath : undefined;
|
||||||
const {
|
const {
|
||||||
agentId,
|
agentId,
|
||||||
globalPolicy,
|
globalPolicy,
|
||||||
@@ -322,7 +333,7 @@ export function createOpenClawCodingTools(options?: {
|
|||||||
const execConfig = resolveExecConfig({ cfg: options?.config, agentId });
|
const execConfig = resolveExecConfig({ cfg: options?.config, agentId });
|
||||||
const fsConfig = resolveToolFsConfig({ cfg: options?.config, agentId });
|
const fsConfig = resolveToolFsConfig({ cfg: options?.config, agentId });
|
||||||
const fsPolicy = createToolFsPolicy({
|
const fsPolicy = createToolFsPolicy({
|
||||||
workspaceOnly: fsConfig.workspaceOnly,
|
workspaceOnly: isMemoryFlushRun || fsConfig.workspaceOnly,
|
||||||
});
|
});
|
||||||
const sandboxRoot = sandbox?.workspaceDir;
|
const sandboxRoot = sandbox?.workspaceDir;
|
||||||
const sandboxFsBridge = sandbox?.fsBridge;
|
const sandboxFsBridge = sandbox?.fsBridge;
|
||||||
@@ -515,7 +526,32 @@ export function createOpenClawCodingTools(options?: {
|
|||||||
sessionId: options?.sessionId,
|
sessionId: options?.sessionId,
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
const toolsForMessageProvider = applyMessageProviderToolPolicy(tools, options?.messageProvider);
|
const toolsForMemoryFlush =
|
||||||
|
isMemoryFlushRun && memoryFlushWritePath
|
||||||
|
? tools.flatMap((tool) => {
|
||||||
|
if (!MEMORY_FLUSH_ALLOWED_TOOL_NAMES.has(tool.name)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (tool.name === "write") {
|
||||||
|
return [
|
||||||
|
wrapToolMemoryFlushAppendOnlyWrite(tool, {
|
||||||
|
root: sandboxRoot ?? workspaceRoot,
|
||||||
|
relativePath: memoryFlushWritePath,
|
||||||
|
containerWorkdir: sandbox?.containerWorkdir,
|
||||||
|
sandbox:
|
||||||
|
sandboxRoot && sandboxFsBridge
|
||||||
|
? { root: sandboxRoot, bridge: sandboxFsBridge }
|
||||||
|
: undefined,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [tool];
|
||||||
|
})
|
||||||
|
: tools;
|
||||||
|
const toolsForMessageProvider = applyMessageProviderToolPolicy(
|
||||||
|
toolsForMemoryFlush,
|
||||||
|
options?.messageProvider,
|
||||||
|
);
|
||||||
const toolsForModelProvider = applyModelProviderToolPolicy(toolsForMessageProvider, {
|
const toolsForModelProvider = applyModelProviderToolPolicy(toolsForMessageProvider, {
|
||||||
modelProvider: options?.modelProvider,
|
modelProvider: options?.modelProvider,
|
||||||
modelId: options?.modelId,
|
modelId: options?.modelId,
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("@mariozechner/pi-ai/oauth", () => ({
|
||||||
|
getOAuthApiKey: () => undefined,
|
||||||
|
getOAuthProviders: () => [],
|
||||||
|
}));
|
||||||
|
|
||||||
import { createOpenClawCodingTools } from "./pi-tools.js";
|
import { createOpenClawCodingTools } from "./pi-tools.js";
|
||||||
|
|
||||||
describe("FS tools with workspaceOnly=false", () => {
|
describe("FS tools with workspaceOnly=false", () => {
|
||||||
@@ -181,4 +187,50 @@ describe("FS tools with workspaceOnly=false", () => {
|
|||||||
}),
|
}),
|
||||||
).rejects.toThrow(/Path escapes (workspace|sandbox) root/);
|
).rejects.toThrow(/Path escapes (workspace|sandbox) root/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("restricts memory-triggered writes to append-only canonical memory files", async () => {
|
||||||
|
const allowedRelativePath = "memory/2026-03-07.md";
|
||||||
|
const allowedAbsolutePath = path.join(workspaceDir, allowedRelativePath);
|
||||||
|
await fs.mkdir(path.dirname(allowedAbsolutePath), { recursive: true });
|
||||||
|
await fs.writeFile(allowedAbsolutePath, "seed");
|
||||||
|
|
||||||
|
const tools = createOpenClawCodingTools({
|
||||||
|
workspaceDir,
|
||||||
|
trigger: "memory",
|
||||||
|
memoryFlushWritePath: allowedRelativePath,
|
||||||
|
config: {
|
||||||
|
tools: {
|
||||||
|
exec: {
|
||||||
|
applyPatch: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
modelProvider: "openai",
|
||||||
|
modelId: "gpt-5",
|
||||||
|
});
|
||||||
|
|
||||||
|
const writeTool = tools.find((tool) => tool.name === "write");
|
||||||
|
expect(writeTool).toBeDefined();
|
||||||
|
expect(tools.map((tool) => tool.name).toSorted()).toEqual(["read", "write"]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
writeTool!.execute("test-call-memory-deny", {
|
||||||
|
path: outsideFile,
|
||||||
|
content: "should not write here",
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/Memory flush writes are restricted to memory\/2026-03-07\.md/);
|
||||||
|
|
||||||
|
const result = await writeTool!.execute("test-call-memory-append", {
|
||||||
|
path: allowedRelativePath,
|
||||||
|
content: "new note",
|
||||||
|
});
|
||||||
|
expect(hasToolError(result)).toBe(false);
|
||||||
|
expect(result.content).toContainEqual({
|
||||||
|
type: "text",
|
||||||
|
text: "Appended content to memory/2026-03-07.md.",
|
||||||
|
});
|
||||||
|
await expect(fs.readFile(allowedAbsolutePath, "utf-8")).resolves.toBe("seed\nnew note");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
hasAlreadyFlushedForCurrentCompaction,
|
hasAlreadyFlushedForCurrentCompaction,
|
||||||
resolveMemoryFlushContextWindowTokens,
|
resolveMemoryFlushContextWindowTokens,
|
||||||
|
resolveMemoryFlushRelativePathForRun,
|
||||||
resolveMemoryFlushPromptForRun,
|
resolveMemoryFlushPromptForRun,
|
||||||
resolveMemoryFlushSettings,
|
resolveMemoryFlushSettings,
|
||||||
shouldRunMemoryFlush,
|
shouldRunMemoryFlush,
|
||||||
@@ -465,6 +466,11 @@ export async function runMemoryFlushIfNeeded(params: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
let memoryCompactionCompleted = false;
|
let memoryCompactionCompleted = false;
|
||||||
|
const memoryFlushNowMs = Date.now();
|
||||||
|
const memoryFlushWritePath = resolveMemoryFlushRelativePathForRun({
|
||||||
|
cfg: params.cfg,
|
||||||
|
nowMs: memoryFlushNowMs,
|
||||||
|
});
|
||||||
const flushSystemPrompt = [
|
const flushSystemPrompt = [
|
||||||
params.followupRun.run.extraSystemPrompt,
|
params.followupRun.run.extraSystemPrompt,
|
||||||
memoryFlushSettings.systemPrompt,
|
memoryFlushSettings.systemPrompt,
|
||||||
@@ -495,9 +501,11 @@ export async function runMemoryFlushIfNeeded(params: {
|
|||||||
...senderContext,
|
...senderContext,
|
||||||
...runBaseParams,
|
...runBaseParams,
|
||||||
trigger: "memory",
|
trigger: "memory",
|
||||||
|
memoryFlushWritePath,
|
||||||
prompt: resolveMemoryFlushPromptForRun({
|
prompt: resolveMemoryFlushPromptForRun({
|
||||||
prompt: memoryFlushSettings.prompt,
|
prompt: memoryFlushSettings.prompt,
|
||||||
cfg: params.cfg,
|
cfg: params.cfg,
|
||||||
|
nowMs: memoryFlushNowMs,
|
||||||
}),
|
}),
|
||||||
extraSystemPrompt: flushSystemPrompt,
|
extraSystemPrompt: flushSystemPrompt,
|
||||||
bootstrapPromptWarningSignaturesSeen,
|
bootstrapPromptWarningSignaturesSeen,
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ type AgentRunParams = {
|
|||||||
type EmbeddedRunParams = {
|
type EmbeddedRunParams = {
|
||||||
prompt?: string;
|
prompt?: string;
|
||||||
extraSystemPrompt?: string;
|
extraSystemPrompt?: string;
|
||||||
|
memoryFlushWritePath?: string;
|
||||||
bootstrapPromptWarningSignaturesSeen?: string[];
|
bootstrapPromptWarningSignaturesSeen?: string[];
|
||||||
bootstrapPromptWarningSignature?: string;
|
bootstrapPromptWarningSignature?: string;
|
||||||
onAgentEvent?: (evt: { stream?: string; data?: { phase?: string; willRetry?: boolean } }) => void;
|
onAgentEvent?: (evt: { stream?: string; data?: { phase?: string; willRetry?: boolean } }) => void;
|
||||||
@@ -1611,9 +1612,14 @@ describe("runReplyAgent memory flush", () => {
|
|||||||
const flushCall = calls[0];
|
const flushCall = calls[0];
|
||||||
expect(flushCall?.prompt).toContain("Write notes.");
|
expect(flushCall?.prompt).toContain("Write notes.");
|
||||||
expect(flushCall?.prompt).toContain("NO_REPLY");
|
expect(flushCall?.prompt).toContain("NO_REPLY");
|
||||||
|
expect(flushCall?.prompt).toMatch(/memory\/\d{4}-\d{2}-\d{2}\.md/);
|
||||||
|
expect(flushCall?.prompt).toContain("MEMORY.md");
|
||||||
|
expect(flushCall?.memoryFlushWritePath).toMatch(/^memory\/\d{4}-\d{2}-\d{2}\.md$/);
|
||||||
expect(flushCall?.extraSystemPrompt).toContain("extra system");
|
expect(flushCall?.extraSystemPrompt).toContain("extra system");
|
||||||
expect(flushCall?.extraSystemPrompt).toContain("Flush memory now.");
|
expect(flushCall?.extraSystemPrompt).toContain("Flush memory now.");
|
||||||
expect(flushCall?.extraSystemPrompt).toContain("NO_REPLY");
|
expect(flushCall?.extraSystemPrompt).toContain("NO_REPLY");
|
||||||
|
expect(flushCall?.extraSystemPrompt).toContain("memory/YYYY-MM-DD.md");
|
||||||
|
expect(flushCall?.extraSystemPrompt).toContain("MEMORY.md");
|
||||||
expect(calls[1]?.prompt).toBe("hello");
|
expect(calls[1]?.prompt).toBe("hello");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1701,9 +1707,17 @@ describe("runReplyAgent memory flush", () => {
|
|||||||
|
|
||||||
await seedSessionStore({ storePath, sessionKey, entry: sessionEntry });
|
await seedSessionStore({ storePath, sessionKey, entry: sessionEntry });
|
||||||
|
|
||||||
const calls: Array<{ prompt?: string }> = [];
|
const calls: Array<{
|
||||||
|
prompt?: string;
|
||||||
|
extraSystemPrompt?: string;
|
||||||
|
memoryFlushWritePath?: string;
|
||||||
|
}> = [];
|
||||||
state.runEmbeddedPiAgentMock.mockImplementation(async (params: EmbeddedRunParams) => {
|
state.runEmbeddedPiAgentMock.mockImplementation(async (params: EmbeddedRunParams) => {
|
||||||
calls.push({ prompt: params.prompt });
|
calls.push({
|
||||||
|
prompt: params.prompt,
|
||||||
|
extraSystemPrompt: params.extraSystemPrompt,
|
||||||
|
memoryFlushWritePath: params.memoryFlushWritePath,
|
||||||
|
});
|
||||||
if (params.prompt?.includes("Pre-compaction memory flush.")) {
|
if (params.prompt?.includes("Pre-compaction memory flush.")) {
|
||||||
return { payloads: [], meta: {} };
|
return { payloads: [], meta: {} };
|
||||||
}
|
}
|
||||||
@@ -1730,6 +1744,10 @@ describe("runReplyAgent memory flush", () => {
|
|||||||
expect(calls[0]?.prompt).toContain("Pre-compaction memory flush.");
|
expect(calls[0]?.prompt).toContain("Pre-compaction memory flush.");
|
||||||
expect(calls[0]?.prompt).toContain("Current time:");
|
expect(calls[0]?.prompt).toContain("Current time:");
|
||||||
expect(calls[0]?.prompt).toMatch(/memory\/\d{4}-\d{2}-\d{2}\.md/);
|
expect(calls[0]?.prompt).toMatch(/memory\/\d{4}-\d{2}-\d{2}\.md/);
|
||||||
|
expect(calls[0]?.prompt).toContain("MEMORY.md");
|
||||||
|
expect(calls[0]?.memoryFlushWritePath).toMatch(/^memory\/\d{4}-\d{2}-\d{2}\.md$/);
|
||||||
|
expect(calls[0]?.extraSystemPrompt).toContain("memory/YYYY-MM-DD.md");
|
||||||
|
expect(calls[0]?.extraSystemPrompt).toContain("MEMORY.md");
|
||||||
expect(calls[1]?.prompt).toBe("hello");
|
expect(calls[1]?.prompt).toBe("hello");
|
||||||
|
|
||||||
const stored = JSON.parse(await fs.readFile(storePath, "utf-8"));
|
const stored = JSON.parse(await fs.readFile(storePath, "utf-8"));
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import type { OpenClawConfig } from "../../config/config.js";
|
import type { OpenClawConfig } from "../../config/config.js";
|
||||||
import { DEFAULT_MEMORY_FLUSH_PROMPT, resolveMemoryFlushPromptForRun } from "./memory-flush.js";
|
import {
|
||||||
|
DEFAULT_MEMORY_FLUSH_PROMPT,
|
||||||
|
resolveMemoryFlushPromptForRun,
|
||||||
|
resolveMemoryFlushRelativePathForRun,
|
||||||
|
} from "./memory-flush.js";
|
||||||
|
|
||||||
describe("resolveMemoryFlushPromptForRun", () => {
|
describe("resolveMemoryFlushPromptForRun", () => {
|
||||||
const cfg = {
|
const cfg = {
|
||||||
@@ -35,6 +39,15 @@ describe("resolveMemoryFlushPromptForRun", () => {
|
|||||||
expect(prompt).toContain("Current time: already present");
|
expect(prompt).toContain("Current time: already present");
|
||||||
expect((prompt.match(/Current time:/g) ?? []).length).toBe(1);
|
expect((prompt.match(/Current time:/g) ?? []).length).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("resolves the canonical relative memory path using user timezone", () => {
|
||||||
|
const relativePath = resolveMemoryFlushRelativePathForRun({
|
||||||
|
cfg,
|
||||||
|
nowMs: Date.UTC(2026, 1, 16, 15, 0, 0),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(relativePath).toBe("memory/2026-02-16.md");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("DEFAULT_MEMORY_FLUSH_PROMPT", () => {
|
describe("DEFAULT_MEMORY_FLUSH_PROMPT", () => {
|
||||||
|
|||||||
@@ -10,10 +10,23 @@ import { SILENT_REPLY_TOKEN } from "../tokens.js";
|
|||||||
export const DEFAULT_MEMORY_FLUSH_SOFT_TOKENS = 4000;
|
export const DEFAULT_MEMORY_FLUSH_SOFT_TOKENS = 4000;
|
||||||
export const DEFAULT_MEMORY_FLUSH_FORCE_TRANSCRIPT_BYTES = 2 * 1024 * 1024;
|
export const DEFAULT_MEMORY_FLUSH_FORCE_TRANSCRIPT_BYTES = 2 * 1024 * 1024;
|
||||||
|
|
||||||
|
const MEMORY_FLUSH_TARGET_HINT =
|
||||||
|
"Store durable memories only in memory/YYYY-MM-DD.md (create memory/ if needed).";
|
||||||
|
const MEMORY_FLUSH_APPEND_ONLY_HINT =
|
||||||
|
"If memory/YYYY-MM-DD.md already exists, APPEND new content only and do not overwrite existing entries.";
|
||||||
|
const MEMORY_FLUSH_READ_ONLY_HINT =
|
||||||
|
"Treat workspace bootstrap/reference files such as MEMORY.md, SOUL.md, TOOLS.md, and AGENTS.md as read-only during this flush; never overwrite, replace, or edit them.";
|
||||||
|
const MEMORY_FLUSH_REQUIRED_HINTS = [
|
||||||
|
MEMORY_FLUSH_TARGET_HINT,
|
||||||
|
MEMORY_FLUSH_APPEND_ONLY_HINT,
|
||||||
|
MEMORY_FLUSH_READ_ONLY_HINT,
|
||||||
|
];
|
||||||
|
|
||||||
export const DEFAULT_MEMORY_FLUSH_PROMPT = [
|
export const DEFAULT_MEMORY_FLUSH_PROMPT = [
|
||||||
"Pre-compaction memory flush.",
|
"Pre-compaction memory flush.",
|
||||||
"Store durable memories now (use memory/YYYY-MM-DD.md; create memory/ if needed).",
|
MEMORY_FLUSH_TARGET_HINT,
|
||||||
"IMPORTANT: If the file already exists, APPEND new content only — do not overwrite existing entries.",
|
MEMORY_FLUSH_READ_ONLY_HINT,
|
||||||
|
MEMORY_FLUSH_APPEND_ONLY_HINT,
|
||||||
"Do NOT create timestamped variant files (e.g., YYYY-MM-DD-HHMM.md); always use the canonical YYYY-MM-DD.md filename.",
|
"Do NOT create timestamped variant files (e.g., YYYY-MM-DD-HHMM.md); always use the canonical YYYY-MM-DD.md filename.",
|
||||||
`If nothing to store, reply with ${SILENT_REPLY_TOKEN}.`,
|
`If nothing to store, reply with ${SILENT_REPLY_TOKEN}.`,
|
||||||
].join(" ");
|
].join(" ");
|
||||||
@@ -21,6 +34,9 @@ export const DEFAULT_MEMORY_FLUSH_PROMPT = [
|
|||||||
export const DEFAULT_MEMORY_FLUSH_SYSTEM_PROMPT = [
|
export const DEFAULT_MEMORY_FLUSH_SYSTEM_PROMPT = [
|
||||||
"Pre-compaction memory flush turn.",
|
"Pre-compaction memory flush turn.",
|
||||||
"The session is near auto-compaction; capture durable memories to disk.",
|
"The session is near auto-compaction; capture durable memories to disk.",
|
||||||
|
MEMORY_FLUSH_TARGET_HINT,
|
||||||
|
MEMORY_FLUSH_READ_ONLY_HINT,
|
||||||
|
MEMORY_FLUSH_APPEND_ONLY_HINT,
|
||||||
`You may reply, but usually ${SILENT_REPLY_TOKEN} is correct.`,
|
`You may reply, but usually ${SILENT_REPLY_TOKEN} is correct.`,
|
||||||
].join(" ");
|
].join(" ");
|
||||||
|
|
||||||
@@ -40,14 +56,29 @@ function formatDateStampInTimezone(nowMs: number, timezone: string): string {
|
|||||||
return new Date(nowMs).toISOString().slice(0, 10);
|
return new Date(nowMs).toISOString().slice(0, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveMemoryFlushRelativePathForRun(params: {
|
||||||
|
cfg?: OpenClawConfig;
|
||||||
|
nowMs?: number;
|
||||||
|
}): string {
|
||||||
|
const nowMs = Number.isFinite(params.nowMs) ? (params.nowMs as number) : Date.now();
|
||||||
|
const { userTimezone } = resolveCronStyleNow(params.cfg ?? {}, nowMs);
|
||||||
|
const dateStamp = formatDateStampInTimezone(nowMs, userTimezone);
|
||||||
|
return `memory/${dateStamp}.md`;
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveMemoryFlushPromptForRun(params: {
|
export function resolveMemoryFlushPromptForRun(params: {
|
||||||
prompt: string;
|
prompt: string;
|
||||||
cfg?: OpenClawConfig;
|
cfg?: OpenClawConfig;
|
||||||
nowMs?: number;
|
nowMs?: number;
|
||||||
}): string {
|
}): string {
|
||||||
const nowMs = Number.isFinite(params.nowMs) ? (params.nowMs as number) : Date.now();
|
const nowMs = Number.isFinite(params.nowMs) ? (params.nowMs as number) : Date.now();
|
||||||
const { userTimezone, timeLine } = resolveCronStyleNow(params.cfg ?? {}, nowMs);
|
const { timeLine } = resolveCronStyleNow(params.cfg ?? {}, nowMs);
|
||||||
const dateStamp = formatDateStampInTimezone(nowMs, userTimezone);
|
const dateStamp = resolveMemoryFlushRelativePathForRun({
|
||||||
|
cfg: params.cfg,
|
||||||
|
nowMs,
|
||||||
|
})
|
||||||
|
.replace(/^memory\//, "")
|
||||||
|
.replace(/\.md$/, "");
|
||||||
const withDate = params.prompt.replaceAll("YYYY-MM-DD", dateStamp).trimEnd();
|
const withDate = params.prompt.replaceAll("YYYY-MM-DD", dateStamp).trimEnd();
|
||||||
if (!withDate) {
|
if (!withDate) {
|
||||||
return timeLine;
|
return timeLine;
|
||||||
@@ -90,8 +121,12 @@ export function resolveMemoryFlushSettings(cfg?: OpenClawConfig): MemoryFlushSet
|
|||||||
const forceFlushTranscriptBytes =
|
const forceFlushTranscriptBytes =
|
||||||
parseNonNegativeByteSize(defaults?.forceFlushTranscriptBytes) ??
|
parseNonNegativeByteSize(defaults?.forceFlushTranscriptBytes) ??
|
||||||
DEFAULT_MEMORY_FLUSH_FORCE_TRANSCRIPT_BYTES;
|
DEFAULT_MEMORY_FLUSH_FORCE_TRANSCRIPT_BYTES;
|
||||||
const prompt = defaults?.prompt?.trim() || DEFAULT_MEMORY_FLUSH_PROMPT;
|
const prompt = ensureMemoryFlushSafetyHints(
|
||||||
const systemPrompt = defaults?.systemPrompt?.trim() || DEFAULT_MEMORY_FLUSH_SYSTEM_PROMPT;
|
defaults?.prompt?.trim() || DEFAULT_MEMORY_FLUSH_PROMPT,
|
||||||
|
);
|
||||||
|
const systemPrompt = ensureMemoryFlushSafetyHints(
|
||||||
|
defaults?.systemPrompt?.trim() || DEFAULT_MEMORY_FLUSH_SYSTEM_PROMPT,
|
||||||
|
);
|
||||||
const reserveTokensFloor =
|
const reserveTokensFloor =
|
||||||
normalizeNonNegativeInt(cfg?.agents?.defaults?.compaction?.reserveTokensFloor) ??
|
normalizeNonNegativeInt(cfg?.agents?.defaults?.compaction?.reserveTokensFloor) ??
|
||||||
DEFAULT_PI_COMPACTION_RESERVE_TOKENS_FLOOR;
|
DEFAULT_PI_COMPACTION_RESERVE_TOKENS_FLOOR;
|
||||||
@@ -113,6 +148,16 @@ function ensureNoReplyHint(text: string): string {
|
|||||||
return `${text}\n\nIf no user-visible reply is needed, start with ${SILENT_REPLY_TOKEN}.`;
|
return `${text}\n\nIf no user-visible reply is needed, start with ${SILENT_REPLY_TOKEN}.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ensureMemoryFlushSafetyHints(text: string): string {
|
||||||
|
let next = text.trim();
|
||||||
|
for (const hint of MEMORY_FLUSH_REQUIRED_HINTS) {
|
||||||
|
if (!next.includes(hint)) {
|
||||||
|
next = next ? `${next}\n\n${hint}` : hint;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveMemoryFlushContextWindowTokens(params: {
|
export function resolveMemoryFlushContextWindowTokens(params: {
|
||||||
modelId?: string;
|
modelId?: string;
|
||||||
agentCfgContextTokens?: number;
|
agentCfgContextTokens?: number;
|
||||||
|
|||||||
@@ -203,6 +203,10 @@ describe("memory flush settings", () => {
|
|||||||
expect(settings?.forceFlushTranscriptBytes).toBe(DEFAULT_MEMORY_FLUSH_FORCE_TRANSCRIPT_BYTES);
|
expect(settings?.forceFlushTranscriptBytes).toBe(DEFAULT_MEMORY_FLUSH_FORCE_TRANSCRIPT_BYTES);
|
||||||
expect(settings?.prompt.length).toBeGreaterThan(0);
|
expect(settings?.prompt.length).toBeGreaterThan(0);
|
||||||
expect(settings?.systemPrompt.length).toBeGreaterThan(0);
|
expect(settings?.systemPrompt.length).toBeGreaterThan(0);
|
||||||
|
expect(settings?.prompt).toContain("memory/YYYY-MM-DD.md");
|
||||||
|
expect(settings?.prompt).toContain("MEMORY.md");
|
||||||
|
expect(settings?.systemPrompt).toContain("memory/YYYY-MM-DD.md");
|
||||||
|
expect(settings?.systemPrompt).toContain("MEMORY.md");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("respects disable flag", () => {
|
it("respects disable flag", () => {
|
||||||
@@ -230,6 +234,10 @@ describe("memory flush settings", () => {
|
|||||||
});
|
});
|
||||||
expect(settings?.prompt).toContain("NO_REPLY");
|
expect(settings?.prompt).toContain("NO_REPLY");
|
||||||
expect(settings?.systemPrompt).toContain("NO_REPLY");
|
expect(settings?.systemPrompt).toContain("NO_REPLY");
|
||||||
|
expect(settings?.prompt).toContain("memory/YYYY-MM-DD.md");
|
||||||
|
expect(settings?.prompt).toContain("MEMORY.md");
|
||||||
|
expect(settings?.systemPrompt).toContain("memory/YYYY-MM-DD.md");
|
||||||
|
expect(settings?.systemPrompt).toContain("MEMORY.md");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to defaults when numeric values are invalid", () => {
|
it("falls back to defaults when numeric values are invalid", () => {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
} from "../test-utils/symlink-rebind-race.js";
|
} from "../test-utils/symlink-rebind-race.js";
|
||||||
import { createTrackedTempDirs } from "../test-utils/tracked-temp-dirs.js";
|
import { createTrackedTempDirs } from "../test-utils/tracked-temp-dirs.js";
|
||||||
import {
|
import {
|
||||||
|
appendFileWithinRoot,
|
||||||
copyFileWithinRoot,
|
copyFileWithinRoot,
|
||||||
createRootScopedReadFile,
|
createRootScopedReadFile,
|
||||||
SafeOpenError,
|
SafeOpenError,
|
||||||
@@ -246,6 +247,22 @@ describe("fs-safe", () => {
|
|||||||
await expect(fs.readFile(path.join(root, "nested", "out.txt"), "utf8")).resolves.toBe("hello");
|
await expect(fs.readFile(path.join(root, "nested", "out.txt"), "utf8")).resolves.toBe("hello");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("appends to a file within root safely", async () => {
|
||||||
|
const root = await tempDirs.make("openclaw-fs-safe-root-");
|
||||||
|
const targetPath = path.join(root, "nested", "out.txt");
|
||||||
|
await fs.mkdir(path.dirname(targetPath), { recursive: true });
|
||||||
|
await fs.writeFile(targetPath, "seed");
|
||||||
|
|
||||||
|
await appendFileWithinRoot({
|
||||||
|
rootDir: root,
|
||||||
|
relativePath: "nested/out.txt",
|
||||||
|
data: "next",
|
||||||
|
prependNewlineIfNeeded: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(fs.readFile(targetPath, "utf8")).resolves.toBe("seed\nnext");
|
||||||
|
});
|
||||||
|
|
||||||
it("does not truncate existing target when atomic rename fails", async () => {
|
it("does not truncate existing target when atomic rename fails", async () => {
|
||||||
const root = await tempDirs.make("openclaw-fs-safe-root-");
|
const root = await tempDirs.make("openclaw-fs-safe-root-");
|
||||||
const targetPath = path.join(root, "nested", "out.txt");
|
const targetPath = path.join(root, "nested", "out.txt");
|
||||||
@@ -439,6 +456,25 @@ describe("fs-safe", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.runIf(process.platform !== "win32")("rejects appending through hardlink aliases", async () => {
|
||||||
|
const root = await tempDirs.make("openclaw-fs-safe-root-");
|
||||||
|
const hardlinkPath = path.join(root, "alias.txt");
|
||||||
|
await withOutsideHardlinkAlias({
|
||||||
|
aliasPath: hardlinkPath,
|
||||||
|
run: async (outsideFile) => {
|
||||||
|
await expect(
|
||||||
|
appendFileWithinRoot({
|
||||||
|
rootDir: root,
|
||||||
|
relativePath: "alias.txt",
|
||||||
|
data: "pwned",
|
||||||
|
prependNewlineIfNeeded: true,
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ code: "invalid-path" });
|
||||||
|
await expect(fs.readFile(outsideFile, "utf8")).resolves.toBe("outside");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("does not truncate out-of-root file when symlink retarget races write open", async () => {
|
it("does not truncate out-of-root file when symlink retarget races write open", async () => {
|
||||||
const { root, outside, slot, outsideTarget } = await setupSymlinkWriteRaceFixture({
|
const { root, outside, slot, outsideTarget } = await setupSymlinkWriteRaceFixture({
|
||||||
seedInsideTarget: true,
|
seedInsideTarget: true,
|
||||||
@@ -459,6 +495,27 @@ describe("fs-safe", () => {
|
|||||||
await expect(fs.readFile(outsideTarget, "utf8")).resolves.toBe("X".repeat(4096));
|
await expect(fs.readFile(outsideTarget, "utf8")).resolves.toBe("X".repeat(4096));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not clobber out-of-root file when symlink retarget races append open", async () => {
|
||||||
|
const { root, outside, slot, outsideTarget } = await setupSymlinkWriteRaceFixture({
|
||||||
|
seedInsideTarget: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expectSymlinkWriteRaceRejectsOutside({
|
||||||
|
slotPath: slot,
|
||||||
|
outsideDir: outside,
|
||||||
|
runWrite: async (relativePath) =>
|
||||||
|
await appendFileWithinRoot({
|
||||||
|
rootDir: root,
|
||||||
|
relativePath,
|
||||||
|
data: "new-content",
|
||||||
|
mkdir: false,
|
||||||
|
prependNewlineIfNeeded: true,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(fs.readFile(outsideTarget, "utf8")).resolves.toBe("X".repeat(4096));
|
||||||
|
});
|
||||||
|
|
||||||
it("does not clobber out-of-root file when symlink retarget races write-from-path open", async () => {
|
it("does not clobber out-of-root file when symlink retarget races write-from-path open", async () => {
|
||||||
const { root, outside, slot, outsideTarget } = await setupSymlinkWriteRaceFixture();
|
const { root, outside, slot, outsideTarget } = await setupSymlinkWriteRaceFixture();
|
||||||
const sourceDir = await tempDirs.make("openclaw-fs-safe-source-");
|
const sourceDir = await tempDirs.make("openclaw-fs-safe-source-");
|
||||||
|
|||||||
@@ -57,6 +57,14 @@ const OPEN_WRITE_CREATE_FLAGS =
|
|||||||
fsConstants.O_CREAT |
|
fsConstants.O_CREAT |
|
||||||
fsConstants.O_EXCL |
|
fsConstants.O_EXCL |
|
||||||
(SUPPORTS_NOFOLLOW ? fsConstants.O_NOFOLLOW : 0);
|
(SUPPORTS_NOFOLLOW ? fsConstants.O_NOFOLLOW : 0);
|
||||||
|
const OPEN_APPEND_EXISTING_FLAGS =
|
||||||
|
fsConstants.O_RDWR | fsConstants.O_APPEND | (SUPPORTS_NOFOLLOW ? fsConstants.O_NOFOLLOW : 0);
|
||||||
|
const OPEN_APPEND_CREATE_FLAGS =
|
||||||
|
fsConstants.O_RDWR |
|
||||||
|
fsConstants.O_APPEND |
|
||||||
|
fsConstants.O_CREAT |
|
||||||
|
fsConstants.O_EXCL |
|
||||||
|
(SUPPORTS_NOFOLLOW ? fsConstants.O_NOFOLLOW : 0);
|
||||||
|
|
||||||
const ensureTrailingSep = (value: string) => (value.endsWith(path.sep) ? value : value + path.sep);
|
const ensureTrailingSep = (value: string) => (value.endsWith(path.sep) ? value : value + path.sep);
|
||||||
|
|
||||||
@@ -375,6 +383,7 @@ export async function openWritableFileWithinRoot(params: {
|
|||||||
mkdir?: boolean;
|
mkdir?: boolean;
|
||||||
mode?: number;
|
mode?: number;
|
||||||
truncateExisting?: boolean;
|
truncateExisting?: boolean;
|
||||||
|
append?: boolean;
|
||||||
}): Promise<SafeWritableOpenResult> {
|
}): Promise<SafeWritableOpenResult> {
|
||||||
const { rootReal, rootWithSep, resolved } = await resolvePathWithinRoot(params);
|
const { rootReal, rootWithSep, resolved } = await resolvePathWithinRoot(params);
|
||||||
try {
|
try {
|
||||||
@@ -410,14 +419,16 @@ export async function openWritableFileWithinRoot(params: {
|
|||||||
|
|
||||||
let handle: FileHandle;
|
let handle: FileHandle;
|
||||||
let createdForWrite = false;
|
let createdForWrite = false;
|
||||||
|
const existingFlags = params.append ? OPEN_APPEND_EXISTING_FLAGS : OPEN_WRITE_EXISTING_FLAGS;
|
||||||
|
const createFlags = params.append ? OPEN_APPEND_CREATE_FLAGS : OPEN_WRITE_CREATE_FLAGS;
|
||||||
try {
|
try {
|
||||||
try {
|
try {
|
||||||
handle = await fs.open(ioPath, OPEN_WRITE_EXISTING_FLAGS, fileMode);
|
handle = await fs.open(ioPath, existingFlags, fileMode);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!isNotFoundPathError(err)) {
|
if (!isNotFoundPathError(err)) {
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
handle = await fs.open(ioPath, OPEN_WRITE_CREATE_FLAGS, fileMode);
|
handle = await fs.open(ioPath, createFlags, fileMode);
|
||||||
createdForWrite = true;
|
createdForWrite = true;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -469,7 +480,7 @@ export async function openWritableFileWithinRoot(params: {
|
|||||||
|
|
||||||
// Truncate only after boundary and identity checks complete. This avoids
|
// Truncate only after boundary and identity checks complete. This avoids
|
||||||
// irreversible side effects if a symlink target changes before validation.
|
// irreversible side effects if a symlink target changes before validation.
|
||||||
if (params.truncateExisting !== false && !createdForWrite) {
|
if (params.append !== true && params.truncateExisting !== false && !createdForWrite) {
|
||||||
await handle.truncate(0);
|
await handle.truncate(0);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -489,6 +500,50 @@ export async function openWritableFileWithinRoot(params: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function appendFileWithinRoot(params: {
|
||||||
|
rootDir: string;
|
||||||
|
relativePath: string;
|
||||||
|
data: string | Buffer;
|
||||||
|
encoding?: BufferEncoding;
|
||||||
|
mkdir?: boolean;
|
||||||
|
prependNewlineIfNeeded?: boolean;
|
||||||
|
}): Promise<void> {
|
||||||
|
const target = await openWritableFileWithinRoot({
|
||||||
|
rootDir: params.rootDir,
|
||||||
|
relativePath: params.relativePath,
|
||||||
|
mkdir: params.mkdir,
|
||||||
|
truncateExisting: false,
|
||||||
|
append: true,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
let prefix = "";
|
||||||
|
if (
|
||||||
|
params.prependNewlineIfNeeded === true &&
|
||||||
|
!target.createdForWrite &&
|
||||||
|
target.openedStat.size > 0 &&
|
||||||
|
((typeof params.data === "string" && !params.data.startsWith("\n")) ||
|
||||||
|
(Buffer.isBuffer(params.data) && params.data.length > 0 && params.data[0] !== 0x0a))
|
||||||
|
) {
|
||||||
|
const lastByte = Buffer.alloc(1);
|
||||||
|
const { bytesRead } = await target.handle.read(lastByte, 0, 1, target.openedStat.size - 1);
|
||||||
|
if (bytesRead === 1 && lastByte[0] !== 0x0a) {
|
||||||
|
prefix = "\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof params.data === "string") {
|
||||||
|
await target.handle.appendFile(`${prefix}${params.data}`, params.encoding ?? "utf8");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload =
|
||||||
|
prefix.length > 0 ? Buffer.concat([Buffer.from(prefix, "utf8"), params.data]) : params.data;
|
||||||
|
await target.handle.appendFile(payload);
|
||||||
|
} finally {
|
||||||
|
await target.handle.close().catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function writeFileWithinRoot(params: {
|
export async function writeFileWithinRoot(params: {
|
||||||
rootDir: string;
|
rootDir: string;
|
||||||
relativePath: string;
|
relativePath: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user