mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
fix(exec): stop isolated state dirs from moving live approvals (#108742)
* fix(exec): isolate approval state directories * chore: drop release-owned changelog entry * chore: refresh native i18n inventory * fix(ci): pin XcodeGen for Periphery scans
This commit is contained in:
@@ -29459,7 +29459,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 1199,
|
||||
"line": 946,
|
||||
"path": "apps/macos/Sources/OpenClaw/ExecApprovalsStore.swift",
|
||||
"source": "missing:\\(hash)",
|
||||
"surface": "apple",
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import CryptoKit
|
||||
import Darwin
|
||||
import Foundation
|
||||
import OSLog
|
||||
import Security
|
||||
@@ -14,12 +13,6 @@ enum ExecApprovalsStore {
|
||||
private static let defaultAutoAllowSkills = false
|
||||
private static let secureStateDirPermissions = 0o700
|
||||
|
||||
private enum LegacyMigrationResult {
|
||||
case notNeeded
|
||||
case migrated
|
||||
case blocked
|
||||
}
|
||||
|
||||
/// Match the TypeScript writer's `<approvals>.lock` protocol. Both processes
|
||||
/// must cover the complete read-modify-write transaction or a stale native
|
||||
/// usage update can restore policy that an administrator just revoked.
|
||||
@@ -68,57 +61,6 @@ enum ExecApprovalsStore {
|
||||
return URL(fileURLWithPath: expanded, isDirectory: true).standardizedFileURL
|
||||
}
|
||||
|
||||
private static func legacyStateDirURLs() -> [URL] {
|
||||
if OpenClawEnv.path("OPENCLAW_HOME") != nil {
|
||||
// OPENCLAW_HOME replaces the OS home; crossing back would mutate
|
||||
// the real default profile during an isolated run.
|
||||
return [
|
||||
self.trustedRootURL()
|
||||
.appendingPathComponent(".openclaw", isDirectory: true),
|
||||
]
|
||||
}
|
||||
return [
|
||||
FileManager().homeDirectoryForCurrentUser
|
||||
.appendingPathComponent(".openclaw", isDirectory: true),
|
||||
]
|
||||
}
|
||||
|
||||
private static func legacyFileURLIfPending() -> URL? {
|
||||
guard OpenClawEnv.path("OPENCLAW_STATE_DIR") != nil || OpenClawEnv.path("OPENCLAW_HOME") != nil
|
||||
else { return nil }
|
||||
// Named profiles are isolated. Bare state-dir relocation keeps the
|
||||
// shipped migration for users who moved their primary state root.
|
||||
if let profile = OpenClawEnv.path("OPENCLAW_PROFILE"),
|
||||
profile.lowercased() != "default"
|
||||
{
|
||||
return nil
|
||||
}
|
||||
let targetURL = self.fileURL()
|
||||
for stateDirURL in self.legacyStateDirURLs() {
|
||||
let legacyURL = stateDirURL
|
||||
.appendingPathComponent("exec-approvals.json", isDirectory: false)
|
||||
guard legacyURL.standardizedFileURL.path != targetURL.standardizedFileURL.path else {
|
||||
continue
|
||||
}
|
||||
guard ExecApprovalsFileIO.pathExistsNoFollow(legacyURL) else { continue }
|
||||
guard !ExecApprovalsFileIO.pathExistsNoFollow(targetURL) else { return nil }
|
||||
return legacyURL
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func unmigratedLegacyFallbackFile() -> ExecApprovalsFile {
|
||||
ExecApprovalsFile(
|
||||
version: 1,
|
||||
socket: nil,
|
||||
defaults: ExecApprovalsDefaults(
|
||||
security: .deny,
|
||||
ask: .always,
|
||||
askFallback: .deny,
|
||||
autoAllowSkills: false),
|
||||
agents: [:])
|
||||
}
|
||||
|
||||
private static func failClosedFallbackFile() -> ExecApprovalsFile {
|
||||
ExecApprovalsFile(
|
||||
version: 1,
|
||||
@@ -131,174 +73,6 @@ enum ExecApprovalsStore {
|
||||
agents: [:])
|
||||
}
|
||||
|
||||
private static func isLegacyDefaultSocketPath(_ raw: String, legacyFileURL: URL) -> Bool {
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty {
|
||||
return true
|
||||
}
|
||||
let expanded = self.expandPath(trimmed)
|
||||
let legacySocket = legacyFileURL.deletingLastPathComponent()
|
||||
.appendingPathComponent("exec-approvals.sock", isDirectory: false)
|
||||
.path
|
||||
return URL(fileURLWithPath: expanded).standardizedFileURL.path
|
||||
== URL(fileURLWithPath: legacySocket).standardizedFileURL.path
|
||||
}
|
||||
|
||||
private static func archiveMigratedLegacyFile(_ legacyURL: URL) throws -> URL {
|
||||
let manager = FileManager()
|
||||
var archiveURL = URL(fileURLWithPath: "\(legacyURL.path).migrated")
|
||||
if manager.fileExists(atPath: archiveURL.path) {
|
||||
archiveURL = URL(fileURLWithPath: "\(archiveURL.path)-\(UUID().uuidString)")
|
||||
}
|
||||
try manager.moveItem(at: legacyURL, to: archiveURL)
|
||||
return archiveURL
|
||||
}
|
||||
|
||||
private static func writeMigratedFileExclusively(_ data: Data, to targetURL: URL) throws -> Bool {
|
||||
let tempURL = targetURL.deletingLastPathComponent()
|
||||
.appendingPathComponent(".exec-approvals.migration.\(UUID().uuidString)")
|
||||
let fd = open(tempURL.path, O_WRONLY | O_CREAT | O_EXCL, S_IRUSR | S_IWUSR)
|
||||
if fd == -1 {
|
||||
throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)
|
||||
}
|
||||
var closed = false
|
||||
defer {
|
||||
if !closed {
|
||||
close(fd)
|
||||
}
|
||||
}
|
||||
do {
|
||||
try data.withUnsafeBytes { rawBuffer in
|
||||
guard let base = rawBuffer.baseAddress else { return }
|
||||
var offset = 0
|
||||
while offset < rawBuffer.count {
|
||||
let written = Darwin.write(
|
||||
fd,
|
||||
base.advanced(by: offset),
|
||||
rawBuffer.count - offset)
|
||||
if written < 0 {
|
||||
throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)
|
||||
}
|
||||
offset += written
|
||||
}
|
||||
}
|
||||
close(fd)
|
||||
closed = true
|
||||
// Publish the complete 0600 temp inode atomically without replacing
|
||||
// policy a mixed-version writer may have created meanwhile.
|
||||
let renamed = renamex_np(tempURL.path, targetURL.path, UInt32(RENAME_EXCL))
|
||||
if renamed == -1 {
|
||||
let code = errno
|
||||
try? FileManager().removeItem(at: tempURL)
|
||||
if code == EEXIST {
|
||||
return false
|
||||
}
|
||||
throw POSIXError(POSIXErrorCode(rawValue: code) ?? .EIO)
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
try? FileManager().removeItem(at: tempURL)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private static func migrateLegacyFileIfNeeded() -> LegacyMigrationResult {
|
||||
guard let legacyURL = legacyFileURLIfPending() else { return .notNeeded }
|
||||
let targetURL = self.fileURL()
|
||||
do {
|
||||
let trustedRoot = self.trustedRootURL()
|
||||
try ExecApprovalsFileIO.assertSafeParentChain(of: targetURL, trustedRoot: trustedRoot)
|
||||
let effectiveLegacyDir = trustedRoot.appendingPathComponent(".openclaw", isDirectory: true)
|
||||
let legacyRoot = legacyURL.deletingLastPathComponent().standardizedFileURL.path
|
||||
== effectiveLegacyDir.standardizedFileURL.path
|
||||
? trustedRoot
|
||||
: FileManager().homeDirectoryForCurrentUser
|
||||
// ensureFileUnlocked already owns the target lock. Always take the
|
||||
// legacy lock second so migration cannot race an older writer and
|
||||
// two migrating processes cannot deadlock with opposite lock order.
|
||||
return try ExecApprovalsFileIO.withLock(
|
||||
fileURL: legacyURL,
|
||||
trustedRoot: legacyRoot)
|
||||
{
|
||||
if ExecApprovalsFileIO.pathExistsNoFollow(targetURL) {
|
||||
return .notNeeded
|
||||
}
|
||||
guard ExecApprovalsFileIO.pathExistsNoFollow(legacyURL) else {
|
||||
throw NSError(domain: "ExecApprovals", code: 10, userInfo: [
|
||||
NSLocalizedDescriptionKey: "legacy exec approvals disappeared during migration",
|
||||
])
|
||||
}
|
||||
return try self.migrateLegacyFileLocked(
|
||||
legacyURL: legacyURL,
|
||||
legacyRoot: legacyRoot,
|
||||
targetURL: targetURL,
|
||||
trustedRoot: trustedRoot)
|
||||
}
|
||||
} catch {
|
||||
self.logger
|
||||
.error(
|
||||
"exec approvals legacy migration failed: \(error.localizedDescription, privacy: .public)")
|
||||
return .blocked
|
||||
}
|
||||
}
|
||||
|
||||
private static func migrateLegacyFileLocked(
|
||||
legacyURL: URL,
|
||||
legacyRoot: URL,
|
||||
targetURL: URL,
|
||||
trustedRoot: URL) throws -> LegacyMigrationResult
|
||||
{
|
||||
guard let legacy = try ExecApprovalsFileIO.read(at: legacyURL, trustedRoot: legacyRoot) else {
|
||||
throw NSError(domain: "ExecApprovals", code: 10, userInfo: [
|
||||
NSLocalizedDescriptionKey: "legacy exec approvals disappeared during migration",
|
||||
])
|
||||
}
|
||||
let data = legacy.data
|
||||
guard self.hasValidPersistedStructure(data) else {
|
||||
throw NSError(domain: "ExecApprovals", code: 13, userInfo: [
|
||||
NSLocalizedDescriptionKey: "invalid legacy approvals structure",
|
||||
])
|
||||
}
|
||||
var file = try JSONDecoder().decode(ExecApprovalsFile.self, from: data)
|
||||
guard file.version == 1 else {
|
||||
throw NSError(domain: "ExecApprovals", code: 11, userInfo: [
|
||||
NSLocalizedDescriptionKey: "unsupported legacy approvals version",
|
||||
])
|
||||
}
|
||||
file = self.normalizeIncoming(file)
|
||||
let rawSocketPath = file.socket?.path?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if self.isLegacyDefaultSocketPath(rawSocketPath, legacyFileURL: legacyURL) {
|
||||
if file.socket == nil {
|
||||
file.socket = ExecApprovalsSocketConfig(path: nil, token: nil)
|
||||
}
|
||||
file.socket?.path = self.socketPath()
|
||||
}
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
let migrated = try encoder.encode(file)
|
||||
try self.ensureSecureStateDirectory()
|
||||
try FileManager().createDirectory(
|
||||
at: targetURL.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true)
|
||||
try ExecApprovalsFileIO.assertSafeParentChain(of: targetURL, trustedRoot: trustedRoot)
|
||||
if ExecApprovalsFileIO.pathExistsNoFollow(targetURL) {
|
||||
return .notNeeded
|
||||
}
|
||||
let created = try writeMigratedFileExclusively(migrated, to: targetURL)
|
||||
if !created {
|
||||
return .notNeeded
|
||||
}
|
||||
do {
|
||||
_ = try self.archiveMigratedLegacyFile(legacyURL)
|
||||
} catch {
|
||||
self.logger
|
||||
.warning(
|
||||
"exec approvals legacy archive failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
return .migrated
|
||||
}
|
||||
|
||||
static func normalizeIncoming(_ file: ExecApprovalsFile) -> ExecApprovalsFile {
|
||||
let socketPath = file.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let token = file.socket?.token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
@@ -348,14 +122,6 @@ enum ExecApprovalsStore {
|
||||
}
|
||||
|
||||
private static func readSnapshotUnlocked() throws -> ExecApprovalsSnapshot {
|
||||
if self.legacyFileURLIfPending() != nil {
|
||||
let file = self.unmigratedLegacyFallbackFile()
|
||||
return ExecApprovalsSnapshot(
|
||||
path: self.fileURL().path,
|
||||
exists: false,
|
||||
hash: self.hashRaw(nil),
|
||||
file: file)
|
||||
}
|
||||
let url = self.fileURL()
|
||||
guard let current = try ExecApprovalsFileIO.read(at: url, trustedRoot: self.trustedRootURL()) else {
|
||||
return ExecApprovalsSnapshot(
|
||||
@@ -375,9 +141,6 @@ enum ExecApprovalsStore {
|
||||
}
|
||||
|
||||
static func loadFile() -> ExecApprovalsFile {
|
||||
if self.legacyFileURLIfPending() != nil {
|
||||
return self.ensureFile()
|
||||
}
|
||||
do {
|
||||
return try self.withWriteLock {
|
||||
self.loadFileUnlocked()
|
||||
@@ -537,16 +300,6 @@ enum ExecApprovalsStore {
|
||||
}
|
||||
|
||||
private static func ensureFileUnlocked() throws -> ExecApprovalsFile {
|
||||
if self.legacyFileURLIfPending() != nil {
|
||||
switch self.migrateLegacyFileIfNeeded() {
|
||||
case .migrated, .notNeeded:
|
||||
break
|
||||
case .blocked:
|
||||
throw NSError(domain: "ExecApprovals", code: 18, userInfo: [
|
||||
NSLocalizedDescriptionKey: "legacy exec approvals migration blocked",
|
||||
])
|
||||
}
|
||||
}
|
||||
try self.ensureSecureStateDirectory()
|
||||
let url = self.fileURL()
|
||||
let current = try ExecApprovalsFileIO.read(at: url, trustedRoot: self.trustedRootURL())
|
||||
@@ -585,9 +338,6 @@ enum ExecApprovalsStore {
|
||||
return try self.withWriteLock {
|
||||
// A conditional write must not create or normalize policy state
|
||||
// before it proves the caller still owns the observed snapshot.
|
||||
guard self.legacyFileURLIfPending() == nil else {
|
||||
return .baseHashUnavailable
|
||||
}
|
||||
let snapshot = try self.readSnapshotUnlocked()
|
||||
let expected = baseHash?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if snapshot.exists {
|
||||
@@ -663,10 +413,7 @@ enum ExecApprovalsStore {
|
||||
let file: ExecApprovalsFile
|
||||
do {
|
||||
file = try self.withWriteLock {
|
||||
guard self.legacyFileURLIfPending() == nil else {
|
||||
return self.unmigratedLegacyFallbackFile()
|
||||
}
|
||||
return self.loadFileUnlocked()
|
||||
self.loadFileUnlocked()
|
||||
}
|
||||
} catch {
|
||||
self.logger.warning("exec approvals read-only lock failed: \(error.localizedDescription, privacy: .public)")
|
||||
|
||||
@@ -254,7 +254,7 @@ struct ExecApprovalsStoreRefactorTests {
|
||||
}
|
||||
|
||||
@Test
|
||||
func `blocked legacy migration cannot create or mutate current policy`() async throws {
|
||||
func `malformed default policy does not block custom state mutations`() async throws {
|
||||
try await self.withTempHomeAndStateDir { home, _ in
|
||||
let legacyDir = home.appendingPathComponent(".openclaw", isDirectory: true)
|
||||
try FileManager().createDirectory(at: legacyDir, withIntermediateDirectories: true)
|
||||
@@ -263,26 +263,25 @@ struct ExecApprovalsStoreRefactorTests {
|
||||
try malformed.write(to: legacyURL)
|
||||
|
||||
let readOnly = ExecApprovalsStore.resolveReadOnly(agentId: "main")
|
||||
#expect(readOnly.agent.security == .deny)
|
||||
#expect(readOnly.agent.ask == .always)
|
||||
#expect(readOnly.agent.security == .full)
|
||||
#expect(readOnly.agent.ask == .off)
|
||||
|
||||
let ensured = ExecApprovalsStore.ensureFile()
|
||||
#expect(ensured.defaults?.security == .deny)
|
||||
#expect(ensured.defaults?.ask == .off)
|
||||
#expect(!FileManager().fileExists(atPath: ExecApprovalsStore.fileURL().path))
|
||||
#expect(ensured.socket?.token?.isEmpty == false)
|
||||
#expect(FileManager().fileExists(atPath: ExecApprovalsStore.fileURL().path))
|
||||
|
||||
let mutation = ExecApprovalsStore.updateDefaults { $0.security = .full }
|
||||
guard case .failure(.unavailable) = mutation else {
|
||||
Issue.record("expected blocked migration mutation failure")
|
||||
let mutation = ExecApprovalsStore.updateDefaults { $0.security = .allowlist }
|
||||
guard case .success = mutation else {
|
||||
Issue.record("expected custom state mutation to succeed")
|
||||
return
|
||||
}
|
||||
#expect(!FileManager().fileExists(atPath: ExecApprovalsStore.fileURL().path))
|
||||
#expect(try Data(contentsOf: legacyURL) == malformed)
|
||||
#expect(!FileManager().fileExists(atPath: "\(legacyURL.path).migrated"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `symlinked legacy policy cannot seed the current store`() async throws {
|
||||
func `symlinked default policy cannot seed the custom store`() async throws {
|
||||
try await self.withTempHomeAndStateDir { home, _ in
|
||||
let legacyDir = home.appendingPathComponent(".openclaw", isDirectory: true)
|
||||
try FileManager().createDirectory(at: legacyDir, withIntermediateDirectories: true)
|
||||
@@ -294,10 +293,10 @@ struct ExecApprovalsStoreRefactorTests {
|
||||
|
||||
let ensured = ExecApprovalsStore.ensureFile()
|
||||
|
||||
#expect(ensured.defaults?.security == .deny)
|
||||
#expect(ensured.defaults?.ask == .off)
|
||||
#expect(!FileManager().fileExists(atPath: ExecApprovalsStore.fileURL().path))
|
||||
#expect(ensured.socket?.token?.isEmpty == false)
|
||||
#expect(FileManager().fileExists(atPath: ExecApprovalsStore.fileURL().path))
|
||||
#expect(try Data(contentsOf: linkedTarget) == permissive)
|
||||
#expect(try FileManager().destinationOfSymbolicLink(atPath: legacyURL.path) == linkedTarget.path)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1131,7 +1130,7 @@ struct ExecApprovalsStoreRefactorTests {
|
||||
}
|
||||
extension ExecApprovalsStoreRefactorTests {
|
||||
@Test
|
||||
func `ensure file migrates default approvals into custom state dir`() async throws {
|
||||
func `ensure file keeps custom state isolated from default approvals`() async throws {
|
||||
try await self.withTempHomeAndStateDir { home, stateDir in
|
||||
let legacyDir = home.appendingPathComponent(".openclaw", isDirectory: true)
|
||||
try FileManager().createDirectory(
|
||||
@@ -1158,6 +1157,7 @@ extension ExecApprovalsStoreRefactorTests {
|
||||
}
|
||||
"""
|
||||
try Data(legacyJson.utf8).write(to: legacyFile)
|
||||
let legacyBefore = try Data(contentsOf: legacyFile)
|
||||
|
||||
let file = ExecApprovalsStore.ensureFile()
|
||||
let targetURL = ExecApprovalsStore.fileURL()
|
||||
@@ -1168,12 +1168,10 @@ extension ExecApprovalsStoreRefactorTests {
|
||||
#expect(targetURL.path == expectedFileURL.path)
|
||||
#expect(FileManager().fileExists(atPath: targetURL.path))
|
||||
#expect(file.socket?.path == ExecApprovalsStore.socketPath())
|
||||
#expect(file.socket?.token == "legacy-token")
|
||||
#expect(file.defaults?.security == .deny)
|
||||
#expect(file.defaults?.ask == .always)
|
||||
#expect(file.agents?["main"]?.allowlist?.map(\.pattern) == ["git status"])
|
||||
#expect(!FileManager().fileExists(atPath: legacyFile.path))
|
||||
#expect(FileManager().fileExists(atPath: "\(legacyFile.path).migrated"))
|
||||
#expect(file.socket?.token != "legacy-token")
|
||||
#expect(file.agents?["main"]?.allowlist == nil)
|
||||
#expect(try Data(contentsOf: legacyFile) == legacyBefore)
|
||||
#expect(!FileManager().fileExists(atPath: "\(legacyFile.path).migrated"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1204,68 +1202,6 @@ extension ExecApprovalsStoreRefactorTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `legacy writer revocation wins before migration publishes and archives`() async throws {
|
||||
try await self.withTempHomeAndStateDir { home, _ in
|
||||
let legacyDir = home.appendingPathComponent(".openclaw", isDirectory: true)
|
||||
try FileManager().createDirectory(
|
||||
at: legacyDir,
|
||||
withIntermediateDirectories: true)
|
||||
let legacyFile = legacyDir.appendingPathComponent("exec-approvals.json")
|
||||
let legacyLock = legacyDir.appendingPathComponent("exec-approvals.json.lock")
|
||||
let initial = Data(
|
||||
#"{"version":1,"agents":{"main":{"allowlist":[{"id":"revoked-entry","pattern":"/usr/bin/echo"}]}}}"#
|
||||
.utf8)
|
||||
let revoked = Data(
|
||||
#"{"version":1,"agents":{"main":{"allowlist":[]}}}"#.utf8)
|
||||
try initial.write(to: legacyFile)
|
||||
try Data("legacy writer owns lock".utf8).write(to: legacyLock)
|
||||
|
||||
// An older writer revokes under its sidecar lock. Migration must
|
||||
// wait, then read and archive only the post-revocation policy.
|
||||
DispatchQueue.global().asyncAfter(deadline: .now() + .milliseconds(100)) {
|
||||
try? revoked.write(to: legacyFile, options: [.atomic])
|
||||
try? FileManager().removeItem(at: legacyLock)
|
||||
}
|
||||
|
||||
let migrated = ExecApprovalsStore.ensureFile()
|
||||
|
||||
#expect(ExecApprovalsStore.resolve(agentId: "main").allowlist.isEmpty)
|
||||
#expect(migrated.agents?["main"]?.allowlist?.isEmpty != false)
|
||||
#expect(!FileManager().fileExists(atPath: legacyFile.path))
|
||||
let archived = URL(fileURLWithPath: "\(legacyFile.path).migrated")
|
||||
#expect(FileManager().fileExists(atPath: archived.path))
|
||||
let archivedFile = try JSONDecoder().decode(
|
||||
ExecApprovalsFile.self,
|
||||
from: Data(contentsOf: archived))
|
||||
#expect(archivedFile.agents?["main"]?.allowlist?.isEmpty == true)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `legacy migration fails closed while legacy writer lock is held`() async throws {
|
||||
try await self.withTempHomeAndStateDir { home, _ in
|
||||
let legacyDir = home.appendingPathComponent(".openclaw", isDirectory: true)
|
||||
try FileManager().createDirectory(
|
||||
at: legacyDir,
|
||||
withIntermediateDirectories: true)
|
||||
let legacyFile = legacyDir.appendingPathComponent("exec-approvals.json")
|
||||
let legacyLock = legacyDir.appendingPathComponent("exec-approvals.json.lock")
|
||||
let permissive = Data(
|
||||
#"{"version":1,"defaults":{"security":"full","ask":"off"}}"#.utf8)
|
||||
try permissive.write(to: legacyFile)
|
||||
try Data("legacy writer owns lock".utf8).write(to: legacyLock)
|
||||
|
||||
let ensured = ExecApprovalsStore.ensureFile()
|
||||
|
||||
#expect(ensured.defaults?.security == .deny)
|
||||
#expect(ensured.defaults?.ask == .off)
|
||||
#expect(!FileManager().fileExists(atPath: ExecApprovalsStore.fileURL().path))
|
||||
#expect(try Data(contentsOf: legacyFile) == permissive)
|
||||
#expect(FileManager().fileExists(atPath: legacyLock.path))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `add allowlist entries accepts basename pattern`() async throws {
|
||||
try await self.withTempStateDir { _ in
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -93,15 +93,12 @@ The default approval socket follows the same root:
|
||||
`$OPENCLAW_STATE_DIR/exec-approvals.sock`, or
|
||||
`~/.openclaw/exec-approvals.sock` when the variable is unset.
|
||||
|
||||
Releases before 2026.6.6 always kept the file in `~/.openclaw`. If
|
||||
`OPENCLAW_STATE_DIR` points somewhere else and an approvals file still exists
|
||||
in the default directory, run `openclaw doctor --fix` directly once to import
|
||||
it into the state directory (the original is archived with a `.migrated`
|
||||
suffix). Interactive doctor can also preview and confirm the import. Automated
|
||||
update and Gateway watch repair runs never import across state directories: a
|
||||
temporary or staging state directory must not capture the default
|
||||
installation's approvals. The same boundary applies to legacy
|
||||
`plugin-binding-approvals.json` imports into shared SQLite state.
|
||||
State directories are independent trust scopes. When `OPENCLAW_STATE_DIR`
|
||||
points somewhere else, OpenClaw never imports or archives
|
||||
`~/.openclaw/exec-approvals.json`; configure approvals separately for the
|
||||
custom state directory. Doctor also imports legacy
|
||||
`plugin-binding-approvals.json` only when it belongs to the active state
|
||||
directory.
|
||||
|
||||
Example schema:
|
||||
|
||||
|
||||
@@ -55,7 +55,6 @@ git_cli="$git_root/openclaw.mjs"
|
||||
|
||||
package_version="$(node -p "require(\"$npm_root/package.json\").version")"
|
||||
update_doctor_env="OPENCLAW_UPDATE_IN_PROGRESS=1"
|
||||
update_doctor_env+=" OPENCLAW_DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS=1"
|
||||
update_doctor_env+=" OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE=1"
|
||||
update_doctor_env+=" OPENCLAW_UPDATE_PARENT_SUPPORTS_GATEWAY_RESTART=1"
|
||||
update_doctor_env+=" OPENCLAW_UPDATE_PARENT_ALLOWS_GATEWAY_SERVICE_REPAIR=1"
|
||||
@@ -162,7 +161,6 @@ run_flow() {
|
||||
fi
|
||||
|
||||
assert_entrypoint "$unit_path" "$doctor_expected"
|
||||
assert_no_env_key "$unit_path" "OPENCLAW_DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS"
|
||||
}
|
||||
|
||||
run_flow \
|
||||
@@ -239,7 +237,6 @@ run_cross_state_approval_flow() {
|
||||
test "$(plugin_binding_approval_count "$state_database")" = "0"
|
||||
|
||||
if ! openclaw_e2e_maybe_timeout "$command_timeout" env \
|
||||
-u OPENCLAW_DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS \
|
||||
-u OPENCLAW_UPDATE_IN_PROGRESS \
|
||||
OPENCLAW_STATE_DIR="$custom_state_dir" \
|
||||
OPENCLAW_CONFIG_PATH="$custom_state_dir/openclaw.json" \
|
||||
@@ -248,12 +245,12 @@ run_cross_state_approval_flow() {
|
||||
exit 1
|
||||
fi
|
||||
|
||||
test ! -e "$exec_source"
|
||||
test ! -e "$plugin_source"
|
||||
test "$(sha256sum "$exec_source.migrated" | awk '{print $1}')" = "$exec_source_hash"
|
||||
test "$(sha256sum "$plugin_source.migrated" | awk '{print $1}')" = "$plugin_source_hash"
|
||||
test -e "$custom_state_dir/exec-approvals.json"
|
||||
test "$(plugin_binding_approval_count "$state_database")" = "1"
|
||||
test "$(sha256sum "$exec_source" | awk '{print $1}')" = "$exec_source_hash"
|
||||
test "$(sha256sum "$plugin_source" | awk '{print $1}')" = "$plugin_source_hash"
|
||||
test ! -e "$exec_source.migrated"
|
||||
test ! -e "$plugin_source.migrated"
|
||||
test ! -e "$custom_state_dir/exec-approvals.json"
|
||||
test "$(plugin_binding_approval_count "$state_database")" = "0"
|
||||
}
|
||||
|
||||
run_cross_state_approval_flow
|
||||
@@ -287,7 +284,6 @@ run_proxy_env_flow() {
|
||||
} >>"$unit_path"
|
||||
if ! openclaw_e2e_maybe_timeout "$command_timeout" env \
|
||||
OPENCLAW_UPDATE_IN_PROGRESS=1 \
|
||||
OPENCLAW_DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS=1 \
|
||||
OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE=1 \
|
||||
OPENCLAW_UPDATE_PARENT_SUPPORTS_GATEWAY_RESTART=1 \
|
||||
OPENCLAW_UPDATE_PARENT_ALLOWS_GATEWAY_SERVICE_REPAIR=1 \
|
||||
@@ -298,7 +294,6 @@ run_proxy_env_flow() {
|
||||
fi
|
||||
assert_no_env_key "$unit_path" "HTTP_PROXY"
|
||||
assert_no_env_key "$unit_path" "HTTPS_PROXY"
|
||||
assert_no_env_key "$unit_path" "OPENCLAW_DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS"
|
||||
}
|
||||
|
||||
run_proxy_env_flow
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -4,13 +4,7 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { withSuppressedNotes } from "../../../packages/terminal-core/src/note.js";
|
||||
import { readConfigFileSnapshot, setRuntimeConfigSnapshot } from "../../config/config.js";
|
||||
import {
|
||||
isNamedProfile,
|
||||
resolveLegacyStateDirs,
|
||||
resolveNewStateDir,
|
||||
resolveOAuthDir,
|
||||
resolveStateDir,
|
||||
} from "../../config/paths.js";
|
||||
import { resolveLegacyStateDirs, resolveOAuthDir, resolveStateDir } from "../../config/paths.js";
|
||||
import type { ConfigFileSnapshot } from "../../config/types.js";
|
||||
import { resolveRequiredHomeDir } from "../../infra/home-dir.js";
|
||||
import { ExitError, type RuntimeEnv } from "../../runtime.js";
|
||||
@@ -101,23 +95,6 @@ function hasBundledChannelLegacyStateMigrationInputs(stateDir: string, oauthDir:
|
||||
return dirHasFile(oauthDir, isLegacyWhatsAppAuthFile);
|
||||
}
|
||||
|
||||
function hasCrossStateDirApprovalMigrationInputs(stateDir: string): boolean {
|
||||
if (!process.env.OPENCLAW_STATE_DIR?.trim() || isNamedProfile()) {
|
||||
return false;
|
||||
}
|
||||
const homeDir = resolveRequiredHomeDir(process.env, os.homedir);
|
||||
const defaultStateDir = resolveNewStateDir(() => homeDir);
|
||||
if (path.resolve(defaultStateDir) === path.resolve(stateDir)) {
|
||||
return false;
|
||||
}
|
||||
const execApprovalsSource = path.join(defaultStateDir, "exec-approvals.json");
|
||||
const execApprovalsTarget = path.join(stateDir, "exec-approvals.json");
|
||||
return (
|
||||
(fileOrDirExists(execApprovalsSource) && !fileOrDirExists(execApprovalsTarget)) ||
|
||||
fileOrDirExists(path.join(defaultStateDir, "plugin-binding-approvals.json"))
|
||||
);
|
||||
}
|
||||
|
||||
function hasPendingSqliteSidecarArchive(sourcePath: string): boolean {
|
||||
return (
|
||||
fileOrDirExists(`${sourcePath}.migrated`) &&
|
||||
@@ -153,8 +130,7 @@ function hasLegacyStateMigrationInputs(): boolean {
|
||||
sqliteSidecarPaths.some(
|
||||
(sourcePath) => fileOrDirExists(sourcePath) || hasPendingSqliteSidecarArchive(sourcePath),
|
||||
) ||
|
||||
hasBundledChannelLegacyStateMigrationInputs(stateDir, oauthDir) ||
|
||||
hasCrossStateDirApprovalMigrationInputs(stateDir)
|
||||
hasBundledChannelLegacyStateMigrationInputs(stateDir, oauthDir)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -232,7 +208,6 @@ export async function ensureConfigReady(params: {
|
||||
migrateLegacyConfig: false,
|
||||
invalidConfigNote: false,
|
||||
...(commandName === "status" ? { observe: false } : {}),
|
||||
crossStateDirImports: false,
|
||||
...(shouldRequireStartupMigrationCheckpoint(commandPath)
|
||||
? { requireStartupMigrationCheckpoint: true }
|
||||
: {}),
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// Register maintenance tests cover maintenance command registration in the CLI program.
|
||||
import { Command } from "commander";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS_ENV } from "../../commands/doctor-invocation.js";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { registerMaintenanceCommands } from "./register.maintenance.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
@@ -69,10 +68,6 @@ describe("registerMaintenanceCommands doctor action", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("exits with code 0 after successful doctor run", async () => {
|
||||
doctorCommand.mockResolvedValue(undefined);
|
||||
|
||||
@@ -106,28 +101,6 @@ describe("registerMaintenanceCommands doctor action", () => {
|
||||
const [runtimeArg, options] = commandCall(doctorCommand);
|
||||
expect(runtimeArg).toBe(runtime);
|
||||
expect(options.repair).toBe(true);
|
||||
expect(options.crossStateDirImports).toBe(true);
|
||||
});
|
||||
|
||||
it("denies cross-state imports when an automation parent disables them", async () => {
|
||||
doctorCommand.mockResolvedValue(undefined);
|
||||
vi.stubEnv(DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS_ENV, "1");
|
||||
|
||||
await runMaintenanceCli(["doctor", "--fix", "--non-interactive"]);
|
||||
|
||||
const [, options] = commandCall(doctorCommand);
|
||||
expect(options.repair).toBe(true);
|
||||
expect(options.crossStateDirImports).toBe(false);
|
||||
});
|
||||
|
||||
it("denies cross-state imports for older update parents", async () => {
|
||||
doctorCommand.mockResolvedValue(undefined);
|
||||
vi.stubEnv("OPENCLAW_UPDATE_IN_PROGRESS", "1");
|
||||
|
||||
await runMaintenanceCli(["doctor", "--fix", "--non-interactive"]);
|
||||
|
||||
const [, options] = commandCall(doctorCommand);
|
||||
expect(options.crossStateDirImports).toBe(false);
|
||||
});
|
||||
|
||||
it("passes session sqlite options to doctor command", async () => {
|
||||
|
||||
@@ -2,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);
|
||||
});
|
||||
|
||||
@@ -7467,7 +7467,6 @@ describe("update-cli", () => {
|
||||
nonInteractive: true,
|
||||
repair: true,
|
||||
yes: true,
|
||||
crossStateDirImports: false,
|
||||
});
|
||||
expect(syncPluginCall()?.channel).toBe("stable");
|
||||
expect(syncPluginCall()?.acknowledgeClawHubRisk).toBe(true);
|
||||
@@ -7568,7 +7567,6 @@ describe("update-cli", () => {
|
||||
nonInteractive: true,
|
||||
repair: true,
|
||||
yes: false,
|
||||
crossStateDirImports: false,
|
||||
});
|
||||
expect(syncPluginCall()?.channel).toBe("beta");
|
||||
expect(syncPluginCall()?.config).toEqual({
|
||||
|
||||
@@ -6,7 +6,6 @@ import path from "node:path";
|
||||
import { isRecord } from "@openclaw/normalization-core/record-coerce";
|
||||
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
|
||||
import { theme } from "../../../packages/terminal-core/src/theme.js";
|
||||
import { DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS_ENV } from "../../commands/doctor-invocation.js";
|
||||
import { doctorCommand } from "../../commands/doctor.js";
|
||||
import {
|
||||
UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR_ENV,
|
||||
@@ -231,7 +230,6 @@ export async function updateFinalizeCommand(opts: UpdateFinalizeOptions): Promis
|
||||
nonInteractive: true,
|
||||
repair: true,
|
||||
yes: opts.yes === true,
|
||||
crossStateDirImports: false,
|
||||
});
|
||||
configSnapshot = await readConfigFileSnapshot({ skipPluginValidation: true });
|
||||
if (requestedChannel) {
|
||||
@@ -520,7 +518,6 @@ export async function continuePostCoreUpdateInFreshProcess(params: {
|
||||
env: {
|
||||
...stripGatewayServiceMarkerEnv(disableUpdatedPackageCompileCacheEnv(process.env)),
|
||||
OPENCLAW_UPDATE_IN_PROGRESS: "1",
|
||||
[DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS_ENV]: "1",
|
||||
[POST_CORE_UPDATE_ENV]: "1",
|
||||
[POST_CORE_UPDATE_CHANNEL_ENV]: params.channel,
|
||||
...(params.requestedChannel
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
checkShellCompletionStatus,
|
||||
ensureCompletionCacheExists,
|
||||
} from "../../commands/doctor-completion.js";
|
||||
import { DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS_ENV } from "../../commands/doctor-invocation.js";
|
||||
import { doctorCommand } from "../../commands/doctor.js";
|
||||
import { UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV } from "../../commands/doctor/shared/update-phase.js";
|
||||
import { resolveGatewayPort } from "../../config/config.js";
|
||||
@@ -880,7 +879,6 @@ export function resolvePostInstallDoctorEnv(params?: {
|
||||
}): NodeJS.ProcessEnv {
|
||||
const resolvedEnv: NodeJS.ProcessEnv = {
|
||||
...disableUpdatedPackageCompileCacheEnv(params?.baseEnv ?? process.env),
|
||||
[DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS_ENV]: "1",
|
||||
};
|
||||
if (!params?.serviceEnv) {
|
||||
return resolvedEnv;
|
||||
@@ -1436,7 +1434,6 @@ export async function maybeRestartService(params: {
|
||||
process.stdin.isTTY && !params.opts.json && params.opts.yes !== true;
|
||||
await doctorCommand(defaultRuntime, {
|
||||
nonInteractive: !interactiveDoctor,
|
||||
crossStateDirImports: false,
|
||||
});
|
||||
} catch (err) {
|
||||
defaultRuntime.log(theme.warn(`Doctor failed: ${String(err)}`));
|
||||
|
||||
@@ -3,7 +3,6 @@ import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS_ENV } from "../../commands/doctor-invocation.js";
|
||||
import { resolveGatewayInstallEntrypoint } from "../../daemon/gateway-entrypoint.js";
|
||||
import type { UpdateRunResult } from "../../infra/update-runner.js";
|
||||
import { updatePluginsAfterCoreUpdate } from "./update-command-plugins.js";
|
||||
@@ -208,7 +207,6 @@ describe("resolvePostInstallDoctorEnv", () => {
|
||||
|
||||
expect(env.PATH).toBe("/bin");
|
||||
expect(env.NODE_DISABLE_COMPILE_CACHE).toBe("1");
|
||||
expect(env[DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS_ENV]).toBe("1");
|
||||
expect(env.OPENCLAW_STATE_DIR).toBe(path.join("/srv/openclaw", "daemon-state"));
|
||||
expect(env.OPENCLAW_CONFIG_PATH).toBe(
|
||||
path.join("/srv/openclaw", "daemon-state", "openclaw.json"),
|
||||
@@ -227,7 +225,6 @@ describe("resolvePostInstallDoctorEnv", () => {
|
||||
|
||||
expect(env.PATH).toBe("/bin");
|
||||
expect(env.NODE_DISABLE_COMPILE_CACHE).toBe("1");
|
||||
expect(env[DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS_ENV]).toBe("1");
|
||||
expect(env.OPENCLAW_STATE_DIR).toBe("/caller/state");
|
||||
expect(env.OPENCLAW_PROFILE).toBe("caller");
|
||||
});
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -225,13 +225,6 @@ export async function runDoctorConfigPreflight(
|
||||
skipPristineCoreStateMigrations?: boolean;
|
||||
/** Prepared before Gateway bootstrap can create files under an otherwise pristine state root. */
|
||||
skipPristineStartupStateMigrations?: boolean;
|
||||
/**
|
||||
* Allows legacy imports whose source lives in the DEFAULT home state dir
|
||||
* while OPENCLAW_STATE_DIR points elsewhere. Only explicit doctor repair
|
||||
* runs opt in; the implicit CLI/gateway preflight must never archive
|
||||
* files that belong to another install's state dir.
|
||||
*/
|
||||
crossStateDirImports?: boolean;
|
||||
} = {},
|
||||
): Promise<DoctorConfigPreflightResult> {
|
||||
const stateMigrationsRequested = options.migrateState !== false;
|
||||
@@ -420,7 +413,6 @@ export async function runDoctorConfigPreflight(
|
||||
: {}),
|
||||
env: process.env,
|
||||
recoverCorruptTargetStore: options.recoverCorruptTargetStore,
|
||||
crossStateDirImports: options.crossStateDirImports,
|
||||
}),
|
||||
);
|
||||
} else if (stateMigrationInput.pluginDoctorConfig) {
|
||||
@@ -433,7 +425,6 @@ export async function runDoctorConfigPreflight(
|
||||
noteStartupStateMigrationResult(
|
||||
await autoMigrateLegacyTaskStateSidecars({
|
||||
env: process.env,
|
||||
crossStateDirImports: options.crossStateDirImports,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -441,7 +432,6 @@ export async function runDoctorConfigPreflight(
|
||||
noteStartupStateMigrationResult(
|
||||
await autoMigrateLegacyTaskStateSidecars({
|
||||
env: process.env,
|
||||
crossStateDirImports: options.crossStateDirImports,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
);
|
||||
}
|
||||
@@ -2820,148 +2820,9 @@ describe("doctor legacy state migrations", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("migrates default exec approvals into an explicit state dir", async () => {
|
||||
const root = await makeTempRoot();
|
||||
const stateDir = path.join(root, "custom-state");
|
||||
const sourcePath = path.join(root, ".openclaw", "exec-approvals.json");
|
||||
const legacySocketPath = path.join(root, ".openclaw", "exec-approvals.sock");
|
||||
const targetPath = path.join(stateDir, "exec-approvals.json");
|
||||
const targetSocketPath = path.join(stateDir, "exec-approvals.sock");
|
||||
writeJson5(sourcePath, {
|
||||
version: 1,
|
||||
socket: {
|
||||
path: legacySocketPath,
|
||||
token: "legacy-token",
|
||||
},
|
||||
defaults: {
|
||||
security: "deny",
|
||||
ask: "always",
|
||||
},
|
||||
agents: {
|
||||
main: {
|
||||
allowlist: [{ pattern: "git status" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const detected = await detectLegacyStateMigrations({
|
||||
cfg: {},
|
||||
env: { OPENCLAW_STATE_DIR: stateDir } as NodeJS.ProcessEnv,
|
||||
homedir: () => root,
|
||||
crossStateDirImports: true,
|
||||
});
|
||||
expect(detected.preview).toContain(`- Exec approvals: ${sourcePath} → ${targetPath}`);
|
||||
|
||||
const result = await runLegacyStateMigrations({ detected });
|
||||
|
||||
expect(result.warnings).toStrictEqual([]);
|
||||
expect(result.changes).toContain(`Migrated exec approvals → ${targetPath}`);
|
||||
expect(result.changes).toContain(`Archived legacy exec approvals → ${sourcePath}.migrated`);
|
||||
expect(fs.existsSync(sourcePath)).toBe(false);
|
||||
expect(fs.existsSync(`${sourcePath}.migrated`)).toBe(true);
|
||||
const migrated = JSON.parse(fs.readFileSync(targetPath, "utf8")) as {
|
||||
socket?: { path?: string; token?: string };
|
||||
defaults?: Record<string, unknown>;
|
||||
agents?: Record<string, { allowlist?: Array<Record<string, unknown>> }>;
|
||||
};
|
||||
expect(migrated.socket?.path).toBe(targetSocketPath);
|
||||
expect(migrated.socket?.token).toBe("legacy-token");
|
||||
expect(migrated.defaults).toEqual({
|
||||
security: "deny",
|
||||
ask: "always",
|
||||
});
|
||||
expect(migrated.agents?.main?.allowlist?.[0]?.pattern).toBe("git status");
|
||||
});
|
||||
|
||||
it("skips exec approvals migration when the target appears after detection", async () => {
|
||||
const root = await makeTempRoot();
|
||||
const stateDir = path.join(root, "custom-state");
|
||||
const sourcePath = path.join(root, ".openclaw", "exec-approvals.json");
|
||||
const targetPath = path.join(stateDir, "exec-approvals.json");
|
||||
writeJson5(sourcePath, {
|
||||
version: 1,
|
||||
socket: {
|
||||
token: "legacy-token",
|
||||
},
|
||||
defaults: {
|
||||
security: "deny",
|
||||
},
|
||||
});
|
||||
const detected = await detectLegacyStateMigrations({
|
||||
cfg: {},
|
||||
env: { OPENCLAW_STATE_DIR: stateDir } as NodeJS.ProcessEnv,
|
||||
homedir: () => root,
|
||||
crossStateDirImports: true,
|
||||
});
|
||||
writeJson5(targetPath, {
|
||||
version: 1,
|
||||
socket: {
|
||||
path: path.join(stateDir, "exec-approvals.sock"),
|
||||
token: "current-token",
|
||||
},
|
||||
defaults: {
|
||||
security: "full",
|
||||
ask: "off",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await runLegacyStateMigrations({ detected });
|
||||
|
||||
expect(result.warnings).toStrictEqual([]);
|
||||
expect(result.changes).not.toContain(`Migrated exec approvals → ${targetPath}`);
|
||||
const current = JSON.parse(fs.readFileSync(targetPath, "utf8")) as {
|
||||
socket?: { token?: string };
|
||||
defaults?: Record<string, unknown>;
|
||||
};
|
||||
expect(current.socket?.token).toBe("current-token");
|
||||
expect(current.defaults).toEqual({
|
||||
security: "full",
|
||||
ask: "off",
|
||||
});
|
||||
});
|
||||
|
||||
it("auto-migrates exec approvals without a valid config snapshot when doctor opts in", async () => {
|
||||
const root = await makeTempRoot();
|
||||
const stateDir = path.join(root, "custom-state");
|
||||
const sourcePath = path.join(root, ".openclaw", "exec-approvals.json");
|
||||
const targetPath = path.join(stateDir, "exec-approvals.json");
|
||||
writeJson5(sourcePath, {
|
||||
version: 1,
|
||||
socket: {
|
||||
token: "legacy-token",
|
||||
},
|
||||
defaults: {
|
||||
security: "deny",
|
||||
ask: "always",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await autoMigrateLegacyTaskStateSidecars({
|
||||
env: { OPENCLAW_STATE_DIR: stateDir } as NodeJS.ProcessEnv,
|
||||
homedir: () => root,
|
||||
crossStateDirImports: true,
|
||||
});
|
||||
|
||||
expect(result.warnings).toStrictEqual([]);
|
||||
expect(result.changes).toContain(`Migrated exec approvals → ${targetPath}`);
|
||||
expect(result.changes).toContain(`Archived legacy exec approvals → ${sourcePath}.migrated`);
|
||||
expect(fs.existsSync(sourcePath)).toBe(false);
|
||||
expect(fs.existsSync(`${sourcePath}.migrated`)).toBe(true);
|
||||
const migrated = JSON.parse(fs.readFileSync(targetPath, "utf8")) as {
|
||||
socket?: { token?: string };
|
||||
defaults?: Record<string, unknown>;
|
||||
};
|
||||
expect(migrated.socket?.token).toBe("legacy-token");
|
||||
expect(migrated.defaults).toEqual({
|
||||
security: "deny",
|
||||
ask: "always",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps default exec approvals in place without the cross-state-dir opt-in", async () => {
|
||||
// Regression: the implicit preflight (every CLI command, gateway startup)
|
||||
// must never archive files that belong to the default state dir just
|
||||
// because OPENCLAW_STATE_DIR points somewhere else.
|
||||
it("never imports default exec approvals into a custom state dir", async () => {
|
||||
// Regression: every custom state root is an independent trust scope.
|
||||
// Even direct doctor repair must not copy or archive default approvals.
|
||||
const root = await makeTempRoot();
|
||||
const stateDir = path.join(root, "custom-state");
|
||||
const sourcePath = path.join(root, ".openclaw", "exec-approvals.json");
|
||||
@@ -2978,22 +2839,23 @@ describe("doctor legacy state migrations", () => {
|
||||
});
|
||||
const sourceRaw = fs.readFileSync(sourcePath, "utf8");
|
||||
|
||||
const result = await autoMigrateLegacyTaskStateSidecars({
|
||||
const detected = await detectLegacyStateMigrations({
|
||||
cfg: {},
|
||||
env: { OPENCLAW_STATE_DIR: stateDir } as NodeJS.ProcessEnv,
|
||||
homedir: () => root,
|
||||
});
|
||||
expect(detected.preview.some((entry) => entry.includes("Exec approvals"))).toBe(false);
|
||||
|
||||
const result = await runLegacyStateMigrations({ detected });
|
||||
|
||||
expect(result.warnings).toStrictEqual([]);
|
||||
expect(result.changes).not.toContain(`Migrated exec approvals → ${targetPath}`);
|
||||
expect(result.notices?.join("\n")).toContain(
|
||||
"Exec approvals in the default state dir were not imported",
|
||||
);
|
||||
expect(fs.readFileSync(sourcePath, "utf8")).toBe(sourceRaw);
|
||||
expect(fs.existsSync(`${sourcePath}.migrated`)).toBe(false);
|
||||
expect(fs.existsSync(targetPath)).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps default exec approvals in place when autoMigrateLegacyState runs without the opt-in", async () => {
|
||||
it("keeps default exec approvals in place during automatic state migration", async () => {
|
||||
const root = await makeTempRoot();
|
||||
const stateDir = path.join(root, "custom-state");
|
||||
const sourcePath = path.join(root, ".openclaw", "exec-approvals.json");
|
||||
@@ -3018,9 +2880,6 @@ describe("doctor legacy state migrations", () => {
|
||||
|
||||
expect(result.warnings).toStrictEqual([]);
|
||||
expect(result.changes).not.toContain(`Migrated exec approvals → ${targetPath}`);
|
||||
expect(result.notices?.join("\n")).toContain(
|
||||
"Exec approvals in the default state dir were not imported",
|
||||
);
|
||||
expect(fs.readFileSync(sourcePath, "utf8")).toBe(sourceRaw);
|
||||
expect(fs.existsSync(`${sourcePath}.migrated`)).toBe(false);
|
||||
expect(fs.existsSync(targetPath)).toBe(false);
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -16,6 +16,4 @@ export type DoctorOptions = {
|
||||
sessionSqliteAllAgents?: boolean;
|
||||
sessionSqliteGithubIssue?: boolean;
|
||||
json?: boolean;
|
||||
/** Internal capability granted only to direct operator-owned doctor invocations. */
|
||||
crossStateDirImports?: boolean;
|
||||
};
|
||||
|
||||
@@ -1461,7 +1461,6 @@ describe("doctor health contributions", () => {
|
||||
expect(mocks.detectLegacyStateMigrations).toHaveBeenCalledWith({
|
||||
cfg,
|
||||
doctorOnlyStateMigrations: true,
|
||||
crossStateDirImports: false,
|
||||
});
|
||||
expect(mocks.runLegacyStateMigrations).toHaveBeenCalledWith({
|
||||
detected,
|
||||
@@ -1470,51 +1469,6 @@ describe("doctor health contributions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("grants legacy-state cross-state imports only to capable doctor origins", async () => {
|
||||
const contribution = requireDoctorContribution("doctor:legacy-state");
|
||||
const detected = { preview: [], warnings: [], notices: [] };
|
||||
mocks.detectLegacyStateMigrations.mockResolvedValue(detected);
|
||||
|
||||
const directRepairContext = {
|
||||
cfg: {},
|
||||
sourceConfigValid: true,
|
||||
prompter: buildDoctorPrompter(true),
|
||||
runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() },
|
||||
options: { nonInteractive: true, repair: true, crossStateDirImports: true },
|
||||
} as unknown as Parameters<(typeof contribution)["run"]>[0];
|
||||
await contribution.run(directRepairContext);
|
||||
expect(mocks.detectLegacyStateMigrations).toHaveBeenLastCalledWith({
|
||||
cfg: {},
|
||||
doctorOnlyStateMigrations: true,
|
||||
crossStateDirImports: true,
|
||||
});
|
||||
|
||||
const interactivePrompter = buildDoctorPrompter(false);
|
||||
interactivePrompter.repairMode.canPrompt = true;
|
||||
interactivePrompter.repairMode.nonInteractive = false;
|
||||
await contribution.run({
|
||||
...directRepairContext,
|
||||
prompter: interactivePrompter,
|
||||
options: { crossStateDirImports: true },
|
||||
});
|
||||
expect(mocks.detectLegacyStateMigrations).toHaveBeenLastCalledWith({
|
||||
cfg: {},
|
||||
doctorOnlyStateMigrations: true,
|
||||
crossStateDirImports: true,
|
||||
});
|
||||
|
||||
const automatedRepairContext = {
|
||||
...directRepairContext,
|
||||
options: { nonInteractive: true, repair: true, crossStateDirImports: false },
|
||||
};
|
||||
await contribution.run(automatedRepairContext);
|
||||
expect(mocks.detectLegacyStateMigrations).toHaveBeenLastCalledWith({
|
||||
cfg: {},
|
||||
doctorOnlyStateMigrations: true,
|
||||
crossStateDirImports: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("prints legacy state migration notices during manual doctor runs", async () => {
|
||||
const contribution = requireDoctorContribution("doctor:legacy-state");
|
||||
const detected = { preview: ["legacy sessions"], warnings: [], notices: [] };
|
||||
|
||||
@@ -518,16 +518,9 @@ async function runLegacyStateHealth(ctx: DoctorHealthFlowContext): Promise<void>
|
||||
const { detectLegacyStateMigrations, runLegacyStateMigrations } =
|
||||
await import("../commands/doctor-state-migrations.js");
|
||||
const { note } = await loadNoteModule();
|
||||
// Only a direct operator-owned doctor may inspect the default state dir for
|
||||
// imports. Automated repair callers explicitly lack this capability so a
|
||||
// temporary OPENCLAW_STATE_DIR cannot capture and archive production trust.
|
||||
const operatorCanApproveCrossStateDirImports =
|
||||
ctx.prompter.repairMode.canPrompt || ctx.prompter.shouldRepair;
|
||||
const legacyState = await detectLegacyStateMigrations({
|
||||
cfg: ctx.cfg,
|
||||
doctorOnlyStateMigrations: true,
|
||||
crossStateDirImports:
|
||||
ctx.options.crossStateDirImports === true && operatorCanApproveCrossStateDirImports,
|
||||
});
|
||||
if (legacyState.warnings.length > 0) {
|
||||
note(legacyState.warnings.join("\n"), "Doctor warnings");
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
normalizeOptionalString,
|
||||
readStringValue,
|
||||
} from "@openclaw/normalization-core/string-coerce";
|
||||
import { isNamedProfile } from "../config/paths.js";
|
||||
import { DEFAULT_AGENT_ID } from "../routing/session-key.js";
|
||||
import { resolveGlobalMap } from "../shared/global-singleton.js";
|
||||
import { getFileLockProcessStartTime } from "../shared/pid-alive.js";
|
||||
@@ -413,34 +412,6 @@ export function resolveExecApprovalsTranscriptPath(): string {
|
||||
: `${DEFAULT_EXEC_APPROVALS_STATE_DIR}/${EXEC_APPROVALS_FILE}`;
|
||||
}
|
||||
|
||||
function resolveLegacyExecApprovalsPath(): string {
|
||||
return path.join(expandHomePrefix(DEFAULT_EXEC_APPROVALS_STATE_DIR), EXEC_APPROVALS_FILE);
|
||||
}
|
||||
|
||||
function hasUnmigratedLegacyExecApprovals(filePath: string): boolean {
|
||||
if (!process.env.OPENCLAW_STATE_DIR?.trim() || isNamedProfile()) {
|
||||
return false;
|
||||
}
|
||||
const legacyPath = resolveLegacyExecApprovalsPath();
|
||||
return (
|
||||
path.resolve(legacyPath) !== path.resolve(filePath) &&
|
||||
!fs.existsSync(filePath) &&
|
||||
fs.existsSync(legacyPath)
|
||||
);
|
||||
}
|
||||
|
||||
function createUnmigratedLegacyExecApprovalsFallback(): ExecApprovalsFile {
|
||||
return normalizeExecApprovals({
|
||||
version: 1,
|
||||
defaults: {
|
||||
security: "deny",
|
||||
ask: "always",
|
||||
askFallback: "deny",
|
||||
},
|
||||
agents: {},
|
||||
});
|
||||
}
|
||||
|
||||
function createFailClosedExecApprovalsFallback(): ExecApprovalsFile {
|
||||
return normalizeExecApprovals({
|
||||
version: 1,
|
||||
@@ -1066,16 +1037,6 @@ function generateToken(): string {
|
||||
|
||||
function readExecApprovalsSnapshotUnlocked(): ExecApprovalsSnapshot {
|
||||
const filePath = resolveExecApprovalsPath();
|
||||
if (hasUnmigratedLegacyExecApprovals(filePath)) {
|
||||
const file = createUnmigratedLegacyExecApprovalsFallback();
|
||||
return {
|
||||
path: filePath,
|
||||
exists: false,
|
||||
raw: null,
|
||||
file,
|
||||
hash: hashExecApprovalsRaw(null),
|
||||
};
|
||||
}
|
||||
return readExecApprovalsSnapshotFromPath(filePath);
|
||||
}
|
||||
|
||||
@@ -1090,9 +1051,6 @@ export function readExecApprovalsSnapshot(): ExecApprovalsSnapshot {
|
||||
|
||||
function loadExecApprovalsUnlocked(): ExecApprovalsFile {
|
||||
const filePath = resolveExecApprovalsPath();
|
||||
if (hasUnmigratedLegacyExecApprovals(filePath)) {
|
||||
return createUnmigratedLegacyExecApprovalsFallback();
|
||||
}
|
||||
try {
|
||||
return readExecApprovalsSnapshotFromPath(filePath).file;
|
||||
} catch {
|
||||
@@ -1293,9 +1251,6 @@ type ExecApprovalsUpdate = {
|
||||
|
||||
function updateExecApprovalsUnlocked(params: ExecApprovalsUpdate): ExecApprovalsSnapshot | null {
|
||||
// Both sync and async entry points hold the sidecar lock across this full CAS transaction.
|
||||
if (hasUnmigratedLegacyExecApprovals(resolveExecApprovalsPath())) {
|
||||
throw new Error("Exec approvals must be migrated before they can be updated");
|
||||
}
|
||||
const current = readExecApprovalsSnapshotUnlocked();
|
||||
if (params.baseHash !== undefined && current.hash !== params.baseHash) {
|
||||
return null;
|
||||
@@ -1465,27 +1420,18 @@ function requireInitializedExecApprovals(
|
||||
}
|
||||
|
||||
export async function ensureExecApprovalsSnapshot(): Promise<ExecApprovalsSnapshot> {
|
||||
if (hasUnmigratedLegacyExecApprovals(resolveExecApprovalsPath())) {
|
||||
return readExecApprovalsSnapshot();
|
||||
}
|
||||
return requireInitializedExecApprovals(
|
||||
await updateExecApprovals({ update: ensureExecApprovalsSocket }),
|
||||
);
|
||||
}
|
||||
|
||||
export function ensureExecApprovals(): ExecApprovalsFile {
|
||||
if (hasUnmigratedLegacyExecApprovals(resolveExecApprovalsPath())) {
|
||||
return createUnmigratedLegacyExecApprovalsFallback();
|
||||
}
|
||||
return requireInitializedExecApprovals(
|
||||
updateExecApprovalsSync({ update: ensureExecApprovalsSocket }),
|
||||
).file;
|
||||
}
|
||||
|
||||
function readExecApprovalsForNoPersistenceUnlocked(filePath: string): ExecApprovalsFile {
|
||||
if (hasUnmigratedLegacyExecApprovals(filePath)) {
|
||||
return createUnmigratedLegacyExecApprovalsFallback();
|
||||
}
|
||||
try {
|
||||
return readExecApprovalsSnapshotFromPath(filePath).file;
|
||||
} catch (err) {
|
||||
@@ -1670,15 +1616,6 @@ export function resolveExecApprovals(
|
||||
overrides?: ExecApprovalsDefaultOverrides,
|
||||
): ExecApprovalsResolved {
|
||||
const filePath = resolveExecApprovalsPath();
|
||||
if (hasUnmigratedLegacyExecApprovals(filePath)) {
|
||||
return shapeResolvedExecApprovals({
|
||||
file: createUnmigratedLegacyExecApprovalsFallback(),
|
||||
filePath,
|
||||
agentId,
|
||||
overrides,
|
||||
socket: "none",
|
||||
});
|
||||
}
|
||||
if (!overrides?.requireSocket) {
|
||||
const file = withExecApprovalsReadLockSync(filePath, () =>
|
||||
readExecApprovalsForNoPersistenceUnlocked(filePath),
|
||||
@@ -1708,15 +1645,6 @@ export async function resolveExecApprovalsLocked(
|
||||
overrides?: ExecApprovalsDefaultOverrides,
|
||||
): Promise<ExecApprovalsResolved> {
|
||||
const filePath = resolveExecApprovalsPath();
|
||||
if (hasUnmigratedLegacyExecApprovals(filePath)) {
|
||||
return shapeResolvedExecApprovals({
|
||||
file: createUnmigratedLegacyExecApprovalsFallback(),
|
||||
filePath,
|
||||
agentId,
|
||||
overrides,
|
||||
socket: "none",
|
||||
});
|
||||
}
|
||||
if (!overrides?.requireSocket) {
|
||||
const file = await withExecApprovalsReadLock(filePath, async () =>
|
||||
readExecApprovalsForNoPersistenceUnlocked(filePath),
|
||||
|
||||
@@ -6,7 +6,7 @@ import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js";
|
||||
import { getChannelPlugin } from "../channels/plugins/registry.js";
|
||||
import type { ChannelLegacyStateMigrationPlan } from "../channels/plugins/types.core.js";
|
||||
import type { ChannelId } from "../channels/plugins/types.public.js";
|
||||
import { isNamedProfile, resolveOAuthDir, resolveStateDir } from "../config/paths.js";
|
||||
import { resolveOAuthDir, resolveStateDir } from "../config/paths.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import {
|
||||
@@ -43,10 +43,6 @@ import {
|
||||
detectLegacyDebugProxyCaptureSidecar,
|
||||
migrateLegacyDebugProxyCaptureSidecar,
|
||||
} from "./state-migrations.debug-proxy.js";
|
||||
import {
|
||||
detectLegacyExecApprovalsMigration,
|
||||
migrateLegacyExecApprovals,
|
||||
} from "./state-migrations.exec-approvals.js";
|
||||
import {
|
||||
existsDir,
|
||||
fileExists,
|
||||
@@ -245,28 +241,12 @@ export async function detectLegacyStateMigrations(params: {
|
||||
homedir?: () => string;
|
||||
pluginSessionStoreAgentIds?: readonly string[];
|
||||
sessionStoreOwnership?: SessionStoreOwnership;
|
||||
crossStateDirImports?: boolean;
|
||||
doctorOnlyStateMigrations?: boolean;
|
||||
}): Promise<LegacyStateDetection> {
|
||||
const env = params.env ?? process.env;
|
||||
const homedir = params.homedir ?? os.homedir;
|
||||
const stateDir = resolveStateDir(env, homedir);
|
||||
const oauthDir = resolveOAuthDir(env, stateDir);
|
||||
// Sources under the DEFAULT home state dir are foreign state when
|
||||
// OPENCLAW_STATE_DIR points elsewhere: an isolated/test gateway must never
|
||||
// import (and archive) another install's files. Only an explicit doctor run
|
||||
// opts into the cross-directory import.
|
||||
const crossStateDirImports = params.crossStateDirImports === true;
|
||||
const notices: string[] = [];
|
||||
const detectedExecApprovals = detectLegacyExecApprovalsMigration({ env, homedir, stateDir });
|
||||
const execApprovals = crossStateDirImports
|
||||
? detectedExecApprovals
|
||||
: { ...detectedExecApprovals, hasLegacy: false };
|
||||
if (detectedExecApprovals.hasLegacy && !crossStateDirImports) {
|
||||
notices.push(
|
||||
`Exec approvals in the default state dir were not imported into OPENCLAW_STATE_DIR automatically (${detectedExecApprovals.sourcePath} -> ${detectedExecApprovals.targetPath}); run \`openclaw doctor --fix\` to import them.`,
|
||||
);
|
||||
}
|
||||
|
||||
const targetAgentId = normalizeAgentId(resolveDefaultAgentId(params.cfg));
|
||||
const rawMainKey = params.cfg.session?.mainKey;
|
||||
@@ -393,22 +373,9 @@ export async function detectLegacyStateMigrations(params: {
|
||||
const pluginBindingApprovals = {
|
||||
sourcePath: resolveLegacyPluginBindingApprovalsPath(env, homedir),
|
||||
};
|
||||
const pluginBindingApprovalsCrossDir =
|
||||
path.resolve(path.dirname(pluginBindingApprovals.sourcePath)) !== path.resolve(stateDir);
|
||||
const hasPluginBindingApprovals =
|
||||
!isNamedProfile(env) &&
|
||||
fileExists(pluginBindingApprovals.sourcePath) &&
|
||||
(crossStateDirImports || !pluginBindingApprovalsCrossDir);
|
||||
if (
|
||||
!isNamedProfile(env) &&
|
||||
fileExists(pluginBindingApprovals.sourcePath) &&
|
||||
pluginBindingApprovalsCrossDir &&
|
||||
!crossStateDirImports
|
||||
) {
|
||||
notices.push(
|
||||
`Plugin binding approvals in the default state dir were not imported into OPENCLAW_STATE_DIR automatically (${pluginBindingApprovals.sourcePath}); run \`openclaw doctor --fix\` to import them.`,
|
||||
);
|
||||
}
|
||||
path.resolve(path.dirname(pluginBindingApprovals.sourcePath)) === path.resolve(stateDir) &&
|
||||
fileExists(pluginBindingApprovals.sourcePath);
|
||||
const currentConversationBindings = {
|
||||
sourcePath: resolveLegacyCurrentConversationBindingsPath(stateDir),
|
||||
};
|
||||
@@ -613,9 +580,6 @@ export async function detectLegacyStateMigrations(params: {
|
||||
if (channelPairing.hasLegacy) {
|
||||
preview.push("- Channel pairing state: legacy JSON files → shared SQLite state");
|
||||
}
|
||||
if (execApprovals.hasLegacy) {
|
||||
preview.push(`- Exec approvals: ${execApprovals.sourcePath} → ${execApprovals.targetPath}`);
|
||||
}
|
||||
if (channelPlans.length > 0) {
|
||||
preview.push(...channelPlans.map(buildLegacyMigrationPreview));
|
||||
}
|
||||
@@ -704,9 +668,8 @@ export async function detectLegacyStateMigrations(params: {
|
||||
subagentRegistry,
|
||||
rescuePending,
|
||||
channelPairing,
|
||||
execApprovals,
|
||||
warnings: pluginPlanWarnings,
|
||||
notices,
|
||||
notices: [],
|
||||
preview,
|
||||
};
|
||||
}
|
||||
@@ -921,7 +884,6 @@ export async function runLegacyStateMigrations(params: {
|
||||
detected: detected.channelPairing,
|
||||
env: { ...env, OPENCLAW_STATE_DIR: detected.stateDir },
|
||||
});
|
||||
const execApprovals = migrateLegacyExecApprovals(detected.execApprovals);
|
||||
const preSessionChannelPlans = await runLegacyMigrationPlans(
|
||||
detected.channelPlans.plans.filter((plan) => plan.kind === "plugin-state-import"),
|
||||
);
|
||||
@@ -978,7 +940,6 @@ export async function runLegacyStateMigrations(params: {
|
||||
...subagentRegistry.changes,
|
||||
...rescuePending.changes,
|
||||
...channelPairing.changes,
|
||||
...execApprovals.changes,
|
||||
...preSessionChannelPlans.changes,
|
||||
...pluginPlans.changes,
|
||||
...sessions.changes,
|
||||
@@ -1008,7 +969,6 @@ export async function runLegacyStateMigrations(params: {
|
||||
...subagentRegistry.warnings,
|
||||
...rescuePending.warnings,
|
||||
...channelPairing.warnings,
|
||||
...execApprovals.warnings,
|
||||
...preSessionChannelPlans.warnings,
|
||||
...pluginPlans.warnings,
|
||||
...sessions.warnings,
|
||||
@@ -1039,7 +999,6 @@ export async function autoMigrateLegacyState(params: {
|
||||
log?: MigrationLogger;
|
||||
now?: () => number;
|
||||
recoverCorruptTargetStore?: boolean;
|
||||
crossStateDirImports?: boolean;
|
||||
}): Promise<{
|
||||
migrated: boolean;
|
||||
skipped: boolean;
|
||||
@@ -1127,7 +1086,6 @@ export async function autoMigrateLegacyState(params: {
|
||||
sessionStoreOwnership,
|
||||
env,
|
||||
homedir: params.homedir,
|
||||
crossStateDirImports: params.crossStateDirImports,
|
||||
});
|
||||
const hasCustomAgentDir = env.OPENCLAW_AGENT_DIR?.trim() || env.PI_CODING_AGENT_DIR?.trim();
|
||||
if (hasCustomAgentDir) {
|
||||
@@ -1171,7 +1129,6 @@ export async function autoMigrateLegacyState(params: {
|
||||
detected: detected.channelPairing,
|
||||
env: { ...env, OPENCLAW_STATE_DIR: detected.stateDir },
|
||||
});
|
||||
const execApprovals = migrateLegacyExecApprovals(detected.execApprovals);
|
||||
const preSessionChannelPlans = await runLegacyMigrationPlans(
|
||||
detected.channelPlans.plans.filter((plan) => plan.kind === "plugin-state-import"),
|
||||
);
|
||||
@@ -1196,7 +1153,6 @@ export async function autoMigrateLegacyState(params: {
|
||||
...pluginBindingApprovals.changes,
|
||||
...currentConversationBindings.changes,
|
||||
...channelPairing.changes,
|
||||
...execApprovals.changes,
|
||||
...preSessionChannelPlans.changes,
|
||||
...pluginPlans.changes,
|
||||
];
|
||||
@@ -1217,7 +1173,6 @@ export async function autoMigrateLegacyState(params: {
|
||||
...pluginBindingApprovals.warnings,
|
||||
...currentConversationBindings.warnings,
|
||||
...channelPairing.warnings,
|
||||
...execApprovals.warnings,
|
||||
...preSessionChannelPlans.warnings,
|
||||
...pluginPlans.warnings,
|
||||
];
|
||||
@@ -1241,7 +1196,6 @@ export async function autoMigrateLegacyState(params: {
|
||||
pluginBindingApprovals.changes.length > 0 ||
|
||||
currentConversationBindings.changes.length > 0 ||
|
||||
channelPairing.changes.length > 0 ||
|
||||
execApprovals.changes.length > 0 ||
|
||||
preSessionChannelPlans.changes.length > 0 ||
|
||||
pluginPlans.changes.length > 0,
|
||||
skipped: true,
|
||||
@@ -1266,8 +1220,7 @@ export async function autoMigrateLegacyState(params: {
|
||||
!detected.configHealth.hasLegacy &&
|
||||
!detected.pluginBindingApprovals.hasLegacy &&
|
||||
!detected.currentConversationBindings.hasLegacy &&
|
||||
!detected.channelPairing.hasLegacy &&
|
||||
!detected.execApprovals.hasLegacy
|
||||
!detected.channelPairing.hasLegacy
|
||||
) {
|
||||
const changes = [
|
||||
...stateDirResult.changes,
|
||||
@@ -1338,7 +1291,6 @@ export async function autoMigrateLegacyState(params: {
|
||||
detected: detected.channelPairing,
|
||||
env: { ...env, OPENCLAW_STATE_DIR: detected.stateDir },
|
||||
});
|
||||
const execApprovals = migrateLegacyExecApprovals(detected.execApprovals);
|
||||
const preSessionChannelPlans = await runLegacyMigrationPlans(
|
||||
detected.channelPlans.plans.filter((plan) => plan.kind === "plugin-state-import"),
|
||||
);
|
||||
@@ -1376,7 +1328,6 @@ export async function autoMigrateLegacyState(params: {
|
||||
...pluginBindingApprovals.changes,
|
||||
...currentConversationBindings.changes,
|
||||
...channelPairing.changes,
|
||||
...execApprovals.changes,
|
||||
...preSessionChannelPlans.changes,
|
||||
...pluginPlans.changes,
|
||||
...sessions.changes,
|
||||
@@ -1401,7 +1352,6 @@ export async function autoMigrateLegacyState(params: {
|
||||
...pluginBindingApprovals.warnings,
|
||||
...currentConversationBindings.warnings,
|
||||
...channelPairing.warnings,
|
||||
...execApprovals.warnings,
|
||||
...preSessionChannelPlans.warnings,
|
||||
...pluginPlans.warnings,
|
||||
...sessions.warnings,
|
||||
|
||||
@@ -1,234 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { isNamedProfile } from "../config/paths.js";
|
||||
import { assertNoSymlinkParentsSync } from "./fs-safe-advanced.js";
|
||||
import { expandHomePrefix, resolveRequiredHomeDir } from "./home-dir.js";
|
||||
import { fileExists } from "./state-migrations.fs.js";
|
||||
import type { LegacyExecApprovalsMigrationDetection } from "./state-migrations.types.js";
|
||||
|
||||
const EXEC_APPROVALS_FILENAME = "exec-approvals.json";
|
||||
const EXEC_APPROVALS_SOCKET_FILENAME = "exec-approvals.sock";
|
||||
|
||||
function resolveDefaultExecApprovalsStateDir(
|
||||
env: NodeJS.ProcessEnv,
|
||||
homedir: () => string,
|
||||
): string {
|
||||
return path.join(resolveRequiredHomeDir(env, homedir), ".openclaw");
|
||||
}
|
||||
|
||||
function resolveDefaultExecApprovalsPath(env: NodeJS.ProcessEnv, homedir: () => string): string {
|
||||
return path.join(resolveDefaultExecApprovalsStateDir(env, homedir), EXEC_APPROVALS_FILENAME);
|
||||
}
|
||||
|
||||
function resolveExecApprovalsPathForStateDir(stateDir: string): string {
|
||||
return path.join(stateDir, EXEC_APPROVALS_FILENAME);
|
||||
}
|
||||
|
||||
function resolveExecApprovalsSocketPathForStateDir(stateDir: string): string {
|
||||
return path.join(stateDir, EXEC_APPROVALS_SOCKET_FILENAME);
|
||||
}
|
||||
|
||||
export function detectLegacyExecApprovalsMigration(params: {
|
||||
env: NodeJS.ProcessEnv;
|
||||
homedir: () => string;
|
||||
stateDir: string;
|
||||
}): LegacyExecApprovalsMigrationDetection {
|
||||
const sourcePath = resolveDefaultExecApprovalsPath(params.env, params.homedir);
|
||||
const targetPath = resolveExecApprovalsPathForStateDir(params.stateDir);
|
||||
return {
|
||||
sourcePath,
|
||||
targetPath,
|
||||
hasLegacy:
|
||||
Boolean(params.env.OPENCLAW_STATE_DIR?.trim()) &&
|
||||
!isNamedProfile(params.env) &&
|
||||
path.resolve(sourcePath) !== path.resolve(targetPath) &&
|
||||
fileExists(sourcePath) &&
|
||||
!fileExists(targetPath),
|
||||
};
|
||||
}
|
||||
|
||||
function isPlainJsonObject(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
}
|
||||
|
||||
function isDefaultLegacyExecApprovalsSocketPath(params: {
|
||||
socketPath: string;
|
||||
sourcePath: string;
|
||||
}): boolean {
|
||||
const expanded = expandHomePrefix(params.socketPath);
|
||||
return (
|
||||
path.resolve(expanded) ===
|
||||
path.join(path.dirname(params.sourcePath), EXEC_APPROVALS_SOCKET_FILENAME)
|
||||
);
|
||||
}
|
||||
|
||||
function prepareMigratedExecApprovalsFile(params: {
|
||||
raw: string;
|
||||
sourcePath: string;
|
||||
targetPath: string;
|
||||
}): { raw: string; warning?: string } {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(params.raw) as unknown;
|
||||
} catch {
|
||||
return {
|
||||
raw: "",
|
||||
warning: `Legacy exec approvals file unreadable; left in place at ${params.sourcePath}`,
|
||||
};
|
||||
}
|
||||
if (!isPlainJsonObject(parsed) || parsed.version !== 1) {
|
||||
return {
|
||||
raw: "",
|
||||
warning: `Legacy exec approvals file has unsupported shape; left in place at ${params.sourcePath}`,
|
||||
};
|
||||
}
|
||||
|
||||
const next: Record<string, unknown> = { ...parsed };
|
||||
const socket = isPlainJsonObject(next.socket) ? { ...next.socket } : {};
|
||||
const rawSocketPath = typeof socket.path === "string" ? socket.path.trim() : "";
|
||||
if (
|
||||
!rawSocketPath ||
|
||||
isDefaultLegacyExecApprovalsSocketPath({
|
||||
socketPath: rawSocketPath,
|
||||
sourcePath: params.sourcePath,
|
||||
})
|
||||
) {
|
||||
socket.path = resolveExecApprovalsSocketPathForStateDir(path.dirname(params.targetPath));
|
||||
}
|
||||
next.socket = socket;
|
||||
return { raw: `${JSON.stringify(next, null, 2)}\n` };
|
||||
}
|
||||
|
||||
function assertSafeExecApprovalsMigrationTarget(targetPath: string): void {
|
||||
const targetDir = path.dirname(targetPath);
|
||||
assertNoSymlinkParentsSync({
|
||||
rootDir: resolveRequiredHomeDir(),
|
||||
targetPath: targetDir,
|
||||
allowOutsideRoot: true,
|
||||
messagePrefix: "Refusing to traverse symlink in exec approvals migration path",
|
||||
});
|
||||
try {
|
||||
const targetStat = fs.lstatSync(targetPath);
|
||||
if (targetStat.isSymbolicLink()) {
|
||||
throw new Error(`Refusing to migrate exec approvals via symlink: ${targetPath}`);
|
||||
}
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function writeMigratedExecApprovalsFile(targetPath: string, raw: string): boolean {
|
||||
const targetDir = path.dirname(targetPath);
|
||||
assertSafeExecApprovalsMigrationTarget(targetPath);
|
||||
fs.mkdirSync(targetDir, { recursive: true, mode: 0o700 });
|
||||
assertSafeExecApprovalsMigrationTarget(targetPath);
|
||||
const dirStat = fs.lstatSync(targetDir);
|
||||
if (!dirStat.isDirectory() || dirStat.isSymbolicLink()) {
|
||||
throw new Error(`Refusing to migrate exec approvals into unsafe directory: ${targetDir}`);
|
||||
}
|
||||
try {
|
||||
fs.chmodSync(targetDir, 0o700);
|
||||
} catch {
|
||||
// best-effort on platforms without chmod
|
||||
}
|
||||
const tempPath = path.join(targetDir, `.exec-approvals.migration.${process.pid}.tmp`);
|
||||
fs.writeFileSync(tempPath, raw, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
||||
try {
|
||||
try {
|
||||
fs.copyFileSync(tempPath, targetPath, fs.constants.COPYFILE_EXCL);
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === "EEXIST") {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
fs.rmSync(targetPath, { force: true });
|
||||
} catch {
|
||||
// best-effort cleanup for an incomplete exclusive copy target
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
try {
|
||||
fs.chmodSync(targetPath, 0o600);
|
||||
} catch {
|
||||
// best-effort on platforms without chmod
|
||||
}
|
||||
return true;
|
||||
} finally {
|
||||
fs.rmSync(tempPath, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function archiveMigratedExecApprovalsSource(sourcePath: string): string {
|
||||
let archivePath = `${sourcePath}.migrated`;
|
||||
if (fileExists(archivePath)) {
|
||||
archivePath = `${archivePath}-${Date.now()}`;
|
||||
}
|
||||
fs.renameSync(sourcePath, archivePath);
|
||||
return archivePath;
|
||||
}
|
||||
|
||||
export function migrateLegacyExecApprovals(detected: LegacyExecApprovalsMigrationDetection): {
|
||||
changes: string[];
|
||||
warnings: string[];
|
||||
} {
|
||||
const changes: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
if (!detected.hasLegacy) {
|
||||
return { changes, warnings };
|
||||
}
|
||||
if (fileExists(detected.targetPath)) {
|
||||
return { changes, warnings };
|
||||
}
|
||||
try {
|
||||
const sourceStat = fs.lstatSync(detected.sourcePath);
|
||||
if (!sourceStat.isFile() || sourceStat.isSymbolicLink()) {
|
||||
warnings.push(
|
||||
`Legacy exec approvals file is not a regular file; left in place at ${detected.sourcePath}`,
|
||||
);
|
||||
return { changes, warnings };
|
||||
}
|
||||
try {
|
||||
const targetStat = fs.lstatSync(detected.targetPath);
|
||||
if (targetStat.isSymbolicLink()) {
|
||||
warnings.push(
|
||||
`Target exec approvals path is a symlink; skipped migration at ${detected.targetPath}`,
|
||||
);
|
||||
return { changes, warnings };
|
||||
}
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
const prepared = prepareMigratedExecApprovalsFile({
|
||||
raw: fs.readFileSync(detected.sourcePath, "utf8"),
|
||||
sourcePath: detected.sourcePath,
|
||||
targetPath: detected.targetPath,
|
||||
});
|
||||
if (prepared.warning) {
|
||||
warnings.push(prepared.warning);
|
||||
return { changes, warnings };
|
||||
}
|
||||
if (!writeMigratedExecApprovalsFile(detected.targetPath, prepared.raw)) {
|
||||
return { changes, warnings };
|
||||
}
|
||||
changes.push(`Migrated exec approvals → ${detected.targetPath}`);
|
||||
try {
|
||||
const archivePath = archiveMigratedExecApprovalsSource(detected.sourcePath);
|
||||
changes.push(`Archived legacy exec approvals → ${archivePath}`);
|
||||
} catch (err) {
|
||||
warnings.push(
|
||||
`Failed archiving legacy exec approvals at ${detected.sourcePath}: ${String(err)}`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
warnings.push(
|
||||
`Failed migrating exec approvals (${detected.sourcePath} → ${detected.targetPath}): ${String(
|
||||
err,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
return { changes, warnings };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user