mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-13 06:16:03 +00:00
docs: absorb documentation PR sweep
This commit is contained in:
@@ -72,7 +72,7 @@ export function registerBackupCommand(program: Command) {
|
||||
() =>
|
||||
`\n${theme.heading("Examples:")}\n${formatHelpExamples([
|
||||
[
|
||||
"openclaw backup verify ./2026-03-09T00-00-00.000Z-openclaw-backup.tar.gz",
|
||||
"openclaw backup verify ./2026-03-09T08-00-00.000+08-00-openclaw-backup.tar.gz",
|
||||
"Check that the archive structure and manifest are intact.",
|
||||
],
|
||||
[
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
resolveOAuthDir,
|
||||
resolveStateDir,
|
||||
} from "../config/config.js";
|
||||
import { formatSessionArchiveTimestamp } from "../config/sessions/artifacts.js";
|
||||
import { pathExists, shortenHomePath } from "../utils.js";
|
||||
import { buildCleanupPlan, isPathWithin } from "./cleanup-utils.js";
|
||||
|
||||
@@ -58,8 +57,28 @@ function backupAssetPriority(kind: BackupAssetKind): number {
|
||||
throw new Error("Unsupported backup asset kind");
|
||||
}
|
||||
|
||||
export function formatBackupArchiveTimestamp(
|
||||
nowMs = Date.now(),
|
||||
offsetMinutes = -new Date(nowMs).getTimezoneOffset(),
|
||||
): string {
|
||||
const shifted = nowMs + offsetMinutes * 60_000;
|
||||
const local = new Date(shifted);
|
||||
const sign = offsetMinutes >= 0 ? "+" : "-";
|
||||
const absOffsetMinutes = Math.abs(offsetMinutes);
|
||||
const offsetHours = String(Math.floor(absOffsetMinutes / 60)).padStart(2, "0");
|
||||
const offsetMins = String(absOffsetMinutes % 60).padStart(2, "0");
|
||||
const year = String(local.getUTCFullYear()).padStart(4, "0");
|
||||
const month = String(local.getUTCMonth() + 1).padStart(2, "0");
|
||||
const day = String(local.getUTCDate()).padStart(2, "0");
|
||||
const hours = String(local.getUTCHours()).padStart(2, "0");
|
||||
const minutes = String(local.getUTCMinutes()).padStart(2, "0");
|
||||
const seconds = String(local.getUTCSeconds()).padStart(2, "0");
|
||||
const millis = String(local.getUTCMilliseconds()).padStart(3, "0");
|
||||
return `${year}-${month}-${day}T${hours}-${minutes}-${seconds}.${millis}${sign}${offsetHours}-${offsetMins}`;
|
||||
}
|
||||
|
||||
export function buildBackupArchiveRoot(nowMs = Date.now()): string {
|
||||
return `${formatSessionArchiveTimestamp(nowMs)}-openclaw-backup`;
|
||||
return `${formatBackupArchiveTimestamp(nowMs)}-openclaw-backup`;
|
||||
}
|
||||
|
||||
export function buildBackupArchiveBasename(nowMs = Date.now()): string {
|
||||
|
||||
@@ -8,6 +8,7 @@ import * as backupShared from "./backup-shared.js";
|
||||
import {
|
||||
buildBackupArchiveRoot,
|
||||
encodeAbsolutePathForBackupArchive,
|
||||
formatBackupArchiveTimestamp,
|
||||
type BackupAsset,
|
||||
resolveBackupPlanFromPaths,
|
||||
resolveBackupPlanFromDisk,
|
||||
@@ -161,6 +162,15 @@ describe("backup commands", () => {
|
||||
]);
|
||||
}
|
||||
|
||||
it("formats backup archive timestamps in local time with an explicit offset", () => {
|
||||
expect(formatBackupArchiveTimestamp(Date.UTC(2026, 2, 14, 1, 2, 3, 456), 8 * 60)).toBe(
|
||||
"2026-03-14T09-02-03.456+08-00",
|
||||
);
|
||||
expect(formatBackupArchiveTimestamp(Date.UTC(2026, 2, 14, 1, 2, 3, 456), -5 * 60)).toBe(
|
||||
"2026-03-13T20-02-03.456-05-00",
|
||||
);
|
||||
});
|
||||
|
||||
it("collapses default config, credentials, and workspace into the state backup root", async () => {
|
||||
const stateDir = path.join(tempHome.home, ".openclaw");
|
||||
const configPath = path.join(stateDir, "openclaw.json");
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -7,6 +7,7 @@ import {
|
||||
CircularIncludeError,
|
||||
ConfigIncludeError,
|
||||
MAX_INCLUDE_FILE_BYTES,
|
||||
MAX_INCLUDE_PATH_LENGTH,
|
||||
deepMerge,
|
||||
type IncludeResolver,
|
||||
resolveConfigIncludes,
|
||||
@@ -576,17 +577,30 @@ describe("security: path traversal protection (CWE-22)", () => {
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it.each([
|
||||
{ includePath: "./file\x00.json", expectedError: undefined },
|
||||
{ includePath: "//etc/passwd", expectedError: ConfigIncludeError },
|
||||
] as const)("rejects malformed include path $includePath", ({ includePath, expectedError }) => {
|
||||
const obj = { $include: includePath };
|
||||
if (expectedError) {
|
||||
expectResolveIncludeError(() => resolve(obj, {}));
|
||||
return;
|
||||
it("rejects malformed include paths", () => {
|
||||
const cases = [
|
||||
{ includePath: "./file\x00.json", pattern: /null bytes?/i },
|
||||
{ includePath: "./a\x00b.json", pattern: /null bytes?/i },
|
||||
{ includePath: "//etc/passwd", pattern: /escapes config directory/ },
|
||||
] as const;
|
||||
for (const testCase of cases) {
|
||||
const obj = { $include: testCase.includePath };
|
||||
expectResolveIncludeError(() => resolve(obj, {}), testCase.pattern);
|
||||
}
|
||||
// Path with null byte should be rejected or handled safely.
|
||||
expectResolveIncludeError(() => resolve(obj, {}));
|
||||
});
|
||||
|
||||
it("rejects include path at or over maximum length (>= MAX_INCLUDE_PATH_LENGTH)", () => {
|
||||
const overLimit = "a".repeat(MAX_INCLUDE_PATH_LENGTH + 1);
|
||||
expectResolveIncludeError(() => resolve({ $include: overLimit }, {}), /maximum length/);
|
||||
// Boundary: length exactly 4096 must be rejected (Linux PATH_MAX includes NUL)
|
||||
const atLimit = "b".repeat(MAX_INCLUDE_PATH_LENGTH);
|
||||
expectResolveIncludeError(() => resolve({ $include: atLimit }, {}), /maximum length/);
|
||||
});
|
||||
|
||||
it("accepts include path at or under maximum length when file exists", () => {
|
||||
const shortPath = configPath("base.json");
|
||||
const files = { [shortPath]: { ok: true } };
|
||||
expect(resolve({ $include: shortPath }, files)).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("allows child include when config is at filesystem root", () => {
|
||||
|
||||
@@ -22,6 +22,9 @@ export const INCLUDE_KEY = "$include";
|
||||
export const MAX_INCLUDE_DEPTH = 10;
|
||||
export const MAX_INCLUDE_FILE_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
/** Maximum length for $include path and resolved path (CWE-22 hardening). */
|
||||
export const MAX_INCLUDE_PATH_LENGTH = 4096;
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
@@ -212,12 +215,29 @@ class IncludeProcessor {
|
||||
}
|
||||
|
||||
private resolvePath(includePath: string): { resolvedPath: string; root: IncludeRoot } {
|
||||
if (includePath.includes("\0")) {
|
||||
throw new ConfigIncludeError("Include path must not contain null bytes", includePath);
|
||||
}
|
||||
if (includePath.length >= MAX_INCLUDE_PATH_LENGTH) {
|
||||
throw new ConfigIncludeError(
|
||||
`Include path exceeds maximum length (${MAX_INCLUDE_PATH_LENGTH} characters)`,
|
||||
includePath,
|
||||
);
|
||||
}
|
||||
|
||||
const configDir = path.dirname(this.basePath);
|
||||
const resolved = path.isAbsolute(includePath)
|
||||
? includePath
|
||||
: path.resolve(configDir, includePath);
|
||||
const normalized = path.normalize(resolved);
|
||||
|
||||
if (normalized.length >= MAX_INCLUDE_PATH_LENGTH) {
|
||||
throw new ConfigIncludeError(
|
||||
`Resolved include path exceeds maximum length (${MAX_INCLUDE_PATH_LENGTH} characters)`,
|
||||
includePath,
|
||||
);
|
||||
}
|
||||
|
||||
// SECURITY: Reject paths outside the config directory and any caller-allowed
|
||||
// roots (CWE-22: Path Traversal). Allowed roots come from
|
||||
// OPENCLAW_INCLUDE_ROOTS and let operators opt into shared include trees
|
||||
|
||||
@@ -18,6 +18,8 @@ export type SignalAccountConfig = CommonChannelMessagingConfig & {
|
||||
account?: string;
|
||||
/** Optional account UUID for signal-cli (used for loop protection). */
|
||||
accountUuid?: string;
|
||||
/** Optional signal-cli config directory path (passed as --config). */
|
||||
configPath?: string;
|
||||
/** Optional full base URL for signal-cli HTTP daemon. */
|
||||
httpUrl?: string;
|
||||
/** HTTP host for signal-cli daemon (default 127.0.0.1). */
|
||||
|
||||
@@ -276,7 +276,7 @@ export type TelegramGroupConfig = {
|
||||
toolsBySender?: GroupToolPolicyBySenderConfig;
|
||||
/** If specified, only load these skills for this group (when no topic). Omit = all skills; empty = no skills. */
|
||||
skills?: string[];
|
||||
/** Per-topic configuration (key is message_thread_id as string) */
|
||||
/** Per-topic configuration (key is message_thread_id as string, or "*" for topic defaults). */
|
||||
topics?: Record<string, TelegramTopicConfig>;
|
||||
/** If false, disable the bot for this group (and its topics). */
|
||||
enabled?: boolean;
|
||||
@@ -311,7 +311,7 @@ export type TelegramDirectConfig = {
|
||||
toolsBySender?: GroupToolPolicyBySenderConfig;
|
||||
/** If specified, only load these skills for this DM (when no topic). Omit = all skills; empty = no skills. */
|
||||
skills?: string[];
|
||||
/** Per-topic configuration for DM topics (key is message_thread_id as string) */
|
||||
/** Per-topic configuration for DM topics (key is message_thread_id as string, or "*" for topic defaults). */
|
||||
topics?: Record<string, TelegramTopicConfig>;
|
||||
/** If false, disable the bot for this DM (and its topics). */
|
||||
enabled?: boolean;
|
||||
|
||||
@@ -1204,6 +1204,7 @@ export const SignalAccountSchemaBase = z
|
||||
configWrites: z.boolean().optional(),
|
||||
account: z.string().optional(),
|
||||
accountUuid: z.string().optional(),
|
||||
configPath: z.string().optional(),
|
||||
httpUrl: z.string().optional(),
|
||||
httpHost: z.string().optional(),
|
||||
httpPort: z.number().int().positive().optional(),
|
||||
|
||||
@@ -418,6 +418,7 @@ function throwBootstrapGuiSessionError(params: {
|
||||
`LaunchAgent ${params.actionHint} requires a logged-in macOS GUI session for this user (${params.domain}).`,
|
||||
"This usually means you are running from SSH/headless context or as the wrong user (including sudo).",
|
||||
`Fix: sign in to the macOS desktop as the target user and rerun \`${params.actionHint}\`.`,
|
||||
"For headless VM setups, enable auto-login for the target user so macOS creates the GUI session after boot.",
|
||||
"Headless deployments should use a dedicated logged-in user session or a custom LaunchDaemon (not shipped): https://docs.openclaw.ai/gateway",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
@@ -79,6 +79,63 @@ describe("resolveEffectiveHomeDir", () => {
|
||||
])("$name", ({ env, expected }) => {
|
||||
expect(resolveEffectiveHomeDir(env)).toBe(path.resolve(expected));
|
||||
});
|
||||
|
||||
it("derives home from PREFIX on Android/Termux when HOME is unset", () => {
|
||||
const env = {
|
||||
PREFIX: "/data/data/com.termux/files/usr",
|
||||
ANDROID_DATA: "/data",
|
||||
} as NodeJS.ProcessEnv;
|
||||
expect(resolveEffectiveHomeDir(env, () => "/home")).toBe(
|
||||
path.resolve("/data/data/com.termux/files/home"),
|
||||
);
|
||||
});
|
||||
|
||||
it("prefers HOME over PREFIX-derived path on Termux", () => {
|
||||
const env = {
|
||||
HOME: "/data/data/com.termux/files/home",
|
||||
PREFIX: "/data/data/com.termux/files/usr",
|
||||
ANDROID_DATA: "/data",
|
||||
} as NodeJS.ProcessEnv;
|
||||
expect(resolveEffectiveHomeDir(env)).toBe(path.resolve("/data/data/com.termux/files/home"));
|
||||
});
|
||||
|
||||
it("ignores PREFIX without com.termux to avoid false positives in generic chroots", () => {
|
||||
const env = {
|
||||
PREFIX: "/usr",
|
||||
ANDROID_DATA: "/data",
|
||||
} as NodeJS.ProcessEnv;
|
||||
expect(resolveEffectiveHomeDir(env, () => "/fallback")).toBe(path.resolve("/fallback"));
|
||||
});
|
||||
|
||||
it("ignores PREFIX values that only mention com.termux outside the Termux app root", () => {
|
||||
const env = {
|
||||
PREFIX: "/tmp/com.termux/usr",
|
||||
ANDROID_DATA: "/data",
|
||||
} as NodeJS.ProcessEnv;
|
||||
expect(resolveEffectiveHomeDir(env, () => "/fallback")).toBe(path.resolve("/fallback"));
|
||||
});
|
||||
|
||||
it("uses Termux PREFIX for tilde expansion when HOME is unset", () => {
|
||||
const env = {
|
||||
OPENCLAW_HOME: "~/workspace",
|
||||
PREFIX: "/data/data/com.termux/files/usr",
|
||||
ANDROID_DATA: "/data",
|
||||
} as NodeJS.ProcessEnv;
|
||||
expect(
|
||||
resolveEffectiveHomeDir(env, () => {
|
||||
throw new Error("no homedir");
|
||||
}),
|
||||
).toBe(path.resolve("/data/data/com.termux/files/home/workspace"));
|
||||
});
|
||||
|
||||
it("expands OPENCLAW_HOME when set to ~", () => {
|
||||
const env = {
|
||||
OPENCLAW_HOME: "~/svc",
|
||||
HOME: "/home/alice",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
expect(resolveEffectiveHomeDir(env)).toBe(path.resolve("/home/alice/svc"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveRequiredHomeDir", () => {
|
||||
|
||||
@@ -17,8 +17,24 @@ function normalizeSafe(homedir: () => string): string | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
function resolveTermuxHome(env: NodeJS.ProcessEnv): string | undefined {
|
||||
const prefix = normalize(env.PREFIX);
|
||||
if (!prefix || !normalize(env.ANDROID_DATA)) {
|
||||
return undefined;
|
||||
}
|
||||
if (!/(?:^|\/)com\.termux\/files\/usr\/?$/u.test(prefix.replace(/\\/gu, "/"))) {
|
||||
return undefined;
|
||||
}
|
||||
return path.resolve(prefix, "..", "home");
|
||||
}
|
||||
|
||||
function resolveRawOsHomeDir(env: NodeJS.ProcessEnv, homedir: () => string): string | undefined {
|
||||
return normalize(env.HOME) ?? normalize(env.USERPROFILE) ?? normalizeSafe(homedir);
|
||||
return (
|
||||
normalize(env.HOME) ??
|
||||
normalize(env.USERPROFILE) ??
|
||||
resolveTermuxHome(env) ??
|
||||
normalizeSafe(homedir)
|
||||
);
|
||||
}
|
||||
|
||||
function resolveRawHomeDir(env: NodeJS.ProcessEnv, homedir: () => string): string | undefined {
|
||||
@@ -48,7 +64,6 @@ export function resolveOsHomeDir(
|
||||
const raw = resolveRawOsHomeDir(env, homedir);
|
||||
return raw ? path.resolve(raw) : undefined;
|
||||
}
|
||||
|
||||
export function resolveRequiredHomeDir(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
homedir: () => string = os.homedir,
|
||||
|
||||
Reference in New Issue
Block a user