Skip to content

Commit 229add9

Browse files
author
t2wei
committed
fix(exec): allow symlinked OPENCLAW_STATE_DIR via terminal stat
`ensureDir` validated the approvals directory with `fs.lstatSync`, then rejected any path where `isSymbolicLink()` was true. This breaks on common deployment topologies where the state directory itself is a top-level symlink (e.g. `/opt/openclaw` -> `/mnt/efs/openclaw` for EFS-backed hosts on AWS). The companion `assertNoExecApprovalsSymlinkParents` check walks the segments BETWEEN the trusted root and the terminal target — when `OPENCLAW_STATE_DIR` equals `OPENCLAW_HOME` (the trusted root), no segments are walked and the terminal symlink slips past that walk without inspection. The follow-up lstat check is therefore the only gate, and it rejects the entire deployment. Switch to `fs.statSync` so a terminal symlink that resolves to a real directory is accepted while: - intermediate path symlinks are still rejected by the parents check - non-directory terminals (regular files, FIFOs, etc.) are still rejected via `dirStat.isDirectory()` Symptom before fix: ```text [tools] exec failed: Refusing to use unsafe exec approvals directory: /opt/openclaw raw_params={"workdir":"...","timeout":20} ``` Every exec tool call from any agent failed with the same error regardless of `workdir`, because `OPENCLAW_STATE_DIR` resolution happens server-side before the workdir is consulted. Adds a regression test that mirrors the deployment shape: `HOME` and `STATE_DIR` both pointing at a symlink whose target is a real directory.
1 parent a0f93cf commit 229add9

2 files changed

Lines changed: 34 additions & 2 deletions

File tree

src/infra/exec-approvals-store.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,30 @@ describe("exec approvals store helpers", () => {
338338
},
339339
);
340340

341+
it.runIf(process.platform !== "win32")(
342+
"accepts a symlinked state directory whose target is a real directory",
343+
() => {
344+
// Deployments commonly mount the state dir via a top-level symlink
345+
// (e.g. `/opt/openclaw` -> `/mnt/efs/openclaw` for EFS-backed hosts).
346+
// When HOME and STATE_DIR coincide at the symlink, the
347+
// `assertNoExecApprovalsSymlinkParents` walk sees zero segments
348+
// (target == root) and does not check the terminal symlink.
349+
// `ensureDir` must therefore resolve the symlink (statSync) instead of
350+
// rejecting any terminal symlink outright.
351+
const tmpRoot = makeTempDir();
352+
tempDirs.push(tmpRoot);
353+
const realRoot = path.join(tmpRoot, "real");
354+
fs.mkdirSync(realRoot, { recursive: true });
355+
const linkedRoot = path.join(tmpRoot, "linked");
356+
fs.symlinkSync(realRoot, linkedRoot);
357+
setTestEnvValue("OPENCLAW_HOME", linkedRoot);
358+
setTestEnvValue("OPENCLAW_STATE_DIR", linkedRoot);
359+
360+
expect(() => ensureExecApprovals()).not.toThrow();
361+
expect(fs.existsSync(path.join(realRoot, "exec-approvals.json"))).toBe(true);
362+
},
363+
);
364+
341365
it.runIf(process.platform !== "win32")(
342366
"rejects symlinked approvals files before resolving the default no-prompt policy",
343367
() => {

src/infra/exec-approvals.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -418,8 +418,16 @@ function ensureDir(filePath: string) {
418418
const dir = path.dirname(filePath);
419419
assertNoExecApprovalsSymlinkParents(dir, resolveRequiredHomeDir());
420420
fs.mkdirSync(dir, { recursive: true });
421-
const dirStat = fs.lstatSync(dir);
422-
if (!dirStat.isDirectory() || dirStat.isSymbolicLink()) {
421+
// Resolve symlinks at the terminal directory: deployments often mount the
422+
// state directory via a top-level symlink (e.g. `/opt/openclaw` →
423+
// `/mnt/efs/openclaw` for EFS-backed hosts). `assertNoExecApprovalsSymlinkParents`
424+
// walks segments BETWEEN the trusted root and the terminal target — when
425+
// the terminal equals the trusted root, no segments are walked and a
426+
// symlink at the terminal is missed. Use `statSync` (follows symlinks) so
427+
// a terminal symlink that resolves to a real directory is accepted while
428+
// intermediate symlinks remain rejected by the parents check above.
429+
const dirStat = fs.statSync(dir);
430+
if (!dirStat.isDirectory()) {
423431
throw new Error(`Refusing to use unsafe exec approvals directory: ${dir}`);
424432
}
425433
try {

0 commit comments

Comments
 (0)