fix(exec): honor state dir approvals (#92056)

This commit is contained in:
Vincent Koc
2026-06-11 09:30:56 +09:00
committed by GitHub
parent d559dfecfa
commit adad27d744
21 changed files with 1008 additions and 74 deletions
@@ -1,4 +1,5 @@
import CryptoKit
import Darwin
import Foundation
import OSLog
import Security
@@ -229,6 +230,12 @@ enum ExecApprovalsStore {
private static let secureStateDirPermissions = 0o700
private static let fileLock = NSRecursiveLock()
private enum LegacyMigrationResult {
case notNeeded
case migrated
case blocked
}
private static func withFileLock<T>(_ body: () throws -> T) rethrows -> T {
self.fileLock.lock()
defer { self.fileLock.unlock() }
@@ -243,6 +250,195 @@ enum ExecApprovalsStore {
OpenClawPaths.stateDirURL.appendingPathComponent("exec-approvals.sock").path
}
private static func legacyStateDirURLs() -> [URL] {
if let home = OpenClawEnv.path("OPENCLAW_HOME") {
var urls = [
URL(fileURLWithPath: home, isDirectory: true)
.appendingPathComponent(".openclaw", isDirectory: true),
]
let osHomeURL = FileManager().homeDirectoryForCurrentUser
.appendingPathComponent(".openclaw", isDirectory: true)
if !urls.contains(where: {
$0.standardizedFileURL.path == osHomeURL.standardizedFileURL.path
}) {
urls.append(osHomeURL)
}
return urls
}
return [
FileManager().homeDirectoryForCurrentUser
.appendingPathComponent(".openclaw", isDirectory: true),
]
}
private static func legacyFileURLIfPending() -> URL? {
guard OpenClawEnv.path("OPENCLAW_STATE_DIR") != nil else { return nil }
let targetURL = self.fileURL()
for stateDirURL in self.legacyStateDirURLs() {
let legacyURL = stateDirURL
.appendingPathComponent("exec-approvals.json", isDirectory: false)
guard legacyURL.standardizedFileURL.path != targetURL.standardizedFileURL.path else {
continue
}
guard FileManager().fileExists(atPath: legacyURL.path) else { continue }
guard !FileManager().fileExists(atPath: targetURL.path) else { return nil }
return legacyURL
}
return nil
}
private static func unmigratedLegacyFallbackFile() -> ExecApprovalsFile {
ExecApprovalsFile(
version: 1,
socket: nil,
defaults: ExecApprovalsDefaults(
security: .deny,
ask: .always,
askFallback: .deny,
autoAllowSkills: nil),
agents: [:])
}
private static func isLegacyDefaultSocketPath(_ raw: String, legacyFileURL: URL) -> Bool {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { return true }
let expanded = self.expandPath(trimmed)
let legacySocket = legacyFileURL.deletingLastPathComponent()
.appendingPathComponent("exec-approvals.sock", isDirectory: false)
.path
return URL(fileURLWithPath: expanded).standardizedFileURL.path
== URL(fileURLWithPath: legacySocket).standardizedFileURL.path
}
private static func hasSymlinkParent(_ url: URL) -> Bool {
var cursor = url.deletingLastPathComponent()
let manager = FileManager()
while true {
var isDirectory = ObjCBool(false)
if manager.fileExists(atPath: cursor.path, isDirectory: &isDirectory) {
if (try? manager.destinationOfSymbolicLink(atPath: cursor.path)) != nil {
return true
}
}
let parent = cursor.deletingLastPathComponent()
if parent.path == cursor.path { return false }
cursor = parent
}
}
private static func archiveMigratedLegacyFile(_ legacyURL: URL) throws -> URL {
let manager = FileManager()
var archiveURL = URL(fileURLWithPath: "\(legacyURL.path).migrated")
if manager.fileExists(atPath: archiveURL.path) {
archiveURL = URL(fileURLWithPath: "\(archiveURL.path)-\(UUID().uuidString)")
}
try manager.moveItem(at: legacyURL, to: archiveURL)
return archiveURL
}
private static func writeMigratedFileExclusively(_ data: Data, to targetURL: URL) throws -> Bool {
let tempURL = targetURL.deletingLastPathComponent()
.appendingPathComponent(".exec-approvals.migration.\(UUID().uuidString)")
let fd = open(tempURL.path, O_WRONLY | O_CREAT | O_EXCL, S_IRUSR | S_IWUSR)
if fd == -1 {
throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)
}
var closed = false
defer {
if !closed { close(fd) }
}
do {
try data.withUnsafeBytes { rawBuffer in
guard let base = rawBuffer.baseAddress else { return }
var offset = 0
while offset < rawBuffer.count {
let written = Darwin.write(
fd,
base.advanced(by: offset),
rawBuffer.count - offset)
if written < 0 {
throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)
}
offset += written
}
}
close(fd)
closed = true
let copied = copyfile(
tempURL.path,
targetURL.path,
nil,
copyfile_flags_t(COPYFILE_EXCL))
if copied == -1 {
if errno == EEXIST {
try? FileManager().removeItem(at: tempURL)
return false
}
try? FileManager().removeItem(at: targetURL)
throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)
}
try? FileManager().removeItem(at: tempURL)
return true
} catch {
try? FileManager().removeItem(at: tempURL)
throw error
}
}
private static func migrateLegacyFileIfNeeded() -> LegacyMigrationResult {
guard let legacyURL = self.legacyFileURLIfPending() else { return .notNeeded }
let targetURL = self.fileURL()
do {
if self.hasSymlinkParent(targetURL) {
throw NSError(domain: "ExecApprovals", code: 10, userInfo: [
NSLocalizedDescriptionKey: "target path has a symlink parent",
])
}
let data = try Data(contentsOf: legacyURL)
var file = try JSONDecoder().decode(ExecApprovalsFile.self, from: data)
guard file.version == 1 else {
throw NSError(domain: "ExecApprovals", code: 11, userInfo: [
NSLocalizedDescriptionKey: "unsupported legacy approvals version",
])
}
file = self.normalizeIncoming(file)
let rawSocketPath = file.socket?.path?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if self.isLegacyDefaultSocketPath(rawSocketPath, legacyFileURL: legacyURL) {
if file.socket == nil {
file.socket = ExecApprovalsSocketConfig(path: nil, token: nil)
}
file.socket?.path = self.socketPath()
}
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
let migrated = try encoder.encode(file)
self.ensureSecureStateDirectory()
try FileManager().createDirectory(
at: targetURL.deletingLastPathComponent(),
withIntermediateDirectories: true)
if FileManager().fileExists(atPath: targetURL.path) { return .notNeeded }
let created = try self.writeMigratedFileExclusively(migrated, to: targetURL)
if !created { return .notNeeded }
try? FileManager().setAttributes(
[.posixPermissions: 0o600],
ofItemAtPath: targetURL.path)
do {
_ = try self.archiveMigratedLegacyFile(legacyURL)
} catch {
self.logger
.warning(
"exec approvals legacy archive failed: \(error.localizedDescription, privacy: .public)")
}
return .migrated
} catch {
self.logger
.error(
"exec approvals legacy migration failed: \(error.localizedDescription, privacy: .public)")
return .blocked
}
}
static func normalizeIncoming(_ file: ExecApprovalsFile) -> ExecApprovalsFile {
let socketPath = file.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let token = file.socket?.token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
@@ -278,6 +474,14 @@ enum ExecApprovalsStore {
static func readSnapshot() -> ExecApprovalsSnapshot {
self.withFileLock {
if self.legacyFileURLIfPending() != nil {
let file = self.unmigratedLegacyFallbackFile()
return ExecApprovalsSnapshot(
path: self.fileURL().path,
exists: false,
hash: self.hashRaw(nil),
file: file)
}
let url = self.fileURL()
guard FileManager().fileExists(atPath: url.path) else {
return ExecApprovalsSnapshot(
@@ -322,6 +526,14 @@ enum ExecApprovalsStore {
static func loadFile() -> ExecApprovalsFile {
self.withFileLock {
if self.legacyFileURLIfPending() != nil {
switch self.migrateLegacyFileIfNeeded() {
case .migrated, .notNeeded:
break
case .blocked:
return self.unmigratedLegacyFallbackFile()
}
}
let url = self.fileURL()
guard FileManager().fileExists(atPath: url.path) else {
return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:])
@@ -361,6 +573,14 @@ enum ExecApprovalsStore {
static func ensureFile() -> ExecApprovalsFile {
self.withFileLock {
if self.legacyFileURLIfPending() != nil {
switch self.migrateLegacyFileIfNeeded() {
case .migrated, .notNeeded:
break
case .blocked:
return self.unmigratedLegacyFallbackFile()
}
}
self.ensureSecureStateDirectory()
let url = self.fileURL()
let existed = FileManager().fileExists(atPath: url.path)
@@ -16,6 +16,23 @@ struct ExecApprovalsStoreRefactorTests {
}
}
private func withTempHomeAndStateDir(
_ body: @escaping @Sendable (URL, URL) async throws -> Void) async throws
{
let root = FileManager().temporaryDirectory
.appendingPathComponent("openclaw-home-state-\(UUID().uuidString)", isDirectory: true)
let home = root.appendingPathComponent("home", isDirectory: true)
let stateDir = root.appendingPathComponent("state", isDirectory: true)
defer { try? FileManager().removeItem(at: root) }
try await TestIsolation.withEnvValues([
"OPENCLAW_HOME": home.path,
"OPENCLAW_STATE_DIR": stateDir.path,
]) {
try await body(home, stateDir)
}
}
@Test
func `ensure file skips rewrite when unchanged`() async throws {
try await self.withTempStateDir { _ in
@@ -30,6 +47,50 @@ struct ExecApprovalsStoreRefactorTests {
}
}
@Test
func `ensure file migrates default approvals into custom state dir`() async throws {
try await self.withTempHomeAndStateDir { home, stateDir in
let legacyDir = home.appendingPathComponent(".openclaw", isDirectory: true)
try FileManager().createDirectory(
at: legacyDir,
withIntermediateDirectories: true)
let legacySocket = legacyDir.appendingPathComponent("exec-approvals.sock").path
let legacyFile = legacyDir.appendingPathComponent("exec-approvals.json")
let legacyJson = """
{
"version": 1,
"socket": {
"path": "\(legacySocket)",
"token": "legacy-token"
},
"defaults": {
"security": "deny",
"ask": "always"
},
"agents": {
"main": {
"allowlist": [{ "pattern": "git status" }]
}
}
}
"""
try Data(legacyJson.utf8).write(to: legacyFile)
let file = ExecApprovalsStore.ensureFile()
let targetURL = ExecApprovalsStore.fileURL()
#expect(targetURL.path == stateDir.appendingPathComponent("exec-approvals.json").path)
#expect(FileManager().fileExists(atPath: targetURL.path))
#expect(file.socket?.path == stateDir.appendingPathComponent("exec-approvals.sock").path)
#expect(file.socket?.token == "legacy-token")
#expect(file.defaults?.security == .deny)
#expect(file.defaults?.ask == .always)
#expect(file.agents?["main"]?.allowlist?.map(\.pattern) == ["git status"])
#expect(!FileManager().fileExists(atPath: legacyFile.path))
#expect(FileManager().fileExists(atPath: "\(legacyFile.path).migrated"))
}
}
@Test
func `update allowlist accepts basename pattern`() async throws {
try await self.withTempStateDir { _ in
+4 -2
View File
@@ -27,7 +27,7 @@ Use it when you want to:
- inspect the local requested policy, host approvals file, and effective merge
- apply a local preset such as YOLO or deny-all
- synchronize local `tools.exec.*` and local `~/.openclaw/exec-approvals.json`
- synchronize local `tools.exec.*` and the local host approvals file
Examples:
@@ -183,7 +183,9 @@ Targeting notes:
- `--node` uses the same resolver as `openclaw nodes` (id, name, ip, or id prefix).
- `--agent` defaults to `"*"`, which applies to all agents.
- The node host must advertise `system.execApprovals.get/set` (macOS app or headless node host).
- Approvals files are stored per host at `~/.openclaw/exec-approvals.json`.
- Approvals files are stored per host in the OpenClaw state dir
(`$OPENCLAW_STATE_DIR/exec-approvals.json`, or
`~/.openclaw/exec-approvals.json` when the variable is unset).
## Related
+2 -1
View File
@@ -162,7 +162,8 @@ The node host stores its node id, token, display name, and gateway connection in
`system.run` is gated by local exec approvals:
- `~/.openclaw/exec-approvals.json`
- `$OPENCLAW_STATE_DIR/exec-approvals.json`, or
`~/.openclaw/exec-approvals.json` when the variable is unset
- [Exec approvals](/tools/exec-approvals)
- `openclaw approvals --node <id|name|ip>` (edit from the Gateway)
+1 -1
View File
@@ -93,7 +93,7 @@ exhaustive):
| `tools.exec.host_sandbox_no_sandbox_agents` | warn | Per-agent `exec host=sandbox` fails closed when sandbox is off | `agents.list[].tools.exec.host`, `agents.list[].sandbox.mode` | no |
| `tools.exec.security_full_configured` | warn/critical | Host exec is running with `security="full"` | `tools.exec.security`, `agents.list[].tools.exec.security` | no |
| `tools.exec.fs_tools_disabled_but_exec_enabled` | warn | Filesystem tool policy does not make shell execution read-only | `tools.deny`, `agents.list[].tools.deny`, `agents.*.sandbox.workspaceAccess` | no |
| `tools.exec.auto_allow_skills_enabled` | warn | Exec approvals trust skill bins implicitly | `~/.openclaw/exec-approvals.json` | no |
| `tools.exec.auto_allow_skills_enabled` | warn | Exec approvals trust skill bins implicitly | host approvals file | no |
| `tools.exec.allowlist_interpreter_without_strict_inline_eval` | warn | Interpreter allowlists permit inline eval without forced reapproval | `tools.exec.strictInlineEval`, `agents.list[].tools.exec.strictInlineEval`, exec approvals allowlist | no |
| `tools.exec.safe_bins_interpreter_unprofiled` | warn | Interpreter/runtime bins in `safeBins` without explicit profiles broaden exec risk | `tools.exec.safeBins`, `tools.exec.safeBinProfiles`, `agents.list[].tools.exec.*` | no |
| `tools.exec.safe_bins_broad_behavior` | warn | Broad-behavior tools in `safeBins` weaken the low-risk stdin-filter trust model | `tools.exec.safeBins`, `agents.list[].tools.exec.safeBins` | no |
+1 -1
View File
@@ -115,7 +115,7 @@ Configuration location:
- `safeBins` comes from config (`tools.exec.safeBins` or per-agent `agents.list[].tools.exec.safeBins`).
- `safeBinTrustedDirs` comes from config (`tools.exec.safeBinTrustedDirs` or per-agent `agents.list[].tools.exec.safeBinTrustedDirs`).
- `safeBinProfiles` comes from config (`tools.exec.safeBinProfiles` or per-agent `agents.list[].tools.exec.safeBinProfiles`). Per-agent profile keys override global keys.
- allowlist entries live in host-local `~/.openclaw/exec-approvals.json` under `agents.<id>.allowlist` (or via Control UI / `openclaw approvals allowlist ...`).
- allowlist entries live in the host-local approvals file under `agents.<id>.allowlist` (or via Control UI / `openclaw approvals allowlist ...`).
- `openclaw security audit` warns with `tools.exec.safe_bins_interpreter_unprofiled` when interpreter/runtime bins appear in `safeBins` without explicit profiles.
- `openclaw doctor --fix` can scaffold missing custom `safeBinProfiles.<bin>` entries as `{}` (review and tighten afterward). Interpreter/runtime bins are not auto-scaffolded.
+13 -6
View File
@@ -23,7 +23,7 @@ Codex Guardian mapping, and ACPX harness permissions, see
Effective policy is the **stricter** of `tools.exec.*` and approvals
defaults; if an approvals field is omitted, the `tools.exec` value is
used. Host exec also uses local approvals state on that machine - a
host-local `ask: "always"` in `~/.openclaw/exec-approvals.json` keeps
host-local `ask: "always"` in the execution host approvals file keeps
prompting even if session or config defaults request `ask: "on-miss"`.
</Note>
@@ -73,12 +73,20 @@ Exec approvals are enforced locally on the execution host:
## Settings and storage
Approvals live in a local JSON file on the execution host:
Approvals live in a local JSON file on the execution host. When
`OPENCLAW_STATE_DIR` is set, the file follows that state directory;
otherwise it uses the default OpenClaw state directory:
```text
$OPENCLAW_STATE_DIR/exec-approvals.json
# otherwise
~/.openclaw/exec-approvals.json
```
The default approval socket follows the same root:
`$OPENCLAW_STATE_DIR/exec-approvals.sock`, or
`~/.openclaw/exec-approvals.sock` when the variable is unset.
Example schema:
```json
@@ -210,7 +218,7 @@ agent under `agents.list[].tools.exec.commandHighlighting`.
If you want host exec to run without approval prompts, you must open
**both** policy layers - requested exec policy in OpenClaw config
(`tools.exec.*`) **and** host-local approvals policy in
`~/.openclaw/exec-approvals.json`.
the execution host approvals file.
OpenClaw defaults omitted `askFallback` to `deny`. Set host
`askFallback` to `full` explicitly when a no-UI approval prompt should
@@ -281,8 +289,7 @@ openclaw exec-policy preset yolo
That local shortcut updates both:
- Local `tools.exec.host/security/ask`.
- Local `~/.openclaw/exec-approvals.json` defaults, including
`askFallback: "full"`.
- Local approvals file defaults, including `askFallback: "full"`.
It is intentionally local-only. To change gateway-host or node-host
approvals remotely, use `openclaw approvals set --gateway` or
@@ -425,7 +432,7 @@ shows last-used metadata per pattern so you can keep the list tidy.
The target selector chooses **Gateway** (local approvals) or a **Node**.
Nodes must advertise `system.execApprovals.get/set` (macOS app or
headless node host). If a node does not advertise exec approvals yet,
edit its local `~/.openclaw/exec-approvals.json` directly.
edit its local approvals file directly.
CLI: `openclaw approvals` supports gateway or node editing - see
[Approvals CLI](/cli/approvals).
+3 -3
View File
@@ -47,7 +47,7 @@ Where to execute. `auto` resolves to `sandbox` when a sandbox runtime is active
<ParamField path="security" type="'deny' | 'allowlist' | 'full'">
Ignored for normal tool calls. `gateway` / `node` security is controlled by
`tools.exec.security` and `~/.openclaw/exec-approvals.json`; elevated mode can
`tools.exec.security` and the host approvals file; elevated mode can
force `security=full` only when the operator explicitly grants elevated access.
</ParamField>
@@ -75,7 +75,7 @@ Notes:
- `tools.exec.mode` is the normalized policy knob. Values are `deny`, `allowlist`, `ask`, `auto`, and `full`. `auto` runs deterministic allowlist/safe-bin matches directly and routes every remaining exec approval case through OpenClaw's native auto reviewer before asking a human. `ask` / `ask=always` still asks a human every time.
- With no extra config, `host=auto` still "just works": no sandbox means it resolves to `gateway`; a live sandbox means it stays in the sandbox.
- `elevated` escapes the sandbox onto the configured host path: `gateway` by default, or `node` when `tools.exec.host=node` (or the session default is `host=node`). It is only available when elevated access is enabled for the current session/provider.
- `gateway`/`node` approvals are controlled by `~/.openclaw/exec-approvals.json`.
- `gateway`/`node` approvals are controlled by the host approvals file.
- `node` requires a paired node (companion app or headless node host).
- If multiple nodes are available, set `exec.node` or `tools.exec.node` to select one.
- `exec host=node` is the only shell-execution path for nodes; the legacy `nodes.run` wrapper has been removed.
@@ -114,7 +114,7 @@ Notes:
- `tools.exec.host` (default: `auto`; resolves to `sandbox` when sandbox runtime is active, `gateway` otherwise)
- `tools.exec.security` (default: `deny` for sandbox, `full` for gateway + node when unset)
- `tools.exec.ask` (default: `off`)
- No-approval host exec is the default for gateway + node. If you want approvals/allowlist behavior, tighten both `tools.exec.*` and the host `~/.openclaw/exec-approvals.json`; see [Exec approvals](/tools/exec-approvals#yolo-mode-no-approval).
- No-approval host exec is the default for gateway + node. If you want approvals/allowlist behavior, tighten both `tools.exec.*` and the host approvals file; see [Exec approvals](/tools/exec-approvals#yolo-mode-no-approval).
- YOLO comes from the host-policy defaults (`security=full`, `ask=off`), not from `host=auto`. If you want to force gateway or node routing, set `tools.exec.host` or use `/exec host=...`.
- In `security=full` plus `ask=off` mode, host exec follows the configured policy directly; there is no extra heuristic command-obfuscation prefilter or script-preflight rejection layer.
- `tools.exec.node` (default: unset)
+2 -1
View File
@@ -16,6 +16,7 @@ import {
maxAsk,
resolveExecApprovalAllowedDecisions,
resolveExecApprovals,
resolveExecApprovalsTranscriptPath,
type ExecAsk,
type ExecApprovalDecision,
type ExecSecurity,
@@ -437,7 +438,7 @@ export function buildHeadlessExecApprovalDeniedMessage(params: {
return [
`exec denied: ${runLabel} cannot wait for interactive exec approval.`,
`Effective host exec policy: security=${params.security} ask=${params.ask} askFallback=${params.askFallback}`,
"Stricter values from tools.exec and ~/.openclaw/exec-approvals.json both apply.",
`Stricter values from tools.exec and ${resolveExecApprovalsTranscriptPath()} both apply.`,
"Fix one of these:",
'- align both files to security="full" and ask="off" for trusted local automation',
"- keep allowlist mode and add an explicit allowlist entry for this command",
@@ -20,22 +20,39 @@ vi.mock("./tools/gateway.js", () => ({
function installAllowlistedGogFixture(root: string): string {
const binDir = path.join(root, "bin");
const openclawDir = path.join(root, ".openclaw");
fs.mkdirSync(binDir, { recursive: true });
fs.mkdirSync(openclawDir, { recursive: true });
const gogPath = path.join(binDir, "gog");
fs.writeFileSync(gogPath, "#!/bin/sh\nprintf 'gog-ok %s\\n' \"$*\"\n", { mode: 0o755 });
fs.writeFileSync(
path.join(openclawDir, "exec-approvals.json"),
`${JSON.stringify({
version: 1,
defaults: { security: "allowlist", ask: "off", askFallback: "allowlist" },
agents: { "*": { allowlist: [{ pattern: gogPath }] } },
})}\n`,
);
writeExecApprovalsFixture(root, {
version: 1,
defaults: { security: "allowlist", ask: "off", askFallback: "allowlist" },
agents: { "*": { allowlist: [{ pattern: gogPath }] } },
});
return binDir;
}
function writeExecApprovalsFixture(root: string, file: Record<string, unknown>): void {
const stateDir = process.env.OPENCLAW_STATE_DIR ?? path.join(root, "state");
fs.mkdirSync(stateDir, { recursive: true });
fs.writeFileSync(path.join(stateDir, "exec-approvals.json"), `${JSON.stringify(file)}\n`);
}
function writeDenyExecApprovalsFixture(root: string): void {
writeExecApprovalsFixture(root, {
version: 1,
defaults: { security: "deny", ask: "off" },
agents: {},
});
}
function writeFullAskExecApprovalsFixture(root: string): void {
writeExecApprovalsFixture(root, {
version: 1,
defaults: { security: "full", ask: "always" },
agents: {},
});
}
describe("exec security floor", () => {
let envSnapshot: ReturnType<typeof captureEnv>;
let tempRoot: string | undefined;
@@ -189,12 +206,7 @@ describe("exec security floor", () => {
});
it("does not let host approval defaults deny implicit sandbox execution", async () => {
const openclawDir = path.join(tempRoot ?? os.tmpdir(), ".openclaw");
fs.mkdirSync(openclawDir, { recursive: true });
fs.writeFileSync(
path.join(openclawDir, "exec-approvals.json"),
`${JSON.stringify({ version: 1, defaults: { security: "deny", ask: "off" }, agents: {} })}\n`,
);
writeDenyExecApprovalsFixture(tempRoot ?? os.tmpdir());
const buildExecSpec = vi.fn(async () => ({
argv: ["/bin/sh", "-lc", "printf sandbox-ok"],
env: process.env,
@@ -273,12 +285,7 @@ describe("exec security floor", () => {
});
it("intersects normalized gateway auto mode with host approval deny defaults", async () => {
const openclawDir = path.join(tempRoot ?? os.tmpdir(), ".openclaw");
fs.mkdirSync(openclawDir, { recursive: true });
fs.writeFileSync(
path.join(openclawDir, "exec-approvals.json"),
`${JSON.stringify({ version: 1, defaults: { security: "deny", ask: "off" }, agents: {} })}\n`,
);
writeDenyExecApprovalsFixture(tempRoot ?? os.tmpdir());
const autoReviewer = vi.fn<ExecAutoReviewer>(async () => ({
decision: "allow-once",
risk: "low",
@@ -300,16 +307,11 @@ describe("exec security floor", () => {
});
it("uses agent-scoped host policy when clamping normalized modes", async () => {
const openclawDir = path.join(tempRoot ?? os.tmpdir(), ".openclaw");
fs.mkdirSync(openclawDir, { recursive: true });
fs.writeFileSync(
path.join(openclawDir, "exec-approvals.json"),
`${JSON.stringify({
version: 1,
defaults: { security: "deny", ask: "off" },
agents: { main: { security: "full", ask: "off" } },
})}\n`,
);
writeExecApprovalsFixture(tempRoot ?? os.tmpdir(), {
version: 1,
defaults: { security: "deny", ask: "off" },
agents: { main: { security: "full", ask: "off" } },
});
const tool = createExecTool({
host: "gateway",
mode: "full",
@@ -326,12 +328,7 @@ describe("exec security floor", () => {
});
it("preserves host ask floors for elevated full gateway exec", async () => {
const openclawDir = path.join(tempRoot ?? os.tmpdir(), ".openclaw");
fs.mkdirSync(openclawDir, { recursive: true });
fs.writeFileSync(
path.join(openclawDir, "exec-approvals.json"),
`${JSON.stringify({ version: 1, defaults: { security: "full", ask: "always" }, agents: {} })}\n`,
);
writeFullAskExecApprovalsFixture(tempRoot ?? os.tmpdir());
const calls: string[] = [];
vi.mocked(callGatewayTool).mockImplementation(async (method) => {
calls.push(method);
+16
View File
@@ -227,6 +227,22 @@ describe("ensureConfigReady", () => {
});
});
it("runs doctor flow before agent commands when default exec approvals must move to a custom state dir", async () => {
const root = useTempOpenClawHome();
const stateDir = path.join(root, "custom-state");
process.env.OPENCLAW_STATE_DIR = stateDir;
writeStateMarker(root, "exec-approvals.json");
await runEnsureConfigReady(["agent"]);
expect(loadAndMaybeMigrateDoctorConfigMock).toHaveBeenCalledOnce();
expect(loadAndMaybeMigrateDoctorConfigMock).toHaveBeenCalledWith({
migrateState: true,
migrateLegacyConfig: false,
invalidConfigNote: false,
});
});
it.each([
["Discord model picker preferences", "discord/model-picker-preferences.json"],
["Discord thread bindings", "discord/thread-bindings.json"],
+17 -1
View File
@@ -97,6 +97,20 @@ function hasBundledChannelLegacyStateMigrationInputs(stateDir: string, oauthDir:
return dirHasFile(oauthDir, isLegacyWhatsAppAuthFile);
}
function hasLegacyExecApprovalsMigrationInput(stateDir: string): boolean {
if (!process.env.OPENCLAW_STATE_DIR?.trim()) {
return false;
}
const homeDir = resolveRequiredHomeDir(process.env, os.homedir);
const sourcePath = path.join(homeDir, ".openclaw", "exec-approvals.json");
const targetPath = path.join(stateDir, "exec-approvals.json");
return (
path.resolve(sourcePath) !== path.resolve(targetPath) &&
fileOrDirExists(sourcePath) &&
!fileOrDirExists(targetPath)
);
}
function hasLegacyStateMigrationInputs(): boolean {
// Only run migration prompts when old state actually exists in known legacy locations.
const stateDir = resolveStateDir(process.env, os.homedir);
@@ -118,7 +132,9 @@ function hasLegacyStateMigrationInputs(): boolean {
path.join(stateDir, "plugins", "installs.json"),
path.join(stateDir, "sessions"),
path.join(stateDir, "tasks", "runs.sqlite"),
].some(fileOrDirExists) || hasBundledChannelLegacyStateMigrationInputs(stateDir, oauthDir)
].some(fileOrDirExists) ||
hasBundledChannelLegacyStateMigrationInputs(stateDir, oauthDir) ||
hasLegacyExecApprovalsMigrationInput(stateDir)
);
}
@@ -83,7 +83,7 @@ describe("runDoctorConfigPreflight state migration", () => {
});
});
it("limits invalid-config preflight to task sidecar migration", async () => {
it("limits invalid-config preflight to config-independent state migration", async () => {
vi.clearAllMocks();
readConfigFileSnapshot.mockResolvedValueOnce({
exists: true,
+2 -1
View File
@@ -14,6 +14,7 @@ import { isLoopbackHost, resolveGatewayBindHost } from "../gateway/net.js";
import { resolveExecPolicyScopeSnapshot } from "../infra/exec-approvals-effective.js";
import {
loadExecApprovals,
resolveExecApprovalsDisplayPath,
type ExecAsk,
type ExecMode,
type ExecSecurity,
@@ -246,7 +247,7 @@ export async function collectSecurityWarnings(
if (cfg.approvals?.exec?.enabled === false) {
warnings.push(
"- Note: approvals.exec.enabled=false disables approval forwarding only.",
" Host exec gating still comes from ~/.openclaw/exec-approvals.json.",
` Host exec gating still comes from ${resolveExecApprovalsDisplayPath()}.`,
` Check local policy with: ${formatCliCommand("openclaw approvals get --gateway")}`,
);
}
@@ -28,6 +28,7 @@ import {
detectLegacyStateMigrations,
resetAutoMigrateLegacyStateDirForTest,
resetAutoMigrateLegacyStateForTest,
resetAutoMigrateLegacyTaskStateSidecarsForTest,
runLegacyStateMigrations,
} from "./doctor-state-migrations.js";
@@ -245,6 +246,7 @@ async function runTelegramAllowFromMigration(params: { root: string; cfg: OpenCl
afterEach(async () => {
resetAutoMigrateLegacyStateForTest();
resetAutoMigrateLegacyStateDirForTest();
resetAutoMigrateLegacyTaskStateSidecarsForTest();
closeOpenClawStateDatabaseForTest();
setMaxPluginStateEntriesPerPluginForTests();
resetPluginStateStoreForTests();
@@ -1857,6 +1859,141 @@ describe("doctor legacy state migrations", () => {
});
});
it("migrates default exec approvals into an explicit state dir", async () => {
const root = await makeTempRoot();
const stateDir = path.join(root, "custom-state");
const sourcePath = path.join(root, ".openclaw", "exec-approvals.json");
const legacySocketPath = path.join(root, ".openclaw", "exec-approvals.sock");
const targetPath = path.join(stateDir, "exec-approvals.json");
const targetSocketPath = path.join(stateDir, "exec-approvals.sock");
writeJson5(sourcePath, {
version: 1,
socket: {
path: legacySocketPath,
token: "legacy-token",
},
defaults: {
security: "deny",
ask: "always",
},
agents: {
main: {
allowlist: [{ pattern: "git status" }],
},
},
});
const detected = await detectLegacyStateMigrations({
cfg: {},
env: { OPENCLAW_STATE_DIR: stateDir } as NodeJS.ProcessEnv,
homedir: () => root,
});
expect(detected.preview).toContain(`- Exec approvals: ${sourcePath}${targetPath}`);
const result = await runLegacyStateMigrations({ detected });
expect(result.warnings).toStrictEqual([]);
expect(result.changes).toContain(`Migrated exec approvals → ${targetPath}`);
expect(result.changes).toContain(`Archived legacy exec approvals → ${sourcePath}.migrated`);
expect(fs.existsSync(sourcePath)).toBe(false);
expect(fs.existsSync(`${sourcePath}.migrated`)).toBe(true);
const migrated = JSON.parse(fs.readFileSync(targetPath, "utf8")) as {
socket?: { path?: string; token?: string };
defaults?: Record<string, unknown>;
agents?: Record<string, { allowlist?: Array<Record<string, unknown>> }>;
};
expect(migrated.socket?.path).toBe(targetSocketPath);
expect(migrated.socket?.token).toBe("legacy-token");
expect(migrated.defaults).toEqual({
security: "deny",
ask: "always",
});
expect(migrated.agents?.main?.allowlist?.[0]?.pattern).toBe("git status");
});
it("skips exec approvals migration when the target appears after detection", async () => {
const root = await makeTempRoot();
const stateDir = path.join(root, "custom-state");
const sourcePath = path.join(root, ".openclaw", "exec-approvals.json");
const targetPath = path.join(stateDir, "exec-approvals.json");
writeJson5(sourcePath, {
version: 1,
socket: {
token: "legacy-token",
},
defaults: {
security: "deny",
},
});
const detected = await detectLegacyStateMigrations({
cfg: {},
env: { OPENCLAW_STATE_DIR: stateDir } as NodeJS.ProcessEnv,
homedir: () => root,
});
writeJson5(targetPath, {
version: 1,
socket: {
path: path.join(stateDir, "exec-approvals.sock"),
token: "current-token",
},
defaults: {
security: "full",
ask: "off",
},
});
const result = await runLegacyStateMigrations({ detected });
expect(result.warnings).toStrictEqual([]);
expect(result.changes).not.toContain(`Migrated exec approvals → ${targetPath}`);
const current = JSON.parse(fs.readFileSync(targetPath, "utf8")) as {
socket?: { token?: string };
defaults?: Record<string, unknown>;
};
expect(current.socket?.token).toBe("current-token");
expect(current.defaults).toEqual({
security: "full",
ask: "off",
});
});
it("auto-migrates exec approvals without a valid config snapshot", async () => {
const root = await makeTempRoot();
const stateDir = path.join(root, "custom-state");
const sourcePath = path.join(root, ".openclaw", "exec-approvals.json");
const targetPath = path.join(stateDir, "exec-approvals.json");
writeJson5(sourcePath, {
version: 1,
socket: {
token: "legacy-token",
},
defaults: {
security: "deny",
ask: "always",
},
});
const result = await autoMigrateLegacyTaskStateSidecars({
env: { OPENCLAW_STATE_DIR: stateDir } as NodeJS.ProcessEnv,
homedir: () => root,
});
expect(result.warnings).toStrictEqual([]);
expect(result.changes).toContain(`Migrated exec approvals → ${targetPath}`);
expect(result.changes).toContain(`Archived legacy exec approvals → ${sourcePath}.migrated`);
expect(fs.existsSync(sourcePath)).toBe(false);
expect(fs.existsSync(`${sourcePath}.migrated`)).toBe(true);
const migrated = JSON.parse(fs.readFileSync(targetPath, "utf8")) as {
socket?: { token?: string };
defaults?: Record<string, unknown>;
};
expect(migrated.socket?.token).toBe("legacy-token");
expect(migrated.defaults).toEqual({
security: "deny",
ask: "always",
});
});
it("keeps the plugin-state sidecar when shared state already has a conflicting row", async () => {
const root = await makeTempRoot();
const sourcePath = writeLegacyPluginStateSidecar(root);
+5
View File
@@ -241,6 +241,11 @@ function createLegacyStateMigrationDetectionResult(params?: {
sessionPath: "/tmp/state/session-delivery-queue",
hasLegacy: false,
},
execApprovals: {
sourcePath: "/tmp/state/exec-approvals.legacy.json",
targetPath: "/tmp/state/exec-approvals.json",
hasLegacy: false,
},
channelPlans: {
hasLegacy: false,
plans: [],
+2 -2
View File
@@ -5,6 +5,7 @@ import { DEFAULT_AGENT_ID } from "../routing/session-key.js";
import {
DEFAULT_EXEC_APPROVAL_ASK_FALLBACK,
resolveExecApprovalAllowedDecisions,
resolveExecApprovalsDisplayPath,
type ExecApprovalDecision,
maxAsk,
minSecurity,
@@ -20,7 +21,6 @@ import {
const DEFAULT_REQUESTED_SECURITY: ExecSecurity = "full";
const DEFAULT_REQUESTED_ASK: ExecAsk = "off";
const DEFAULT_HOST_PATH = "~/.openclaw/exec-approvals.json";
const REQUESTED_DEFAULT_LABEL = {
security: DEFAULT_REQUESTED_SECURITY,
ask: DEFAULT_REQUESTED_ASK,
@@ -367,7 +367,7 @@ export function resolveExecPolicyScopeSnapshot(params: {
ask: requestedPolicy.ask,
},
});
const hostPath = params.hostPath ?? DEFAULT_HOST_PATH;
const hostPath = params.hostPath ?? resolveExecApprovalsDisplayPath();
const effectiveSecurity = minSecurity(requestedPolicy.security, resolved.agent.security);
const effectiveAsk = maxAsk(requestedPolicy.ask, resolved.agent.ask);
const effectiveAskFallback = minSecurity(effectiveSecurity, resolved.agent.askFallback);
+32
View File
@@ -1,4 +1,5 @@
// Tests execution approval policy matching and persistence.
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { DEFAULT_AGENT_ID } from "../routing/session-key.js";
@@ -550,6 +551,37 @@ describe("exec approvals policy helpers", () => {
});
});
it("uses OPENCLAW_STATE_DIR when reporting default host sources", () => {
const originalOpenClawStateDir = process.env.OPENCLAW_STATE_DIR;
const stateDir = path.join(process.cwd(), ".tmp-openclaw-state");
process.env.OPENCLAW_STATE_DIR = stateDir;
try {
const summary = resolveExecPolicyScopeSummary({
approvals: {
version: 1,
defaults: {
security: "allowlist",
},
},
scopeExecConfig: {
security: "full",
},
configPath: "tools.exec",
scopeLabel: "tools.exec",
});
expect(summary.security.hostSource).toBe(
`${path.join(stateDir, "exec-approvals.json")} defaults.security`,
);
} finally {
if (originalOpenClawStateDir === undefined) {
delete process.env.OPENCLAW_STATE_DIR;
} else {
process.env.OPENCLAW_STATE_DIR = originalOpenClawStateDir;
}
}
});
it("does not let host ask=off suppress a stricter requested ask", () => {
const summary = resolveExecPolicyScopeSummary({
approvals: {
+93
View File
@@ -25,12 +25,15 @@ let recordAllowlistMatchesUse: ExecApprovalsModule["recordAllowlistMatchesUse"];
let recordAllowlistUse: ExecApprovalsModule["recordAllowlistUse"];
let requestExecApprovalViaSocket: ExecApprovalsModule["requestExecApprovalViaSocket"];
let resolveExecApprovals: ExecApprovalsModule["resolveExecApprovals"];
let resolveExecApprovalsDisplayPath: ExecApprovalsModule["resolveExecApprovalsDisplayPath"];
let resolveExecApprovalsPath: ExecApprovalsModule["resolveExecApprovalsPath"];
let resolveExecApprovalsSocketPath: ExecApprovalsModule["resolveExecApprovalsSocketPath"];
let resolveExecApprovalsTranscriptPath: ExecApprovalsModule["resolveExecApprovalsTranscriptPath"];
let saveExecApprovals: ExecApprovalsModule["saveExecApprovals"];
const tempDirs: string[] = [];
const originalOpenClawHome = process.env.OPENCLAW_HOME;
const originalOpenClawStateDir = process.env.OPENCLAW_STATE_DIR;
beforeAll(async () => {
({
@@ -45,8 +48,10 @@ beforeAll(async () => {
recordAllowlistUse,
requestExecApprovalViaSocket,
resolveExecApprovals,
resolveExecApprovalsDisplayPath,
resolveExecApprovalsPath,
resolveExecApprovalsSocketPath,
resolveExecApprovalsTranscriptPath,
saveExecApprovals,
} = await import("./exec-approvals.js"));
});
@@ -62,6 +67,11 @@ afterEach(() => {
} else {
process.env.OPENCLAW_HOME = originalOpenClawHome;
}
if (originalOpenClawStateDir === undefined) {
delete process.env.OPENCLAW_STATE_DIR;
} else {
process.env.OPENCLAW_STATE_DIR = originalOpenClawStateDir;
}
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
@@ -71,6 +81,7 @@ function createHomeDir(): string {
const dir = makeTempDir();
tempDirs.push(dir);
process.env.OPENCLAW_HOME = dir;
delete process.env.OPENCLAW_STATE_DIR;
return dir;
}
@@ -78,6 +89,10 @@ function approvalsFilePath(homeDir: string): string {
return path.join(homeDir, ".openclaw", "exec-approvals.json");
}
function stateApprovalsFilePath(stateDir: string): string {
return path.join(stateDir, "exec-approvals.json");
}
function readApprovalsFile(homeDir: string): ExecApprovalsFile {
return JSON.parse(fs.readFileSync(approvalsFilePath(homeDir), "utf8")) as ExecApprovalsFile;
}
@@ -121,6 +136,84 @@ describe("exec approvals store helpers", () => {
expect(path.normalize(resolveExecApprovalsSocketPath())).toBe(
path.normalize(path.join(dir, ".openclaw", "exec-approvals.sock")),
);
expect(resolveExecApprovalsDisplayPath()).toBe("~/.openclaw/exec-approvals.json");
});
it("uses OPENCLAW_STATE_DIR for default file and socket paths", () => {
const dir = createHomeDir();
const stateDir = path.join(dir, "custom-state");
process.env.OPENCLAW_STATE_DIR = stateDir;
expect(path.normalize(resolveExecApprovalsPath())).toBe(
path.normalize(stateApprovalsFilePath(stateDir)),
);
expect(path.normalize(resolveExecApprovalsSocketPath())).toBe(
path.normalize(path.join(stateDir, "exec-approvals.sock")),
);
expect(resolveExecApprovalsDisplayPath()).toBe(stateApprovalsFilePath(stateDir));
expect(resolveExecApprovalsTranscriptPath()).toBe("$OPENCLAW_STATE_DIR/exec-approvals.json");
const ensured = ensureExecApprovals();
expect(ensured.socket?.path).toBe(resolveExecApprovalsSocketPath());
expect(fs.existsSync(stateApprovalsFilePath(stateDir))).toBe(true);
expect(fs.existsSync(approvalsFilePath(dir))).toBe(false);
});
it("fails closed without writing target approvals before state migration runs", () => {
const dir = createHomeDir();
const stateDir = path.join(dir, "custom-state");
fs.mkdirSync(path.dirname(approvalsFilePath(dir)), { recursive: true });
fs.writeFileSync(
approvalsFilePath(dir),
`${JSON.stringify({
version: 1,
socket: {
path: path.join(dir, ".openclaw", "exec-approvals.sock"),
token: "legacy-token",
},
defaults: {
security: "deny",
ask: "always",
},
agents: {},
})}\n`,
"utf8",
);
process.env.OPENCLAW_STATE_DIR = stateDir;
const resolved = resolveExecApprovals("main", {
security: "full",
ask: "off",
});
expect(resolved.agent.security).toBe("deny");
expect(resolved.agent.ask).toBe("always");
expect(resolved.token).toBe("");
expect(fs.existsSync(stateApprovalsFilePath(stateDir))).toBe(false);
expect(fs.existsSync(approvalsFilePath(dir))).toBe(true);
const ensured = ensureExecApprovals();
expect(ensured.defaults).toEqual({
security: "deny",
ask: "always",
askFallback: "deny",
autoAllowSkills: undefined,
});
expect(fs.existsSync(stateApprovalsFilePath(stateDir))).toBe(false);
});
it("keeps the default approvals path when only legacy state exists", () => {
const dir = createHomeDir();
fs.mkdirSync(path.join(dir, ".clawdbot"), { recursive: true });
expect(path.normalize(resolveExecApprovalsPath())).toBe(path.normalize(approvalsFilePath(dir)));
ensureExecApprovals();
expect(fs.existsSync(approvalsFilePath(dir))).toBe(true);
expect(fs.existsSync(path.join(dir, ".clawdbot", "exec-approvals.json"))).toBe(false);
});
it("merges socket defaults from normalized, current, and built-in fallback", () => {
+94 -5
View File
@@ -15,7 +15,7 @@ import { analyzeShellCommand, type ExecCommandSegment } from "./exec-approvals-a
import type { ExecAllowlistEntry } from "./exec-approvals.types.js";
import { isShellWrapperInvocation } from "./exec-wrapper-resolution.js";
import { assertNoSymlinkParentsSync } from "./fs-safe-advanced.js";
import { expandHomePrefix, resolveRequiredHomeDir } from "./home-dir.js";
import { expandHomePrefix, resolveHomeRelativePath, resolveRequiredHomeDir } from "./home-dir.js";
import { requestJsonlSocket } from "./jsonl-socket.js";
export * from "./exec-approvals-analysis.js";
export * from "./exec-approvals-allowlist.js";
@@ -284,8 +284,9 @@ const DEFAULT_SECURITY: ExecSecurity = "full";
const DEFAULT_ASK: ExecAsk = "off";
export const DEFAULT_EXEC_APPROVAL_ASK_FALLBACK: ExecSecurity = "deny";
const DEFAULT_AUTO_ALLOW_SKILLS = false;
const DEFAULT_SOCKET = "~/.openclaw/exec-approvals.sock";
const DEFAULT_FILE = "~/.openclaw/exec-approvals.json";
const DEFAULT_EXEC_APPROVALS_STATE_DIR = "~/.openclaw";
const EXEC_APPROVALS_FILE = "exec-approvals.json";
const EXEC_APPROVALS_SOCKET = "exec-approvals.sock";
function hashExecApprovalsRaw(raw: string | null): string {
return crypto
@@ -294,12 +295,71 @@ function hashExecApprovalsRaw(raw: string | null): string {
.digest("hex");
}
function resolveExecApprovalsStateDir(env: NodeJS.ProcessEnv = process.env): {
path: string;
displayPath: string;
} {
const override = env.OPENCLAW_STATE_DIR?.trim();
if (override) {
const resolved = resolveHomeRelativePath(override, { env });
return {
path: resolved,
displayPath: resolved,
};
}
return {
path: expandHomePrefix(DEFAULT_EXEC_APPROVALS_STATE_DIR, { env }),
displayPath: DEFAULT_EXEC_APPROVALS_STATE_DIR,
};
}
export function resolveExecApprovalsPath(): string {
return expandHomePrefix(DEFAULT_FILE);
return path.join(resolveExecApprovalsStateDir().path, EXEC_APPROVALS_FILE);
}
export function resolveExecApprovalsSocketPath(): string {
return expandHomePrefix(DEFAULT_SOCKET);
return path.join(resolveExecApprovalsStateDir().path, EXEC_APPROVALS_SOCKET);
}
export function resolveExecApprovalsDisplayPath(): string {
const stateDir = resolveExecApprovalsStateDir().displayPath;
return stateDir === DEFAULT_EXEC_APPROVALS_STATE_DIR
? `${stateDir}/${EXEC_APPROVALS_FILE}`
: path.join(stateDir, EXEC_APPROVALS_FILE);
}
export function resolveExecApprovalsTranscriptPath(): string {
return process.env.OPENCLAW_STATE_DIR?.trim()
? `$OPENCLAW_STATE_DIR/${EXEC_APPROVALS_FILE}`
: `${DEFAULT_EXEC_APPROVALS_STATE_DIR}/${EXEC_APPROVALS_FILE}`;
}
function resolveLegacyExecApprovalsPath(): string {
return path.join(expandHomePrefix(DEFAULT_EXEC_APPROVALS_STATE_DIR), EXEC_APPROVALS_FILE);
}
function hasUnmigratedLegacyExecApprovals(filePath: string): boolean {
if (!process.env.OPENCLAW_STATE_DIR?.trim()) {
return false;
}
const legacyPath = resolveLegacyExecApprovalsPath();
return (
path.resolve(legacyPath) !== path.resolve(filePath) &&
!fs.existsSync(filePath) &&
fs.existsSync(legacyPath)
);
}
function createUnmigratedLegacyExecApprovalsFallback(): ExecApprovalsFile {
return normalizeExecApprovals({
version: 1,
defaults: {
security: "deny",
ask: "always",
askFallback: "deny",
},
agents: {},
});
}
function normalizeAllowlistPattern(value: string | undefined): string | null {
@@ -728,6 +788,16 @@ function generateToken(): string {
export function readExecApprovalsSnapshot(): ExecApprovalsSnapshot {
const filePath = resolveExecApprovalsPath();
if (hasUnmigratedLegacyExecApprovals(filePath)) {
const file = createUnmigratedLegacyExecApprovalsFallback();
return {
path: filePath,
exists: false,
raw: null,
file,
hash: hashExecApprovalsRaw(null),
};
}
if (!fs.existsSync(filePath)) {
const file = normalizeExecApprovals({ version: 1, agents: {} });
return {
@@ -760,6 +830,9 @@ export function readExecApprovalsSnapshot(): ExecApprovalsSnapshot {
export function loadExecApprovals(): ExecApprovalsFile {
const filePath = resolveExecApprovalsPath();
if (hasUnmigratedLegacyExecApprovals(filePath)) {
return createUnmigratedLegacyExecApprovalsFallback();
}
try {
if (!fs.existsSync(filePath)) {
return normalizeExecApprovals({ version: 1, agents: {} });
@@ -820,6 +893,9 @@ export function restoreExecApprovalsSnapshot(snapshot: ExecApprovalsSnapshot): v
}
export function ensureExecApprovals(): ExecApprovalsFile {
if (hasUnmigratedLegacyExecApprovals(resolveExecApprovalsPath())) {
return createUnmigratedLegacyExecApprovalsFallback();
}
const loaded = loadExecApprovals();
const next = normalizeExecApprovals(loaded);
const socketPath = next.socket?.path?.trim();
@@ -836,6 +912,9 @@ export function ensureExecApprovals(): ExecApprovalsFile {
}
function readExecApprovalsForNoPersistence(filePath: string): ExecApprovalsFile {
if (hasUnmigratedLegacyExecApprovals(filePath)) {
return createUnmigratedLegacyExecApprovalsFallback();
}
const dir = path.dirname(filePath);
assertNoExecApprovalsSymlinkParents(dir, resolveRequiredHomeDir());
assertSafeExecApprovalsDestination(filePath);
@@ -1000,6 +1079,16 @@ export function resolveExecApprovals(
overrides?: ExecApprovalsDefaultOverrides,
): ExecApprovalsResolved {
const filePath = resolveExecApprovalsPath();
if (hasUnmigratedLegacyExecApprovals(filePath)) {
return resolveExecApprovalsFromFile({
file: createUnmigratedLegacyExecApprovalsFallback(),
agentId,
overrides,
path: filePath,
socketPath: resolveExecApprovalsSocketPath(),
token: "",
});
}
if (!overrides?.requireSocket) {
const file = readExecApprovalsForNoPersistence(filePath);
const resolved = resolveExecApprovalsFromFile({
+267 -11
View File
@@ -61,7 +61,8 @@ import {
repairOpenClawStateDatabaseSchema,
runOpenClawStateWriteTransaction,
} from "../state/openclaw-state-db.js";
import { expandHomePrefix } from "./home-dir.js";
import { assertNoSymlinkParentsSync } from "./fs-safe-advanced.js";
import { expandHomePrefix, resolveRequiredHomeDir } from "./home-dir.js";
import {
executeSqliteQuerySync,
executeSqliteQueryTakeFirstSync,
@@ -128,9 +129,16 @@ export type LegacyStateDetection = {
sessionPath: string;
hasLegacy: boolean;
};
execApprovals: {
sourcePath: string;
targetPath: string;
hasLegacy: boolean;
};
preview: string[];
};
type LegacyExecApprovalsMigrationDetection = LegacyStateDetection["execApprovals"];
type MigrationLogger = {
info: (message: string) => void;
warn: (message: string) => void;
@@ -173,6 +181,8 @@ const LEGACY_DELIVERY_QUEUE_DIRS = [
{ label: "outbound delivery queue", queueName: "outbound", dirName: "delivery-queue" },
{ label: "session delivery queue", queueName: "session", dirName: "session-delivery-queue" },
] as const;
const EXEC_APPROVALS_FILENAME = "exec-approvals.json";
const EXEC_APPROVALS_SOCKET_FILENAME = "exec-approvals.sock";
type LegacyDeliveryQueueFile = {
sourcePath: string;
status: "pending" | "failed";
@@ -232,6 +242,43 @@ function resolveLegacyFlowRunsSidecarPath(stateDir: string): string {
return path.join(stateDir, "flows", "registry.sqlite");
}
function resolveDefaultExecApprovalsStateDir(
env: NodeJS.ProcessEnv,
homedir: () => string,
): string {
return path.join(resolveRequiredHomeDir(env, homedir), ".openclaw");
}
function resolveDefaultExecApprovalsPath(env: NodeJS.ProcessEnv, homedir: () => string): string {
return path.join(resolveDefaultExecApprovalsStateDir(env, homedir), EXEC_APPROVALS_FILENAME);
}
function resolveExecApprovalsPathForStateDir(stateDir: string): string {
return path.join(stateDir, EXEC_APPROVALS_FILENAME);
}
function resolveExecApprovalsSocketPathForStateDir(stateDir: string): string {
return path.join(stateDir, EXEC_APPROVALS_SOCKET_FILENAME);
}
function detectLegacyExecApprovalsMigration(params: {
env: NodeJS.ProcessEnv;
homedir: () => string;
stateDir: string;
}): LegacyExecApprovalsMigrationDetection {
const sourcePath = resolveDefaultExecApprovalsPath(params.env, params.homedir);
const targetPath = resolveExecApprovalsPathForStateDir(params.stateDir);
return {
sourcePath,
targetPath,
hasLegacy:
Boolean(params.env.OPENCLAW_STATE_DIR?.trim()) &&
path.resolve(sourcePath) !== path.resolve(targetPath) &&
fileExists(sourcePath) &&
!fileExists(targetPath),
};
}
function readLegacyPluginStateSidecarRows(sourcePath: string): LegacyPluginStateSidecarRow[] {
const sqlite = requireNodeSqlite();
const db = new sqlite.DatabaseSync(sourcePath, { readOnly: true });
@@ -2583,22 +2630,29 @@ export async function autoMigrateLegacyTaskStateSidecars(params: {
const stateDir = resolveStateDir(params.env ?? process.env, params.homedir);
const result = await migrateLegacyTaskStateSidecars({ stateDir });
const execApprovals = migrateLegacyExecApprovals(
detectLegacyExecApprovalsMigration({
env: params.env ?? process.env,
homedir: params.homedir ?? os.homedir,
stateDir,
}),
);
const changes = [...result.changes, ...execApprovals.changes];
const warnings = [...result.warnings, ...execApprovals.warnings];
const logger = params.log ?? createSubsystemLogger("state-migrations");
if (result.changes.length > 0) {
logger.info(
`Auto-migrated legacy task state:\n${result.changes.map((entry) => `- ${entry}`).join("\n")}`,
);
if (changes.length > 0) {
logger.info(`Auto-migrated legacy state:\n${changes.map((entry) => `- ${entry}`).join("\n")}`);
}
if (result.warnings.length > 0) {
if (warnings.length > 0) {
logger.warn(
`Legacy task state migration warnings:\n${result.warnings.map((entry) => `- ${entry}`).join("\n")}`,
`Legacy state migration warnings:\n${warnings.map((entry) => `- ${entry}`).join("\n")}`,
);
}
return {
migrated: result.changes.length > 0,
migrated: changes.length > 0,
skipped: false,
changes: result.changes,
warnings: result.warnings,
changes,
warnings,
};
}
@@ -2684,6 +2738,7 @@ export async function detectLegacyStateMigrations(params: {
const homedir = params.homedir ?? os.homedir;
const stateDir = resolveStateDir(env, homedir);
const oauthDir = resolveOAuthDir(env, stateDir);
const execApprovals = detectLegacyExecApprovalsMigration({ env, homedir, stateDir });
const targetAgentId = normalizeAgentId(resolveDefaultAgentId(params.cfg));
const rawMainKey = params.cfg.session?.mainKey;
@@ -2797,6 +2852,9 @@ export async function detectLegacyStateMigrations(params: {
if (hasDeliveryQueues) {
preview.push("- Delivery queues: legacy JSON queue files → shared SQLite state");
}
if (execApprovals.hasLegacy) {
preview.push(`- Exec approvals: ${execApprovals.sourcePath}${execApprovals.targetPath}`);
}
if (channelPlans.length > 0) {
preview.push(...channelPlans.map(buildLegacyMigrationPreview));
}
@@ -2852,6 +2910,7 @@ export async function detectLegacyStateMigrations(params: {
...deliveryQueuePaths,
hasLegacy: hasDeliveryQueues,
},
execApprovals,
preview,
};
}
@@ -3146,6 +3205,192 @@ async function runPluginDoctorStateMigrationPlans(params: {
return { changes, warnings };
}
function isPlainJsonObject(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function isDefaultLegacyExecApprovalsSocketPath(params: {
socketPath: string;
sourcePath: string;
}): boolean {
const expanded = expandHomePrefix(params.socketPath);
return (
path.resolve(expanded) ===
path.join(path.dirname(params.sourcePath), EXEC_APPROVALS_SOCKET_FILENAME)
);
}
function prepareMigratedExecApprovalsFile(params: {
raw: string;
sourcePath: string;
targetPath: string;
}): { raw: string; warning?: string } {
let parsed: unknown;
try {
parsed = JSON.parse(params.raw) as unknown;
} catch {
return {
raw: "",
warning: `Legacy exec approvals file unreadable; left in place at ${params.sourcePath}`,
};
}
if (!isPlainJsonObject(parsed) || parsed.version !== 1) {
return {
raw: "",
warning: `Legacy exec approvals file has unsupported shape; left in place at ${params.sourcePath}`,
};
}
const next: Record<string, unknown> = { ...parsed };
const socket = isPlainJsonObject(next.socket) ? { ...next.socket } : {};
const rawSocketPath = typeof socket.path === "string" ? socket.path.trim() : "";
if (
!rawSocketPath ||
isDefaultLegacyExecApprovalsSocketPath({
socketPath: rawSocketPath,
sourcePath: params.sourcePath,
})
) {
socket.path = resolveExecApprovalsSocketPathForStateDir(path.dirname(params.targetPath));
}
next.socket = socket;
return { raw: `${JSON.stringify(next, null, 2)}\n` };
}
function assertSafeExecApprovalsMigrationTarget(targetPath: string): void {
const targetDir = path.dirname(targetPath);
assertNoSymlinkParentsSync({
rootDir: resolveRequiredHomeDir(),
targetPath: targetDir,
allowOutsideRoot: true,
messagePrefix: "Refusing to traverse symlink in exec approvals migration path",
});
try {
const targetStat = fs.lstatSync(targetPath);
if (targetStat.isSymbolicLink()) {
throw new Error(`Refusing to migrate exec approvals via symlink: ${targetPath}`);
}
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
throw err;
}
}
}
function writeMigratedExecApprovalsFile(targetPath: string, raw: string): boolean {
const targetDir = path.dirname(targetPath);
assertSafeExecApprovalsMigrationTarget(targetPath);
fs.mkdirSync(targetDir, { recursive: true, mode: 0o700 });
assertSafeExecApprovalsMigrationTarget(targetPath);
const dirStat = fs.lstatSync(targetDir);
if (!dirStat.isDirectory() || dirStat.isSymbolicLink()) {
throw new Error(`Refusing to migrate exec approvals into unsafe directory: ${targetDir}`);
}
try {
fs.chmodSync(targetDir, 0o700);
} catch {
// best-effort on platforms without chmod
}
const tempPath = path.join(targetDir, `.exec-approvals.migration.${process.pid}.tmp`);
fs.writeFileSync(tempPath, raw, { encoding: "utf8", mode: 0o600, flag: "wx" });
try {
try {
fs.copyFileSync(tempPath, targetPath, fs.constants.COPYFILE_EXCL);
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "EEXIST") {
return false;
}
try {
fs.rmSync(targetPath, { force: true });
} catch {
// best-effort cleanup for an incomplete exclusive copy target
}
throw err;
}
try {
fs.chmodSync(targetPath, 0o600);
} catch {
// best-effort on platforms without chmod
}
return true;
} finally {
fs.rmSync(tempPath, { force: true });
}
}
function archiveMigratedExecApprovalsSource(sourcePath: string): string {
let archivePath = `${sourcePath}.migrated`;
if (fileExists(archivePath)) {
archivePath = `${archivePath}-${Date.now()}`;
}
fs.renameSync(sourcePath, archivePath);
return archivePath;
}
function migrateLegacyExecApprovals(detected: LegacyExecApprovalsMigrationDetection): {
changes: string[];
warnings: string[];
} {
const changes: string[] = [];
const warnings: string[] = [];
if (!detected.hasLegacy) {
return { changes, warnings };
}
if (fileExists(detected.targetPath)) {
return { changes, warnings };
}
try {
const sourceStat = fs.lstatSync(detected.sourcePath);
if (!sourceStat.isFile() || sourceStat.isSymbolicLink()) {
warnings.push(
`Legacy exec approvals file is not a regular file; left in place at ${detected.sourcePath}`,
);
return { changes, warnings };
}
try {
const targetStat = fs.lstatSync(detected.targetPath);
if (targetStat.isSymbolicLink()) {
warnings.push(
`Target exec approvals path is a symlink; skipped migration at ${detected.targetPath}`,
);
return { changes, warnings };
}
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
throw err;
}
}
const prepared = prepareMigratedExecApprovalsFile({
raw: fs.readFileSync(detected.sourcePath, "utf8"),
sourcePath: detected.sourcePath,
targetPath: detected.targetPath,
});
if (prepared.warning) {
warnings.push(prepared.warning);
return { changes, warnings };
}
if (!writeMigratedExecApprovalsFile(detected.targetPath, prepared.raw)) {
return { changes, warnings };
}
changes.push(`Migrated exec approvals → ${detected.targetPath}`);
try {
const archivePath = archiveMigratedExecApprovalsSource(detected.sourcePath);
changes.push(`Archived legacy exec approvals → ${archivePath}`);
} catch (err) {
warnings.push(
`Failed archiving legacy exec approvals at ${detected.sourcePath}: ${String(err)}`,
);
}
} catch (err) {
warnings.push(
`Failed migrating exec approvals (${detected.sourcePath}${detected.targetPath}): ${String(
err,
)}`,
);
}
return { changes, warnings };
}
function migrateLegacyStateSchema(detected: LegacyStateDetection): {
changes: string[];
warnings: string[];
@@ -3179,6 +3424,7 @@ export async function runLegacyStateMigrations(params: {
const deliveryQueues = await migrateLegacyDeliveryQueues({
stateDir: detected.stateDir,
});
const execApprovals = migrateLegacyExecApprovals(detected.execApprovals);
const preSessionChannelPlans = await runLegacyMigrationPlans(
detected.channelPlans.plans.filter((plan) => plan.kind === "plugin-state-import"),
);
@@ -3207,6 +3453,7 @@ export async function runLegacyStateMigrations(params: {
...pluginInstallIndex.changes,
...taskStateSidecars.changes,
...deliveryQueues.changes,
...execApprovals.changes,
...preSessionChannelPlans.changes,
...pluginPlans.changes,
...sessions.changes,
@@ -3220,6 +3467,7 @@ export async function runLegacyStateMigrations(params: {
...pluginInstallIndex.warnings,
...taskStateSidecars.warnings,
...deliveryQueues.warnings,
...execApprovals.warnings,
...preSessionChannelPlans.warnings,
...pluginPlans.warnings,
...sessions.warnings,
@@ -3551,6 +3799,7 @@ export async function autoMigrateLegacyState(params: {
const deliveryQueues = await migrateLegacyDeliveryQueues({
stateDir: detected.stateDir,
});
const execApprovals = migrateLegacyExecApprovals(detected.execApprovals);
const preSessionChannelPlans = await runLegacyMigrationPlans(
detected.channelPlans.plans.filter((plan) => plan.kind === "plugin-state-import"),
);
@@ -3567,6 +3816,7 @@ export async function autoMigrateLegacyState(params: {
...pluginInstallIndex.changes,
...taskStateSidecars.changes,
...deliveryQueues.changes,
...execApprovals.changes,
...preSessionChannelPlans.changes,
...pluginPlans.changes,
];
@@ -3579,6 +3829,7 @@ export async function autoMigrateLegacyState(params: {
...pluginInstallIndex.warnings,
...taskStateSidecars.warnings,
...deliveryQueues.warnings,
...execApprovals.warnings,
...preSessionChannelPlans.warnings,
...pluginPlans.warnings,
];
@@ -3593,6 +3844,7 @@ export async function autoMigrateLegacyState(params: {
pluginInstallIndex.changes.length > 0 ||
taskStateSidecars.changes.length > 0 ||
deliveryQueues.changes.length > 0 ||
execApprovals.changes.length > 0 ||
preSessionChannelPlans.changes.length > 0 ||
pluginPlans.changes.length > 0,
skipped: true,
@@ -3609,7 +3861,8 @@ export async function autoMigrateLegacyState(params: {
!detected.pluginInstallIndex.hasLegacy &&
!detected.stateSchema.hasLegacy &&
!detected.taskStateSidecars.hasLegacy &&
!detected.deliveryQueues.hasLegacy
!detected.deliveryQueues.hasLegacy &&
!detected.execApprovals.hasLegacy
) {
const changes = [
...stateDirResult.changes,
@@ -3649,6 +3902,7 @@ export async function autoMigrateLegacyState(params: {
const deliveryQueues = await migrateLegacyDeliveryQueues({
stateDir: detected.stateDir,
});
const execApprovals = migrateLegacyExecApprovals(detected.execApprovals);
const preSessionChannelPlans = await runLegacyMigrationPlans(
detected.channelPlans.plans.filter((plan) => plan.kind === "plugin-state-import"),
);
@@ -3677,6 +3931,7 @@ export async function autoMigrateLegacyState(params: {
...pluginInstallIndex.changes,
...taskStateSidecars.changes,
...deliveryQueues.changes,
...execApprovals.changes,
...preSessionChannelPlans.changes,
...pluginPlans.changes,
...sessions.changes,
@@ -3693,6 +3948,7 @@ export async function autoMigrateLegacyState(params: {
...pluginInstallIndex.warnings,
...taskStateSidecars.warnings,
...deliveryQueues.warnings,
...execApprovals.warnings,
...preSessionChannelPlans.warnings,
...pluginPlans.warnings,
...sessions.warnings,