fix(exec): stop isolated state dirs from moving live approvals (#108742)

* fix(exec): isolate approval state directories

* chore: drop release-owned changelog entry

* chore: refresh native i18n inventory

* fix(ci): pin XcodeGen for Periphery scans
This commit is contained in:
Peter Steinberger
2026-07-16 08:57:32 -07:00
committed by GitHub
parent 7a18a781bc
commit 8fe4eeea9e
35 changed files with 88 additions and 1208 deletions
+1 -1
View File
@@ -29459,7 +29459,7 @@
},
{
"kind": "conditional-branch",
"line": 1199,
"line": 946,
"path": "apps/macos/Sources/OpenClaw/ExecApprovalsStore.swift",
"source": "missing:\\(hash)",
"surface": "apple",
@@ -1,5 +1,4 @@
import CryptoKit
import Darwin
import Foundation
import OSLog
import Security
@@ -14,12 +13,6 @@ enum ExecApprovalsStore {
private static let defaultAutoAllowSkills = false
private static let secureStateDirPermissions = 0o700
private enum LegacyMigrationResult {
case notNeeded
case migrated
case blocked
}
/// Match the TypeScript writer's `<approvals>.lock` protocol. Both processes
/// must cover the complete read-modify-write transaction or a stale native
/// usage update can restore policy that an administrator just revoked.
@@ -68,57 +61,6 @@ enum ExecApprovalsStore {
return URL(fileURLWithPath: expanded, isDirectory: true).standardizedFileURL
}
private static func legacyStateDirURLs() -> [URL] {
if OpenClawEnv.path("OPENCLAW_HOME") != nil {
// OPENCLAW_HOME replaces the OS home; crossing back would mutate
// the real default profile during an isolated run.
return [
self.trustedRootURL()
.appendingPathComponent(".openclaw", isDirectory: true),
]
}
return [
FileManager().homeDirectoryForCurrentUser
.appendingPathComponent(".openclaw", isDirectory: true),
]
}
private static func legacyFileURLIfPending() -> URL? {
guard OpenClawEnv.path("OPENCLAW_STATE_DIR") != nil || OpenClawEnv.path("OPENCLAW_HOME") != nil
else { return nil }
// Named profiles are isolated. Bare state-dir relocation keeps the
// shipped migration for users who moved their primary state root.
if let profile = OpenClawEnv.path("OPENCLAW_PROFILE"),
profile.lowercased() != "default"
{
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 ExecApprovalsFileIO.pathExistsNoFollow(legacyURL) else { continue }
guard !ExecApprovalsFileIO.pathExistsNoFollow(targetURL) 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: false),
agents: [:])
}
private static func failClosedFallbackFile() -> ExecApprovalsFile {
ExecApprovalsFile(
version: 1,
@@ -131,174 +73,6 @@ enum ExecApprovalsStore {
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 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
// Publish the complete 0600 temp inode atomically without replacing
// policy a mixed-version writer may have created meanwhile.
let renamed = renamex_np(tempURL.path, targetURL.path, UInt32(RENAME_EXCL))
if renamed == -1 {
let code = errno
try? FileManager().removeItem(at: tempURL)
if code == EEXIST {
return false
}
throw POSIXError(POSIXErrorCode(rawValue: code) ?? .EIO)
}
return true
} catch {
try? FileManager().removeItem(at: tempURL)
throw error
}
}
private static func migrateLegacyFileIfNeeded() -> LegacyMigrationResult {
guard let legacyURL = legacyFileURLIfPending() else { return .notNeeded }
let targetURL = self.fileURL()
do {
let trustedRoot = self.trustedRootURL()
try ExecApprovalsFileIO.assertSafeParentChain(of: targetURL, trustedRoot: trustedRoot)
let effectiveLegacyDir = trustedRoot.appendingPathComponent(".openclaw", isDirectory: true)
let legacyRoot = legacyURL.deletingLastPathComponent().standardizedFileURL.path
== effectiveLegacyDir.standardizedFileURL.path
? trustedRoot
: FileManager().homeDirectoryForCurrentUser
// ensureFileUnlocked already owns the target lock. Always take the
// legacy lock second so migration cannot race an older writer and
// two migrating processes cannot deadlock with opposite lock order.
return try ExecApprovalsFileIO.withLock(
fileURL: legacyURL,
trustedRoot: legacyRoot)
{
if ExecApprovalsFileIO.pathExistsNoFollow(targetURL) {
return .notNeeded
}
guard ExecApprovalsFileIO.pathExistsNoFollow(legacyURL) else {
throw NSError(domain: "ExecApprovals", code: 10, userInfo: [
NSLocalizedDescriptionKey: "legacy exec approvals disappeared during migration",
])
}
return try self.migrateLegacyFileLocked(
legacyURL: legacyURL,
legacyRoot: legacyRoot,
targetURL: targetURL,
trustedRoot: trustedRoot)
}
} catch {
self.logger
.error(
"exec approvals legacy migration failed: \(error.localizedDescription, privacy: .public)")
return .blocked
}
}
private static func migrateLegacyFileLocked(
legacyURL: URL,
legacyRoot: URL,
targetURL: URL,
trustedRoot: URL) throws -> LegacyMigrationResult
{
guard let legacy = try ExecApprovalsFileIO.read(at: legacyURL, trustedRoot: legacyRoot) else {
throw NSError(domain: "ExecApprovals", code: 10, userInfo: [
NSLocalizedDescriptionKey: "legacy exec approvals disappeared during migration",
])
}
let data = legacy.data
guard self.hasValidPersistedStructure(data) else {
throw NSError(domain: "ExecApprovals", code: 13, userInfo: [
NSLocalizedDescriptionKey: "invalid legacy approvals structure",
])
}
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)
try self.ensureSecureStateDirectory()
try FileManager().createDirectory(
at: targetURL.deletingLastPathComponent(),
withIntermediateDirectories: true)
try ExecApprovalsFileIO.assertSafeParentChain(of: targetURL, trustedRoot: trustedRoot)
if ExecApprovalsFileIO.pathExistsNoFollow(targetURL) {
return .notNeeded
}
let created = try writeMigratedFileExclusively(migrated, to: targetURL)
if !created {
return .notNeeded
}
do {
_ = try self.archiveMigratedLegacyFile(legacyURL)
} catch {
self.logger
.warning(
"exec approvals legacy archive failed: \(error.localizedDescription, privacy: .public)")
}
return .migrated
}
static func normalizeIncoming(_ file: ExecApprovalsFile) -> ExecApprovalsFile {
let socketPath = file.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let token = file.socket?.token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
@@ -348,14 +122,6 @@ enum ExecApprovalsStore {
}
private static func readSnapshotUnlocked() throws -> ExecApprovalsSnapshot {
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 let current = try ExecApprovalsFileIO.read(at: url, trustedRoot: self.trustedRootURL()) else {
return ExecApprovalsSnapshot(
@@ -375,9 +141,6 @@ enum ExecApprovalsStore {
}
static func loadFile() -> ExecApprovalsFile {
if self.legacyFileURLIfPending() != nil {
return self.ensureFile()
}
do {
return try self.withWriteLock {
self.loadFileUnlocked()
@@ -537,16 +300,6 @@ enum ExecApprovalsStore {
}
private static func ensureFileUnlocked() throws -> ExecApprovalsFile {
if self.legacyFileURLIfPending() != nil {
switch self.migrateLegacyFileIfNeeded() {
case .migrated, .notNeeded:
break
case .blocked:
throw NSError(domain: "ExecApprovals", code: 18, userInfo: [
NSLocalizedDescriptionKey: "legacy exec approvals migration blocked",
])
}
}
try self.ensureSecureStateDirectory()
let url = self.fileURL()
let current = try ExecApprovalsFileIO.read(at: url, trustedRoot: self.trustedRootURL())
@@ -585,9 +338,6 @@ enum ExecApprovalsStore {
return try self.withWriteLock {
// A conditional write must not create or normalize policy state
// before it proves the caller still owns the observed snapshot.
guard self.legacyFileURLIfPending() == nil else {
return .baseHashUnavailable
}
let snapshot = try self.readSnapshotUnlocked()
let expected = baseHash?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if snapshot.exists {
@@ -663,10 +413,7 @@ enum ExecApprovalsStore {
let file: ExecApprovalsFile
do {
file = try self.withWriteLock {
guard self.legacyFileURLIfPending() == nil else {
return self.unmigratedLegacyFallbackFile()
}
return self.loadFileUnlocked()
self.loadFileUnlocked()
}
} catch {
self.logger.warning("exec approvals read-only lock failed: \(error.localizedDescription, privacy: .public)")
@@ -254,7 +254,7 @@ struct ExecApprovalsStoreRefactorTests {
}
@Test
func `blocked legacy migration cannot create or mutate current policy`() async throws {
func `malformed default policy does not block custom state mutations`() async throws {
try await self.withTempHomeAndStateDir { home, _ in
let legacyDir = home.appendingPathComponent(".openclaw", isDirectory: true)
try FileManager().createDirectory(at: legacyDir, withIntermediateDirectories: true)
@@ -263,26 +263,25 @@ struct ExecApprovalsStoreRefactorTests {
try malformed.write(to: legacyURL)
let readOnly = ExecApprovalsStore.resolveReadOnly(agentId: "main")
#expect(readOnly.agent.security == .deny)
#expect(readOnly.agent.ask == .always)
#expect(readOnly.agent.security == .full)
#expect(readOnly.agent.ask == .off)
let ensured = ExecApprovalsStore.ensureFile()
#expect(ensured.defaults?.security == .deny)
#expect(ensured.defaults?.ask == .off)
#expect(!FileManager().fileExists(atPath: ExecApprovalsStore.fileURL().path))
#expect(ensured.socket?.token?.isEmpty == false)
#expect(FileManager().fileExists(atPath: ExecApprovalsStore.fileURL().path))
let mutation = ExecApprovalsStore.updateDefaults { $0.security = .full }
guard case .failure(.unavailable) = mutation else {
Issue.record("expected blocked migration mutation failure")
let mutation = ExecApprovalsStore.updateDefaults { $0.security = .allowlist }
guard case .success = mutation else {
Issue.record("expected custom state mutation to succeed")
return
}
#expect(!FileManager().fileExists(atPath: ExecApprovalsStore.fileURL().path))
#expect(try Data(contentsOf: legacyURL) == malformed)
#expect(!FileManager().fileExists(atPath: "\(legacyURL.path).migrated"))
}
}
@Test
func `symlinked legacy policy cannot seed the current store`() async throws {
func `symlinked default policy cannot seed the custom store`() async throws {
try await self.withTempHomeAndStateDir { home, _ in
let legacyDir = home.appendingPathComponent(".openclaw", isDirectory: true)
try FileManager().createDirectory(at: legacyDir, withIntermediateDirectories: true)
@@ -294,10 +293,10 @@ struct ExecApprovalsStoreRefactorTests {
let ensured = ExecApprovalsStore.ensureFile()
#expect(ensured.defaults?.security == .deny)
#expect(ensured.defaults?.ask == .off)
#expect(!FileManager().fileExists(atPath: ExecApprovalsStore.fileURL().path))
#expect(ensured.socket?.token?.isEmpty == false)
#expect(FileManager().fileExists(atPath: ExecApprovalsStore.fileURL().path))
#expect(try Data(contentsOf: linkedTarget) == permissive)
#expect(try FileManager().destinationOfSymbolicLink(atPath: legacyURL.path) == linkedTarget.path)
}
}
@@ -1131,7 +1130,7 @@ struct ExecApprovalsStoreRefactorTests {
}
extension ExecApprovalsStoreRefactorTests {
@Test
func `ensure file migrates default approvals into custom state dir`() async throws {
func `ensure file keeps custom state isolated from default approvals`() async throws {
try await self.withTempHomeAndStateDir { home, stateDir in
let legacyDir = home.appendingPathComponent(".openclaw", isDirectory: true)
try FileManager().createDirectory(
@@ -1158,6 +1157,7 @@ extension ExecApprovalsStoreRefactorTests {
}
"""
try Data(legacyJson.utf8).write(to: legacyFile)
let legacyBefore = try Data(contentsOf: legacyFile)
let file = ExecApprovalsStore.ensureFile()
let targetURL = ExecApprovalsStore.fileURL()
@@ -1168,12 +1168,10 @@ extension ExecApprovalsStoreRefactorTests {
#expect(targetURL.path == expectedFileURL.path)
#expect(FileManager().fileExists(atPath: targetURL.path))
#expect(file.socket?.path == ExecApprovalsStore.socketPath())
#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"))
#expect(file.socket?.token != "legacy-token")
#expect(file.agents?["main"]?.allowlist == nil)
#expect(try Data(contentsOf: legacyFile) == legacyBefore)
#expect(!FileManager().fileExists(atPath: "\(legacyFile.path).migrated"))
}
}
@@ -1204,68 +1202,6 @@ extension ExecApprovalsStoreRefactorTests {
}
}
@Test
func `legacy writer revocation wins before migration publishes and archives`() async throws {
try await self.withTempHomeAndStateDir { home, _ in
let legacyDir = home.appendingPathComponent(".openclaw", isDirectory: true)
try FileManager().createDirectory(
at: legacyDir,
withIntermediateDirectories: true)
let legacyFile = legacyDir.appendingPathComponent("exec-approvals.json")
let legacyLock = legacyDir.appendingPathComponent("exec-approvals.json.lock")
let initial = Data(
#"{"version":1,"agents":{"main":{"allowlist":[{"id":"revoked-entry","pattern":"/usr/bin/echo"}]}}}"#
.utf8)
let revoked = Data(
#"{"version":1,"agents":{"main":{"allowlist":[]}}}"#.utf8)
try initial.write(to: legacyFile)
try Data("legacy writer owns lock".utf8).write(to: legacyLock)
// An older writer revokes under its sidecar lock. Migration must
// wait, then read and archive only the post-revocation policy.
DispatchQueue.global().asyncAfter(deadline: .now() + .milliseconds(100)) {
try? revoked.write(to: legacyFile, options: [.atomic])
try? FileManager().removeItem(at: legacyLock)
}
let migrated = ExecApprovalsStore.ensureFile()
#expect(ExecApprovalsStore.resolve(agentId: "main").allowlist.isEmpty)
#expect(migrated.agents?["main"]?.allowlist?.isEmpty != false)
#expect(!FileManager().fileExists(atPath: legacyFile.path))
let archived = URL(fileURLWithPath: "\(legacyFile.path).migrated")
#expect(FileManager().fileExists(atPath: archived.path))
let archivedFile = try JSONDecoder().decode(
ExecApprovalsFile.self,
from: Data(contentsOf: archived))
#expect(archivedFile.agents?["main"]?.allowlist?.isEmpty == true)
}
}
@Test
func `legacy migration fails closed while legacy writer lock is held`() async throws {
try await self.withTempHomeAndStateDir { home, _ in
let legacyDir = home.appendingPathComponent(".openclaw", isDirectory: true)
try FileManager().createDirectory(
at: legacyDir,
withIntermediateDirectories: true)
let legacyFile = legacyDir.appendingPathComponent("exec-approvals.json")
let legacyLock = legacyDir.appendingPathComponent("exec-approvals.json.lock")
let permissive = Data(
#"{"version":1,"defaults":{"security":"full","ask":"off"}}"#.utf8)
try permissive.write(to: legacyFile)
try Data("legacy writer owns lock".utf8).write(to: legacyLock)
let ensured = ExecApprovalsStore.ensureFile()
#expect(ensured.defaults?.security == .deny)
#expect(ensured.defaults?.ask == .off)
#expect(!FileManager().fileExists(atPath: ExecApprovalsStore.fileURL().path))
#expect(try Data(contentsOf: legacyFile) == permissive)
#expect(FileManager().fileExists(atPath: legacyLock.path))
}
}
@Test
func `add allowlist entries accepts basename pattern`() async throws {
try await self.withTempStateDir { _ in
+7 -6
View File
@@ -149,6 +149,10 @@ without exceptions outside doctor/import/export/debug boundaries.
- Doctor migration: `migrating`, intentionally. Doctor imports legacy JSON,
JSONL, and retired sidecar stores into SQLite, records migration runs/sources,
and removes successful sources.
- Exec approvals: `file-runtime`. TypeScript and macOS still read and write the
active state directory's `exec-approvals.json`; the reserved
`exec_approvals_config` schema has no runtime owner yet. A future cutover must
add same-state doctor import and move both runtimes together.
- E2E scripts: `clean` for runtime coverage. Docker MCP seeding writes SQLite
rows. The runtime-context Docker script creates legacy JSONL only inside the
doctor migration seed and names the legacy session index path explicitly.
@@ -456,12 +460,9 @@ The branch already has a real shared SQLite base:
`.openclaw/workspace-state.json`; runtime no longer reads or rewrites the
legacy workspace marker, and helper APIs no longer pass around a fake
`.openclaw/setup-state` path just to derive storage identity.
- Exec approvals now live in the typed shared SQLite `exec_approvals_config`
singleton row. Doctor imports legacy `~/.openclaw/exec-approvals.json`;
runtime writes no longer create, rewrite, or report that file as its active
store location. The macOS companion reads and writes the same
`state/openclaw.sqlite` table row; it keeps only the Unix prompt socket on disk
because that is IPC, not durable runtime state.
- The shared schema reserves an `exec_approvals_config` singleton row, but the
runtime cutover remains pending. TypeScript and the macOS companion still use
the state-scoped JSON file and must move to SQLite together.
- Device identity, device auth, and bootstrap runtime modules now keep their
SQLite snapshot readers/writers separate from doctor-only legacy JSON import
helpers. Device identity uses typed `device_identities` rows and device auth
+6 -9
View File
@@ -93,15 +93,12 @@ The default approval socket follows the same root:
`$OPENCLAW_STATE_DIR/exec-approvals.sock`, or
`~/.openclaw/exec-approvals.sock` when the variable is unset.
Releases before 2026.6.6 always kept the file in `~/.openclaw`. If
`OPENCLAW_STATE_DIR` points somewhere else and an approvals file still exists
in the default directory, run `openclaw doctor --fix` directly once to import
it into the state directory (the original is archived with a `.migrated`
suffix). Interactive doctor can also preview and confirm the import. Automated
update and Gateway watch repair runs never import across state directories: a
temporary or staging state directory must not capture the default
installation's approvals. The same boundary applies to legacy
`plugin-binding-approvals.json` imports into shared SQLite state.
State directories are independent trust scopes. When `OPENCLAW_STATE_DIR`
points somewhere else, OpenClaw never imports or archives
`~/.openclaw/exec-approvals.json`; configure approvals separately for the
custom state directory. Doctor also imports legacy
`plugin-binding-approvals.json` only when it belongs to the active state
directory.
Example schema:
@@ -55,7 +55,6 @@ git_cli="$git_root/openclaw.mjs"
package_version="$(node -p "require(\"$npm_root/package.json\").version")"
update_doctor_env="OPENCLAW_UPDATE_IN_PROGRESS=1"
update_doctor_env+=" OPENCLAW_DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS=1"
update_doctor_env+=" OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE=1"
update_doctor_env+=" OPENCLAW_UPDATE_PARENT_SUPPORTS_GATEWAY_RESTART=1"
update_doctor_env+=" OPENCLAW_UPDATE_PARENT_ALLOWS_GATEWAY_SERVICE_REPAIR=1"
@@ -162,7 +161,6 @@ run_flow() {
fi
assert_entrypoint "$unit_path" "$doctor_expected"
assert_no_env_key "$unit_path" "OPENCLAW_DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS"
}
run_flow \
@@ -239,7 +237,6 @@ run_cross_state_approval_flow() {
test "$(plugin_binding_approval_count "$state_database")" = "0"
if ! openclaw_e2e_maybe_timeout "$command_timeout" env \
-u OPENCLAW_DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS \
-u OPENCLAW_UPDATE_IN_PROGRESS \
OPENCLAW_STATE_DIR="$custom_state_dir" \
OPENCLAW_CONFIG_PATH="$custom_state_dir/openclaw.json" \
@@ -248,12 +245,12 @@ run_cross_state_approval_flow() {
exit 1
fi
test ! -e "$exec_source"
test ! -e "$plugin_source"
test "$(sha256sum "$exec_source.migrated" | awk '{print $1}')" = "$exec_source_hash"
test "$(sha256sum "$plugin_source.migrated" | awk '{print $1}')" = "$plugin_source_hash"
test -e "$custom_state_dir/exec-approvals.json"
test "$(plugin_binding_approval_count "$state_database")" = "1"
test "$(sha256sum "$exec_source" | awk '{print $1}')" = "$exec_source_hash"
test "$(sha256sum "$plugin_source" | awk '{print $1}')" = "$plugin_source_hash"
test ! -e "$exec_source.migrated"
test ! -e "$plugin_source.migrated"
test ! -e "$custom_state_dir/exec-approvals.json"
test "$(plugin_binding_approval_count "$state_database")" = "0"
}
run_cross_state_approval_flow
@@ -287,7 +284,6 @@ run_proxy_env_flow() {
} >>"$unit_path"
if ! openclaw_e2e_maybe_timeout "$command_timeout" env \
OPENCLAW_UPDATE_IN_PROGRESS=1 \
OPENCLAW_DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS=1 \
OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE=1 \
OPENCLAW_UPDATE_PARENT_SUPPORTS_GATEWAY_RESTART=1 \
OPENCLAW_UPDATE_PARENT_ALLOWS_GATEWAY_SERVICE_REPAIR=1 \
@@ -298,7 +294,6 @@ run_proxy_env_flow() {
fi
assert_no_env_key "$unit_path" "HTTP_PROXY"
assert_no_env_key "$unit_path" "HTTPS_PROXY"
assert_no_env_key "$unit_path" "OPENCLAW_DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS"
}
run_proxy_env_flow
-5
View File
@@ -22,10 +22,6 @@ const WATCH_LOCK_DIR = path.join(".local", "watch-node");
const WATCH_DIST_ENTRY_POLL_MS = 1_000;
const WATCH_DIST_ENTRY_TIMEOUT_MS = 5 * 60 * 1_000;
const AUTO_DOCTOR_DISABLE_VALUES = new Set(["0", "false", "no", "off"]);
// The source watcher cannot import the TypeScript owner; keep this literal
// aligned with src/commands/doctor-invocation.ts.
const DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS_ENV =
"OPENCLAW_DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS";
const buildRunnerArgs = (args) => [WATCH_NODE_RUNNER, ...args];
const buildDoctorRunnerArgs = () => [WATCH_NODE_RUNNER, "doctor", "--fix", "--non-interactive"];
@@ -487,7 +483,6 @@ export async function runWatchMain(params = {}) {
detached: useChildProcessGroup,
env: {
...childEnv,
[DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS_ENV]: "1",
},
stdio: "inherit",
});
+2 -16
View File
@@ -183,7 +183,6 @@ describe("ensureConfigReady", () => {
migrateState: true,
migrateLegacyConfig: false,
invalidConfigNote: false,
crossStateDirImports: false,
});
}
});
@@ -205,7 +204,6 @@ describe("ensureConfigReady", () => {
migrateLegacyConfig: false,
invalidConfigNote: false,
observe: false,
crossStateDirImports: false,
});
});
@@ -220,7 +218,6 @@ describe("ensureConfigReady", () => {
migrateLegacyConfig: false,
invalidConfigNote: false,
observe: false,
crossStateDirImports: false,
});
});
@@ -231,7 +228,6 @@ describe("ensureConfigReady", () => {
migrateState: true,
migrateLegacyConfig: false,
invalidConfigNote: false,
crossStateDirImports: false,
requireStartupMigrationCheckpoint: true,
});
});
@@ -264,7 +260,6 @@ describe("ensureConfigReady", () => {
migrateState: true,
migrateLegacyConfig: false,
invalidConfigNote: false,
crossStateDirImports: false,
});
});
@@ -288,7 +283,6 @@ describe("ensureConfigReady", () => {
migrateState: true,
migrateLegacyConfig: false,
invalidConfigNote: false,
crossStateDirImports: false,
});
});
@@ -313,7 +307,6 @@ describe("ensureConfigReady", () => {
migrateState: true,
migrateLegacyConfig: false,
invalidConfigNote: false,
crossStateDirImports: false,
});
expect(setRuntimeConfigSnapshotMock).toHaveBeenCalledWith(
migratedSnapshot.runtimeConfig,
@@ -336,7 +329,7 @@ describe("ensureConfigReady", () => {
{ commandPath: ["plugins", "list"], source: "exec-approvals.json" },
{ commandPath: ["tasks", "list"], source: "plugin-binding-approvals.json" },
])(
"runs notice-only preflight for $commandPath with default-state $source",
"ignores default-state $source while $commandPath uses custom state",
async ({ commandPath, source }) => {
const root = useTempOpenClawHome();
const stateDir = path.join(root, "custom-state");
@@ -347,14 +340,7 @@ describe("ensureConfigReady", () => {
await runEnsureConfigReady(commandPath);
expect(loadAndMaybeMigrateDoctorConfigMock).toHaveBeenCalledOnce();
expect(loadAndMaybeMigrateDoctorConfigMock).toHaveBeenCalledWith({
migrateState: true,
migrateLegacyConfig: false,
invalidConfigNote: false,
...(commandPath[0] === "status" ? { observe: false } : {}),
crossStateDirImports: false,
});
expect(loadAndMaybeMigrateDoctorConfigMock).not.toHaveBeenCalled();
expect(fs.readFileSync(sourcePath, "utf8")).toBe(sourceRaw);
expect(fs.existsSync(`${sourcePath}.migrated`)).toBe(false);
expect(fs.existsSync(path.join(stateDir, "exec-approvals.json"))).toBe(false);
+2 -27
View File
@@ -4,13 +4,7 @@ import os from "node:os";
import path from "node:path";
import { withSuppressedNotes } from "../../../packages/terminal-core/src/note.js";
import { readConfigFileSnapshot, setRuntimeConfigSnapshot } from "../../config/config.js";
import {
isNamedProfile,
resolveLegacyStateDirs,
resolveNewStateDir,
resolveOAuthDir,
resolveStateDir,
} from "../../config/paths.js";
import { resolveLegacyStateDirs, resolveOAuthDir, resolveStateDir } from "../../config/paths.js";
import type { ConfigFileSnapshot } from "../../config/types.js";
import { resolveRequiredHomeDir } from "../../infra/home-dir.js";
import { ExitError, type RuntimeEnv } from "../../runtime.js";
@@ -101,23 +95,6 @@ function hasBundledChannelLegacyStateMigrationInputs(stateDir: string, oauthDir:
return dirHasFile(oauthDir, isLegacyWhatsAppAuthFile);
}
function hasCrossStateDirApprovalMigrationInputs(stateDir: string): boolean {
if (!process.env.OPENCLAW_STATE_DIR?.trim() || isNamedProfile()) {
return false;
}
const homeDir = resolveRequiredHomeDir(process.env, os.homedir);
const defaultStateDir = resolveNewStateDir(() => homeDir);
if (path.resolve(defaultStateDir) === path.resolve(stateDir)) {
return false;
}
const execApprovalsSource = path.join(defaultStateDir, "exec-approvals.json");
const execApprovalsTarget = path.join(stateDir, "exec-approvals.json");
return (
(fileOrDirExists(execApprovalsSource) && !fileOrDirExists(execApprovalsTarget)) ||
fileOrDirExists(path.join(defaultStateDir, "plugin-binding-approvals.json"))
);
}
function hasPendingSqliteSidecarArchive(sourcePath: string): boolean {
return (
fileOrDirExists(`${sourcePath}.migrated`) &&
@@ -153,8 +130,7 @@ function hasLegacyStateMigrationInputs(): boolean {
sqliteSidecarPaths.some(
(sourcePath) => fileOrDirExists(sourcePath) || hasPendingSqliteSidecarArchive(sourcePath),
) ||
hasBundledChannelLegacyStateMigrationInputs(stateDir, oauthDir) ||
hasCrossStateDirApprovalMigrationInputs(stateDir)
hasBundledChannelLegacyStateMigrationInputs(stateDir, oauthDir)
);
}
@@ -232,7 +208,6 @@ export async function ensureConfigReady(params: {
migrateLegacyConfig: false,
invalidConfigNote: false,
...(commandName === "status" ? { observe: false } : {}),
crossStateDirImports: false,
...(shouldRequireStartupMigrationCheckpoint(commandPath)
? { requireStartupMigrationCheckpoint: true }
: {}),
+1 -28
View File
@@ -1,7 +1,6 @@
// Register maintenance tests cover maintenance command registration in the CLI program.
import { Command } from "commander";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS_ENV } from "../../commands/doctor-invocation.js";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { registerMaintenanceCommands } from "./register.maintenance.js";
const mocks = vi.hoisted(() => ({
@@ -69,10 +68,6 @@ describe("registerMaintenanceCommands doctor action", () => {
vi.clearAllMocks();
});
afterEach(() => {
vi.unstubAllEnvs();
});
it("exits with code 0 after successful doctor run", async () => {
doctorCommand.mockResolvedValue(undefined);
@@ -106,28 +101,6 @@ describe("registerMaintenanceCommands doctor action", () => {
const [runtimeArg, options] = commandCall(doctorCommand);
expect(runtimeArg).toBe(runtime);
expect(options.repair).toBe(true);
expect(options.crossStateDirImports).toBe(true);
});
it("denies cross-state imports when an automation parent disables them", async () => {
doctorCommand.mockResolvedValue(undefined);
vi.stubEnv(DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS_ENV, "1");
await runMaintenanceCli(["doctor", "--fix", "--non-interactive"]);
const [, options] = commandCall(doctorCommand);
expect(options.repair).toBe(true);
expect(options.crossStateDirImports).toBe(false);
});
it("denies cross-state imports for older update parents", async () => {
doctorCommand.mockResolvedValue(undefined);
vi.stubEnv("OPENCLAW_UPDATE_IN_PROGRESS", "1");
await runMaintenanceCli(["doctor", "--fix", "--non-interactive"]);
const [, options] = commandCall(doctorCommand);
expect(options.crossStateDirImports).toBe(false);
});
it("passes session sqlite options to doctor command", async () => {
-2
View File
@@ -2,7 +2,6 @@
import type { Command } from "commander";
import { formatDocsLink } from "../../../packages/terminal-core/src/links.js";
import { theme } from "../../../packages/terminal-core/src/theme.js";
import { resolveDoctorCrossStateDirImports } from "../../commands/doctor-invocation.js";
import { defaultRuntime } from "../../runtime.js";
import { runCommandWithRuntime } from "../cli-utils.js";
import { hasExplicitOptions } from "../command-options.js";
@@ -171,7 +170,6 @@ export function registerMaintenanceCommands(program: Command) {
sessionSqliteAllAgents: Boolean(opts.sessionSqliteAllAgents),
sessionSqliteGithubIssue: Boolean(opts.githubIssue),
json: Boolean(opts.json),
crossStateDirImports: resolveDoctorCrossStateDirImports(),
});
defaultRuntime.exit(0);
});
-2
View File
@@ -7467,7 +7467,6 @@ describe("update-cli", () => {
nonInteractive: true,
repair: true,
yes: true,
crossStateDirImports: false,
});
expect(syncPluginCall()?.channel).toBe("stable");
expect(syncPluginCall()?.acknowledgeClawHubRisk).toBe(true);
@@ -7568,7 +7567,6 @@ describe("update-cli", () => {
nonInteractive: true,
repair: true,
yes: false,
crossStateDirImports: false,
});
expect(syncPluginCall()?.channel).toBe("beta");
expect(syncPluginCall()?.config).toEqual({
@@ -6,7 +6,6 @@ import path from "node:path";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { theme } from "../../../packages/terminal-core/src/theme.js";
import { DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS_ENV } from "../../commands/doctor-invocation.js";
import { doctorCommand } from "../../commands/doctor.js";
import {
UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR_ENV,
@@ -231,7 +230,6 @@ export async function updateFinalizeCommand(opts: UpdateFinalizeOptions): Promis
nonInteractive: true,
repair: true,
yes: opts.yes === true,
crossStateDirImports: false,
});
configSnapshot = await readConfigFileSnapshot({ skipPluginValidation: true });
if (requestedChannel) {
@@ -520,7 +518,6 @@ export async function continuePostCoreUpdateInFreshProcess(params: {
env: {
...stripGatewayServiceMarkerEnv(disableUpdatedPackageCompileCacheEnv(process.env)),
OPENCLAW_UPDATE_IN_PROGRESS: "1",
[DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS_ENV]: "1",
[POST_CORE_UPDATE_ENV]: "1",
[POST_CORE_UPDATE_CHANNEL_ENV]: params.channel,
...(params.requestedChannel
@@ -11,7 +11,6 @@ import {
checkShellCompletionStatus,
ensureCompletionCacheExists,
} from "../../commands/doctor-completion.js";
import { DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS_ENV } from "../../commands/doctor-invocation.js";
import { doctorCommand } from "../../commands/doctor.js";
import { UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV } from "../../commands/doctor/shared/update-phase.js";
import { resolveGatewayPort } from "../../config/config.js";
@@ -880,7 +879,6 @@ export function resolvePostInstallDoctorEnv(params?: {
}): NodeJS.ProcessEnv {
const resolvedEnv: NodeJS.ProcessEnv = {
...disableUpdatedPackageCompileCacheEnv(params?.baseEnv ?? process.env),
[DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS_ENV]: "1",
};
if (!params?.serviceEnv) {
return resolvedEnv;
@@ -1436,7 +1434,6 @@ export async function maybeRestartService(params: {
process.stdin.isTTY && !params.opts.json && params.opts.yes !== true;
await doctorCommand(defaultRuntime, {
nonInteractive: !interactiveDoctor,
crossStateDirImports: false,
});
} catch (err) {
defaultRuntime.log(theme.warn(`Doctor failed: ${String(err)}`));
@@ -3,7 +3,6 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS_ENV } from "../../commands/doctor-invocation.js";
import { resolveGatewayInstallEntrypoint } from "../../daemon/gateway-entrypoint.js";
import type { UpdateRunResult } from "../../infra/update-runner.js";
import { updatePluginsAfterCoreUpdate } from "./update-command-plugins.js";
@@ -208,7 +207,6 @@ describe("resolvePostInstallDoctorEnv", () => {
expect(env.PATH).toBe("/bin");
expect(env.NODE_DISABLE_COMPILE_CACHE).toBe("1");
expect(env[DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS_ENV]).toBe("1");
expect(env.OPENCLAW_STATE_DIR).toBe(path.join("/srv/openclaw", "daemon-state"));
expect(env.OPENCLAW_CONFIG_PATH).toBe(
path.join("/srv/openclaw", "daemon-state", "openclaw.json"),
@@ -227,7 +225,6 @@ describe("resolvePostInstallDoctorEnv", () => {
expect(env.PATH).toBe("/bin");
expect(env.NODE_DISABLE_COMPILE_CACHE).toBe("1");
expect(env[DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS_ENV]).toBe("1");
expect(env.OPENCLAW_STATE_DIR).toBe("/caller/state");
expect(env.OPENCLAW_PROFILE).toBe("caller");
});
-24
View File
@@ -1538,30 +1538,6 @@ describe("doctor config flow", () => {
runDoctorConfigPreflightOptionsMock.mockClear();
});
it("grants config preflight cross-state imports only with repair and direct capability", async () => {
await runDoctorConfigWithInput({
config: {},
repair: true,
run: ({ options, confirm }) =>
loadAndMaybeMigrateDoctorConfig({
options: { ...options, crossStateDirImports: true },
confirm: async () => confirm(),
}),
});
expect(runDoctorConfigPreflightOptionsMock).toHaveBeenLastCalledWith(
expect.objectContaining({ crossStateDirImports: true }),
);
await runDoctorConfigWithInput({
config: {},
repair: true,
run: loadAndMaybeMigrateDoctorConfig,
});
expect(runDoctorConfigPreflightOptionsMock).toHaveBeenLastCalledWith(
expect.objectContaining({ crossStateDirImports: false }),
);
});
it("preserves invalid config for doctor repairs", async () => {
const result = await runDoctorConfigWithInput({
config: {
-1
View File
@@ -142,7 +142,6 @@ export async function loadAndMaybeMigrateDoctorConfig(params: {
const preflight = await runDoctorConfigPreflight({
repairPrefixedConfig: shouldRepair,
recoverCorruptTargetStore: shouldRepair,
crossStateDirImports: shouldRepair && params.options.crossStateDirImports === true,
});
const snapshot = preflight.snapshot;
const baseCfg = preflight.baseConfig;
-10
View File
@@ -225,13 +225,6 @@ export async function runDoctorConfigPreflight(
skipPristineCoreStateMigrations?: boolean;
/** Prepared before Gateway bootstrap can create files under an otherwise pristine state root. */
skipPristineStartupStateMigrations?: boolean;
/**
* Allows legacy imports whose source lives in the DEFAULT home state dir
* while OPENCLAW_STATE_DIR points elsewhere. Only explicit doctor repair
* runs opt in; the implicit CLI/gateway preflight must never archive
* files that belong to another install's state dir.
*/
crossStateDirImports?: boolean;
} = {},
): Promise<DoctorConfigPreflightResult> {
const stateMigrationsRequested = options.migrateState !== false;
@@ -420,7 +413,6 @@ export async function runDoctorConfigPreflight(
: {}),
env: process.env,
recoverCorruptTargetStore: options.recoverCorruptTargetStore,
crossStateDirImports: options.crossStateDirImports,
}),
);
} else if (stateMigrationInput.pluginDoctorConfig) {
@@ -433,7 +425,6 @@ export async function runDoctorConfigPreflight(
noteStartupStateMigrationResult(
await autoMigrateLegacyTaskStateSidecars({
env: process.env,
crossStateDirImports: options.crossStateDirImports,
}),
);
}
@@ -441,7 +432,6 @@ export async function runDoctorConfigPreflight(
noteStartupStateMigrationResult(
await autoMigrateLegacyTaskStateSidecars({
env: process.env,
crossStateDirImports: options.crossStateDirImports,
}),
);
}
-16
View File
@@ -1,16 +0,0 @@
/** Internal doctor invocation capabilities shared by direct and automated callers. */
import { isTruthyEnvValue } from "../infra/env.js";
import { UPDATE_IN_PROGRESS_ENV } from "./doctor/shared/update-phase.js";
export const DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS_ENV =
"OPENCLAW_DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS";
/** Direct CLI doctor owns cross-state imports unless its automation parent denies them. */
export function resolveDoctorCrossStateDirImports(env: NodeJS.ProcessEnv = process.env): boolean {
// Older update parents know only OPENCLAW_UPDATE_IN_PROGRESS. Treat that
// existing cross-version handshake as deny-by-default for a newer doctor.
return !(
isTruthyEnvValue(env[DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS_ENV]) ||
isTruthyEnvValue(env[UPDATE_IN_PROGRESS_ENV])
);
}
+9 -150
View File
@@ -2820,148 +2820,9 @@ 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,
crossStateDirImports: true,
});
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,
crossStateDirImports: true,
});
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 when doctor opts in", 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,
crossStateDirImports: true,
});
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 default exec approvals in place without the cross-state-dir opt-in", async () => {
// Regression: the implicit preflight (every CLI command, gateway startup)
// must never archive files that belong to the default state dir just
// because OPENCLAW_STATE_DIR points somewhere else.
it("never imports default exec approvals into a custom state dir", async () => {
// Regression: every custom state root is an independent trust scope.
// Even direct doctor repair must not copy or archive default approvals.
const root = await makeTempRoot();
const stateDir = path.join(root, "custom-state");
const sourcePath = path.join(root, ".openclaw", "exec-approvals.json");
@@ -2978,22 +2839,23 @@ describe("doctor legacy state migrations", () => {
});
const sourceRaw = fs.readFileSync(sourcePath, "utf8");
const result = await autoMigrateLegacyTaskStateSidecars({
const detected = await detectLegacyStateMigrations({
cfg: {},
env: { OPENCLAW_STATE_DIR: stateDir } as NodeJS.ProcessEnv,
homedir: () => root,
});
expect(detected.preview.some((entry) => entry.includes("Exec approvals"))).toBe(false);
const result = await runLegacyStateMigrations({ detected });
expect(result.warnings).toStrictEqual([]);
expect(result.changes).not.toContain(`Migrated exec approvals → ${targetPath}`);
expect(result.notices?.join("\n")).toContain(
"Exec approvals in the default state dir were not imported",
);
expect(fs.readFileSync(sourcePath, "utf8")).toBe(sourceRaw);
expect(fs.existsSync(`${sourcePath}.migrated`)).toBe(false);
expect(fs.existsSync(targetPath)).toBe(false);
});
it("keeps default exec approvals in place when autoMigrateLegacyState runs without the opt-in", async () => {
it("keeps default exec approvals in place during automatic state migration", async () => {
const root = await makeTempRoot();
const stateDir = path.join(root, "custom-state");
const sourcePath = path.join(root, ".openclaw", "exec-approvals.json");
@@ -3018,9 +2880,6 @@ describe("doctor legacy state migrations", () => {
expect(result.warnings).toStrictEqual([]);
expect(result.changes).not.toContain(`Migrated exec approvals → ${targetPath}`);
expect(result.notices?.join("\n")).toContain(
"Exec approvals in the default state dir were not imported",
);
expect(fs.readFileSync(sourcePath, "utf8")).toBe(sourceRaw);
expect(fs.existsSync(`${sourcePath}.migrated`)).toBe(false);
expect(fs.existsSync(targetPath)).toBe(false);
-5
View File
@@ -317,11 +317,6 @@ function createLegacyStateMigrationDetectionResult(params?: {
accountIds: {},
hasLegacy: false,
},
execApprovals: {
sourcePath: "/tmp/state/exec-approvals.legacy.json",
targetPath: "/tmp/state/exec-approvals.json",
hasLegacy: false,
},
channelPlans: {
hasLegacy: false,
plans: [],
-2
View File
@@ -16,6 +16,4 @@ export type DoctorOptions = {
sessionSqliteAllAgents?: boolean;
sessionSqliteGithubIssue?: boolean;
json?: boolean;
/** Internal capability granted only to direct operator-owned doctor invocations. */
crossStateDirImports?: boolean;
};
@@ -1461,7 +1461,6 @@ describe("doctor health contributions", () => {
expect(mocks.detectLegacyStateMigrations).toHaveBeenCalledWith({
cfg,
doctorOnlyStateMigrations: true,
crossStateDirImports: false,
});
expect(mocks.runLegacyStateMigrations).toHaveBeenCalledWith({
detected,
@@ -1470,51 +1469,6 @@ describe("doctor health contributions", () => {
});
});
it("grants legacy-state cross-state imports only to capable doctor origins", async () => {
const contribution = requireDoctorContribution("doctor:legacy-state");
const detected = { preview: [], warnings: [], notices: [] };
mocks.detectLegacyStateMigrations.mockResolvedValue(detected);
const directRepairContext = {
cfg: {},
sourceConfigValid: true,
prompter: buildDoctorPrompter(true),
runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() },
options: { nonInteractive: true, repair: true, crossStateDirImports: true },
} as unknown as Parameters<(typeof contribution)["run"]>[0];
await contribution.run(directRepairContext);
expect(mocks.detectLegacyStateMigrations).toHaveBeenLastCalledWith({
cfg: {},
doctorOnlyStateMigrations: true,
crossStateDirImports: true,
});
const interactivePrompter = buildDoctorPrompter(false);
interactivePrompter.repairMode.canPrompt = true;
interactivePrompter.repairMode.nonInteractive = false;
await contribution.run({
...directRepairContext,
prompter: interactivePrompter,
options: { crossStateDirImports: true },
});
expect(mocks.detectLegacyStateMigrations).toHaveBeenLastCalledWith({
cfg: {},
doctorOnlyStateMigrations: true,
crossStateDirImports: true,
});
const automatedRepairContext = {
...directRepairContext,
options: { nonInteractive: true, repair: true, crossStateDirImports: false },
};
await contribution.run(automatedRepairContext);
expect(mocks.detectLegacyStateMigrations).toHaveBeenLastCalledWith({
cfg: {},
doctorOnlyStateMigrations: true,
crossStateDirImports: false,
});
});
it("prints legacy state migration notices during manual doctor runs", async () => {
const contribution = requireDoctorContribution("doctor:legacy-state");
const detected = { preview: ["legacy sessions"], warnings: [], notices: [] };
-7
View File
@@ -518,16 +518,9 @@ async function runLegacyStateHealth(ctx: DoctorHealthFlowContext): Promise<void>
const { detectLegacyStateMigrations, runLegacyStateMigrations } =
await import("../commands/doctor-state-migrations.js");
const { note } = await loadNoteModule();
// Only a direct operator-owned doctor may inspect the default state dir for
// imports. Automated repair callers explicitly lack this capability so a
// temporary OPENCLAW_STATE_DIR cannot capture and archive production trust.
const operatorCanApproveCrossStateDirImports =
ctx.prompter.repairMode.canPrompt || ctx.prompter.shouldRepair;
const legacyState = await detectLegacyStateMigrations({
cfg: ctx.cfg,
doctorOnlyStateMigrations: true,
crossStateDirImports:
ctx.options.crossStateDirImports === true && operatorCanApproveCrossStateDirImports,
});
if (legacyState.warnings.length > 0) {
note(legacyState.warnings.join("\n"), "Doctor warnings");
+15 -17
View File
@@ -292,7 +292,7 @@ describe("exec approvals store helpers", () => {
}
});
it("fails closed without writing target approvals before state migration runs", async () => {
it("keeps custom-state approvals independent from the default state", async () => {
const dir = createHomeDir();
const stateDir = path.join(dir, "custom-state");
fs.mkdirSync(path.dirname(approvalsFilePath(dir)), { recursive: true });
@@ -312,6 +312,7 @@ describe("exec approvals store helpers", () => {
})}\n`,
"utf8",
);
const defaultBefore = fs.readFileSync(approvalsFilePath(dir), "utf8");
setTestEnvValue("OPENCLAW_STATE_DIR", stateDir);
const resolved = await resolveExecApprovals("main", {
@@ -319,28 +320,25 @@ describe("exec approvals store helpers", () => {
ask: "off",
});
expect(resolved.agent.security).toBe("deny");
expect(resolved.agent.ask).toBe("always");
expect(resolved.agent.security).toBe("full");
expect(resolved.agent.ask).toBe("off");
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);
expect(ensured.socket?.token).not.toBe("legacy-token");
expect(fs.existsSync(stateApprovalsFilePath(stateDir))).toBe(true);
await expect(
updateExecApprovals({
update: (current) => ({ ...current, defaults: { security: "full" } }),
}),
).rejects.toThrow("must be migrated");
expect(fs.existsSync(stateApprovalsFilePath(stateDir))).toBe(false);
await updateExecApprovals({
update: (current) => ({ ...current, defaults: { security: "allowlist" } }),
});
const custom = JSON.parse(
fs.readFileSync(stateApprovalsFilePath(stateDir), "utf8"),
) as ExecApprovalsFile;
expect(custom.defaults?.security).toBe("allowlist");
expect(fs.readFileSync(approvalsFilePath(dir), "utf8")).toBe(defaultBefore);
expect(fs.existsSync(`${approvalsFilePath(dir)}.migrated`)).toBe(false);
});
it("keeps named-profile approvals isolated from the default profile", () => {
-72
View File
@@ -8,7 +8,6 @@ import {
normalizeOptionalString,
readStringValue,
} from "@openclaw/normalization-core/string-coerce";
import { isNamedProfile } from "../config/paths.js";
import { DEFAULT_AGENT_ID } from "../routing/session-key.js";
import { resolveGlobalMap } from "../shared/global-singleton.js";
import { getFileLockProcessStartTime } from "../shared/pid-alive.js";
@@ -413,34 +412,6 @@ export function resolveExecApprovalsTranscriptPath(): string {
: `${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() || isNamedProfile()) {
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 createFailClosedExecApprovalsFallback(): ExecApprovalsFile {
return normalizeExecApprovals({
version: 1,
@@ -1066,16 +1037,6 @@ function generateToken(): string {
function readExecApprovalsSnapshotUnlocked(): ExecApprovalsSnapshot {
const filePath = resolveExecApprovalsPath();
if (hasUnmigratedLegacyExecApprovals(filePath)) {
const file = createUnmigratedLegacyExecApprovalsFallback();
return {
path: filePath,
exists: false,
raw: null,
file,
hash: hashExecApprovalsRaw(null),
};
}
return readExecApprovalsSnapshotFromPath(filePath);
}
@@ -1090,9 +1051,6 @@ export function readExecApprovalsSnapshot(): ExecApprovalsSnapshot {
function loadExecApprovalsUnlocked(): ExecApprovalsFile {
const filePath = resolveExecApprovalsPath();
if (hasUnmigratedLegacyExecApprovals(filePath)) {
return createUnmigratedLegacyExecApprovalsFallback();
}
try {
return readExecApprovalsSnapshotFromPath(filePath).file;
} catch {
@@ -1293,9 +1251,6 @@ type ExecApprovalsUpdate = {
function updateExecApprovalsUnlocked(params: ExecApprovalsUpdate): ExecApprovalsSnapshot | null {
// Both sync and async entry points hold the sidecar lock across this full CAS transaction.
if (hasUnmigratedLegacyExecApprovals(resolveExecApprovalsPath())) {
throw new Error("Exec approvals must be migrated before they can be updated");
}
const current = readExecApprovalsSnapshotUnlocked();
if (params.baseHash !== undefined && current.hash !== params.baseHash) {
return null;
@@ -1465,27 +1420,18 @@ function requireInitializedExecApprovals(
}
export async function ensureExecApprovalsSnapshot(): Promise<ExecApprovalsSnapshot> {
if (hasUnmigratedLegacyExecApprovals(resolveExecApprovalsPath())) {
return readExecApprovalsSnapshot();
}
return requireInitializedExecApprovals(
await updateExecApprovals({ update: ensureExecApprovalsSocket }),
);
}
export function ensureExecApprovals(): ExecApprovalsFile {
if (hasUnmigratedLegacyExecApprovals(resolveExecApprovalsPath())) {
return createUnmigratedLegacyExecApprovalsFallback();
}
return requireInitializedExecApprovals(
updateExecApprovalsSync({ update: ensureExecApprovalsSocket }),
).file;
}
function readExecApprovalsForNoPersistenceUnlocked(filePath: string): ExecApprovalsFile {
if (hasUnmigratedLegacyExecApprovals(filePath)) {
return createUnmigratedLegacyExecApprovalsFallback();
}
try {
return readExecApprovalsSnapshotFromPath(filePath).file;
} catch (err) {
@@ -1670,15 +1616,6 @@ export function resolveExecApprovals(
overrides?: ExecApprovalsDefaultOverrides,
): ExecApprovalsResolved {
const filePath = resolveExecApprovalsPath();
if (hasUnmigratedLegacyExecApprovals(filePath)) {
return shapeResolvedExecApprovals({
file: createUnmigratedLegacyExecApprovalsFallback(),
filePath,
agentId,
overrides,
socket: "none",
});
}
if (!overrides?.requireSocket) {
const file = withExecApprovalsReadLockSync(filePath, () =>
readExecApprovalsForNoPersistenceUnlocked(filePath),
@@ -1708,15 +1645,6 @@ export async function resolveExecApprovalsLocked(
overrides?: ExecApprovalsDefaultOverrides,
): Promise<ExecApprovalsResolved> {
const filePath = resolveExecApprovalsPath();
if (hasUnmigratedLegacyExecApprovals(filePath)) {
return shapeResolvedExecApprovals({
file: createUnmigratedLegacyExecApprovalsFallback(),
filePath,
agentId,
overrides,
socket: "none",
});
}
if (!overrides?.requireSocket) {
const file = await withExecApprovalsReadLock(filePath, async () =>
readExecApprovalsForNoPersistenceUnlocked(filePath),
+5 -55
View File
@@ -6,7 +6,7 @@ import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js";
import { getChannelPlugin } from "../channels/plugins/registry.js";
import type { ChannelLegacyStateMigrationPlan } from "../channels/plugins/types.core.js";
import type { ChannelId } from "../channels/plugins/types.public.js";
import { isNamedProfile, resolveOAuthDir, resolveStateDir } from "../config/paths.js";
import { resolveOAuthDir, resolveStateDir } from "../config/paths.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import {
@@ -43,10 +43,6 @@ import {
detectLegacyDebugProxyCaptureSidecar,
migrateLegacyDebugProxyCaptureSidecar,
} from "./state-migrations.debug-proxy.js";
import {
detectLegacyExecApprovalsMigration,
migrateLegacyExecApprovals,
} from "./state-migrations.exec-approvals.js";
import {
existsDir,
fileExists,
@@ -245,28 +241,12 @@ export async function detectLegacyStateMigrations(params: {
homedir?: () => string;
pluginSessionStoreAgentIds?: readonly string[];
sessionStoreOwnership?: SessionStoreOwnership;
crossStateDirImports?: boolean;
doctorOnlyStateMigrations?: boolean;
}): Promise<LegacyStateDetection> {
const env = params.env ?? process.env;
const homedir = params.homedir ?? os.homedir;
const stateDir = resolveStateDir(env, homedir);
const oauthDir = resolveOAuthDir(env, stateDir);
// Sources under the DEFAULT home state dir are foreign state when
// OPENCLAW_STATE_DIR points elsewhere: an isolated/test gateway must never
// import (and archive) another install's files. Only an explicit doctor run
// opts into the cross-directory import.
const crossStateDirImports = params.crossStateDirImports === true;
const notices: string[] = [];
const detectedExecApprovals = detectLegacyExecApprovalsMigration({ env, homedir, stateDir });
const execApprovals = crossStateDirImports
? detectedExecApprovals
: { ...detectedExecApprovals, hasLegacy: false };
if (detectedExecApprovals.hasLegacy && !crossStateDirImports) {
notices.push(
`Exec approvals in the default state dir were not imported into OPENCLAW_STATE_DIR automatically (${detectedExecApprovals.sourcePath} -> ${detectedExecApprovals.targetPath}); run \`openclaw doctor --fix\` to import them.`,
);
}
const targetAgentId = normalizeAgentId(resolveDefaultAgentId(params.cfg));
const rawMainKey = params.cfg.session?.mainKey;
@@ -393,22 +373,9 @@ export async function detectLegacyStateMigrations(params: {
const pluginBindingApprovals = {
sourcePath: resolveLegacyPluginBindingApprovalsPath(env, homedir),
};
const pluginBindingApprovalsCrossDir =
path.resolve(path.dirname(pluginBindingApprovals.sourcePath)) !== path.resolve(stateDir);
const hasPluginBindingApprovals =
!isNamedProfile(env) &&
fileExists(pluginBindingApprovals.sourcePath) &&
(crossStateDirImports || !pluginBindingApprovalsCrossDir);
if (
!isNamedProfile(env) &&
fileExists(pluginBindingApprovals.sourcePath) &&
pluginBindingApprovalsCrossDir &&
!crossStateDirImports
) {
notices.push(
`Plugin binding approvals in the default state dir were not imported into OPENCLAW_STATE_DIR automatically (${pluginBindingApprovals.sourcePath}); run \`openclaw doctor --fix\` to import them.`,
);
}
path.resolve(path.dirname(pluginBindingApprovals.sourcePath)) === path.resolve(stateDir) &&
fileExists(pluginBindingApprovals.sourcePath);
const currentConversationBindings = {
sourcePath: resolveLegacyCurrentConversationBindingsPath(stateDir),
};
@@ -613,9 +580,6 @@ export async function detectLegacyStateMigrations(params: {
if (channelPairing.hasLegacy) {
preview.push("- Channel pairing state: legacy JSON files → shared SQLite state");
}
if (execApprovals.hasLegacy) {
preview.push(`- Exec approvals: ${execApprovals.sourcePath}${execApprovals.targetPath}`);
}
if (channelPlans.length > 0) {
preview.push(...channelPlans.map(buildLegacyMigrationPreview));
}
@@ -704,9 +668,8 @@ export async function detectLegacyStateMigrations(params: {
subagentRegistry,
rescuePending,
channelPairing,
execApprovals,
warnings: pluginPlanWarnings,
notices,
notices: [],
preview,
};
}
@@ -921,7 +884,6 @@ export async function runLegacyStateMigrations(params: {
detected: detected.channelPairing,
env: { ...env, OPENCLAW_STATE_DIR: detected.stateDir },
});
const execApprovals = migrateLegacyExecApprovals(detected.execApprovals);
const preSessionChannelPlans = await runLegacyMigrationPlans(
detected.channelPlans.plans.filter((plan) => plan.kind === "plugin-state-import"),
);
@@ -978,7 +940,6 @@ export async function runLegacyStateMigrations(params: {
...subagentRegistry.changes,
...rescuePending.changes,
...channelPairing.changes,
...execApprovals.changes,
...preSessionChannelPlans.changes,
...pluginPlans.changes,
...sessions.changes,
@@ -1008,7 +969,6 @@ export async function runLegacyStateMigrations(params: {
...subagentRegistry.warnings,
...rescuePending.warnings,
...channelPairing.warnings,
...execApprovals.warnings,
...preSessionChannelPlans.warnings,
...pluginPlans.warnings,
...sessions.warnings,
@@ -1039,7 +999,6 @@ export async function autoMigrateLegacyState(params: {
log?: MigrationLogger;
now?: () => number;
recoverCorruptTargetStore?: boolean;
crossStateDirImports?: boolean;
}): Promise<{
migrated: boolean;
skipped: boolean;
@@ -1127,7 +1086,6 @@ export async function autoMigrateLegacyState(params: {
sessionStoreOwnership,
env,
homedir: params.homedir,
crossStateDirImports: params.crossStateDirImports,
});
const hasCustomAgentDir = env.OPENCLAW_AGENT_DIR?.trim() || env.PI_CODING_AGENT_DIR?.trim();
if (hasCustomAgentDir) {
@@ -1171,7 +1129,6 @@ export async function autoMigrateLegacyState(params: {
detected: detected.channelPairing,
env: { ...env, OPENCLAW_STATE_DIR: detected.stateDir },
});
const execApprovals = migrateLegacyExecApprovals(detected.execApprovals);
const preSessionChannelPlans = await runLegacyMigrationPlans(
detected.channelPlans.plans.filter((plan) => plan.kind === "plugin-state-import"),
);
@@ -1196,7 +1153,6 @@ export async function autoMigrateLegacyState(params: {
...pluginBindingApprovals.changes,
...currentConversationBindings.changes,
...channelPairing.changes,
...execApprovals.changes,
...preSessionChannelPlans.changes,
...pluginPlans.changes,
];
@@ -1217,7 +1173,6 @@ export async function autoMigrateLegacyState(params: {
...pluginBindingApprovals.warnings,
...currentConversationBindings.warnings,
...channelPairing.warnings,
...execApprovals.warnings,
...preSessionChannelPlans.warnings,
...pluginPlans.warnings,
];
@@ -1241,7 +1196,6 @@ export async function autoMigrateLegacyState(params: {
pluginBindingApprovals.changes.length > 0 ||
currentConversationBindings.changes.length > 0 ||
channelPairing.changes.length > 0 ||
execApprovals.changes.length > 0 ||
preSessionChannelPlans.changes.length > 0 ||
pluginPlans.changes.length > 0,
skipped: true,
@@ -1266,8 +1220,7 @@ export async function autoMigrateLegacyState(params: {
!detected.configHealth.hasLegacy &&
!detected.pluginBindingApprovals.hasLegacy &&
!detected.currentConversationBindings.hasLegacy &&
!detected.channelPairing.hasLegacy &&
!detected.execApprovals.hasLegacy
!detected.channelPairing.hasLegacy
) {
const changes = [
...stateDirResult.changes,
@@ -1338,7 +1291,6 @@ export async function autoMigrateLegacyState(params: {
detected: detected.channelPairing,
env: { ...env, OPENCLAW_STATE_DIR: detected.stateDir },
});
const execApprovals = migrateLegacyExecApprovals(detected.execApprovals);
const preSessionChannelPlans = await runLegacyMigrationPlans(
detected.channelPlans.plans.filter((plan) => plan.kind === "plugin-state-import"),
);
@@ -1376,7 +1328,6 @@ export async function autoMigrateLegacyState(params: {
...pluginBindingApprovals.changes,
...currentConversationBindings.changes,
...channelPairing.changes,
...execApprovals.changes,
...preSessionChannelPlans.changes,
...pluginPlans.changes,
...sessions.changes,
@@ -1401,7 +1352,6 @@ export async function autoMigrateLegacyState(params: {
...pluginBindingApprovals.warnings,
...currentConversationBindings.warnings,
...channelPairing.warnings,
...execApprovals.warnings,
...preSessionChannelPlans.warnings,
...pluginPlans.warnings,
...sessions.warnings,
@@ -1,234 +0,0 @@
import fs from "node:fs";
import path from "node:path";
import { isNamedProfile } from "../config/paths.js";
import { assertNoSymlinkParentsSync } from "./fs-safe-advanced.js";
import { expandHomePrefix, resolveRequiredHomeDir } from "./home-dir.js";
import { fileExists } from "./state-migrations.fs.js";
import type { LegacyExecApprovalsMigrationDetection } from "./state-migrations.types.js";
const EXEC_APPROVALS_FILENAME = "exec-approvals.json";
const EXEC_APPROVALS_SOCKET_FILENAME = "exec-approvals.sock";
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);
}
export 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()) &&
!isNamedProfile(params.env) &&
path.resolve(sourcePath) !== path.resolve(targetPath) &&
fileExists(sourcePath) &&
!fileExists(targetPath),
};
}
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;
}
export 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 };
}
+2 -2
View File
@@ -649,8 +649,8 @@ export function migrateLegacyPluginBindingApprovals(params: {
}): { changes: string[]; warnings: string[] } {
const changes: string[] = [];
const warnings: string[] = [];
// hasLegacy is the detection verdict (it stays false for suppressed
// cross-state-dir sources); fileExists re-checks for races since detection.
// Detection requires the source to belong to this state root; fileExists
// re-checks for races before the import mutates the same trust scope.
if (!params.detected.hasLegacy || !fileExists(params.detected.sourcePath)) {
return { changes, warnings };
}
+9 -38
View File
@@ -4,10 +4,6 @@ import path from "node:path";
import { resolveLegacyStateDirs, resolveNewStateDir, resolveStateDir } from "../config/paths.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { isWithinDir } from "./path-safety.js";
import {
detectLegacyExecApprovalsMigration,
migrateLegacyExecApprovals,
} from "./state-migrations.exec-approvals.js";
import { migrateLegacyInstalledPluginIndex } from "./state-migrations.plugin-state.js";
import { migrateLegacyTaskStateSidecars } from "./state-migrations.storage.js";
import type { MigrationLogger } from "./state-migrations.types.js";
@@ -309,7 +305,6 @@ export async function autoMigrateLegacyTaskStateSidecars(params: {
env?: NodeJS.ProcessEnv;
homedir?: () => string;
log?: MigrationLogger;
crossStateDirImports?: boolean;
}): Promise<{
migrated: boolean;
skipped: boolean;
@@ -324,45 +319,21 @@ export async function autoMigrateLegacyTaskStateSidecars(params: {
const stateDir = resolveStateDir(params.env ?? process.env, params.homedir);
const result = await migrateLegacyTaskStateSidecars({ stateDir });
const detectedExecApprovals = detectLegacyExecApprovalsMigration({
env: params.env ?? process.env,
homedir: params.homedir ?? os.homedir,
stateDir,
});
// Cross-state-dir sources need the explicit doctor opt-in (see
// detectLegacyStateMigrations); the implicit preflight must not archive
// files that belong to the default state dir.
const crossStateDirImports = params.crossStateDirImports === true;
const execApprovals = migrateLegacyExecApprovals(
crossStateDirImports ? detectedExecApprovals : { ...detectedExecApprovals, hasLegacy: false },
);
const notices: string[] = [];
if (detectedExecApprovals.hasLegacy && !crossStateDirImports) {
notices.push(
`Exec approvals in the default state dir were not imported into OPENCLAW_STATE_DIR automatically (${detectedExecApprovals.sourcePath} -> ${detectedExecApprovals.targetPath}); run \`openclaw doctor --fix\` to import them.`,
);
}
const changes = [...result.changes, ...execApprovals.changes];
const warnings = [...result.warnings, ...execApprovals.warnings];
const logger = params.log ?? createSubsystemLogger("state-migrations");
if (changes.length > 0) {
logger.info(`Auto-migrated legacy state:\n${changes.map((entry) => `- ${entry}`).join("\n")}`);
}
if (warnings.length > 0) {
logger.warn(
`Legacy state migration warnings:\n${warnings.map((entry) => `- ${entry}`).join("\n")}`,
if (result.changes.length > 0) {
logger.info(
`Auto-migrated legacy state:\n${result.changes.map((entry) => `- ${entry}`).join("\n")}`,
);
}
if (notices.length > 0) {
logger.info(
`Legacy state migration notes:\n${notices.map((entry) => `- ${entry}`).join("\n")}`,
if (result.warnings.length > 0) {
logger.warn(
`Legacy state migration warnings:\n${result.warnings.map((entry) => `- ${entry}`).join("\n")}`,
);
}
return {
migrated: changes.length > 0,
migrated: result.changes.length > 0,
skipped: false,
changes,
warnings,
...(notices.length > 0 ? { notices } : {}),
changes: result.changes,
warnings: result.warnings,
};
}
+3 -60
View File
@@ -2641,61 +2641,9 @@ describe("state migrations", () => {
);
});
it("migrates home-state-dir plugin binding approvals only with the cross-state-dir opt-in", async () => {
const root = await createTempDir();
const stateDir = path.join(root, "custom-state");
const env = createEnv(stateDir);
const cfg = createConfig();
const sourcePath = path.join(root, ".openclaw", "plugin-binding-approvals.json");
await fs.mkdir(path.dirname(sourcePath), { recursive: true });
await fs.writeFile(
sourcePath,
JSON.stringify({
version: 1,
approvals: [
{
pluginRoot: "/plugins/codex-a",
pluginId: "codex",
channel: "telegram",
accountId: "default",
approvedAt: 2345,
},
],
}),
"utf8",
);
const detected = await detectLegacyStateMigrations({
cfg,
env,
homedir: () => root,
crossStateDirImports: true,
});
expect(detected.pluginBindingApprovals).toMatchObject({
sourcePath,
hasLegacy: true,
});
const result = await runLegacyStateMigrations({ detected, config: cfg });
expect(result.warnings).toStrictEqual([]);
expect(result.changes).toContain("Migrated 1 plugin binding approval → shared SQLite state");
expect(readPluginBindingApprovalRows(env)).toEqual([
{
plugin_root: "/plugins/codex-a",
channel: "telegram",
account_id: "default",
plugin_id: "codex",
plugin_name: null,
approved_at: 2345,
},
]);
await expectMissingPath(sourcePath);
});
it("leaves home-state-dir plugin binding approvals alone without the cross-state-dir opt-in", async () => {
// Regression: an isolated gateway pointed at a temp OPENCLAW_STATE_DIR must
// not import (and archive) the default state dir's approvals file.
it("never imports home-state plugin approvals into a custom state dir", async () => {
// Regression: direct doctor repair follows the same trust boundary as
// automatic startup migration and cannot archive another state's policy.
const root = await createTempDir();
const stateDir = path.join(root, "custom-state");
const env = createEnv(stateDir);
@@ -2721,9 +2669,6 @@ describe("state migrations", () => {
sourcePath,
hasLegacy: false,
});
expect(detected.notices.join("\n")).toContain(
"Plugin binding approvals in the default state dir were not imported",
);
expect(detected.preview).not.toContain(
"- Plugin binding approvals: legacy JSON file → shared SQLite state",
);
@@ -2770,10 +2715,8 @@ describe("state migrations", () => {
cfg,
env,
homedir: () => root,
crossStateDirImports: true,
});
expect(detected.execApprovals.hasLegacy).toBe(false);
expect(detected.pluginBindingApprovals.hasLegacy).toBe(false);
expect(detected.notices).toEqual([]);
const result = await runLegacyStateMigrations({ detected, config: cfg, env });
-7
View File
@@ -123,18 +123,11 @@ export type LegacyStateDetection = {
};
rescuePending: LegacyRescuePendingDetection;
channelPairing: LegacyChannelPairingStateDetection;
execApprovals: {
sourcePath: string;
targetPath: string;
hasLegacy: boolean;
};
warnings: string[];
notices: string[];
preview: string[];
};
export type LegacyExecApprovalsMigrationDetection = LegacyStateDetection["execApprovals"];
export type MigrationLogger = {
info: (message: string) => void;
warn: (message: string) => void;
-2
View File
@@ -1091,7 +1091,6 @@ describe("runGatewayUpdate", () => {
expect(result.status).toBe("ok");
expect(doctorEnv?.OPENCLAW_UPDATE_IN_PROGRESS).toBe("1");
expect(doctorEnv?.OPENCLAW_DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS).toBe("1");
expect(doctorEnv?.OPENCLAW_UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR).toBe("1");
expect(doctorEnv?.OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE).toBe("1");
expect(doctorEnv?.OPENCLAW_UPDATE_PARENT_SUPPORTS_GATEWAY_RESTART).toBe("1");
@@ -2821,7 +2820,6 @@ describe("runGatewayUpdate", () => {
expect(calls).toContain(doctorCommand);
expect(result.steps.map((step) => step.name)).toContain("openclaw doctor");
expect(doctorEnv?.OPENCLAW_UPDATE_IN_PROGRESS).toBe("1");
expect(doctorEnv?.OPENCLAW_DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS).toBe("1");
expect(doctorEnv?.OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE).toBe("1");
expect(doctorEnv?.OPENCLAW_UPDATE_PARENT_SUPPORTS_GATEWAY_RESTART).toBe("1");
expect(doctorEnv?.OPENCLAW_UPDATE_PARENT_ALLOWS_GATEWAY_SERVICE_REPAIR).toBe("1");
-3
View File
@@ -6,7 +6,6 @@ import {
normalizeStringEntries,
uniqueStrings,
} from "@openclaw/normalization-core/string-normalization";
import { DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS_ENV } from "../commands/doctor-invocation.js";
import { resolveGatewayInstallEntrypoint } from "../daemon/gateway-entrypoint.js";
import { type CommandOptions, runCommandWithTimeout } from "../process/exec.js";
import {
@@ -1634,7 +1633,6 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise<
const doctorStep = await runStep(
step("openclaw doctor", doctorArgv, gitRoot, {
OPENCLAW_UPDATE_IN_PROGRESS: "1",
[DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS_ENV]: "1",
...(opts.deferConfiguredPluginInstallRepair
? { [UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR_ENV]: "1" }
: {}),
@@ -1830,7 +1828,6 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise<
timeoutMs,
env: {
OPENCLAW_UPDATE_IN_PROGRESS: "1",
[DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS_ENV]: "1",
[UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV]: "1",
[UPDATE_PARENT_SUPPORTS_GATEWAY_RESTART_ENV]: "1",
[UPDATE_PARENT_ALLOWS_GATEWAY_SERVICE_REPAIR_ENV]: allowGatewayServiceRepair
-4
View File
@@ -366,7 +366,6 @@ describe("watch-node script", () => {
"--non-interactive",
]);
expect(requireSpawnOptions(spawn, 1).stdio).toBe("inherit");
expect(requireSpawnEnv(spawn, 1).OPENCLAW_DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS).toBe("1");
doctor.emit("exit", 0, null);
await new Promise((resolve) => {
@@ -378,9 +377,6 @@ describe("watch-node script", () => {
expect(restartedGatewaySpawnCall[0]).toBe("/usr/local/bin/node");
expect(restartedGatewaySpawnCall[1]).toEqual(["scripts/run-node.mjs", "gateway", "--force"]);
expect(requireSpawnOptions(spawn, 2).stdio).toBe("inherit");
expect(
requireSpawnEnv(spawn, 2).OPENCLAW_DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS,
).toBeUndefined();
fakeProcess.emit("SIGINT");
const exitCode = await runPromise;