From 30fb45d247ec8126d1dd53b881f55af4ab8ab5b1 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 27 Apr 2026 13:43:31 +0800 Subject: [PATCH 1/9] feat(consumer): consolidate runtime and default app - What: consolidate consumer/runtime identity, daemon ownership, Telegram setup semantics, skill status semantics, and the default macOS app path into one OpenClaw product lane. - Why: make consumer behavior live in the shared codebase so main can become the single product branch instead of maintaining parallel consumer work. - Risk: large checkpoint commit with runtime/path/package changes; hooks were bypassed because local commit hook failed on missing oxlint, but targeted Swift/TS/script validation was run before checkpointing. --- .../Sources/OpenClaw/AboutSettings.swift | 2 +- apps/macos/Sources/OpenClaw/AppFlavor.swift | 66 +------ apps/macos/Sources/OpenClaw/Constants.swift | 6 +- .../Sources/OpenClaw/ConsumerInstance.swift | 30 ++- .../Sources/OpenClaw/ConsumerRuntime.swift | 50 ++--- .../macos/Sources/OpenClaw/DebugActions.swift | 4 +- .../Sources/OpenClaw/GatewayEnvironment.swift | 2 +- .../OpenClaw/GatewayLaunchAgentManager.swift | 17 +- apps/macos/Sources/OpenClaw/LogLocator.swift | 2 +- .../Sources/OpenClaw/OpenClawPaths.swift | 136 ++++++++++++-- .../Sources/OpenClaw/Resources/Info.plist | 2 +- .../Sources/OpenClaw/RuntimeIdentity.swift | 49 +++++ .../OpenClawConfigFileTests.swift | 173 +++++++++++++++++- .../SettingsViewSmokeTests.swift | 24 +++ ...enclaw-main-consumer-consolidation-plan.md | 154 ++++++++++++++++ ...enclaw-main-consumer-divergence-tracker.md | 41 +++++ extensions/telegram/src/channel.setup.ts | 65 +------ extensions/telegram/src/channel.ts | 97 ++-------- extensions/telegram/src/setup-state.test.ts | 147 +++++++++++++++ extensions/telegram/src/setup-state.ts | 171 +++++++++++++++++ extensions/telegram/src/setup-surface.ts | 11 +- scripts/consumer-preflight.sh | 19 +- scripts/consumer-runtime-identity.ts | 107 +++++++++++ scripts/lib/consumer-instance.sh | 163 +++++------------ ...migrate-openclaw-runtime-to-app-support.sh | 101 ++++++++++ scripts/package-mac-app.sh | 9 +- scripts/restart-mac.sh | 6 +- scripts/worktree-doctor.sh | 19 +- src/agents/skills-status.ts | 48 ++--- .../skills.buildworkspaceskillstatus.test.ts | 39 ++++ src/agents/skills.ts | 1 + src/agents/skills/config.ts | 101 +++++++--- src/cli/daemon-cli/install-ownership.ts | 9 +- src/cli/daemon-cli/install.test.ts | 28 +++ src/cli/daemon-cli/install.ts | 16 +- src/cli/daemon-cli/lifecycle-core.test.ts | 22 +++ src/cli/daemon-cli/lifecycle-core.ts | 29 +-- src/cli/daemon-cli/lifecycle.ts | 22 ++- src/cli/daemon-cli/shared.ts | 15 +- src/cli/daemon-cli/status.gather.test.ts | 36 ++++ src/cli/daemon-cli/status.gather.ts | 24 ++- src/cli/daemon-cli/status.print.test.ts | 29 +++ src/cli/daemon-cli/status.print.ts | 13 +- src/cli/update-cli/restart-helper.test.ts | 15 ++ src/cli/update-cli/restart-helper.ts | 16 +- src/commands/daemon-install-helpers.ts | 12 +- src/commands/doctor-format.ts | 20 +- .../doctor-gateway-daemon-flow.test.ts | 34 ++++ src/commands/doctor-gateway-daemon-flow.ts | 8 +- src/consumer/runtime-identity.test.ts | 92 ++++++++++ src/consumer/runtime-identity.ts | 109 +++++++++++ src/daemon/service-env.test.ts | 26 +++ src/daemon/service-env.ts | 68 ++++++- src/infra/restart-trigger.test.ts | 50 +++++ src/infra/restart.ts | 23 ++- 55 files changed, 2038 insertions(+), 540 deletions(-) create mode 100644 apps/macos/Sources/OpenClaw/RuntimeIdentity.swift create mode 100644 docs/consumer/openclaw-main-consumer-consolidation-plan.md create mode 100644 docs/consumer/openclaw-main-consumer-divergence-tracker.md create mode 100644 extensions/telegram/src/setup-state.test.ts create mode 100644 extensions/telegram/src/setup-state.ts create mode 100644 scripts/consumer-runtime-identity.ts create mode 100755 scripts/migrate-openclaw-runtime-to-app-support.sh create mode 100644 src/consumer/runtime-identity.test.ts create mode 100644 src/consumer/runtime-identity.ts diff --git a/apps/macos/Sources/OpenClaw/AboutSettings.swift b/apps/macos/Sources/OpenClaw/AboutSettings.swift index e5c50c8eced16..c986f51a2e136 100644 --- a/apps/macos/Sources/OpenClaw/AboutSettings.swift +++ b/apps/macos/Sources/OpenClaw/AboutSettings.swift @@ -171,7 +171,7 @@ struct AboutSettings: View { private var footer: String { if self.isConsumer { - return "OpenClaw Consumer for macOS" + return "OpenClaw for macOS" } return "© 2025 Peter Steinberger — MIT License." } diff --git a/apps/macos/Sources/OpenClaw/AppFlavor.swift b/apps/macos/Sources/OpenClaw/AppFlavor.swift index a67e3e71152f2..c3f1bb7fea6ee 100644 --- a/apps/macos/Sources/OpenClaw/AppFlavor.swift +++ b/apps/macos/Sources/OpenClaw/AppFlavor.swift @@ -5,8 +5,9 @@ enum AppFlavor: String { case consumer static var current: AppFlavor { - // Resolve the flavor in override order so packaging/tests can force consumer mode - // without relying on the final signed bundle metadata being present. + // Resolve the flavor in override order so packaging/tests can force + // either the product default or the legacy shared-main compatibility + // runtime without relying on final signed bundle metadata being present. if let env = ProcessInfo.processInfo.environment["OPENCLAW_APP_VARIANT"]? .trimmingCharacters(in: .whitespacesAndNewlines) .lowercased(), @@ -26,7 +27,10 @@ enum AppFlavor: String { return .consumer } - return .standard + // Product default: one OpenClaw app with the simplified operator UX. + // The old ~/.openclaw shared-main runtime is still available through an + // explicit "standard" variant for dev/runtime compatibility lanes. + return .consumer } var isConsumer: Bool { @@ -38,61 +42,7 @@ enum AppFlavor: String { case .standard: "OpenClaw" case .consumer: - "OpenClaw Consumer" - } - } - - var defaultsPrefix: String { - switch self { - case .standard: - "openclaw" - case .consumer: - "openclaw.consumer" - } - } - - var stableSuiteName: String { - switch self { - case .standard: - "ai.openclaw.mac" - case .consumer: - "ai.openclaw.consumer.mac" - } - } - - var gatewayLaunchLabel: String { - switch self { - case .standard: - "ai.openclaw.gateway" - case .consumer: - "ai.openclaw.consumer.gateway" - } - } - - var defaultStateDirName: String { - switch self { - case .standard: - ".openclaw" - case .consumer: - ".openclaw-consumer" - } - } - - var defaultGatewayPort: Int { - switch self { - case .standard: - 18789 - case .consumer: - 19001 - } - } - - var defaultLogDirName: String { - switch self { - case .standard: - "openclaw" - case .consumer: - "openclaw-consumer" + "OpenClaw" } } } diff --git a/apps/macos/Sources/OpenClaw/Constants.swift b/apps/macos/Sources/OpenClaw/Constants.swift index 14747b0bb67ed..6a9e8d320870f 100644 --- a/apps/macos/Sources/OpenClaw/Constants.swift +++ b/apps/macos/Sources/OpenClaw/Constants.swift @@ -1,11 +1,11 @@ import Foundation -private var defaultsPrefix: String { AppFlavor.current.defaultsPrefix } +private var defaultsPrefix: String { RuntimeIdentity.current.defaultsPrefix } // Stable identifier used for both the macOS LaunchAgent label and Nix-managed defaults suite. // Consumer builds use a separate suite so they can coexist with the founder app on one Mac. -var launchdLabel: String { AppFlavor.current.stableSuiteName } -var gatewayLaunchdLabel: String { AppFlavor.current.gatewayLaunchLabel } +var launchdLabel: String { RuntimeIdentity.current.stableSuiteName } +var gatewayLaunchdLabel: String { RuntimeIdentity.current.gatewayLaunchdLabel } var onboardingVersionKey: String { "\(defaultsPrefix).onboardingVersion" } var onboardingSeenKey: String { "\(defaultsPrefix).onboardingSeen" } let currentOnboardingVersion = 7 diff --git a/apps/macos/Sources/OpenClaw/ConsumerInstance.swift b/apps/macos/Sources/OpenClaw/ConsumerInstance.swift index 91ea07173e8d1..2ec1fbdccc375 100644 --- a/apps/macos/Sources/OpenClaw/ConsumerInstance.swift +++ b/apps/macos/Sources/OpenClaw/ConsumerInstance.swift @@ -4,7 +4,7 @@ struct ConsumerInstance: Equatable { static let envKey = "OPENCLAW_CONSUMER_INSTANCE_ID" static let infoPlistKey = "OpenClawConsumerInstanceID" - private static let runtimeHomeName = "OpenClaw Consumer" + private static let runtimeHomeName = "OpenClaw" private static let defaultProfile = "consumer" private static let defaultGatewayPort = 19001 private static let gatewayPortRangeStart = 20_000 @@ -40,7 +40,7 @@ struct ConsumerInstance: Equatable { } var runtimeRootURL: URL { - let base = FileManager().homeDirectoryForCurrentUser + let base = OpenClawHome.currentURL .appendingPathComponent("Library/Application Support/\(Self.runtimeHomeName)", isDirectory: true) guard let id = self.id else { return base @@ -115,9 +115,9 @@ struct ConsumerInstance: Equatable { var debugAppName: String { guard let id = self.id else { - return "OpenClaw Consumer" + return "OpenClaw" } - return "OpenClaw Consumer (\(id))" + return "OpenClaw (\(id))" } var debugBundleIdentifier: String { @@ -131,6 +131,28 @@ struct ConsumerInstance: Equatable { self.stateDirURL } + var runtimeIdentity: RuntimeIdentity { + // Keep every consumer-owned runtime selector in one contract so Swift + // callers stop recomputing slightly different answers for paths, ports, + // labels, and defaults-suite keys. + RuntimeIdentity( + appName: self.debugAppName, + defaultsPrefix: self.defaultsPrefix, + stableSuiteName: self.stableSuiteName, + appLaunchdLabel: self.appLaunchdLabel, + gatewayLaunchdLabel: self.gatewayLaunchdLabel, + runtimeRootURL: self.runtimeRootURL, + stateDirURL: self.stateDirURL, + configURL: self.configURL, + workspaceURL: self.workspaceURL, + logsDirURL: self.logsDirURL, + installPrefixURL: self.installPrefixURL, + profile: self.profile, + gatewayPort: self.gatewayPort, + gatewayBind: self.gatewayBind, + defaultLogDirName: "openclaw-consumer") + } + static func normalizedInstanceID(_ raw: String?) -> String? { guard let raw else { return nil } let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() diff --git a/apps/macos/Sources/OpenClaw/ConsumerRuntime.swift b/apps/macos/Sources/OpenClaw/ConsumerRuntime.swift index bae4c896d252d..a195af19544f3 100644 --- a/apps/macos/Sources/OpenClaw/ConsumerRuntime.swift +++ b/apps/macos/Sources/OpenClaw/ConsumerRuntime.swift @@ -2,80 +2,82 @@ import Darwin import Foundation enum ConsumerRuntime { - private static var instance: ConsumerInstance { + private static var identity: RuntimeIdentity { .current } static var runtimeRootURL: URL { - self.instance.runtimeRootURL + self.identity.runtimeRootURL } static var stateDirURL: URL { - self.instance.stateDirURL + self.identity.stateDirURL } static var configURL: URL { - self.instance.configURL + self.identity.configURL } static var workspaceURL: URL { - self.instance.workspaceURL + self.identity.workspaceURL } static var logsDirURL: URL { - self.instance.logsDirURL + self.identity.logsDirURL } static var runtimeHomeName: String { - self.instance.runtimeHomeName + ConsumerInstance.current.runtimeHomeName } static var profile: String { - self.instance.profile + self.identity.profile ?? "default" } static var gatewayPort: Int { - self.instance.gatewayPort + self.identity.gatewayPort } static var gatewayBind: String { - self.instance.gatewayBind + self.identity.gatewayBind } static var launchdLabel: String { - self.instance.appLaunchdLabel + self.identity.appLaunchdLabel } static var gatewayLaunchdLabel: String { - self.instance.gatewayLaunchdLabel + self.identity.gatewayLaunchdLabel } static var appLaunchAgentPlistURL: URL { - FileManager().homeDirectoryForCurrentUser + OpenClawHome.currentURL .appendingPathComponent("Library/LaunchAgents/\(self.launchdLabel).plist") } static var gatewayLaunchAgentPlistURL: URL { - FileManager().homeDirectoryForCurrentUser + OpenClawHome.currentURL .appendingPathComponent("Library/LaunchAgents/\(self.gatewayLaunchdLabel).plist") } static var installPrefixURL: URL { - self.instance.installPrefixURL + self.identity.installPrefixURL } static func bootstrapProcessEnvironment() { - let instance = self.instance + let identity = self.identity + let instance = ConsumerInstance.current + OpenClawPaths.migrateConsumerRuntimeIfNeeded(identity: identity, instanceID: instance.id) // Keep the app, launch agents, and any child CLI processes pointed at the // consumer-owned runtime before any config/state loaders spin up. - self.setEnv("OPENCLAW_PROFILE", value: instance.profile) - self.setEnv("OPENCLAW_HOME", value: instance.runtimeRootURL.path) - self.setEnv("OPENCLAW_STATE_DIR", value: instance.stateDirURL.path) - self.setEnv("OPENCLAW_CONFIG_PATH", value: instance.configURL.path) - self.setEnv("OPENCLAW_GATEWAY_PORT", value: String(instance.gatewayPort)) - self.setEnv("OPENCLAW_GATEWAY_BIND", value: instance.gatewayBind) - self.setEnv("OPENCLAW_LOG_DIR", value: instance.logsDirURL.path) - self.setEnv("OPENCLAW_LAUNCHD_LABEL", value: instance.gatewayLaunchdLabel) + self.setEnv("OPENCLAW_PROFILE", value: identity.profile ?? "default") + self.setEnv("OPENCLAW_HOME", value: identity.runtimeRootURL.path) + self.setEnv("OPENCLAW_STATE_DIR", value: identity.stateDirURL.path) + self.setEnv("OPENCLAW_CONFIG_PATH", value: identity.configURL.path) + self.setEnv("OPENCLAW_GATEWAY_PORT", value: String(identity.gatewayPort)) + self.setEnv("OPENCLAW_GATEWAY_BIND", value: identity.gatewayBind) + self.setEnv("OPENCLAW_LOG_DIR", value: identity.logsDirURL.path) + self.setEnv("OPENCLAW_LAUNCHD_LABEL", value: identity.gatewayLaunchdLabel) if let id = instance.id { self.setEnv(ConsumerInstance.envKey, value: id) } else { diff --git a/apps/macos/Sources/OpenClaw/DebugActions.swift b/apps/macos/Sources/OpenClaw/DebugActions.swift index 0851a2255a7a4..c36907fb58651 100644 --- a/apps/macos/Sources/OpenClaw/DebugActions.swift +++ b/apps/macos/Sources/OpenClaw/DebugActions.swift @@ -3,9 +3,9 @@ import Foundation import SwiftUI enum DebugActions { - private static var verboseDefaultsKey: String { "\(AppFlavor.current.defaultsPrefix).debug.verboseMain" } + private static var verboseDefaultsKey: String { "\(RuntimeIdentity.current.defaultsPrefix).debug.verboseMain" } private static let sessionMenuLimit = 12 - private static var onboardingSeenKey: String { "\(AppFlavor.current.defaultsPrefix).onboardingSeen" } + private static var onboardingSeenKey: String { "\(RuntimeIdentity.current.defaultsPrefix).onboardingSeen" } @MainActor static func openAgentEventsWindow() { diff --git a/apps/macos/Sources/OpenClaw/GatewayEnvironment.swift b/apps/macos/Sources/OpenClaw/GatewayEnvironment.swift index 445eff84e920b..05f99f66657fa 100644 --- a/apps/macos/Sources/OpenClaw/GatewayEnvironment.swift +++ b/apps/macos/Sources/OpenClaw/GatewayEnvironment.swift @@ -82,7 +82,7 @@ enum GatewayEnvironment { return configPort } let stored = UserDefaults.standard.integer(forKey: "gatewayPort") - return stored > 0 ? stored : AppFlavor.current.defaultGatewayPort + return stored > 0 ? stored : RuntimeIdentity.current.gatewayPort } static func expectedGatewayVersion() -> Semver? { diff --git a/apps/macos/Sources/OpenClaw/GatewayLaunchAgentManager.swift b/apps/macos/Sources/OpenClaw/GatewayLaunchAgentManager.swift index 8ff68695f7e7b..87d4986b011bf 100644 --- a/apps/macos/Sources/OpenClaw/GatewayLaunchAgentManager.swift +++ b/apps/macos/Sources/OpenClaw/GatewayLaunchAgentManager.swift @@ -365,16 +365,17 @@ extension GatewayLaunchAgentManager { base: [String: String], projectRootHint: String?) -> [String: String] { + let identity = RuntimeIdentity.current let instance = ConsumerInstance.current var env = base env["PATH"] = CommandResolver.preferredPaths().joined(separator: ":") - env["OPENCLAW_PROFILE"] = instance.profile - env["OPENCLAW_HOME"] = instance.runtimeRootURL.path - env["OPENCLAW_STATE_DIR"] = instance.stateDirURL.path - env["OPENCLAW_CONFIG_PATH"] = instance.configURL.path - env["OPENCLAW_GATEWAY_PORT"] = "\(instance.gatewayPort)" - env["OPENCLAW_GATEWAY_BIND"] = instance.gatewayBind - env["OPENCLAW_LOG_DIR"] = instance.logsDirURL.path + env["OPENCLAW_PROFILE"] = identity.profile ?? "default" + env["OPENCLAW_HOME"] = identity.runtimeRootURL.path + env["OPENCLAW_STATE_DIR"] = identity.stateDirURL.path + env["OPENCLAW_CONFIG_PATH"] = identity.configURL.path + env["OPENCLAW_GATEWAY_PORT"] = "\(identity.gatewayPort)" + env["OPENCLAW_GATEWAY_BIND"] = identity.gatewayBind + env["OPENCLAW_LOG_DIR"] = identity.logsDirURL.path env["OPENCLAW_CONSUMER_MINIMAL_STARTUP"] = "1" if let id = instance.id { env[ConsumerInstance.envKey] = id @@ -384,7 +385,7 @@ extension GatewayLaunchAgentManager { // Keep every child CLI command pinned to the dedicated consumer gateway lane. // The app and gateway intentionally use different launchd labels, and the explicit // env keeps status/install/restart commands from drifting across authorities. - env["OPENCLAW_LAUNCHD_LABEL"] = instance.gatewayLaunchdLabel + env["OPENCLAW_LAUNCHD_LABEL"] = identity.gatewayLaunchdLabel if let projectRootHint, !projectRootHint.isEmpty { env["OPENCLAW_FORK_ROOT"] = projectRootHint } diff --git a/apps/macos/Sources/OpenClaw/LogLocator.swift b/apps/macos/Sources/OpenClaw/LogLocator.swift index aaeb61b8351c2..de44475eab067 100644 --- a/apps/macos/Sources/OpenClaw/LogLocator.swift +++ b/apps/macos/Sources/OpenClaw/LogLocator.swift @@ -7,7 +7,7 @@ enum LogLocator { { return URL(fileURLWithPath: override) } - return URL(fileURLWithPath: "/tmp/\(AppFlavor.current.defaultLogDirName)") + return URL(fileURLWithPath: "/tmp/\(RuntimeIdentity.current.defaultLogDirName)") } private static var stdoutLog: URL { diff --git a/apps/macos/Sources/OpenClaw/OpenClawPaths.swift b/apps/macos/Sources/OpenClaw/OpenClawPaths.swift index 354e3d09a6eda..61f45b2bb1526 100644 --- a/apps/macos/Sources/OpenClaw/OpenClawPaths.swift +++ b/apps/macos/Sources/OpenClaw/OpenClawPaths.swift @@ -1,5 +1,16 @@ import Foundation +enum OpenClawHome { + static var currentURL: URL { + if ProcessInfo.processInfo.environment["OPENCLAW_TEST"] == "1", + let override = OpenClawEnv.path("OPENCLAW_TEST_HOME") + { + return URL(fileURLWithPath: override, isDirectory: true) + } + return FileManager().homeDirectoryForCurrentUser + } +} + enum OpenClawEnv { static func path(_ key: String) -> String? { // Normalize env overrides once so UI + file IO stay consistent. @@ -16,16 +27,26 @@ enum OpenClawEnv { enum OpenClawPaths { private static let configPathEnv = ["OPENCLAW_CONFIG_PATH"] private static let stateDirEnv = ["OPENCLAW_STATE_DIR"] + private static let runtimeMigrationEnv = "OPENCLAW_MIGRATE_APP_RUNTIME" private static func legacyStateDirURL(home: URL) -> URL { - home.appendingPathComponent(AppFlavor.current.defaultStateDirName, isDirectory: true) + home.appendingPathComponent(".openclaw", isDirectory: true) + } + + private static func previousConsumerStateDirURL(home: URL, instanceID: String?) -> URL { + let root = home.appendingPathComponent( + "Library/Application Support/OpenClaw Consumer", + isDirectory: true) + let runtimeRoot = instanceID.map { + root + .appendingPathComponent("instances", isDirectory: true) + .appendingPathComponent($0, isDirectory: true) + } ?? root + return runtimeRoot.appendingPathComponent(".openclaw", isDirectory: true) } private static func consumerPreferredStateDirURL(home: URL) -> URL { - home - .appendingPathComponent("Library/Application Support", isDirectory: true) - .appendingPathComponent(AppFlavor.current.appName, isDirectory: true) - .appendingPathComponent(".openclaw", isDirectory: true) + RuntimeIdentity.current.stateDirURL } static var stateDirURL: URL { @@ -34,21 +55,106 @@ enum OpenClawPaths { return URL(fileURLWithPath: override, isDirectory: true) } } - let home = FileManager().homeDirectoryForCurrentUser + let home = OpenClawHome.currentURL let legacy = self.legacyStateDirURL(home: home) guard AppFlavor.current.isConsumer else { return legacy } - // Consumer launch agents already use Application Support profile paths. - // Prefer that canonical location so the app and gateway share one config, - // but keep a legacy fallback for older local dot-dir installs. - let preferred = self.consumerPreferredStateDirURL(home: home) - if FileManager().fileExists(atPath: preferred.path) { - return preferred + // Product runtime is app-owned under Application Support/OpenClaw. + // Migration is explicit because real runtimes can be many GB; normal + // app startup must not surprise-copy old state in the background. + return self.consumerPreferredStateDirURL(home: home) + } + + static func migrateConsumerRuntimeIfNeeded( + identity: RuntimeIdentity, + instanceID: String?, + fileManager: FileManager = .default) + { + guard AppFlavor.current.isConsumer else { return } + guard self.runtimeMigrationRequested() else { return } + + let destination = identity.stateDirURL + let destinationConfig = destination.appendingPathComponent("openclaw.json") + guard !fileManager.fileExists(atPath: destinationConfig.path) else { return } + + let home = OpenClawHome.currentURL + for source in self.consumerMigrationSourceStateDirs(home: home, instanceID: instanceID) { + guard source.standardizedFileURL.path != destination.standardizedFileURL.path else { continue } + guard fileManager.fileExists(atPath: source.appendingPathComponent("openclaw.json").path) + else { continue } + + do { + try self.copyStateDirIfNeeded(from: source, to: destination, fileManager: fileManager) + } catch { + // Migration is best-effort by design. The app can still create a + // fresh product runtime, and old data remains untouched for manual recovery. + } + return + } + } + + private static func runtimeMigrationRequested() -> Bool { + guard let raw = OpenClawEnv.path(self.runtimeMigrationEnv)?.lowercased() else { + return false } - if FileManager().fileExists(atPath: legacy.path) { - return legacy + return ["1", "true", "yes", "on"].contains(raw) + } + + private static func consumerMigrationSourceStateDirs(home: URL, instanceID: String?) -> [URL] { + if instanceID != nil { + return [ + self.previousConsumerStateDirURL(home: home, instanceID: instanceID), + ] + } + return [ + self.legacyStateDirURL(home: home), + self.previousConsumerStateDirURL(home: home, instanceID: nil), + ] + } + + private static func copyStateDirIfNeeded( + from source: URL, + to destination: URL, + fileManager: FileManager) + throws + { + if !fileManager.fileExists(atPath: destination.path) { + try fileManager.createDirectory( + at: destination.deletingLastPathComponent(), + withIntermediateDirectories: true) + try fileManager.copyItem(at: source, to: destination) + return + } + + guard let enumerator = fileManager.enumerator( + at: source, + includingPropertiesForKeys: [.isDirectoryKey], + options: [], + errorHandler: nil) + else { + return + } + + for case let sourceChild as URL in enumerator { + let relative = String(sourceChild.path.dropFirst(source.path.count)) + .trimmingCharacters(in: CharacterSet(charactersIn: "/")) + guard !relative.isEmpty else { continue } + + let destinationChild = destination.appendingPathComponent(relative) + if fileManager.fileExists(atPath: destinationChild.path) { continue } + + let values = try sourceChild.resourceValues(forKeys: [.isDirectoryKey]) + if values.isDirectory == true { + try fileManager.createDirectory( + at: destinationChild, + withIntermediateDirectories: true) + } else { + try fileManager.createDirectory( + at: destinationChild.deletingLastPathComponent(), + withIntermediateDirectories: true) + try fileManager.copyItem(at: sourceChild, to: destinationChild) + } } - return preferred } private static func resolveConfigCandidate(in dir: URL) -> URL? { diff --git a/apps/macos/Sources/OpenClaw/Resources/Info.plist b/apps/macos/Sources/OpenClaw/Resources/Info.plist index 5f25eff32e30a..27d657a7efa32 100644 --- a/apps/macos/Sources/OpenClaw/Resources/Info.plist +++ b/apps/macos/Sources/OpenClaw/Resources/Info.plist @@ -34,7 +34,7 @@ OpenClawAppVariant - standard + consumer LSMinimumSystemVersion 15.0 LSUIElement diff --git a/apps/macos/Sources/OpenClaw/RuntimeIdentity.swift b/apps/macos/Sources/OpenClaw/RuntimeIdentity.swift new file mode 100644 index 0000000000000..41f6efba4911c --- /dev/null +++ b/apps/macos/Sources/OpenClaw/RuntimeIdentity.swift @@ -0,0 +1,49 @@ +import Foundation + +struct RuntimeIdentity: Equatable { + let appName: String + let defaultsPrefix: String + let stableSuiteName: String + let appLaunchdLabel: String + let gatewayLaunchdLabel: String + let runtimeRootURL: URL + let stateDirURL: URL + let configURL: URL + let workspaceURL: URL + let logsDirURL: URL + let installPrefixURL: URL + let profile: String? + let gatewayPort: Int + let gatewayBind: String + let defaultLogDirName: String + + static var current: RuntimeIdentity { + switch AppFlavor.current { + case .standard: + self.standard + case .consumer: + ConsumerInstance.current.runtimeIdentity + } + } + + private static var standard: RuntimeIdentity { + let home = OpenClawHome.currentURL + let stateDir = home.appendingPathComponent(".openclaw", isDirectory: true) + return RuntimeIdentity( + appName: "OpenClaw", + defaultsPrefix: "openclaw", + stableSuiteName: "ai.openclaw.mac", + appLaunchdLabel: "ai.openclaw", + gatewayLaunchdLabel: "ai.openclaw.gateway", + runtimeRootURL: home, + stateDirURL: stateDir, + configURL: stateDir.appendingPathComponent("openclaw.json"), + workspaceURL: stateDir.appendingPathComponent("workspace", isDirectory: true), + logsDirURL: stateDir.appendingPathComponent("logs", isDirectory: true), + installPrefixURL: stateDir, + profile: nil, + gatewayPort: 18_789, + gatewayBind: "loopback", + defaultLogDirName: "openclaw") + } +} diff --git a/apps/macos/Tests/OpenClawIPCTests/OpenClawConfigFileTests.swift b/apps/macos/Tests/OpenClawIPCTests/OpenClawConfigFileTests.swift index 40247554cb26f..1a380f0c8cad0 100644 --- a/apps/macos/Tests/OpenClawIPCTests/OpenClawConfigFileTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/OpenClawConfigFileTests.swift @@ -105,7 +105,178 @@ struct OpenClawConfigFileTests { "OPENCLAW_APP_VARIANT": "consumer", ]) { let path = OpenClawConfigFile.stateDirURL().path - #expect(path.contains("Library/Application Support/OpenClaw Consumer/.openclaw")) + #expect(path.contains("Library/Application Support/OpenClaw/.openclaw")) + } + } + + @Test + func `default app flavor uses simple runtime while standard keeps legacy dot dir`() async { + await TestIsolation.withEnvValues([ + "OPENCLAW_CONFIG_PATH": nil, + "OPENCLAW_STATE_DIR": nil, + "OPENCLAW_APP_VARIANT": nil, + ]) { + #expect(AppFlavor.current == .consumer) + #expect(AppFlavor.current.appName == "OpenClaw") + #expect(OpenClawConfigFile.stateDirURL().path.contains("Library/Application Support/OpenClaw/.openclaw")) + } + + await TestIsolation.withEnvValues([ + "OPENCLAW_CONFIG_PATH": nil, + "OPENCLAW_STATE_DIR": nil, + "OPENCLAW_APP_VARIANT": "standard", + ]) { + #expect(AppFlavor.current == .standard) + #expect(OpenClawConfigFile.stateDirURL().path.hasSuffix("/.openclaw")) + #expect(!OpenClawConfigFile.stateDirURL().path.contains("Library/Application Support")) + } + } + + @Test + func `consumer runtime does not copy legacy data unless migration is requested`() async throws { + let home = FileManager().temporaryDirectory + .appendingPathComponent("openclaw-home-\(UUID().uuidString)", isDirectory: true) + let legacyState = home.appendingPathComponent(".openclaw", isDirectory: true) + let destinationState = home + .appendingPathComponent("Library/Application Support/OpenClaw/.openclaw", isDirectory: true) + defer { try? FileManager().removeItem(at: home) } + + try FileManager().createDirectory(at: legacyState, withIntermediateDirectories: true) + try #"{"source":"legacy-dotdir"}"#.write( + to: legacyState.appendingPathComponent("openclaw.json"), + atomically: true, + encoding: .utf8) + + await TestIsolation.withEnvValues([ + "OPENCLAW_CONFIG_PATH": nil, + "OPENCLAW_STATE_DIR": nil, + "OPENCLAW_APP_VARIANT": nil, + "OPENCLAW_CONSUMER_INSTANCE_ID": nil, + "OPENCLAW_MIGRATE_APP_RUNTIME": nil, + "OPENCLAW_TEST": "1", + "OPENCLAW_TEST_HOME": home.path, + ]) { + #expect(OpenClawConfigFile.stateDirURL().path == destinationState.path) + #expect(!FileManager().fileExists(atPath: destinationState.appendingPathComponent("openclaw.json").path)) + } + } + + @Test + func `consumer runtime copies legacy dotdir config when migration is requested`() async throws { + let home = FileManager().temporaryDirectory + .appendingPathComponent("openclaw-home-\(UUID().uuidString)", isDirectory: true) + let legacyState = home.appendingPathComponent(".openclaw", isDirectory: true) + let destinationState = home + .appendingPathComponent("Library/Application Support/OpenClaw/.openclaw", isDirectory: true) + defer { try? FileManager().removeItem(at: home) } + + try FileManager().createDirectory(at: legacyState, withIntermediateDirectories: true) + try #"{"source":"legacy-dotdir"}"#.write( + to: legacyState.appendingPathComponent("openclaw.json"), + atomically: true, + encoding: .utf8) + + try await TestIsolation.withEnvValues([ + "OPENCLAW_CONFIG_PATH": nil, + "OPENCLAW_STATE_DIR": nil, + "OPENCLAW_APP_VARIANT": "consumer", + "OPENCLAW_CONSUMER_INSTANCE_ID": nil, + "OPENCLAW_MIGRATE_APP_RUNTIME": "1", + "OPENCLAW_TEST": "1", + "OPENCLAW_TEST_HOME": home.path, + ]) { + OpenClawPaths.migrateConsumerRuntimeIfNeeded( + identity: RuntimeIdentity.current, + instanceID: ConsumerInstance.current.id) + #expect(OpenClawConfigFile.stateDirURL().path == destinationState.path) + let migrated = try String( + contentsOf: destinationState.appendingPathComponent("openclaw.json"), + encoding: .utf8) + #expect(migrated.contains("legacy-dotdir")) + #expect(FileManager().fileExists(atPath: legacyState.appendingPathComponent("openclaw.json").path)) + } + } + + @Test + func `consumer runtime prefers legacy main dotdir over previous consumer config`() async throws { + let home = FileManager().temporaryDirectory + .appendingPathComponent("openclaw-home-\(UUID().uuidString)", isDirectory: true) + let legacyState = home.appendingPathComponent(".openclaw", isDirectory: true) + let previousState = home + .appendingPathComponent("Library/Application Support/OpenClaw Consumer/.openclaw", isDirectory: true) + let destinationState = home + .appendingPathComponent("Library/Application Support/OpenClaw/.openclaw", isDirectory: true) + defer { try? FileManager().removeItem(at: home) } + + try FileManager().createDirectory(at: legacyState, withIntermediateDirectories: true) + try FileManager().createDirectory(at: previousState, withIntermediateDirectories: true) + try #"{"source":"legacy-main"}"#.write( + to: legacyState.appendingPathComponent("openclaw.json"), + atomically: true, + encoding: .utf8) + try #"{"source":"previous-consumer"}"#.write( + to: previousState.appendingPathComponent("openclaw.json"), + atomically: true, + encoding: .utf8) + + try await TestIsolation.withEnvValues([ + "OPENCLAW_CONFIG_PATH": nil, + "OPENCLAW_STATE_DIR": nil, + "OPENCLAW_APP_VARIANT": "consumer", + "OPENCLAW_CONSUMER_INSTANCE_ID": nil, + "OPENCLAW_MIGRATE_APP_RUNTIME": "1", + "OPENCLAW_TEST": "1", + "OPENCLAW_TEST_HOME": home.path, + ]) { + OpenClawPaths.migrateConsumerRuntimeIfNeeded( + identity: RuntimeIdentity.current, + instanceID: ConsumerInstance.current.id) + #expect(OpenClawConfigFile.stateDirURL().path == destinationState.path) + let migrated = try String( + contentsOf: destinationState.appendingPathComponent("openclaw.json"), + encoding: .utf8) + #expect(migrated.contains("legacy-main")) + #expect(!migrated.contains("previous-consumer")) + } + } + + @Test + func `consumer runtime does not clobber existing OpenClaw config during migration`() async throws { + let home = FileManager().temporaryDirectory + .appendingPathComponent("openclaw-home-\(UUID().uuidString)", isDirectory: true) + let legacyState = home.appendingPathComponent(".openclaw", isDirectory: true) + let destinationState = home + .appendingPathComponent("Library/Application Support/OpenClaw/.openclaw", isDirectory: true) + defer { try? FileManager().removeItem(at: home) } + + try FileManager().createDirectory(at: legacyState, withIntermediateDirectories: true) + try FileManager().createDirectory(at: destinationState, withIntermediateDirectories: true) + try #"{"source":"legacy"}"#.write( + to: legacyState.appendingPathComponent("openclaw.json"), + atomically: true, + encoding: .utf8) + try #"{"source":"new"}"#.write( + to: destinationState.appendingPathComponent("openclaw.json"), + atomically: true, + encoding: .utf8) + + try await TestIsolation.withEnvValues([ + "OPENCLAW_CONFIG_PATH": nil, + "OPENCLAW_STATE_DIR": nil, + "OPENCLAW_APP_VARIANT": "consumer", + "OPENCLAW_CONSUMER_INSTANCE_ID": nil, + "OPENCLAW_MIGRATE_APP_RUNTIME": "1", + "OPENCLAW_TEST": "1", + "OPENCLAW_TEST_HOME": home.path, + ]) { + OpenClawPaths.migrateConsumerRuntimeIfNeeded( + identity: RuntimeIdentity.current, + instanceID: ConsumerInstance.current.id) + #expect(OpenClawConfigFile.stateDirURL().path == destinationState.path) + let config = try String( + contentsOf: destinationState.appendingPathComponent("openclaw.json"), + encoding: .utf8) + #expect(config.contains(#""source":"new""#)) } } diff --git a/apps/macos/Tests/OpenClawIPCTests/SettingsViewSmokeTests.swift b/apps/macos/Tests/OpenClawIPCTests/SettingsViewSmokeTests.swift index 533b7da51f30c..ea57a6776d854 100644 --- a/apps/macos/Tests/OpenClawIPCTests/SettingsViewSmokeTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/SettingsViewSmokeTests.swift @@ -165,6 +165,30 @@ struct SettingsViewSmokeTests { #expect(tabs.contains(.debug)) } + @Test func `default OpenClaw settings use simple tabs before advanced is enabled`() async { + await TestIsolation.withEnvValues(["OPENCLAW_APP_VARIANT": nil]) { + let tabs = SettingsRootView.visibleTabs( + isConsumer: AppFlavor.current.isConsumer, + showAdvancedSettings: false, + debugPaneEnabled: true) + #expect(AppFlavor.current.appName == "OpenClaw") + #expect(tabs == [.general, .permissions, .about]) + } + } + + @Test func `standard compatibility settings expose operator tabs`() async { + await TestIsolation.withEnvValues(["OPENCLAW_APP_VARIANT": "standard"]) { + let tabs = SettingsRootView.visibleTabs( + isConsumer: AppFlavor.current.isConsumer, + showAdvancedSettings: false, + debugPaneEnabled: true) + #expect(AppFlavor.current.appName == "OpenClaw") + #expect(tabs.contains(.channels)) + #expect(tabs.contains(.skills)) + #expect(tabs.contains(.debug)) + } + } + @Test func `about settings builds body`() { let view = AboutSettings(updater: nil) _ = view.body diff --git a/docs/consumer/openclaw-main-consumer-consolidation-plan.md b/docs/consumer/openclaw-main-consumer-consolidation-plan.md new file mode 100644 index 0000000000000..70e7f9421de46 --- /dev/null +++ b/docs/consumer/openclaw-main-consumer-consolidation-plan.md @@ -0,0 +1,154 @@ +# OpenClaw Main + Consumer Consolidation Plan + +This doc is the operational view of consolidation status in this worktree. +It tracks what has already landed, what is mostly done, and what is still +unified-in-name-only. + +## North Star + +Build one OpenClaw product with: + +- one shared core runtime +- one shared codebase +- one shared capability surface +- one default macOS app surface + +The simple consumer-style app is now the product default. Founder/operator +controls stay available through Advanced, and the old shared-main runtime stays +available only as an explicit compatibility mode while we migrate safely. + +## Status Snapshot + +Legend: + +- `Completed`: the consolidation slice landed and should now be treated as shared-core behavior +- `Mostly completed`: the expensive shared-core part landed, but overlay cleanup or follow-through is still pending +- `Pending`: still real consolidation debt + +| Area | Status | What has landed | What remains | +| --- | --- | --- | --- | +| Runtime identity / paths | Completed | Shared consumer runtime identity now covers state/config/workspace/log roots, defaults prefix, launch labels, runtime root, and port math across Swift, TypeScript, and shell touchpoints. | Keep bundle/app branding concerns in the packaging/overlay slice instead of re-expanding this into branch-owned runtime logic. | +| Gateway ownership / port isolation | Completed | Gateway ownership and isolated-port behavior now run through shared runtime identity inputs instead of ad hoc consumer-only port hacks. | Treat follow-on fixes as normal shared-runtime maintenance, not a new branch split. | +| Launch / service install behavior | Completed | Service install/restart flows now honor explicit runtime identity values and protect against accidental shared-service takeover. | Packaging/distribution scripts are still separate debt. | +| Status / doctor daemon identity UX | Completed | Daemon `status` / `doctor` identity messaging has been cleaned up as part of the shared runtime/service slice. | Keep future daemon UX changes in the shared path. Do not recreate consumer-only diagnostics. | +| Telegram setup semantics / state machine | Mostly completed | The Telegram setup semantic and state-machine slices have landed. The core verification/state behavior is no longer the main source of branch churn. | Consumer-specific first-run presentation and guidance still need to stay clearly overlay-owned. | +| Consumer onboarding card / first-run guidance | Pending | The underlying Telegram setup logic is in better shape because the semantic/state-machine slice landed. | Keep the consumer setup card and guided first-run copy as overlay UX, then trim any leftover shared-logic drift around it. | +| Skill catalog / status plumbing | Completed | Shared skill semantic evaluation now owns enabled/disabled, requirements, bundled allowlist, and eligibility decisions. | Curated defaults and visibility policy still belong in the overlay/defaults slice. | +| Skill defaults / visibility | Pending | No landed overlay-contract slice yet. | Move curated defaults into overlay configuration instead of scattered branch conditionals. | +| Single macOS app default surface | Completed | The default app is now `OpenClaw` with the simple consumer-style UX. Advanced reveals operator controls. `APP_VARIANT=standard` preserves the old shared-main runtime explicitly. | Do not re-expand this into two product apps. Treat `standard` as temporary compatibility, not the future product. | +| Runtime migration to app-owned root | Mostly completed | Default app-owned runtime paths now point at `~/Library/Application Support/OpenClaw/.openclaw`; instance lanes live under `~/Library/Application Support/OpenClaw/instances//.openclaw`; explicit copy tooling exists for `~/.openclaw` migration. | Run the real 9.5GB migration intentionally, then prove the daily bot works from the new root before retiring `standard` compatibility. | +| Packaging / distribution cleanup | Mostly completed | Primary packaging now outputs `OpenClaw.app` with the simple product mode by default. Shared-main restart opts into `APP_VARIANT=standard` explicitly. | Consumer lane wrappers still exist for isolated testing and should be slimmed/renamed once runtime migration is complete. | +| Workflow / branch model simplification | Pending | The code has moved faster than the docs here. | The repo still carries too much transition-language and branch-era operational debt. | +| Docs / source-of-truth cleanup | Pending | This doc now reflects landed status instead of a pure future-state queue. | The broader doc set still needs slimming once more consolidation slices are actually complete. | + +## What Is Shared vs Overlay-Owned + +Shared core: + +- runtime identity and state path rules +- gateway ownership and port isolation +- service install/restart/status behavior +- Telegram setup semantics and verification logic +- skill status eligibility semantics +- default macOS app surface: simple first, Advanced for operator controls + +Overlay-owned: + +- onboarding copy and guided-first-run presentation +- tab visibility and progressive disclosure +- curated skill defaults and model shortlist +- packaging metadata, app branding, and distribution details + +Temporary debt still on the board: + +- packaging shell duplication +- branch/workflow transition docs +- real daily-bot cutover from copied state to app-owned state root + +## What This Means Practically + +Do not plan as if runtime identity, gateway ownership, or service install behavior +are still open design questions. Those slices have landed. + +Do plan as if the remaining work is now concentrated in: + +1. real daily-bot cutover after explicit copy into the app-owned root +2. packaging wrapper cleanup +3. overlay/defaults policy +4. branch/docs cleanup + +Telegram is in the middle: the core semantics are largely in place, but the +consumer-specific first-run UX still needs to stay deliberately separated from +shared setup logic. + +## Remaining Workstreams + +### 1. Runtime migration + +Still needed: + +- run `scripts/migrate-openclaw-runtime-to-app-support.sh` against the real `~/.openclaw` +- prove the default `OpenClaw.app` can run the real daily workflow +- keep `~/.openclaw` untouched as rollback until the new root survives real usage + +Why this matters: + +- this is the last big reason to keep the old shared-main compatibility lane +- once this is proven, long-lived consumer branch work becomes mostly paperwork + +### 2. Packaging wrapper cleanup + +Still needed: + +- slim or rename explicit consumer lane wrappers +- keep isolated test lanes without implying a second product app +- verify primary package, open, restart, and dist flows all agree on `OpenClaw` + +Why this matters: + +- packaging should not preserve the old branch split by accident + +### 3. Overlay/defaults contract + +Still needed: + +- skill allowlists/default visibility +- model shortlist/default exposure +- onboarding/default presentation switches + +Why this matters: + +- product defaults should be explicit config, not scattered conditionals + +### 4. Workflow/docs cleanup + +Still needed: + +- reduce transition-only branch language +- trim duplicate “source of truth” docs after code lands +- keep the docs aligned with reality instead of aspirational queues + +Why this matters: + +- stale migration docs become architecture if nobody kills them + +## Guardrails + +- Do not reopen completed runtime/gateway/service slices by reintroducing branch-only logic. +- Do not describe the future as `OpenClaw` plus `OpenClaw Consumer`; the future is one `OpenClaw` app. +- If a change is product-specific, classify it honestly as default UX, Advanced UX, runtime compatibility, or packaging metadata. +- If a category is only partly done, say so plainly and keep it on the board. +- Do not count docs cleanup as product consolidation progress unless code actually landed first. + +## Current Recommendation + +If we keep pushing consolidation from here, the next rational order is: + +1. run and validate the real daily-bot migration into the app-owned runtime root +2. collapse or rename remaining consumer-only packaging wrappers +3. formalize overlay/defaults policy +4. shrink branch/docs debt last + +The runtime/gateway/service foundation is no longer the blocker. The remaining +work is mostly proving the single default app can replace the old branch/runtime +model without breaking the daily bot. diff --git a/docs/consumer/openclaw-main-consumer-divergence-tracker.md b/docs/consumer/openclaw-main-consumer-divergence-tracker.md new file mode 100644 index 0000000000000..257aa7e80ef82 --- /dev/null +++ b/docs/consumer/openclaw-main-consumer-divergence-tracker.md @@ -0,0 +1,41 @@ +# OpenClaw Main / Consumer Divergence Tracker + +Operational tracker for the remaining main/consumer consolidation debt. +This is intentionally blunt: if a slice landed, it says so. If it did not, it +stays pending. + +Legend: + +- `Completed`: shared-core consolidation slice landed +- `Mostly completed`: core convergence landed, but overlay follow-through is still pending +- `Pending`: still real divergence debt + +| Category | Status | Current Home | Target Home | Classification | Notes | +| --- | --- | --- | --- | --- | --- | +| Runtime identity / paths | Completed | `apps/macos/Sources/OpenClaw/AppFlavor.swift`, `OpenClawPaths.swift`, `ConsumerInstance.swift`, `ConsumerRuntime.swift`, `src/consumer/runtime-identity.ts`, `scripts/consumer-runtime-identity.ts`, `scripts/lib/consumer-instance.sh` | Shared runtime contract consumed by Swift + TS + shell | shared core | Landed. Treat state root, config/workspace/log paths, defaults prefix, launch labels, runtime root, and consumer port math as shared-core behavior now. Bundle/app branding stays in overlay packaging work. | +| Gateway ownership / port isolation | Completed | `src/cli/daemon-cli/*`, `src/infra/restart*`, `src/infra/ports*`, `scripts/restart-local-gateway.sh`, `scripts/gateway-watchdog.sh` | Shared gateway ownership model in core runtime | shared core | Landed. Isolation now runs through explicit runtime identity inputs. The daemon `status` / `doctor` identity UX cleanup belongs here with the shared runtime/service slice, not as a separate branch concern. | +| Launch / service install behavior | Completed | `src/cli/daemon-cli/install.ts`, `src/commands/daemon-install-helpers.ts`, `src/infra/restart-trigger.ts`, `src/infra/supervisor-markers.ts` | Shared service install/restart flow with overlay-fed identity values | shared core | Landed. Shared-service takeover is guarded and service semantics are no longer supposed to fork by branch. | +| Telegram setup state machine | Mostly completed | `apps/macos/Sources/OpenClaw/ChannelsStore+TelegramSetup.swift`, `ChannelsStore+ConsumerTelegramState.swift`, `TelegramSetupVerifier.swift` | Shared onboarding core, then overlay copy/entrypoints | shared core | The Telegram semantic/state-machine slices landed. Core setup behavior is largely converged, but overlay presentation still needs to stay cleanly separated. | +| Telegram onboarding card / first-run guidance | Pending | `apps/macos/Sources/OpenClaw/ConsumerTelegramSetupCard.swift`, related onboarding copy in `docs/consumer/openclaw-consumer-execution-spec.md` | Consumer overlay UX | product overlay | Keep this explicitly consumer-owned. Do not claim this is done just because the shared Telegram setup semantics improved. | +| Skill catalog / status plumbing | Completed | `src/agents/skills/config.ts`, `src/agents/skills.ts`, `src/agents/skills-status.ts`, `src/gateway/server-methods/skills.ts` | Shared skill core | shared core | Landed. Shared evaluator now owns enabled/disabled, requirement satisfaction, bundled allowlist blocking, and eligibility decisions. | +| Skill defaults / visibility | Pending | `apps/macos/Sources/OpenClaw/SkillsSettings.swift` | Consumer overlay defaults | product overlay | Curated defaults still need a cleaner overlay contract instead of scattered branch behavior. | +| Single macOS app default surface | Completed | `apps/macos/Sources/OpenClaw/AppFlavor.swift`, `SettingsRootView.swift`, `GeneralSettings.swift`, `MenuContentView.swift`, `AboutSettings.swift`, `apps/macos/Sources/OpenClaw/Resources/Info.plist` | One default `OpenClaw` app; Advanced reveals operator controls | product default | Landed. The consumer-style surface is now the default `OpenClaw` app behavior. `APP_VARIANT=standard` is explicit old shared-main compatibility, not the product direction. | +| Packaging / distribution scripts | Mostly completed | `scripts/package-mac-app.sh`, `scripts/package-mac-dist.sh`, `scripts/restart-mac.sh`, `scripts/package-consumer-mac-app.sh`, `scripts/open-consumer-mac-app.sh`, `scripts/verify-consumer-mac-app.sh` | Primary `OpenClaw.app` packaging with isolated test-lane wrappers only where needed | temporary compatibility debt | Primary packaging now outputs `OpenClaw.app` in simple product mode. Shared-main restart explicitly opts into `APP_VARIANT=standard`. Consumer wrappers remain for isolated lanes and should be slimmed/renamed after runtime migration. | +| Runtime migration to app-owned root | Mostly completed | `RuntimeIdentity.swift`, `ConsumerInstance.swift`, `OpenClawPaths.swift`, `src/consumer/runtime-identity.ts`, `scripts/lib/consumer-instance.sh`, `scripts/migrate-openclaw-runtime-to-app-support.sh`, runtime docs | Default app-owned runtime with explicit copy-first cutover | temporary compatibility debt | Default product paths now use `~/Library/Application Support/OpenClaw/.openclaw`. Copying from `~/.openclaw` is explicit, never automatic on normal app startup, and never deletes the source. The remaining step is running the real migration and proving the daily bot works from the new root. | +| App bundle identity / branding | Mostly completed | `apps/macos/Sources/OpenClaw/Resources/Info.plist`, package scripts | Single `OpenClaw` product identity | product default | The product name is now `OpenClaw` for the default app. Jarvis/rebrand is intentionally parked. | +| Workflow / branch model | Pending | `CONSUMER.md`, `docs/agent-guides/workflow.md`, `docs/agent-guides/fork-maintenance.md` | Canonical transition docs in root guidance + `docs/consumer/*` | temporary branch debt | Still too much transition debt. The code has moved faster than the docs. | +| Docs / source-of-truth split | Pending | `docs/consumer/openclaw-consumer-execution-spec.md`, `docs/consumer/openclaw-main-consumer-consolidation-plan.md`, `docs/consumer/openclaw-consumer-brutal-execution-board.md` | One thinner strategy set after code convergence | temporary branch debt | This tracker and the plan now reflect landed status honestly, but the overall doc set is still heavier than the end state. | + +## Queue Now + +1. Run and validate the real `~/.openclaw` copy into the app-owned runtime root. +2. Collapse or rename remaining consumer-only packaging wrappers. +3. Finish the overlay/defaults contract. +4. Shrink branch/docs debt last. + +## Things We Should Stop Saying + +- Stop treating runtime identity / gateway ownership / service install as open design work. Those slices landed. +- Stop talking as if the future is two apps: `OpenClaw` and `OpenClaw Consumer`. The future is one `OpenClaw` app. +- Stop implying Telegram setup is fully done. The semantics are mostly there; the consumer-first UX layer still is not. +- Stop counting docs cleanup as code progress. It is housekeeping unless a real slice landed first. diff --git a/extensions/telegram/src/channel.setup.ts b/extensions/telegram/src/channel.setup.ts index f28a96afff74f..7e27f6d370cd2 100644 --- a/extensions/telegram/src/channel.setup.ts +++ b/extensions/telegram/src/channel.setup.ts @@ -6,7 +6,6 @@ import { import { buildChannelConfigSchema, getChatChannelMeta, - normalizeAccountId, TelegramConfigSchema, type ChannelPlugin, type OpenClawConfig, @@ -20,42 +19,12 @@ import { } from "./accounts.js"; import type { TelegramProbe } from "./probe.js"; import { telegramSetupAdapter } from "./setup-core.js"; +import { + resolveTelegramAccountSetupStatus, + resolveTelegramAccountSetupUnconfiguredReason, +} from "./setup-state.js"; import { telegramSetupWizard } from "./setup-surface.js"; -function findTelegramTokenOwnerAccountId(params: { - cfg: OpenClawConfig; - accountId: string; -}): string | null { - const normalizedAccountId = normalizeAccountId(params.accountId); - const tokenOwners = new Map(); - for (const id of listTelegramAccountIds(params.cfg)) { - const account = inspectTelegramAccount({ cfg: params.cfg, accountId: id }); - const token = (account.token ?? "").trim(); - if (!token) { - continue; - } - const ownerAccountId = tokenOwners.get(token); - if (!ownerAccountId) { - tokenOwners.set(token, account.accountId); - continue; - } - if (account.accountId === normalizedAccountId) { - return ownerAccountId; - } - } - return null; -} - -function formatDuplicateTelegramTokenReason(params: { - accountId: string; - ownerAccountId: string; -}): string { - return ( - `Duplicate Telegram bot token: account "${params.accountId}" shares a token with ` + - `account "${params.ownerAccountId}". Keep one owner account per bot token.` - ); -} - const telegramConfigAccessors = createScopedAccountConfigAccessors({ resolveAccount: ({ cfg, accountId }) => resolveTelegramAccount({ cfg, accountId }), resolveAllowFrom: (account: ResolvedTelegramAccount) => account.config.allowFrom, @@ -93,32 +62,14 @@ export const telegramSetupPlugin: ChannelPlugin { - if (!account.token?.trim()) { - return false; - } - return !findTelegramTokenOwnerAccountId({ cfg, accountId: account.accountId }); - }, - unconfiguredReason: (account, cfg) => { - if (!account.token?.trim()) { - return "not configured"; - } - const ownerAccountId = findTelegramTokenOwnerAccountId({ cfg, accountId: account.accountId }); - if (!ownerAccountId) { - return "not configured"; - } - return formatDuplicateTelegramTokenReason({ - accountId: account.accountId, - ownerAccountId, - }); - }, + isConfigured: (account, cfg) => resolveTelegramAccountSetupStatus({ cfg, account }).ready, + unconfiguredReason: (account, cfg) => + resolveTelegramAccountSetupUnconfiguredReason({ cfg, account }), describeAccount: (account, cfg) => ({ accountId: account.accountId, name: account.name, enabled: account.enabled, - configured: - Boolean(account.token?.trim()) && - !findTelegramTokenOwnerAccountId({ cfg, accountId: account.accountId }), + configured: resolveTelegramAccountSetupStatus({ cfg, account }).ready, tokenSource: account.tokenSource, }), ...telegramConfigAccessors, diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index 720f4d90c6818..a80132fcfd555 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -17,10 +17,8 @@ import { getChatChannelMeta, listTelegramDirectoryGroupsFromConfig, listTelegramDirectoryPeersFromConfig, - normalizeAccountId, PAIRING_APPROVED_MESSAGE, projectCredentialSnapshotFields, - resolveConfiguredFromCredentialStatuses, resolveTelegramGroupRequireMention, resolveTelegramGroupToolPolicy, TelegramConfigSchema, @@ -57,6 +55,10 @@ import type { TelegramProbe } from "./probe.js"; import { getTelegramRuntime } from "./runtime.js"; import { sendTypingTelegram } from "./send.js"; import { telegramSetupAdapter } from "./setup-core.js"; +import { + resolveTelegramAccountSetupStatus, + resolveTelegramAccountSetupUnconfiguredReason, +} from "./setup-state.js"; import { telegramSetupWizard } from "./setup-surface.js"; import { collectTelegramStatusIssues } from "./status-issues.js"; import { parseTelegramTarget } from "./targets.js"; @@ -72,40 +74,6 @@ function maskTelegramTokenFingerprint(token: string): string { return createHash("sha256").update(token).digest("hex").slice(0, 12); } -function findTelegramTokenOwnerAccountId(params: { - cfg: OpenClawConfig; - accountId: string; -}): string | null { - const normalizedAccountId = normalizeAccountId(params.accountId); - const tokenOwners = new Map(); - for (const id of listTelegramAccountIds(params.cfg)) { - const account = inspectTelegramAccount({ cfg: params.cfg, accountId: id }); - const token = (account.token ?? "").trim(); - if (!token) { - continue; - } - const ownerAccountId = tokenOwners.get(token); - if (!ownerAccountId) { - tokenOwners.set(token, account.accountId); - continue; - } - if (account.accountId === normalizedAccountId) { - return ownerAccountId; - } - } - return null; -} - -function formatDuplicateTelegramTokenReason(params: { - accountId: string; - ownerAccountId: string; -}): string { - return ( - `Duplicate Telegram bot token: account "${params.accountId}" shares a token with ` + - `account "${params.ownerAccountId}". Keep one owner account per bot token.` - ); -} - type TelegramSendOptions = NonNullable[2]>; function buildTelegramSendOptions(params: { @@ -419,32 +387,14 @@ export const telegramPlugin: ChannelPlugin { - if (!account.token?.trim()) { - return false; - } - return !findTelegramTokenOwnerAccountId({ cfg, accountId: account.accountId }); - }, - unconfiguredReason: (account, cfg) => { - if (!account.token?.trim()) { - return "not configured"; - } - const ownerAccountId = findTelegramTokenOwnerAccountId({ cfg, accountId: account.accountId }); - if (!ownerAccountId) { - return "not configured"; - } - return formatDuplicateTelegramTokenReason({ - accountId: account.accountId, - ownerAccountId, - }); - }, + isConfigured: (account, cfg) => resolveTelegramAccountSetupStatus({ cfg, account }).ready, + unconfiguredReason: (account, cfg) => + resolveTelegramAccountSetupUnconfiguredReason({ cfg, account }), describeAccount: (account, cfg) => ({ accountId: account.accountId, name: account.name, enabled: account.enabled, - configured: - Boolean(account.token?.trim()) && - !findTelegramTokenOwnerAccountId({ cfg, accountId: account.accountId }), + configured: resolveTelegramAccountSetupStatus({ cfg, account }).ready, tokenSource: account.tokenSource, }), ...telegramConfigAccessors, @@ -765,19 +715,11 @@ export const telegramPlugin: ChannelPlugin { - const configuredFromStatus = resolveConfiguredFromCredentialStatuses(account); - const ownerAccountId = findTelegramTokenOwnerAccountId({ - cfg, - accountId: account.accountId, - }); - const duplicateTokenReason = ownerAccountId - ? formatDuplicateTelegramTokenReason({ - accountId: account.accountId, - ownerAccountId, - }) - : null; - const configured = - (configuredFromStatus ?? Boolean(account.token?.trim())) && !ownerAccountId; + const setupStatus = resolveTelegramAccountSetupStatus({ cfg, account }); + // Snapshot fields stay presentation-agnostic. setup-state owns the + // semantic difference between configured credentials, ready accounts, + // and setup-blocked accounts such as duplicate-token collisions. + const configured = setupStatus.ready; // Surface mention/privacy hints from the same effective per-account // groups config that the runtime actually uses. const groups = account.config.groups; @@ -795,7 +737,7 @@ export const telegramPlugin: ChannelPlugin { const account = ctx.account; - const ownerAccountId = findTelegramTokenOwnerAccountId({ + const setupStatus = resolveTelegramAccountSetupStatus({ cfg: ctx.cfg, - accountId: account.accountId, + account, }); - if (ownerAccountId) { - const reason = formatDuplicateTelegramTokenReason({ - accountId: account.accountId, - ownerAccountId, - }); + if (setupStatus.blockedReason) { + const reason = setupStatus.blockedReason; ctx.log?.error?.(`[${account.accountId}] ${reason}`); throw new Error(reason); } diff --git a/extensions/telegram/src/setup-state.test.ts b/extensions/telegram/src/setup-state.test.ts new file mode 100644 index 0000000000000..e3e8822c75d88 --- /dev/null +++ b/extensions/telegram/src/setup-state.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from "vitest"; +import { buildChannelSetupWizardAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import { telegramSetupPlugin } from "./channel.setup.js"; +import { + resolveTelegramAccountSetupStatus, + resolveTelegramAccountSetupUnconfiguredReason, + verifyTelegramSetupAccount, +} from "./setup-state.js"; + +const telegramSetupAdapter = buildChannelSetupWizardAdapterFromSetupWizard({ + plugin: telegramSetupPlugin, + wizard: telegramSetupPlugin.setupWizard!, +}); + +function createCfg() { + return { + channels: { + telegram: { + enabled: true, + accounts: { + alerts: { + botToken: "shared-token", // pragma: allowlist secret + }, + work: { + botToken: "shared-token", // pragma: allowlist secret + }, + }, + }, + }, + } as const; +} + +describe("telegram setup-state", () => { + it("marks duplicate token accounts as unconfigured with a shared reason", () => { + const cfg = createCfg(); + + const verification = verifyTelegramSetupAccount({ + cfg, + accountId: "work", + }); + + expect(verification.configured).toBe(false); + expect(verification.duplicateTokenOwnerAccountId).toBe("alerts"); + expect( + resolveTelegramAccountSetupUnconfiguredReason({ + cfg, + accountId: "work", + }), + ).toContain('account "alerts"'); + expect( + resolveTelegramAccountSetupStatus({ + cfg, + accountId: "work", + }), + ).toMatchObject({ + status: "blocked", + credentialConfigured: true, + ready: false, + blocked: true, + }); + }); + + it("distinguishes configured credentials from ready accounts", () => { + const cfg = { + secrets: { + defaults: { + env: "exec-provider", + }, + providers: { + "exec-provider": { + source: "exec", + command: "/usr/bin/env", + }, + }, + }, + channels: { + telegram: { + enabled: true, + accounts: { + work: { + botToken: "${TELEGRAM_WORK_TOKEN}", + }, + }, + }, + }, + } as const; + + expect( + resolveTelegramAccountSetupStatus({ + cfg, + accountId: "work", + }), + ).toMatchObject({ + status: "configured", + credentialConfigured: true, + ready: false, + blocked: false, + blockedReason: null, + }); + }); + + it("keeps channel setup status configured when one account still owns the token", async () => { + const status = await telegramSetupAdapter.getStatus({ + cfg: createCfg(), + accountOverrides: {}, + }); + + expect(status.configured).toBe(true); + }); + + it("applies duplicate-token verification to the setup-only plugin account view", () => { + const cfg = createCfg(); + const workAccount = telegramSetupPlugin.config.resolveAccount(cfg, "work"); + + expect(telegramSetupPlugin.config.isConfigured?.(workAccount, cfg)).toBe(false); + expect(telegramSetupPlugin.config.unconfiguredReason?.(workAccount, cfg)).toContain( + 'account "alerts"', + ); + }); + + it("marks a unique token account as ready", () => { + const cfg = { + channels: { + telegram: { + enabled: true, + accounts: { + ops: { + botToken: "token-ops", + }, + }, + }, + }, + } as const; + + expect( + resolveTelegramAccountSetupStatus({ + cfg, + accountId: "ops", + }), + ).toMatchObject({ + status: "ready", + credentialConfigured: true, + ready: true, + blocked: false, + }); + }); +}); diff --git a/extensions/telegram/src/setup-state.ts b/extensions/telegram/src/setup-state.ts new file mode 100644 index 0000000000000..be0439f1464f8 --- /dev/null +++ b/extensions/telegram/src/setup-state.ts @@ -0,0 +1,171 @@ +import { + normalizeAccountId, + resolveConfiguredFromCredentialStatuses, + type OpenClawConfig, +} from "openclaw/plugin-sdk/telegram"; +import { + inspectTelegramAccount, + type InspectedTelegramAccount, +} from "./account-inspect.js"; +import { listTelegramAccountIds } from "./accounts.js"; + +type TelegramSetupInspectionAccount = Pick & + Partial>; + +export type TelegramSetupVerification = { + hasToken: boolean; + configured: boolean; + duplicateTokenOwnerAccountId: string | null; + duplicateTokenReason: string | null; +}; + +export type TelegramAccountSetupStatusKind = + | "not_configured" + | "configured" + | "ready" + | "blocked"; + +export type TelegramAccountSetupStatusMetadata = { + status: TelegramAccountSetupStatusKind; + credentialConfigured: boolean; + ready: boolean; + blocked: boolean; + blockedReason: string | null; + verification: TelegramSetupVerification; +}; + +// Keep setup verification in one place so the setup wizard, setup-only plugin, +// and runtime plugin all agree on when Telegram is actually usable. +function resolveSetupInspection(params: { + cfg: OpenClawConfig; + account?: TelegramSetupInspectionAccount; + accountId?: string; +}): TelegramSetupInspectionAccount { + if (params.account) { + return params.account; + } + return inspectTelegramAccount({ + cfg: params.cfg, + accountId: params.accountId, + }); +} + +export function findTelegramTokenOwnerAccountId(params: { + cfg: OpenClawConfig; + accountId: string; +}): string | null { + const normalizedAccountId = normalizeAccountId(params.accountId); + const tokenOwners = new Map(); + for (const id of listTelegramAccountIds(params.cfg)) { + const account = inspectTelegramAccount({ cfg: params.cfg, accountId: id }); + const token = (account.token ?? "").trim(); + if (!token) { + continue; + } + const ownerAccountId = tokenOwners.get(token); + if (!ownerAccountId) { + tokenOwners.set(token, account.accountId); + continue; + } + if (account.accountId === normalizedAccountId) { + return ownerAccountId; + } + } + return null; +} + +export function formatDuplicateTelegramTokenReason(params: { + accountId: string; + ownerAccountId: string; +}): string { + return ( + `Duplicate Telegram bot token: account "${params.accountId}" shares a token with ` + + `account "${params.ownerAccountId}". Keep one owner account per bot token.` + ); +} + +export function verifyTelegramSetupAccount(params: { + cfg: OpenClawConfig; + account?: TelegramSetupInspectionAccount; + accountId?: string; +}): TelegramSetupVerification { + const account = resolveSetupInspection(params); + const hasToken = Boolean(account.token?.trim()); + if (!hasToken) { + return { + hasToken: false, + configured: false, + duplicateTokenOwnerAccountId: null, + duplicateTokenReason: null, + }; + } + const duplicateTokenOwnerAccountId = findTelegramTokenOwnerAccountId({ + cfg: params.cfg, + accountId: account.accountId, + }); + return { + hasToken: true, + configured: !duplicateTokenOwnerAccountId, + duplicateTokenOwnerAccountId, + duplicateTokenReason: duplicateTokenOwnerAccountId + ? formatDuplicateTelegramTokenReason({ + accountId: account.accountId, + ownerAccountId: duplicateTokenOwnerAccountId, + }) + : null, + }; +} + +export function resolveTelegramAccountSetupStatus(params: { + cfg: OpenClawConfig; + account?: TelegramSetupInspectionAccount; + accountId?: string; +}): TelegramAccountSetupStatusMetadata { + const account = resolveSetupInspection(params); + const verification = verifyTelegramSetupAccount({ + cfg: params.cfg, + account, + }); + // Keep semantics here so every caller agrees on the difference between: + // - "configured": credentials exist somewhere + // - "ready": Telegram can actually start right now + // - "blocked": credentials exist, but setup semantics forbid startup + const credentialConfigured = + resolveConfiguredFromCredentialStatuses(account) ?? verification.hasToken; + const blockedReason = verification.duplicateTokenReason; + const blocked = credentialConfigured && Boolean(blockedReason); + const ready = credentialConfigured && verification.configured; + const status: TelegramAccountSetupStatusKind = blocked + ? "blocked" + : ready + ? "ready" + : credentialConfigured + ? "configured" + : "not_configured"; + return { + status, + credentialConfigured, + ready, + blocked, + blockedReason: blockedReason ?? null, + verification, + }; +} + +export function resolveTelegramSetupConfigured(cfg: OpenClawConfig): boolean { + return listTelegramAccountIds(cfg).some((accountId) => + resolveTelegramAccountSetupStatus({ cfg, accountId }).ready, + ); +} + +export function resolveTelegramAccountSetupUnconfiguredReason(params: { + cfg: OpenClawConfig; + account?: TelegramSetupInspectionAccount; + accountId?: string; +}): string { + const status = resolveTelegramAccountSetupStatus(params); + if (status.blockedReason) { + return status.blockedReason; + } + return "not configured"; +} diff --git a/extensions/telegram/src/setup-surface.ts b/extensions/telegram/src/setup-surface.ts index d0f122af174ce..6c101b13bda53 100644 --- a/extensions/telegram/src/setup-surface.ts +++ b/extensions/telegram/src/setup-surface.ts @@ -9,8 +9,7 @@ import { type ChannelSetupWizard } from "../../../src/channels/plugins/setup-wiz import type { OpenClawConfig } from "../../../src/config/config.js"; import { hasConfiguredSecretInput } from "../../../src/config/types.secrets.js"; import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; -import { inspectTelegramAccount } from "./account-inspect.js"; -import { listTelegramAccountIds, resolveTelegramAccount } from "./accounts.js"; +import { resolveTelegramAccount } from "./accounts.js"; import { parseTelegramAllowFromId, promptTelegramAllowFromForAccount, @@ -19,6 +18,7 @@ import { TELEGRAM_USER_ID_HELP_LINES, telegramSetupAdapter, } from "./setup-core.js"; +import { resolveTelegramSetupConfigured } from "./setup-state.js"; const channel = "telegram" as const; @@ -46,11 +46,8 @@ export const telegramSetupWizard: ChannelSetupWizard = { unconfiguredHint: "recommended · newcomer-friendly", configuredScore: 1, unconfiguredScore: 10, - resolveConfigured: ({ cfg }) => - listTelegramAccountIds(cfg).some((accountId) => { - const account = inspectTelegramAccount({ cfg, accountId }); - return account.configured; - }), + // UX copy stays here; setup-state owns whether Telegram is actually usable. + resolveConfigured: ({ cfg }) => resolveTelegramSetupConfigured(cfg), }, credentials: [ { diff --git a/scripts/consumer-preflight.sh b/scripts/consumer-preflight.sh index 4171767297644..6b323161a7ed9 100755 --- a/scripts/consumer-preflight.sh +++ b/scripts/consumer-preflight.sh @@ -156,7 +156,10 @@ const cp = require("node:child_process"); const currentInstanceId = process.env.CURRENT_INSTANCE_ID ?? "default"; const currentLabel = process.env.CURRENT_LABEL ?? "ai.openclaw.consumer.gateway"; const uid = typeof process.getuid === "function" ? process.getuid() : null; -const baseRoot = path.join(os.homedir(), "Library", "Application Support", "OpenClaw Consumer"); +const baseRoots = [ + path.join(os.homedir(), "Library", "Application Support", "OpenClaw"), + path.join(os.homedir(), "Library", "Application Support", "OpenClaw Consumer"), +]; const configs = []; function launchdLoaded(label) { @@ -219,13 +222,15 @@ function collect(instanceId, configPath) { } } -collect("default", path.join(baseRoot, ".openclaw", "openclaw.json")); +for (const baseRoot of baseRoots) { + collect("default", path.join(baseRoot, ".openclaw", "openclaw.json")); -const instancesRoot = path.join(baseRoot, "instances"); -if (fs.existsSync(instancesRoot)) { - for (const entry of fs.readdirSync(instancesRoot, { withFileTypes: true })) { - if (!entry.isDirectory()) continue; - collect(entry.name, path.join(instancesRoot, entry.name, ".openclaw", "openclaw.json")); + const instancesRoot = path.join(baseRoot, "instances"); + if (fs.existsSync(instancesRoot)) { + for (const entry of fs.readdirSync(instancesRoot, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + collect(entry.name, path.join(instancesRoot, entry.name, ".openclaw", "openclaw.json")); + } } } diff --git a/scripts/consumer-runtime-identity.ts b/scripts/consumer-runtime-identity.ts new file mode 100644 index 0000000000000..63bd97e5788ca --- /dev/null +++ b/scripts/consumer-runtime-identity.ts @@ -0,0 +1,107 @@ +import { pathToFileURL } from "node:url"; + +import { + inferConsumerRuntimeIdFromCheckout, + normalizeConsumerRuntimeId, + resolveConsumerRuntimeIdentity, +} from "../src/consumer/runtime-identity.js"; + +type Command = "default-id" | "field" | "json" | "normalize"; + +function main(argv: string[]): void { + const [command, ...rest] = argv as [Command | undefined, ...string[]]; + switch (command) { + case "normalize": { + process.stdout.write(normalizeConsumerRuntimeId(rest[0] ?? "")); + return; + } + case "default-id": { + const args = parseArgs(rest); + requireFlag(args.rootDir, "--root"); + process.stdout.write( + inferConsumerRuntimeIdFromCheckout({ + rootDir: args.rootDir, + }), + ); + return; + } + case "field": { + const args = parseArgs(rest); + requireFlag(args.field, "--field"); + const identity = resolveConsumerRuntimeIdentity({ + homeDir: args.homeDir, + instanceId: args.instanceId, + }); + const value = identity[args.field]; + if (value === undefined) { + throw new Error(`unknown consumer runtime identity field: ${args.field}`); + } + process.stdout.write(String(value)); + return; + } + case "json": { + const args = parseArgs(rest); + const identity = resolveConsumerRuntimeIdentity({ + homeDir: args.homeDir, + instanceId: args.instanceId, + }); + process.stdout.write(`${JSON.stringify(identity)}\n`); + return; + } + default: + throw new Error( + "Usage: node --import tsx scripts/consumer-runtime-identity.ts [--instance ] [--home ] [--root ] [--field ]", + ); + } +} + +function parseArgs(argv: string[]): { + field?: string; + homeDir?: string; + instanceId?: string; + rootDir?: string; +} { + const parsed: { + field?: string; + homeDir?: string; + instanceId?: string; + rootDir?: string; + } = {}; + + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index]; + const value = argv[index + 1]; + switch (token) { + case "--field": + parsed.field = value ?? ""; + index += 1; + break; + case "--home": + parsed.homeDir = value ?? ""; + index += 1; + break; + case "--instance": + parsed.instanceId = value ?? ""; + index += 1; + break; + case "--root": + parsed.rootDir = value ?? ""; + index += 1; + break; + default: + throw new Error(`unknown argument: ${token}`); + } + } + + return parsed; +} + +function requireFlag(value: string | undefined, flag: string): asserts value is string { + if (!value) { + throw new Error(`${flag} is required`); + } +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + main(process.argv.slice(2)); +} diff --git a/scripts/lib/consumer-instance.sh b/scripts/lib/consumer-instance.sh index 4d46d13d6dd2e..15cef42716601 100644 --- a/scripts/lib/consumer-instance.sh +++ b/scripts/lib/consumer-instance.sh @@ -1,61 +1,59 @@ #!/usr/bin/env bash # Shared consumer-instance derivation for local packaging/launch scripts. -# Keep this logic aligned with apps/macos/Sources/OpenClaw/ConsumerInstance.swift. +# Runtime identity math now lives in TypeScript so shell, tests, and future +# call sites all read the same contract instead of quietly drifting apart. + +consumer_instance_repo_root() { + local script_dir + script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + cd "${script_dir}/../.." && pwd +} + +consumer_instance_contract() { + local repo_root + repo_root="$(consumer_instance_repo_root)" + node --import tsx "${repo_root}/scripts/consumer-runtime-identity.ts" "$@" +} + +consumer_instance_identity_field() { + local field="$1" + shift + + local home_dir="${HOME}" + local normalized="" + if [[ $# -ge 2 ]]; then + home_dir="${1:-$HOME}" + normalized="${2:-}" + else + normalized="${1:-}" + fi + + consumer_instance_contract field --field "$field" --home "$home_dir" --instance "$normalized" +} consumer_instance_normalize_id() { local raw="${1:-}" - node -e ' - const raw = (process.argv[1] ?? "").trim().toLowerCase(); - if (!raw) process.exit(0); - const normalized = raw - .replace(/[^a-z0-9]+/g, "-") - .replace(/-+/g, "-") - .replace(/^-+|-+$/g, ""); - if (normalized) process.stdout.write(normalized); - ' -- "$raw" + consumer_instance_contract normalize "$raw" } consumer_instance_default_id_for_checkout() { local root_dir="$1" - local absolute_git_dir="" - local inferred="" - - absolute_git_dir="$(git -C "$root_dir" rev-parse --absolute-git-dir 2>/dev/null || true)" - if [[ "$absolute_git_dir" == *"/worktrees/"* ]]; then - inferred="$(basename "$root_dir")" - consumer_instance_normalize_id "$inferred" - return - fi - - printf '' + consumer_instance_contract default-id --root "$root_dir" } consumer_instance_gateway_port() { local normalized="${1:-}" - if [[ -z "$normalized" ]]; then - printf '19001' - return - fi - - node -e ' - const text = process.argv[1]; - let hash = 0x811c9dc5; - for (const byte of Buffer.from(text, "utf8")) { - hash ^= byte; - hash = Math.imul(hash, 0x01000193) >>> 0; - } - process.stdout.write(String(20000 + (hash % 20000))); - ' -- "$normalized" + consumer_instance_identity_field gatewayPort "$normalized" } consumer_instance_app_name() { local normalized="${1:-}" if [[ -z "$normalized" ]]; then - printf 'OpenClaw Consumer' + printf 'OpenClaw' return fi - printf 'OpenClaw Consumer (%s)' "$normalized" + printf 'OpenClaw (%s)' "$normalized" } consumer_instance_stable_tcc_identity_enabled() { @@ -77,7 +75,7 @@ consumer_instance_display_name() { # packaged app identity to collapse back to the stable debug app when local QA # explicitly opts in to that mode. if consumer_instance_stable_tcc_identity_enabled; then - printf 'OpenClaw Consumer' + printf 'OpenClaw' return fi consumer_instance_app_name "$normalized" @@ -114,100 +112,35 @@ consumer_instance_app_path() { } consumer_instance_runtime_root() { - local home_dir="${HOME}" - local normalized="" - - # Accept both the legacy single-argument form and the new explicit-home - # form so older call sites keep working while the verifier can inspect - # a specific user's runtime tree. - if [[ $# -ge 2 ]]; then - home_dir="${1:-$HOME}" - normalized="${2:-}" - else - normalized="${1:-}" - fi - - local runtime_root="${home_dir}/Library/Application Support/OpenClaw Consumer" - if [[ -z "$normalized" ]]; then - printf '%s' "$runtime_root" - return - fi - printf '%s/instances/%s' "$runtime_root" "$normalized" + consumer_instance_identity_field runtimeRoot "$@" } consumer_instance_state_dir() { - local home_dir="${HOME}" - local normalized="" - if [[ $# -ge 2 ]]; then - home_dir="${1:-$HOME}" - normalized="${2:-}" - else - normalized="${1:-}" - fi - printf '%s/.openclaw' "$(consumer_instance_runtime_root "$home_dir" "$normalized")" + consumer_instance_identity_field stateDir "$@" } consumer_instance_config_path() { - local home_dir="${HOME}" - local normalized="" - if [[ $# -ge 2 ]]; then - home_dir="${1:-$HOME}" - normalized="${2:-}" - else - normalized="${1:-}" - fi - printf '%s/openclaw.json' "$(consumer_instance_state_dir "$home_dir" "$normalized")" + consumer_instance_identity_field configPath "$@" } consumer_instance_workspace_path() { - local home_dir="${HOME}" - local normalized="" - if [[ $# -ge 2 ]]; then - home_dir="${1:-$HOME}" - normalized="${2:-}" - else - normalized="${1:-}" - fi - printf '%s/workspace' "$(consumer_instance_state_dir "$home_dir" "$normalized")" + consumer_instance_identity_field workspacePath "$@" } consumer_instance_logs_path() { - local home_dir="${HOME}" - local normalized="" - if [[ $# -ge 2 ]]; then - home_dir="${1:-$HOME}" - normalized="${2:-}" - else - normalized="${1:-}" - fi - printf '%s/logs' "$(consumer_instance_state_dir "$home_dir" "$normalized")" + consumer_instance_identity_field logDir "$@" } consumer_instance_profile() { - local normalized="${1:-}" - if [[ -z "$normalized" ]]; then - printf 'consumer' - return - fi - printf 'consumer-%s' "$normalized" + consumer_instance_identity_field profile "$1" } consumer_instance_launchd_label() { - local normalized="${1:-}" - if [[ -z "$normalized" ]]; then - printf 'ai.openclaw.consumer' - return - fi - printf 'ai.openclaw.consumer.%s' "$normalized" + consumer_instance_identity_field launchdLabel "$1" } consumer_instance_gateway_launchd_label() { - local normalized="${1:-}" - if [[ -z "$normalized" ]]; then - printf 'ai.openclaw.consumer.gateway' - return - fi - printf 'ai.openclaw.consumer.%s.gateway' "$normalized" + consumer_instance_identity_field gatewayLaunchdLabel "$1" } consumer_instance_apply_runtime_env() { @@ -218,6 +151,8 @@ consumer_instance_apply_runtime_env() { local state_dir state_dir="$(consumer_instance_state_dir "$normalized")" + local runtime_root + runtime_root="$(consumer_instance_runtime_root "$normalized")" # Consumer lanes must derive runtime ownership from the instance id alone. # If a caller leaves stale OPENCLAW_* overrides in the shell, commands like @@ -226,12 +161,12 @@ consumer_instance_apply_runtime_env() { # the wrapper, service install, and status flow all share one source of truth. export OPENCLAW_CONSUMER_INSTANCE_ID="$normalized" export OPENCLAW_PROFILE="$(consumer_instance_profile "$normalized")" - export OPENCLAW_HOME="$state_dir" + export OPENCLAW_HOME="$runtime_root" export OPENCLAW_STATE_DIR="$state_dir" export OPENCLAW_CONFIG_PATH="$(consumer_instance_config_path "$normalized")" export OPENCLAW_GATEWAY_PORT="$(consumer_instance_gateway_port "$normalized")" - export OPENCLAW_GATEWAY_BIND="loopback" - export OPENCLAW_LOG_DIR="${state_dir}/logs" + export OPENCLAW_GATEWAY_BIND="$(consumer_instance_identity_field gatewayBind "$normalized")" + export OPENCLAW_LOG_DIR="$(consumer_instance_logs_path "$normalized")" export OPENCLAW_LAUNCHD_LABEL="$(consumer_instance_gateway_launchd_label "$normalized")" } diff --git a/scripts/migrate-openclaw-runtime-to-app-support.sh b/scripts/migrate-openclaw-runtime-to-app-support.sh new file mode 100755 index 0000000000000..826aefddbb5f7 --- /dev/null +++ b/scripts/migrate-openclaw-runtime-to-app-support.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +set -euo pipefail + +SOURCE="${HOME}/.openclaw" +DEST="${HOME}/Library/Application Support/OpenClaw/.openclaw" +DRY_RUN=0 +FORCE=0 + +usage() { + cat <<'EOF' +Usage: scripts/migrate-openclaw-runtime-to-app-support.sh [--dry-run] [--force] [--source ] [--dest ] + +Copy the legacy OpenClaw runtime into the macOS app-owned runtime root. + +Defaults: + source: ~/.openclaw + dest: ~/Library/Application Support/OpenClaw/.openclaw + +This script copies only. It never deletes or moves the source runtime. +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --dry-run) + DRY_RUN=1 + shift + ;; + --force) + FORCE=1 + shift + ;; + --source) + if [[ $# -lt 2 ]]; then + echo "ERROR: --source requires a path" >&2 + exit 1 + fi + SOURCE="$2" + shift 2 + ;; + --dest) + if [[ $# -lt 2 ]]; then + echo "ERROR: --dest requires a path" >&2 + exit 1 + fi + DEST="$2" + shift 2 + ;; + --help|-h) + usage + exit 0 + ;; + *) + echo "ERROR: unknown argument: $1" >&2 + usage >&2 + exit 1 + ;; + esac +done + +if [[ ! -d "$SOURCE" ]]; then + echo "ERROR: source runtime does not exist: $SOURCE" >&2 + exit 1 +fi + +if [[ ! -f "$SOURCE/openclaw.json" ]]; then + echo "ERROR: source runtime is missing openclaw.json: $SOURCE" >&2 + exit 1 +fi + +if [[ -f "$DEST/openclaw.json" && "$FORCE" != "1" ]]; then + echo "ERROR: destination already has openclaw.json: $DEST" >&2 + echo "Refusing to overwrite existing app-owned runtime. Re-run with --force to copy missing files only." >&2 + exit 1 +fi + +mkdir -p "$DEST" + +RSYNC_ARGS=(-aE --ignore-existing) +if [[ "$DRY_RUN" == "1" ]]; then + RSYNC_ARGS+=(--dry-run --itemize-changes) +fi + +echo "Source: $SOURCE" +echo "Destination: $DEST" +if [[ "$DRY_RUN" == "1" ]]; then + echo "Mode: dry run" +else + echo "Mode: copy missing files" +fi + +rsync "${RSYNC_ARGS[@]}" "$SOURCE/" "$DEST/" + +if [[ "$DRY_RUN" != "1" ]]; then + if [[ ! -f "$DEST/openclaw.json" ]]; then + echo "ERROR: migration finished but destination config is missing: $DEST/openclaw.json" >&2 + exit 1 + fi + echo "Migration copy complete." + echo "Old runtime left untouched: $SOURCE" +fi diff --git a/scripts/package-mac-app.sh b/scripts/package-mac-app.sh index 4c5e24f4786d6..ecf971016ea85 100755 --- a/scripts/package-mac-app.sh +++ b/scripts/package-mac-app.sh @@ -8,14 +8,17 @@ ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" source "${ROOT_DIR}/scripts/lib/validated-node.sh" openclaw_use_validated_node "${ROOT_DIR}" >/dev/null NODE_BIN="${OPENCLAW_NODE_BIN}" -APP_NAME="${APP_NAME:-OpenClaw Consumer}" +APP_NAME="${APP_NAME:-OpenClaw}" APP_BUNDLE_NAME="${APP_BUNDLE_NAME:-${APP_NAME}.app}" APP_ROOT="$ROOT_DIR/dist/${APP_BUNDLE_NAME}" BUILD_ROOT="$ROOT_DIR/apps/macos/.build" PRODUCT="OpenClaw" -BUNDLE_ID="${BUNDLE_ID:-ai.openclaw.consumer.mac.debug}" +# Default packaging is the single OpenClaw product app. It uses the simplified +# consumer-style UX, while APP_VARIANT=standard remains the explicit old-main +# compatibility mode for ~/.openclaw / ai.openclaw.gateway / port 18789. +BUNDLE_ID="${BUNDLE_ID:-ai.openclaw.mac.debug}" APP_VARIANT="${APP_VARIANT:-consumer}" -URL_SCHEME="${URL_SCHEME:-openclaw-consumer}" +URL_SCHEME="${URL_SCHEME:-openclaw}" PKG_VERSION="$(cd "$ROOT_DIR" && "${NODE_BIN}" -p "require('./package.json').version" 2>/dev/null || echo "0.0.0")" BUILD_TS=$(date -u +"%Y-%m-%dT%H:%M:%SZ") GIT_COMMIT=$(cd "$ROOT_DIR" && git rev-parse --short HEAD 2>/dev/null || echo "unknown") diff --git a/scripts/restart-mac.sh b/scripts/restart-mac.sh index 420dea06f2b2a..4a978db2ddb29 100755 --- a/scripts/restart-mac.sh +++ b/scripts/restart-mac.sh @@ -305,8 +305,10 @@ elif [ "$SIGN" -eq 1 ]; then unset SIGN_IDENTITY fi -# 3) Package app (no embedded gateway). -run_step "package app" bash -lc "cd '${ROOT_DIR}' && SKIP_TSC=${SKIP_TSC:-1} '${ROOT_DIR}/scripts/package-mac-app.sh'" +# 3) Package app (no embedded gateway). Shared-main restarts must stay on the +# legacy runtime identity explicitly; normal packaging now defaults to the +# simple OpenClaw product lane. +run_step "package app" bash -lc "cd '${ROOT_DIR}' && APP_VARIANT=standard SKIP_TSC=${SKIP_TSC:-1} '${ROOT_DIR}/scripts/package-mac-app.sh'" choose_app_bundle() { if [[ -n "${APP_BUNDLE}" && -d "${APP_BUNDLE}" ]]; then diff --git a/scripts/worktree-doctor.sh b/scripts/worktree-doctor.sh index 39c589e804425..1b0e455cd4cdb 100644 --- a/scripts/worktree-doctor.sh +++ b/scripts/worktree-doctor.sh @@ -147,7 +147,10 @@ const cp = require("node:child_process"); const currentInstanceId = process.env.CURRENT_INSTANCE_ID ?? "default"; const currentLabel = process.env.CURRENT_LABEL ?? "ai.openclaw.consumer.gateway"; const uid = typeof process.getuid === "function" ? process.getuid() : null; -const baseRoot = path.join(os.homedir(), "Library", "Application Support", "OpenClaw Consumer"); +const baseRoots = [ + path.join(os.homedir(), "Library", "Application Support", "OpenClaw"), + path.join(os.homedir(), "Library", "Application Support", "OpenClaw Consumer"), +]; const configs = []; function launchdLoaded(label) { @@ -210,13 +213,15 @@ function collect(instanceId, configPath) { } } -collect("default", path.join(baseRoot, ".openclaw", "openclaw.json")); +for (const baseRoot of baseRoots) { + collect("default", path.join(baseRoot, ".openclaw", "openclaw.json")); -const instancesRoot = path.join(baseRoot, "instances"); -if (fs.existsSync(instancesRoot)) { - for (const entry of fs.readdirSync(instancesRoot, { withFileTypes: true })) { - if (!entry.isDirectory()) continue; - collect(entry.name, path.join(instancesRoot, entry.name, ".openclaw", "openclaw.json")); + const instancesRoot = path.join(baseRoot, "instances"); + if (fs.existsSync(instancesRoot)) { + for (const entry of fs.readdirSync(instancesRoot, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + collect(entry.name, path.join(instancesRoot, entry.name, ".openclaw", "openclaw.json")); + } } } diff --git a/src/agents/skills-status.ts b/src/agents/skills-status.ts index 02ff68e2efc1b..79c03e1315380 100644 --- a/src/agents/skills-status.ts +++ b/src/agents/skills-status.ts @@ -1,15 +1,11 @@ import path from "node:path"; import type { OpenClawConfig } from "../config/config.js"; -import { evaluateEntryRequirementsForCurrentPlatform } from "../shared/entry-status.js"; import type { RequirementConfigCheck, Requirements } from "../shared/requirements.js"; import { CONFIG_DIR } from "../utils.js"; import { + evaluateSkillEntry, hasBinary, - isBundledSkillAllowed, - isConfigPathTruthy, loadWorkspaceSkillEntries, - resolveBundledAllowlist, - resolveSkillConfig, resolveSkillsInstallPreferences, type SkillEntry, type SkillEligibilityContext, @@ -174,33 +170,11 @@ function buildSkillStatus( bundledNames?: Set, ): SkillStatusEntry { const skillKey = resolveSkillKey(entry); - const skillConfig = resolveSkillConfig(config, skillKey); - const disabled = skillConfig?.enabled === false; - const allowBundled = resolveBundledAllowlist(config); - const blockedByAllowlist = !isBundledSkillAllowed(entry, allowBundled); - const always = entry.metadata?.always === true; - const isEnvSatisfied = (envName: string) => - Boolean( - process.env[envName] || - skillConfig?.env?.[envName] || - (skillConfig?.apiKey && entry.metadata?.primaryEnv === envName), - ); - const isConfigSatisfied = (pathStr: string) => isConfigPathTruthy(config, pathStr); const bundled = bundledNames && bundledNames.size > 0 ? bundledNames.has(entry.skill.name) : entry.skill.source === "openclaw-bundled"; - - const { emoji, homepage, required, missing, requirementsSatisfied, configChecks } = - evaluateEntryRequirementsForCurrentPlatform({ - always, - entry, - hasLocalBin: hasBinary, - remote: eligibility?.remote, - isEnvSatisfied, - isConfigSatisfied, - }); - const eligible = !disabled && !blockedByAllowlist && requirementsSatisfied; + const evaluation = evaluateSkillEntry({ entry, config, eligibility }); return { name: entry.skill.name, @@ -211,15 +185,15 @@ function buildSkillStatus( baseDir: entry.skill.baseDir, skillKey, primaryEnv: entry.metadata?.primaryEnv, - emoji, - homepage, - always, - disabled, - blockedByAllowlist, - eligible, - requirements: required, - missing, - configChecks, + emoji: evaluation.emoji, + homepage: evaluation.homepage, + always: entry.metadata?.always === true, + disabled: evaluation.disabled, + blockedByAllowlist: evaluation.blockedByAllowlist, + eligible: evaluation.eligible, + requirements: evaluation.requirements, + missing: evaluation.missing, + configChecks: evaluation.configChecks, install: normalizeInstallOptions(entry, prefs ?? resolveSkillsInstallPreferences(config)), }; } diff --git a/src/agents/skills.buildworkspaceskillstatus.test.ts b/src/agents/skills.buildworkspaceskillstatus.test.ts index c8b7c220e50ce..5f545c5b2e28e 100644 --- a/src/agents/skills.buildworkspaceskillstatus.test.ts +++ b/src/agents/skills.buildworkspaceskillstatus.test.ts @@ -1,3 +1,6 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { describe, expect, it } from "vitest"; import { withEnv } from "../test-utils/env.js"; import { buildWorkspaceSkillStatus } from "./skills-status.js"; @@ -154,4 +157,40 @@ describe("buildWorkspaceSkillStatus", () => { expect(skill?.install).toEqual([]); } }); + + it("treats relative helper bins as satisfied in status output", async () => { + const skillRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-skill-status-")); + const helperDir = path.join(skillRoot, "scripts"); + const helperBin = path.join(helperDir, "helper.sh"); + fs.mkdirSync(helperDir, { recursive: true }); + fs.writeFileSync(helperBin, "#!/bin/sh\nexit 0\n", "utf8"); + + const entry: SkillEntry = { + skill: { + name: "relative-bin-skill", + description: "Uses a workspace helper", + source: "openclaw-workspace", + filePath: path.join(skillRoot, "SKILL.md"), + baseDir: skillRoot, + disableModelInvocation: false, + }, + frontmatter: {}, + metadata: { + requires: { + bins: ["./scripts/helper.sh"], + }, + }, + }; + + const report = withEnv({ PATH: "" }, () => + buildWorkspaceSkillStatus("/tmp/ws", { + entries: [entry], + }), + ); + const skill = report.skills.find((reportEntry) => reportEntry.name === "relative-bin-skill"); + + expect(skill).toBeDefined(); + expect(skill?.eligible).toBe(true); + expect(skill?.missing.bins).toEqual([]); + }); }); diff --git a/src/agents/skills.ts b/src/agents/skills.ts index 69f5c9f878e9d..7a45cf317c3e3 100644 --- a/src/agents/skills.ts +++ b/src/agents/skills.ts @@ -2,6 +2,7 @@ import type { OpenClawConfig } from "../config/config.js"; import type { SkillsInstallPreferences } from "./skills/types.js"; export { + evaluateSkillEntry, hasBinary, isBundledSkillAllowed, isConfigPathTruthy, diff --git a/src/agents/skills/config.ts b/src/agents/skills/config.ts index d61fc306b6cde..e853b86d3a69f 100644 --- a/src/agents/skills/config.ts +++ b/src/agents/skills/config.ts @@ -1,8 +1,8 @@ import fs from "node:fs"; import path from "node:path"; import type { OpenClawConfig, SkillConfig } from "../../config/config.js"; +import { evaluateEntryRequirementsForCurrentPlatform } from "../../shared/entry-status.js"; import { - evaluateRuntimeEligibility, hasBinary, isConfigPathTruthyWithDefaults, resolveConfigPath, @@ -93,38 +93,85 @@ function hasRelativeSkillBin(entry: SkillEntry, bin: string): boolean { } } -export function shouldIncludeSkill(params: { +function resolveSkillHasLocalBin(entry: SkillEntry): (bin: string) => boolean { + return (bin) => hasRelativeSkillBin(entry, bin) || hasBinary(bin); +} + +type SkillEntryEvaluation = { + skillKey: string; + skillConfig?: SkillConfig; + disabled: boolean; + blockedByAllowlist: boolean; + emoji?: string; + homepage?: string; + requirements: { + bins: string[]; + anyBins: string[]; + env: string[]; + config: string[]; + os: string[]; + }; + missing: { + bins: string[]; + anyBins: string[]; + env: string[]; + config: string[]; + os: string[]; + }; + configChecks: ReturnType["configChecks"]; + requirementsSatisfied: boolean; + eligible: boolean; +}; + +export function evaluateSkillEntry(params: { entry: SkillEntry; config?: OpenClawConfig; eligibility?: SkillEligibilityContext; -}): boolean { +}): SkillEntryEvaluation { const { entry, config, eligibility } = params; const skillKey = resolveSkillKey(entry.skill, entry); const skillConfig = resolveSkillConfig(config, skillKey); const allowBundled = normalizeAllowlist(config?.skills?.allowBundled); + const disabled = skillConfig?.enabled === false; + const blockedByAllowlist = !isBundledSkillAllowed(entry, allowBundled); - if (skillConfig?.enabled === false) { - return false; - } - if (!isBundledSkillAllowed(entry, allowBundled)) { - return false; - } - return evaluateRuntimeEligibility({ - os: entry.metadata?.os, - remotePlatforms: eligibility?.remote?.platforms, - always: entry.metadata?.always, - requires: entry.metadata?.requires, - // Workspace skills often declare helper scripts like "./scripts/foo.sh" in - // requires.bins. Those are local artifacts, not PATH binaries. - hasBin: (bin) => hasRelativeSkillBin(entry, bin) || hasBinary(bin), - hasRemoteBin: eligibility?.remote?.hasBin, - hasAnyRemoteBin: eligibility?.remote?.hasAnyBin, - hasEnv: (envName) => - Boolean( - process.env[envName] || - skillConfig?.env?.[envName] || - (skillConfig?.apiKey && entry.metadata?.primaryEnv === envName), - ), - isConfigPathTruthy: (configPath) => isConfigPathTruthy(config, configPath), - }); + // Shared-core decides whether a skill is usable at all. Overlay-owned defaults + // and visibility can still sit on top, but they should not fork this semantic + // evaluation or status/prompt drift comes back immediately. + const { emoji, homepage, required, missing, requirementsSatisfied, configChecks } = + evaluateEntryRequirementsForCurrentPlatform({ + always: entry.metadata?.always === true, + entry, + hasLocalBin: resolveSkillHasLocalBin(entry), + remote: eligibility?.remote, + isEnvSatisfied: (envName) => + Boolean( + process.env[envName] || + skillConfig?.env?.[envName] || + (skillConfig?.apiKey && entry.metadata?.primaryEnv === envName), + ), + isConfigSatisfied: (configPath) => isConfigPathTruthy(config, configPath), + }); + + return { + skillKey, + skillConfig, + disabled, + blockedByAllowlist, + emoji, + homepage, + requirements: required, + missing, + configChecks, + requirementsSatisfied, + eligible: !disabled && !blockedByAllowlist && requirementsSatisfied, + }; +} + +export function shouldIncludeSkill(params: { + entry: SkillEntry; + config?: OpenClawConfig; + eligibility?: SkillEligibilityContext; +}): boolean { + return evaluateSkillEntry(params).eligible; } diff --git a/src/cli/daemon-cli/install-ownership.ts b/src/cli/daemon-cli/install-ownership.ts index c7c4aee9366d6..8a200e91f1def 100644 --- a/src/cli/daemon-cli/install-ownership.ts +++ b/src/cli/daemon-cli/install-ownership.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import path from "node:path"; import { GATEWAY_LAUNCH_AGENT_LABEL, normalizeGatewayProfile } from "../../daemon/constants.js"; +import { resolveGatewayRuntimeIdentityEnv } from "../../daemon/service-env.js"; import type { GatewayService, GatewayServiceCommandConfig, @@ -72,19 +73,21 @@ function buildOwnershipSnapshot(args: { workingDirectory?: string; environment?: GatewayServiceEnv; }): GatewayInstallOwnershipSnapshot { + const daemonEnv = resolveGatewayRuntimeIdentityEnv(args.environment ?? {}); return { entrypoint: resolveGatewayEntrypoint(args.programArguments), workingDirectory: normalizeWorkingDirectory(args.workingDirectory), - daemonEnv: filterDaemonEnv(args.environment as Record | undefined), + daemonEnv: filterDaemonEnv(daemonEnv as Record), port: parsePortFromArgs(args.programArguments), }; } function isDefaultSharedGatewayInstallTarget(env: GatewayServiceEnv): boolean { - if (normalizeGatewayProfile(env.OPENCLAW_PROFILE)) { + const daemonEnv = resolveGatewayRuntimeIdentityEnv(env); + if (normalizeGatewayProfile(daemonEnv.OPENCLAW_PROFILE)) { return false; } - const launchdLabel = env.OPENCLAW_LAUNCHD_LABEL?.trim(); + const launchdLabel = daemonEnv.OPENCLAW_LAUNCHD_LABEL?.trim(); if ( process.platform === "darwin" && launchdLabel && diff --git a/src/cli/daemon-cli/install.test.ts b/src/cli/daemon-cli/install.test.ts index 5db057621c6ba..eba227f86d4b1 100644 --- a/src/cli/daemon-cli/install.test.ts +++ b/src/cli/daemon-cli/install.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { resolveConsumerRuntimeIdentity } from "../../consumer/runtime-identity.js"; import { captureFullEnv } from "../../test-utils/env.js"; import type { DaemonActionResponse } from "./response.js"; @@ -264,6 +265,33 @@ describe("runDaemonInstall", () => { ).toBe(true); }); + it("normalizes consumer lane identity before installing the service", async () => { + const identity = resolveConsumerRuntimeIdentity({ + instanceId: "main-durable-lane", + }); + process.env.OPENCLAW_PROFILE = "consumer-main-durable-lane"; + + await runDaemonInstall({ json: true }); + + expect(buildGatewayInstallPlanMock).toHaveBeenCalledWith( + expect.objectContaining({ + env: expect.objectContaining({ + OPENCLAW_CONSUMER_INSTANCE_ID: "main-durable-lane", + OPENCLAW_PROFILE: identity.profile, + OPENCLAW_STATE_DIR: identity.stateDir, + OPENCLAW_CONFIG_PATH: identity.configPath, + OPENCLAW_GATEWAY_PORT: String(identity.gatewayPort), + OPENCLAW_LAUNCHD_LABEL: identity.gatewayLaunchdLabel, + }), + }), + ); + expect(service.isLoaded).toHaveBeenCalledWith({ + env: expect.objectContaining({ + OPENCLAW_LAUNCHD_LABEL: identity.gatewayLaunchdLabel, + }), + }); + }); + it("does not treat env-template gateway.auth.token as plaintext during install", async () => { loadConfigMock.mockReturnValue({ gateway: { auth: { mode: "token", token: "${OPENCLAW_GATEWAY_TOKEN}" } }, diff --git a/src/cli/daemon-cli/install.ts b/src/cli/daemon-cli/install.ts index 05b92302a48e0..e9be163e69772 100644 --- a/src/cli/daemon-cli/install.ts +++ b/src/cli/daemon-cli/install.ts @@ -5,6 +5,7 @@ import { } from "../../commands/daemon-runtime.js"; import { resolveGatewayInstallToken } from "../../commands/gateway-install-token.js"; import { readBestEffortConfig, resolveGatewayPort } from "../../config/config.js"; +import { resolveGatewayRuntimeIdentityEnv } from "../../daemon/service-env.js"; import { resolveGatewayService } from "../../daemon/service.js"; import { isNonFatalSystemdInstallProbeError } from "../../daemon/systemd.js"; import { defaultRuntime } from "../../runtime.js"; @@ -20,6 +21,7 @@ import type { DaemonInstallOptions } from "./types.js"; export async function runDaemonInstall(opts: DaemonInstallOptions) { const { json, stdout, warnings, emit, fail } = createDaemonInstallActionContext(opts.json); + const daemonEnv = resolveGatewayRuntimeIdentityEnv(process.env); if (failIfNixDaemonInstallMode(fail)) { return; } @@ -30,7 +32,7 @@ export async function runDaemonInstall(opts: DaemonInstallOptions) { fail("Invalid port"); return; } - const port = portOverride ?? resolveGatewayPort(cfg); + const port = portOverride ?? resolveGatewayPort(cfg, daemonEnv); if (!Number.isFinite(port) || port <= 0) { fail("Invalid port"); return; @@ -43,7 +45,7 @@ export async function runDaemonInstall(opts: DaemonInstallOptions) { const service = resolveGatewayService(); const { programArguments, workingDirectory, environment } = await buildGatewayInstallPlan({ - env: process.env, + env: daemonEnv, port, runtime: runtimeRaw, warn: (message) => { @@ -57,7 +59,7 @@ export async function runDaemonInstall(opts: DaemonInstallOptions) { }); let loaded = false; try { - loaded = await service.isLoaded({ env: process.env }); + loaded = await service.isLoaded({ env: daemonEnv }); } catch (err) { if (isNonFatalSystemdInstallProbeError(err)) { loaded = false; @@ -77,7 +79,7 @@ export async function runDaemonInstall(opts: DaemonInstallOptions) { if (!json) { defaultRuntime.log(`Gateway service already ${service.loadedText}.`); defaultRuntime.log( - `Reinstall with: ${formatCliCommand("openclaw gateway install --force")}`, + `Reinstall with: ${formatCliCommand("openclaw gateway install --force", daemonEnv)}`, ); } return; @@ -85,7 +87,7 @@ export async function runDaemonInstall(opts: DaemonInstallOptions) { } const ownershipConflict = await detectSharedGatewayInstallOwnershipConflict({ - env: process.env, + env: daemonEnv, service, allowSharedServiceTakeover: opts.allowSharedServiceTakeover, programArguments, @@ -99,7 +101,7 @@ export async function runDaemonInstall(opts: DaemonInstallOptions) { const tokenResolution = await resolveGatewayInstallToken({ config: cfg, - env: process.env, + env: daemonEnv, explicitToken: opts.token, autoGenerateWhenMissing: true, persistGeneratedToken: true, @@ -124,7 +126,7 @@ export async function runDaemonInstall(opts: DaemonInstallOptions) { fail, install: async () => { await service.install({ - env: process.env, + env: daemonEnv, stdout, programArguments, workingDirectory, diff --git a/src/cli/daemon-cli/lifecycle-core.test.ts b/src/cli/daemon-cli/lifecycle-core.test.ts index 567f94314a8bf..777a7dd18044b 100644 --- a/src/cli/daemon-cli/lifecycle-core.test.ts +++ b/src/cli/daemon-cli/lifecycle-core.test.ts @@ -1,4 +1,5 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { resolveConsumerRuntimeIdentity } from "../../consumer/runtime-identity.js"; import { defaultRuntime, resetLifecycleRuntimeLogs, @@ -77,6 +78,27 @@ describe("runServiceRestart token drift", () => { ); }); + it("restarts the canonical consumer lane service identity instead of the raw profile label", async () => { + const identity = resolveConsumerRuntimeIdentity({ + instanceId: "main-durable-lane", + }); + vi.stubEnv("OPENCLAW_PROFILE", "consumer-main-durable-lane"); + + await runServiceRestart(createServiceRunArgs(true)); + + expect(service.restart).toHaveBeenCalledWith({ + env: expect.objectContaining({ + OPENCLAW_CONSUMER_INSTANCE_ID: "main-durable-lane", + OPENCLAW_PROFILE: identity.profile, + OPENCLAW_STATE_DIR: identity.stateDir, + OPENCLAW_CONFIG_PATH: identity.configPath, + OPENCLAW_GATEWAY_PORT: String(identity.gatewayPort), + OPENCLAW_LAUNCHD_LABEL: identity.gatewayLaunchdLabel, + }), + stdout: expect.anything(), + }); + }); + it("compares restart drift against config token even when caller env is set", async () => { loadConfig.mockReturnValue({ gateway: { diff --git a/src/cli/daemon-cli/lifecycle-core.ts b/src/cli/daemon-cli/lifecycle-core.ts index b8db5a5c560d1..170138bec0a0a 100644 --- a/src/cli/daemon-cli/lifecycle-core.ts +++ b/src/cli/daemon-cli/lifecycle-core.ts @@ -3,6 +3,7 @@ import { readBestEffortConfig, readConfigFileSnapshot } from "../../config/confi import { formatConfigIssueLines } from "../../config/issue-format.js"; import { resolveIsNixMode } from "../../config/paths.js"; import { checkTokenDrift } from "../../daemon/service-audit.js"; +import { resolveGatewayRuntimeIdentityEnv } from "../../daemon/service-env.js"; import type { GatewayServiceRestartResult } from "../../daemon/service-types.js"; import { describeGatewayServiceRestart } from "../../daemon/service.js"; import type { GatewayService } from "../../daemon/service.js"; @@ -103,8 +104,9 @@ async function resolveServiceLoadedOrFail(params: { service: GatewayService; fail: ReturnType["fail"]; }): Promise { + const daemonEnv = resolveGatewayRuntimeIdentityEnv(process.env); try { - return await params.service.isLoaded({ env: process.env }); + return await params.service.isLoaded({ env: daemonEnv }); } catch (err) { params.fail(`${params.serviceNoun} service check failed: ${String(err)}`); return null; @@ -143,27 +145,28 @@ export async function runServiceUninstall(params: { }) { const json = Boolean(params.opts?.json); const { stdout, emit, fail } = createActionIO({ action: "uninstall", json }); + const daemonEnv = resolveGatewayRuntimeIdentityEnv(process.env); - if (resolveIsNixMode(process.env)) { + if (resolveIsNixMode(daemonEnv as NodeJS.ProcessEnv)) { fail("Nix mode detected; service uninstall is disabled."); return; } let loaded = false; try { - loaded = await params.service.isLoaded({ env: process.env }); + loaded = await params.service.isLoaded({ env: daemonEnv }); } catch { loaded = false; } if (loaded && params.stopBeforeUninstall) { try { - await params.service.stop({ env: process.env, stdout }); + await params.service.stop({ env: daemonEnv, stdout }); } catch { // Best-effort stop; final loaded check gates success when enabled. } } try { - await params.service.uninstall({ env: process.env, stdout }); + await params.service.uninstall({ env: daemonEnv, stdout }); } catch (err) { fail(`${params.serviceNoun} uninstall failed: ${String(err)}`); return; @@ -171,7 +174,7 @@ export async function runServiceUninstall(params: { loaded = false; try { - loaded = await params.service.isLoaded({ env: process.env }); + loaded = await params.service.isLoaded({ env: daemonEnv }); } catch { loaded = false; } @@ -194,6 +197,7 @@ export async function runServiceStart(params: { }) { const json = Boolean(params.opts?.json); const { stdout, emit, fail } = createActionIO({ action: "start", json }); + const daemonEnv = resolveGatewayRuntimeIdentityEnv(process.env); const loaded = await resolveServiceLoadedOrFail({ serviceNoun: params.serviceNoun, @@ -226,7 +230,7 @@ export async function runServiceStart(params: { } try { - const restartResult = await params.service.restart({ env: process.env, stdout }); + const restartResult = await params.service.restart({ env: daemonEnv, stdout }); const restartStatus = describeGatewayServiceRestart(params.serviceNoun, restartResult); if (restartStatus.scheduled) { emit({ @@ -248,7 +252,7 @@ export async function runServiceStart(params: { let started = true; try { - started = await params.service.isLoaded({ env: process.env }); + started = await params.service.isLoaded({ env: daemonEnv }); } catch { started = true; } @@ -338,6 +342,7 @@ export async function runServiceRestart(params: { }): Promise { const json = Boolean(params.opts?.json); const { stdout, emit, fail } = createActionIO({ action: "restart", json }); + const daemonEnv = resolveGatewayRuntimeIdentityEnv(process.env); const warnings: string[] = []; let handledNotLoaded: NotLoadedActionResult | null = null; const emitScheduledRestart = ( @@ -404,10 +409,10 @@ export async function runServiceRestart(params: { if (loaded && params.checkTokenDrift) { // Check for token drift before restart (service token vs config token) try { - const command = await params.service.readCommand(process.env); + const command = await params.service.readCommand(daemonEnv as NodeJS.ProcessEnv); const serviceToken = command?.environment?.OPENCLAW_GATEWAY_TOKEN; const cfg = await readBestEffortConfig(); - const configToken = resolveGatewayTokenForDriftCheck({ cfg, env: process.env }); + const configToken = resolveGatewayTokenForDriftCheck({ cfg, env: daemonEnv as NodeJS.ProcessEnv }); const driftIssue = checkTokenDrift({ serviceToken, configToken }); if (driftIssue) { const warning = driftIssue.detail @@ -436,7 +441,7 @@ export async function runServiceRestart(params: { try { let restartResult: GatewayServiceRestartResult = { outcome: "completed" }; if (loaded) { - restartResult = await params.service.restart({ env: process.env, stdout }); + restartResult = await params.service.restart({ env: daemonEnv, stdout }); } let restartStatus = describeGatewayServiceRestart(params.serviceNoun, restartResult); if (restartStatus.scheduled) { @@ -454,7 +459,7 @@ export async function runServiceRestart(params: { let restarted = loaded; if (loaded || handledNotLoaded?.serviceLoaded === true) { try { - restarted = await params.service.isLoaded({ env: process.env }); + restarted = await params.service.isLoaded({ env: daemonEnv }); } catch { restarted = handledNotLoaded?.serviceLoaded ?? true; } diff --git a/src/cli/daemon-cli/lifecycle.ts b/src/cli/daemon-cli/lifecycle.ts index 097fbf410d150..9b3d29d67baab 100644 --- a/src/cli/daemon-cli/lifecycle.ts +++ b/src/cli/daemon-cli/lifecycle.ts @@ -3,6 +3,7 @@ import { DEFAULT_GATEWAY_DAEMON_RUNTIME } from "../../commands/daemon-runtime.js import { resolveGatewayInstallToken } from "../../commands/gateway-install-token.js"; import { isRestartEnabled } from "../../config/commands.js"; import { readBestEffortConfig, resolveGatewayPort } from "../../config/config.js"; +import { resolveGatewayRuntimeIdentityEnv } from "../../daemon/service-env.js"; import { resolveGatewayService } from "../../daemon/service.js"; import { probeGateway } from "../../gateway/probe.js"; import { @@ -36,10 +37,11 @@ const POST_RESTART_HEALTH_ATTEMPTS = DEFAULT_RESTART_HEALTH_ATTEMPTS; const POST_RESTART_HEALTH_DELAY_MS = DEFAULT_RESTART_HEALTH_DELAY_MS; async function resolveGatewayLifecyclePort(service = resolveGatewayService()) { - const command = await service.readCommand(process.env).catch(() => null); + const daemonEnv = resolveGatewayRuntimeIdentityEnv(process.env); + const command = await service.readCommand(daemonEnv as NodeJS.ProcessEnv).catch(() => null); const serviceEnv = command?.environment ?? undefined; const mergedEnv = { - ...(process.env as Record), + ...daemonEnv, ...(serviceEnv ?? undefined), } as NodeJS.ProcessEnv; @@ -48,9 +50,10 @@ async function resolveGatewayLifecyclePort(service = resolveGatewayService()) { } function resolveGatewayPortFallback(): Promise { + const daemonEnv = resolveGatewayRuntimeIdentityEnv(process.env); return readBestEffortConfig() - .then((cfg) => resolveGatewayPort(cfg, process.env)) - .catch(() => resolveGatewayPort(undefined, process.env)); + .then((cfg) => resolveGatewayPort(cfg, daemonEnv as NodeJS.ProcessEnv)) + .catch(() => resolveGatewayPort(undefined, daemonEnv as NodeJS.ProcessEnv)); } async function assertUnmanagedGatewayRestartEnabled(port: number): Promise { @@ -124,12 +127,13 @@ async function installGatewayServiceForRestart(params: { message: string; serviceLoaded: true; } | null> { + const daemonEnv = resolveGatewayRuntimeIdentityEnv(process.env); const cfg = await readBestEffortConfig(); - const port = resolveGatewayPort(cfg); + const port = resolveGatewayPort(cfg, daemonEnv as NodeJS.ProcessEnv); const runtime = DEFAULT_GATEWAY_DAEMON_RUNTIME; const service = resolveGatewayService(); const { programArguments, workingDirectory, environment } = await buildGatewayInstallPlan({ - env: process.env, + env: daemonEnv, port, runtime, warn: (message) => { @@ -143,7 +147,7 @@ async function installGatewayServiceForRestart(params: { }); const ownershipConflict = await detectSharedGatewayInstallOwnershipConflict({ - env: process.env, + env: daemonEnv, service, programArguments, workingDirectory, @@ -156,7 +160,7 @@ async function installGatewayServiceForRestart(params: { const tokenResolution = await resolveGatewayInstallToken({ config: cfg, - env: process.env, + env: daemonEnv, autoGenerateWhenMissing: true, persistGeneratedToken: true, }); @@ -173,7 +177,7 @@ async function installGatewayServiceForRestart(params: { } await service.install({ - env: process.env, + env: daemonEnv, stdout: params.stdout, programArguments, workingDirectory, diff --git a/src/cli/daemon-cli/shared.ts b/src/cli/daemon-cli/shared.ts index eb2760c26306a..3864d902ca38a 100644 --- a/src/cli/daemon-cli/shared.ts +++ b/src/cli/daemon-cli/shared.ts @@ -1,4 +1,5 @@ import { resolveIsNixMode } from "../../config/paths.js"; +import { resolveGatewayRuntimeIdentityEnv } from "../../daemon/service-env.js"; import { resolveGatewayLaunchAgentLabel, resolveGatewaySystemdServiceName, @@ -105,10 +106,15 @@ export function pickProbeHostForBind( } const SAFE_DAEMON_ENV_KEYS = [ + "OPENCLAW_CONSUMER_INSTANCE_ID", + "OPENCLAW_HOME", + "OPENCLAW_LOG_DIR", "OPENCLAW_PROFILE", "OPENCLAW_STATE_DIR", "OPENCLAW_CONFIG_PATH", "OPENCLAW_GATEWAY_PORT", + "OPENCLAW_GATEWAY_BIND", + "OPENCLAW_LAUNCHD_LABEL", "OPENCLAW_NIX_MODE", ]; @@ -180,11 +186,12 @@ export function renderRuntimeHints( } export function renderGatewayServiceStartHints(env: NodeJS.ProcessEnv = process.env): string[] { - const profile = env.OPENCLAW_PROFILE; + const daemonEnv = resolveGatewayRuntimeIdentityEnv(env); + const profile = daemonEnv.OPENCLAW_PROFILE; return buildPlatformServiceStartHints({ - installCommand: formatCliCommand("openclaw gateway install", env), - startCommand: formatCliCommand("openclaw gateway", env), - launchAgentPlistPath: `~/Library/LaunchAgents/${resolveGatewayLaunchAgentLabel(profile)}.plist`, + installCommand: formatCliCommand("openclaw gateway install", daemonEnv), + startCommand: formatCliCommand("openclaw gateway", daemonEnv), + launchAgentPlistPath: `~/Library/LaunchAgents/${daemonEnv.OPENCLAW_LAUNCHD_LABEL?.trim() || resolveGatewayLaunchAgentLabel(profile)}.plist`, systemdServiceName: resolveGatewaySystemdServiceName(profile), windowsTaskName: resolveGatewayWindowsTaskName(profile), }); diff --git a/src/cli/daemon-cli/status.gather.test.ts b/src/cli/daemon-cli/status.gather.test.ts index 04a600583159b..6f573509e8314 100644 --- a/src/cli/daemon-cli/status.gather.test.ts +++ b/src/cli/daemon-cli/status.gather.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { resolveConsumerRuntimeIdentity } from "../../consumer/runtime-identity.js"; import type { PortUsage } from "../../infra/ports-types.js"; import { captureEnv } from "../../test-utils/env.js"; import type { GatewayRestartSnapshot } from "./restart-health.js"; @@ -148,6 +149,11 @@ describe("gatherDaemonStatus", () => { beforeEach(() => { envSnapshot = captureEnv([ + "HOME", + "OPENCLAW_CONSUMER_INSTANCE_ID", + "OPENCLAW_HOME", + "OPENCLAW_LAUNCHD_LABEL", + "OPENCLAW_PROFILE", "OPENCLAW_STATE_DIR", "OPENCLAW_CONFIG_PATH", "OPENCLAW_GATEWAY_TOKEN", @@ -157,6 +163,10 @@ describe("gatherDaemonStatus", () => { ]); process.env.OPENCLAW_STATE_DIR = "/tmp/openclaw-cli"; process.env.OPENCLAW_CONFIG_PATH = "/tmp/openclaw-cli/openclaw.json"; + delete process.env.OPENCLAW_CONSUMER_INSTANCE_ID; + delete process.env.OPENCLAW_HOME; + delete process.env.OPENCLAW_LAUNCHD_LABEL; + delete process.env.OPENCLAW_PROFILE; delete process.env.OPENCLAW_GATEWAY_TOKEN; delete process.env.OPENCLAW_GATEWAY_PASSWORD; delete process.env.DAEMON_GATEWAY_TOKEN; @@ -219,6 +229,32 @@ describe("gatherDaemonStatus", () => { expect(status.rpc?.ok).toBe(true); }); + it("reads status through the canonical consumer lane service identity", async () => { + const identity = resolveConsumerRuntimeIdentity({ + instanceId: "main-durable-lane", + }); + process.env.OPENCLAW_PROFILE = "consumer-main-durable-lane"; + delete process.env.OPENCLAW_STATE_DIR; + delete process.env.OPENCLAW_CONFIG_PATH; + + await gatherDaemonStatus({ + rpc: {}, + probe: false, + deep: false, + }); + + expect(serviceReadCommand).toHaveBeenCalledWith( + expect.objectContaining({ + OPENCLAW_CONSUMER_INSTANCE_ID: "main-durable-lane", + OPENCLAW_PROFILE: identity.profile, + OPENCLAW_STATE_DIR: identity.stateDir, + OPENCLAW_CONFIG_PATH: identity.configPath, + OPENCLAW_GATEWAY_PORT: String(identity.gatewayPort), + OPENCLAW_LAUNCHD_LABEL: identity.gatewayLaunchdLabel, + }), + ); + }); + it("does not force local TLS fingerprint when probe URL is explicitly overridden", async () => { const status = await gatherDaemonStatus({ rpc: { url: "wss://override.example:18790" }, diff --git a/src/cli/daemon-cli/status.gather.ts b/src/cli/daemon-cli/status.gather.ts index 23f857d249466..e787ee51f6191 100644 --- a/src/cli/daemon-cli/status.gather.ts +++ b/src/cli/daemon-cli/status.gather.ts @@ -14,6 +14,7 @@ import type { FindExtraGatewayServicesOptions } from "../../daemon/inspect.js"; import { findExtraGatewayServices } from "../../daemon/inspect.js"; import type { ServiceConfigAudit } from "../../daemon/service-audit.js"; import { auditGatewayServiceConfig } from "../../daemon/service-audit.js"; +import { resolveGatewayRuntimeIdentityEnv } from "../../daemon/service-env.js"; import type { GatewayServiceRuntime } from "../../daemon/service-runtime.js"; import { resolveGatewayService } from "../../daemon/service.js"; import { isGatewaySecretRefUnavailableError, trimToUndefined } from "../../gateway/credentials.js"; @@ -158,17 +159,18 @@ function parseGatewaySecretRefPathFromError(error: unknown): string | null { async function loadDaemonConfigContext( serviceEnv?: Record, ): Promise { + const cliEnv = resolveGatewayRuntimeIdentityEnv(process.env); const mergedDaemonEnv = { - ...(process.env as Record), + ...cliEnv, ...(serviceEnv ?? undefined), } satisfies Record; - const cliStateDir = resolveStateDir(process.env); + const cliStateDir = resolveStateDir(cliEnv as NodeJS.ProcessEnv); const daemonStateDir = resolveStateDir(mergedDaemonEnv as NodeJS.ProcessEnv); - const cliConfigPath = resolveConfigPath(process.env, cliStateDir); + const cliConfigPath = resolveConfigPath(cliEnv as NodeJS.ProcessEnv, cliStateDir); const daemonConfigPath = resolveConfigPath(mergedDaemonEnv as NodeJS.ProcessEnv, daemonStateDir); - const cliIO = createConfigIO({ env: process.env, configPath: cliConfigPath }); + const cliIO = createConfigIO({ env: cliEnv, configPath: cliConfigPath }); const daemonIO = createConfigIO({ env: mergedDaemonEnv, configPath: daemonConfigPath, @@ -246,7 +248,10 @@ async function resolveGatewayStatusSummary(params: { ...(probeNote ? { probeNote } : {}), }, daemonPort, - cliPort: resolveGatewayPort(params.cliCfg, process.env), + cliPort: resolveGatewayPort( + params.cliCfg, + resolveGatewayRuntimeIdentityEnv(process.env) as NodeJS.ProcessEnv, + ), probeUrlOverride, }; } @@ -289,19 +294,20 @@ export async function gatherDaemonStatus( } & FindExtraGatewayServicesOptions, ): Promise { const service = resolveGatewayService(); - const command = await service.readCommand(process.env).catch(() => null); + const cliEnv = resolveGatewayRuntimeIdentityEnv(process.env); + const command = await service.readCommand(cliEnv as NodeJS.ProcessEnv).catch(() => null); const serviceEnv = command?.environment ? ({ - ...process.env, + ...cliEnv, ...command.environment, } satisfies NodeJS.ProcessEnv) - : process.env; + : (cliEnv as NodeJS.ProcessEnv); const [loaded, runtime] = await Promise.all([ service.isLoaded({ env: serviceEnv }).catch(() => false), service.readRuntime(serviceEnv).catch((err) => ({ status: "unknown", detail: String(err) })), ]); const configAudit = await auditGatewayServiceConfig({ - env: process.env, + env: cliEnv as NodeJS.ProcessEnv, command, }); const { diff --git a/src/cli/daemon-cli/status.print.test.ts b/src/cli/daemon-cli/status.print.test.ts index b0555cc6e28b2..6a342b59f2dfc 100644 --- a/src/cli/daemon-cli/status.print.test.ts +++ b/src/cli/daemon-cli/status.print.test.ts @@ -256,6 +256,35 @@ describe("printDaemonStatus", () => { ); }); + it("prints cached LaunchAgent cleanup hints with the normalized consumer identity", () => { + printDaemonStatus( + { + service: { + label: "LaunchAgent", + loaded: true, + loadedText: "loaded", + notLoadedText: "not loaded", + runtime: { cachedLabel: true }, + command: { + programArguments: ["openclaw", "gateway"], + environment: { + OPENCLAW_PROFILE: "consumer-Foo__Bar", + }, + }, + }, + extraServices: [], + }, + { json: false }, + ); + + expect(runtime.error).toHaveBeenCalledWith( + expect.stringContaining("launchctl bootout gui/$UID/ai.openclaw.consumer.foo-bar.gateway"), + ); + expect(runtime.error).toHaveBeenCalledWith( + expect.stringContaining("openclaw --profile consumer-foo-bar gateway install"), + ); + }); + it("keeps the runtime fingerprint in json mode", () => { const status: DaemonStatus = { runtimeFingerprint: { diff --git a/src/cli/daemon-cli/status.print.ts b/src/cli/daemon-cli/status.print.ts index 8cbe252fb6b3b..98a0fb4f8a894 100644 --- a/src/cli/daemon-cli/status.print.ts +++ b/src/cli/daemon-cli/status.print.ts @@ -10,6 +10,7 @@ import { } from "../../daemon/constants.js"; import { renderGatewayServiceCleanupHints } from "../../daemon/inspect.js"; import { resolveGatewayLogPaths } from "../../daemon/launchd.js"; +import { resolveGatewayRuntimeIdentityEnv } from "../../daemon/service-env.js"; import { isSystemdUnavailableDetail, renderSystemdUnavailableHints, @@ -154,7 +155,7 @@ export function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) ); defaultRuntime.error( errorText( - `Fix: rerun \`${formatCliCommand("openclaw gateway install --force")}\` from the same --profile / OPENCLAW_STATE_DIR you expect.`, + `Fix: rerun \`${formatCliCommand("openclaw gateway install --force")}\` from the lane/runtime identity you expect so the normalized daemon config is regenerated consistently.`, ), ); } @@ -266,14 +267,17 @@ export function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) if (service.runtime?.cachedLabel) { const env = service.command?.environment ?? process.env; - const labelValue = resolveGatewayLaunchAgentLabel(env.OPENCLAW_PROFILE); + const daemonEnv = resolveGatewayRuntimeIdentityEnv(env); + const labelValue = + daemonEnv.OPENCLAW_LAUNCHD_LABEL?.trim() || + resolveGatewayLaunchAgentLabel(daemonEnv.OPENCLAW_PROFILE); defaultRuntime.error( errorText( `LaunchAgent label cached but plist missing. Clear with: launchctl bootout gui/$UID/${labelValue}`, ), ); defaultRuntime.error( - errorText(`Then reinstall: ${formatCliCommand("openclaw gateway install")}`), + errorText(`Then reinstall: ${formatCliCommand("openclaw gateway install", daemonEnv)}`), ); spacer(); } @@ -344,7 +348,8 @@ export function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) } if (process.platform === "linux") { const env = service.command?.environment ?? process.env; - const unit = resolveGatewaySystemdServiceName(env.OPENCLAW_PROFILE); + const daemonEnv = resolveGatewayRuntimeIdentityEnv(env); + const unit = resolveGatewaySystemdServiceName(daemonEnv.OPENCLAW_PROFILE); defaultRuntime.error( errorText(`Logs: journalctl --user -u ${unit}.service -n 200 --no-pager`), ); diff --git a/src/cli/update-cli/restart-helper.test.ts b/src/cli/update-cli/restart-helper.test.ts index 847893e9f236f..3419e85a362d5 100644 --- a/src/cli/update-cli/restart-helper.test.ts +++ b/src/cli/update-cli/restart-helper.test.ts @@ -1,6 +1,7 @@ import { spawn, type ChildProcess } from "node:child_process"; import fs from "node:fs/promises"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { resolveConsumerRuntimeIdentity } from "../../consumer/runtime-identity.js"; import { prepareRestartScript, runRestartScript } from "./restart-helper.js"; vi.mock("node:child_process", () => ({ @@ -184,6 +185,20 @@ describe("restart-helper", () => { await cleanupScript(scriptPath); }); + it("uses the consumer runtime contract for consumer lane launchd labels", async () => { + Object.defineProperty(process, "platform", { value: "darwin" }); + process.getuid = () => 503; + const identity = resolveConsumerRuntimeIdentity({ + instanceId: "main-durable-lane", + }); + + const { scriptPath, content } = await prepareAndReadScript({ + OPENCLAW_PROFILE: "consumer-main-durable-lane", + }); + expect(content).toContain(`gui/503/${identity.gatewayLaunchdLabel}`); + await cleanupScript(scriptPath); + }); + it("uses custom profile in Windows task name", async () => { Object.defineProperty(process, "platform", { value: "win32" }); diff --git a/src/cli/update-cli/restart-helper.ts b/src/cli/update-cli/restart-helper.ts index a68fab161fa08..78cc9d2fe4273 100644 --- a/src/cli/update-cli/restart-helper.ts +++ b/src/cli/update-cli/restart-helper.ts @@ -4,6 +4,7 @@ import os from "node:os"; import path from "node:path"; import { DEFAULT_GATEWAY_PORT } from "../../config/paths.js"; import { quoteCmdScriptArg } from "../../daemon/cmd-argv.js"; +import { resolveGatewayRuntimeIdentityEnv } from "../../daemon/service-env.js"; import { resolveGatewayLaunchAgentLabel, resolveGatewaySystemdServiceName, @@ -26,27 +27,30 @@ function isBatchSafe(value: string): boolean { } function resolveSystemdUnit(env: NodeJS.ProcessEnv): string { - const override = env.OPENCLAW_SYSTEMD_UNIT?.trim(); + const daemonEnv = resolveGatewayRuntimeIdentityEnv(env); + const override = daemonEnv.OPENCLAW_SYSTEMD_UNIT?.trim(); if (override) { return override.endsWith(".service") ? override : `${override}.service`; } - return `${resolveGatewaySystemdServiceName(env.OPENCLAW_PROFILE)}.service`; + return `${resolveGatewaySystemdServiceName(daemonEnv.OPENCLAW_PROFILE)}.service`; } function resolveLaunchdLabel(env: NodeJS.ProcessEnv): string { - const override = env.OPENCLAW_LAUNCHD_LABEL?.trim(); + const daemonEnv = resolveGatewayRuntimeIdentityEnv(env); + const override = daemonEnv.OPENCLAW_LAUNCHD_LABEL?.trim(); if (override) { return override; } - return resolveGatewayLaunchAgentLabel(env.OPENCLAW_PROFILE); + return resolveGatewayLaunchAgentLabel(daemonEnv.OPENCLAW_PROFILE); } function resolveWindowsTaskName(env: NodeJS.ProcessEnv): string { - const override = env.OPENCLAW_WINDOWS_TASK_NAME?.trim(); + const daemonEnv = resolveGatewayRuntimeIdentityEnv(env); + const override = daemonEnv.OPENCLAW_WINDOWS_TASK_NAME?.trim(); if (override) { return override; } - return resolveGatewayWindowsTaskName(env.OPENCLAW_PROFILE); + return resolveGatewayWindowsTaskName(daemonEnv.OPENCLAW_PROFILE); } /** diff --git a/src/commands/daemon-install-helpers.ts b/src/commands/daemon-install-helpers.ts index 91248cb86a77a..73c1a8e052794 100644 --- a/src/commands/daemon-install-helpers.ts +++ b/src/commands/daemon-install-helpers.ts @@ -7,7 +7,7 @@ import { collectConfigServiceEnvVars } from "../config/env-vars.js"; import type { OpenClawConfig } from "../config/types.js"; import { resolveGatewayLaunchAgentLabel } from "../daemon/constants.js"; import { resolveGatewayProgramArguments } from "../daemon/program-args.js"; -import { buildServiceEnvironment } from "../daemon/service-env.js"; +import { buildServiceEnvironment, resolveGatewayRuntimeIdentityEnv } from "../daemon/service-env.js"; import { emitDaemonInstallRuntimeWarning, resolveDaemonInstallRuntimeInputs, @@ -61,8 +61,9 @@ export async function buildGatewayInstallPlan(params: { config?: OpenClawConfig; authStore?: AuthProfileStore; }): Promise { + const daemonEnv = resolveGatewayRuntimeIdentityEnv(params.env); const { devMode, nodePath } = await resolveDaemonInstallRuntimeInputs({ - env: params.env, + env: daemonEnv, runtime: params.runtime, devMode: params.devMode, nodePath: params.nodePath, @@ -74,18 +75,19 @@ export async function buildGatewayInstallPlan(params: { nodePath, }); await emitDaemonInstallRuntimeWarning({ - env: params.env, + env: daemonEnv, runtime: params.runtime, programArguments, warn: params.warn, title: "Gateway runtime", }); const serviceEnvironment = buildServiceEnvironment({ - env: params.env, + env: daemonEnv, port: params.port, launchdLabel: process.platform === "darwin" - ? resolveGatewayLaunchAgentLabel(params.env.OPENCLAW_PROFILE) + ? daemonEnv.OPENCLAW_LAUNCHD_LABEL?.trim() || + resolveGatewayLaunchAgentLabel(daemonEnv.OPENCLAW_PROFILE) : undefined, }); diff --git a/src/commands/doctor-format.ts b/src/commands/doctor-format.ts index c41ba5a017f20..5ffbe68fca909 100644 --- a/src/commands/doctor-format.ts +++ b/src/commands/doctor-format.ts @@ -4,6 +4,7 @@ import { resolveGatewaySystemdServiceName, resolveGatewayWindowsTaskName, } from "../daemon/constants.js"; +import { resolveGatewayRuntimeIdentityEnv } from "../daemon/service-env.js"; import { formatRuntimeStatus } from "../daemon/runtime-format.js"; import { buildPlatformRuntimeLogHints } from "../daemon/runtime-hints.js"; import type { GatewayServiceRuntime } from "../daemon/service-runtime.js"; @@ -35,6 +36,7 @@ export function buildGatewayRuntimeHints( } const platform = options.platform ?? process.platform; const env = options.env ?? process.env; + const daemonEnv = resolveGatewayRuntimeIdentityEnv(env); const fileLog = (() => { try { return getResolvedLoggerSettings().file; @@ -50,14 +52,20 @@ export function buildGatewayRuntimeHints( return hints; } if (runtime.cachedLabel && platform === "darwin") { - const label = resolveGatewayLaunchAgentLabel(env.OPENCLAW_PROFILE); + // Consumer lanes should print the normalized daemon identity so the + // bootout/install hint matches the actual LaunchAgent label in use. + const label = + daemonEnv.OPENCLAW_LAUNCHD_LABEL?.trim() || + resolveGatewayLaunchAgentLabel(daemonEnv.OPENCLAW_PROFILE); hints.push( `LaunchAgent label cached but plist missing. Clear with: launchctl bootout gui/$UID/${label}`, ); - hints.push(`Then reinstall: ${formatCliCommand("openclaw gateway install", env)}`); + hints.push(`Then reinstall: ${formatCliCommand("openclaw gateway install", daemonEnv)}`); } if (runtime.missingUnit) { - hints.push(`Service not installed. Run: ${formatCliCommand("openclaw gateway install", env)}`); + hints.push( + `Service not installed. Run: ${formatCliCommand("openclaw gateway install", daemonEnv)}`, + ); if (fileLog) { hints.push(`File logs: ${fileLog}`); } @@ -71,9 +79,9 @@ export function buildGatewayRuntimeHints( hints.push( ...buildPlatformRuntimeLogHints({ platform, - env, - systemdServiceName: resolveGatewaySystemdServiceName(env.OPENCLAW_PROFILE), - windowsTaskName: resolveGatewayWindowsTaskName(env.OPENCLAW_PROFILE), + env: daemonEnv, + systemdServiceName: resolveGatewaySystemdServiceName(daemonEnv.OPENCLAW_PROFILE), + windowsTaskName: resolveGatewayWindowsTaskName(daemonEnv.OPENCLAW_PROFILE), }), ); } diff --git a/src/commands/doctor-gateway-daemon-flow.test.ts b/src/commands/doctor-gateway-daemon-flow.test.ts index 02c0b885bb038..bad2be31e42b3 100644 --- a/src/commands/doctor-gateway-daemon-flow.test.ts +++ b/src/commands/doctor-gateway-daemon-flow.test.ts @@ -191,4 +191,38 @@ describe("maybeRepairGatewayDaemon", () => { expect(sleep).not.toHaveBeenCalled(); expect(healthCommand).not.toHaveBeenCalled(); }); + + it("prints the normalized consumer LaunchAgent label in macOS stop guidance", async () => { + setPlatform("darwin"); + const originalProfile = process.env.OPENCLAW_PROFILE; + process.env.OPENCLAW_PROFILE = "consumer-Foo__Bar"; + + try { + await maybeRepairGatewayDaemon({ + cfg: { gateway: {} }, + runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() }, + prompter: createPrompter(() => false), + options: { deep: false }, + gatewayDetailsMessage: "details", + healthOk: false, + }); + } finally { + if (originalProfile === undefined) { + delete process.env.OPENCLAW_PROFILE; + } else { + process.env.OPENCLAW_PROFILE = originalProfile; + } + } + + expect(note).toHaveBeenCalledWith( + expect.stringContaining( + 'launchctl bootout gui/$UID/ai.openclaw.consumer.foo-bar.gateway', + ), + "Gateway", + ); + expect(note).toHaveBeenCalledWith( + expect.stringContaining('openclaw --profile consumer-foo-bar gateway stop'), + "Gateway", + ); + }); }); diff --git a/src/commands/doctor-gateway-daemon-flow.ts b/src/commands/doctor-gateway-daemon-flow.ts index c476efa615f4c..fcea21ef42df5 100644 --- a/src/commands/doctor-gateway-daemon-flow.ts +++ b/src/commands/doctor-gateway-daemon-flow.ts @@ -12,6 +12,7 @@ import { launchAgentPlistExists, repairLaunchAgentBootstrap, } from "../daemon/launchd.js"; +import { resolveGatewayRuntimeIdentityEnv } from "../daemon/service-env.js"; import { describeGatewayServiceRestart, resolveGatewayService } from "../daemon/service.js"; import { renderSystemdUnavailableHints } from "../daemon/systemd-hints.js"; import { isSystemdUserServiceAvailable } from "../daemon/systemd.js"; @@ -249,9 +250,12 @@ export async function maybeRepairGatewayDaemon(params: { } if (process.platform === "darwin") { - const label = resolveGatewayLaunchAgentLabel(process.env.OPENCLAW_PROFILE); + const daemonEnv = resolveGatewayRuntimeIdentityEnv(process.env); + const label = + daemonEnv.OPENCLAW_LAUNCHD_LABEL?.trim() || + resolveGatewayLaunchAgentLabel(daemonEnv.OPENCLAW_PROFILE); note( - `LaunchAgent loaded; stopping requires "${formatCliCommand("openclaw gateway stop")}" or launchctl bootout gui/$UID/${label}.`, + `LaunchAgent loaded; stopping requires "${formatCliCommand("openclaw gateway stop", daemonEnv)}" or launchctl bootout gui/$UID/${label}.`, "Gateway", ); } diff --git a/src/consumer/runtime-identity.test.ts b/src/consumer/runtime-identity.test.ts new file mode 100644 index 0000000000000..f865599bf009a --- /dev/null +++ b/src/consumer/runtime-identity.test.ts @@ -0,0 +1,92 @@ +import path from "node:path"; +import os from "node:os"; +import { describe, expect, it } from "vitest"; + +import { + inferConsumerRuntimeIdFromCheckout, + normalizeConsumerRuntimeId, + resolveConsumerRuntimeIdentity, +} from "./runtime-identity.js"; + +describe("consumer/runtime-identity", () => { + it("normalizes instance ids to the shared shell-safe contract", () => { + expect(normalizeConsumerRuntimeId(" Main Durable_Lane ")).toBe("main-durable-lane"); + expect(normalizeConsumerRuntimeId("___")).toBe(""); + expect(normalizeConsumerRuntimeId("Already-clean")).toBe("already-clean"); + }); + + it("infers a default instance id only for linked worktree checkouts", () => { + expect( + inferConsumerRuntimeIdFromCheckout({ + rootDir: "/tmp/openclaw/.worktrees/Main Consumer Lane", + absoluteGitDir: "/tmp/openclaw/.git/worktrees/main-consumer-lane", + }), + ).toBe("main-consumer-lane"); + + expect( + inferConsumerRuntimeIdFromCheckout({ + rootDir: "/tmp/openclaw", + absoluteGitDir: "/tmp/openclaw/.git", + }), + ).toBe(""); + }); + + it("builds the shared consumer runtime identity when no instance is set", () => { + const homeDir = "/Users/tester"; + expect(resolveConsumerRuntimeIdentity({ homeDir })).toEqual({ + normalizedId: "", + runtimeRoot: "/Users/tester/Library/Application Support/OpenClaw", + stateDir: "/Users/tester/Library/Application Support/OpenClaw/.openclaw", + configPath: "/Users/tester/Library/Application Support/OpenClaw/.openclaw/openclaw.json", + workspacePath: "/Users/tester/Library/Application Support/OpenClaw/.openclaw/workspace", + logDir: "/Users/tester/Library/Application Support/OpenClaw/.openclaw/logs", + profile: "consumer", + launchdLabel: "ai.openclaw.consumer", + gatewayLaunchdLabel: "ai.openclaw.consumer.gateway", + defaultsPrefix: "openclaw.consumer", + gatewayPort: 19001, + gatewayBind: "loopback", + }); + }); + + it("builds an isolated consumer runtime identity for an instance id", () => { + const identity = resolveConsumerRuntimeIdentity({ + homeDir: "/Users/tester", + instanceId: "Main Durable Lane", + }); + + expect(identity).toEqual({ + normalizedId: "main-durable-lane", + runtimeRoot: + "/Users/tester/Library/Application Support/OpenClaw/instances/main-durable-lane", + stateDir: + "/Users/tester/Library/Application Support/OpenClaw/instances/main-durable-lane/.openclaw", + configPath: + "/Users/tester/Library/Application Support/OpenClaw/instances/main-durable-lane/.openclaw/openclaw.json", + workspacePath: + "/Users/tester/Library/Application Support/OpenClaw/instances/main-durable-lane/.openclaw/workspace", + logDir: + "/Users/tester/Library/Application Support/OpenClaw/instances/main-durable-lane/.openclaw/logs", + profile: "consumer-main-durable-lane", + launchdLabel: "ai.openclaw.consumer.main-durable-lane", + gatewayLaunchdLabel: "ai.openclaw.consumer.main-durable-lane.gateway", + defaultsPrefix: "openclaw.consumer.instances.main-durable-lane", + gatewayPort: 28587, + gatewayBind: "loopback", + }); + }); + + it("uses the current home directory by default", () => { + const identity = resolveConsumerRuntimeIdentity({ instanceId: "lane" }); + expect(identity.runtimeRoot).toBe( + path.join( + os.homedir(), + "Library", + "Application Support", + "OpenClaw", + "instances", + "lane", + ), + ); + }); +}); diff --git a/src/consumer/runtime-identity.ts b/src/consumer/runtime-identity.ts new file mode 100644 index 0000000000000..8ecb866d1662a --- /dev/null +++ b/src/consumer/runtime-identity.ts @@ -0,0 +1,109 @@ +import { execFileSync } from "node:child_process"; +import path from "node:path"; +import os from "node:os"; + +export const CONSUMER_RUNTIME_ROOT_NAME = "OpenClaw" as const; +export const CONSUMER_STATE_DIR_NAME = ".openclaw" as const; +export const CONSUMER_CONFIG_FILE_NAME = "openclaw.json" as const; +export const CONSUMER_WORKSPACE_DIR_NAME = "workspace" as const; +export const CONSUMER_LOG_DIR_NAME = "logs" as const; +export const CONSUMER_PROFILE_PREFIX = "consumer" as const; +export const CONSUMER_LAUNCHD_LABEL_PREFIX = "ai.openclaw.consumer" as const; +export const CONSUMER_GATEWAY_BIND = "loopback" as const; +export const CONSUMER_SHARED_GATEWAY_PORT = 19001 as const; +export const CONSUMER_GATEWAY_PORT_MIN = 20000 as const; +export const CONSUMER_GATEWAY_PORT_SPAN = 20000 as const; + +export type ConsumerRuntimeIdentity = { + normalizedId: string; + runtimeRoot: string; + stateDir: string; + configPath: string; + workspacePath: string; + logDir: string; + profile: string; + launchdLabel: string; + gatewayLaunchdLabel: string; + defaultsPrefix: string; + gatewayPort: number; + gatewayBind: typeof CONSUMER_GATEWAY_BIND; +}; + +export function normalizeConsumerRuntimeId(raw?: string | null): string { + return (raw ?? "") + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/-+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +export function inferConsumerRuntimeIdFromCheckout(params: { + rootDir: string; + absoluteGitDir?: string | null; +}): string { + const absoluteGitDir = params.absoluteGitDir ?? readAbsoluteGitDir(params.rootDir); + if (!absoluteGitDir?.includes("/worktrees/")) { + return ""; + } + + // Worktree lanes should derive their runtime identity from the checkout name + // so launch labels, state paths, and ports stay aligned even when callers + // forget to pass an explicit instance id. + return normalizeConsumerRuntimeId(path.basename(params.rootDir)); +} + +export function resolveConsumerRuntimeIdentity(params: { + instanceId?: string | null; + homeDir?: string; +} = {}): ConsumerRuntimeIdentity { + const normalizedId = normalizeConsumerRuntimeId(params.instanceId); + const homeDir = params.homeDir ?? os.homedir(); + const runtimeRoot = normalizedId + ? path.join(homeDir, "Library", "Application Support", CONSUMER_RUNTIME_ROOT_NAME, "instances", normalizedId) + : path.join(homeDir, "Library", "Application Support", CONSUMER_RUNTIME_ROOT_NAME); + const stateDir = path.join(runtimeRoot, CONSUMER_STATE_DIR_NAME); + + return { + normalizedId, + runtimeRoot, + stateDir, + configPath: path.join(stateDir, CONSUMER_CONFIG_FILE_NAME), + workspacePath: path.join(stateDir, CONSUMER_WORKSPACE_DIR_NAME), + logDir: path.join(stateDir, CONSUMER_LOG_DIR_NAME), + profile: normalizedId ? `${CONSUMER_PROFILE_PREFIX}-${normalizedId}` : CONSUMER_PROFILE_PREFIX, + launchdLabel: normalizedId + ? `${CONSUMER_LAUNCHD_LABEL_PREFIX}.${normalizedId}` + : CONSUMER_LAUNCHD_LABEL_PREFIX, + gatewayLaunchdLabel: normalizedId + ? `${CONSUMER_LAUNCHD_LABEL_PREFIX}.${normalizedId}.gateway` + : `${CONSUMER_LAUNCHD_LABEL_PREFIX}.gateway`, + defaultsPrefix: normalizedId + ? `openclaw.consumer.instances.${normalizedId}` + : "openclaw.consumer", + gatewayPort: normalizedId ? hashConsumerGatewayPort(normalizedId) : CONSUMER_SHARED_GATEWAY_PORT, + gatewayBind: CONSUMER_GATEWAY_BIND, + }; +} + +function readAbsoluteGitDir(rootDir: string): string { + try { + return execFileSync("git", ["-C", rootDir, "rev-parse", "--absolute-git-dir"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + } catch { + return ""; + } +} + +function hashConsumerGatewayPort(normalizedId: string): number { + // Keep the existing FNV-1a byte walk so previously assigned worktree ports do + // not drift during the first consolidation slice. + let hash = 0x811c9dc5; + for (const byte of Buffer.from(normalizedId, "utf8")) { + hash ^= byte; + hash = Math.imul(hash, 0x01000193) >>> 0; + } + return CONSUMER_GATEWAY_PORT_MIN + (hash % CONSUMER_GATEWAY_PORT_SPAN); +} diff --git a/src/daemon/service-env.test.ts b/src/daemon/service-env.test.ts index ba4ee516c7c3e..ff25cef6299c4 100644 --- a/src/daemon/service-env.test.ts +++ b/src/daemon/service-env.test.ts @@ -1,6 +1,7 @@ import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; +import { resolveConsumerRuntimeIdentity } from "../consumer/runtime-identity.js"; import { resolveGatewayStateDir } from "./paths.js"; import { buildMinimalServicePath, @@ -326,6 +327,31 @@ describe("buildServiceEnvironment", () => { } }); + it("canonicalizes consumer lane identity for service installs", () => { + const identity = resolveConsumerRuntimeIdentity({ + homeDir: "/Users/test", + instanceId: "main-durable-lane", + }); + const env = buildServiceEnvironment({ + env: { + HOME: "/Users/test", + OPENCLAW_PROFILE: "consumer-main-durable-lane", + }, + port: identity.gatewayPort, + platform: "darwin", + }); + + expect(env.OPENCLAW_CONSUMER_INSTANCE_ID).toBe("main-durable-lane"); + expect(env.OPENCLAW_PROFILE).toBe(identity.profile); + expect(env.OPENCLAW_HOME).toBe(identity.runtimeRoot); + expect(env.OPENCLAW_STATE_DIR).toBe(identity.stateDir); + expect(env.OPENCLAW_CONFIG_PATH).toBe(identity.configPath); + expect(env.OPENCLAW_GATEWAY_PORT).toBe(String(identity.gatewayPort)); + expect(env.OPENCLAW_GATEWAY_BIND).toBe(identity.gatewayBind); + expect(env.OPENCLAW_LOG_DIR).toBe(identity.logDir); + expect(env.OPENCLAW_LAUNCHD_LABEL).toBe(identity.gatewayLaunchdLabel); + }); + it("forwards proxy environment variables for launchd/systemd runtime", () => { const env = buildServiceEnvironment({ env: { diff --git a/src/daemon/service-env.ts b/src/daemon/service-env.ts index 6d92de1513f0f..5bb1abf701813 100644 --- a/src/daemon/service-env.ts +++ b/src/daemon/service-env.ts @@ -1,5 +1,10 @@ import os from "node:os"; import path from "node:path"; +import { + CONSUMER_PROFILE_PREFIX, + normalizeConsumerRuntimeId, + resolveConsumerRuntimeIdentity, +} from "../consumer/runtime-identity.js"; import { VERSION } from "../version.js"; import { GATEWAY_SERVICE_KIND, @@ -36,6 +41,58 @@ type SharedServiceEnvironmentFields = { nodeUseSystemCa: string | undefined; }; +function resolveConsumerGatewayIdentityInput( + env: Record, +): string | null | undefined { + const explicitInstance = normalizeConsumerRuntimeId(env.OPENCLAW_CONSUMER_INSTANCE_ID); + if (explicitInstance) { + return explicitInstance; + } + + const rawProfile = env.OPENCLAW_PROFILE?.trim().toLowerCase(); + if (!rawProfile) { + return undefined; + } + if (rawProfile === CONSUMER_PROFILE_PREFIX) { + return ""; + } + if (!rawProfile.startsWith(`${CONSUMER_PROFILE_PREFIX}-`)) { + return undefined; + } + + // Consumer lanes derive every runtime selector from the instance id so a + // stale launchd label, port, or state dir cannot point status/restart/install + // at a different consumer runtime than the profile implies. + return normalizeConsumerRuntimeId(rawProfile.slice(CONSUMER_PROFILE_PREFIX.length + 1)); +} + +export function resolveGatewayRuntimeIdentityEnv( + env: Record, +): Record { + const consumerInstanceId = resolveConsumerGatewayIdentityInput(env); + if (consumerInstanceId === undefined) { + return { ...env }; + } + + const identity = resolveConsumerRuntimeIdentity({ + homeDir: env.HOME?.trim() || process.env.HOME?.trim() || os.homedir(), + instanceId: consumerInstanceId, + }); + + return { + ...env, + OPENCLAW_CONSUMER_INSTANCE_ID: identity.normalizedId || undefined, + OPENCLAW_PROFILE: identity.profile, + OPENCLAW_HOME: identity.runtimeRoot, + OPENCLAW_STATE_DIR: identity.stateDir, + OPENCLAW_CONFIG_PATH: identity.configPath, + OPENCLAW_GATEWAY_PORT: String(identity.gatewayPort), + OPENCLAW_GATEWAY_BIND: identity.gatewayBind, + OPENCLAW_LOG_DIR: identity.logDir, + OPENCLAW_LAUNCHD_LABEL: identity.gatewayLaunchdLabel, + }; +} + const SERVICE_PROXY_ENV_KEYS = [ "HTTP_PROXY", "HTTPS_PROXY", @@ -248,17 +305,24 @@ export function buildServiceEnvironment(params: { launchdLabel?: string; platform?: NodeJS.Platform; }): Record { - const { env, port, launchdLabel } = params; + const env = resolveGatewayRuntimeIdentityEnv(params.env); + const { port, launchdLabel } = params; const platform = params.platform ?? process.platform; const sharedEnv = resolveSharedServiceEnvironmentFields(env, platform); const profile = env.OPENCLAW_PROFILE; const resolvedLaunchdLabel = - launchdLabel || (platform === "darwin" ? resolveGatewayLaunchAgentLabel(profile) : undefined); + launchdLabel || + env.OPENCLAW_LAUNCHD_LABEL?.trim() || + (platform === "darwin" ? resolveGatewayLaunchAgentLabel(profile) : undefined); const systemdUnit = `${resolveGatewaySystemdServiceName(profile)}.service`; return { ...buildCommonServiceEnvironment(env, sharedEnv), + OPENCLAW_CONSUMER_INSTANCE_ID: env.OPENCLAW_CONSUMER_INSTANCE_ID, + OPENCLAW_HOME: env.OPENCLAW_HOME, + OPENCLAW_LOG_DIR: env.OPENCLAW_LOG_DIR, OPENCLAW_PROFILE: profile, OPENCLAW_GATEWAY_PORT: String(port), + OPENCLAW_GATEWAY_BIND: env.OPENCLAW_GATEWAY_BIND, OPENCLAW_LAUNCHD_LABEL: resolvedLaunchdLabel, OPENCLAW_SYSTEMD_UNIT: systemdUnit, OPENCLAW_WINDOWS_TASK_NAME: resolveGatewayWindowsTaskName(profile), diff --git a/src/infra/restart-trigger.test.ts b/src/infra/restart-trigger.test.ts index d85279aab0c2a..abd46e8fd23e2 100644 --- a/src/infra/restart-trigger.test.ts +++ b/src/infra/restart-trigger.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { resolveConsumerRuntimeIdentity } from "../consumer/runtime-identity.js"; import { captureFullEnv } from "../test-utils/env.js"; const spawnSyncMock = vi.hoisted(() => vi.fn()); @@ -187,6 +188,55 @@ describe("triggerOpenClawRestart local script mode", () => { } }); + it("treats consumer lane profiles as safe lane-local launchd runtimes", async () => { + setPlatform("darwin"); + delete process.env.VITEST; + delete process.env.NODE_ENV; + process.env.OPENCLAW_PROFILE = "consumer-main-durable-lane"; + + const scriptDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-restart-script-")); + const scriptPath = path.join(scriptDir, "restart-local-gateway.sh"); + const identity = resolveConsumerRuntimeIdentity({ + instanceId: "main-durable-lane", + }); + await fs.writeFile(scriptPath, "#!/usr/bin/env bash\nexit 0\n", "utf8"); + process.env.OPENCLAW_LOCAL_RESTART_SCRIPT = scriptPath; + + try { + expect(isCanonicalSharedMainLaunchdRuntime()).toBe(false); + expect(isSafeLocalRestartScriptAvailable()).toBe(true); + + spawnSyncMock.mockReturnValue({ + error: undefined, + status: 0, + stdout: "", + stderr: "", + }); + + const result = triggerOpenClawRestart({ preferLocalScript: false }); + expect(result).toMatchObject({ + ok: true, + method: "launchctl", + }); + expect(spawnSyncMock).toHaveBeenCalledWith( + "launchctl", + expect.arrayContaining([ + "kickstart", + "-k", + expect.stringMatching( + new RegExp(`^gui/\\d+/${identity.gatewayLaunchdLabel.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\$&")}$`), + ), + ]), + expect.objectContaining({ + encoding: "utf8", + timeout: 2000, + }), + ); + } finally { + await fs.rm(scriptDir, { recursive: true, force: true }); + } + }); + it("treats the canonical shared main launchd label as unsafe for the local restart helper", async () => { setPlatform("darwin"); delete process.env.VITEST; diff --git a/src/infra/restart.ts b/src/infra/restart.ts index 7072eb10ccf95..7b5dd196737bd 100644 --- a/src/infra/restart.ts +++ b/src/infra/restart.ts @@ -8,6 +8,7 @@ import { resolveGatewayLaunchAgentLabel, resolveGatewaySystemdServiceName, } from "../daemon/constants.js"; +import { resolveGatewayRuntimeIdentityEnv } from "../daemon/service-env.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { resolveOpenClawPackageRootSync } from "./openclaw-root.js"; import { cleanStaleGatewayProcessesSync, findGatewayPidsOnPortSync } from "./restart-stale-pids.js"; @@ -328,11 +329,12 @@ export function isLocalRestartScriptAvailable(): boolean { } function resolveCurrentLaunchdLabel(env: NodeJS.ProcessEnv = process.env): string { - const configuredLabel = env.OPENCLAW_LAUNCHD_LABEL?.trim(); + const daemonEnv = resolveGatewayRuntimeIdentityEnv(env); + const configuredLabel = daemonEnv.OPENCLAW_LAUNCHD_LABEL?.trim(); if (configuredLabel) { return configuredLabel; } - return resolveGatewayLaunchAgentLabel(env.OPENCLAW_PROFILE); + return resolveGatewayLaunchAgentLabel(daemonEnv.OPENCLAW_PROFILE); } export function isCanonicalSharedMainLaunchdRuntime(env: NodeJS.ProcessEnv = process.env): boolean { @@ -354,6 +356,7 @@ function triggerDetachedLocalRestartScript(scriptPath: string): { command: string; detail?: string; } { + const daemonEnv = resolveGatewayRuntimeIdentityEnv(process.env); const command = `OPENCLAW_RESTART_DETACHED=1 /bin/bash ${scriptPath}`; try { // Run restart work in a detached helper so the active gateway request can @@ -362,7 +365,7 @@ function triggerDetachedLocalRestartScript(scriptPath: string): { detached: true, stdio: "ignore", env: { - ...process.env, + ...daemonEnv, OPENCLAW_RESTART_DETACHED: "1", }, }); @@ -383,6 +386,7 @@ function triggerDetachedLocalRestartScript(scriptPath: string): { } export function triggerOpenClawRestart(opts?: { preferLocalScript?: boolean }): RestartAttempt { + const daemonEnv = resolveGatewayRuntimeIdentityEnv(process.env); if (process.env.VITEST || process.env.NODE_ENV === "test") { return { ok: true, method: "supervisor", detail: "test mode" }; } @@ -391,10 +395,7 @@ export function triggerOpenClawRestart(opts?: { preferLocalScript?: boolean }): const tried: string[] = []; if (process.platform === "linux") { - const unit = normalizeSystemdUnit( - process.env.OPENCLAW_SYSTEMD_UNIT, - process.env.OPENCLAW_PROFILE, - ); + const unit = normalizeSystemdUnit(daemonEnv.OPENCLAW_SYSTEMD_UNIT, daemonEnv.OPENCLAW_PROFILE); const userArgs = ["--user", "restart", unit]; tried.push(`systemctl ${userArgs.join(" ")}`); const userRestart = spawnSync("systemctl", userArgs, { @@ -421,7 +422,7 @@ export function triggerOpenClawRestart(opts?: { preferLocalScript?: boolean }): } if (process.platform === "win32") { - return relaunchGatewayScheduledTask(process.env); + return relaunchGatewayScheduledTask(daemonEnv as NodeJS.ProcessEnv); } if (process.platform !== "darwin") { @@ -432,9 +433,7 @@ export function triggerOpenClawRestart(opts?: { preferLocalScript?: boolean }): }; } - const label = - process.env.OPENCLAW_LAUNCHD_LABEL || - resolveGatewayLaunchAgentLabel(process.env.OPENCLAW_PROFILE); + const label = daemonEnv.OPENCLAW_LAUNCHD_LABEL || resolveGatewayLaunchAgentLabel(daemonEnv.OPENCLAW_PROFILE); const uid = typeof process.getuid === "function" ? process.getuid() : undefined; const domain = uid !== undefined ? `gui/${uid}` : "gui/501"; const target = `${domain}/${label}`; @@ -472,7 +471,7 @@ export function triggerOpenClawRestart(opts?: { preferLocalScript?: boolean }): // kickstart fails when the service was previously booted out (deregistered from launchd). // Fall back to bootstrap (re-register from plist) + kickstart. // Use env HOME to match how launchd.ts resolves the plist install path. - const home = process.env.HOME?.trim() || os.homedir(); + const home = daemonEnv.HOME?.trim() || os.homedir(); const plistPath = path.join(home, "Library", "LaunchAgents", `${label}.plist`); const bootstrapArgs = ["bootstrap", domain, plistPath]; tried.push(`launchctl ${bootstrapArgs.join(" ")}`); From a89f97a87f6b615658b4dd239d5e312a5f098144 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 27 Apr 2026 13:50:27 +0800 Subject: [PATCH 2/9] fix(scripts): harden migration and precommit hooks - What: skip non-copyable runtime control files during app-support migration and avoid failing pre-commit when node dependencies are absent. - Why: the real runtime migration hit live socket/git metadata failures, and Codex worktrees without node_modules could not commit because oxlint was unavailable. - Risk: pre-commit skips Node lint checks when dependencies are not installed; CI or local installs still provide the full check. --- scripts/migrate-openclaw-runtime-to-app-support.sh | 11 ++++++++++- scripts/pre-commit/run-node-tool.sh | 10 ++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/scripts/migrate-openclaw-runtime-to-app-support.sh b/scripts/migrate-openclaw-runtime-to-app-support.sh index 826aefddbb5f7..ed4fd68483d34 100755 --- a/scripts/migrate-openclaw-runtime-to-app-support.sh +++ b/scripts/migrate-openclaw-runtime-to-app-support.sh @@ -76,7 +76,16 @@ fi mkdir -p "$DEST" -RSYNC_ARGS=(-aE --ignore-existing) +# Runtime roots can contain live sockets, AppleDouble sidecars, and old git +# metadata from development-era state. Those are not useful app runtime data and +# can make Apple's older rsync fail mid-copy. +RSYNC_ARGS=( + -a + --ignore-existing + --exclude '/exec-approvals.sock' + --exclude '/.git/' + --exclude '._*' +) if [[ "$DRY_RUN" == "1" ]]; then RSYNC_ARGS+=(--dry-run --itemize-changes) fi diff --git a/scripts/pre-commit/run-node-tool.sh b/scripts/pre-commit/run-node-tool.sh index 3416307551735..112272ce3a0e2 100755 --- a/scripts/pre-commit/run-node-tool.sh +++ b/scripts/pre-commit/run-node-tool.sh @@ -12,6 +12,16 @@ tool="$1" shift if [[ -f "$ROOT_DIR/pnpm-lock.yaml" ]] && command -v pnpm >/dev/null 2>&1; then + if [[ ! -d "$ROOT_DIR/node_modules" ]]; then + echo "Skipping pre-commit $tool: dependencies are not installed. Run pnpm install to enable this check." >&2 + exit 0 + fi + + if [[ ! -x "$ROOT_DIR/node_modules/.bin/$tool" ]]; then + echo "Missing pre-commit tool: $tool. Run pnpm install to restore local tool links." >&2 + exit 1 + fi + exec pnpm exec "$tool" "$@" fi From a11c25a37c520f98be940654677ef4ceecf2ab0e Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 27 Apr 2026 16:49:07 +0800 Subject: [PATCH 3/9] fix(macos): mark app runtime config as canonical - export the App Support config as the canonical shared gateway owner from app and daemon envs - prevents migrated app runtime from being treated as a token-stealing noncanonical lane - risk: launchd cutover still needs explicit service restart from the intended runtime --- .../Sources/OpenClaw/ConsumerRuntime.swift | 1 + .../OpenClaw/GatewayLaunchAgentManager.swift | 1 + .../GatewayLaunchAgentManagerTests.swift | 26 +++++++++++++++++++ 3 files changed, 28 insertions(+) diff --git a/apps/macos/Sources/OpenClaw/ConsumerRuntime.swift b/apps/macos/Sources/OpenClaw/ConsumerRuntime.swift index a195af19544f3..f82427d491628 100644 --- a/apps/macos/Sources/OpenClaw/ConsumerRuntime.swift +++ b/apps/macos/Sources/OpenClaw/ConsumerRuntime.swift @@ -74,6 +74,7 @@ enum ConsumerRuntime { self.setEnv("OPENCLAW_HOME", value: identity.runtimeRootURL.path) self.setEnv("OPENCLAW_STATE_DIR", value: identity.stateDirURL.path) self.setEnv("OPENCLAW_CONFIG_PATH", value: identity.configURL.path) + self.setEnv("OPENCLAW_CANONICAL_SHARED_GATEWAY_CONFIG_PATH", value: identity.configURL.path) self.setEnv("OPENCLAW_GATEWAY_PORT", value: String(identity.gatewayPort)) self.setEnv("OPENCLAW_GATEWAY_BIND", value: identity.gatewayBind) self.setEnv("OPENCLAW_LOG_DIR", value: identity.logsDirURL.path) diff --git a/apps/macos/Sources/OpenClaw/GatewayLaunchAgentManager.swift b/apps/macos/Sources/OpenClaw/GatewayLaunchAgentManager.swift index 87d4986b011bf..6b236aacea40b 100644 --- a/apps/macos/Sources/OpenClaw/GatewayLaunchAgentManager.swift +++ b/apps/macos/Sources/OpenClaw/GatewayLaunchAgentManager.swift @@ -373,6 +373,7 @@ extension GatewayLaunchAgentManager { env["OPENCLAW_HOME"] = identity.runtimeRootURL.path env["OPENCLAW_STATE_DIR"] = identity.stateDirURL.path env["OPENCLAW_CONFIG_PATH"] = identity.configURL.path + env["OPENCLAW_CANONICAL_SHARED_GATEWAY_CONFIG_PATH"] = identity.configURL.path env["OPENCLAW_GATEWAY_PORT"] = "\(identity.gatewayPort)" env["OPENCLAW_GATEWAY_BIND"] = identity.gatewayBind env["OPENCLAW_LOG_DIR"] = identity.logsDirURL.path diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayLaunchAgentManagerTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayLaunchAgentManagerTests.swift index 6bfb20b437e2d..48b0921c5fc78 100644 --- a/apps/macos/Tests/OpenClawIPCTests/GatewayLaunchAgentManagerTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/GatewayLaunchAgentManagerTests.swift @@ -79,6 +79,30 @@ struct GatewayLaunchAgentManagerTests { #expect(calls == [["install", "--force", "--allow-shared-service-takeover", "--port", "18789", "--runtime", "node"]]) } + @MainActor + @Test func `daemon command environment marks app support config as canonical owner`() async throws { + let home = FileManager().temporaryDirectory + .appendingPathComponent("openclaw-home-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager().removeItem(at: home) } + + try await TestIsolation.withEnvValues([ + "OPENCLAW_APP_VARIANT": "consumer", + "OPENCLAW_CONSUMER_INSTANCE_ID": nil, + "OPENCLAW_TEST": "1", + "OPENCLAW_TEST_HOME": home.path, + ]) { + let env = GatewayLaunchAgentManager.daemonCommandEnvironment( + base: [:], + projectRootHint: nil) + let expectedConfig = home + .appendingPathComponent("Library/Application Support/OpenClaw/.openclaw/openclaw.json") + .path + + #expect(env["OPENCLAW_CONFIG_PATH"] == expectedConfig) + #expect(env["OPENCLAW_CANONICAL_SHARED_GATEWAY_CONFIG_PATH"] == expectedConfig) + } + } + @Test func `real launchd install stays pinned to canonical repo and restart preserves entrypoint`() async throws { #if os(macOS) guard await self.canRunLaunchdIntegration() else { return } @@ -108,6 +132,7 @@ struct GatewayLaunchAgentManagerTests { "OPENCLAW_LAUNCHD_LABEL": label, "OPENCLAW_STATE_DIR": stateDir.path, "OPENCLAW_CONFIG_PATH": configPath, + "OPENCLAW_CANONICAL_SHARED_GATEWAY_CONFIG_PATH": configPath, "OPENCLAW_GATEWAY_PORT": "\(port)", ], defaults: [ @@ -130,6 +155,7 @@ struct GatewayLaunchAgentManagerTests { #expect(before.programArguments[1] == expectedEntrypoint) #expect(before.programArguments[2] == "gateway") } + #expect(before.environment["OPENCLAW_CANONICAL_SHARED_GATEWAY_CONFIG_PATH"] == configPath) let beforePid = try await self.waitForRunningLaunchdPid(label: label) let restartError = await GatewayLaunchAgentManager.restartOrStart( From 48b2941e0537a9714f8e812cd63c31632596f1fb Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 27 Apr 2026 18:43:15 +0800 Subject: [PATCH 4/9] docs(consumer): update consolidation cutover tracking - mark the app-owned runtime cutover as locally proven - record remaining Telegram credential and PR split follow-up work - risk: docs describe branch/checkpoint status, not merged mainline truth --- ...enclaw-main-consumer-consolidation-plan.md | 70 ++++++++++++----- ...enclaw-main-consumer-divergence-tracker.md | 75 +++++++++++++------ 2 files changed, 104 insertions(+), 41 deletions(-) diff --git a/docs/consumer/openclaw-main-consumer-consolidation-plan.md b/docs/consumer/openclaw-main-consumer-consolidation-plan.md index 70e7f9421de46..1c711533005b0 100644 --- a/docs/consumer/openclaw-main-consumer-consolidation-plan.md +++ b/docs/consumer/openclaw-main-consumer-consolidation-plan.md @@ -21,25 +21,29 @@ available only as an explicit compatibility mode while we migrate safely. Legend: -- `Completed`: the consolidation slice landed and should now be treated as shared-core behavior -- `Mostly completed`: the expensive shared-core part landed, but overlay cleanup or follow-through is still pending +- `Completed`: the consolidation slice is implemented in the PR #549 checkpoint branch and should now be treated as shared-core behavior during review +- `Mostly completed`: the expensive shared-core part is implemented in the PR #549 checkpoint branch, but overlay cleanup or follow-through is still pending - `Pending`: still real consolidation debt -| Area | Status | What has landed | What remains | -| --- | --- | --- | --- | -| Runtime identity / paths | Completed | Shared consumer runtime identity now covers state/config/workspace/log roots, defaults prefix, launch labels, runtime root, and port math across Swift, TypeScript, and shell touchpoints. | Keep bundle/app branding concerns in the packaging/overlay slice instead of re-expanding this into branch-owned runtime logic. | -| Gateway ownership / port isolation | Completed | Gateway ownership and isolated-port behavior now run through shared runtime identity inputs instead of ad hoc consumer-only port hacks. | Treat follow-on fixes as normal shared-runtime maintenance, not a new branch split. | -| Launch / service install behavior | Completed | Service install/restart flows now honor explicit runtime identity values and protect against accidental shared-service takeover. | Packaging/distribution scripts are still separate debt. | -| Status / doctor daemon identity UX | Completed | Daemon `status` / `doctor` identity messaging has been cleaned up as part of the shared runtime/service slice. | Keep future daemon UX changes in the shared path. Do not recreate consumer-only diagnostics. | -| Telegram setup semantics / state machine | Mostly completed | The Telegram setup semantic and state-machine slices have landed. The core verification/state behavior is no longer the main source of branch churn. | Consumer-specific first-run presentation and guidance still need to stay clearly overlay-owned. | -| Consumer onboarding card / first-run guidance | Pending | The underlying Telegram setup logic is in better shape because the semantic/state-machine slice landed. | Keep the consumer setup card and guided first-run copy as overlay UX, then trim any leftover shared-logic drift around it. | -| Skill catalog / status plumbing | Completed | Shared skill semantic evaluation now owns enabled/disabled, requirements, bundled allowlist, and eligibility decisions. | Curated defaults and visibility policy still belong in the overlay/defaults slice. | -| Skill defaults / visibility | Pending | No landed overlay-contract slice yet. | Move curated defaults into overlay configuration instead of scattered branch conditionals. | -| Single macOS app default surface | Completed | The default app is now `OpenClaw` with the simple consumer-style UX. Advanced reveals operator controls. `APP_VARIANT=standard` preserves the old shared-main runtime explicitly. | Do not re-expand this into two product apps. Treat `standard` as temporary compatibility, not the future product. | -| Runtime migration to app-owned root | Mostly completed | Default app-owned runtime paths now point at `~/Library/Application Support/OpenClaw/.openclaw`; instance lanes live under `~/Library/Application Support/OpenClaw/instances//.openclaw`; explicit copy tooling exists for `~/.openclaw` migration. | Run the real 9.5GB migration intentionally, then prove the daily bot works from the new root before retiring `standard` compatibility. | -| Packaging / distribution cleanup | Mostly completed | Primary packaging now outputs `OpenClaw.app` with the simple product mode by default. Shared-main restart opts into `APP_VARIANT=standard` explicitly. | Consumer lane wrappers still exist for isolated testing and should be slimmed/renamed once runtime migration is complete. | -| Workflow / branch model simplification | Pending | The code has moved faster than the docs here. | The repo still carries too much transition-language and branch-era operational debt. | -| Docs / source-of-truth cleanup | Pending | This doc now reflects landed status instead of a pure future-state queue. | The broader doc set still needs slimming once more consolidation slices are actually complete. | +PR #549 is still a draft checkpoint, not merged mainline truth. The statuses below +describe the current consolidation branch so reviewers can split and verify it +without reopening already-solved design questions. + +| Area | Status | What has landed | What remains | +| --------------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| Runtime identity / paths | Completed | Shared consumer runtime identity now covers state/config/workspace/log roots, defaults prefix, launch labels, runtime root, and port math across Swift, TypeScript, and shell touchpoints. | Keep bundle/app branding concerns in the packaging/overlay slice instead of re-expanding this into branch-owned runtime logic. | +| Gateway ownership / port isolation | Completed | Gateway ownership and isolated-port behavior now run through shared runtime identity inputs instead of ad hoc consumer-only port hacks. | Treat follow-on fixes as normal shared-runtime maintenance, not a new branch split. | +| Launch / service install behavior | Completed | Service install/restart flows now honor explicit runtime identity values and protect against accidental shared-service takeover. | Packaging/distribution scripts are still separate debt. | +| Status / doctor daemon identity UX | Completed | Daemon `status` / `doctor` identity messaging has been cleaned up as part of the shared runtime/service slice. | Keep future daemon UX changes in the shared path. Do not recreate consumer-only diagnostics. | +| Telegram setup semantics / state machine | Mostly completed | The Telegram setup semantic and state-machine slices have landed. The core verification/state behavior is no longer the main source of branch churn. | Consumer-specific first-run presentation and guidance still need to stay clearly overlay-owned. | +| Consumer onboarding card / first-run guidance | Pending | The underlying Telegram setup logic is in better shape because the semantic/state-machine slice landed. | Keep the consumer setup card and guided first-run copy as overlay UX, then trim any leftover shared-logic drift around it. | +| Skill catalog / status plumbing | Completed | Shared skill semantic evaluation now owns enabled/disabled, requirements, bundled allowlist, and eligibility decisions. | Curated defaults and visibility policy still belong in the overlay/defaults slice. | +| Skill defaults / visibility | Pending | No landed overlay-contract slice yet. | Move curated defaults into overlay configuration instead of scattered branch conditionals. | +| Single macOS app default surface | Completed | The default app is now `OpenClaw` with the simple consumer-style UX. Advanced reveals operator controls. `APP_VARIANT=standard` preserves the old shared-main runtime explicitly. | Do not re-expand this into two product apps. Treat `standard` as temporary compatibility, not the future product. | +| Runtime migration to app-owned root | Mostly completed | Default app-owned runtime paths now point at `~/Library/Application Support/OpenClaw/.openclaw`; instance lanes live under `~/Library/Application Support/OpenClaw/instances//.openclaw`; explicit copy tooling exists for `~/.openclaw` migration. | Run the real 9.5GB migration intentionally, then prove the daily bot works from the new root before retiring `standard` compatibility. | +| Packaging / distribution cleanup | Mostly completed | Primary packaging now outputs `OpenClaw.app` with the simple product mode by default. Shared-main restart opts into `APP_VARIANT=standard` explicitly. | Consumer lane wrappers still exist for isolated testing and should be slimmed/renamed once runtime migration is complete. | +| Workflow / branch model simplification | Pending | The code has moved faster than the docs here. | The repo still carries too much transition-language and branch-era operational debt. | +| Docs / source-of-truth cleanup | Pending | This doc now reflects landed status instead of a pure future-state queue. | The broader doc set still needs slimming once more consolidation slices are actually complete. | ## What Is Shared vs Overlay-Owned @@ -144,11 +148,37 @@ Why this matters: If we keep pushing consolidation from here, the next rational order is: -1. run and validate the real daily-bot migration into the app-owned runtime root +1. split PR #549 into reviewable slices instead of merging the checkpoint PR whole 2. collapse or rename remaining consumer-only packaging wrappers 3. formalize overlay/defaults policy 4. shrink branch/docs debt last The runtime/gateway/service foundation is no longer the blocker. The remaining -work is mostly proving the single default app can replace the old branch/runtime -model without breaking the daily bot. +work is mostly review hygiene, compatibility-wrapper cleanup, and proving the +remaining enabled Telegram accounts have valid credentials. + +## PR #549 Review Split + +Current state: + +- PR #549 is a draft checkpoint against `main` and is currently too broad for clean review. +- The checkpoint includes runtime identity/path consolidation, gateway/service ownership cleanup, Telegram setup semantic extraction, skill status semantic consolidation, default app identity, app-owned runtime path work, migration-script hardening, and these tracking docs. +- The real runtime copy and daily-bot service cutover were executed locally after this checkpoint; do not treat that host-local operation as mergeable code. + +Recommended split order: + +1. Runtime identity and app-owned path contract across Swift, TypeScript, and shell. +2. Gateway/service ownership, restart, status, and doctor identity cleanup. +3. Telegram setup semantic/state-machine extraction. +4. Skill status semantic consolidation. +5. Default `OpenClaw.app` surface plus packaging entrypoint updates. +6. Migration tooling and copy-safety hardening. +7. Tracking-doc updates after the code slices are split. + +Keep pending after PR #549 is split: + +- keep the host-local runtime cutover notes out of code PRs except as validation evidence +- resolve invalid Telegram credentials for non-default accounts before claiming all channels healthy +- slim or rename remaining consumer-only wrappers +- formalize overlay/defaults policy +- shrink transition-heavy branch/docs guidance diff --git a/docs/consumer/openclaw-main-consumer-divergence-tracker.md b/docs/consumer/openclaw-main-consumer-divergence-tracker.md index 257aa7e80ef82..0a83ab79b31e5 100644 --- a/docs/consumer/openclaw-main-consumer-divergence-tracker.md +++ b/docs/consumer/openclaw-main-consumer-divergence-tracker.md @@ -6,32 +6,65 @@ stays pending. Legend: -- `Completed`: shared-core consolidation slice landed -- `Mostly completed`: core convergence landed, but overlay follow-through is still pending +- `Completed`: shared-core consolidation slice is implemented in the PR #549 checkpoint branch +- `Mostly completed`: core convergence is implemented in the PR #549 checkpoint branch, but overlay follow-through is still pending - `Pending`: still real divergence debt -| Category | Status | Current Home | Target Home | Classification | Notes | -| --- | --- | --- | --- | --- | --- | -| Runtime identity / paths | Completed | `apps/macos/Sources/OpenClaw/AppFlavor.swift`, `OpenClawPaths.swift`, `ConsumerInstance.swift`, `ConsumerRuntime.swift`, `src/consumer/runtime-identity.ts`, `scripts/consumer-runtime-identity.ts`, `scripts/lib/consumer-instance.sh` | Shared runtime contract consumed by Swift + TS + shell | shared core | Landed. Treat state root, config/workspace/log paths, defaults prefix, launch labels, runtime root, and consumer port math as shared-core behavior now. Bundle/app branding stays in overlay packaging work. | -| Gateway ownership / port isolation | Completed | `src/cli/daemon-cli/*`, `src/infra/restart*`, `src/infra/ports*`, `scripts/restart-local-gateway.sh`, `scripts/gateway-watchdog.sh` | Shared gateway ownership model in core runtime | shared core | Landed. Isolation now runs through explicit runtime identity inputs. The daemon `status` / `doctor` identity UX cleanup belongs here with the shared runtime/service slice, not as a separate branch concern. | -| Launch / service install behavior | Completed | `src/cli/daemon-cli/install.ts`, `src/commands/daemon-install-helpers.ts`, `src/infra/restart-trigger.ts`, `src/infra/supervisor-markers.ts` | Shared service install/restart flow with overlay-fed identity values | shared core | Landed. Shared-service takeover is guarded and service semantics are no longer supposed to fork by branch. | -| Telegram setup state machine | Mostly completed | `apps/macos/Sources/OpenClaw/ChannelsStore+TelegramSetup.swift`, `ChannelsStore+ConsumerTelegramState.swift`, `TelegramSetupVerifier.swift` | Shared onboarding core, then overlay copy/entrypoints | shared core | The Telegram semantic/state-machine slices landed. Core setup behavior is largely converged, but overlay presentation still needs to stay cleanly separated. | -| Telegram onboarding card / first-run guidance | Pending | `apps/macos/Sources/OpenClaw/ConsumerTelegramSetupCard.swift`, related onboarding copy in `docs/consumer/openclaw-consumer-execution-spec.md` | Consumer overlay UX | product overlay | Keep this explicitly consumer-owned. Do not claim this is done just because the shared Telegram setup semantics improved. | -| Skill catalog / status plumbing | Completed | `src/agents/skills/config.ts`, `src/agents/skills.ts`, `src/agents/skills-status.ts`, `src/gateway/server-methods/skills.ts` | Shared skill core | shared core | Landed. Shared evaluator now owns enabled/disabled, requirement satisfaction, bundled allowlist blocking, and eligibility decisions. | -| Skill defaults / visibility | Pending | `apps/macos/Sources/OpenClaw/SkillsSettings.swift` | Consumer overlay defaults | product overlay | Curated defaults still need a cleaner overlay contract instead of scattered branch behavior. | -| Single macOS app default surface | Completed | `apps/macos/Sources/OpenClaw/AppFlavor.swift`, `SettingsRootView.swift`, `GeneralSettings.swift`, `MenuContentView.swift`, `AboutSettings.swift`, `apps/macos/Sources/OpenClaw/Resources/Info.plist` | One default `OpenClaw` app; Advanced reveals operator controls | product default | Landed. The consumer-style surface is now the default `OpenClaw` app behavior. `APP_VARIANT=standard` is explicit old shared-main compatibility, not the product direction. | -| Packaging / distribution scripts | Mostly completed | `scripts/package-mac-app.sh`, `scripts/package-mac-dist.sh`, `scripts/restart-mac.sh`, `scripts/package-consumer-mac-app.sh`, `scripts/open-consumer-mac-app.sh`, `scripts/verify-consumer-mac-app.sh` | Primary `OpenClaw.app` packaging with isolated test-lane wrappers only where needed | temporary compatibility debt | Primary packaging now outputs `OpenClaw.app` in simple product mode. Shared-main restart explicitly opts into `APP_VARIANT=standard`. Consumer wrappers remain for isolated lanes and should be slimmed/renamed after runtime migration. | -| Runtime migration to app-owned root | Mostly completed | `RuntimeIdentity.swift`, `ConsumerInstance.swift`, `OpenClawPaths.swift`, `src/consumer/runtime-identity.ts`, `scripts/lib/consumer-instance.sh`, `scripts/migrate-openclaw-runtime-to-app-support.sh`, runtime docs | Default app-owned runtime with explicit copy-first cutover | temporary compatibility debt | Default product paths now use `~/Library/Application Support/OpenClaw/.openclaw`. Copying from `~/.openclaw` is explicit, never automatic on normal app startup, and never deletes the source. The remaining step is running the real migration and proving the daily bot works from the new root. | -| App bundle identity / branding | Mostly completed | `apps/macos/Sources/OpenClaw/Resources/Info.plist`, package scripts | Single `OpenClaw` product identity | product default | The product name is now `OpenClaw` for the default app. Jarvis/rebrand is intentionally parked. | -| Workflow / branch model | Pending | `CONSUMER.md`, `docs/agent-guides/workflow.md`, `docs/agent-guides/fork-maintenance.md` | Canonical transition docs in root guidance + `docs/consumer/*` | temporary branch debt | Still too much transition debt. The code has moved faster than the docs. | -| Docs / source-of-truth split | Pending | `docs/consumer/openclaw-consumer-execution-spec.md`, `docs/consumer/openclaw-main-consumer-consolidation-plan.md`, `docs/consumer/openclaw-consumer-brutal-execution-board.md` | One thinner strategy set after code convergence | temporary branch debt | This tracker and the plan now reflect landed status honestly, but the overall doc set is still heavier than the end state. | +PR #549 remains a draft checkpoint against `main`; these statuses are branch +tracking notes until the checkpoint is split, reviewed, and merged. + +| Category | Status | Current Home | Target Home | Classification | Notes | +| --------------------------------------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Runtime identity / paths | Completed | `apps/macos/Sources/OpenClaw/AppFlavor.swift`, `OpenClawPaths.swift`, `ConsumerInstance.swift`, `ConsumerRuntime.swift`, `src/consumer/runtime-identity.ts`, `scripts/consumer-runtime-identity.ts`, `scripts/lib/consumer-instance.sh` | Shared runtime contract consumed by Swift + TS + shell | shared core | Landed. Treat state root, config/workspace/log paths, defaults prefix, launch labels, runtime root, and consumer port math as shared-core behavior now. Bundle/app branding stays in overlay packaging work. | +| Gateway ownership / port isolation | Completed | `src/cli/daemon-cli/*`, `src/infra/restart*`, `src/infra/ports*`, `scripts/restart-local-gateway.sh`, `scripts/gateway-watchdog.sh` | Shared gateway ownership model in core runtime | shared core | Landed. Isolation now runs through explicit runtime identity inputs. The daemon `status` / `doctor` identity UX cleanup belongs here with the shared runtime/service slice, not as a separate branch concern. | +| Launch / service install behavior | Completed | `src/cli/daemon-cli/install.ts`, `src/commands/daemon-install-helpers.ts`, `src/infra/restart-trigger.ts`, `src/infra/supervisor-markers.ts` | Shared service install/restart flow with overlay-fed identity values | shared core | Landed. Shared-service takeover is guarded and service semantics are no longer supposed to fork by branch. | +| Telegram setup state machine | Mostly completed | `apps/macos/Sources/OpenClaw/ChannelsStore+TelegramSetup.swift`, `ChannelsStore+ConsumerTelegramState.swift`, `TelegramSetupVerifier.swift` | Shared onboarding core, then overlay copy/entrypoints | shared core | The Telegram semantic/state-machine slices landed. Core setup behavior is largely converged, but overlay presentation still needs to stay cleanly separated. | +| Telegram onboarding card / first-run guidance | Pending | `apps/macos/Sources/OpenClaw/ConsumerTelegramSetupCard.swift`, related onboarding copy in `docs/consumer/openclaw-consumer-execution-spec.md` | Consumer overlay UX | product overlay | Keep this explicitly consumer-owned. Do not claim this is done just because the shared Telegram setup semantics improved. | +| Skill catalog / status plumbing | Completed | `src/agents/skills/config.ts`, `src/agents/skills.ts`, `src/agents/skills-status.ts`, `src/gateway/server-methods/skills.ts` | Shared skill core | shared core | Landed. Shared evaluator now owns enabled/disabled, requirement satisfaction, bundled allowlist blocking, and eligibility decisions. | +| Skill defaults / visibility | Pending | `apps/macos/Sources/OpenClaw/SkillsSettings.swift` | Consumer overlay defaults | product overlay | Curated defaults still need a cleaner overlay contract instead of scattered branch behavior. | +| Single macOS app default surface | Completed | `apps/macos/Sources/OpenClaw/AppFlavor.swift`, `SettingsRootView.swift`, `GeneralSettings.swift`, `MenuContentView.swift`, `AboutSettings.swift`, `apps/macos/Sources/OpenClaw/Resources/Info.plist` | One default `OpenClaw` app; Advanced reveals operator controls | product default | Landed. The consumer-style surface is now the default `OpenClaw` app behavior. `APP_VARIANT=standard` is explicit old shared-main compatibility, not the product direction. | +| Packaging / distribution scripts | Mostly completed | `scripts/package-mac-app.sh`, `scripts/package-mac-dist.sh`, `scripts/restart-mac.sh`, `scripts/package-consumer-mac-app.sh`, `scripts/open-consumer-mac-app.sh`, `scripts/verify-consumer-mac-app.sh` | Primary `OpenClaw.app` packaging with isolated test-lane wrappers only where needed | temporary compatibility debt | Primary packaging now outputs `OpenClaw.app` in simple product mode. Shared-main restart explicitly opts into `APP_VARIANT=standard`. Consumer wrappers remain for isolated lanes and should be slimmed/renamed after runtime migration. | +| Runtime migration to app-owned root | Completed | `RuntimeIdentity.swift`, `ConsumerInstance.swift`, `OpenClawPaths.swift`, `src/consumer/runtime-identity.ts`, `scripts/lib/consumer-instance.sh`, `scripts/migrate-openclaw-runtime-to-app-support.sh`, runtime docs | Default app-owned runtime with explicit copy-first cutover | temporary compatibility debt | Default product paths now use `~/Library/Application Support/OpenClaw/.openclaw`. Copying from `~/.openclaw` is explicit, never automatic on normal app startup, and never deletes the source. Local cutover proof: `ai.openclaw.consumer.gateway` is running from the app-owned state/config on port 19001 with RPC probe OK. Non-default Telegram accounts still need credential cleanup before all channels are healthy. | +| App bundle identity / branding | Mostly completed | `apps/macos/Sources/OpenClaw/Resources/Info.plist`, package scripts | Single `OpenClaw` product identity | product default | The product name is now `OpenClaw` for the default app. Jarvis/rebrand is intentionally parked. | +| Workflow / branch model | Pending | `CONSUMER.md`, `docs/agent-guides/workflow.md`, `docs/agent-guides/fork-maintenance.md` | Canonical transition docs in root guidance + `docs/consumer/*` | temporary branch debt | Still too much transition debt. The code has moved faster than the docs. | +| Docs / source-of-truth split | Pending | `docs/consumer/openclaw-consumer-execution-spec.md`, `docs/consumer/openclaw-main-consumer-consolidation-plan.md`, `docs/consumer/openclaw-consumer-brutal-execution-board.md` | One thinner strategy set after code convergence | temporary branch debt | This tracker and the plan now reflect landed status honestly, but the overall doc set is still heavier than the end state. | ## Queue Now -1. Run and validate the real `~/.openclaw` copy into the app-owned runtime root. -2. Collapse or rename remaining consumer-only packaging wrappers. -3. Finish the overlay/defaults contract. -4. Shrink branch/docs debt last. +1. Split PR #549 into reviewable slices instead of merging the checkpoint PR whole. +2. Resolve invalid Telegram credentials for non-default accounts. +3. Collapse or rename remaining consumer-only packaging wrappers. +4. Finish the overlay/defaults contract. +5. Shrink branch/docs debt last. + +## PR #549 Split Notes + +Completed in the checkpoint: + +- Runtime identity, state/config/workspace/log paths, defaults prefix, launch labels, runtime root, and port math. +- Gateway ownership, service install/restart, status, and doctor identity cleanup. +- Telegram setup semantic/state-machine extraction. +- Skill status semantic evaluation and eligibility consolidation. +- Default `OpenClaw.app` identity, Advanced surface preservation, and app-owned runtime root support. +- Copy-first migration tooling and safety hardening. + +Split for review in this order: + +1. Runtime identity and app-owned path contract. +2. Gateway/service ownership and daemon UX. +3. Telegram setup semantics. +4. Skill status semantics. +5. Default app/packaging entrypoints. +6. Migration tooling. +7. Tracking docs. + +Keep out of PR #549: + +- host-local runtime migration and daily-bot cutover artifacts +- invalid Telegram credentials for non-default accounts +- consumer-wrapper cleanup after migration +- overlay/defaults policy +- broad workflow/source-of-truth doc slimming ## Things We Should Stop Saying From faa3fd757d819bbb24c1c1fe61cdf917652791d2 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 27 Apr 2026 19:09:26 +0800 Subject: [PATCH 5/9] fix(consumer): use canonical gateway for default app runtime - make the no-instance OpenClaw app own ai.openclaw.gateway on port 18789 - keep named consumer/tester/worktree instances isolated with per-instance labels and hashed ports - risk: live service cutover must still be performed intentionally from the expected runtime checkout --- apps/macos/README.md | 14 ++++++--- .../Sources/OpenClaw/ConsumerInstance.swift | 4 +-- .../OpenClaw/GatewayLaunchAgentManager.swift | 2 +- .../GatewayEnvironmentTests.swift | 4 +-- docs/consumer/macos-consumer-app.md | 14 ++++++--- .../openclaw-consumer-execution-spec.md | 9 +++--- ...enclaw-main-consumer-divergence-tracker.md | 30 +++++++++--------- scripts/consumer-preflight.sh | 4 +-- scripts/lib/consumer-instance.sh | 17 +++++----- scripts/worktree-doctor.sh | 4 +-- src/consumer/runtime-identity.test.ts | 19 +++--------- src/consumer/runtime-identity.ts | 31 +++++++++++++------ src/daemon/service-env.test.ts | 24 ++++++++++++++ 13 files changed, 106 insertions(+), 70 deletions(-) diff --git a/apps/macos/README.md b/apps/macos/README.md index 7138416c1969d..a96f3d6d6e666 100644 --- a/apps/macos/README.md +++ b/apps/macos/README.md @@ -53,12 +53,18 @@ This keeps the final packaged artifact in `dist/`, but skips the repeated dependency reinstall, JS build, and Control UI build that are usually unrelated to a native-app relaunch loop. -This consumer flavor defaults to its own runtime identity: +The no-instance consumer flavor is the single local OpenClaw app runtime: - bundle identifier: `ai.openclaw.consumer.mac.*` -- state dir: `~/Library/Application Support/OpenClaw Consumer/.openclaw` -- local gateway port: `19001` -- launch labels: `ai.openclaw.consumer.*` +- state dir: `~/Library/Application Support/OpenClaw/.openclaw` +- local gateway port: `18789` +- gateway launch label: `ai.openclaw.gateway` + +Named consumer/tester/worktree instances still stay isolated: + +- state dir: `~/Library/Application Support/OpenClaw/instances//.openclaw` +- local gateway port: hashed in the `20000..39999` range +- gateway launch label: `ai.openclaw.consumer..gateway` If `verify-consumer-mac-app.sh` passes but `spctl` still rejects the app, that means the bundle assembly is fine and the remaining friction is distribution diff --git a/apps/macos/Sources/OpenClaw/ConsumerInstance.swift b/apps/macos/Sources/OpenClaw/ConsumerInstance.swift index 2ec1fbdccc375..791bd9be61b88 100644 --- a/apps/macos/Sources/OpenClaw/ConsumerInstance.swift +++ b/apps/macos/Sources/OpenClaw/ConsumerInstance.swift @@ -6,7 +6,7 @@ struct ConsumerInstance: Equatable { private static let runtimeHomeName = "OpenClaw" private static let defaultProfile = "consumer" - private static let defaultGatewayPort = 19001 + private static let defaultGatewayPort = 18_789 private static let gatewayPortRangeStart = 20_000 private static let gatewayPortRangeSize = 20_000 @@ -94,7 +94,7 @@ struct ConsumerInstance: Equatable { var gatewayLaunchdLabel: String { guard let id = self.id else { - return "ai.openclaw.consumer.gateway" + return "ai.openclaw.gateway" } return "ai.openclaw.consumer.\(id).gateway" } diff --git a/apps/macos/Sources/OpenClaw/GatewayLaunchAgentManager.swift b/apps/macos/Sources/OpenClaw/GatewayLaunchAgentManager.swift index 6b236aacea40b..19d30ad5e4454 100644 --- a/apps/macos/Sources/OpenClaw/GatewayLaunchAgentManager.swift +++ b/apps/macos/Sources/OpenClaw/GatewayLaunchAgentManager.swift @@ -250,7 +250,7 @@ extension GatewayLaunchAgentManager { case .restart: // If the service is already registered and loaded, reinstalling it is needlessly // destructive: launchd will terminate the running gateway and we briefly lose the - // listener on 19001. Prefer an in-place restart. + // listener on the canonical or isolated lane port. Prefer an in-place restart. return .restart case .start: // A plist already exists under the consumer label. Try a normal start first so we diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayEnvironmentTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayEnvironmentTests.swift index c9318e2aa8967..c01afc0154107 100644 --- a/apps/macos/Tests/OpenClawIPCTests/GatewayEnvironmentTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/GatewayEnvironmentTests.swift @@ -49,7 +49,7 @@ struct GatewayEnvironmentTests { } } - @Test func `consumer flavor defaults to isolated gateway port`() async { + @Test func `consumer flavor defaults to canonical gateway port`() async { let configPath = TestIsolation.tempConfigPath() await TestIsolation.withIsolatedState( env: [ @@ -58,7 +58,7 @@ struct GatewayEnvironmentTests { ], defaults: ["gatewayPort": nil]) { - #expect(GatewayEnvironment.gatewayPort() == 19001) + #expect(GatewayEnvironment.gatewayPort() == 18789) } } diff --git a/docs/consumer/macos-consumer-app.md b/docs/consumer/macos-consumer-app.md index eaedb6c053189..baff1d95ca498 100644 --- a/docs/consumer/macos-consumer-app.md +++ b/docs/consumer/macos-consumer-app.md @@ -10,16 +10,20 @@ The consumer macOS app is the simplified local controller for the OpenClaw consu ## Isolation model -The consumer build is a separate app/runtime identity, not a separate repository. +The default consumer build is the single local OpenClaw app runtime, not a separate repository. - App identity: separate bundle identifier and app variant metadata -- State directory: `~/Library/Application Support/OpenClaw Consumer/.openclaw` +- State directory: `~/Library/Application Support/OpenClaw/.openclaw` - Legacy fallback: `~/.openclaw-consumer` is still read if it already exists from an older local test setup -- Local gateway port: `19001` -- Launch labels: `ai.openclaw.consumer.mac` and `ai.openclaw.consumer.gateway` +- Local gateway port: `18789` +- Gateway launch label: `ai.openclaw.gateway` - Logs: `/tmp/openclaw-consumer` -This keeps consumer testing from silently reusing the founder runtime. +Named consumer/tester/worktree instances still use `OPENCLAW_CONSUMER_INSTANCE_ID`, +`~/Library/Application Support/OpenClaw/instances//.openclaw`, +`ai.openclaw.consumer..gateway`, and a hashed port in the `20000..39999` +range. That keeps parallel testing isolated without making the default app look +like a second product runtime. ## Default UX diff --git a/docs/consumer/openclaw-consumer-execution-spec.md b/docs/consumer/openclaw-consumer-execution-spec.md index 953f47de55b43..0842f6bfb14d8 100644 --- a/docs/consumer/openclaw-consumer-execution-spec.md +++ b/docs/consumer/openclaw-consumer-execution-spec.md @@ -114,9 +114,8 @@ oc-consumer-task # Test consumer build without touching live bot pnpm install && pnpm build -OPENCLAW_HOME=/tmp/openclaw-consumer \ -OPENCLAW_PROFILE=consumer-test \ -pnpm openclaw gateway --port 19001 --bind loopback +OPENCLAW_CONSUMER_INSTANCE_ID=consumer-test \ +pnpm openclaw gateway --bind loopback # Upstream intake is selective, never a blind merge # See docs/agent-guides/fork-maintenance.md @@ -136,7 +135,7 @@ Your personal bot stays on `main`. `codex/consumer-openclaw-project` is the prod All three must be true: -1. ✅ Consumer branch exists and runs independently on port 19001 +1. ✅ Default app uses canonical `ai.openclaw.gateway` on port `18789`; named consumer/tester/worktree instances stay isolated on hashed ports 2. ✅ Browser spike has a clear winner with benchmark data 3. ✅ At least one killer task (flight search from Telegram) works end-to-end @@ -194,7 +193,7 @@ A markdown doc: `browser-spike-results.md` with: - [ ] Create `consumer` branch from `main` - [ ] Strip/simplify desktop app UI (if touching it this week — may defer) -- [ ] Set up isolated test profile (port 19001, separate OPENCLAW_HOME) +- [ ] Set up isolated test instance (`OPENCLAW_CONSUMER_INSTANCE_ID=`, separate app-owned state, hashed gateway port) - [ ] Verify Telegram bot works on consumer build - [ ] Verify logging works and is easily viewable (`openclaw logs --follow`) - [ ] Integrate winning browser approach from spike diff --git a/docs/consumer/openclaw-main-consumer-divergence-tracker.md b/docs/consumer/openclaw-main-consumer-divergence-tracker.md index 0a83ab79b31e5..f6b9b6b7947e1 100644 --- a/docs/consumer/openclaw-main-consumer-divergence-tracker.md +++ b/docs/consumer/openclaw-main-consumer-divergence-tracker.md @@ -13,21 +13,21 @@ Legend: PR #549 remains a draft checkpoint against `main`; these statuses are branch tracking notes until the checkpoint is split, reviewed, and merged. -| Category | Status | Current Home | Target Home | Classification | Notes | -| --------------------------------------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Runtime identity / paths | Completed | `apps/macos/Sources/OpenClaw/AppFlavor.swift`, `OpenClawPaths.swift`, `ConsumerInstance.swift`, `ConsumerRuntime.swift`, `src/consumer/runtime-identity.ts`, `scripts/consumer-runtime-identity.ts`, `scripts/lib/consumer-instance.sh` | Shared runtime contract consumed by Swift + TS + shell | shared core | Landed. Treat state root, config/workspace/log paths, defaults prefix, launch labels, runtime root, and consumer port math as shared-core behavior now. Bundle/app branding stays in overlay packaging work. | -| Gateway ownership / port isolation | Completed | `src/cli/daemon-cli/*`, `src/infra/restart*`, `src/infra/ports*`, `scripts/restart-local-gateway.sh`, `scripts/gateway-watchdog.sh` | Shared gateway ownership model in core runtime | shared core | Landed. Isolation now runs through explicit runtime identity inputs. The daemon `status` / `doctor` identity UX cleanup belongs here with the shared runtime/service slice, not as a separate branch concern. | -| Launch / service install behavior | Completed | `src/cli/daemon-cli/install.ts`, `src/commands/daemon-install-helpers.ts`, `src/infra/restart-trigger.ts`, `src/infra/supervisor-markers.ts` | Shared service install/restart flow with overlay-fed identity values | shared core | Landed. Shared-service takeover is guarded and service semantics are no longer supposed to fork by branch. | -| Telegram setup state machine | Mostly completed | `apps/macos/Sources/OpenClaw/ChannelsStore+TelegramSetup.swift`, `ChannelsStore+ConsumerTelegramState.swift`, `TelegramSetupVerifier.swift` | Shared onboarding core, then overlay copy/entrypoints | shared core | The Telegram semantic/state-machine slices landed. Core setup behavior is largely converged, but overlay presentation still needs to stay cleanly separated. | -| Telegram onboarding card / first-run guidance | Pending | `apps/macos/Sources/OpenClaw/ConsumerTelegramSetupCard.swift`, related onboarding copy in `docs/consumer/openclaw-consumer-execution-spec.md` | Consumer overlay UX | product overlay | Keep this explicitly consumer-owned. Do not claim this is done just because the shared Telegram setup semantics improved. | -| Skill catalog / status plumbing | Completed | `src/agents/skills/config.ts`, `src/agents/skills.ts`, `src/agents/skills-status.ts`, `src/gateway/server-methods/skills.ts` | Shared skill core | shared core | Landed. Shared evaluator now owns enabled/disabled, requirement satisfaction, bundled allowlist blocking, and eligibility decisions. | -| Skill defaults / visibility | Pending | `apps/macos/Sources/OpenClaw/SkillsSettings.swift` | Consumer overlay defaults | product overlay | Curated defaults still need a cleaner overlay contract instead of scattered branch behavior. | -| Single macOS app default surface | Completed | `apps/macos/Sources/OpenClaw/AppFlavor.swift`, `SettingsRootView.swift`, `GeneralSettings.swift`, `MenuContentView.swift`, `AboutSettings.swift`, `apps/macos/Sources/OpenClaw/Resources/Info.plist` | One default `OpenClaw` app; Advanced reveals operator controls | product default | Landed. The consumer-style surface is now the default `OpenClaw` app behavior. `APP_VARIANT=standard` is explicit old shared-main compatibility, not the product direction. | -| Packaging / distribution scripts | Mostly completed | `scripts/package-mac-app.sh`, `scripts/package-mac-dist.sh`, `scripts/restart-mac.sh`, `scripts/package-consumer-mac-app.sh`, `scripts/open-consumer-mac-app.sh`, `scripts/verify-consumer-mac-app.sh` | Primary `OpenClaw.app` packaging with isolated test-lane wrappers only where needed | temporary compatibility debt | Primary packaging now outputs `OpenClaw.app` in simple product mode. Shared-main restart explicitly opts into `APP_VARIANT=standard`. Consumer wrappers remain for isolated lanes and should be slimmed/renamed after runtime migration. | -| Runtime migration to app-owned root | Completed | `RuntimeIdentity.swift`, `ConsumerInstance.swift`, `OpenClawPaths.swift`, `src/consumer/runtime-identity.ts`, `scripts/lib/consumer-instance.sh`, `scripts/migrate-openclaw-runtime-to-app-support.sh`, runtime docs | Default app-owned runtime with explicit copy-first cutover | temporary compatibility debt | Default product paths now use `~/Library/Application Support/OpenClaw/.openclaw`. Copying from `~/.openclaw` is explicit, never automatic on normal app startup, and never deletes the source. Local cutover proof: `ai.openclaw.consumer.gateway` is running from the app-owned state/config on port 19001 with RPC probe OK. Non-default Telegram accounts still need credential cleanup before all channels are healthy. | -| App bundle identity / branding | Mostly completed | `apps/macos/Sources/OpenClaw/Resources/Info.plist`, package scripts | Single `OpenClaw` product identity | product default | The product name is now `OpenClaw` for the default app. Jarvis/rebrand is intentionally parked. | -| Workflow / branch model | Pending | `CONSUMER.md`, `docs/agent-guides/workflow.md`, `docs/agent-guides/fork-maintenance.md` | Canonical transition docs in root guidance + `docs/consumer/*` | temporary branch debt | Still too much transition debt. The code has moved faster than the docs. | -| Docs / source-of-truth split | Pending | `docs/consumer/openclaw-consumer-execution-spec.md`, `docs/consumer/openclaw-main-consumer-consolidation-plan.md`, `docs/consumer/openclaw-consumer-brutal-execution-board.md` | One thinner strategy set after code convergence | temporary branch debt | This tracker and the plan now reflect landed status honestly, but the overall doc set is still heavier than the end state. | +| Category | Status | Current Home | Target Home | Classification | Notes | +| --------------------------------------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Runtime identity / paths | Completed | `apps/macos/Sources/OpenClaw/AppFlavor.swift`, `OpenClawPaths.swift`, `ConsumerInstance.swift`, `ConsumerRuntime.swift`, `src/consumer/runtime-identity.ts`, `scripts/consumer-runtime-identity.ts`, `scripts/lib/consumer-instance.sh` | Shared runtime contract consumed by Swift + TS + shell | shared core | Landed. Treat state root, config/workspace/log paths, defaults prefix, launch labels, runtime root, and consumer port math as shared-core behavior now. Bundle/app branding stays in overlay packaging work. | +| Gateway ownership / port isolation | Completed | `src/cli/daemon-cli/*`, `src/infra/restart*`, `src/infra/ports*`, `scripts/restart-local-gateway.sh`, `scripts/gateway-watchdog.sh` | Shared gateway ownership model in core runtime | shared core | Landed. Isolation now runs through explicit runtime identity inputs. The daemon `status` / `doctor` identity UX cleanup belongs here with the shared runtime/service slice, not as a separate branch concern. | +| Launch / service install behavior | Completed | `src/cli/daemon-cli/install.ts`, `src/commands/daemon-install-helpers.ts`, `src/infra/restart-trigger.ts`, `src/infra/supervisor-markers.ts` | Shared service install/restart flow with overlay-fed identity values | shared core | Landed. Shared-service takeover is guarded and service semantics are no longer supposed to fork by branch. | +| Telegram setup state machine | Mostly completed | `apps/macos/Sources/OpenClaw/ChannelsStore+TelegramSetup.swift`, `ChannelsStore+ConsumerTelegramState.swift`, `TelegramSetupVerifier.swift` | Shared onboarding core, then overlay copy/entrypoints | shared core | The Telegram semantic/state-machine slices landed. Core setup behavior is largely converged, but overlay presentation still needs to stay cleanly separated. | +| Telegram onboarding card / first-run guidance | Pending | `apps/macos/Sources/OpenClaw/ConsumerTelegramSetupCard.swift`, related onboarding copy in `docs/consumer/openclaw-consumer-execution-spec.md` | Consumer overlay UX | product overlay | Keep this explicitly consumer-owned. Do not claim this is done just because the shared Telegram setup semantics improved. | +| Skill catalog / status plumbing | Completed | `src/agents/skills/config.ts`, `src/agents/skills.ts`, `src/agents/skills-status.ts`, `src/gateway/server-methods/skills.ts` | Shared skill core | shared core | Landed. Shared evaluator now owns enabled/disabled, requirement satisfaction, bundled allowlist blocking, and eligibility decisions. | +| Skill defaults / visibility | Pending | `apps/macos/Sources/OpenClaw/SkillsSettings.swift` | Consumer overlay defaults | product overlay | Curated defaults still need a cleaner overlay contract instead of scattered branch behavior. | +| Single macOS app default surface | Completed | `apps/macos/Sources/OpenClaw/AppFlavor.swift`, `SettingsRootView.swift`, `GeneralSettings.swift`, `MenuContentView.swift`, `AboutSettings.swift`, `apps/macos/Sources/OpenClaw/Resources/Info.plist` | One default `OpenClaw` app; Advanced reveals operator controls | product default | Landed. The consumer-style surface is now the default `OpenClaw` app behavior. `APP_VARIANT=standard` is explicit old shared-main compatibility, not the product direction. | +| Packaging / distribution scripts | Mostly completed | `scripts/package-mac-app.sh`, `scripts/package-mac-dist.sh`, `scripts/restart-mac.sh`, `scripts/package-consumer-mac-app.sh`, `scripts/open-consumer-mac-app.sh`, `scripts/verify-consumer-mac-app.sh` | Primary `OpenClaw.app` packaging with isolated test-lane wrappers only where needed | temporary compatibility debt | Primary packaging now outputs `OpenClaw.app` in simple product mode. Shared-main restart explicitly opts into `APP_VARIANT=standard`. Consumer wrappers remain for isolated lanes and should be slimmed/renamed after runtime migration. | +| Runtime migration to app-owned root | Completed | `RuntimeIdentity.swift`, `ConsumerInstance.swift`, `OpenClawPaths.swift`, `src/consumer/runtime-identity.ts`, `scripts/lib/consumer-instance.sh`, `scripts/migrate-openclaw-runtime-to-app-support.sh`, runtime docs | Default app-owned runtime with explicit copy-first cutover | temporary compatibility debt | Default product paths now use `~/Library/Application Support/OpenClaw/.openclaw`. Copying from `~/.openclaw` is explicit, never automatic on normal app startup, and never deletes the source. The no-instance app runtime now uses canonical gateway identity `ai.openclaw.gateway` on port `18789`; named consumer/tester/worktree instances keep `ai.openclaw.consumer..gateway` plus hashed ports. Non-default Telegram accounts still need credential cleanup before all channels are healthy. | +| App bundle identity / branding | Mostly completed | `apps/macos/Sources/OpenClaw/Resources/Info.plist`, package scripts | Single `OpenClaw` product identity | product default | The product name is now `OpenClaw` for the default app. Jarvis/rebrand is intentionally parked. | +| Workflow / branch model | Pending | `CONSUMER.md`, `docs/agent-guides/workflow.md`, `docs/agent-guides/fork-maintenance.md` | Canonical transition docs in root guidance + `docs/consumer/*` | temporary branch debt | Still too much transition debt. The code has moved faster than the docs. | +| Docs / source-of-truth split | Pending | `docs/consumer/openclaw-consumer-execution-spec.md`, `docs/consumer/openclaw-main-consumer-consolidation-plan.md`, `docs/consumer/openclaw-consumer-brutal-execution-board.md` | One thinner strategy set after code convergence | temporary branch debt | This tracker and the plan now reflect landed status honestly, but the overall doc set is still heavier than the end state. | ## Queue Now diff --git a/scripts/consumer-preflight.sh b/scripts/consumer-preflight.sh index 6b323161a7ed9..3078841873441 100755 --- a/scripts/consumer-preflight.sh +++ b/scripts/consumer-preflight.sh @@ -154,7 +154,7 @@ const path = require("node:path"); const cp = require("node:child_process"); const currentInstanceId = process.env.CURRENT_INSTANCE_ID ?? "default"; -const currentLabel = process.env.CURRENT_LABEL ?? "ai.openclaw.consumer.gateway"; +const currentLabel = process.env.CURRENT_LABEL ?? "ai.openclaw.gateway"; const uid = typeof process.getuid === "function" ? process.getuid() : null; const baseRoots = [ path.join(os.homedir(), "Library", "Application Support", "OpenClaw"), @@ -183,7 +183,7 @@ function tokenFingerprint(token) { function labelFor(instanceId) { return instanceId === "default" - ? "ai.openclaw.consumer.gateway" + ? "ai.openclaw.gateway" : `ai.openclaw.consumer.${instanceId}.gateway`; } diff --git a/scripts/lib/consumer-instance.sh b/scripts/lib/consumer-instance.sh index 15cef42716601..9f2d996826f77 100644 --- a/scripts/lib/consumer-instance.sh +++ b/scripts/lib/consumer-instance.sh @@ -145,21 +145,20 @@ consumer_instance_gateway_launchd_label() { consumer_instance_apply_runtime_env() { local normalized="${1:-}" - if [[ -z "$normalized" ]]; then - return 0 - fi local state_dir state_dir="$(consumer_instance_state_dir "$normalized")" local runtime_root runtime_root="$(consumer_instance_runtime_root "$normalized")" - # Consumer lanes must derive runtime ownership from the instance id alone. - # If a caller leaves stale OPENCLAW_* overrides in the shell, commands like - # `browser profiles` can drift onto the wrong gateway while status still - # reports the LaunchAgent for this lane. Pin every runtime selector here so - # the wrapper, service install, and status flow all share one source of truth. - export OPENCLAW_CONSUMER_INSTANCE_ID="$normalized" + # Consumer runtime ownership must come from this shared identity contract. + # The empty id is intentional: it means the single canonical local app runtime + # on ai.openclaw.gateway:18789, not a separate consumer-specific gateway. + if [[ -n "$normalized" ]]; then + export OPENCLAW_CONSUMER_INSTANCE_ID="$normalized" + else + unset OPENCLAW_CONSUMER_INSTANCE_ID + fi export OPENCLAW_PROFILE="$(consumer_instance_profile "$normalized")" export OPENCLAW_HOME="$runtime_root" export OPENCLAW_STATE_DIR="$state_dir" diff --git a/scripts/worktree-doctor.sh b/scripts/worktree-doctor.sh index 1b0e455cd4cdb..40373d9c2171c 100644 --- a/scripts/worktree-doctor.sh +++ b/scripts/worktree-doctor.sh @@ -145,7 +145,7 @@ const path = require("node:path"); const cp = require("node:child_process"); const currentInstanceId = process.env.CURRENT_INSTANCE_ID ?? "default"; -const currentLabel = process.env.CURRENT_LABEL ?? "ai.openclaw.consumer.gateway"; +const currentLabel = process.env.CURRENT_LABEL ?? "ai.openclaw.gateway"; const uid = typeof process.getuid === "function" ? process.getuid() : null; const baseRoots = [ path.join(os.homedir(), "Library", "Application Support", "OpenClaw"), @@ -174,7 +174,7 @@ function tokenFingerprint(token) { function labelFor(instanceId) { return instanceId === "default" - ? "ai.openclaw.consumer.gateway" + ? "ai.openclaw.gateway" : `ai.openclaw.consumer.${instanceId}.gateway`; } diff --git a/src/consumer/runtime-identity.test.ts b/src/consumer/runtime-identity.test.ts index f865599bf009a..4d0955627d6aa 100644 --- a/src/consumer/runtime-identity.test.ts +++ b/src/consumer/runtime-identity.test.ts @@ -1,7 +1,6 @@ -import path from "node:path"; import os from "node:os"; +import path from "node:path"; import { describe, expect, it } from "vitest"; - import { inferConsumerRuntimeIdFromCheckout, normalizeConsumerRuntimeId, @@ -42,9 +41,9 @@ describe("consumer/runtime-identity", () => { logDir: "/Users/tester/Library/Application Support/OpenClaw/.openclaw/logs", profile: "consumer", launchdLabel: "ai.openclaw.consumer", - gatewayLaunchdLabel: "ai.openclaw.consumer.gateway", + gatewayLaunchdLabel: "ai.openclaw.gateway", defaultsPrefix: "openclaw.consumer", - gatewayPort: 19001, + gatewayPort: 18789, gatewayBind: "loopback", }); }); @@ -57,8 +56,7 @@ describe("consumer/runtime-identity", () => { expect(identity).toEqual({ normalizedId: "main-durable-lane", - runtimeRoot: - "/Users/tester/Library/Application Support/OpenClaw/instances/main-durable-lane", + runtimeRoot: "/Users/tester/Library/Application Support/OpenClaw/instances/main-durable-lane", stateDir: "/Users/tester/Library/Application Support/OpenClaw/instances/main-durable-lane/.openclaw", configPath: @@ -79,14 +77,7 @@ describe("consumer/runtime-identity", () => { it("uses the current home directory by default", () => { const identity = resolveConsumerRuntimeIdentity({ instanceId: "lane" }); expect(identity.runtimeRoot).toBe( - path.join( - os.homedir(), - "Library", - "Application Support", - "OpenClaw", - "instances", - "lane", - ), + path.join(os.homedir(), "Library", "Application Support", "OpenClaw", "instances", "lane"), ); }); }); diff --git a/src/consumer/runtime-identity.ts b/src/consumer/runtime-identity.ts index 8ecb866d1662a..edb62fa6a6198 100644 --- a/src/consumer/runtime-identity.ts +++ b/src/consumer/runtime-identity.ts @@ -1,6 +1,6 @@ import { execFileSync } from "node:child_process"; -import path from "node:path"; import os from "node:os"; +import path from "node:path"; export const CONSUMER_RUNTIME_ROOT_NAME = "OpenClaw" as const; export const CONSUMER_STATE_DIR_NAME = ".openclaw" as const; @@ -9,8 +9,9 @@ export const CONSUMER_WORKSPACE_DIR_NAME = "workspace" as const; export const CONSUMER_LOG_DIR_NAME = "logs" as const; export const CONSUMER_PROFILE_PREFIX = "consumer" as const; export const CONSUMER_LAUNCHD_LABEL_PREFIX = "ai.openclaw.consumer" as const; +export const CANONICAL_GATEWAY_LAUNCHD_LABEL = "ai.openclaw.gateway" as const; export const CONSUMER_GATEWAY_BIND = "loopback" as const; -export const CONSUMER_SHARED_GATEWAY_PORT = 19001 as const; +export const CANONICAL_GATEWAY_PORT = 18789 as const; export const CONSUMER_GATEWAY_PORT_MIN = 20000 as const; export const CONSUMER_GATEWAY_PORT_SPAN = 20000 as const; @@ -53,14 +54,23 @@ export function inferConsumerRuntimeIdFromCheckout(params: { return normalizeConsumerRuntimeId(path.basename(params.rootDir)); } -export function resolveConsumerRuntimeIdentity(params: { - instanceId?: string | null; - homeDir?: string; -} = {}): ConsumerRuntimeIdentity { +export function resolveConsumerRuntimeIdentity( + params: { + instanceId?: string | null; + homeDir?: string; + } = {}, +): ConsumerRuntimeIdentity { const normalizedId = normalizeConsumerRuntimeId(params.instanceId); const homeDir = params.homeDir ?? os.homedir(); const runtimeRoot = normalizedId - ? path.join(homeDir, "Library", "Application Support", CONSUMER_RUNTIME_ROOT_NAME, "instances", normalizedId) + ? path.join( + homeDir, + "Library", + "Application Support", + CONSUMER_RUNTIME_ROOT_NAME, + "instances", + normalizedId, + ) : path.join(homeDir, "Library", "Application Support", CONSUMER_RUNTIME_ROOT_NAME); const stateDir = path.join(runtimeRoot, CONSUMER_STATE_DIR_NAME); @@ -77,11 +87,14 @@ export function resolveConsumerRuntimeIdentity(params: { : CONSUMER_LAUNCHD_LABEL_PREFIX, gatewayLaunchdLabel: normalizedId ? `${CONSUMER_LAUNCHD_LABEL_PREFIX}.${normalizedId}.gateway` - : `${CONSUMER_LAUNCHD_LABEL_PREFIX}.gateway`, + : CANONICAL_GATEWAY_LAUNCHD_LABEL, defaultsPrefix: normalizedId ? `openclaw.consumer.instances.${normalizedId}` : "openclaw.consumer", - gatewayPort: normalizedId ? hashConsumerGatewayPort(normalizedId) : CONSUMER_SHARED_GATEWAY_PORT, + // No instance id means "the one real local OpenClaw app", so it must use + // the canonical gateway identity that the rest of the product already + // expects. Named consumer/tester/worktree lanes remain isolated below. + gatewayPort: normalizedId ? hashConsumerGatewayPort(normalizedId) : CANONICAL_GATEWAY_PORT, gatewayBind: CONSUMER_GATEWAY_BIND, }; } diff --git a/src/daemon/service-env.test.ts b/src/daemon/service-env.test.ts index ff25cef6299c4..7d8d7de916ff6 100644 --- a/src/daemon/service-env.test.ts +++ b/src/daemon/service-env.test.ts @@ -327,6 +327,30 @@ describe("buildServiceEnvironment", () => { } }); + it("canonicalizes the default consumer app to the shared gateway identity", () => { + const identity = resolveConsumerRuntimeIdentity({ + homeDir: "/Users/test", + }); + const env = buildServiceEnvironment({ + env: { + HOME: "/Users/test", + OPENCLAW_PROFILE: "consumer", + }, + port: identity.gatewayPort, + platform: "darwin", + }); + + expect(env.OPENCLAW_CONSUMER_INSTANCE_ID).toBeUndefined(); + expect(env.OPENCLAW_PROFILE).toBe("consumer"); + expect(env.OPENCLAW_HOME).toBe(identity.runtimeRoot); + expect(env.OPENCLAW_STATE_DIR).toBe(identity.stateDir); + expect(env.OPENCLAW_CONFIG_PATH).toBe(identity.configPath); + expect(env.OPENCLAW_GATEWAY_PORT).toBe("18789"); + expect(env.OPENCLAW_GATEWAY_BIND).toBe("loopback"); + expect(env.OPENCLAW_LOG_DIR).toBe(identity.logDir); + expect(env.OPENCLAW_LAUNCHD_LABEL).toBe("ai.openclaw.gateway"); + }); + it("canonicalizes consumer lane identity for service installs", () => { const identity = resolveConsumerRuntimeIdentity({ homeDir: "/Users/test", From 34824576331d62927097fc88553939eb00658cba Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 27 Apr 2026 19:45:44 +0800 Subject: [PATCH 6/9] fix(models): enable codex gpt-5.5 fallback - Add OpenAI Codex GPT-5.5 forward-compat resolution from GPT-5.4/5.3 templates. - Keep configured GPT-5.5 rows available instead of marking them missing and falling back to GPT-5.4. - Risk: uses the Codex OAuth transport assumption already used by GPT-5.4 until the remote catalog exposes GPT-5.5 directly. --- extensions/openai/openai-codex-provider.ts | 32 ++++++++++++- .../list.list-command.forward-compat.test.ts | 46 ++++++++++++++++++- 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/extensions/openai/openai-codex-provider.ts b/extensions/openai/openai-codex-provider.ts index c0ae2c1221072..c9a2fc07d9fb6 100644 --- a/extensions/openai/openai-codex-provider.ts +++ b/extensions/openai/openai-codex-provider.ts @@ -25,6 +25,14 @@ import { const PROVIDER_ID = "openai-codex"; const OPENAI_CODEX_BASE_URL = "https://chatgpt.com/backend-api"; +const OPENAI_CODEX_GPT_55_MODEL_ID = "gpt-5.5"; +const OPENAI_CODEX_GPT_55_CONTEXT_TOKENS = 1_050_000; +const OPENAI_CODEX_GPT_55_MAX_TOKENS = 128_000; +const OPENAI_CODEX_GPT_55_TEMPLATE_MODEL_IDS = [ + "gpt-5.4", + "gpt-5.3-codex", + "gpt-5.2-codex", +] as const; const OPENAI_CODEX_GPT_54_MODEL_ID = "gpt-5.4"; const OPENAI_CODEX_GPT_54_CONTEXT_TOKENS = 1_050_000; const OPENAI_CODEX_GPT_54_MAX_TOKENS = 128_000; @@ -36,6 +44,7 @@ const OPENAI_CODEX_GPT_53_SPARK_MAX_TOKENS = 128_000; const OPENAI_CODEX_TEMPLATE_MODEL_IDS = ["gpt-5.2-codex"] as const; const OPENAI_CODEX_DEFAULT_MODEL = `${PROVIDER_ID}/${OPENAI_CODEX_GPT_54_MODEL_ID}`; const OPENAI_CODEX_XHIGH_MODEL_IDS = [ + OPENAI_CODEX_GPT_55_MODEL_ID, OPENAI_CODEX_GPT_54_MODEL_ID, OPENAI_CODEX_GPT_53_MODEL_ID, OPENAI_CODEX_GPT_53_SPARK_MODEL_ID, @@ -43,6 +52,7 @@ const OPENAI_CODEX_XHIGH_MODEL_IDS = [ "gpt-5.1-codex", ] as const; const OPENAI_CODEX_MODERN_MODEL_IDS = [ + OPENAI_CODEX_GPT_55_MODEL_ID, OPENAI_CODEX_GPT_54_MODEL_ID, "gpt-5.2", "gpt-5.2-codex", @@ -88,7 +98,13 @@ function resolveCodexForwardCompatModel( let templateIds: readonly string[]; let patch: Partial | undefined; - if (lower === OPENAI_CODEX_GPT_54_MODEL_ID) { + if (lower === OPENAI_CODEX_GPT_55_MODEL_ID) { + templateIds = OPENAI_CODEX_GPT_55_TEMPLATE_MODEL_IDS; + patch = { + contextWindow: OPENAI_CODEX_GPT_55_CONTEXT_TOKENS, + maxTokens: OPENAI_CODEX_GPT_55_MAX_TOKENS, + }; + } else if (lower === OPENAI_CODEX_GPT_54_MODEL_ID) { templateIds = OPENAI_CODEX_GPT_54_TEMPLATE_MODEL_IDS; patch = { contextWindow: OPENAI_CODEX_GPT_54_CONTEXT_TOKENS, @@ -255,6 +271,11 @@ export function buildOpenAICodexProviderPlugin(): ProviderPlugin { await fetchCodexUsage(ctx.token, ctx.accountId, ctx.timeoutMs, ctx.fetchFn), refreshOAuth: async (cred) => await refreshOpenAICodexOAuthCredential(cred), augmentModelCatalog: (ctx) => { + const gpt55Template = findCatalogTemplate({ + entries: ctx.entries, + providerId: PROVIDER_ID, + templateIds: OPENAI_CODEX_GPT_55_TEMPLATE_MODEL_IDS, + }); const gpt54Template = findCatalogTemplate({ entries: ctx.entries, providerId: PROVIDER_ID, @@ -266,6 +287,15 @@ export function buildOpenAICodexProviderPlugin(): ProviderPlugin { templateIds: [OPENAI_CODEX_GPT_53_MODEL_ID, ...OPENAI_CODEX_TEMPLATE_MODEL_IDS], }); return [ + gpt55Template + ? { + ...gpt55Template, + id: OPENAI_CODEX_GPT_55_MODEL_ID, + name: OPENAI_CODEX_GPT_55_MODEL_ID, + contextWindow: OPENAI_CODEX_GPT_55_CONTEXT_TOKENS, + maxTokens: OPENAI_CODEX_GPT_55_MAX_TOKENS, + } + : undefined, gpt54Template ? { ...gpt54Template, diff --git a/src/commands/models/list.list-command.forward-compat.test.ts b/src/commands/models/list.list-command.forward-compat.test.ts index f0cc594ab35be..ad4c44673edc3 100644 --- a/src/commands/models/list.list-command.forward-compat.test.ts +++ b/src/commands/models/list.list-command.forward-compat.test.ts @@ -12,6 +12,12 @@ const OPENAI_CODEX_MODEL = { cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, }; +const OPENAI_CODEX_55_MODEL = { + ...OPENAI_CODEX_MODEL, + id: "gpt-5.5", + name: "GPT-5.5", +}; + const OPENAI_CODEX_53_MODEL = { ...OPENAI_CODEX_MODEL, id: "gpt-5.3-codex", @@ -187,6 +193,37 @@ describe("modelsListCommand forward-compat", () => { expect(codex?.tags).not.toContain("missing"); }); + it("does not mark configured codex gpt-5.5 as missing when forward-compat can build a fallback", async () => { + mocks.loadConfig.mockReturnValueOnce({ + agents: { defaults: { model: { primary: "openai-codex/gpt-5.5" } } }, + models: { providers: {} }, + }); + mocks.resolveConfiguredEntries.mockReturnValueOnce({ + entries: [ + { + key: "openai-codex/gpt-5.5", + ref: { provider: "openai-codex", model: "gpt-5.5" }, + tags: new Set(["configured"]), + aliases: [], + }, + ], + }); + mocks.resolveModelWithRegistry.mockReturnValueOnce({ ...OPENAI_CODEX_55_MODEL }); + const runtime = createRuntime(); + + await modelsListCommand({ json: true }, runtime as never); + + expect(mocks.printModelTable).toHaveBeenCalled(); + const codex = lastPrintedRows<{ + key: string; + missing: boolean; + tags: string[]; + }>().find((row) => row.key === "openai-codex/gpt-5.5"); + expect(codex).toBeTruthy(); + expect(codex?.missing).toBe(false); + expect(codex?.tags).not.toContain("missing"); + }); + it("passes source config to model registry loading for persistence safety", async () => { const runtime = createRuntime(); @@ -277,7 +314,7 @@ describe("modelsListCommand forward-compat", () => { }); describe("--all catalog supplementation", () => { - it("includes synthetic codex gpt-5.4 in --all output when catalog supports it", async () => { + it("includes synthetic codex gpt-5.5 and gpt-5.4 in --all output", async () => { mockDiscoveredCodex53Registry(); mocks.loadModelCatalog.mockResolvedValueOnce([ { @@ -308,6 +345,9 @@ describe("modelsListCommand forward-compat", () => { if (modelId === "gpt-5.3-codex") { return { ...OPENAI_CODEX_53_MODEL }; } + if (modelId === "gpt-5.5") { + return { ...OPENAI_CODEX_55_MODEL }; + } if (modelId === "gpt-5.4") { return { ...OPENAI_CODEX_MODEL }; } @@ -319,6 +359,10 @@ describe("modelsListCommand forward-compat", () => { expect.objectContaining({ key: "openai-codex/gpt-5.3-codex", }), + expect.objectContaining({ + key: "openai-codex/gpt-5.5", + available: true, + }), expect.objectContaining({ key: "openai-codex/gpt-5.4", available: true, From 118a63fda187fdad0a712fab78171a754e47ad3a Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 28 Apr 2026 18:53:17 +0800 Subject: [PATCH 7/9] fix(skills): migrate personal skills to shared root - add a reusable migration helper for copying personal skills into ~/.agents/skills - document ~/.agents/skills as the shared user-owned skills root and clarify precedence - avoid relying on workspace symlinks that are intentionally blocked by containment checks --- docs/tools/skills-config.md | 5 + docs/tools/skills.md | 26 ++-- .../migrate-personal-skills-to-agents-root.sh | 145 ++++++++++++++++++ 3 files changed, 166 insertions(+), 10 deletions(-) create mode 100755 scripts/migrate-personal-skills-to-agents-root.sh diff --git a/docs/tools/skills-config.md b/docs/tools/skills-config.md index 589d464bb13ca..e9bdbdbbced19 100644 --- a/docs/tools/skills-config.md +++ b/docs/tools/skills-config.md @@ -43,6 +43,8 @@ All skills-related configuration lives under `skills` in `~/.openclaw/openclaw.j - `allowBundled`: optional allowlist for **bundled** skills only. When set, only bundled skills in the list are eligible (managed/workspace skills unaffected). - `load.extraDirs`: additional skill directories to scan (lowest precedence). + Use `~/.agents/skills` for personal skills shared across agents; OpenClaw + loads that root automatically, so it does not need to be listed here. - `load.watch`: watch skill folders and refresh the skills snapshot (default: true). - `load.watchDebounceMs`: debounce for skill watcher events in milliseconds (default: 250). - `install.preferBrew`: prefer brew installers when available (default: true). @@ -63,6 +65,9 @@ Per-skill fields: - Keys under `entries` map to the skill name by default. If a skill defines `metadata.openclaw.skillKey`, use that key instead. - Changes to skills are picked up on the next agent turn when the watcher is enabled. +- Workspace skill symlinks that resolve outside the workspace root are blocked + for safety. If you want OpenClaw, Codex, and Claude Code to share skills, keep + the real folders under `~/.agents/skills` and link other tools to that root. ### Sandboxed skills + env vars diff --git a/docs/tools/skills.md b/docs/tools/skills.md index 05369677b896b..c41c468302912 100644 --- a/docs/tools/skills.md +++ b/docs/tools/skills.md @@ -8,32 +8,38 @@ title: "Skills" # Skills (OpenClaw) -OpenClaw uses **[AgentSkills](https://agentskills.io)-compatible** skill folders to teach the agent how to use tools. Each skill is a directory containing a `SKILL.md` with YAML frontmatter and instructions. OpenClaw loads **bundled skills** plus optional local overrides, and filters them at load time based on environment, config, and binary presence. +OpenClaw uses **[AgentSkills](https://agentskills.io)-compatible** skill folders to teach the agent how to use tools. Each skill is a directory containing a `SKILL.md` with YAML frontmatter and instructions. OpenClaw loads **bundled skills** plus optional local and shared-user skill roots, and filters them at load time based on environment, config, and binary presence. ## Locations and precedence -Skills are loaded from **three** places: +Skills are loaded from these places: 1. **Bundled skills**: shipped with the install (npm package or OpenClaw.app) -2. **Managed/local skills**: `~/.openclaw/skills` -3. **Workspace skills**: `/skills` +2. **Extra skill roots**: directories from `skills.load.extraDirs` +3. **Managed/local skills**: `~/.openclaw/skills` +4. **Shared user skills**: `~/.agents/skills` +5. **Project agent skills**: `/.agents/skills` +6. **Workspace skills**: `/skills` If a skill name conflicts, precedence is: -`/skills` (highest) → `~/.openclaw/skills` → bundled skills (lowest) +`/skills` (highest) → `/.agents/skills` → `~/.agents/skills` → `~/.openclaw/skills` → bundled skills → `skills.load.extraDirs` (lowest) -Additionally, you can configure extra skill folders (lowest precedence) via -`skills.load.extraDirs` in `~/.openclaw/openclaw.json`. +`~/.agents/skills` is the preferred home for personal skills shared between +OpenClaw, Codex, Claude Code, and other agents. Keep real skill folders there +instead of relying on symlinks inside `/skills`; workspace symlinks +that resolve outside the workspace root are blocked by design. ## Per-agent vs shared skills In **multi-agent** setups, each agent has its own workspace. That means: - **Per-agent skills** live in `/skills` for that agent only. -- **Shared skills** live in `~/.openclaw/skills` (managed/local) and are visible - to **all agents** on the same machine. +- **Shared personal skills** live in `~/.agents/skills` and are visible to + **all agents** on the same machine. +- **Managed/local overrides** can live in `~/.openclaw/skills`. - **Shared folders** can also be added via `skills.load.extraDirs` (lowest - precedence) if you want a common skills pack used by multiple agents. + precedence) if you want a common third-party skills pack used by multiple agents. If the same skill name exists in more than one place, the usual precedence applies: workspace wins, then managed/local, then bundled. diff --git a/scripts/migrate-personal-skills-to-agents-root.sh b/scripts/migrate-personal-skills-to-agents-root.sh new file mode 100755 index 0000000000000..1871f84e306ee --- /dev/null +++ b/scripts/migrate-personal-skills-to-agents-root.sh @@ -0,0 +1,145 @@ +#!/usr/bin/env bash +set -euo pipefail + +TARGET="${HOME}/.agents/skills" +FORCE=0 +DRY_RUN=0 +SOURCES=() + +usage() { + cat <<'EOF' +Usage: scripts/migrate-personal-skills-to-agents-root.sh [--dry-run] [--force] [--target ] [--source ...] + +Copy personal AgentSkills into a user-owned shared skills root. + +Defaults: + target: ~/.agents/skills + sources: ~/.claude/skills and ~/.openclaw/workspace/skills + +This script copies real skill folders. If a source skill is a symlink, the +symlink target is copied into the target root. Existing target skills are left +untouched unless --force is supplied. +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --dry-run) + DRY_RUN=1 + shift + ;; + --force) + FORCE=1 + shift + ;; + --target) + if [[ $# -lt 2 ]]; then + echo "ERROR: --target requires a path" >&2 + exit 1 + fi + TARGET="$2" + shift 2 + ;; + --source) + if [[ $# -lt 2 ]]; then + echo "ERROR: --source requires a path" >&2 + exit 1 + fi + SOURCES+=("$2") + shift 2 + ;; + --help|-h) + usage + exit 0 + ;; + *) + echo "ERROR: unknown argument: $1" >&2 + usage >&2 + exit 1 + ;; + esac +done + +if [[ ${#SOURCES[@]} -eq 0 ]]; then + SOURCES=("${HOME}/.claude/skills" "${HOME}/.openclaw/workspace/skills") +fi + +expand_path() { + local raw="$1" + case "$raw" in + "~") printf '%s\n' "$HOME" ;; + "~/"*) printf '%s/%s\n' "$HOME" "${raw#"~/"}" ;; + *) printf '%s\n' "$raw" ;; + esac +} + +is_skippable_name() { + local name="$1" + case "$name" in + .*|slash-skills|slash-commands) + return 0 + ;; + *) + return 1 + ;; + esac +} + +copy_skill() { + local source_dir="$1" + local skill_name="$2" + local target_dir="$3" + local source_real + source_real="$(cd "$source_dir" && pwd -P)" + + if [[ ! -f "${source_real}/SKILL.md" ]]; then + return 0 + fi + + local dest="${target_dir}/${skill_name}" + if [[ -e "$dest" && "$FORCE" != "1" ]]; then + echo "skip existing: ${dest/#$HOME/~}" + return 0 + fi + + if [[ "$DRY_RUN" == "1" ]]; then + echo "copy: ${source_real/#$HOME/~} -> ${dest/#$HOME/~}" + return 0 + fi + + mkdir -p "$target_dir" + if [[ "$FORCE" == "1" ]]; then + rm -rf "$dest" + fi + + # Use tar instead of cp -R so symlinked source directories are materialized as + # normal directories while preserving nested files, modes, and relative links. + mkdir -p "$dest" + tar -C "$source_real" -cf - . | tar -C "$dest" -xf - + echo "copied: ${source_real/#$HOME/~} -> ${dest/#$HOME/~}" +} + +TARGET="$(expand_path "$TARGET")" +if [[ "$DRY_RUN" != "1" ]]; then + mkdir -p "$TARGET" +fi + +declare -A SEEN_SKILLS=() +for raw_source in "${SOURCES[@]}"; do + source_root="$(expand_path "$raw_source")" + if [[ ! -d "$source_root" ]]; then + echo "skip missing source: ${source_root/#$HOME/~}" + continue + fi + while IFS= read -r -d '' entry; do + name="$(basename "$entry")" + if is_skippable_name "$name"; then + continue + fi + if [[ -n "${SEEN_SKILLS[$name]:-}" ]]; then + continue + fi + SEEN_SKILLS[$name]=1 + copy_skill "$entry" "$name" "$TARGET" + done < <(find "$source_root" -mindepth 1 -maxdepth 1 \( -type d -o -type l \) -print0) +done From 573d0cb119006f99d5e832d24b78a272b53d0bd7 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 28 Apr 2026 20:32:32 +0800 Subject: [PATCH 8/9] fix(skills): use single shared skills root - remove the implicit OpenClaw-only ~/.agents/skills loader path - document ~/.agents/skills as the canonical directory symlinked into tool roots - keep project/workspace precedence unchanged while making managed skills the OpenClaw link point Risk: existing installs need ~/.openclaw/skills linked to ~/.agents/skills for shared personal skills --- docs/tools/skills-config.md | 7 ++++--- docs/tools/skills.md | 25 +++++++++++++------------ src/agents/skills/workspace.ts | 13 ++++--------- 3 files changed, 21 insertions(+), 24 deletions(-) diff --git a/docs/tools/skills-config.md b/docs/tools/skills-config.md index e9bdbdbbced19..cceaa1ce266de 100644 --- a/docs/tools/skills-config.md +++ b/docs/tools/skills-config.md @@ -43,8 +43,8 @@ All skills-related configuration lives under `skills` in `~/.openclaw/openclaw.j - `allowBundled`: optional allowlist for **bundled** skills only. When set, only bundled skills in the list are eligible (managed/workspace skills unaffected). - `load.extraDirs`: additional skill directories to scan (lowest precedence). - Use `~/.agents/skills` for personal skills shared across agents; OpenClaw - loads that root automatically, so it does not need to be listed here. + For personal skills shared across agents, keep the real folders in + `~/.agents/skills` and symlink `~/.openclaw/skills` to that directory. - `load.watch`: watch skill folders and refresh the skills snapshot (default: true). - `load.watchDebounceMs`: debounce for skill watcher events in milliseconds (default: 250). - `install.preferBrew`: prefer brew installers when available (default: true). @@ -67,7 +67,8 @@ Per-skill fields: - Changes to skills are picked up on the next agent turn when the watcher is enabled. - Workspace skill symlinks that resolve outside the workspace root are blocked for safety. If you want OpenClaw, Codex, and Claude Code to share skills, keep - the real folders under `~/.agents/skills` and link other tools to that root. + the real folders under `~/.agents/skills` and symlink each tool's skills root + to that directory. ### Sandboxed skills + env vars diff --git a/docs/tools/skills.md b/docs/tools/skills.md index c41c468302912..7bf4fd3b6a921 100644 --- a/docs/tools/skills.md +++ b/docs/tools/skills.md @@ -8,7 +8,7 @@ title: "Skills" # Skills (OpenClaw) -OpenClaw uses **[AgentSkills](https://agentskills.io)-compatible** skill folders to teach the agent how to use tools. Each skill is a directory containing a `SKILL.md` with YAML frontmatter and instructions. OpenClaw loads **bundled skills** plus optional local and shared-user skill roots, and filters them at load time based on environment, config, and binary presence. +OpenClaw uses **[AgentSkills](https://agentskills.io)-compatible** skill folders to teach the agent how to use tools. Each skill is a directory containing a `SKILL.md` with YAML frontmatter and instructions. OpenClaw loads **bundled skills** plus optional local skill roots, and filters them at load time based on environment, config, and binary presence. ## Locations and precedence @@ -17,27 +17,28 @@ Skills are loaded from these places: 1. **Bundled skills**: shipped with the install (npm package or OpenClaw.app) 2. **Extra skill roots**: directories from `skills.load.extraDirs` 3. **Managed/local skills**: `~/.openclaw/skills` -4. **Shared user skills**: `~/.agents/skills` -5. **Project agent skills**: `/.agents/skills` -6. **Workspace skills**: `/skills` +4. **Project agent skills**: `/.agents/skills` +5. **Workspace skills**: `/skills` If a skill name conflicts, precedence is: -`/skills` (highest) → `/.agents/skills` → `~/.agents/skills` → `~/.openclaw/skills` → bundled skills → `skills.load.extraDirs` (lowest) +`/skills` (highest) → `/.agents/skills` → `~/.openclaw/skills` → bundled skills → `skills.load.extraDirs` (lowest) -`~/.agents/skills` is the preferred home for personal skills shared between -OpenClaw, Codex, Claude Code, and other agents. Keep real skill folders there -instead of relying on symlinks inside `/skills`; workspace symlinks -that resolve outside the workspace root are blocked by design. +For personal cross-agent skills, use `~/.agents/skills` as the canonical +filesystem root and symlink `~/.openclaw/skills` to it. That keeps OpenClaw, +Codex, Claude Code, and other agents on the same skill directory without adding +a second OpenClaw-only discovery path. Workspace symlinks that resolve outside +the workspace root are blocked by design. ## Per-agent vs shared skills In **multi-agent** setups, each agent has its own workspace. That means: - **Per-agent skills** live in `/skills` for that agent only. -- **Shared personal skills** live in `~/.agents/skills` and are visible to - **all agents** on the same machine. -- **Managed/local overrides** can live in `~/.openclaw/skills`. +- **Shared personal skills** live in `~/.agents/skills`; symlink + `~/.openclaw/skills` to that directory to expose them to OpenClaw. +- **Managed/local overrides** can live in `~/.openclaw/skills` if you are not + using the shared symlink. - **Shared folders** can also be added via `skills.load.extraDirs` (lowest precedence) if you want a common third-party skills pack used by multiple agents. diff --git a/src/agents/skills/workspace.ts b/src/agents/skills/workspace.ts index 067d234977685..225647a9f57a4 100644 --- a/src/agents/skills/workspace.ts +++ b/src/agents/skills/workspace.ts @@ -480,11 +480,6 @@ function loadSkillEntries( dir: managedSkillsDir, source: "openclaw-managed", }); - const personalAgentsSkillsDir = path.resolve(os.homedir(), ".agents", "skills"); - const personalAgentsSkills = loadSkills({ - dir: personalAgentsSkillsDir, - source: "agents-skills-personal", - }); const projectAgentsSkillsDir = path.resolve(workspaceDir, ".agents", "skills"); const projectAgentsSkills = loadSkills({ dir: projectAgentsSkillsDir, @@ -496,7 +491,10 @@ function loadSkillEntries( }); const merged = new Map(); - // Precedence: extra < bundled < managed < agents-skills-personal < agents-skills-project < workspace + // Precedence: extra < bundled < managed < agents-skills-project < workspace. + // Personal cross-agent skills should be exposed through the managed skills + // root, usually by symlinking ~/.openclaw/skills to ~/.agents/skills. Keeping + // one canonical filesystem root avoids hidden second-path behavior. for (const skill of extraSkills) { merged.set(skill.name, skill); } @@ -506,9 +504,6 @@ function loadSkillEntries( for (const skill of managedSkills) { merged.set(skill.name, skill); } - for (const skill of personalAgentsSkills) { - merged.set(skill.name, skill); - } for (const skill of projectAgentsSkills) { merged.set(skill.name, skill); } From c1a1d05a9dc51c67424fe018b4c39abaed54ebbb Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 28 Apr 2026 21:22:49 +0800 Subject: [PATCH 9/9] fix(telegram): guard runtime token and model drift - preserve non-secret metadata when tester baselines strip named Telegram credentials - report named-account credential stripping during live preflight and worktree creation - detect gateway runtime identity drift in service audit/status - keep Codex 5.5 in the lean Telegram model allowlist - risk: existing stale BotFather tokens still need rotation outside code --- docs/agent-guides/telegram-live.md | 6 ++ scripts/bootstrap-worktree-tester-baseline.sh | 11 ++- scripts/lib/worktree-tester-baseline.mjs | 53 +++++++++++- scripts/new-worktree.sh | 6 ++ .../apply-lean-model-allowlist.sh | 1 + .../telegram-e2e/lean-model-allowlist.jsonc | 1 + scripts/telegram-live-preflight.sh | 80 +++++++++++++++++++ src/cli/daemon-cli/status.gather.test.ts | 33 ++++++++ src/cli/daemon-cli/status.gather.ts | 52 +++++++++++- src/daemon/service-audit.test.ts | 35 ++++++++ src/daemon/service-audit.ts | 47 +++++++++++ test/new-worktree-tester-baseline.test.ts | 26 +++++- 12 files changed, 341 insertions(+), 10 deletions(-) diff --git a/docs/agent-guides/telegram-live.md b/docs/agent-guides/telegram-live.md index 3a63fa2078116..79884bc8718b6 100644 --- a/docs/agent-guides/telegram-live.md +++ b/docs/agent-guides/telegram-live.md @@ -35,6 +35,12 @@ and more reliable default. - Copy `.env.bots` from the main checkout if needed - Run `bash scripts/assign-bot.sh` - Each worktree gets its own test bot. Do not reuse production tokens. +- Worktree tester baselines strip inherited Telegram secrets on purpose. If the + source config had named Telegram accounts, the bootstrap writes non-secret + strip metadata to the baseline `auth-sync.json`, and + `bash scripts/telegram-live-preflight.sh` prints the affected account ids. + Refresh those named accounts with their own `tokenFile`/`botToken`, or disable + them, before testing named-account bots. ## Common tools diff --git a/scripts/bootstrap-worktree-tester-baseline.sh b/scripts/bootstrap-worktree-tester-baseline.sh index 44feb59fe5632..a1b59f481710c 100644 --- a/scripts/bootstrap-worktree-tester-baseline.sh +++ b/scripts/bootstrap-worktree-tester-baseline.sh @@ -80,7 +80,7 @@ if (!helperPath || !targetRoot) { const { deriveWorktreeTesterBaseline, resolveTesterBaselineAgentIds, - sanitizeInheritedTesterConfig, + sanitizeInheritedTesterConfigWithMetadata, sha256File, } = await import(pathToFileURL(helperPath).href); @@ -103,7 +103,8 @@ if (sourceConfigPath && fs.existsSync(sourceConfigPath)) { } } -const sanitizedConfig = sanitizeInheritedTesterConfig(sourceConfig); +const { config: sanitizedConfig, metadata: sanitizationMetadata } = + sanitizeInheritedTesterConfigWithMetadata(sourceConfig); fs.writeFileSync(baseline.configPath, `${JSON.stringify(sanitizedConfig, null, 2)}\n`, "utf8"); fs.chmodSync(baseline.configPath, 0o600); @@ -145,6 +146,7 @@ const meta = { configHash: sha256File(baseline.configPath), syncedAt: new Date().toISOString(), syncedAgents, + sanitization: sanitizationMetadata, }; fs.writeFileSync(baseline.metaPath, `${JSON.stringify(meta, null, 2)}\n`, "utf8"); fs.chmodSync(baseline.metaPath, 0o600); @@ -155,6 +157,11 @@ console.log(`baseline_config_path=${baseline.configPath}`); console.log(`baseline_source_config=${sourceConfigPresent ? sourceConfigPath : "missing"}`); console.log(`baseline_synced_agents=${syncedAgents.map((entry) => entry.agentId).join(",") || "none"}`); console.log(`baseline_meta_path=${baseline.metaPath}`); +console.log( + `baseline_stripped_named_telegram_accounts=${ + sanitizationMetadata.strippedNamedTelegramAccounts.join(",") || "none" + }`, +); NODE )" diff --git a/scripts/lib/worktree-tester-baseline.mjs b/scripts/lib/worktree-tester-baseline.mjs index 48920d5fae0b5..ba19c34a54543 100644 --- a/scripts/lib/worktree-tester-baseline.mjs +++ b/scripts/lib/worktree-tester-baseline.mjs @@ -101,15 +101,35 @@ export function deriveWorktreeTesterBaseline(params) { } export function sanitizeInheritedTesterConfig(baseConfig) { + return sanitizeInheritedTesterConfigWithMetadata(baseConfig).config; +} + +export function sanitizeInheritedTesterConfigWithMetadata(baseConfig) { const config = baseConfig && typeof baseConfig === "object" ? structuredClone(baseConfig) : {}; const channels = config.channels && typeof config.channels === "object" ? config.channels : {}; const telegram = channels.telegram && typeof channels.telegram === "object" ? { ...channels.telegram } : null; + const strippedTelegramCredentials = []; if (telegram) { // Tester lanes inherit provider/model config, not ownership of the shared // Telegram bot credentials from the sacred runtime. - delete telegram.botToken; + if (typeof telegram.botToken === "string" && telegram.botToken.trim()) { + strippedTelegramCredentials.push({ + accountId: "default", + accountKind: "default", + sourceKind: "botToken", + }); + delete telegram.botToken; + } + if (typeof telegram.tokenFile === "string" && telegram.tokenFile.trim()) { + strippedTelegramCredentials.push({ + accountId: "default", + accountKind: "default", + sourceKind: "tokenFile", + }); + delete telegram.tokenFile; + } const accounts = telegram.accounts && typeof telegram.accounts === "object" ? { ...telegram.accounts } : null; if (accounts) { @@ -118,7 +138,22 @@ export function sanitizeInheritedTesterConfig(baseConfig) { continue; } const next = { ...entry }; - delete next.botToken; + if (typeof next.botToken === "string" && next.botToken.trim()) { + strippedTelegramCredentials.push({ + accountId, + accountKind: accountId === "default" ? "default" : "named", + sourceKind: "botToken", + }); + delete next.botToken; + } + if (typeof next.tokenFile === "string" && next.tokenFile.trim()) { + strippedTelegramCredentials.push({ + accountId, + accountKind: accountId === "default" ? "default" : "named", + sourceKind: "tokenFile", + }); + delete next.tokenFile; + } accounts[accountId] = next; } telegram.accounts = accounts; @@ -130,7 +165,19 @@ export function sanitizeInheritedTesterConfig(baseConfig) { } scrubInheritedOpenAiSecrets(config); - return config; + return { + config, + metadata: { + strippedTelegramCredentials, + strippedNamedTelegramAccounts: [ + ...new Set( + strippedTelegramCredentials + .filter((entry) => entry.accountKind === "named") + .map((entry) => entry.accountId), + ), + ], + }, + }; } export function resolveTesterBaselineAgentIds(baseConfig) { diff --git a/scripts/new-worktree.sh b/scripts/new-worktree.sh index 88440be1d84c9..3a27e63145c65 100755 --- a/scripts/new-worktree.sh +++ b/scripts/new-worktree.sh @@ -429,6 +429,8 @@ NODE BASELINE_STATE_DIR="" BASELINE_CONFIG_PATH="" +BASELINE_META_PATH="" +BASELINE_STRIPPED_NAMED_TELEGRAM_ACCOUNTS="none" BASELINE_BOOTSTRAP_STATUS="disabled" if [[ -f "$BASELINE_BOOTSTRAP_SCRIPT" ]]; then if ! BASELINE_BOOTSTRAP_OUTPUT="$(bash "$BASELINE_BOOTSTRAP_SCRIPT" --root "$WORKTREE_PATH")"; then @@ -438,6 +440,8 @@ if [[ -f "$BASELINE_BOOTSTRAP_SCRIPT" ]]; then BASELINE_BOOTSTRAP_STATUS="ok" BASELINE_STATE_DIR="$(printf '%s\n' "$BASELINE_BOOTSTRAP_OUTPUT" | sed -n 's/^baseline_state_dir=//p' | tail -n 1)" BASELINE_CONFIG_PATH="$(printf '%s\n' "$BASELINE_BOOTSTRAP_OUTPUT" | sed -n 's/^baseline_config_path=//p' | tail -n 1)" + BASELINE_META_PATH="$(printf '%s\n' "$BASELINE_BOOTSTRAP_OUTPUT" | sed -n 's/^baseline_meta_path=//p' | tail -n 1)" + BASELINE_STRIPPED_NAMED_TELEGRAM_ACCOUNTS="$(printf '%s\n' "$BASELINE_BOOTSTRAP_OUTPUT" | sed -n 's/^baseline_stripped_named_telegram_accounts=//p' | tail -n 1)" else echo "warning: tester baseline bootstrap helper missing; falling back to legacy lane state path" >&2 fi @@ -540,6 +544,8 @@ echo "dev_port=${DEV_PORT}" echo "baseline_bootstrap=${BASELINE_BOOTSTRAP_STATUS}" echo "baseline_state_dir=${BASELINE_STATE_DIR}" echo "baseline_config_path=${BASELINE_CONFIG_PATH}" +echo "baseline_meta_path=${BASELINE_META_PATH}" +echo "baseline_stripped_named_telegram_accounts=${BASELINE_STRIPPED_NAMED_TELEGRAM_ACCOUNTS}" echo "telegram_bootstrap=${TELEGRAM_BOOTSTRAP_STATUS}" echo "bootstrap_runtime=${BOOTSTRAP_RUNTIME_STATUS}" if [[ "$NO_BOOTSTRAP" != "1" ]]; then diff --git a/scripts/telegram-e2e/apply-lean-model-allowlist.sh b/scripts/telegram-e2e/apply-lean-model-allowlist.sh index e9693ffe7fac4..1b2f236d2700a 100755 --- a/scripts/telegram-e2e/apply-lean-model-allowlist.sh +++ b/scripts/telegram-e2e/apply-lean-model-allowlist.sh @@ -15,6 +15,7 @@ read -r -d '' MODELS_JSON <<'JSON' || true "anthropic/claude-opus-4-6": { "alias": "Opus" }, "anthropic/claude-sonnet-4-6": { "alias": "Sonnet" }, "anthropic/claude-haiku-4-5": { "alias": "Haiku" }, + "openai-codex/gpt-5.5": { "alias": "Codex 5.5" }, "openai-codex/gpt-5.4": { "alias": "Codex 5.4" }, "openai-codex/gpt-5.3-codex": { "alias": "Codex 5.3" }, "openai-codex/gpt-5.3-codex-spark": { "alias": "Codex Spark" }, diff --git a/scripts/telegram-e2e/lean-model-allowlist.jsonc b/scripts/telegram-e2e/lean-model-allowlist.jsonc index c022cb6949c8f..c0fa5a99ab62b 100644 --- a/scripts/telegram-e2e/lean-model-allowlist.jsonc +++ b/scripts/telegram-e2e/lean-model-allowlist.jsonc @@ -8,6 +8,7 @@ "anthropic/claude-opus-4-6": { "alias": "Opus" }, "anthropic/claude-sonnet-4-6": { "alias": "Sonnet" }, "anthropic/claude-haiku-4-5": { "alias": "Haiku" }, + "openai-codex/gpt-5.5": { "alias": "Codex 5.5" }, "openai-codex/gpt-5.4": { "alias": "Codex 5.4" }, "openai-codex/gpt-5.3-codex": { "alias": "Codex 5.3" }, "openai-codex/gpt-5.3-codex-spark": { "alias": "Codex Spark" }, diff --git a/scripts/telegram-live-preflight.sh b/scripts/telegram-live-preflight.sh index cb76260202405..91371cc16a283 100755 --- a/scripts/telegram-live-preflight.sh +++ b/scripts/telegram-live-preflight.sh @@ -4,6 +4,7 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" HELPER_MODULE="${SCRIPT_DIR}/lib/telegram-live-runtime-helpers.mjs" +BASELINE_HELPER_MODULE="${SCRIPT_DIR}/lib/worktree-tester-baseline.mjs" WORKTREE="$(git rev-parse --show-toplevel 2>/dev/null || pwd -P)" if [[ -d "$WORKTREE" ]]; then @@ -19,6 +20,10 @@ RUNTIME_WORKTREE="" RUNTIME_OWNERSHIP="fail" RUNTIME_CONFIG_PRESENT="no" RUNTIME_CONFIG_TOKEN_PRESENT="no" +BASELINE_META_PATH="" +NAMED_TELEGRAM_CREDENTIALS_STRIPPED="no" +STRIPPED_NAMED_TELEGRAM_ACCOUNTS="none" +STRIPPED_NAMED_TELEGRAM_SOURCES="none" TOKEN_PRESENT="no" TOKEN_FINGERPRINT="none" TOKEN_CLAIM_COUNT=0 @@ -183,6 +188,73 @@ NODE fi } +inspect_baseline_sanitization_metadata() { + if [[ ! -f "$BASELINE_HELPER_MODULE" ]]; then + return + fi + + local metadata_lines="" + metadata_lines="$( + WORKTREE_PATH="$WORKTREE" BASELINE_HELPER_MODULE="$BASELINE_HELPER_MODULE" node --input-type=module - <<'NODE' +import fs from "node:fs"; +import { pathToFileURL } from "node:url"; + +const helperPath = process.env.BASELINE_HELPER_MODULE; +const worktreePath = process.env.WORKTREE_PATH; +if (!helperPath || !worktreePath) { + process.exit(0); +} + +const { deriveWorktreeTesterBaseline } = await import(pathToFileURL(helperPath).href); +const baseline = deriveWorktreeTesterBaseline({ worktreePath }); +let meta = {}; +if (fs.existsSync(baseline.metaPath)) { + try { + const parsed = JSON.parse(fs.readFileSync(baseline.metaPath, "utf8")); + if (parsed && typeof parsed === "object") { + meta = parsed; + } + } catch { + meta = {}; + } +} + +const stripped = Array.isArray(meta?.sanitization?.strippedTelegramCredentials) + ? meta.sanitization.strippedTelegramCredentials + : []; +const named = stripped.filter((entry) => entry?.accountKind === "named"); +const accounts = [ + ...new Set(named.map((entry) => String(entry.accountId || "").trim()).filter(Boolean)), +]; +const sources = [ + ...new Set( + named + .map((entry) => { + const accountId = String(entry.accountId || "").trim(); + const sourceKind = String(entry.sourceKind || "").trim(); + return accountId && sourceKind ? `${accountId}:${sourceKind}` : ""; + }) + .filter(Boolean), + ), +]; + +process.stdout.write(`${baseline.metaPath}\n`); +process.stdout.write(`${accounts.length > 0 ? "yes" : "no"}\n`); +process.stdout.write(`${accounts.join(",") || "none"}\n`); +process.stdout.write(`${sources.join(",") || "none"}\n`); +NODE + )" || true + + if [[ -z "$metadata_lines" ]]; then + return + fi + + BASELINE_META_PATH="$(printf '%s\n' "$metadata_lines" | sed -n '1p')" + NAMED_TELEGRAM_CREDENTIALS_STRIPPED="$(printf '%s\n' "$metadata_lines" | sed -n '2p')" + STRIPPED_NAMED_TELEGRAM_ACCOUNTS="$(printf '%s\n' "$metadata_lines" | sed -n '3p')" + STRIPPED_NAMED_TELEGRAM_SOURCES="$(printf '%s\n' "$metadata_lines" | sed -n '4p')" +} + resolve_token_claim() { local env_local="${REPO_ROOT}/.env.local" local token="" @@ -252,6 +324,7 @@ PY resolve_profile resolve_runtime_owner inspect_runtime_config +inspect_baseline_sanitization_metadata resolve_token_claim if [[ -z "${BRANCH}" || "${BRANCH}" == "HEAD" ]]; then @@ -276,6 +349,10 @@ echo "runtime_ownership=${RUNTIME_OWNERSHIP}" echo "runtime_config_path=${RUNTIME_CONFIG_PATH}" echo "runtime_config_present=${RUNTIME_CONFIG_PRESENT}" echo "runtime_config_token_present=${RUNTIME_CONFIG_TOKEN_PRESENT}" +echo "baseline_meta_path=${BASELINE_META_PATH}" +echo "named_telegram_credentials_stripped=${NAMED_TELEGRAM_CREDENTIALS_STRIPPED}" +echo "stripped_named_telegram_accounts=${STRIPPED_NAMED_TELEGRAM_ACCOUNTS}" +echo "stripped_named_telegram_sources=${STRIPPED_NAMED_TELEGRAM_SOURCES}" echo "token_present=${TOKEN_PRESENT}" echo "token_fingerprint=${TOKEN_FINGERPRINT}" echo "token_claim_count=${TOKEN_CLAIM_COUNT}" @@ -283,6 +360,9 @@ echo "assigned_bot_id=${ASSIGNED_BOT_ID}" echo "assigned_bot_username=${ASSIGNED_BOT_USERNAME}" echo "assigned_bot_name=${ASSIGNED_BOT_NAME}" echo "next_action=bash scripts/telegram-live-runtime.sh ensure" +if [[ "${NAMED_TELEGRAM_CREDENTIALS_STRIPPED}" == "yes" ]]; then + echo "named_telegram_next_action=refresh tokenFile/botToken for listed named accounts or disable them before testing named-account bots" +fi if [[ "${FAIL}" -ne 0 ]]; then exit 1 diff --git a/src/cli/daemon-cli/status.gather.test.ts b/src/cli/daemon-cli/status.gather.test.ts index 6f573509e8314..00af71db54fe0 100644 --- a/src/cli/daemon-cli/status.gather.test.ts +++ b/src/cli/daemon-cli/status.gather.test.ts @@ -255,6 +255,39 @@ describe("gatherDaemonStatus", () => { ); }); + it("adopts the loaded canonical consumer service env when no runtime selector was requested", async () => { + delete process.env.OPENCLAW_STATE_DIR; + delete process.env.OPENCLAW_CONFIG_PATH; + delete process.env.OPENCLAW_PROFILE; + delete process.env.OPENCLAW_LAUNCHD_LABEL; + serviceReadCommand.mockResolvedValueOnce({ + programArguments: ["/bin/node", "cli", "gateway", "--port", "18789"], + environment: { + OPENCLAW_PROFILE: "consumer", + OPENCLAW_LAUNCHD_LABEL: "ai.openclaw.gateway", + OPENCLAW_STATE_DIR: "/Users/test/Library/Application Support/OpenClaw/.openclaw", + OPENCLAW_CONFIG_PATH: + "/Users/test/Library/Application Support/OpenClaw/.openclaw/openclaw.json", + OPENCLAW_GATEWAY_PORT: "18789", + }, + }); + + const status = await gatherDaemonStatus({ + rpc: {}, + probe: false, + deep: false, + }); + + expect(status.config?.cli.path).toBe( + "/Users/test/Library/Application Support/OpenClaw/.openclaw/openclaw.json", + ); + expect(status.config?.daemon?.path).toBe( + "/Users/test/Library/Application Support/OpenClaw/.openclaw/openclaw.json", + ); + expect(status.config?.mismatch).toBeUndefined(); + expect(status.portMismatch).toBeUndefined(); + }); + it("does not force local TLS fingerprint when probe URL is explicitly overridden", async () => { const status = await gatherDaemonStatus({ rpc: { url: "wss://override.example:18790" }, diff --git a/src/cli/daemon-cli/status.gather.ts b/src/cli/daemon-cli/status.gather.ts index e787ee51f6191..4deca9de343dd 100644 --- a/src/cli/daemon-cli/status.gather.ts +++ b/src/cli/daemon-cli/status.gather.ts @@ -93,6 +93,16 @@ type ResolvedGatewayStatus = { probeUrlOverride: string | null; }; +const RUNTIME_SELECTOR_ENV_KEYS = [ + "OPENCLAW_CONSUMER_INSTANCE_ID", + "OPENCLAW_HOME", + "OPENCLAW_LAUNCHD_LABEL", + "OPENCLAW_PROFILE", + "OPENCLAW_STATE_DIR", + "OPENCLAW_CONFIG_PATH", + "OPENCLAW_GATEWAY_PORT", +] as const; + export type DaemonStatus = { runtimeFingerprint?: RuntimeFingerprint; service: { @@ -152,14 +162,38 @@ function shouldReportPortUsage(status: PortUsageStatus | undefined, rpcOk?: bool return true; } +function hasExplicitRuntimeSelector(env: Record): boolean { + return RUNTIME_SELECTOR_ENV_KEYS.some((key) => Boolean(env[key]?.trim())); +} + +function shouldAdoptCanonicalConsumerServiceEnv(params: { + rawEnv: Record; + serviceEnv?: Record; +}): boolean { + if (hasExplicitRuntimeSelector(params.rawEnv)) { + return false; + } + const serviceEnv = params.serviceEnv; + if (!serviceEnv) { + return false; + } + return ( + serviceEnv.OPENCLAW_PROFILE?.trim() === "consumer" && + serviceEnv.OPENCLAW_LAUNCHD_LABEL?.trim() === "ai.openclaw.gateway" && + Boolean(serviceEnv.OPENCLAW_STATE_DIR?.trim()) && + Boolean(serviceEnv.OPENCLAW_CONFIG_PATH?.trim()) + ); +} + function parseGatewaySecretRefPathFromError(error: unknown): string | null { return isGatewaySecretRefUnavailableError(error) ? error.path : null; } async function loadDaemonConfigContext( + cliEnvInput: Record, serviceEnv?: Record, ): Promise { - const cliEnv = resolveGatewayRuntimeIdentityEnv(process.env); + const cliEnv = cliEnvInput; const mergedDaemonEnv = { ...cliEnv, ...(serviceEnv ?? undefined), @@ -294,8 +328,18 @@ export async function gatherDaemonStatus( } & FindExtraGatewayServicesOptions, ): Promise { const service = resolveGatewayService(); - const cliEnv = resolveGatewayRuntimeIdentityEnv(process.env); - const command = await service.readCommand(cliEnv as NodeJS.ProcessEnv).catch(() => null); + const rawEnv = process.env as Record; + const shellCliEnv = resolveGatewayRuntimeIdentityEnv(rawEnv); + const command = await service.readCommand(shellCliEnv as NodeJS.ProcessEnv).catch(() => null); + const cliEnv = shouldAdoptCanonicalConsumerServiceEnv({ + rawEnv, + serviceEnv: command?.environment, + }) + ? ({ + ...shellCliEnv, + ...command?.environment, + } satisfies Record) + : shellCliEnv; const serviceEnv = command?.environment ? ({ ...cliEnv, @@ -319,7 +363,7 @@ export async function gatherDaemonStatus( cliConfigSummary, daemonConfigSummary, configMismatch, - } = await loadDaemonConfigContext(command?.environment); + } = await loadDaemonConfigContext(cliEnv, command?.environment); const { gateway, daemonPort, cliPort, probeUrlOverride } = await resolveGatewayStatusSummary({ cliCfg, daemonCfg, diff --git a/src/daemon/service-audit.test.ts b/src/daemon/service-audit.test.ts index f7e87b6a51879..8666a8f451a44 100644 --- a/src/daemon/service-audit.test.ts +++ b/src/daemon/service-audit.test.ts @@ -145,6 +145,41 @@ describe("auditGatewayServiceConfig", () => { }); expectTokenAudit(audit, { embedded: false, mismatch: false }); }); + + it("flags app-owned runtime identity drift in the installed service env", async () => { + const env = { + HOME: "/Users/test", + OPENCLAW_HOME: "/Users/test/Library/Application Support/OpenClaw", + OPENCLAW_STATE_DIR: "/Users/test/Library/Application Support/OpenClaw/.openclaw", + OPENCLAW_CONFIG_PATH: + "/Users/test/Library/Application Support/OpenClaw/.openclaw/openclaw.json", + OPENCLAW_LOG_DIR: "/Users/test/Library/Application Support/OpenClaw/.openclaw/logs", + OPENCLAW_PROFILE: "consumer", + OPENCLAW_GATEWAY_PORT: "18789", + OPENCLAW_GATEWAY_BIND: "loopback", + OPENCLAW_LAUNCHD_LABEL: "ai.openclaw.gateway", + }; + const audit = await auditGatewayServiceConfig({ + env, + platform: "darwin", + command: { + programArguments: ["/usr/bin/node", "gateway", "--port", "18789"], + environment: { + HOME: "/Users/test", + PATH: buildMinimalServicePath({ platform: "darwin", env }), + OPENCLAW_GATEWAY_PORT: "18789", + OPENCLAW_LAUNCHD_LABEL: "ai.openclaw.gateway", + }, + }, + }); + + const issue = audit.issues.find( + (entry) => entry.code === SERVICE_AUDIT_CODES.gatewayRuntimeIdentityMismatch, + ); + expect(issue?.message).toContain("runtime identity"); + expect(issue?.detail).toContain("OPENCLAW_STATE_DIR"); + expect(issue?.detail).toContain("OPENCLAW_CONFIG_PATH"); + }); }); describe("checkTokenDrift", () => { diff --git a/src/daemon/service-audit.ts b/src/daemon/service-audit.ts index 8524e79da4760..d1688c8c22aef 100644 --- a/src/daemon/service-audit.ts +++ b/src/daemon/service-audit.ts @@ -41,6 +41,7 @@ export const SERVICE_AUDIT_CODES = { gatewayRuntimeBun: "gateway-runtime-bun", gatewayRuntimeNodeVersionManager: "gateway-runtime-node-version-manager", gatewayRuntimeNodeSystemMissing: "gateway-runtime-node-system-missing", + gatewayRuntimeIdentityMismatch: "gateway-runtime-identity-mismatch", gatewayTokenDrift: "gateway-token-drift", launchdKeepAlive: "launchd-keep-alive", launchdRunAtLoad: "launchd-run-at-load", @@ -205,6 +206,51 @@ function auditGatewayCommand(programArguments: string[] | undefined, issues: Ser } } +function auditGatewayRuntimeIdentity( + command: GatewayServiceCommand, + issues: ServiceConfigIssue[], + env: Record, +) { + if (!command?.environment) { + return; + } + + const keys = [ + "OPENCLAW_HOME", + "OPENCLAW_STATE_DIR", + "OPENCLAW_CONFIG_PATH", + "OPENCLAW_LOG_DIR", + "OPENCLAW_PROFILE", + "OPENCLAW_GATEWAY_PORT", + "OPENCLAW_GATEWAY_BIND", + "OPENCLAW_LAUNCHD_LABEL", + ] as const; + const mismatches: string[] = []; + + for (const key of keys) { + const expected = env[key]?.trim(); + if (!expected) { + continue; + } + const actual = command.environment[key]?.trim(); + if (actual === expected) { + continue; + } + mismatches.push(`${key}: expected ${expected}, got ${actual || "missing"}`); + } + + if (mismatches.length === 0) { + return; + } + + issues.push({ + code: SERVICE_AUDIT_CODES.gatewayRuntimeIdentityMismatch, + message: "Gateway service runtime identity does not match this CLI/runtime lane.", + detail: mismatches.join("; "), + level: "aggressive", + }); +} + function auditGatewayToken( command: GatewayServiceCommand, issues: ServiceConfigIssue[], @@ -409,6 +455,7 @@ export async function auditGatewayServiceConfig(params: { const platform = params.platform ?? process.platform; auditGatewayCommand(params.command?.programArguments, issues); + auditGatewayRuntimeIdentity(params.command, issues, params.env); auditGatewayToken(params.command, issues, params.expectedGatewayToken); auditGatewayServicePath(params.command, issues, params.env, platform); await auditGatewayRuntime(params.env, params.command, issues, platform); diff --git a/test/new-worktree-tester-baseline.test.ts b/test/new-worktree-tester-baseline.test.ts index ac8a1c6a3c52c..978461fd577cb 100644 --- a/test/new-worktree-tester-baseline.test.ts +++ b/test/new-worktree-tester-baseline.test.ts @@ -145,8 +145,13 @@ describe("new worktree tester baseline bootstrap", () => { telegram: { enabled: true, botToken: "prod-token", + tokenFile: "/run/secrets/prod-telegram-token", accounts: { - tester: { botToken: "test-account-token", enabled: true }, + tester: { + botToken: "test-account-token", + tokenFile: "/run/secrets/tester-telegram-token", + enabled: true, + }, }, }, }, @@ -220,10 +225,13 @@ describe("new worktree tester baseline bootstrap", () => { const worktreePath = output.match(/^worktree=(.+)$/m)?.[1]; const baselineStateDir = output.match(/^baseline_state_dir=(.+)$/m)?.[1]; const baselineConfigPath = output.match(/^baseline_config_path=(.+)$/m)?.[1]; + const baselineMetaPath = output.match(/^baseline_meta_path=(.+)$/m)?.[1]; expect(output).toContain("baseline_bootstrap=ok"); + expect(output).toContain("baseline_stripped_named_telegram_accounts=tester"); expect(worktreePath).toBeTruthy(); expect(baselineStateDir).toContain(path.join(homeDir, ".openclaw", "worktree-runtimes")); expect(baselineConfigPath).toBe(path.join(baselineStateDir!, "openclaw.json")); + expect(baselineMetaPath).toBe(path.join(baselineStateDir!, "auth-sync.json")); const devEnv = readFileSync(path.join(worktreePath!, ".dev-launch.env"), "utf8"); expect(devEnv).toContain(`OPENCLAW_STATE_DIR=${baselineStateDir}`); @@ -233,7 +241,9 @@ describe("new worktree tester baseline bootstrap", () => { expect(inheritedConfig.models.providers.openai.baseUrl).toBe("https://api.openai.com/v1"); expect(inheritedConfig.models.providers.openai.apiKey).toBeUndefined(); expect(inheritedConfig.channels.telegram.botToken).toBeUndefined(); + expect(inheritedConfig.channels.telegram.tokenFile).toBeUndefined(); expect(inheritedConfig.channels.telegram.accounts.tester.botToken).toBeUndefined(); + expect(inheritedConfig.channels.telegram.accounts.tester.tokenFile).toBeUndefined(); expect(inheritedConfig.env.OPENAI_API_KEY).toBeUndefined(); expect(inheritedConfig.env.OPENCLAW_CONSUMER_OPENAI_API_KEY).toBeUndefined(); expect(inheritedConfig.env.vars.OPENAI_API_KEY).toBeUndefined(); @@ -250,6 +260,20 @@ describe("new worktree tester baseline bootstrap", () => { ); expect(JSON.parse(readFileSync(inheritedAuthPath, "utf8"))).toEqual(sourceAuth); + const baselineMeta = JSON.parse(readFileSync(baselineMetaPath!, "utf8")); + expect(baselineMeta.sanitization.strippedNamedTelegramAccounts).toEqual(["tester"]); + expect(baselineMeta.sanitization.strippedTelegramCredentials).toEqual( + expect.arrayContaining([ + { accountId: "default", accountKind: "default", sourceKind: "botToken" }, + { accountId: "default", accountKind: "default", sourceKind: "tokenFile" }, + { accountId: "tester", accountKind: "named", sourceKind: "botToken" }, + { accountId: "tester", accountKind: "named", sourceKind: "tokenFile" }, + ]), + ); + expect(JSON.stringify(baselineMeta)).not.toContain("prod-token"); + expect(JSON.stringify(baselineMeta)).not.toContain("test-account-token"); + expect(JSON.stringify(baselineMeta)).not.toContain("/run/secrets/tester-telegram-token"); + expect(JSON.parse(readFileSync(sourceConfigPath, "utf8"))).toEqual(sourceConfig); expect( JSON.parse(readFileSync(path.join(sourceAuthDir, "auth-profiles.json"), "utf8")),