From 915c25d713c505100603ebee74d57aabd6f1de42 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 8 Aug 2026 17:16:00 -0700 Subject: [PATCH] fix(macos): complete ChatGPT subscription setup (#120782) Fresh Dev installs now preflight disk space and stream honest stages, while Codex activation probes the refreshed request-scoped registry. Closes #120779 Closes #120780 --- apps/.i18n/native-source.json | 98 +++--- .../Sources/OpenClaw/CLIInstallPrompter.swift | 4 +- .../macos/Sources/OpenClaw/CLIInstaller.swift | 42 ++- .../OpenClaw/OnboardingView+Pages.swift | 12 +- .../Sources/OpenClaw/ShellExecutor.swift | 296 +++++++++++++----- .../OpenClawIPCTests/CLIInstallerTests.swift | 49 +++ .../ShellExecutorTimeoutTests.swift | 132 ++++++-- scripts/install-cli.sh | 73 +++++ src/system-agent/setup-inference-activate.ts | 45 +-- src/system-agent/setup-inference.test.ts | 62 ++++ test/scripts/install-cli.test.ts | 146 +++++++++ 11 files changed, 777 insertions(+), 182 deletions(-) diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index 4bf03018d4b0..51770582cb9d 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -30123,7 +30123,7 @@ }, { "kind": "conditional-branch", - "line": 190, + "line": 192, "path": "apps/macos/Sources/OpenClaw/CLIInstallPrompter.swift", "source": "CLI install failed", "surface": "apple", @@ -30131,7 +30131,7 @@ }, { "kind": "conditional-branch", - "line": 190, + "line": 192, "path": "apps/macos/Sources/OpenClaw/CLIInstallPrompter.swift", "source": "CLI install finished", "surface": "apple", @@ -30195,7 +30195,7 @@ }, { "kind": "ui-localized-call", - "line": 490, + "line": 493, "path": "apps/macos/Sources/OpenClaw/CLIInstaller.swift", "source": "Repairing the OpenClaw Gateway update…", "surface": "apple", @@ -30203,7 +30203,7 @@ }, { "kind": "ui-localized-call", - "line": 491, + "line": 494, "path": "apps/macos/Sources/OpenClaw/CLIInstaller.swift", "source": "Updating the OpenClaw Gateway to \\(targetVersion)…", "surface": "apple", @@ -30211,7 +30211,7 @@ }, { "kind": "ui-localized-call", - "line": 512, + "line": 515, "path": "apps/macos/Sources/OpenClaw/CLIInstaller.swift", "source": "Gateway update needs attention.", "surface": "apple", @@ -30219,7 +30219,7 @@ }, { "kind": "ui-localized-call", - "line": 514, + "line": 517, "path": "apps/macos/Sources/OpenClaw/CLIInstaller.swift", "source": "Gateway update failed.", "surface": "apple", @@ -30227,7 +30227,7 @@ }, { "kind": "ui-localized-call", - "line": 529, + "line": 532, "path": "apps/macos/Sources/OpenClaw/CLIInstaller.swift", "source": "Gateway update finished, but verification failed.", "surface": "apple", @@ -30235,7 +30235,7 @@ }, { "kind": "ui-localized-call", - "line": 536, + "line": 539, "path": "apps/macos/Sources/OpenClaw/CLIInstaller.swift", "source": "OpenClaw Gateway \\(installedVersion) is installed.", "surface": "apple", @@ -35931,7 +35931,7 @@ }, { "kind": "ui-call", - "line": 862, + "line": 864, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Getting things ready", "surface": "apple", @@ -35939,7 +35939,7 @@ }, { "kind": "ui-named-argument", - "line": 873, + "line": 875, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Install OpenClaw", "surface": "apple", @@ -35947,7 +35947,7 @@ }, { "kind": "conditional-branch", - "line": 880, + "line": 882, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Prepare the Mac node", "surface": "apple", @@ -35955,7 +35955,7 @@ }, { "kind": "conditional-branch", - "line": 880, + "line": 882, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Start the background service", "surface": "apple", @@ -35963,7 +35963,7 @@ }, { "kind": "conditional-branch", - "line": 882, + "line": 884, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Runs inside the app and uses its macOS permissions.", "surface": "apple", @@ -35971,7 +35971,7 @@ }, { "kind": "conditional-branch", - "line": 883, + "line": 885, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Runs quietly and starts again after a restart.", "surface": "apple", @@ -35979,7 +35979,7 @@ }, { "kind": "ui-named-argument", - "line": 886, + "line": 888, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Ready for the next step", "surface": "apple", @@ -35987,7 +35987,7 @@ }, { "kind": "conditional-branch", - "line": 888, + "line": 890, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Once ready, this Mac connects to your selected Gateway.", "surface": "apple", @@ -35995,23 +35995,31 @@ }, { "kind": "conditional-branch", - "line": 889, + "line": 891, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Once the service answers, you’ll connect your AI.", "surface": "apple", "id": "native.apple.9f58badc863cbf2b" }, { - "kind": "ui-named-argument", - "line": 894, + "kind": "conditional-branch", + "line": 897, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "The Gateway didn’t start", "surface": "apple", "id": "native.apple.3bcb83316b3d90bb" }, + { + "kind": "conditional-branch", + "line": 898, + "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", + "source": "OpenClaw installation failed", + "surface": "apple", + "id": "native.apple.f0b128f895130fef" + }, { "kind": "ui-named-argument", - "line": 897, + "line": 901, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Try again", "surface": "apple", @@ -36019,7 +36027,7 @@ }, { "kind": "ui-call", - "line": 988, + "line": 992, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "You’re all set!", "surface": "apple", @@ -36027,7 +36035,7 @@ }, { "kind": "ui-call", - "line": 991, + "line": 995, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Finish opens the chat — say hi to your new agent.", "surface": "apple", @@ -36035,7 +36043,7 @@ }, { "kind": "ui-named-argument", - "line": 999, + "line": 1003, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Configure later", "surface": "apple", @@ -36043,7 +36051,7 @@ }, { "kind": "ui-named-argument", - "line": 1000, + "line": 1004, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Pick Local or Remote in Settings → General whenever you’re ready.", "surface": "apple", @@ -36051,7 +36059,7 @@ }, { "kind": "ui-named-argument", - "line": 1007, + "line": 1011, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Remote gateway checklist", "surface": "apple", @@ -36059,7 +36067,7 @@ }, { "kind": "ui-named-argument-multiline", - "line": 1008, + "line": 1012, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "On your gateway host: install/update the `openclaw` package and make sure credentials exist\n(typically `~/.openclaw/credentials/oauth.json`). Then connect again if needed.", "surface": "apple", @@ -36067,7 +36075,7 @@ }, { "kind": "ui-named-argument", - "line": 1017, + "line": 1021, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Open the menu bar panel", "surface": "apple", @@ -36075,7 +36083,7 @@ }, { "kind": "ui-named-argument", - "line": 1018, + "line": 1022, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Click the OpenClaw menu bar icon for the compact chat panel and status.", "surface": "apple", @@ -36083,7 +36091,7 @@ }, { "kind": "ui-named-argument", - "line": 1021, + "line": 1025, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Connect Discord, Slack, Telegram, WhatsApp, …", "surface": "apple", @@ -36091,7 +36099,7 @@ }, { "kind": "ui-named-argument", - "line": 1022, + "line": 1026, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Open Settings → Channels to link channels and monitor status.", "surface": "apple", @@ -36099,7 +36107,7 @@ }, { "kind": "ui-named-argument", - "line": 1024, + "line": 1028, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Open Settings → Channels", "surface": "apple", @@ -36107,7 +36115,7 @@ }, { "kind": "ui-named-argument", - "line": 1029, + "line": 1033, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Try Voice Wake", "surface": "apple", @@ -36115,7 +36123,7 @@ }, { "kind": "ui-named-argument", - "line": 1030, + "line": 1034, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Enable Voice Wake in Settings for hands-free commands with a live transcript overlay.", "surface": "apple", @@ -36123,7 +36131,7 @@ }, { "kind": "ui-named-argument", - "line": 1033, + "line": 1037, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Use the panel + Canvas", "surface": "apple", @@ -36131,7 +36139,7 @@ }, { "kind": "ui-named-argument-concatenated", - "line": 1034, + "line": 1038, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Open the compact chat panel; the agent can show previews and richer visuals in Canvas.", "surface": "apple", @@ -36139,7 +36147,7 @@ }, { "kind": "ui-named-argument", - "line": 1038, + "line": 1042, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Give your agent more powers", "surface": "apple", @@ -36147,7 +36155,7 @@ }, { "kind": "ui-named-argument", - "line": 1039, + "line": 1043, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Enable optional skills (Peekaboo, oracle, camsnap, …) from Settings → Skills.", "surface": "apple", @@ -36155,7 +36163,7 @@ }, { "kind": "ui-named-argument", - "line": 1041, + "line": 1045, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Open Settings → Skills", "surface": "apple", @@ -36163,7 +36171,7 @@ }, { "kind": "ui-call", - "line": 1046, + "line": 1050, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Launch at login", "surface": "apple", @@ -36171,7 +36179,7 @@ }, { "kind": "ui-call", - "line": 1073, + "line": 1077, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Skills included", "surface": "apple", @@ -36179,7 +36187,7 @@ }, { "kind": "ui-call", - "line": 1080, + "line": 1084, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Refresh", "surface": "apple", @@ -36187,7 +36195,7 @@ }, { "kind": "ui-call", - "line": 1089, + "line": 1093, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Couldn’t load skills from the Gateway.", "surface": "apple", @@ -36195,7 +36203,7 @@ }, { "kind": "ui-call-concatenated", - "line": 1092, + "line": 1096, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Make sure the Gateway is running and connected, then hit Refresh (or open Settings → Skills).", "surface": "apple", @@ -36203,7 +36211,7 @@ }, { "kind": "ui-call", - "line": 1098, + "line": 1102, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Details: \\(error)", "surface": "apple", @@ -36211,7 +36219,7 @@ }, { "kind": "ui-call", - "line": 1104, + "line": 1108, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "No skills reported yet.", "surface": "apple", diff --git a/apps/macos/Sources/OpenClaw/CLIInstallPrompter.swift b/apps/macos/Sources/OpenClaw/CLIInstallPrompter.swift index 056335b0b54e..01a45d4c1af9 100644 --- a/apps/macos/Sources/OpenClaw/CLIInstallPrompter.swift +++ b/apps/macos/Sources/OpenClaw/CLIInstallPrompter.swift @@ -104,7 +104,9 @@ final class CLIInstallPrompter { alert.messageText = "Choose OpenClaw CLI channel" alert.informativeText = "This is an unreleased OpenClaw build. " + - "Local mode can use Stable, Beta, or Dev from Git main." + "Stable and Beta use published builds and are usually quick. " + + "Dev (Git main) downloads and builds from source, so it can take several minutes " + + "and needs several gigabytes free." for channel in channels { alert.addButton(withTitle: channel.label) } diff --git a/apps/macos/Sources/OpenClaw/CLIInstaller.swift b/apps/macos/Sources/OpenClaw/CLIInstaller.swift index 4c096d7f764c..389d5a9f4566 100644 --- a/apps/macos/Sources/OpenClaw/CLIInstaller.swift +++ b/apps/macos/Sources/OpenClaw/CLIInstaller.swift @@ -336,11 +336,15 @@ enum CLIInstaller { prefix: prefix, scriptPath: installerURL.path, compatibleWith: target.requiresExactVersion ? nil : appVersion) - let response = await ShellExecutor.runDetailed( + let response = await ShellExecutor.runStreamingDetailed( command: cmd, cwd: nil, env: nil, timeout: self.installWatchdogTimeout(for: target)) + { line in + guard let status = self.installStatus(forEventLine: line) else { return } + await statusHandler(status) + } if response.success { let expectedVersion = target.requiresExactVersion ? GatewayEnvironment.appVersionString() : nil @@ -370,8 +374,7 @@ enum CLIInstaller { return true } - let parsed = self.parseInstallEvents(response.stdout) - if let error = parsed.last(where: { $0.event == "error" })?.message { + if let error = self.installErrorMessage(from: response.stdout) { await statusHandler("Install failed: \(error)") return false } @@ -593,6 +596,37 @@ enum CLIInstaller { return events } + nonisolated static func installStatus(forEventLine line: String) -> String? { + guard let data = line.data(using: .utf8), + let event = try? JSONDecoder().decode(InstallEvent.self, from: data), + event.event == "step", + let name = event.name, + let status = event.status + else { + return nil + } + + return switch (name, status) { + case ("disk-space", "start"): "Checking available disk space…" + case ("node", "start"): "Installing Node.js runtime…" + case ("git-tools", "start"): "Preparing Git and pnpm…" + case ("git-clone", "start"): "Downloading OpenClaw source…" + case ("git-update", "start"): "Updating OpenClaw source…" + case ("dependencies", "start"): "Installing dependencies…" + case ("control-ui", "start"): "Building interface…" + case ("cli-build", "start"): "Building OpenClaw CLI…" + case ("openclaw", "retry"): "Retrying OpenClaw CLI install…" + case ("disk-space", "warn"): "Couldn’t verify free disk space; continuing…" + case ("git-update", "warn"): "Using the existing modified OpenClaw source…" + case ("control-ui", "warn"): "Interface build did not finish; continuing…" + default: nil + } + } + + static func installErrorMessage(from output: String) -> String? { + self.parseInstallEvents(output).last(where: { $0.event == "error" })?.message + } + static func parseManagedUpdateSummary(_ output: String) -> ManagedCLIUpdateSummary? { let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return nil } @@ -627,6 +661,8 @@ enum CLIInstaller { private struct InstallEvent: Decodable { let event: String + let name: String? + let status: String? let version: String? let message: String? } diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift index 9b189d8f7340..9b1ad484c4b9 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift @@ -851,13 +851,15 @@ extension OnboardingView { func cliPage() -> some View { let remoteMode = self.state.connectionMode == .remote - let detail = if remoteMode { + let setupDetail = if remoteMode { "OpenClaw is installing the matching runtime for this Mac node. " + "It will connect to your selected Gateway without starting another one here." } else { - "OpenClaw is setting up its background service on this Mac. " + - "This usually takes under a minute — no Terminal, no administrator password." + "OpenClaw is setting up its background service on this Mac." } + let detail = setupDetail + " Published Stable and Beta installs are usually quick. " + + "Dev (Git main) downloads and builds OpenClaw from source, so allow several minutes " + + "and several gigabytes of free space. No administrator password is required." return onboardingPage { Text("Getting things ready") .font(.largeTitle.weight(.semibold)) @@ -891,7 +893,9 @@ extension OnboardingView { if self.installFailed { OnboardingErrorCard( - title: "The Gateway didn’t start", + title: self.cliExecutableReady + ? "The Gateway didn’t start" + : "OpenClaw installation failed", message: self.cliStatus ?? "The installer did not finish.", docsSlug: "platforms/mac/bundled-gateway", retryTitle: "Try again") diff --git a/apps/macos/Sources/OpenClaw/ShellExecutor.swift b/apps/macos/Sources/OpenClaw/ShellExecutor.swift index 98a5a3eb986b..898e438eda4d 100644 --- a/apps/macos/Sources/OpenClaw/ShellExecutor.swift +++ b/apps/macos/Sources/OpenClaw/ShellExecutor.swift @@ -68,6 +68,40 @@ enum ShellExecutor { case timedOut } + private enum StreamingTaskResult: Sendable { + case drained + case deadline(timedOut: Bool) + } + + private final class StreamingOutputCapture: @unchecked Sendable { + private let lock = NSLock() + private var stdoutLines: [String] = [] + private var stderrLines: [String] = [] + + func appendStdout(line: String) { + self.lock.withLock { + self.stdoutLines.append(line) + } + } + + func appendStderr(line: String) { + self.lock.withLock { + self.stderrLines.append(line) + } + } + + func snapshot() -> (stdout: String, stderr: String) { + self.lock.withLock { + (Self.output(from: self.stdoutLines), Self.output(from: self.stderrLines)) + } + } + + private static func output(from lines: [String]) -> String { + guard !lines.isEmpty else { return "" } + return lines.joined(separator: "\n") + "\n" + } + } + private final class ProcessExitSignal: @unchecked Sendable { private let lock = NSLock() private let source: DispatchSourceProcess @@ -128,6 +162,64 @@ enum ShellExecutor { return .custom(converted) } + private static func configuration(command: [String], cwd: String?, env: [String: String]?) -> Configuration { + var platformOptions = PlatformOptions() + platformOptions.qualityOfService = .userInitiated + platformOptions.createSession = true + platformOptions.teardownSequence = [ + .send( + signal: .kill, + toProcessGroup: true, + allowedDurationToNextStep: .zero), + ] + return Configuration( + .path(.init("/usr/bin/env")), + arguments: Arguments(command), + environment: self.environment(from: env), + workingDirectory: cwd.map { .init($0) }, + platformOptions: platformOptions) + } + + private static func completedResult( + _ terminationStatus: TerminationStatus, + captured: (stdout: String, stderr: String)) -> ShellResult + { + let status = switch terminationStatus { + case let .exited(code), let .signaled(code): + Int(code) + } + return ShellResult( + stdout: captured.stdout, + stderr: captured.stderr, + exitCode: status, + timedOut: false, + success: terminationStatus.isSuccess, + errorMessage: terminationStatus.isSuccess ? nil : "exit \(status)") + } + + private static func timedOutResult(captured: (stdout: String, stderr: String)) -> ShellResult { + ShellResult( + stdout: captured.stdout, + stderr: captured.stderr, + exitCode: nil, + timedOut: true, + success: false, + errorMessage: "timeout") + } + + private static func failedResult( + captured: (stdout: String, stderr: String) = ("", ""), + message: String) -> ShellResult + { + ShellResult( + stdout: captured.stdout, + stderr: captured.stderr, + exitCode: nil, + timedOut: false, + success: false, + errorMessage: message) + } + private static func runSubprocess( configuration: Configuration, output: OutputFiles) async throws -> TerminationStatus @@ -151,39 +243,99 @@ enum ShellExecutor { output: output.subprocessStandardOutput, error: output.subprocessStandardError) { execution in - let processIdentifier = pid_t(execution.processIdentifier.value) - return await withTaskCancellationHandler { - let deadline = await withTaskGroup(of: DeadlineOutcome.self) { group in - let exitSignal = ProcessExitSignal(processIdentifier: processIdentifier) - group.addTask { - await exitSignal.wait() + await self.waitForExitOrTimeout(execution: execution, timeout: timeout) + } + return result.closureOutput ? .timedOut : .completed(result.terminationStatus) + } + + private static func waitForExitOrTimeout( + execution: Execution, + timeout: Double) async -> Bool + { + let processIdentifier = pid_t(execution.processIdentifier.value) + return await withTaskCancellationHandler { + let deadline = await withTaskGroup(of: DeadlineOutcome.self) { group in + let exitSignal = ProcessExitSignal(processIdentifier: processIdentifier) + group.addTask { + await exitSignal.wait() + return .exited + } + group.addTask { + do { + try await Task.sleep(for: .seconds(timeout)) + return .timedOut + } catch { return .exited } + } + defer { group.cancelAll() } + return await group.next() ?? .exited + } + + guard deadline == .timedOut else { return false } + try? execution.send(signal: .terminate, toProcessGroup: true) + try? await Task.sleep(for: .milliseconds(100)) + // The group leader may have exited on TERM. Keep the body alive until + // the final group kill so TERM-ignoring descendants cannot escape. + try? execution.send(signal: .kill, toProcessGroup: true) + return true + } onCancel: { + // Cancellation can arrive before the timeout race finishes. + _ = Darwin.kill(-processIdentifier, SIGKILL) + } + } + + private static func runStreamingSubprocess( + configuration: Configuration, + timeout: Double?, + capture: StreamingOutputCapture, + onStandardOutputLine: @escaping @Sendable (String) async -> Void) async throws + -> (terminationStatus: TerminationStatus, timedOut: Bool) + { + let result = try await Subprocess.run( + configuration, + input: .standardInput, + output: .sequence, + error: .sequence) + { execution in + let processIdentifier = pid_t(execution.processIdentifier.value) + return try await withTaskCancellationHandler { + try await withThrowingTaskGroup(of: StreamingTaskResult.self) { group in group.addTask { - do { - try await Task.sleep(for: .seconds(timeout)) - return .timedOut - } catch { - return .exited + for try await line in execution.standardOutput.strings(bufferingPolicy: .unbounded) { + capture.appendStdout(line: line) + await onStandardOutputLine(line) + } + return .drained + } + group.addTask { + for try await line in execution.standardError.strings(bufferingPolicy: .unbounded) { + capture.appendStderr(line: line) + } + return .drained + } + if let timeout, timeout > 0 { + group.addTask { + await .deadline( + timedOut: self.waitForExitOrTimeout( + execution: execution, + timeout: timeout)) } } - defer { group.cancelAll() } - return await group.next() ?? .exited - } - guard deadline == .timedOut else { return false } - try? execution.send(signal: .terminate, toProcessGroup: true) - try? await Task.sleep(for: .milliseconds(100)) - // The group leader may have exited on TERM. Keep the body alive until - // the final group kill so TERM-ignoring descendants cannot escape. - try? execution.send(signal: .kill, toProcessGroup: true) - return true + var timedOut = false + for try await taskResult in group { + if case let .deadline(didTimeOut) = taskResult { + timedOut = didTimeOut + } + } + return timedOut + } } onCancel: { - // Cancellation can arrive before the timeout race finishes. _ = Darwin.kill(-processIdentifier, SIGKILL) } } - return result.closureOutput ? .timedOut : .completed(result.terminationStatus) + return (result.terminationStatus, result.closureOutput) } static func runDetailed( @@ -193,43 +345,17 @@ enum ShellExecutor { timeout: Double?) async -> ShellResult { guard !command.isEmpty else { - return ShellResult( - stdout: "", - stderr: "", - exitCode: nil, - timedOut: false, - success: false, - errorMessage: "empty command") + return self.failedResult(message: "empty command") } let output: OutputFiles do { output = try OutputFiles() } catch { - return ShellResult( - stdout: "", - stderr: "", - exitCode: nil, - timedOut: false, - success: false, - errorMessage: "failed to capture output: \(error.localizedDescription)") + return self.failedResult(message: "failed to capture output: \(error.localizedDescription)") } - var platformOptions = PlatformOptions() - platformOptions.qualityOfService = .userInitiated - platformOptions.createSession = true - platformOptions.teardownSequence = [ - .send( - signal: .kill, - toProcessGroup: true, - allowedDurationToNextStep: .zero), - ] - let configuration = Configuration( - .path(.init("/usr/bin/env")), - arguments: Arguments(command), - environment: self.environment(from: env), - workingDirectory: cwd.map { .init($0) }, - platformOptions: platformOptions) + let configuration = self.configuration(command: command, cwd: cwd, env: env) do { let outcome = if let timeout, timeout > 0 { @@ -244,35 +370,51 @@ enum ShellExecutor { let captured = output.readAndRemove() switch outcome { case .timedOut: - return ShellResult( - stdout: captured.stdout, - stderr: captured.stderr, - exitCode: nil, - timedOut: true, - success: false, - errorMessage: "timeout") + return self.timedOutResult(captured: captured) case let .completed(terminationStatus): - let status = switch terminationStatus { - case let .exited(code), let .signaled(code): - Int(code) - } - return ShellResult( - stdout: captured.stdout, - stderr: captured.stderr, - exitCode: status, - timedOut: false, - success: terminationStatus.isSuccess, - errorMessage: terminationStatus.isSuccess ? nil : "exit \(status)") + return self.completedResult(terminationStatus, captured: captured) } } catch { let captured = output.readAndRemove() - return ShellResult( - stdout: captured.stdout, - stderr: captured.stderr, - exitCode: nil, - timedOut: false, - success: false, - errorMessage: "failed to start: \(error.localizedDescription)") + return self.failedResult( + captured: captured, + message: "failed to start: \(error.localizedDescription)") + } + } + + /// The installer owns its process tree and does not daemonize descendants, so + /// it can safely use pipe-backed streaming. Broad callers keep the file-backed + /// path above because an unrelated descendant may inherit their stdout. + static func runStreamingDetailed( + command: [String], + cwd: String?, + env: [String: String]?, + timeout: Double?, + onStandardOutputLine: @escaping @Sendable (String) async -> Void) async -> ShellResult + { + guard !command.isEmpty else { + return self.failedResult(message: "empty command") + } + + let configuration = self.configuration(command: command, cwd: cwd, env: env) + let capture = StreamingOutputCapture() + + do { + let outcome = try await self.runStreamingSubprocess( + configuration: configuration, + timeout: timeout, + capture: capture, + onStandardOutputLine: onStandardOutputLine) + let captured = capture.snapshot() + if outcome.timedOut { + return self.timedOutResult(captured: captured) + } + return self.completedResult(outcome.terminationStatus, captured: captured) + } catch { + let captured = capture.snapshot() + return self.failedResult( + captured: captured, + message: "failed to start: \(error.localizedDescription)") } } diff --git a/apps/macos/Tests/OpenClawIPCTests/CLIInstallerTests.swift b/apps/macos/Tests/OpenClawIPCTests/CLIInstallerTests.swift index c0c00186f223..243853d2bd68 100644 --- a/apps/macos/Tests/OpenClawIPCTests/CLIInstallerTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/CLIInstallerTests.swift @@ -90,6 +90,55 @@ struct CLIInstallerTests { #expect(CLIInstaller.installWatchdogTimeout(for: .exact(String())) == 900) } + @Test func `installer events map to concise live status`() { + let cases: [(String, String)] = [ + (#"{"event":"step","name":"disk-space","status":"start"}"#, "Checking available disk space…"), + (#"{"event":"step","name":"node","status":"start"}"#, "Installing Node.js runtime…"), + (#"{"event":"step","name":"git-tools","status":"start"}"#, "Preparing Git and pnpm…"), + (#"{"event":"step","name":"git-clone","status":"start"}"#, "Downloading OpenClaw source…"), + (#"{"event":"step","name":"git-update","status":"start"}"#, "Updating OpenClaw source…"), + (#"{"event":"step","name":"dependencies","status":"start"}"#, "Installing dependencies…"), + (#"{"event":"step","name":"control-ui","status":"start"}"#, "Building interface…"), + (#"{"event":"step","name":"cli-build","status":"start"}"#, "Building OpenClaw CLI…"), + (#"{"event":"step","name":"openclaw","status":"retry"}"#, "Retrying OpenClaw CLI install…"), + ( + #"{"event":"step","name":"disk-space","status":"warn"}"#, + "Couldn’t verify free disk space; continuing…"), + ( + #"{"event":"step","name":"git-update","status":"warn"}"#, + "Using the existing modified OpenClaw source…"), + ( + #"{"event":"step","name":"control-ui","status":"warn"}"#, + "Interface build did not finish; continuing…"), + ] + + for (line, expected) in cases { + #expect(CLIInstaller.installStatus(forEventLine: line) == expected) + } + } + + @Test func `installer status ignores malformed unknown and terminal events`() { + for line in [ + "not json", + #"{"event":"step","name":"future-stage","status":"start"}"#, + #"{"event":"step","name":"dependencies","status":"ok"}"#, + #"{"event":"done","ok":true,"version":"2026.7.3"}"#, + #"{"event":"error","message":"failed"}"#, + ] { + #expect(CLIInstaller.installStatus(forEventLine: line) == nil) + } + } + + @Test func `installer extracts one actionable disk preflight error`() { + let output = """ + {"event":"step","name":"disk-space","status":"start"} + {"event":"error","message":"Fresh Git installs require at least 6 GiB of free disk space; only 2.0 GiB is available. Free disk space and retry."} + """ + + #expect(CLIInstaller.installErrorMessage(from: output) == + "Fresh Git installs require at least 6 GiB of free disk space; only 2.0 GiB is available. Free disk space and retry.") + } + @Test func `managed update uses the canonical updater without accepting downgrades`() { let command = CLIInstaller.managedUpdateCommand( executable: "/Users/Test User/.openclaw/bin/openclaw", diff --git a/apps/macos/Tests/OpenClawIPCTests/ShellExecutorTimeoutTests.swift b/apps/macos/Tests/OpenClawIPCTests/ShellExecutorTimeoutTests.swift index 05b8e4772094..a03669914fe7 100644 --- a/apps/macos/Tests/OpenClawIPCTests/ShellExecutorTimeoutTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/ShellExecutorTimeoutTests.swift @@ -3,36 +3,93 @@ import Foundation import Testing @testable import OpenClaw +private actor ShellLineRecorder { + private var lines: [String] = [] + + func append(_ line: String) { + self.lines.append(line) + } + + func snapshot() -> [String] { + self.lines + } +} + struct ShellExecutorTimeoutTests { + @Test func `streaming captures both streams while delivering stdout lines`() async { + let recorder = ShellLineRecorder() + let result = await ShellExecutor.runStreamingDetailed( + command: [ + "/bin/sh", + "-c", + "printf 'one\\ntwo\\n'; printf 'problem\\n' >&2", + ], + cwd: nil, + env: nil, + timeout: 5) + { line in + await recorder.append(line) + } + + #expect(result.success) + #expect(await recorder.snapshot() == ["one", "two"]) + #expect(result.stdout == "one\ntwo\n") + #expect(result.stderr == "problem\n") + } + @Test func `timeout kills and reaps a TERM-ignoring command`() async throws { + try await self.assertTimeoutKillsAndReaps(streaming: false) + } + + @Test func `streaming timeout kills and reaps a TERM-ignoring command`() async throws { + try await self.assertTimeoutKillsAndReaps(streaming: true) + } + + @Test func `timeout terminates TERM-ignoring descendants`() async throws { + try await self.assertTimeoutTerminatesDescendants(streaming: false) + } + + @Test func `streaming timeout terminates TERM-ignoring descendants`() async throws { + try await self.assertTimeoutTerminatesDescendants(streaming: true) + } + + private func assertTimeoutKillsAndReaps(streaming: Bool) async throws { let pidFile = FileManager.default.temporaryDirectory .appendingPathComponent("openclaw-shell-timeout-\(UUID().uuidString).pid") defer { try? FileManager.default.removeItem(at: pidFile) } - let result = await ShellExecutor.runDetailed( - command: [ - "/bin/sh", - "-c", - "echo $$ > \"$PID_FILE\"; trap '' TERM; exec /bin/sleep 30", - ], - cwd: nil, - env: ["PID_FILE": pidFile.path], - timeout: 0.1) + let command = [ + "/bin/sh", + "-c", + "echo $$ > \"$PID_FILE\"; trap '' TERM; exec /bin/sleep 30", + ] + let environment = ["PID_FILE": pidFile.path] + let result = if streaming { + await ShellExecutor.runStreamingDetailed( + command: command, + cwd: nil, + env: environment, + timeout: 0.1, + onStandardOutputLine: { _ in }) + } else { + await ShellExecutor.runDetailed( + command: command, + cwd: nil, + env: environment, + timeout: 0.1) + } #expect(result.timedOut) - let pidString = try String(contentsOf: pidFile, encoding: .utf8) - .trimmingCharacters(in: .whitespacesAndNewlines) - let pid = try #require(pid_t(pidString)) + let pid = try self.readPID(from: pidFile) defer { if kill(pid, 0) == 0 { kill(pid, SIGKILL) } } - #expect(kill(pid, 0) == -1) - #expect(errno == ESRCH) + #expect(await self.waitUntilGone(pid)) } - @Test func `timeout terminates TERM-ignoring descendants`() async throws { + private func assertTimeoutTerminatesDescendants(streaming: Bool) async throws { let directory = FileManager.default.temporaryDirectory .appendingPathComponent("openclaw-shell-timeout-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) @@ -40,23 +97,34 @@ struct ShellExecutorTimeoutTests { let parentPIDFile = directory.appendingPathComponent("parent.pid") let childPIDFile = directory.appendingPathComponent("child.pid") - let result = await ShellExecutor.runDetailed( - command: [ - "/bin/sh", - "-c", - """ - /bin/sh -c 'trap "" TERM; echo $$ > "$CHILD_PID_FILE"; while :; do sleep 10; done' & - echo $$ > "$PARENT_PID_FILE" - while [ ! -s "$CHILD_PID_FILE" ]; do sleep 0.01; done - while :; do sleep 10; done - """, - ], - cwd: nil, - env: [ - "PARENT_PID_FILE": parentPIDFile.path, - "CHILD_PID_FILE": childPIDFile.path, - ], - timeout: 0.2) + let command = [ + "/bin/sh", + "-c", + """ + /bin/sh -c 'trap "" TERM; echo $$ > "$CHILD_PID_FILE"; while :; do sleep 10; done' & + echo $$ > "$PARENT_PID_FILE" + while [ ! -s "$CHILD_PID_FILE" ]; do sleep 0.01; done + while :; do sleep 10; done + """, + ] + let environment = [ + "PARENT_PID_FILE": parentPIDFile.path, + "CHILD_PID_FILE": childPIDFile.path, + ] + let result = if streaming { + await ShellExecutor.runStreamingDetailed( + command: command, + cwd: nil, + env: environment, + timeout: 0.2, + onStandardOutputLine: { _ in }) + } else { + await ShellExecutor.runDetailed( + command: command, + cwd: nil, + env: environment, + timeout: 0.2) + } #expect(result.timedOut) let parentPID = try self.readPID(from: parentPIDFile) diff --git a/scripts/install-cli.sh b/scripts/install-cli.sh index 0ebb65cb8e96..0be3be12bf8e 100755 --- a/scripts/install-cli.sh +++ b/scripts/install-cli.sh @@ -85,6 +85,7 @@ JSON=0 RUN_ONBOARD=0 SET_NPM_PREFIX=0 PNPM_CMD=() +FRESH_GIT_MIN_FREE_KIB=$((6 * 1024 * 1024)) print_usage() { cat </dev/null | awk 'NR == 2 { print $4; exit }' || true +} + +preflight_fresh_git_disk_space() { + local repo_dir="$1" + local ancestor + local available_kib + local available_gib + + if [[ "$repo_dir" != /* ]]; then + repo_dir="$(pwd)/$repo_dir" + fi + if [[ -d "$repo_dir/.git" ]]; then + return 0 + fi + + emit_json "{\"event\":\"step\",\"name\":\"disk-space\",\"status\":\"start\"}" + ancestor="$repo_dir" + while [[ ! -e "$ancestor" ]]; do + local parent + parent="$(dirname "$ancestor")" + if [[ "$parent" == "$ancestor" ]]; then + break + fi + ancestor="$parent" + done + if [[ ! -d "$ancestor" ]]; then + ancestor="$(dirname "$ancestor")" + fi + + available_kib="$(available_disk_kib "$ancestor")" + if [[ ! "$available_kib" =~ ^[0-9]+$ ]]; then + emit_json "{\"event\":\"step\",\"name\":\"disk-space\",\"status\":\"warn\",\"reason\":\"unreadable\"}" + return 0 + fi + if ((available_kib < FRESH_GIT_MIN_FREE_KIB)); then + available_gib="$(awk -v kib="$available_kib" 'BEGIN { printf "%.1f", kib / 1048576 }')" + fail "Fresh Git installs require at least 6 GiB of free disk space; only ${available_gib} GiB is available. Free disk space and retry." + fi + emit_json "{\"event\":\"step\",\"name\":\"disk-space\",\"status\":\"ok\"}" +} + has_sudo() { command -v sudo >/dev/null 2>&1 } @@ -1306,6 +1351,7 @@ ensure_pnpm_git_prepare_allowlist() { install_openclaw_from_git() { local repo_dir="$1" local repo_url="https://github.com/openclaw/openclaw.git" + local fresh_checkout=0 if [[ -z "$repo_dir" ]]; then fail "Git install dir cannot be empty" @@ -1323,9 +1369,11 @@ install_openclaw_from_git() { log "Installing Openclaw from GitHub (${repo_url})..." fi + emit_json '{"event":"step","name":"git-tools","status":"start"}' ensure_git ensure_pnpm ensure_pnpm_binary_for_scripts + emit_json '{"event":"step","name":"git-tools","status":"ok"}' if [[ -d "$repo_dir/.git" ]] && ! git --git-dir="$repo_dir/.git" --work-tree="$repo_dir" rev-parse --verify --quiet 'HEAD^{commit}' >/dev/null 2>&1; then @@ -1336,21 +1384,34 @@ install_openclaw_from_git() { : elif [[ -d "$repo_dir" ]]; then if [[ -z "$(ls -A "$repo_dir" 2>/dev/null || true)" ]]; then + emit_json '{"event":"step","name":"git-clone","status":"start"}' git clone "$repo_url" "$repo_dir" + emit_json '{"event":"step","name":"git-clone","status":"ok"}' + fresh_checkout=1 else fail "Git install dir exists but is not a git repo: ${repo_dir}" fi else + emit_json '{"event":"step","name":"git-clone","status":"start"}' git clone "$repo_url" "$repo_dir" + emit_json '{"event":"step","name":"git-clone","status":"ok"}' + fresh_checkout=1 fi local git_ref git_ref="$(resolve_git_openclaw_ref)" if [[ -z "$(git -C "$repo_dir" status --porcelain 2>/dev/null || true)" ]]; then log "Using git ref: ${git_ref}" + if [[ "$fresh_checkout" -eq 0 ]]; then + emit_json '{"event":"step","name":"git-update","status":"start"}' + fi checkout_git_openclaw_ref "$repo_dir" "$git_ref" + if [[ "$fresh_checkout" -eq 0 ]]; then + emit_json '{"event":"step","name":"git-update","status":"ok"}' + fi else log "Repo is dirty; skipping git checkout/update" + emit_json '{"event":"step","name":"git-update","status":"warn","reason":"dirty"}' fi if [[ -n "${REQUIRED_COMPATIBLE_VERSION:-}" ]]; then @@ -1368,12 +1429,20 @@ install_openclaw_from_git() { local install_lockfile_flag install_lockfile_flag="$(git_install_lockfile_flag "$repo_dir" "$git_ref")" + emit_json '{"event":"step","name":"dependencies","status":"start"}' CI="${CI:-true}" run_pnpm -C "$repo_dir" install "$install_lockfile_flag" + emit_json '{"event":"step","name":"dependencies","status":"ok"}' + emit_json '{"event":"step","name":"control-ui","status":"start"}' if ! run_pnpm -C "$repo_dir" ui:build; then log "UI build failed; continuing (CLI may still work)" + emit_json '{"event":"step","name":"control-ui","status":"warn"}' + else + emit_json '{"event":"step","name":"control-ui","status":"ok"}' fi + emit_json '{"event":"step","name":"cli-build","status":"start"}' run_pnpm -C "$repo_dir" build + emit_json '{"event":"step","name":"cli-build","status":"ok"}' mkdir -p "${PREFIX}/bin" cat > "${PREFIX}/bin/openclaw" < (registryRefreshWarning = message) }, }); try { - const pluginRegistry = loadAgentRuntimePluginRegistryHandle({ + codexProbePluginRegistry = loadAgentRuntimePluginRegistryHandle({ config: testPlan.config, workspaceDir: tempDir, selections: [ @@ -297,7 +300,7 @@ async function activateSetupInferenceUnredacted( }, ], }); - if (!pluginRegistry) { + if (!codexProbePluginRegistry) { throw new Error("The Codex runtime plugin registry is unavailable."); } } catch (error) { @@ -388,13 +391,13 @@ async function activateSetupInferenceUnredacted( let stagedOwnerPluginArtifacts: SystemAgentOwnerPluginArtifactSnapshot; try { - stagedOwnerPluginArtifacts = ( - deps.captureSystemAgentOwnerPluginArtifacts ?? captureSystemAgentOwnerPluginArtifacts - )({ - config: stagedExecutionRoute.runConfig, - executionRoute: stagedExecutionRoute, - deps, - }); + stagedOwnerPluginArtifacts = withPluginRuntimeRegistryScope(codexProbePluginRegistry, () => + (deps.captureSystemAgentOwnerPluginArtifacts ?? captureSystemAgentOwnerPluginArtifacts)({ + config: stagedExecutionRoute.runConfig, + executionRoute: stagedExecutionRoute, + deps, + }), + ); } catch { return { ok: false, @@ -409,16 +412,18 @@ async function activateSetupInferenceUnredacted( } let test: Awaited>; try { - test = await runSetupInferenceTest({ - plan: testPlan, - tempDir, - deps, - // The setup probe is evidence, not an auth-store mutation. Manual keys - // already exist in the isolated store and every other route stays read-only. - authProfileStateMode: "read-only", - requireExecutionOwner: true, - ...(params.signal ? { signal: params.signal } : {}), - }); + test = await withPluginRuntimeRegistryScope(codexProbePluginRegistry, () => + runSetupInferenceTest({ + plan: testPlan, + tempDir, + deps, + // The setup probe is evidence, not an auth-store mutation. Manual keys + // already exist in the isolated store and every other route stays read-only. + authProfileStateMode: "read-only", + requireExecutionOwner: true, + ...(params.signal ? { signal: params.signal } : {}), + }), + ); throwIfSetupInferenceCancelled(params); } catch (error) { if (error instanceof SetupInferenceCancelledError || params.signal?.aborted) { diff --git a/src/system-agent/setup-inference.test.ts b/src/system-agent/setup-inference.test.ts index 0dc87ec15cc3..b910c29d5b3b 100644 --- a/src/system-agent/setup-inference.test.ts +++ b/src/system-agent/setup-inference.test.ts @@ -13,6 +13,7 @@ import { fingerprintResolvedProviderAuth, type AgentExecutionAuthBinding, } from "../agents/execution-auth-binding.js"; +import { ensureSelectedAgentHarnessPlugin } from "../agents/harness/runtime-plugin.js"; import { detectInferenceBackends } from "../commands/onboard-inference.js"; import { resolveAgentModelPrimaryValue } from "../config/model-input.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; @@ -29,6 +30,10 @@ import { resetPluginRuntimeStateForTest, setActivePluginRegistry, } from "../plugins/runtime.js"; +import { + getPluginRuntimeGatewayRequestScope, + withPluginRuntimeGatewayRequestScope, +} from "../plugins/runtime/gateway-request-scope.js"; import { ensurePluginRegistryLoaded } from "../plugins/runtime/runtime-registry-loader.js"; import type { ProviderPlugin } from "../plugins/types.js"; import { @@ -4419,6 +4424,63 @@ describe("activateSetupInference", () => { }); }); + it("probes a newly loaded Codex harness inside an older Gateway registry scope", async () => { + const oldRegistry = createEmptyPluginRegistry(); + const stagedRegistry = createEmptyPluginRegistry(); + stagedRegistry.agentHarnesses.push({ + pluginId: "codex", + source: "test", + harness: { + id: "codex", + label: "Codex", + supports: () => ({ supported: true }), + runAttempt: async () => { + throw new Error("unused"); + }, + }, + }); + mocks.loadAgentRuntimePluginRegistryHandle.mockReturnValueOnce(stagedRegistry); + let ownerArtifactRegistry: unknown; + const captureSystemAgentOwnerPluginArtifacts = vi.fn(() => { + ownerArtifactRegistry = getPluginRuntimeGatewayRequestScope()?.pluginRegistry; + return { ownerPluginIds: [], ownerPluginArtifacts: [] } as const; + }); + const runEmbeddedAgent = vi.fn(async (params: SuccessfulRunParams) => { + const pluginRegistry = getPluginRuntimeGatewayRequestScope()?.pluginRegistry; + expect(pluginRegistry).toBe(stagedRegistry); + await ensureSelectedAgentHarnessPlugin({ + provider: "openai", + modelId: "gpt-5.6-sol", + agentHarnessRuntimeOverride: "codex", + workspaceDir: "/tmp/work", + pluginRegistry, + }); + return successfulRun("openai", "gpt-5.6-sol", params); + }); + + await withPluginRuntimeGatewayRequestScope( + { isWebchatConnect: () => false, pluginRegistry: oldRegistry }, + async () => { + const result = await activateCodexSetup({ + workspace: "/tmp/work", + deps: { + captureSystemAgentOwnerPluginArtifacts, + runEmbeddedAgent: runEmbeddedAgent as never, + transformConfigWithPendingPluginInstalls: createPreRosterConfigTransformHarness() + .transform as never, + }, + }); + + expect(result).toMatchObject({ ok: true, modelRef: "openai/gpt-5.6-sol" }); + expect(getPluginRuntimeGatewayRequestScope()?.pluginRegistry).toBe(oldRegistry); + }, + ); + expect(getPluginRuntimeGatewayRequestScope()).toBeUndefined(); + expect(ownerArtifactRegistry).toBe(stagedRegistry); + expect(captureSystemAgentOwnerPluginArtifacts).toHaveBeenCalledOnce(); + expect(runEmbeddedAgent).toHaveBeenCalledOnce(); + }); + it("commits only the refreshed codex record when authored install metadata is stale", async () => { const staleAuthoredRecords = { codex: { diff --git a/test/scripts/install-cli.test.ts b/test/scripts/install-cli.test.ts index e3efaf1fb554..62dff3ba5589 100644 --- a/test/scripts/install-cli.test.ts +++ b/test/scripts/install-cli.test.ts @@ -2,6 +2,7 @@ import { spawnSync } from "node:child_process"; import { chmodSync, + existsSync, lstatSync, mkdirSync, mkdtempSync, @@ -41,6 +42,151 @@ function linkRequiredShellTools(bin: string) { describe("install-cli.sh", () => { const script = readFileSync(SCRIPT_PATH, "utf8"); + it("fails a low-space fresh Git install before Node or checkout work", () => { + const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-cli-disk-low-")); + const commandLog = join(tmp, "commands.log"); + const repo = join(tmp, "new", "openclaw"); + + try { + const result = runInstallCliShell( + [ + "set -euo pipefail", + `cd ${JSON.stringify(process.cwd())}`, + `source ${JSON.stringify(SCRIPT_PATH)}`, + "available_disk_kib() { printf '2097152\\n'; }", + `install_node() { printf 'node\\n' >> ${JSON.stringify(commandLog)}; }`, + `install_openclaw_from_git() { printf 'git\\n' >> ${JSON.stringify(commandLog)}; }`, + `main --json --git --git-dir ${JSON.stringify(repo)}`, + ].join("\n"), + ); + + expect(result.status).toBe(1); + expect(existsSync(commandLog)).toBe(false); + const events = result.stdout + .trim() + .split("\n") + .map((line) => JSON.parse(line) as { event: string; name?: string; message?: string }); + expect(events).toEqual([ + { event: "step", name: "disk-space", status: "start" }, + { + event: "error", + message: + "Fresh Git installs require at least 6 GiB of free disk space; only 2.0 GiB is available. Free disk space and retry.", + }, + ]); + } finally { + rmSync(tmp, { force: true, recursive: true }); + } + }); + + it("allows a fresh Git install with enough free space", () => { + const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-cli-disk-ok-")); + const repo = join(tmp, "new", "openclaw"); + + try { + const result = runInstallCliShell( + [ + "set -euo pipefail", + `cd ${JSON.stringify(process.cwd())}`, + `source ${JSON.stringify(SCRIPT_PATH)}`, + "JSON=1", + "available_disk_kib() { printf '7340032\\n'; }", + `preflight_fresh_git_disk_space ${JSON.stringify(repo)}`, + "printf 'continued\\n'", + ].join("\n"), + ); + + expect(result.status).toBe(0); + expect(result.stdout).toContain('{"event":"step","name":"disk-space","status":"start"}'); + expect(result.stdout).toContain('{"event":"step","name":"disk-space","status":"ok"}'); + expect(result.stdout).toContain("continued"); + } finally { + rmSync(tmp, { force: true, recursive: true }); + } + }); + + it("does not apply the fresh-install disk threshold to an existing checkout", () => { + const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-cli-disk-existing-")); + const repo = join(tmp, "openclaw"); + mkdirSync(join(repo, ".git"), { recursive: true }); + + try { + const result = runInstallCliShell( + [ + "set -euo pipefail", + `cd ${JSON.stringify(process.cwd())}`, + `source ${JSON.stringify(SCRIPT_PATH)}`, + "JSON=1", + "available_disk_kib() { printf 'disk check should not run\\n' >&2; return 99; }", + `preflight_fresh_git_disk_space ${JSON.stringify(repo)}`, + ].join("\n"), + ); + + expect(result.status).toBe(0); + expect(result.stdout).toBe(""); + expect(result.stderr).toBe(""); + } finally { + rmSync(tmp, { force: true, recursive: true }); + } + }); + + it("emits ordered stages for an existing Git checkout build", () => { + const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-cli-events-")); + const repo = join(tmp, "openclaw"); + mkdirSync(join(repo, ".git"), { recursive: true }); + + try { + const result = runInstallCliShell( + [ + "set -euo pipefail", + `cd ${JSON.stringify(process.cwd())}`, + `source ${JSON.stringify(SCRIPT_PATH)}`, + "JSON=1", + `PREFIX=${JSON.stringify(join(tmp, "prefix"))}`, + "ensure_git() { :; }", + "ensure_pnpm() { :; }", + "ensure_pnpm_binary_for_scripts() { :; }", + "ensure_pnpm_git_prepare_allowlist() { :; }", + "activate_repo_pnpm_version() { :; }", + "cleanup_legacy_submodules() { :; }", + "resolve_git_openclaw_ref() { printf 'main\\n'; }", + "checkout_git_openclaw_ref() { :; }", + "run_pnpm() { :; }", + "git() {", + ' if [[ "$1" == --git-dir=* ]]; then return 0; fi', + ' if [[ "$1" == "-C" && "$3" == "status" ]]; then return 0; fi', + " return 0", + "}", + `install_openclaw_from_git ${JSON.stringify(repo)}`, + ].join("\n"), + ); + + expect(result.status).toBe(0); + const stages = result.stdout + .trim() + .split("\n") + .map((line) => JSON.parse(line) as { event: string; name?: string; status?: string }) + .filter((event) => event.event === "step") + .map((event) => `${event.name}:${event.status}`); + expect(stages).toEqual([ + "openclaw:start", + "git-tools:start", + "git-tools:ok", + "git-update:start", + "git-update:ok", + "dependencies:start", + "dependencies:ok", + "control-ui:start", + "control-ui:ok", + "cli-build:start", + "cli-build:ok", + "openclaw:ok", + ]); + } finally { + rmSync(tmp, { force: true, recursive: true }); + } + }); + it("rejects a git checkout without a commit before updating it", () => { const result = runInstallCliShell(` set -euo pipefail