Skip to content

fix(agents): repair stale bootstrap completion#71230

Merged
Patrick-Erichsen merged 3 commits into
openclaw:mainfrom
Patrick-Erichsen:codex/repair-stale-bootstrap-completion
Apr 24, 2026
Merged

fix(agents): repair stale bootstrap completion#71230
Patrick-Erichsen merged 3 commits into
openclaw:mainfrom
Patrick-Erichsen:codex/repair-stale-bootstrap-completion

Conversation

@Patrick-Erichsen

Copy link
Copy Markdown
Contributor

Summary

  • repair stale workspace bootstrap state when BOOTSTRAP.md is still present but onboarding evidence shows setup already completed
  • delete the stale bootstrap file and persist setupCompletedAt so future /new, /reset, Control UI/TUI, and embedded runs do not re-enter bootstrap
  • keep .git alone from counting as completion evidence, since OpenClaw can create it during workspace initialization
  • keep display-side sanitization for leaked bootstrap preludes as defense in depth

Related issues

Root cause

Workspace bootstrap completion depended on the model deleting BOOTSTRAP.md. If onboarding updated IDENTITY.md, USER.md, or SOUL.md but the model failed to delete BOOTSTRAP.md, the harness never wrote setupCompletedAt. After the workspace-truth routing change in #68000, those stale files caused new sessions and resets to keep injecting the Bootstrap pending wrapper.

Validation

  • pnpm test src/agents/workspace.test.ts src/gateway/chat-sanitize.test.ts
  • pnpm check:changed

@openclaw-barnacle openclaw-barnacle Bot added gateway Gateway runtime agents Agent runtime and tooling size: M maintainer Maintainer-authored PR labels Apr 24, 2026
Comment thread src/agents/workspace.ts Outdated
@Patrick-Erichsen
Patrick-Erichsen force-pushed the codex/repair-stale-bootstrap-completion branch 2 times, most recently from f8d7b22 to 5e7eea3 Compare April 24, 2026 20:01
@openclaw-barnacle openclaw-barnacle Bot removed the gateway Gateway runtime label Apr 24, 2026
@Patrick-Erichsen
Patrick-Erichsen force-pushed the codex/repair-stale-bootstrap-completion branch from 5e7eea3 to 58893bd Compare April 24, 2026 20:02
@Patrick-Erichsen
Patrick-Erichsen force-pushed the codex/repair-stale-bootstrap-completion branch from 58893bd to 9c28c69 Compare April 24, 2026 20:05
@Patrick-Erichsen
Patrick-Erichsen marked this pull request as ready for review April 24, 2026 20:08
@aisle-research-bot

aisle-research-bot Bot commented Apr 24, 2026

Copy link
Copy Markdown

🔒 Aisle Security Analysis

We found 3 potential security issue(s) in this PR:

# Severity Title
1 🟠 High Arbitrary file delete/write via exported reconcileWorkspaceBootstrapCompletion(dir) with unvalidated path
2 🟠 High Symlink/hardlink clobber via workspace bootstrap repair and state persistence
3 🟡 Medium Unbounded file reads in workspace bootstrap reconciliation can cause memory/CPU DoS
1. 🟠 Arbitrary file delete/write via exported reconcileWorkspaceBootstrapCompletion(dir) with unvalidated path
Property Value
Severity High
CWE CWE-73
Location src/agents/workspace.ts:307-314

Description

The exported reconcileWorkspaceBootstrapCompletion(dir) function performs filesystem writes and deletions in the caller-provided directory without ensuring the directory is an expected/safe workspace root.

  • Input: dir parameter (string) is resolved via resolveUserPath, which expands ~ and resolves the path but does not restrict it to an application-controlled workspace directory.
  • Sinks:
    • Deletes ${dir}/BOOTSTRAP.md via fs.rm(..., { force: true })
    • Creates/overwrites ${dir}/.openclaw/workspace-state.json via writeWorkspaceSetupState() (mkdir + writeFile + rename)
  • If any external surface (CLI arg, API request, plugin input, etc.) can influence dir, an attacker can point it at arbitrary filesystem locations (including sensitive directories) and cause unintended modification/deletion.

Vulnerable code (core sink):

await fs.rm(params.bootstrapPath, { force: true });
await writeWorkspaceSetupState(params.statePath, repairedState);

Recommendation

Restrict operations to an application-controlled workspace root and defend against symlink/path escape.

  • Require dir to be under DEFAULT_AGENT_WORKSPACE_DIR (or another configured workspace root), using realpath/canonicalization checks.
  • Consider reusing the existing boundary-guard pattern (e.g., openBoundaryFile) for writes/deletes as well.

Example mitigation:

import fsSync from "node:fs";
import path from "node:path";

function assertUnderRoot(targetDir: string, rootDir: string) {
  const rootReal = fsSync.realpathSync(rootDir);
  const targetReal = fsSync.realpathSync(targetDir);
  const rel = path.relative(rootReal, targetReal);
  if (rel.startsWith("..") || path.isAbsolute(rel)) {
    throw new Error(`Unsafe workspace dir: ${targetDir}`);
  }
}

export async function reconcileWorkspaceBootstrapCompletion(dir: string) {
  const resolvedDir = resolveUserPath(dir);
  assertUnderRoot(resolvedDir, DEFAULT_AGENT_WORKSPACE_DIR);// ... proceed
}

Additionally, avoid force: true unless necessary, and ensure callers cannot supply arbitrary paths.

2. 🟠 Symlink/hardlink clobber via workspace bootstrap repair and state persistence
Property Value
Severity High
CWE CWE-61
Location src/agents/workspace.ts:313-314

Description

The workspace bootstrap reconciliation and state persistence logic performs file deletion and file writes in a user-chosen workspace directory without protecting against symlink/hardlink attacks.

Impacts:

  • Arbitrary file overwrite (within process permissions): writeWorkspaceSetupState writes a temp file and then rename()s it into statePath. If the .openclaw directory (or statePath) is a symlink pointing outside the workspace, the code can end up writing workspace-state.json into an unintended location.
  • Arbitrary file deletion (within process permissions): reconcileWorkspaceBootstrapCompletionState calls fs.rm(bootstrapPath, { force: true }). If an attacker can replace BOOTSTRAP.md with a hardlink to another file writable by the process, unlinking it may delete the target file (or at least remove a link to it).

Vulnerable code:

await fs.rm(params.bootstrapPath, { force: true });
...
await fs.mkdir(path.dirname(statePath), { recursive: true });
await fs.writeFile(tmpPath, payload, { encoding: "utf-8" });
await fs.rename(tmpPath, statePath);

This can be triggered during normal workspace startup paths, including legacy migration persistence (persistLegacyMigration: true).

Recommendation

Harden workspace file operations against symlink/hardlink attacks:

  1. Refuse symlinks for security-sensitive paths (BOOTSTRAP.md, .openclaw, workspace-state.json). Use lstat() (not stat()) and reject if isSymbolicLink().
  2. Ensure paths stay within the intended workspace: resolve realpath() of dir and of path.dirname(statePath) and verify the latter starts with the former.
  3. Write state atomically without following symlinks (best-effort in Node): open the destination with O_NOFOLLOW (where supported) and write via file descriptor; alternatively, create .openclaw with safe permissions at workspace creation time and reject if it already exists but is not a real directory.
  4. Before deletion, verify BOOTSTRAP.md is a regular file and (optionally) that its link count is 1 to reduce hardlink risk.

Example hardening (POSIX-oriented, adjust for platform support):

import { constants as FS } from "node:fs";

async function assertNoSymlink(p: string) {
  const st = await fs.lstat(p).catch((e) => {
    if (e?.code === "ENOENT") return null;
    throw e;
  });
  if (st?.isSymbolicLink()) throw new Error(`Refusing to follow symlink: ${p}`);
}

await assertNoSymlink(path.join(dir, ".openclaw"));
await assertNoSymlink(statePath);
await assertNoSymlink(bootstrapPath);

If you need to support pre-existing workspaces, treat symlinked .openclaw/state files as an error and require user intervention rather than writing through them.

3. 🟡 Unbounded file reads in workspace bootstrap reconciliation can cause memory/CPU DoS
Property Value
Severity Medium
CWE CWE-400
Location src/agents/workspace.ts:213-225

Description

fileContentDiffersFromTemplate() reads entire onboarding profile files into memory using fs.readFile(..., "utf-8") with no size checks. This is invoked during ensureAgentWorkspace() legacy migration and via reconcileWorkspaceBootstrapCompletionState() when deciding whether a stale BOOTSTRAP.md indicates onboarding completion.

Impact:

  • An attacker who can write to the workspace directory (e.g., via the gateway method agents.files.set for SOUL.md/IDENTITY.md/USER.md, or any local/untrusted workspace content) can place extremely large files at these paths.
  • On startup/bootstrapping, the agent will attempt to read these files fully into memory (and do so in parallel across 3 files), which can lead to high memory usage, process slowdown, or crash (denial of service).

Vulnerable code:

return (await fs.readFile(filePath, "utf-8")) !== template;

Recommendation

Add a maximum size guard before reading, and avoid loading entire files when you only need to know whether they match the template.

Example fix (size check + bounded read):

const MAX_PROFILE_BYTES = 128 * 1024; // tune as appropriate

async function fileContentDiffersFromTemplate(filePath: string, template: string): Promise<boolean> {
  try {
    const st = await fs.stat(filePath);
    if (!st.isFile()) return false;
    if (st.size > MAX_PROFILE_BYTES) {// treat as "configured" without reading whole file (or handle as error)
      return true;
    }
    const content = await fs.readFile(filePath, "utf-8");
    return content !== template;
  } catch (err) {
    const anyErr = err as { code?: string };
    if (anyErr.code !== "ENOENT") throw err;
    return false;
  }
}

Alternatively, compare via streaming/hash to avoid full buffering, and consider applying similar limits anywhere workspace files are read during bootstrap.


Analyzed PR: #71230 at commit 028a1d8

Last updated on: 2026-04-24T21:00:23Z

@greptile-apps

greptile-apps Bot commented Apr 24, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes stale workspace bootstrap state where BOOTSTRAP.md persists after onboarding has actually completed. When profile files (IDENTITY.md, USER.md, or SOUL.md) differ from their templates — or when a memory directory exists — the harness now deletes the stale bootstrap file and records setupCompletedAt, preventing infinite re-entry into the bootstrap flow. The .git exclusion from the new repair path (while retaining it in the legacy migration path) is correctly implemented and tested.

Confidence Score: 5/5

Safe to merge; all remaining findings are minor style/documentation observations with no correctness impact.

The repair logic is correct: state mutations are fully captured before the repair call, stateDirty=false after repair prevents double-writes, and the .git exclusion asymmetry between the repair and legacy paths is intentional and tested. The two P2 comments (thin wrapper, implicit write side-effect) do not affect behavior.

No files require special attention.

Comments Outside Diff (1)

  1. src/agents/workspace.ts, line 358-378 (link)

    P2 resolveWorkspaceBootstrapStatus now has an implicit write side-effect

    Before this PR, resolveWorkspaceBootstrapStatus was a pure read — any caller could rely on it being non-mutating. It now conditionally deletes BOOTSTRAP.md and persists setupCompletedAt via repairStaleWorkspaceBootstrapCompletion. Callers like Control UI, TUI, or /new that call this function for a status check will silently mutate workspace state as a side-effect of reading. This is the intended repair behavior per the PR, but the function name ("resolve") gives no hint of the potential writes. A doc comment capturing the repair-on-first-call semantics would prevent future callers from being caught off guard.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: src/agents/workspace.ts
    Line: 358-378
    
    Comment:
    **`resolveWorkspaceBootstrapStatus` now has an implicit write side-effect**
    
    Before this PR, `resolveWorkspaceBootstrapStatus` was a pure read — any caller could rely on it being non-mutating. It now conditionally deletes `BOOTSTRAP.md` and persists `setupCompletedAt` via `repairStaleWorkspaceBootstrapCompletion`. Callers like Control UI, TUI, or `/new` that call this function for a status check will silently mutate workspace state as a side-effect of reading. This is the intended repair behavior per the PR, but the function name ("resolve") gives no hint of the potential writes. A doc comment capturing the repair-on-first-call semantics would prevent future callers from being caught off guard.
    
    How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
This is a comment left during a code review.
Path: src/agents/workspace.ts
Line: 264-266

Comment:
**Thin wrapper adds no structural distinction**

`workspaceHasBootstrapCompletionEvidence` is identical to calling `workspaceProfileLooksConfigured(params)` — the wrapper adds no logic, no option remapping, and no documentation beyond the name. Since `repairStaleWorkspaceBootstrapCompletion` is the only caller, the indirection quietly obscures that `includeGitEvidence` is intentionally omitted in the repair path. Inlining the call to `workspaceProfileLooksConfigured` directly (and noting the deliberate omission) would make the `.git`-exclusion intent explicit at the call site rather than hidden in a wrapper.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: src/agents/workspace.ts
Line: 358-378

Comment:
**`resolveWorkspaceBootstrapStatus` now has an implicit write side-effect**

Before this PR, `resolveWorkspaceBootstrapStatus` was a pure read — any caller could rely on it being non-mutating. It now conditionally deletes `BOOTSTRAP.md` and persists `setupCompletedAt` via `repairStaleWorkspaceBootstrapCompletion`. Callers like Control UI, TUI, or `/new` that call this function for a status check will silently mutate workspace state as a side-effect of reading. This is the intended repair behavior per the PR, but the function name ("resolve") gives no hint of the potential writes. A doc comment capturing the repair-on-first-call semantics would prevent future callers from being caught off guard.

How can I resolve this? If you propose a fix, please make it concise.

Reviews (1): Last reviewed commit: "fix(agents): repair stale bootstrap comp..." | Re-trigger Greptile

Comment thread src/agents/workspace.ts
Comment on lines +264 to +266
async function workspaceHasBootstrapCompletionEvidence(params: { dir: string }): Promise<boolean> {
return await workspaceProfileLooksConfigured(params);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Thin wrapper adds no structural distinction

workspaceHasBootstrapCompletionEvidence is identical to calling workspaceProfileLooksConfigured(params) — the wrapper adds no logic, no option remapping, and no documentation beyond the name. Since repairStaleWorkspaceBootstrapCompletion is the only caller, the indirection quietly obscures that includeGitEvidence is intentionally omitted in the repair path. Inlining the call to workspaceProfileLooksConfigured directly (and noting the deliberate omission) would make the .git-exclusion intent explicit at the call site rather than hidden in a wrapper.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agents/workspace.ts
Line: 264-266

Comment:
**Thin wrapper adds no structural distinction**

`workspaceHasBootstrapCompletionEvidence` is identical to calling `workspaceProfileLooksConfigured(params)` — the wrapper adds no logic, no option remapping, and no documentation beyond the name. Since `repairStaleWorkspaceBootstrapCompletion` is the only caller, the indirection quietly obscures that `includeGitEvidence` is intentionally omitted in the repair path. Inlining the call to `workspaceProfileLooksConfigured` directly (and noting the deliberate omission) would make the `.git`-exclusion intent explicit at the call site rather than hidden in a wrapper.

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9c28c697f5

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/agents/workspace.ts
Comment on lines +257 to +260
profileFileDiffs.some(Boolean) ||
(await hasWorkspaceUserContentEvidence(params.dir, {
includeGit: params.includeGitEvidence,
}))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Exclude memory evidence from stale-bootstrap repair

Treating memory/ or MEMORY.md as bootstrap-completion evidence causes false completion in normal /new and /reset flows: the bundled session-memory hook creates workspace/memory at the start of those commands (src/hooks/bundled/session-memory/handler.ts:80-82), and reset handling runs hooks before building the bare reset prompt (src/auto-reply/reply/get-reply.ts:497-563 then src/auto-reply/reply/get-reply-run.ts:358-374). With this change, isWorkspaceBootstrapPending() can immediately classify an actually-pending workspace as complete, delete BOOTSTRAP.md, and record setupCompletedAt without any onboarding/profile-file customization.

Useful? React with 👍 / 👎.

@Takhoffman

Copy link
Copy Markdown
Contributor

only thing that popped up for me was that bootstrap status resolution still mutates and can throw on read-only workspaces.

@Patrick-Erichsen

Copy link
Copy Markdown
Contributor Author

Validation update:

  • Ran pnpm test src/agents/workspace.test.ts; workspace regressions pass.
  • Ran a real CLI smoke with disposable OPENCLAW_HOME dirs via pnpm openclaw onboard --non-interactive --accept-risk and setup skips.
  • Confirmed a fresh workspace remains pending with BOOTSTRAP.md present and no setupCompletedAt.
  • Confirmed stale BOOTSTRAP.md plus customized IDENTITY.md repairs by deleting BOOTSTRAP.md, writing setupCompletedAt, and resolving bootstrap status as complete.

@Patrick-Erichsen
Patrick-Erichsen merged commit 137f5c3 into openclaw:main Apr 24, 2026
70 checks passed
Angfr95 pushed a commit to Angfr95/openclaw that referenced this pull request Apr 25, 2026
* fix(agents): repair stale bootstrap completion

* fix: reconcile stale workspace bootstrap explicitly

* fix: keep bootstrap reconciliation in workspace lifecycle
ogt-redknie pushed a commit to ogt-redknie/OPENX that referenced this pull request May 2, 2026
* fix(agents): repair stale bootstrap completion

* fix: reconcile stale workspace bootstrap explicitly

* fix: keep bootstrap reconciliation in workspace lifecycle
github-actions Bot pushed a commit to Desicool/openclaw that referenced this pull request May 9, 2026
* fix(agents): repair stale bootstrap completion

* fix: reconcile stale workspace bootstrap explicitly

* fix: keep bootstrap reconciliation in workspace lifecycle
globalcaos pushed a commit to globalcaos/tinkerclaw that referenced this pull request May 13, 2026
* fix(agents): repair stale bootstrap completion

* fix: reconcile stale workspace bootstrap explicitly

* fix: keep bootstrap reconciliation in workspace lifecycle
github-actions Bot pushed a commit to Desicool/openclaw that referenced this pull request May 24, 2026
* fix(agents): repair stale bootstrap completion

* fix: reconcile stale workspace bootstrap explicitly

* fix: keep bootstrap reconciliation in workspace lifecycle
jameslcowan pushed a commit to jameslcowan/openclaw that referenced this pull request Jun 2, 2026
* fix(agents): repair stale bootstrap completion

* fix: reconcile stale workspace bootstrap explicitly

* fix: keep bootstrap reconciliation in workspace lifecycle
sablehead pushed a commit to sablehead/openclaw that referenced this pull request Jun 10, 2026
* fix(agents): repair stale bootstrap completion

* fix: reconcile stale workspace bootstrap explicitly

* fix: keep bootstrap reconciliation in workspace lifecycle
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agents Agent runtime and tooling maintainer Maintainer-authored PR size: M

Projects

None yet

2 participants