mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
refactor(fs): adopt shared safe filesystem primitives (#113596)
* refactor(fs): adopt fs-safe helpers * fix(zalouser): keep stable per-profile QR temp path * fix(fs): route adoption through owned facades
This commit is contained in:
committed by
GitHub
parent
91b5b43d78
commit
b0603cb443
@@ -10,6 +10,7 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { tryReadSecretFileSync } from "openclaw/plugin-sdk/secret-file-runtime";
|
||||
import { resolveOAuthDir } from "openclaw/plugin-sdk/state-paths";
|
||||
|
||||
const RELAY_SECRET_FILE = "browser-extension-relay.secret";
|
||||
@@ -26,11 +27,10 @@ function normalizeToken(raw: string): string | null {
|
||||
|
||||
/** Read the host-local relay token, or null when it has not been created yet. */
|
||||
export function readExtensionRelayToken(env: NodeJS.ProcessEnv = process.env): string | null {
|
||||
try {
|
||||
return normalizeToken(fs.readFileSync(resolveExtensionRelaySecretPath(env), "utf8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return normalizeToken(
|
||||
tryReadSecretFileSync(resolveExtensionRelaySecretPath(env), "browser extension relay secret") ??
|
||||
"",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,6 +3,7 @@ import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { resolvePluginConfigObject } from "openclaw/plugin-sdk/plugin-config-runtime";
|
||||
import type { PluginDoctorStateMigration } from "openclaw/plugin-sdk/runtime-doctor";
|
||||
import { pathExists } from "openclaw/plugin-sdk/security-runtime";
|
||||
import {
|
||||
asOptionalRecord as readRecord,
|
||||
readStringValue as readString,
|
||||
@@ -36,15 +37,6 @@ async function listDocumentIds(documentsDir: string): Promise<string[]> {
|
||||
}
|
||||
}
|
||||
|
||||
async function pathExists(candidate: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(candidate);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export const stateMigrations: PluginDoctorStateMigration[] = [
|
||||
{
|
||||
id: "canvas-custom-root-documents-to-core",
|
||||
|
||||
@@ -3,7 +3,7 @@ import { mkdtemp, mkdir, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createCanvasTool } from "./tool.js";
|
||||
import { CANVAS_JSONL_MAX_BYTES, createCanvasTool } from "./tool.js";
|
||||
|
||||
const VALID_A2UI_V08_JSONL = [
|
||||
JSON.stringify({
|
||||
@@ -80,6 +80,25 @@ describe("Canvas tool", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects jsonlPath files above the shared bounded-read limit", async () => {
|
||||
tempRoot = await mkdtemp(path.join(os.tmpdir(), "openclaw-canvas-tool-"));
|
||||
const workspaceDir = path.join(tempRoot, "workspace");
|
||||
await mkdir(workspaceDir);
|
||||
await writeFile(
|
||||
path.join(workspaceDir, "events.jsonl"),
|
||||
Buffer.alloc(CANVAS_JSONL_MAX_BYTES + 1),
|
||||
);
|
||||
const tool = createCanvasTool({ workspaceDir });
|
||||
|
||||
await expect(
|
||||
tool.execute("tool-call-1", {
|
||||
action: "a2ui_push",
|
||||
jsonlPath: "events.jsonl",
|
||||
}),
|
||||
).rejects.toThrow(`exceeds ${CANVAS_JSONL_MAX_BYTES} bytes`);
|
||||
expect(mocks.callGatewayTool).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("applies configured image limits to canvas snapshots", async () => {
|
||||
mocks.callGatewayTool.mockResolvedValue({
|
||||
payload: {
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from "openclaw/plugin-sdk/channel-actions";
|
||||
import { readFiniteNumberParam, readPositiveIntegerParam } from "openclaw/plugin-sdk/param-readers";
|
||||
import type { AnyAgentTool, OpenClawConfig } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { readRegularFile } from "openclaw/plugin-sdk/security-runtime";
|
||||
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
|
||||
import { validateSupportedA2UIJsonl } from "./a2ui-jsonl.js";
|
||||
import { normalizeCanvasSnapshotFileExtension, parseCanvasSnapshotPayload } from "./cli-helpers.js";
|
||||
@@ -32,6 +33,8 @@ type CanvasImageSanitizationLimits = {
|
||||
maxDimensionPx?: number;
|
||||
};
|
||||
|
||||
export const CANVAS_JSONL_MAX_BYTES = 16 * 1024 * 1024;
|
||||
|
||||
function readGatewayCallOptions(params: Record<string, unknown>) {
|
||||
return {
|
||||
gatewayUrl: readStringParam(params, "gatewayUrl", { trim: false }),
|
||||
@@ -78,7 +81,9 @@ async function readJsonlFromPath(jsonlPath: string, workspaceDir?: string): Prom
|
||||
if (!isPathInsideRoot(workspaceReal, resolvedReal)) {
|
||||
throw new Error("jsonlPath outside workspace");
|
||||
}
|
||||
return await fs.readFile(resolvedReal, "utf8");
|
||||
return (
|
||||
await readRegularFile({ filePath: resolvedReal, maxBytes: CANVAS_JSONL_MAX_BYTES })
|
||||
).buffer.toString("utf8");
|
||||
}
|
||||
|
||||
function resolveCanvasImageSanitizationLimits(
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
// Google plugin module implements oauth.credentials behavior.
|
||||
import { existsSync, readFileSync, readdirSync, realpathSync } from "node:fs";
|
||||
import { existsSync, readdirSync, realpathSync } from "node:fs";
|
||||
import type { Dirent } from "node:fs";
|
||||
import { delimiter, dirname, join } from "node:path";
|
||||
import { readSecretFileSync } from "openclaw/plugin-sdk/secret-file-runtime";
|
||||
import { lowercasePreservingWhitespace } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { CLIENT_ID_KEYS, CLIENT_SECRET_KEYS } from "./oauth.shared.js";
|
||||
|
||||
type CredentialFs = {
|
||||
existsSync: (path: Parameters<typeof existsSync>[0]) => ReturnType<typeof existsSync>;
|
||||
readFileSync: (path: Parameters<typeof readFileSync>[0], encoding: "utf8") => string;
|
||||
readFileSync: (path: string, encoding: "utf8") => string;
|
||||
realpathSync: (path: Parameters<typeof realpathSync>[0]) => string;
|
||||
readdirSync: (
|
||||
path: Parameters<typeof readdirSync>[0],
|
||||
@@ -17,7 +18,11 @@ type CredentialFs = {
|
||||
|
||||
const defaultFs: CredentialFs = {
|
||||
existsSync,
|
||||
readFileSync,
|
||||
readFileSync: (path) =>
|
||||
readSecretFileSync(path, "Gemini CLI OAuth credentials", {
|
||||
maxBytes: 1024 * 1024,
|
||||
rejectHardlinks: false,
|
||||
}),
|
||||
realpathSync,
|
||||
readdirSync,
|
||||
};
|
||||
|
||||
@@ -28,7 +28,7 @@ export async function readPolicyFile(
|
||||
ocDocName: basename(displayName),
|
||||
};
|
||||
} catch (err) {
|
||||
if (isNotFound(err)) {
|
||||
if (isNotFoundPathError(err)) {
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
@@ -48,7 +48,7 @@ export async function readExecApprovalsFile(
|
||||
ocDocName: "exec-approvals.json",
|
||||
};
|
||||
} catch (err) {
|
||||
if (isNotFound(err)) {
|
||||
if (isNotFoundPathError(err)) {
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
@@ -64,7 +64,7 @@ export async function readWorkspaceFile(
|
||||
const fs = await loadFsPromisesModule();
|
||||
return { raw: await fs.readFile(path, "utf-8"), path };
|
||||
} catch (err) {
|
||||
if (isNotFound(err)) {
|
||||
if (isNotFoundPathError(err)) {
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
@@ -129,7 +129,7 @@ function resolveWorkspacePath(ctx: HealthCheckContext, fileName: string): string
|
||||
return resolve(ctx.cwd ?? process.cwd(), fileName);
|
||||
}
|
||||
|
||||
function isNotFound(err: unknown): boolean {
|
||||
function isNotFoundPathError(err: unknown): boolean {
|
||||
return typeof err === "object" && err !== null && "code" in err && err.code === "ENOENT";
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createInterface } from "node:readline/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { resolveSecretPlanTargetByPath } from "openclaw/plugin-sdk/secret-ref-runtime";
|
||||
import { pathExists } from "openclaw/plugin-sdk/security-runtime";
|
||||
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
|
||||
import { parseVaultSecretId } from "../vault-secret-id.js";
|
||||
|
||||
@@ -202,15 +203,6 @@ function resolveStatusProviderAlias(config: OpenClawConfig, requestedAlias?: str
|
||||
return configuredAliases[0] ?? VAULT_PROVIDER_ALIAS;
|
||||
}
|
||||
|
||||
async function pathExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function resolverScriptPathCandidates(baseUrl: string): [string, string] {
|
||||
return [
|
||||
fileURLToPath(new URL("../vault-secret-ref-resolver.js", baseUrl)),
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { writeQrDataUrlToTempFile } from "./qr-temp-file.js";
|
||||
|
||||
describe("writeQrDataUrlToTempFile", () => {
|
||||
const profile = `test/profile-${process.pid}`;
|
||||
const expectedPath = path.join(
|
||||
resolvePreferredOpenClawTmpDir(),
|
||||
`openclaw-zalouser-qr-test-profile-${process.pid}.png`,
|
||||
);
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(expectedPath, { force: true });
|
||||
});
|
||||
|
||||
it("overwrites the stable per-profile path and enforces private mode", async () => {
|
||||
const firstData = Buffer.from("first-qr-image");
|
||||
const secondData = Buffer.from("second-qr-image");
|
||||
const first = await writeQrDataUrlToTempFile(
|
||||
`data:image/png;base64,${firstData.toString("base64")}`,
|
||||
profile,
|
||||
);
|
||||
expect(first).toBe(expectedPath);
|
||||
await fs.chmod(expectedPath, 0o644);
|
||||
|
||||
const second = await writeQrDataUrlToTempFile(
|
||||
`data:image/png;base64,${secondData.toString("base64")}`,
|
||||
profile,
|
||||
);
|
||||
expect(second).toBe(expectedPath);
|
||||
await expect(fs.readFile(expectedPath)).resolves.toEqual(secondData);
|
||||
if (process.platform !== "win32") {
|
||||
expect((await fs.stat(expectedPath)).mode & 0o777).toBe(0o600);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -14,10 +14,12 @@ export async function writeQrDataUrlToTempFile(
|
||||
return null;
|
||||
}
|
||||
const safeProfile = profile.replace(/[^a-zA-Z0-9_-]+/g, "-") || "default";
|
||||
// The stable private-root name lets QR refreshes overwrite instead of accumulating temp files.
|
||||
const filePath = path.join(
|
||||
resolvePreferredOpenClawTmpDir(),
|
||||
`openclaw-zalouser-qr-${safeProfile}.png`,
|
||||
);
|
||||
await fsp.writeFile(filePath, Buffer.from(base64, "base64"));
|
||||
await fsp.writeFile(filePath, Buffer.from(base64, "base64"), { mode: 0o600 });
|
||||
await fsp.chmod(filePath, 0o600);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* These checks intentionally avoid loading secret-bearing credential payloads.
|
||||
*/
|
||||
import fs from "node:fs";
|
||||
import { tryReadJsonSync } from "../../infra/json-files.js";
|
||||
import { evaluateStoredCredentialEligibility } from "./credential-state.js";
|
||||
import {
|
||||
resolveAuthStatePath,
|
||||
@@ -27,14 +28,6 @@ function hasStoredAuthProfileFiles(agentDir?: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function readJsonFile(pathname: string): unknown {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(pathname, "utf8")) as unknown;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeProvider(provider: string): string {
|
||||
return provider.trim().toLowerCase();
|
||||
}
|
||||
@@ -153,13 +146,17 @@ export function hasAuthProfileStoreSourceForProvider(
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
rawStoreHasProviderProfile(readJsonFile(resolveAuthStorePath(agentDir)), provider, profileIds)
|
||||
rawStoreHasProviderProfile(
|
||||
tryReadJsonSync(resolveAuthStorePath(agentDir)),
|
||||
provider,
|
||||
profileIds,
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
rawStoreHasProviderProfile(
|
||||
readJsonFile(resolveLegacyAuthStorePath(agentDir)),
|
||||
tryReadJsonSync(resolveLegacyAuthStorePath(agentDir)),
|
||||
provider,
|
||||
profileIds,
|
||||
)
|
||||
@@ -179,11 +176,11 @@ export function hasAuthProfileStoreSourceForProvider(
|
||||
if (runtimeStoreHasProviderProfile(mainRuntimeStore, provider, profileIds)) {
|
||||
return true;
|
||||
}
|
||||
if (rawStoreHasProviderProfile(readJsonFile(resolveAuthStorePath()), provider, profileIds)) {
|
||||
if (rawStoreHasProviderProfile(tryReadJsonSync(resolveAuthStorePath()), provider, profileIds)) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
rawStoreHasProviderProfile(readJsonFile(resolveLegacyAuthStorePath()), provider, profileIds)
|
||||
rawStoreHasProviderProfile(tryReadJsonSync(resolveLegacyAuthStorePath()), provider, profileIds)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
*
|
||||
* Expands user/file URL inputs and resolves read/write paths against the active cwd with macOS filename variants.
|
||||
*/
|
||||
import { accessSync, constants } from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import { isAbsolute, resolve as resolvePath } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { pathExistsSync } from "../../../infra/fs-safe.js";
|
||||
|
||||
const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
|
||||
const NARROW_NO_BREAK_SPACE = "\u202F";
|
||||
@@ -29,15 +29,6 @@ function tryCurlyQuoteVariant(filePath: string): string {
|
||||
return filePath.replace(/'/g, "\u2019");
|
||||
}
|
||||
|
||||
function fileExists(filePath: string): boolean {
|
||||
try {
|
||||
accessSync(filePath, constants.F_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeAtPrefix(filePath: string): string {
|
||||
return filePath.startsWith("@") ? filePath.slice(1) : filePath;
|
||||
}
|
||||
@@ -75,31 +66,31 @@ export function resolveToCwd(filePath: string, cwd: string): string {
|
||||
export function resolveReadPath(filePath: string, cwd: string): string {
|
||||
const resolved = resolveToCwd(filePath, cwd);
|
||||
|
||||
if (fileExists(resolved)) {
|
||||
if (pathExistsSync(resolved)) {
|
||||
return resolved;
|
||||
}
|
||||
|
||||
// Try macOS AM/PM variant (narrow no-break space before AM/PM)
|
||||
const amPmVariant = tryMacOSScreenshotPath(resolved);
|
||||
if (amPmVariant !== resolved && fileExists(amPmVariant)) {
|
||||
if (amPmVariant !== resolved && pathExistsSync(amPmVariant)) {
|
||||
return amPmVariant;
|
||||
}
|
||||
|
||||
// Try NFD variant (macOS stores filenames in NFD form)
|
||||
const nfdVariant = tryNFDVariant(resolved);
|
||||
if (nfdVariant !== resolved && fileExists(nfdVariant)) {
|
||||
if (nfdVariant !== resolved && pathExistsSync(nfdVariant)) {
|
||||
return nfdVariant;
|
||||
}
|
||||
|
||||
// Try curly quote variant (macOS uses U+2019 in screenshot names)
|
||||
const curlyVariant = tryCurlyQuoteVariant(resolved);
|
||||
if (curlyVariant !== resolved && fileExists(curlyVariant)) {
|
||||
if (curlyVariant !== resolved && pathExistsSync(curlyVariant)) {
|
||||
return curlyVariant;
|
||||
}
|
||||
|
||||
// Try combined NFD + curly quote (for French macOS screenshots like "Capture d'écran")
|
||||
const nfdCurlyVariant = tryCurlyQuoteVariant(nfdVariant);
|
||||
if (nfdCurlyVariant !== resolved && fileExists(nfdCurlyVariant)) {
|
||||
if (nfdCurlyVariant !== resolved && pathExistsSync(nfdCurlyVariant)) {
|
||||
return nfdCurlyVariant;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { resolveStateDir } from "../config/paths.js";
|
||||
import { withTempWorkspace } from "../infra/private-temp-workspace.js";
|
||||
import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
|
||||
import { killProcessTree } from "../process/kill-tree.js";
|
||||
|
||||
const SNAPSHOT_VERSION = 1;
|
||||
@@ -260,39 +262,41 @@ async function validateSnapshot(
|
||||
|
||||
async function captureShellSnapshot(opts: ShellSnapshotWrapOptions): Promise<string | null> {
|
||||
const shellName = path.basename(opts.shell);
|
||||
const captureOutputDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-shell-snapshot-"));
|
||||
await fs.chmod(captureOutputDir, 0o700);
|
||||
const captureOutputPath = path.join(captureOutputDir, "snapshot.out");
|
||||
const captureOutputFile = await fs.open(captureOutputPath, "wx", 0o600);
|
||||
await captureOutputFile.close();
|
||||
const captureCommand = [
|
||||
"{",
|
||||
buildStartupSourceScript(shellName),
|
||||
`printf '\\n%s\\n' ${shQuote(CAPTURE_MARKER)}`,
|
||||
buildAliasCaptureScript(shellName),
|
||||
"(typeset -f 2>/dev/null || declare -f 2>/dev/null || true)",
|
||||
`printf '\\n%s\\n' ${shQuote(ENV_MARKER)}`,
|
||||
`${shQuote(process.execPath)} -e ${shQuote(ENV_CAPTURE_NODE_SCRIPT)}`,
|
||||
`} > ${shQuote(captureOutputPath)}`,
|
||||
].join("\n");
|
||||
return await withTempWorkspace(
|
||||
{
|
||||
rootDir: resolvePreferredOpenClawTmpDir(),
|
||||
prefix: "openclaw-shell-snapshot-",
|
||||
dirMode: 0o700,
|
||||
mode: 0o600,
|
||||
},
|
||||
async (workspace) => {
|
||||
const captureOutputPath = await workspace.writeText("snapshot.out", "");
|
||||
const captureCommand = [
|
||||
"{",
|
||||
buildStartupSourceScript(shellName),
|
||||
`printf '\\n%s\\n' ${shQuote(CAPTURE_MARKER)}`,
|
||||
buildAliasCaptureScript(shellName),
|
||||
"(typeset -f 2>/dev/null || declare -f 2>/dev/null || true)",
|
||||
`printf '\\n%s\\n' ${shQuote(ENV_MARKER)}`,
|
||||
`${shQuote(process.execPath)} -e ${shQuote(ENV_CAPTURE_NODE_SCRIPT)}`,
|
||||
`} > ${shQuote(captureOutputPath)}`,
|
||||
].join("\n");
|
||||
|
||||
try {
|
||||
const result = await runShell({
|
||||
shell: opts.shell,
|
||||
shellArgs: buildCaptureShellArgs(shellName, opts.shellArgs),
|
||||
cwd: opts.cwd,
|
||||
env: buildTrustedSnapshotCaptureEnv(opts.env),
|
||||
command: captureCommand,
|
||||
timeoutMs: 5_000,
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
return null;
|
||||
}
|
||||
const stdout = await fs.readFile(captureOutputPath, "utf8");
|
||||
return buildSnapshotFile(stdout);
|
||||
} finally {
|
||||
await fs.rm(captureOutputDir, { force: true, recursive: true });
|
||||
}
|
||||
const result = await runShell({
|
||||
shell: opts.shell,
|
||||
shellArgs: buildCaptureShellArgs(shellName, opts.shellArgs),
|
||||
cwd: opts.cwd,
|
||||
env: buildTrustedSnapshotCaptureEnv(opts.env),
|
||||
command: captureCommand,
|
||||
timeoutMs: 5_000,
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
return null;
|
||||
}
|
||||
const stdout = await fs.readFile(captureOutputPath, "utf8");
|
||||
return buildSnapshotFile(stdout);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function buildCaptureShellArgs(shellName: string, shellArgs: string[]): string[] {
|
||||
|
||||
@@ -35,6 +35,7 @@ import type { AuthProfileConfig } from "../config/types.auth.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { coerceSecretRef } from "../config/types.secrets.js";
|
||||
import { loadJsonFile } from "../infra/json-file.js";
|
||||
import { writeJsonSync } from "../infra/json-files.js";
|
||||
import { shortenHomePath } from "../utils.js";
|
||||
import type { DoctorPrompter } from "./doctor-prompter.js";
|
||||
|
||||
@@ -739,10 +740,6 @@ function backupAndRemoveAuthProfileJson(
|
||||
return backupPath;
|
||||
}
|
||||
|
||||
function writeJsonFile(pathname: string, value: unknown): void {
|
||||
fs.writeFileSync(pathname, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports legacy auth profile JSON and state files into the per-agent SQLite store.
|
||||
*
|
||||
@@ -974,7 +971,7 @@ export async function maybeMigrateAuthProfileJsonStoresToSqlite(params: {
|
||||
if (fs.existsSync(candidate.authPath)) {
|
||||
if (unresolvedSidecarRawStore) {
|
||||
backups.push(backupAuthProfileJson(candidate.authPath, "sqlite-import", now));
|
||||
writeJsonFile(candidate.authPath, unresolvedSidecarRawStore);
|
||||
writeJsonSync(candidate.authPath, unresolvedSidecarRawStore);
|
||||
} else {
|
||||
backups.push(backupAndRemoveAuthProfileJson(candidate.authPath, "sqlite-import", now));
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createReadStream } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { sliceUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
|
||||
import { hasNodeErrorCode } from "../../infra/path-guards.js";
|
||||
import { killProcessTree } from "../../process/kill-tree.js";
|
||||
import { workerSshCommandOptions } from "./ssh.js";
|
||||
import { isDerivedWorkspacePath } from "./workspace-path-exclusions.js";
|
||||
@@ -151,16 +152,6 @@ export async function runLocalCommandToFile(params: {
|
||||
}
|
||||
}
|
||||
|
||||
function hasErrorCode(error: unknown, code: string): boolean {
|
||||
return (
|
||||
typeof error === "object" &&
|
||||
error !== null &&
|
||||
"code" in error &&
|
||||
typeof error.code === "string" &&
|
||||
error.code === code
|
||||
);
|
||||
}
|
||||
|
||||
export async function writeEligibleGitFiles(params: {
|
||||
gitRoot: string;
|
||||
eligiblePath: string;
|
||||
@@ -186,7 +177,7 @@ export async function writeEligibleGitFiles(params: {
|
||||
}
|
||||
const absolute = path.join(canonicalRoot, file);
|
||||
const stats = await fs.lstat(absolute).catch((error: unknown) => {
|
||||
if (hasErrorCode(error, "ENOENT")) {
|
||||
if (hasNodeErrorCode(error, "ENOENT")) {
|
||||
return undefined;
|
||||
}
|
||||
throw error;
|
||||
|
||||
@@ -4,6 +4,7 @@ import "./fs-safe-defaults.js";
|
||||
|
||||
// Generic path guard facade for containment checks and safe relative paths.
|
||||
export {
|
||||
hasNodeErrorCode,
|
||||
isNotFoundPathError,
|
||||
isPathInside,
|
||||
normalizeWindowsPathForComparison,
|
||||
|
||||
+36
-150
@@ -1,13 +1,8 @@
|
||||
// Creates temporary OpenClaw directories for runtime scratch work.
|
||||
import fs from "node:fs";
|
||||
import { tmpdir as getOsTmpDir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
/** Preferred shared OpenClaw temp root on POSIX systems when ownership and permissions are safe. */
|
||||
export const DEFAULT_POSIX_TMP_ROOT = "/tmp/openclaw";
|
||||
|
||||
type MaybeNodeError = { code?: string };
|
||||
|
||||
type SecureDirStat = {
|
||||
isDirectory(): boolean;
|
||||
isSymbolicLink(): boolean;
|
||||
@@ -27,155 +22,46 @@ export type ResolvePreferredOpenClawTmpDirOptions = {
|
||||
warn?: (message: string) => void;
|
||||
};
|
||||
|
||||
function isNodeErrorWithCode(err: unknown, code: string): err is MaybeNodeError {
|
||||
return (
|
||||
typeof err === "object" &&
|
||||
err !== null &&
|
||||
"code" in err &&
|
||||
(err as MaybeNodeError).code === code
|
||||
);
|
||||
type ResolveSecureTempRoot = typeof import("@openclaw/fs-safe/temp").resolveSecureTempRoot;
|
||||
|
||||
let resolveSecureTempRootRuntime: ResolveSecureTempRoot | undefined;
|
||||
|
||||
function loadResolveSecureTempRoot(): ResolveSecureTempRoot {
|
||||
if (resolveSecureTempRootRuntime) {
|
||||
return resolveSecureTempRootRuntime;
|
||||
}
|
||||
// Keep this module browser-import safe: fs-safe's temp barrel owns Node-only
|
||||
// workspaces, so load it only when the Node runtime actually resolves a temp root.
|
||||
const getBuiltinModule = (
|
||||
process as NodeJS.Process & {
|
||||
getBuiltinModule?: (id: string) => unknown;
|
||||
}
|
||||
).getBuiltinModule;
|
||||
if (typeof getBuiltinModule !== "function") {
|
||||
throw new Error("Node module loading is unavailable for secure temp-root resolution");
|
||||
}
|
||||
const moduleNamespace = getBuiltinModule("module") as {
|
||||
createRequire?: (id: string) => NodeJS.Require;
|
||||
};
|
||||
if (typeof moduleNamespace.createRequire !== "function") {
|
||||
throw new Error("Node createRequire is unavailable for secure temp-root resolution");
|
||||
}
|
||||
const require = moduleNamespace.createRequire(import.meta.url);
|
||||
const fsSafeTemp = require("@openclaw/fs-safe/temp") as typeof import("@openclaw/fs-safe/temp");
|
||||
resolveSecureTempRootRuntime = fsSafeTemp.resolveSecureTempRoot;
|
||||
return resolveSecureTempRootRuntime;
|
||||
}
|
||||
|
||||
/** Resolves a safe OpenClaw temp root, falling back to user-scoped os.tmpdir paths when needed. */
|
||||
export function resolvePreferredOpenClawTmpDir(
|
||||
options: ResolvePreferredOpenClawTmpDirOptions = {},
|
||||
): string {
|
||||
const accessMode = fs.constants.W_OK | fs.constants.X_OK;
|
||||
const accessSync = options.accessSync ?? fs.accessSync;
|
||||
const chmodSync = options.chmodSync ?? fs.chmodSync;
|
||||
const lstatSync = options.lstatSync ?? fs.lstatSync;
|
||||
const mkdirSync = options.mkdirSync ?? fs.mkdirSync;
|
||||
const warn = options.warn ?? ((message: string) => console.warn(message));
|
||||
const getuid =
|
||||
options.getuid ??
|
||||
(() => {
|
||||
try {
|
||||
return typeof process.getuid === "function" ? process.getuid() : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
const tmpdir = typeof options.tmpdir === "function" ? options.tmpdir : getOsTmpDir;
|
||||
const platform = options.platform ?? process.platform;
|
||||
const uid = getuid();
|
||||
|
||||
const isSecureDirForUser = (st: { mode?: number; uid?: number }): boolean => {
|
||||
if (uid === undefined) {
|
||||
return true;
|
||||
}
|
||||
if (typeof st.uid === "number" && st.uid !== uid) {
|
||||
return false;
|
||||
}
|
||||
return typeof st.mode !== "number" || (st.mode & 0o022) === 0;
|
||||
};
|
||||
|
||||
const fallback = (): string => {
|
||||
const suffix = uid === undefined ? "openclaw" : `openclaw-${uid}`;
|
||||
const joiner = platform === "win32" ? path.win32.join : path.join;
|
||||
return joiner(tmpdir(), suffix);
|
||||
};
|
||||
|
||||
const isTrustedTmpDir = (st: SecureDirStat): boolean =>
|
||||
st.isDirectory() && !st.isSymbolicLink() && isSecureDirForUser(st);
|
||||
|
||||
const resolveDirState = (candidatePath: string): "available" | "missing" | "invalid" => {
|
||||
try {
|
||||
const candidate = lstatSync(candidatePath);
|
||||
if (!isTrustedTmpDir(candidate)) {
|
||||
return "invalid";
|
||||
}
|
||||
accessSync(candidatePath, accessMode);
|
||||
return "available";
|
||||
} catch (err) {
|
||||
return isNodeErrorWithCode(err, "ENOENT") ? "missing" : "invalid";
|
||||
}
|
||||
};
|
||||
|
||||
const tryRepairWritableBits = (candidatePath: string): boolean => {
|
||||
try {
|
||||
const st = lstatSync(candidatePath);
|
||||
if (!st.isDirectory() || st.isSymbolicLink()) {
|
||||
return false;
|
||||
}
|
||||
if (uid !== undefined && typeof st.uid === "number" && st.uid !== uid) {
|
||||
return false;
|
||||
}
|
||||
if (typeof st.mode !== "number") {
|
||||
return false;
|
||||
}
|
||||
if ((st.mode & 0o022) === 0) {
|
||||
return resolveDirState(candidatePath) === "available";
|
||||
}
|
||||
try {
|
||||
chmodSync(candidatePath, 0o700);
|
||||
} catch (chmodErr) {
|
||||
if (
|
||||
isNodeErrorWithCode(chmodErr, "EPERM") ||
|
||||
isNodeErrorWithCode(chmodErr, "EACCES") ||
|
||||
isNodeErrorWithCode(chmodErr, "ENOENT")
|
||||
) {
|
||||
return resolveDirState(candidatePath) === "available";
|
||||
}
|
||||
throw chmodErr;
|
||||
}
|
||||
warn(`[openclaw] tightened permissions on temp dir: ${candidatePath}`);
|
||||
return resolveDirState(candidatePath) === "available";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const ensureTrustedFallbackDir = (): string => {
|
||||
const fallbackPath = fallback();
|
||||
const state = resolveDirState(fallbackPath);
|
||||
if (state === "available") {
|
||||
return fallbackPath;
|
||||
}
|
||||
if (state === "invalid") {
|
||||
if (tryRepairWritableBits(fallbackPath)) {
|
||||
return fallbackPath;
|
||||
}
|
||||
// Never continue with a symlinked, wrong-owner, or world-writable temp root;
|
||||
// callers create executable/media artifacts under this path.
|
||||
throw new Error(`Unsafe fallback OpenClaw temp dir: ${fallbackPath}`);
|
||||
}
|
||||
try {
|
||||
mkdirSync(fallbackPath, { recursive: true, mode: 0o700 });
|
||||
chmodSync(fallbackPath, 0o700);
|
||||
} catch {
|
||||
throw new Error(`Unable to create fallback OpenClaw temp dir: ${fallbackPath}`);
|
||||
}
|
||||
if (resolveDirState(fallbackPath) !== "available" && !tryRepairWritableBits(fallbackPath)) {
|
||||
throw new Error(`Unsafe fallback OpenClaw temp dir: ${fallbackPath}`);
|
||||
}
|
||||
return fallbackPath;
|
||||
};
|
||||
|
||||
if (platform === "win32") {
|
||||
return ensureTrustedFallbackDir();
|
||||
}
|
||||
|
||||
const preferredDir = DEFAULT_POSIX_TMP_ROOT;
|
||||
const preferredState = resolveDirState(preferredDir);
|
||||
if (preferredState === "available") {
|
||||
return preferredDir;
|
||||
}
|
||||
if (preferredState === "invalid") {
|
||||
if (tryRepairWritableBits(preferredDir)) {
|
||||
return preferredDir;
|
||||
}
|
||||
return ensureTrustedFallbackDir();
|
||||
}
|
||||
|
||||
try {
|
||||
accessSync(path.dirname(preferredDir), accessMode);
|
||||
mkdirSync(preferredDir, { recursive: true, mode: 0o700 });
|
||||
chmodSync(preferredDir, 0o700);
|
||||
if (resolveDirState(preferredDir) !== "available" && !tryRepairWritableBits(preferredDir)) {
|
||||
return ensureTrustedFallbackDir();
|
||||
}
|
||||
return preferredDir;
|
||||
} catch {
|
||||
return ensureTrustedFallbackDir();
|
||||
}
|
||||
return loadResolveSecureTempRoot()({
|
||||
...options,
|
||||
preferredDir: DEFAULT_POSIX_TMP_ROOT,
|
||||
fallbackPrefix: "openclaw",
|
||||
warningPrefix: "[openclaw]",
|
||||
unsafeFallbackLabel: "OpenClaw temp dir",
|
||||
skipPreferredOnWindows: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { sha256Hex } from "../infra/crypto-digest.js";
|
||||
import { removePathWithinRoot } from "../infra/fs-safe-remove.js";
|
||||
import { writeExternalFileWithinRoot } from "../infra/fs-safe.js";
|
||||
import type { TranscriptSessionDescriptor } from "./provider-types.js";
|
||||
|
||||
@@ -82,7 +83,7 @@ export async function writeTranscriptArtifact(
|
||||
}
|
||||
|
||||
export async function removeTranscriptArtifact(rootDir: string, fileName: string): Promise<void> {
|
||||
await fs.rm(path.join(rootDir, fileName), { force: true });
|
||||
await removePathWithinRoot({ rootDir, relativePath: fileName, force: true });
|
||||
}
|
||||
|
||||
export async function isCaseSensitiveDirectory(directory: string): Promise<boolean> {
|
||||
|
||||
Reference in New Issue
Block a user