Files
Abdel Gomez-PerezandAyaan Zaidi ed0df5bf09 fix(backup): preserve symbolic links to external targets (#141925)
Closes #141923

## What Problem This Solves

Fixes an issue where `openclaw backup create` publishes nothing when included state contains an absolute symbolic link to an external file or directory, including a dangling target.

This reworks @adele-with-a-b's reproduction and contribution under the maintainer's explicit recovery policy: preserve links and their targets, report external targets durably, and recreate the links during restore.

## Why This Change Was Made

One archive-entry policy preserves the original link target and classifies whether it names content outside the state directory, even when another declared asset includes the target. The existing traversal records external links. The manifest is sealed after that traversal and includes `externalSymbolicLinks`; no link target is followed to copy content.

The decision `how a symbolic link is recorded and whether its target is external` is made by exactly one mechanism at `src/infra/backup-archive-path-policy.ts:54`.

Restore extracts payload files and creates every link parent directory before creating the verified links. Archive path normalization preserves filename whitespace. Verification rejects entries beneath a symbolic link and checks that external-link records match the archive. Writer and reader share the existing 1 MiB manifest limit; oversized metadata fails before publication.

This intentionally reverses the absolute/escaping-target rejection from #123672 (`8c7e86a2ead`) and the owned-absolute rewrite from #136343 (`0aa9ae9f3e3`). @steipete @vsumner: the maintainer-directed recovery policy now preserves the original target text.

## User Impact

Backups complete with ordinary absolute, relative, and dangling links. The manifest, create summary, JSON output, and restore warnings identify outside-state targets. Creation never copies content through a link; a separately declared config, credentials, or workspace asset can still include that content. Absolute links still name their original locations after restore; review those paths before activating a relocated backup.

No database schema, migration, config key, CLI flag, or manifest version changes. No installation/update behavior change is claimed. Existing managed-root exclusions and SQLite snapshot/sanitization owners remain in place.

Compatibility findings: v2026.9.4 rejects preserved absolute or escaping targets. Such archives require the updated reader. Older portable relative archive links remain supported, including Windows links between drives. Absolute owned links are no longer rewritten for relocation, as required by literal target preservation. The manifest now appears after the payload and always includes an external-link report, which can be empty. Readers retain the older portable-relative archive contract when that report is absent. Old cross-asset links are still reported as outside-state during verification and restore.

## Evidence

- Current head `712293765774e11ac6b01ca0ce3fb839d98e5e97` adds one required test diagnostic label to the unchanged production implementation. CI caught the omitted second argument to `expectDefined`; both affected command cases and formatting pass after correction. Fresh source review confirms reuse of the production and independent proof below with their original tested identities. Corrected-input CI must establish type validity.

- Production proof on `a634bd60fa5e1dbb361c8ee5d3c77c07d418ed27`: rebuilt production CLI create/verify/restore and text create all exit 0; four separately included outside-state links produce four manifest/output records and preserve literal restored targets. The actual v2026.9.4 source build restores its ordinary archive with exact bytes, and rejects its absolute-link archive before creating the target.

- Census correction: the prior candidate preserved four links to separately included outside-state config, credentials, and workspace targets, but reported zero. Production CLI proof captures that failure. The corrected shared policy uses the state boundary and preserves literal targets.
- The writer seals the canonical state directory used by inventory. A command regression reproduced a verification mismatch when an enclosing workspace covers state accessed through an alias; the canonical manifest fact resolves it.

- Pinned main `0a991fdc6423686210830b7b582ea482174686fd`: production CLI `backup create --verify --json` exits 1 on the external file link; no archive published.
- Committed candidate `23ace9e1304471b162bad241fc32aaf18e9c7080`, with subsequent CI and containment corrections recorded below: production CLI creation and verification succeed; four link entries preserve targets; three external targets appear in the manifest and output.
- Previously reviewed candidate `dde971d1d42d24560160d62d50140665b150e441`: fresh independent CLI proof passes on its exact build. Seven links preserve targets, including trailing-space names and both dangling forms; all four external links are reported. Internal links read restored bytes after source mutation; external content stays absent. Both ordinary and space-suffixed crafted descendants are refused before target creation, with the complete external directory unchanged.
- v2026.9.4-created archive restores on candidate. The actual v2026.9.4 source build restores the candidate no-link archive (exit 0), but rejects the candidate external-link archive before extraction (exit 1: symbolic link target must be relative).
- No-link control on final head: payload paths, types, modes, targets, and bytes match pinned base after normalizing run identity. Manifest differences are its final position and an explicit empty `externalSymbolicLinks` report. The actual v2026.9.4 source reader restores this new ordinary archive byte-for-byte.
- Hardlink control: 1,000 hardlinks stall on the same entry at the unchanged 300000ms watchdog on pinned main and candidate. This is the adjacent #145429 failure; no hardlink fix is claimed.
- Third-party SQLite: baseline and candidate creation pass; candidate restore preserves the original vendor table row.
- Manifest-limit stress: 1,300 links with long targets exceed the existing 1 MiB manifest bound. Creation exits 1 with an explicit size error and publishes no archive.
- Focused create/verify/restore owner tests pass. Four stale command tar mocks were repaired; the command and atomic-publication sibling run then passed all 23 tests. Scoped production/test lint, formatting, line limits, assertion checks, and unused exports pass. All four structural checks also pass on the exact merge tree. The unused-export scan needed a frozen workspace install to load its browser-test dependency; the original setup failure is retained. Full typechecks and suites remain CI-owned.
- First-head CI exposed stream/filter type mismatches; the correction uses the producer’s `AsyncIterable<Buffer>` contract and the verifier’s recorded link paths. Local typechecks remain disabled; exact-head CI owns that proof.
- Live review found a space-suffixed link ancestor bypass. Production CLI reproduction confirmed an external write on the prior candidate. The corrected normalizer retains filename spaces, and restore finishes all parent directories before exclusive link creation. Restore/verify/atomic tests pass: 76, with one existing platform skip.
- Supplemental census-correction proof on `00f727bb6da9e1f8e09f0a19f87ef4e40576806a`: a fresh source-blind validator exercised 22 CLI invocations. Nine outside-state first hops were reported across all outputs; 11 literal links and separately declared asset bytes survived. Aliased state inside an enclosing workspace, old portable cross-asset archives, malformed report refusals, both link-descendant refusals, and the previous-stable artifact all passed.
- Execution proof is macOS arm64 only. Linux and native Windows restoration remain unproven.

## Consumers

| Reader or writer | Disposition |
| --- | --- |
| `register.backup.ts` → `backupCreateCommand` → `createBackupArchive` | Same CLI path and options; writer retains links and emits report. |
| `backup-archive-path-policy.ts` | One link policy replaces the remapper plus rejecting guard. Entry-path containment remains separate from link-target text. |
| `backup-create-stream.ts` | Streams payload once and appends the finalized manifest before gzip completes. |
| `backup-verify-manifest.ts` | Reads additive external records; owns the shared manifest byte limit. |
| `backup-verify.ts` listing/verification | Existing tar listing is the metadata census; uses the same link policy, matches report, and rejects link ancestors. |
| `backup-restore.ts` | Consumes verified link records after ordinary extraction and all-parent-directory preparation; recreates exact targets with exclusive creation. |
| `migrate/apply.ts` pre-migration backup | Uses the same verified backup; report survives inside its archive even though create text is suppressed. |
| `backup-health.ts`, `backup-run-records.ts`, status/Doctor | Existing success/failure receipt contract remains unchanged; no separate archive parser. |
| `scripts/e2e/lib/external-package-transition.sh` | Calls the registered CLI and retains JSON; it does not parse the manifest or link records. This external-install harness is not an updater proof. |
| `src/infra/backup-create.test.ts`, `src/commands/backup-verify.test.ts`, `src/commands/backup-restore.test.ts` | Direct typed owner-test readers. The test ledger replaces target rejection and rewriting with literal-target, external-report, and restored-filesystem assertions through the registered command entry points; containment and older-reader cases remain covered. |
| `src/cli/program/register.backup.product-path.test.ts:13` | Launches the actual create CLI and verifies completion when SQLite capture outlives the audit lease. The existing `backup-cli-registration` lane collected its passing case in job 103526025671, log line 11081. |
| `src/cli/help-exit.process.test.ts:451` | Launches actual create CLI cases for an absolute configured-config link and excluded workspace. `test/vitest/vitest.cli-process-paths.mjs:10` assigns it to the separate process group. Both cases passed in job 103526024850, log lines 1593–1594; required receipt `backup-cli-process` binds this consumer and output. |
| `backup-capture-privacy.test.ts`, `backup-create.sqlite-hardlinks.test.ts`, `backup-config-capture.test.ts`, `backup-create.legacy-audit-lease.test.ts` | Typed-reference census found existing test consumers. Calls and inputs remain supported; core owner and hardlink/SQLite endpoint evidence cover the touched archive behavior. Their full CI test lanes remain required. |
| `backup.test-support.ts`, `backup.test.ts`, `backup.atomic.test.ts`, `backup.create-verify.test.ts` | The mock stream contract was updated; finished-archive assertions replace temporary-manifest callback inspection. All 23 command/atomic sibling tests pass. |
| `src/commands/backup-shared.ts` | Unchanged shared payload encoder and plan fields remain supported. Caller canonicalization supplies inventory.stateDir; raw plan.stateDir continues serving existing config/lease planning. The new manifest/report boundary consumes the canonical inventory path without changing the planner contract. |
| `src/commands/backup-resource-inventory.ts` | Unchanged stateDir declaration remains the canonical inventory boundary. New writer metadata reuses this authoritative fact rather than creating a competing state owner. |
| `src/infra/backup-sqlite-snapshot.ts` | Unchanged inventory.stateDir consumers classify snapshot paths, locate gateway coordination, walk the canonical state tree and set isolated snapshot environment. Reporting reuse does not alter these snapshot/lease side effects or database ownership. |
| SQLite backup list/verify/restore, Git backups, Fleet and skill collection backups | Separate artifact formats; unchanged. There is no native whole-archive list command. |
| `docs/cli/backup.md` | Documents literal link targets, durable reporting, extraction order, and the previous-reader compatibility limit. |
| `docs/install/backups.md` | Removes the unqualified portable-archive promise; states that absolute links retain original locations, including separately backed-up config/credentials, and need review before relocated activation. Links the detailed CLI caveat. |
| `docs/install/migrating.md` | Machine-move instructions now require reviewing original absolute targets, including separately backed-up config/credentials, before activation at another location. Links the detailed CLI caveat. |
| `docs/install/digitalocean.md`, `docs/install/oracle.md`, `docs/install/raspberry-pi.md` | Deployment backup instructions replace unqualified portability claims with archive transfer/staging and review of original absolute targets before activation, including separately backed-up config/credentials. Each links the detailed CLI caveat. |
| `docs/install/updating/rollback-and-recovery.md:195` | Unchanged explicit pre-update backup command and manifest/source-path guidance. It does not promise automatic link relocation or exercise the installed-software updater. |
| `docs/cli/reset.md:40`, `docs/cli/uninstall.md:34` | Unchanged commands recommend an archive before removal and use supported create options; no archive parser or relocation rule. |
| `docs/cli/migrate.md:78` | Unchanged description of the verified pre-migration backup uses the same create command; no separate format or link policy. |
| `docs/plugins/manifest/surfaces.md:174` | Unchanged `--only-config` example and plugin resource inclusion contract; link preservation does not change manifest resource discovery. |
| `docs/cli/doctor/sqlite-maintenance.md:32,70` | Backup-advice consumers are outside the explicit Doctor-advice contract; maintenance wording remains unchanged. |
| `docs/releases/2026.7.1.md` | Historical release statements are records of shipped behavior, not current operator instructions to rewrite. |

The process receipt supplements the retained prior-head evidence for run 34683341770, attempt 1, event `pull_request`: `checks-node-compact-small-12` / `agentic-cli-process-hosted-6` passed eight files with one skipped and 53 tests with five skipped (job 103526024850, log lines 1629–1630). The corrected receipt set includes required lane `backup-cli-process`; registration proof already includes the product-path process case. These receipts do not claim execution of a later revision.

The preceding receipt plan bound head `a634bd60fa5e1dbb361c8ee5d3c77c07d418ed27`, merge `665241f7662257d8339b71b79fd28dab939aa268`, and run 34685894510 attempt 1. The writer lane is `checks-node-compact-large-36` (103532821764); command consumers moved to `checks-node-compact-small-4` (103532820738); registration uses `checks-node-compact-small-9` (103532822348); the separate required `backup-cli-process` lane uses `checks-node-compact-small-12` (103532821731). That run exposed the missing test-helper argument in `check-test-types-core-1`; it cannot supply a passing type receipt. Run 34686603366 on the corrected head must supply fresh completed output for the same 15 required structural and affected lanes. Prior receipt identities remain historical, not current-head success.

Unchanged-helper reference expansion is explicitly dispositioned here. The queried `writeJson` and `realpathSync` contracts are unchanged in `src/infra/json-files.ts`, `src/infra/json-files.test.ts`, `src/infra/install-package-dir.ts`, `src/infra/npm-managed-root.ts`, `src/skills/loading/workspace-skill-sync.runtime.ts`, `src/skills/lifecycle/clawhub-store.ts`, `src/skills/lifecycle/source-install.ts`, `src/agents/cli-runner/bundle-mcp-runtime.ts`, `src/cli/update-cli/update-command-post-core.ts`, `scripts/lib/package-dist-inventory.ts`, `src/plugin-sdk/json-store.ts`, `src/wizard/setup.migration-promotion.ts`, `src/gateway/test-helpers.config-runtime.ts`, and `src/gateway/test-helpers.server.ts`. Dependency declarations `@openclaw/fs-safe/dist/json.d.ts` and `@types/node/fs.d.ts` are also unchanged. These are helper-reference results, not affected archive consumers; no SDK or updater change or shipped-consumer proof is requested for them. CLI startup/config-preflight readers that only recognize unchanged command spelling likewise do not consume the archive contract. No additional affected native app, channel, plugin runtime, SDK export, locale, or persistence consumer was found in the inspected source/lexical closure.

Removed `assertArchiveSymbolicLinkTarget` had only native create and verify consumers; both use `recordArchiveSymbolicLink`. The private absolute remapper is deleted. No removed policy remains reachable.

Typed census: pinned-base and final merge-tree runs use explicit `tsconfig.core.json`, `test/tsconfig/tsconfig.core.test.commands.json`, and `test/tsconfig/tsconfig.core.test.infra.json` projects. Selected declarations and import bindings are covered in the loaded projects; the raw parsed JSON property has an explicitly recorded dynamic-query gap. All returned reader locations have retained dispositions, including unchanged `writeJson` and `realpathSync` helper users. Unloaded projects and sources remain explicit coverage gaps; no whole-repository typed coverage is claimed. The retained prior-head 86-position batch ran on CI merge `f7c0b811779ffbbca93b2e3047807d8683a1bfba`; all three projects loaded and analysis exited 0. The lexical census found no remaining reader of the removed policy, no manual rows, and no generic exemptions.

The shared filename normalizer also serves manifest asset membership, SQLite snapshot-root discovery, duplicate/portable-collision checks, and older rootless hardlink lookup. Each now retains significant whitespace; declaration/import and individual reader dispositions are retained with the typed results. The ordinary and trailing-space hardlink tests remain green.

<details>
<summary>Typed coverage gaps</summary>

Unloaded projects (136): `extensions/{a2a, acpx, admin-http-rpc, alibaba, amazon-bedrock-mantle, amazon-bedrock, anthropic-vertex, anthropic, arcee, azure-speech, baseten, brave, browser, buzz, byteplus, cerebras, chutes, clawrouter, clickclack, cloudflare-ai-gateway, codex, cohere, comfy, copilot-proxy, copilot, deepgram, deepinfra, deepseek, diagnostics-otel, diagnostics-prometheus, diffs, discord, duckduckgo, elevenlabs, exa, fal, featherless, feishu, firecrawl, fireworks, geolocation, github-copilot, gmi, google-meet, google, googlechat, gradium, groq, huggingface, image-generation-core, imessage, inworld, irc, kilocode, kimi-coding, line, litellm, llm-task, lobster, longcat, matrix, mattermost, memory-core, memory-lancedb, memory-wiki, meta, microsoft-foundry, microsoft, minimax, mistral, moonshot, msteams, mxc, nextcloud-talk, nostr, novita, nvidia, ollama, openai, opencode-go, opencode, openrouter, openshell, parallel, perplexity, pixverse, qa-channel, qa-lab, qianfan, qwen, raft, reef, runway, searxng, sglang, signal, slack, sms, stepfun, synology-chat, synthetic, tavily, teams-meetings, telegram, tencent, tlon, together, tokenjuice, twitch, venice, vercel-ai-gateway, vllm, voice-call, volcengine, vydra, webhooks, whatsapp, xai, xiaomi, zai, zalo, zalouser, zoom-meetings}/tsconfig.json`; `extensions/tsconfig.json`; `extensions/tsconfig.package-boundary.base.json`; `extensions/tsconfig.package-boundary.paths.json`; `packages/ai/tsconfig.json`; `packages/llm-core/tsconfig.json`; `packages/model-catalog-core/tsconfig.json`; `packages/plugin-sdk/tsconfig.json`; `test/tsconfig/tsconfig.extensions.test.json`; `tsconfig.extensions.json`; `tsconfig.extensions.projects.json`; `tsconfig.json`; `tsconfig.scripts.json`; `tsconfig.ui.json`. These projects were not selected and remain explicit coverage gaps.

</details>

The retained prior-head merge census has 441 distinct symbol/location records, each with a retained semantic disposition. Of 258 project-position queries, 211 resolve and 47 are outside one selected project but covered by another; no position is wholly unresolved. All 13 files from that prior head had empty candidate-to-merge diffs. The lexical PR inventory uses final rebase base `0ffc3f0fb17f43563ea42fcd3a10fa1af31a98ab`; the original failure baseline remains pinned separately.

Supplemental correction BASE census: 48 positions on the prior tested merge, whose affected owner files match `dde971d1`; all 144 project queries resolve, with 253 distinct reader dispositions across 11 files. No projects failed. This supplements the immutable original baseline census. The new merge-tree query and its exact coverage are recorded below.

Previous-candidate typed census on merge `665241f7662257d8339b71b79fd28dab939aa268`, tree `0c10bb805dde42acfbe16cce50587b595f120690`, for head `a634bd60fa5e1dbb361c8ee5d3c77c07d418ed27`: 146 freshly selected positions cover the original 86-position batch, the 48-position correction BASE batch, and new state/report-presence declarations and aliases. All three selected projects loaded and analysis exited 0 with no project failures. The 3,080 returned references reduce to 758 distinct symbol/location records across 23 files; every record has a retained source-based disposition.

Of 438 project-position queries, 382 resolve. Another 53 are outside one selected project and resolve in a named sibling project. Three query instances are the same raw JSON position, `src/commands/backup-verify-manifest.ts:187:16`: `parsed.externalSymbolicLinks` has no declared member symbol because `parsed` is narrowed to `Record<string, unknown>`. This is an explicit dynamic-field coverage gap. The parser function, `parsed` local, `BackupManifest.externalSymbolicLinks` declaration, and returned shorthand at :187:61 resolve. Source review traces the presence check at :150/:187: an absent field stays absent; a present array is validated and copied. The verifier consumes that distinction at `src/commands/backup-verify.ts:672,684`; command tests distinguish absent, empty, and populated reports at `src/commands/backup-verify.test.ts:1542,1555,1574`. No unresolved export edge was returned.

The 136 unloaded projects remain explicit coverage gaps. All 66,307 source-outside-project records remain named by project without truncation in the coverage artifact; this includes overlapping exclusions and is not a distinct-file count. Native code, docs, configuration, process-launch strings and dynamic wire keys remain manual-census surfaces. No whole-repository typed coverage is claimed.

All 17 PR files and all 23 returned-reader files have empty candidate-to-merge diffs. Six dependency manifests/project configs and 95 direct relative imports also match. The merge contains 353 other changed files, retained in the dependency record; no whole-tree or complete transitive runtime equivalence is claimed. The original failure BASE and supplemental prior-candidate BASE remain separate evidence.

Fresh typed analysis completed on merge `c044a4f1d0ed93856127e2d72c2e69e362a37c39`, tree `d0111aa9cfce690f1f98e825012d0e6f21d890cb`, for head `712293765774e11ac6b01ca0ce3fb839d98e5e97`. Its 146 checked positions retain the original 86-position scope, the 48-position supplemental BASE scope, and new canonical-state/report-presence declarations and aliases. All three selected projects loaded; exit 0, no project failures. The 3,080 raw references reduce to 758 distinct symbol/location records across 23 files. Every returned record has a fresh source-based disposition with its full query/project origins and enclosing context retained.

Of 438 project-position queries, 382 resolve. Another 53 miss within one selected project but resolve in a named sibling project. Three instances represent one persistent dynamic-field gap at `src/commands/backup-verify-manifest.ts:187:16`: `parsed.externalSymbolicLinks` is a raw wire member on `Record<string, unknown>`, so it has no declared member symbol. The parser function, `parsed` local, typed manifest property and returned shorthand at :187:61 resolve. Manual source review confirms the checks at :150/:187 preserve absent versus present reports; verifier lines :672/:684 consume that distinction. Verifier tests at :1542/:1555/:1574 cover empty, missing and populated reports. No unresolved export edge was returned; all 133 export records retain dispositions.

The 136 unloaded projects remain named coverage gaps. All 66,315 source-outside-project records are retained without truncation, including overlapping project exclusions; this is not a distinct-file count. The selected scope is `tsconfig.core.json`, `test/tsconfig/tsconfig.core.test.commands.json` and `test/tsconfig/tsconfig.core.test.infra.json`. Native code, docs, configuration, process-launch strings and dynamic wire keys remain manual-census surfaces. This is not whole-repository typed coverage.

All 17 task files, all 23 returned-reader files, six dependency/configuration files and 95 direct relative-import edges (54 unique target files) match between candidate and merge. The merge also contains 391 other file changes, listed in the dependency record. The comparison establishes no whole-tree or complete transitive-runtime equivalence. The original pinned failure BASE and supplemental BASE evidence remain unchanged and separately identified.

The only candidate change since the previous census adds the required `expectDefined` context argument in the restored workspace-file assertion. Fresh positions were checked against the new merge, and later returned test locations were reviewed with their one-line shift. This correction adds no production contract. Previous failed type evidence remains failed; corrected-head CI is separate required evidence.

Current receipt plan: head `712293765774e11ac6b01ca0ce3fb839d98e5e97`, merge `c044a4f1d0ed93856127e2d72c2e69e362a37c39`, run 34686603366 attempt 1. Writer job 103534715513, command job 103534714257, registration job 103534716527, process job 103534715637, and corrected infra-type job 103534712831 are explicitly required. All 15 structural and affected lanes need successful completed output; the plan is not a success receipt.

Pipeline endpoint: restored filesystem entries, their exact `readlink` values, readable internal content, and untouched external content.

## Invalidation

No persistent cache is added. The external-link list belongs to one archive attempt and is cleared before each existing retry. The final manifest derives from observed write entries. Verification derives new link records from the selected archive for each invocation; restore consumes those verified records.

## Contention

No new shared lock, queue, or database transaction is held. Existing SQLite/audit snapshot capture completes before tar traversal. Compression, temporary output, and the link report belong to one archive operation.

## Tests

New and rewritten tests name `backupCreateCommand`, `backupVerifyCommand`, and `backupRestoreCommand`. Coverage includes separately included outside-state config/credentials/workspace targets (absolute and relative), an aliased state directory covered by a workspace, absent versus empty legacy reports, file/directory/dangling/internal/external links, exact manifest records, old Windows cross-drive links, managed-skill non-promotion, literal backslashes, and refusal of files or links beneath an archived symlink. Existing hardlink and SQLite coverage remains.

Test deletion ledger: obsolete target-rejection and owned-target-rewrite expectations are replaced by literal-target and restored-file assertions in the same owner tests. No whole test file is removed.

## Context

Related #145429 remains a draft hardlink-stall repair. This diff does not change its link-cache or traversal policy, but both PRs edit the tar-options owner in `src/infra/backup-create.ts`: #145429 replaces the link-cache helper and adds serial hardlink options. There is no semantic landing order. Whichever lands second must preserve both the hardlink options and this manifest/link path, then prove the combined writer. #144552, #107433, and #40786 are adjacent issues outside this repair.

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-09-12 15:51:07 +05:30

8.6 KiB

summary, read_when, title
summary read_when title
Host OpenClaw on Oracle Cloud's Always Free ARM tier
Setting up OpenClaw on Oracle Cloud
Looking for free VPS hosting for OpenClaw
Want 24/7 OpenClaw on a small server
Oracle Cloud

Run a persistent OpenClaw Gateway on Oracle Cloud's Always Free ARM tier (up to 4 OCPU, 24 GB RAM, 200 GB storage) at no cost.

Prerequisites

Setup

1. Log into [Oracle Cloud Console](https://cloud.oracle.com/). 2. Navigate to **Compute > Instances > Create Instance**. 3. Configure: - **Name:** `openclaw` - **Image:** Ubuntu 24.04 (aarch64) - **Shape:** `VM.Standard.A1.Flex` (Ampere ARM) - **OCPUs:** 2 (or up to 4) - **Memory:** 12 GB (or up to 24 GB) - **Boot volume:** 50 GB (up to 200 GB free) - **SSH key:** Add your public key 4. Click **Create** and note the public IP address.
<Tip>
If instance creation fails with "Out of capacity", try a different availability domain or retry later. Free tier capacity is limited.
</Tip>
```bash ssh ubuntu@YOUR_PUBLIC_IP
sudo apt update && sudo apt upgrade -y
sudo apt install -y build-essential
```

`build-essential` is required for ARM compilation of some dependencies.
```bash sudo hostnamectl set-hostname openclaw sudo passwd ubuntu sudo loginctl enable-linger ubuntu ```
Enabling linger keeps user services running after logout.
```bash curl -fsSL https://tailscale.com/install.sh | sh sudo tailscale up --ssh --hostname=openclaw ```
From now on, connect via Tailscale: `ssh ubuntu@openclaw`.
```bash curl -fsSL https://openclaw.ai/install.sh | bash source ~/.bashrc ```
When the installer offers to hatch an agent, defer it — the gateway steps
below configure the host first.
Use token auth with Tailscale Serve for secure remote access.
```bash
openclaw config set gateway.bind loopback
openclaw config set gateway.auth.mode token
openclaw doctor --generate-gateway-token
openclaw config set gateway.tailscale.mode serve
openclaw config set gateway.trustedProxies '["127.0.0.1"]'

openclaw gateway install
systemctl --user restart openclaw-gateway.service
```

`gateway.trustedProxies=["127.0.0.1"]` here is only for the local Tailscale Serve proxy's forwarded-IP/local-client handling. It is **not** `gateway.auth.mode: "trusted-proxy"`. Diff viewer routes keep fail-closed behavior in this setup: raw `127.0.0.1` viewer requests without forwarded proxy headers return `Diff not found`. Use `mode=file` / `mode=both` for attachments, or intentionally enable remote viewers and set `plugins.entries.diffs.config.viewerBaseUrl` (or pass a proxy `baseUrl`) if you need shareable viewer links.
Block all traffic except Tailscale at the network edge:
1. Go to **Networking > Virtual Cloud Networks** in the OCI Console.
2. Click your VCN, then **Security Lists > Default Security List**.
3. **Remove** all ingress rules except `0.0.0.0/0 UDP 41641` (Tailscale).
4. Keep default egress rules (allow all outbound).

This blocks SSH on port 22, HTTP, HTTPS, and everything else at the network edge. You can only connect via Tailscale from this point on.
```bash openclaw --version systemctl --user status openclaw-gateway.service tailscale serve status curl http://localhost:18789 ```
Access the Control UI from any device on your tailnet:

```
https://openclaw.<tailnet-name>.ts.net/
```

Replace `<tailnet-name>` with your tailnet name (visible in `tailscale status`).

Verify the security posture

With the VCN locked down (only UDP 41641 open) and the Gateway bound to loopback, public traffic is blocked at the network edge and admin access is tailnet-only. That removes the need for several traditional VPS hardening steps:

Traditional step Needed? Why
UFW firewall No The VCN blocks traffic before it reaches the instance.
fail2ban No Port 22 is blocked at the VCN; no brute-force surface.
sshd hardening No Tailscale SSH does not use sshd.
Disable root login No Tailscale authenticates by tailnet identity, not system users.
SSH key-only auth No Same -- tailnet identity replaces system SSH keys.
IPv6 hardening Usually not Depends on VCN/subnet settings; verify what is actually assigned/exposed.

Still recommended:

  • chmod 700 ~/.openclaw to restrict credential file permissions.
  • openclaw security audit for an OpenClaw-specific posture check.
  • Regular sudo apt update && sudo apt upgrade for OS patches.
  • Review devices in the Tailscale admin console periodically.

Quick verification commands:

# Confirm no public ports are listening
sudo ss -tlnp | grep -v '127.0.0.1\|::1'

# Verify Tailscale SSH is active
tailscale status | grep -q 'offers: ssh' && echo "Tailscale SSH active"

# Optional: disable sshd entirely once Tailscale SSH is confirmed working
sudo systemctl disable --now ssh

ARM notes

The Always Free tier is ARM (aarch64). Most OpenClaw features work fine; a small number of native binaries need ARM builds:

  • Node.js, Telegram, WhatsApp (Baileys): pure JavaScript, no issues.
  • Most npm packages with native code: pre-built linux-arm64 artifacts available.
  • Optional CLI helpers (e.g. Go/Rust binaries shipped by skills): check for an aarch64 / linux-arm64 release before installing.

Verify the architecture with uname -m (should print aarch64). For binaries without an ARM build, install from source or skip them.

Persistence and backups

OpenClaw state lives under:

  • ~/.openclaw/ -- openclaw.json, shared and per-agent SQLite auth stores, channel/provider state, and session data.
  • ~/.openclaw/workspace/ -- the agent workspace (SOUL.md, memory, artifacts).

These survive reboots. To create a backup archive:

openclaw backup create
openclaw backup restore <archive.tar.gz> --target <fresh-directory>

Absolute symbolic links keep their original target locations, including links to separately backed-up config or credentials. Review these links before activating state on another host or at another path; see the backup symbolic-link caveat. Restore verifies and extracts into a fresh staging directory; activation is a separate offline step. See Restore a full archive for the rollback warnings and activation sequence.

Fallback: SSH tunnel

If Tailscale Serve is not working, use an SSH tunnel from your local machine:

ssh -L 18789:127.0.0.1:18789 ubuntu@openclaw

Then open http://localhost:18789.

Troubleshooting

Instance creation fails ("Out of capacity") -- Free tier ARM instances are popular. Try a different availability domain or retry during off-peak hours.

Tailscale will not connect -- Run sudo tailscale up --ssh --hostname=openclaw --reset to re-authenticate.

Gateway will not start -- Run openclaw doctor --non-interactive and check logs with journalctl --user -u openclaw-gateway.service -n 50.

ARM binary issues -- Most npm packages work on ARM64. For native binaries, look for linux-arm64 or aarch64 releases. Verify architecture with uname -m.

Next steps