mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(exec): honor state dir approvals (#92056)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user