From 8fe4eeea9ee1e4fd2b9c756aea01213e47caac5e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 16 Jul 2026 08:57:32 -0700 Subject: [PATCH] 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 --- apps/.i18n/native-source.json | 2 +- .../Sources/OpenClaw/ExecApprovalsStore.swift | 255 +----------------- .../ExecApprovalsStoreRefactorTests.swift | 102 ++----- docs/refactor/database-first.md | 13 +- docs/tools/exec-approvals.md | 15 +- .../e2e/lib/doctor-install-switch/scenario.sh | 17 +- scripts/watch-node.mjs | 5 - src/cli/program/config-guard.test.ts | 18 +- src/cli/program/config-guard.ts | 29 +- src/cli/program/register.maintenance.test.ts | 29 +- src/cli/program/register.maintenance.ts | 2 - src/cli/update-cli.test.ts | 2 - .../update-cli/update-command-post-core.ts | 3 - src/cli/update-cli/update-command-service.ts | 3 - src/cli/update-cli/update-command.test.ts | 3 - src/commands/doctor-config-flow.test.ts | 24 -- src/commands/doctor-config-flow.ts | 1 - src/commands/doctor-config-preflight.ts | 10 - src/commands/doctor-invocation.ts | 16 -- src/commands/doctor-state-migrations.test.ts | 159 +---------- src/commands/doctor.e2e-harness.ts | 5 - src/commands/doctor.types.ts | 2 - src/flows/doctor-health-contributions.test.ts | 46 ---- src/flows/doctor-health-contributions.ts | 7 - src/infra/exec-approvals-store.test.ts | 32 ++- src/infra/exec-approvals.ts | 72 ----- src/infra/state-migrations.doctor.ts | 60 +---- src/infra/state-migrations.exec-approvals.ts | 234 ---------------- src/infra/state-migrations.runtime-state.ts | 4 +- src/infra/state-migrations.state-dir.ts | 47 +--- src/infra/state-migrations.test.ts | 63 +---- src/infra/state-migrations.types.ts | 7 - src/infra/update-runner.test.ts | 2 - src/infra/update-runner.ts | 3 - src/infra/watch-node.test.ts | 4 - 35 files changed, 88 insertions(+), 1208 deletions(-) delete mode 100644 src/commands/doctor-invocation.ts delete mode 100644 src/infra/state-migrations.exec-approvals.ts diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index f98fb6f9b093..8b815aeb8a48 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -29459,7 +29459,7 @@ }, { "kind": "conditional-branch", - "line": 1199, + "line": 946, "path": "apps/macos/Sources/OpenClaw/ExecApprovalsStore.swift", "source": "missing:\\(hash)", "surface": "apple", diff --git a/apps/macos/Sources/OpenClaw/ExecApprovalsStore.swift b/apps/macos/Sources/OpenClaw/ExecApprovalsStore.swift index a82c71ec2bde..187ec8472bdc 100644 --- a/apps/macos/Sources/OpenClaw/ExecApprovalsStore.swift +++ b/apps/macos/Sources/OpenClaw/ExecApprovalsStore.swift @@ -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 `.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)") diff --git a/apps/macos/Tests/OpenClawIPCTests/ExecApprovalsStoreRefactorTests.swift b/apps/macos/Tests/OpenClawIPCTests/ExecApprovalsStoreRefactorTests.swift index 1714d84bca8d..6e973d60adbc 100644 --- a/apps/macos/Tests/OpenClawIPCTests/ExecApprovalsStoreRefactorTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/ExecApprovalsStoreRefactorTests.swift @@ -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 diff --git a/docs/refactor/database-first.md b/docs/refactor/database-first.md index 736deda678b5..075bdc99c251 100644 --- a/docs/refactor/database-first.md +++ b/docs/refactor/database-first.md @@ -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 diff --git a/docs/tools/exec-approvals.md b/docs/tools/exec-approvals.md index a05ee8a1bf68..443f36e541f8 100644 --- a/docs/tools/exec-approvals.md +++ b/docs/tools/exec-approvals.md @@ -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: diff --git a/scripts/e2e/lib/doctor-install-switch/scenario.sh b/scripts/e2e/lib/doctor-install-switch/scenario.sh index b9bacacb1ba4..e87d5bee56df 100644 --- a/scripts/e2e/lib/doctor-install-switch/scenario.sh +++ b/scripts/e2e/lib/doctor-install-switch/scenario.sh @@ -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 diff --git a/scripts/watch-node.mjs b/scripts/watch-node.mjs index 0b8b4c736274..4f5b6498a0e1 100644 --- a/scripts/watch-node.mjs +++ b/scripts/watch-node.mjs @@ -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", }); diff --git a/src/cli/program/config-guard.test.ts b/src/cli/program/config-guard.test.ts index 8eb736fe52d7..90b2bb725dbd 100644 --- a/src/cli/program/config-guard.test.ts +++ b/src/cli/program/config-guard.test.ts @@ -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); diff --git a/src/cli/program/config-guard.ts b/src/cli/program/config-guard.ts index 7f0f81605bbc..8d0d8f02018e 100644 --- a/src/cli/program/config-guard.ts +++ b/src/cli/program/config-guard.ts @@ -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 } : {}), diff --git a/src/cli/program/register.maintenance.test.ts b/src/cli/program/register.maintenance.test.ts index 2aa2087b2b24..daf5350cbefa 100644 --- a/src/cli/program/register.maintenance.test.ts +++ b/src/cli/program/register.maintenance.test.ts @@ -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 () => { diff --git a/src/cli/program/register.maintenance.ts b/src/cli/program/register.maintenance.ts index ba72eaafca79..f28d99ce6a92 100644 --- a/src/cli/program/register.maintenance.ts +++ b/src/cli/program/register.maintenance.ts @@ -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); }); diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index 2e0a9b327102..bb93a8c431c1 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -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({ diff --git a/src/cli/update-cli/update-command-post-core.ts b/src/cli/update-cli/update-command-post-core.ts index a6705df32dba..a73984afef02 100644 --- a/src/cli/update-cli/update-command-post-core.ts +++ b/src/cli/update-cli/update-command-post-core.ts @@ -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 diff --git a/src/cli/update-cli/update-command-service.ts b/src/cli/update-cli/update-command-service.ts index 55ccad3ba01d..5b0a64c2e123 100644 --- a/src/cli/update-cli/update-command-service.ts +++ b/src/cli/update-cli/update-command-service.ts @@ -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)}`)); diff --git a/src/cli/update-cli/update-command.test.ts b/src/cli/update-cli/update-command.test.ts index b745c2dfbddb..3be97d493e0a 100644 --- a/src/cli/update-cli/update-command.test.ts +++ b/src/cli/update-cli/update-command.test.ts @@ -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"); }); diff --git a/src/commands/doctor-config-flow.test.ts b/src/commands/doctor-config-flow.test.ts index 31610c2b7103..71757e910f6b 100644 --- a/src/commands/doctor-config-flow.test.ts +++ b/src/commands/doctor-config-flow.test.ts @@ -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: { diff --git a/src/commands/doctor-config-flow.ts b/src/commands/doctor-config-flow.ts index bab209e1bc89..ea400bf829fd 100644 --- a/src/commands/doctor-config-flow.ts +++ b/src/commands/doctor-config-flow.ts @@ -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; diff --git a/src/commands/doctor-config-preflight.ts b/src/commands/doctor-config-preflight.ts index b32a600c6364..a693f08690d3 100644 --- a/src/commands/doctor-config-preflight.ts +++ b/src/commands/doctor-config-preflight.ts @@ -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 { 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, }), ); } diff --git a/src/commands/doctor-invocation.ts b/src/commands/doctor-invocation.ts deleted file mode 100644 index ed936152e227..000000000000 --- a/src/commands/doctor-invocation.ts +++ /dev/null @@ -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]) - ); -} diff --git a/src/commands/doctor-state-migrations.test.ts b/src/commands/doctor-state-migrations.test.ts index 38bc436dbbe4..08fcb082472a 100644 --- a/src/commands/doctor-state-migrations.test.ts +++ b/src/commands/doctor-state-migrations.test.ts @@ -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; - agents?: Record> }>; - }; - 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; - }; - 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; - }; - 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); diff --git a/src/commands/doctor.e2e-harness.ts b/src/commands/doctor.e2e-harness.ts index 090a32fd66db..72a45ea83006 100644 --- a/src/commands/doctor.e2e-harness.ts +++ b/src/commands/doctor.e2e-harness.ts @@ -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: [], diff --git a/src/commands/doctor.types.ts b/src/commands/doctor.types.ts index 742b05f99c99..a98b9bb90982 100644 --- a/src/commands/doctor.types.ts +++ b/src/commands/doctor.types.ts @@ -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; }; diff --git a/src/flows/doctor-health-contributions.test.ts b/src/flows/doctor-health-contributions.test.ts index 183df6c5d237..ca99b2210c55 100644 --- a/src/flows/doctor-health-contributions.test.ts +++ b/src/flows/doctor-health-contributions.test.ts @@ -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: [] }; diff --git a/src/flows/doctor-health-contributions.ts b/src/flows/doctor-health-contributions.ts index fcb857411e72..be2b18751431 100644 --- a/src/flows/doctor-health-contributions.ts +++ b/src/flows/doctor-health-contributions.ts @@ -518,16 +518,9 @@ async function runLegacyStateHealth(ctx: DoctorHealthFlowContext): Promise 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"); diff --git a/src/infra/exec-approvals-store.test.ts b/src/infra/exec-approvals-store.test.ts index 2b411bcba35a..9d325ba29f1d 100644 --- a/src/infra/exec-approvals-store.test.ts +++ b/src/infra/exec-approvals-store.test.ts @@ -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", () => { diff --git a/src/infra/exec-approvals.ts b/src/infra/exec-approvals.ts index 4e3fe6b6cf0b..34ae1c9d8907 100644 --- a/src/infra/exec-approvals.ts +++ b/src/infra/exec-approvals.ts @@ -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 { - 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 { 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), diff --git a/src/infra/state-migrations.doctor.ts b/src/infra/state-migrations.doctor.ts index 527166c45aea..ebdec2d4d4df 100644 --- a/src/infra/state-migrations.doctor.ts +++ b/src/infra/state-migrations.doctor.ts @@ -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 { 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, diff --git a/src/infra/state-migrations.exec-approvals.ts b/src/infra/state-migrations.exec-approvals.ts deleted file mode 100644 index 30964c7b88fc..000000000000 --- a/src/infra/state-migrations.exec-approvals.ts +++ /dev/null @@ -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 { - 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 = { ...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 }; -} diff --git a/src/infra/state-migrations.runtime-state.ts b/src/infra/state-migrations.runtime-state.ts index 8c26c2151d85..a6b2f0c45dbb 100644 --- a/src/infra/state-migrations.runtime-state.ts +++ b/src/infra/state-migrations.runtime-state.ts @@ -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 }; } diff --git a/src/infra/state-migrations.state-dir.ts b/src/infra/state-migrations.state-dir.ts index da13f5087f01..0dc25a09bafd 100644 --- a/src/infra/state-migrations.state-dir.ts +++ b/src/infra/state-migrations.state-dir.ts @@ -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, }; } diff --git a/src/infra/state-migrations.test.ts b/src/infra/state-migrations.test.ts index b9faf80bbf1d..8393177a9c9c 100644 --- a/src/infra/state-migrations.test.ts +++ b/src/infra/state-migrations.test.ts @@ -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 }); diff --git a/src/infra/state-migrations.types.ts b/src/infra/state-migrations.types.ts index 705387d30420..723a564d9fd1 100644 --- a/src/infra/state-migrations.types.ts +++ b/src/infra/state-migrations.types.ts @@ -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; diff --git a/src/infra/update-runner.test.ts b/src/infra/update-runner.test.ts index 56a777c6a699..1f7bc9133c4d 100644 --- a/src/infra/update-runner.test.ts +++ b/src/infra/update-runner.test.ts @@ -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"); diff --git a/src/infra/update-runner.ts b/src/infra/update-runner.ts index ee7e93f5c23e..a88f8702b1ff 100644 --- a/src/infra/update-runner.ts +++ b/src/infra/update-runner.ts @@ -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 diff --git a/src/infra/watch-node.test.ts b/src/infra/watch-node.test.ts index ac4058a022f5..2ba80b9e6c85 100644 --- a/src/infra/watch-node.test.ts +++ b/src/infra/watch-node.test.ts @@ -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;