Skip to content

Commit 3acd668

Browse files
fix(exec): respect OPENCLAW_STATE_DIR for exec approvals
1 parent 18237bc commit 3acd668

6 files changed

Lines changed: 119 additions & 12 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai
1313

1414
### Fixes
1515

16+
- Exec approvals: honor `OPENCLAW_STATE_DIR` for the local approvals JSON, default socket, and effective policy host-source path while keeping the unset default at `~/.openclaw`. (#65736) Thanks @oinoom.
1617
- Gateway/shutdown: report structured shutdown warnings and HTTP close timeout warnings through `ShutdownResult` while preserving lifecycle hook hardening. Carries forward #41296. Thanks @edenfunf.
1718
- Plugins/QA: prebuild the private QA channel runtime before plugin gauntlet source runs so wrapper CPU/RSS measurements are not polluted by private QA dist rebuild work. Thanks @vincentkoc.
1819
- Gateway/reload: bound default restart deferral and SIGUSR1 restart drain to five minutes while preserving explicit `deferralTimeoutMs: 0` indefinite waits, so stale active work accounting cannot block config reloads forever. Thanks @vincentkoc.

docs/tools/exec-approvals.md

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ skips approvals).
1919
Effective policy is the **stricter** of `tools.exec.*` and approvals
2020
defaults; if an approvals field is omitted, the `tools.exec` value is
2121
used. Host exec also uses local approvals state on that machine — a
22-
host-local `ask: "always"` in `~/.openclaw/exec-approvals.json` keeps
22+
host-local `ask: "always"` in the execution host approvals file keeps
2323
prompting even if session or config defaults request `ask: "on-miss"`.
2424
</Note>
2525

@@ -68,12 +68,20 @@ Exec approvals are enforced locally on the execution host:
6868

6969
## Settings and storage
7070

71-
Approvals live in a local JSON file on the execution host:
71+
Approvals live in a local JSON file on the execution host. When
72+
`OPENCLAW_STATE_DIR` is set, the file follows that state directory;
73+
otherwise it uses the default OpenClaw state directory:
7274

7375
```text
76+
$OPENCLAW_STATE_DIR/exec-approvals.json
77+
# otherwise
7478
~/.openclaw/exec-approvals.json
7579
```
7680

81+
The default approval socket follows the same root:
82+
`$OPENCLAW_STATE_DIR/exec-approvals.sock`, or
83+
`~/.openclaw/exec-approvals.sock` when the variable is unset.
84+
7785
Example schema:
7886

7987
```json
@@ -170,7 +178,7 @@ automatically.
170178
If you want host exec to run without approval prompts, you must open
171179
**both** policy layers — requested exec policy in OpenClaw config
172180
(`tools.exec.*`) **and** host-local approvals policy in
173-
`~/.openclaw/exec-approvals.json`.
181+
the execution host approvals file.
174182

175183
YOLO is the default host behavior unless you tighten it explicitly:
176184

@@ -237,7 +245,7 @@ openclaw exec-policy preset yolo
237245
That local shortcut updates both:
238246

239247
- Local `tools.exec.host/security/ask`.
240-
- Local `~/.openclaw/exec-approvals.json` defaults.
248+
- Local approvals file defaults.
241249

242250
It is intentionally local-only. To change gateway-host or node-host
243251
approvals remotely, use `openclaw approvals set --gateway` or
@@ -339,7 +347,7 @@ shows last-used metadata per pattern so you can keep the list tidy.
339347
The target selector chooses **Gateway** (local approvals) or a **Node**.
340348
Nodes must advertise `system.execApprovals.get/set` (macOS app or
341349
headless node host). If a node does not advertise exec approvals yet,
342-
edit its local `~/.openclaw/exec-approvals.json` directly.
350+
edit its local approvals file directly.
343351

344352
CLI: `openclaw approvals` supports gateway or node editing — see
345353
[Approvals CLI](/cli/approvals).

src/infra/exec-approvals-effective.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { DEFAULT_AGENT_ID } from "../routing/session-key.js";
33
import {
44
DEFAULT_EXEC_APPROVAL_ASK_FALLBACK,
55
resolveExecApprovalAllowedDecisions,
6+
resolveExecApprovalsDisplayPath,
67
type ExecApprovalDecision,
78
maxAsk,
89
minSecurity,
@@ -15,7 +16,6 @@ import {
1516

1617
const DEFAULT_REQUESTED_SECURITY: ExecSecurity = "full";
1718
const DEFAULT_REQUESTED_ASK: ExecAsk = "off";
18-
const DEFAULT_HOST_PATH = "~/.openclaw/exec-approvals.json";
1919
const REQUESTED_DEFAULT_LABEL = {
2020
security: DEFAULT_REQUESTED_SECURITY,
2121
ask: DEFAULT_REQUESTED_ASK,
@@ -234,7 +234,7 @@ export function resolveExecPolicyScopeSnapshot(params: {
234234
ask: requestedAsk.value,
235235
},
236236
});
237-
const hostPath = params.hostPath ?? DEFAULT_HOST_PATH;
237+
const hostPath = params.hostPath ?? resolveExecApprovalsDisplayPath();
238238
const effectiveSecurity = minSecurity(requestedSecurity.value, resolved.agent.security);
239239
const effectiveAsk = maxAsk(requestedAsk.value, resolved.agent.ask);
240240
const effectiveAskFallback = minSecurity(effectiveSecurity, resolved.agent.askFallback);

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

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import path from "node:path";
12
import { describe, expect, it } from "vitest";
23
import type { OpenClawConfig } from "../config/config.js";
34
import { DEFAULT_AGENT_ID } from "../routing/session-key.js";
@@ -306,6 +307,37 @@ describe("exec approvals policy helpers", () => {
306307
});
307308
});
308309

310+
it("uses OPENCLAW_STATE_DIR when reporting default host sources", () => {
311+
const originalOpenClawStateDir = process.env.OPENCLAW_STATE_DIR;
312+
const stateDir = path.join(process.cwd(), ".tmp-openclaw-state");
313+
process.env.OPENCLAW_STATE_DIR = stateDir;
314+
try {
315+
const summary = resolveExecPolicyScopeSummary({
316+
approvals: {
317+
version: 1,
318+
defaults: {
319+
security: "allowlist",
320+
},
321+
},
322+
scopeExecConfig: {
323+
security: "full",
324+
},
325+
configPath: "tools.exec",
326+
scopeLabel: "tools.exec",
327+
});
328+
329+
expect(summary.security.hostSource).toBe(
330+
`${path.join(stateDir, "exec-approvals.json")} defaults.security`,
331+
);
332+
} finally {
333+
if (originalOpenClawStateDir === undefined) {
334+
delete process.env.OPENCLAW_STATE_DIR;
335+
} else {
336+
process.env.OPENCLAW_STATE_DIR = originalOpenClawStateDir;
337+
}
338+
}
339+
});
340+
309341
it("does not let host ask=off suppress a stricter requested ask", () => {
310342
const summary = resolveExecPolicyScopeSummary({
311343
approvals: {

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

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ let saveExecApprovals: ExecApprovalsModule["saveExecApprovals"];
2929

3030
const tempDirs: string[] = [];
3131
const originalOpenClawHome = process.env.OPENCLAW_HOME;
32+
const originalOpenClawStateDir = process.env.OPENCLAW_STATE_DIR;
3233

3334
beforeAll(async () => {
3435
({
@@ -59,6 +60,11 @@ afterEach(() => {
5960
} else {
6061
process.env.OPENCLAW_HOME = originalOpenClawHome;
6162
}
63+
if (originalOpenClawStateDir === undefined) {
64+
delete process.env.OPENCLAW_STATE_DIR;
65+
} else {
66+
process.env.OPENCLAW_STATE_DIR = originalOpenClawStateDir;
67+
}
6268
for (const dir of tempDirs.splice(0)) {
6369
fs.rmSync(dir, { recursive: true, force: true });
6470
}
@@ -68,6 +74,7 @@ function createHomeDir(): string {
6874
const dir = makeTempDir();
6975
tempDirs.push(dir);
7076
process.env.OPENCLAW_HOME = dir;
77+
delete process.env.OPENCLAW_STATE_DIR;
7178
return dir;
7279
}
7380

@@ -91,6 +98,39 @@ describe("exec approvals store helpers", () => {
9198
);
9299
});
93100

101+
it("uses OPENCLAW_STATE_DIR for default file and socket paths", () => {
102+
const dir = createHomeDir();
103+
const stateDir = path.join(dir, "custom-state");
104+
process.env.OPENCLAW_STATE_DIR = stateDir;
105+
106+
expect(path.normalize(resolveExecApprovalsPath())).toBe(
107+
path.normalize(path.join(stateDir, "exec-approvals.json")),
108+
);
109+
expect(path.normalize(resolveExecApprovalsSocketPath())).toBe(
110+
path.normalize(path.join(stateDir, "exec-approvals.sock")),
111+
);
112+
113+
const ensured = ensureExecApprovals();
114+
115+
expect(ensured.socket?.path).toBe(resolveExecApprovalsSocketPath());
116+
expect(fs.existsSync(path.join(stateDir, "exec-approvals.json"))).toBe(true);
117+
expect(fs.existsSync(approvalsFilePath(dir))).toBe(false);
118+
});
119+
120+
it("keeps the default approvals path in .openclaw when only legacy state exists", () => {
121+
const dir = createHomeDir();
122+
fs.mkdirSync(path.join(dir, ".clawdbot"), { recursive: true });
123+
124+
expect(path.normalize(resolveExecApprovalsPath())).toBe(
125+
path.normalize(path.join(dir, ".openclaw", "exec-approvals.json")),
126+
);
127+
128+
ensureExecApprovals();
129+
130+
expect(fs.existsSync(approvalsFilePath(dir))).toBe(true);
131+
expect(fs.existsSync(path.join(dir, ".clawdbot", "exec-approvals.json"))).toBe(false);
132+
});
133+
94134
it("merges socket defaults from normalized, current, and built-in fallback", () => {
95135
const normalized = normalizeExecApprovals({
96136
version: 1,

src/infra/exec-approvals.ts

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
import { resolveAllowAlwaysPatternEntries } from "./exec-approvals-allowlist.js";
1212
import type { ExecCommandSegment } from "./exec-approvals-analysis.js";
1313
import type { ExecAllowlistEntry } from "./exec-approvals.types.js";
14-
import { expandHomePrefix, resolveRequiredHomeDir } from "./home-dir.js";
14+
import { expandHomePrefix, resolveHomeRelativePath, resolveRequiredHomeDir } from "./home-dir.js";
1515
import { requestJsonlSocket } from "./jsonl-socket.js";
1616
export * from "./exec-approvals-analysis.js";
1717
export * from "./exec-approvals-allowlist.js";
@@ -171,8 +171,9 @@ const DEFAULT_SECURITY: ExecSecurity = "full";
171171
const DEFAULT_ASK: ExecAsk = "off";
172172
export const DEFAULT_EXEC_APPROVAL_ASK_FALLBACK: ExecSecurity = "full";
173173
const DEFAULT_AUTO_ALLOW_SKILLS = false;
174-
const DEFAULT_SOCKET = "~/.openclaw/exec-approvals.sock";
175-
const DEFAULT_FILE = "~/.openclaw/exec-approvals.json";
174+
const DEFAULT_EXEC_APPROVALS_STATE_DIR = "~/.openclaw";
175+
const EXEC_APPROVALS_FILE = "exec-approvals.json";
176+
const EXEC_APPROVALS_SOCKET = "exec-approvals.sock";
176177

177178
function hashExecApprovalsRaw(raw: string | null): string {
178179
return crypto
@@ -181,12 +182,37 @@ function hashExecApprovalsRaw(raw: string | null): string {
181182
.digest("hex");
182183
}
183184

185+
function resolveExecApprovalsStateDir(env: NodeJS.ProcessEnv = process.env): {
186+
path: string;
187+
displayPath: string;
188+
} {
189+
const override = env.OPENCLAW_STATE_DIR?.trim();
190+
if (override) {
191+
const resolved = resolveHomeRelativePath(override, { env });
192+
return {
193+
path: resolved,
194+
displayPath: resolved,
195+
};
196+
}
197+
return {
198+
path: expandHomePrefix(DEFAULT_EXEC_APPROVALS_STATE_DIR, { env }),
199+
displayPath: DEFAULT_EXEC_APPROVALS_STATE_DIR,
200+
};
201+
}
202+
184203
export function resolveExecApprovalsPath(): string {
185-
return expandHomePrefix(DEFAULT_FILE);
204+
return path.join(resolveExecApprovalsStateDir().path, EXEC_APPROVALS_FILE);
186205
}
187206

188207
export function resolveExecApprovalsSocketPath(): string {
189-
return expandHomePrefix(DEFAULT_SOCKET);
208+
return path.join(resolveExecApprovalsStateDir().path, EXEC_APPROVALS_SOCKET);
209+
}
210+
211+
export function resolveExecApprovalsDisplayPath(): string {
212+
const stateDir = resolveExecApprovalsStateDir().displayPath;
213+
return stateDir === DEFAULT_EXEC_APPROVALS_STATE_DIR
214+
? `${stateDir}/${EXEC_APPROVALS_FILE}`
215+
: path.join(stateDir, EXEC_APPROVALS_FILE);
190216
}
191217

192218
function normalizeAllowlistPattern(value: string | undefined): string | null {

0 commit comments

Comments
 (0)