mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
docs: absorb documentation PR sweep
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:**
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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" } } },
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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" } } },
|
||||
}
|
||||
```
|
||||
|
||||
33
docs/reference/secret-placeholder-conventions.md
Normal file
33
docs/reference/secret-placeholder-conventions.md
Normal 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}"
|
||||
```
|
||||
@@ -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:
|
||||
|
||||
@@ -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) ||
|
||||
|
||||
@@ -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>;
|
||||
|
||||
24
extensions/signal/src/daemon.test.ts
Normal file
24
extensions/signal/src/daemon.test.ts
Normal 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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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