docs: absorb documentation PR sweep

This commit is contained in:
Peter Steinberger
2026-05-23 10:23:22 +01:00
parent 6b04170167
commit 2c536a8626
39 changed files with 455 additions and 71 deletions

View File

@@ -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.",
],
[

View File

@@ -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 {

View File

@@ -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

View File

@@ -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", () => {

View File

@@ -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

View File

@@ -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). */

View File

@@ -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;

View File

@@ -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(),

View File

@@ -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"),
);

View File

@@ -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", () => {

View File

@@ -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,