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

@@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai
### Changes
- Docs/channels/config: add Signal `configPath`, Telegram wildcard topic defaults, local-time backup archive names, Termux home fallback, include-path validation, secret-scanner-safe placeholder guidance, Gemini CLI/Antigravity media guidance, and macOS VM auto-login guidance. Thanks @NorseGaud, @yudistiraashadi, @huangqian8, @VibhorGautam, @maweibin, @tianxingleo, @IgnacioPro, and @xzcxzcyy-claw.
- Docs: clarify model-usage portability, Codex migration prerequisites, status bootstrap wording, thread-bound subagent limits, hook ownership, and config-preserving safety guidance. Thanks @aniruddhaadak80, @leno23, @TomDjerry, @matthewxmurphy, @vincentkoc, and @stablegenius49.
- Docs: clarify README onboarding and Gateway startup paths, WhatsApp QR/408 recovery, cron output language prompts, skill advanced features, gateway upstream 403 troubleshooting, and plugin fallback override guidance. Thanks @deepujain, @Zacxxx, @Jah-yee, @neyric, @usimic, @Renu-Cybe, @BigUncle, and @SeashoreShi.
- Docs: clarify context-pruning ratio bounds, local dashboard recovery, CLI env markers, remote onboarding token behavior, and Peekaboo Bridge permissions for subprocess agents. Thanks @ayesha-aziz123, @dishraters, @hougangdev, and @brandonlipman.

View File

@@ -45,12 +45,13 @@ Minimal config:
Field reference:
| Field | Description |
| ----------- | ------------------------------------------------- |
| `account` | Bot phone number in E.164 format (`+15551234567`) |
| `cliPath` | Path to `signal-cli` (`signal-cli` if on `PATH`) |
| `dmPolicy` | DM access policy (`pairing` recommended) |
| `allowFrom` | Phone numbers or `uuid:<id>` values allowed to DM |
| Field | Description |
| ------------ | ------------------------------------------------- |
| `account` | Bot phone number in E.164 format (`+15551234567`) |
| `cliPath` | Path to `signal-cli` (`signal-cli` if on `PATH`) |
| `configPath` | signal-cli config dir passed as `--config` |
| `dmPolicy` | DM access policy (`pairing` recommended) |
| `allowFrom` | Phone numbers or `uuid:<id>` values allowed to DM |
## What it is
@@ -365,6 +366,7 @@ Provider options:
- `channels.signal.apiMode`: `auto | native | container` (default: auto). See [Container mode](#container-mode-bbernhardsignal-cli-rest-api).
- `channels.signal.account`: E.164 for the bot account.
- `channels.signal.cliPath`: path to `signal-cli`.
- `channels.signal.configPath`: optional `signal-cli --config` directory.
- `channels.signal.httpUrl`: full daemon URL (overrides host/port).
- `channels.signal.httpHost`, `channels.signal.httpPort`: daemon bind (default 127.0.0.1:8080).
- `channels.signal.autoStart`: auto-spawn daemon (default true if `httpUrl` unset).

View File

@@ -27,7 +27,7 @@ Both transports are production-ready and reach feature parity for messaging, sla
| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
| Public Gateway URL | Not required | Required (DNS, TLS, reverse proxy or tunnel) |
| Outbound network | Outbound WSS to `wss-primary.slack.com` must be reachable | No outbound WS; inbound HTTPS only |
| Tokens needed | Bot token (`xoxb-...`) + App-Level Token (`xapp-...`) with `connections:write` | Bot token (`xoxb-...`) + Signing Secret |
| Tokens needed | Bot token + App-Level Token with `connections:write` | Bot token + Signing Secret |
| Dev laptop / behind firewall | Works as-is | Needs a public tunnel (ngrok, Cloudflare Tunnel, Tailscale Funnel) or staging Gateway |
| Horizontal scaling | One Socket Mode session per app per host; multiple Gateways need separate Slack apps | Stateless POST handler; multiple Gateway replicas can share one app behind a load balancer |
| Multi-account on one Gateway | Supported; each account opens its own WS | Supported; each account needs a unique `webhookPath` (default `/slack/events`) so registrations do not collide |
@@ -222,8 +222,8 @@ openclaw plugins install @openclaw/slack
After Slack creates the app:
- **Basic Information App-Level Tokens Generate Token and Scopes**: add `connections:write`, save, copy the `xapp-...` value.
- **Install App Install to Workspace**: copy the `xoxb-...` Bot User OAuth Token.
- **Basic Information -> App-Level Tokens -> Generate Token and Scopes**: add `connections:write`, save, copy the App-Level Token.
- **Install App -> Install to Workspace**: copy the Bot User OAuth Token.
</Step>
@@ -232,8 +232,8 @@ openclaw plugins install @openclaw/slack
Recommended SecretRef setup:
```bash
export SLACK_APP_TOKEN=xapp-...
export SLACK_BOT_TOKEN=xoxb-...
export SLACK_APP_TOKEN=slack-app-token-example
export SLACK_BOT_TOKEN=slack-bot-token-example
cat > slack.socket.patch.json5 <<'JSON5'
{
channels: {
@@ -253,8 +253,8 @@ openclaw config patch --file ./slack.socket.patch.json5
Env fallback (default account only):
```bash
SLACK_APP_TOKEN=xapp-...
SLACK_BOT_TOKEN=xoxb-...
SLACK_APP_TOKEN=slack-app-token-example
SLACK_BOT_TOKEN=slack-bot-token-example
```
</Step>
@@ -455,7 +455,7 @@ openclaw gateway
After Slack creates the app:
- **Basic Information → App Credentials**: copy the **Signing Secret** for request verification.
- **Install App Install to Workspace**: copy the `xoxb-...` Bot User OAuth Token.
- **Install App -> Install to Workspace**: copy the Bot User OAuth Token.
</Step>
@@ -464,7 +464,7 @@ openclaw gateway
Recommended SecretRef setup:
```bash
export SLACK_BOT_TOKEN=xoxb-...
export SLACK_BOT_TOKEN=slack-bot-token-example
export SLACK_SIGNING_SECRET=...
cat > slack.http.patch.json5 <<'JSON5'
{
@@ -867,7 +867,7 @@ The default manifest enables the Slack App Home **Home** tab and subscribes to `
strings or SecretRef objects.
- Config tokens override env fallback.
- `SLACK_BOT_TOKEN` / `SLACK_APP_TOKEN` env fallback applies only to the default account.
- `userToken` (`xoxp-...`) is config-only (no env fallback) and defaults to read-only behavior (`userTokenReadOnly: true`).
- `userToken` is config-only (no env fallback) and defaults to read-only behavior (`userTokenReadOnly: true`).
Status snapshot behavior:
@@ -1462,7 +1462,7 @@ openclaw pairing list slack
<Accordion title="Socket mode not connecting">
Validate bot + app tokens and Socket Mode enablement in Slack app settings.
The `xapp-...` App-Level Token needs `connections:write`, and the `xoxb-...`
The App-Level Token needs `connections:write`, and the Bot User OAuth Token
bot token must belong to the same Slack app/workspace as the app token.
If `openclaw channels status --probe --json` shows `botTokenStatus` or
@@ -1532,7 +1532,7 @@ Slack can attach downloaded media to the agent turn when Slack file downloads su
When a Slack message with file attachments arrives:
1. OpenClaw downloads the file from Slack's private URL using the bot token (`xoxb-...`).
1. OpenClaw downloads the file from Slack's private URL using the bot token.
2. The file is written to the media store on success.
3. Downloaded media paths and content types are added to the inbound context.
4. Image-capable model/tool paths can use image attachments from that context.

View File

@@ -635,6 +635,7 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
Topic inheritance: topic entries inherit group settings unless overridden (`requireMention`, `allowFrom`, `skills`, `systemPrompt`, `enabled`, `groupPolicy`).
`agentId` is topic-only and does not inherit from group defaults.
`topics."*"` sets defaults for every topic in that group; exact topic IDs still win over `"*"`.
**Per-topic agent routing**: Each topic can route to a different agent by setting `agentId` in the topic config. This gives each topic its own isolated workspace, memory, and session. Example:
@@ -1074,6 +1075,7 @@ Primary reference: [Configuration reference - Telegram](/gateway/config-channels
- startup/auth: `enabled`, `botToken`, `tokenFile`, `accounts.*` (`tokenFile` must point to a regular file; symlinks are rejected)
- access control: `dmPolicy`, `allowFrom`, `groupPolicy`, `groupAllowFrom`, `groups`, `groups.*.topics.*`, top-level `bindings[]` (`type: "acp"`)
- topic defaults: `groups.<chatId>.topics."*"` applies to unmatched forum topics; exact topic IDs override it
- exec approvals: `execApprovals`, `accounts.*.execApprovals`
- command/menu: `commands.native`, `commands.nativeSkills`, `customCommands`
- threading/replies: `replyToMode`, `dm.threadReplies`, `direct.*.threadReplies`

View File

@@ -17,13 +17,14 @@ openclaw backup create --dry-run --json
openclaw backup create --verify
openclaw backup create --no-include-workspace
openclaw backup create --only-config
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
```
## Notes
- The archive includes a `manifest.json` file with the resolved source paths and archive layout.
- Default output is a timestamped `.tar.gz` archive in the current working directory.
- Timestamped backup filenames use your machine's local timezone and include the UTC offset.
- If the current working directory is inside a backed-up source tree, OpenClaw falls back to your home directory for the default archive location.
- Existing archive files are never overwritten.
- Output paths inside the source state/workspace trees are rejected to avoid self-inclusion.

View File

@@ -1379,10 +1379,10 @@ Split config into multiple files:
- Array of files: deep-merged in order (later overrides earlier).
- Sibling keys: merged after includes (override included values).
- Nested includes: up to 10 levels deep.
- Paths: resolved relative to the including file, but must stay inside the top-level config directory (`dirname` of `openclaw.json`). Absolute/`../` forms are allowed only when they still resolve inside that boundary.
- Paths: resolved relative to the including file, but must stay inside the top-level config directory (`dirname` of `openclaw.json`). Absolute/`../` forms are allowed only when they still resolve inside that boundary. Paths must not contain null bytes and must be strictly shorter than 4096 characters before and after resolution.
- OpenClaw-owned writes that change only one top-level section backed by a single-file include write through to that included file. For example, `plugins install` updates `plugins: { $include: "./plugins.json5" }` in `plugins.json5` and leaves `openclaw.json` intact.
- Root includes, include arrays, and includes with sibling overrides are read-only for OpenClaw-owned writes; those writes fail closed instead of flattening the config.
- Errors: clear messages for missing files, parse errors, and circular includes.
- Errors: clear messages for missing files, parse errors, circular includes, invalid path format, and excessive length.
---

View File

@@ -513,6 +513,7 @@ candidate contains redacted secret placeholders such as `***`.
- **Sibling keys**: merged after includes (override included values)
- **Nested includes**: supported up to 10 levels deep
- **Relative paths**: resolved relative to the including file
- **Path format**: include paths must not contain null bytes and must be strictly shorter than 4096 characters before and after resolution
- **OpenClaw-owned writes**: when a write changes only one top-level section
backed by a single-file include such as `plugins: { $include: "./plugins.json5" }`,
OpenClaw updates that included file and leaves `openclaw.json` intact
@@ -525,7 +526,7 @@ candidate contains redacted secret placeholders such as `***`.
additional directories that includes may reference. Symlinks are resolved
and re-checked, so a path that lexically lives in a config dir but whose
real target escapes every allowed root is still rejected.
- **Error handling**: clear errors for missing files, parse errors, and circular includes
- **Error handling**: clear errors for missing files, parse errors, circular includes, invalid path format, and excessive length
</Accordion>
</AccordionGroup>

View File

@@ -159,7 +159,7 @@ shorthand values.
When set, `OPENCLAW_HOME` replaces the system home directory (`$HOME` / `os.homedir()`) for all internal path resolution. This enables full filesystem isolation for headless service accounts.
**Precedence:** `OPENCLAW_HOME` > `$HOME` > `USERPROFILE` > `os.homedir()`
**Precedence:** `OPENCLAW_HOME` > `$HOME` > `USERPROFILE` > Termux `PREFIX` home fallback on Android > `os.homedir()`
**Example** (macOS LaunchDaemon):
@@ -171,7 +171,7 @@ When set, `OPENCLAW_HOME` replaces the system home directory (`$HOME` / `os.home
</dict>
```
`OPENCLAW_HOME` can also be set to a tilde path (e.g. `~/svc`), which gets expanded using `$HOME` before use.
`OPENCLAW_HOME` can also be set to a tilde path (e.g. `~/svc`), which gets expanded using the same OS home fallback chain before use.
## nvm users: web_fetch TLS failures

View File

@@ -98,14 +98,14 @@ read_when:
fly secrets set OPENCLAW_GATEWAY_TOKEN=$(openssl rand -hex 32)
# Model provider API keys
fly secrets set ANTHROPIC_API_KEY=sk-ant-...
fly secrets set ANTHROPIC_API_KEY=example-anthropic-key-not-real
# Optional: Other providers
fly secrets set OPENAI_API_KEY=sk-...
fly secrets set OPENAI_API_KEY=example-openai-key-not-real
fly secrets set GOOGLE_API_KEY=...
# Channel tokens
fly secrets set DISCORD_BOT_TOKEN=MTQ...
fly secrets set DISCORD_BOT_TOKEN=example-discord-bot-token
```
**Notes:**

View File

@@ -105,10 +105,10 @@ In the VNC window:
3. Create a user account (remember the username and password)
4. Skip all optional features
After setup completes, enable SSH:
After setup completes:
1. Open System Settings General Sharing
2. Enable "Remote Login"
1. Enable SSH: Open System Settings -> General -> Sharing and enable "Remote Login".
2. For headless VM use, enable auto-login: Open System Settings -> Users & Groups, select "Automatically log in as:", and choose the VM user.
---

View File

@@ -26,11 +26,12 @@ OpenClaw auto-detects in this order and stops at the first working option:
- `sherpa-onnx-offline` (requires `SHERPA_ONNX_MODEL_DIR` with encoder/decoder/joiner/tokens)
- `whisper-cli` (from `whisper-cpp`; uses `WHISPER_CPP_MODEL` or the bundled tiny model)
- `whisper` (Python CLI; downloads models automatically)
3. **Gemini CLI** (`gemini`) using `read_many_files`
4. **Provider auth**
3. **Provider auth**
- Configured `models.providers.*` entries that support audio are tried first
- Bundled fallback order: OpenAI → Groq → xAI → Deepgram → Google → SenseAudio → ElevenLabs → Mistral
As of 2026-05-22, Gemini CLI auto-detect is no longer supported for media understanding. Google is transitioning Gemini CLI users to Antigravity CLI; audio should use local or provider transcription, while image/video CLI fallback should move to Antigravity CLI (`agy`).
To disable auto-detection, set `tools.media.audio.enabled: false`.
To customize, set `tools.media.audio.models`.
Note: Binary detection is best-effort across macOS/Linux/Windows; ensure the CLI is on `PATH` (we expand `~`), or set an explicit CLI model with a full command path.

View File

@@ -60,7 +60,7 @@ Anthropic's current public docs:
```json5
{
env: { ANTHROPIC_API_KEY: "sk-ant-..." },
env: { ANTHROPIC_API_KEY: "example-anthropic-key-not-real" },
agents: { defaults: { model: { primary: "anthropic/claude-opus-4-6" } } },
}
```

View File

@@ -28,7 +28,7 @@ Choose your preferred auth method and follow the setup steps.
<Steps>
<Step title="Set AWS credentials on the gateway host">
```bash
export AWS_ACCESS_KEY_ID="AKIA..."
export AWS_ACCESS_KEY_ID="EXAMPLE_AWS_ACCESS_KEY_ID"
export AWS_SECRET_ACCESS_KEY="..."
export AWS_REGION="us-east-1"
# Optional:

View File

@@ -170,7 +170,7 @@ Choose your preferred auth method and follow the setup steps.
```json5
{
env: { OPENAI_API_KEY: "sk-..." },
env: { OPENAI_API_KEY: "example-openai-key-not-real" },
agents: { defaults: { model: { primary: "openai/gpt-5.5" } } },
}
```
@@ -180,7 +180,7 @@ Choose your preferred auth method and follow the setup steps.
```json5
{
env: { OPENAI_API_KEY: "sk-..." },
env: { OPENAI_API_KEY: "example-openai-key-not-real" },
agents: { defaults: { model: { primary: "openai/chat-latest" } } },
}
```

View File

@@ -0,0 +1,33 @@
---
summary: "Secret-scanner-safe placeholder conventions for docs and examples"
read_when:
- Writing docs that include tokens, API keys, or credential snippets
- Updating examples that may be scanned by secret-detection tooling
title: "Secret Placeholder Conventions"
---
# Secret placeholder conventions
Use placeholders that are human-readable but do not resemble real secrets.
## Recommended style
- Prefer descriptive values like `example-openai-key-not-real` or `example-discord-bot-token`.
- For shell snippets, prefer `${OPENAI_API_KEY}` over inline token-like strings.
- Keep examples obviously fake and scoped to purpose (provider, channel, auth type).
## Avoid these patterns in docs
- Private key sentinels such as `-----BEGIN PRIVATE KEY-----`.
- Prefixes that resemble live credentials, for example `sk-...`, `xoxb-...`, `AKIA...`.
- Realistic-looking bearer tokens copied from runtime logs.
## Example
```bash
# Good
export OPENAI_API_KEY="example-openai-key-not-real"
# Better (when the doc is about env wiring)
export OPENAI_API_KEY="${OPENAI_API_KEY}"
```

View File

@@ -26,6 +26,10 @@ OpenClaw assembles its own system prompt on every run. It includes:
See the full breakdown in [System Prompt](/concepts/system-prompt).
When documenting credentials or auth snippets, use the
[Secret Placeholder Conventions](/reference/secret-placeholder-conventions) to
avoid secret-scanner false positives in docs-only changes.
## What counts in the context window
Everything the model receives counts toward the context limit:

View File

@@ -50,6 +50,7 @@ export function resolveSignalAccount(params: {
const baseUrl = normalizeOptionalString(merged.httpUrl) ?? `http://${host}:${port}`;
const configured = Boolean(
normalizeOptionalString(merged.account) ||
normalizeOptionalString(merged.configPath) ||
normalizeOptionalString(merged.httpUrl) ||
normalizeOptionalString(merged.cliPath) ||
normalizeOptionalString(merged.httpHost) ||

View File

@@ -17,4 +17,8 @@ export const signalChannelConfigUiHints = {
label: "Signal Account",
help: "Signal account identifier (phone/number handle) used to bind this channel config to a specific Signal identity. Keep this aligned with your linked device/session state.",
},
configPath: {
label: "Signal CLI Config Path",
help: "Optional directory passed to signal-cli via --config when the service needs a non-default signal-cli data path.",
},
} satisfies Record<string, ChannelConfigUiHint>;

View File

@@ -0,0 +1,24 @@
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { __testing } from "./daemon.js";
describe("signal daemon args", () => {
it("expands home-relative configPath before passing it to signal-cli", () => {
expect(
__testing.buildDaemonArgs({
cliPath: "signal-cli",
configPath: "~/.openclaw/signal-cli",
httpHost: "127.0.0.1",
httpPort: 8080,
}),
).toEqual([
"--config",
path.join(os.homedir(), ".openclaw/signal-cli"),
"daemon",
"--http",
"127.0.0.1:8080",
"--no-receive-stdout",
]);
});
});

View File

@@ -1,8 +1,11 @@
import { spawn } from "node:child_process";
import os from "node:os";
import path from "node:path";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
type SignalDaemonOpts = {
cliPath: string;
configPath?: string;
account?: string;
httpHost: string;
httpPort: number;
@@ -63,8 +66,22 @@ function bindSignalCliOutput(params: {
});
}
function resolveSignalCliConfigPath(raw: string): string {
const value = raw.trim();
if (value === "~") {
return os.homedir();
}
if (value.startsWith("~/") || value.startsWith("~\\")) {
return path.join(os.homedir(), value.slice(2));
}
return value;
}
function buildDaemonArgs(opts: SignalDaemonOpts): string[] {
const args: string[] = [];
if (opts.configPath?.trim()) {
args.push("--config", resolveSignalCliConfigPath(opts.configPath));
}
if (opts.account) {
args.push("-a", opts.account);
}
@@ -145,3 +162,8 @@ export function spawnSignalDaemon(opts: SignalDaemonOpts): SignalDaemonHandle {
},
};
}
export const __testing = {
buildDaemonArgs,
resolveSignalCliConfigPath,
} as const;

View File

@@ -114,6 +114,42 @@ describe("monitorSignalProvider autostart", () => {
expectWaitForTransportReadyTimeout(90_000);
});
it("passes channels.signal.configPath to signal-cli daemon startup", async () => {
const runtime = createMonitorRuntime();
setSignalAutoStartConfig({ configPath: "~/.openclaw/signal-cli" });
const abortController = createAutoAbortController();
await runMonitorWithMocks({
autoStart: true,
baseUrl: SIGNAL_BASE_URL,
abortSignal: abortController.signal,
runtime,
});
expect(spawnSignalDaemonMock).toHaveBeenCalledWith(
expect.objectContaining({
configPath: "~/.openclaw/signal-cli",
}),
);
});
it("omits configPath when channels.signal.configPath is blank", async () => {
const runtime = createMonitorRuntime();
setSignalAutoStartConfig({ configPath: " " });
const abortController = createAutoAbortController();
await runMonitorWithMocks({
autoStart: true,
baseUrl: SIGNAL_BASE_URL,
abortSignal: abortController.signal,
runtime,
});
const [daemonOpts] = spawnSignalDaemonMock.mock.calls[0] ?? [];
expect(daemonOpts).toBeDefined();
expect(daemonOpts).not.toHaveProperty("configPath");
});
it("caps startupTimeoutMs at 2 minutes", async () => {
const runtime = createMonitorRuntime();
setSignalAutoStartConfig({ startupTimeoutMs: 180_000 });

View File

@@ -56,6 +56,7 @@ export type MonitorSignalOpts = {
autoStart?: boolean;
startupTimeoutMs?: number;
cliPath?: string;
configPath?: string;
httpHost?: string;
httpPort?: number;
receiveMode?: "on-start" | "manual";
@@ -460,10 +461,14 @@ export async function monitorSignalProvider(opts: MonitorSignalOpts = {}): Promi
if (autoStart) {
const cliPath = opts.cliPath ?? accountInfo.config.cliPath ?? "signal-cli";
const configPath =
normalizeOptionalString(opts.configPath) ??
normalizeOptionalString(accountInfo.config.configPath);
const httpHost = opts.httpHost ?? accountInfo.config.httpHost ?? "127.0.0.1";
const httpPort = opts.httpPort ?? accountInfo.config.httpPort ?? 8080;
daemonHandle = spawnSignalDaemon({
cliPath,
...(configPath ? { configPath } : {}),
account,
httpHost,
httpPort,

View File

@@ -32,7 +32,7 @@ export const signalConfigAdapter = createScopedChannelConfigAdapter<ResolvedSign
listAccountIds: (cfg) => listSignalAccountIds(cfg),
resolveAccount: adaptScopedAccountAccessor((params) => resolveSignalAccount(params)),
defaultAccountId: (cfg) => resolveDefaultSignalAccountId(cfg),
clearBaseFields: ["account", "httpUrl", "httpHost", "httpPort", "cliPath", "name"],
clearBaseFields: ["account", "configPath", "httpUrl", "httpHost", "httpPort", "cliPath", "name"],
resolveAllowFrom: (account: ResolvedSignalAccount) => account.config.allowFrom,
formatAllowFrom: (allowFrom) =>
allowFrom

View File

@@ -55,6 +55,19 @@ export function resolveTelegramScopedGroupConfig(
chatId: string | number,
messageThreadId?: number,
) {
const resolveTopicConfig = <T extends object>(
scopedConfig: { topics?: Record<string, T | undefined> } | undefined,
): T | undefined => {
if (!scopedConfig || messageThreadId == null) {
return undefined;
}
const defaultConfig = scopedConfig.topics?.["*"];
const exactConfig = scopedConfig.topics?.[String(messageThreadId)];
if (defaultConfig && exactConfig) {
return { ...defaultConfig, ...exactConfig };
}
return exactConfig ?? defaultConfig;
};
const groups = telegramCfg.groups;
const direct = telegramCfg.direct;
const chatIdStr = String(chatId);
@@ -62,18 +75,12 @@ export function resolveTelegramScopedGroupConfig(
if (isDm) {
const groupConfig = direct?.[chatIdStr] ?? direct?.["*"];
const topicConfig =
groupConfig && messageThreadId != null
? groupConfig.topics?.[String(messageThreadId)]
: undefined;
const topicConfig = resolveTopicConfig(groupConfig);
return { groupConfig, topicConfig };
}
const groupConfig = groups?.[chatIdStr] ?? groups?.["*"];
const topicConfig =
groupConfig && messageThreadId != null
? groupConfig.topics?.[String(messageThreadId)]
: undefined;
const topicConfig = resolveTopicConfig(groupConfig);
return { groupConfig, topicConfig };
}

View File

@@ -2622,6 +2622,72 @@ describe("createTelegramBot", () => {
expect(topicConfig).toEqual({});
});
it("uses topics.* as the default config for unmatched forum topics", () => {
const { groupConfig, topicConfig } = resolveTelegramScopedGroupConfig(
{
groupPolicy: "allowlist",
groups: {
"-1001234567890": {
allowFrom: ["999999999"],
topics: {
"*": { allowFrom: ["123456789"], agentId: "zu" },
},
},
},
},
-1001234567890,
77,
);
const group = groupConfig as TelegramGroupConfig | undefined;
expect(group?.allowFrom).toEqual(["999999999"]);
expect(topicConfig).toEqual({ allowFrom: ["123456789"], agentId: "zu" });
});
it("prefers exact topic config over topics.* fallback", () => {
const { topicConfig } = resolveTelegramScopedGroupConfig(
{
groupPolicy: "allowlist",
groups: {
"-1001234567890": {
topics: {
"*": { allowFrom: ["123456789"], agentId: "zu" },
"77": { allowFrom: ["555555555"], agentId: "main" },
},
},
},
},
-1001234567890,
77,
);
expect(topicConfig).toEqual({ allowFrom: ["555555555"], agentId: "main" });
});
it("inherits topics.* fields that exact topic config does not override", () => {
const { topicConfig } = resolveTelegramScopedGroupConfig(
{
groupPolicy: "allowlist",
groups: {
"-1001234567890": {
topics: {
"*": { allowFrom: ["123456789"], requireMention: false },
"77": { agentId: "main" },
},
},
},
},
-1001234567890,
77,
);
expect(topicConfig).toEqual({
allowFrom: ["123456789"],
requireMention: false,
agentId: "main",
});
});
it.each([
{
label: "parent binding",

View File

@@ -33,6 +33,36 @@ describe("resolveTelegramGroupRequireMention", () => {
}),
).toBe(false);
});
it("lets exact topic configs inherit wildcard topic requireMention", () => {
const cfg = {
channels: {
telegram: {
botToken: "telegram-test",
groups: {
"-1001": {
requireMention: true,
topics: {
"*": {
requireMention: false,
},
"77": {
agentId: "main",
},
},
},
},
},
},
} as OpenClawConfig;
expect(
resolveTelegramGroupRequireMention({
cfg,
groupId: "-1001:topic:77",
}),
).toBe(false);
});
});
describe("resolveTelegramGroupToolPolicy", () => {

View File

@@ -40,9 +40,14 @@ function resolveTelegramRequireMention(params: {
cfg.channels?.telegram?.groups;
const groupConfig = scopedGroups?.[chatId];
const groupDefault = scopedGroups?.["*"];
const topicConfig = topicId && groupConfig?.topics ? groupConfig.topics[topicId] : undefined;
const topicConfig =
topicId && groupConfig?.topics
? { ...groupConfig.topics["*"], ...groupConfig.topics[topicId] }
: undefined;
const defaultTopicConfig =
topicId && groupDefault?.topics ? groupDefault.topics[topicId] : undefined;
topicId && groupDefault?.topics
? { ...groupDefault.topics["*"], ...groupDefault.topics[topicId] }
: undefined;
if (typeof topicConfig?.requireMention === "boolean") {
return topicConfig.requireMention;
}

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,