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
This commit is contained in:
Peter Steinberger
2026-08-08 17:16:00 -07:00
committed by GitHub
parent 1758008a91
commit 915c25d713
11 changed files with 777 additions and 182 deletions
+53 -45
View File
@@ -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, youll 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 didnt 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": "Youre 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 youre 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": "Couldnt 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",
@@ -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)
}
+39 -3
View File
@@ -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"): "Couldnt 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?
}
@@ -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 didnt start",
title: self.cliExecutableReady
? "The Gateway didnt start"
: "OpenClaw installation failed",
message: self.cliStatus ?? "The installer did not finish.",
docsSlug: "platforms/mac/bundled-gateway",
retryTitle: "Try again")
+219 -77
View File
@@ -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<some InputProtocol, some OutputProtocol, some OutputProtocol>,
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)")
}
}
@@ -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"}"#,
"Couldnt 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",
@@ -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)
+73
View File
@@ -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 <<EOF
@@ -197,6 +198,50 @@ require_bin() {
fi
}
available_disk_kib() {
local target="$1"
df -Pk "$target" 2>/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" <<EOF
@@ -1451,6 +1520,10 @@ main() {
RUN_ONBOARD=0
fi
if [[ "$INSTALL_METHOD" == "git" ]]; then
preflight_fresh_git_disk_space "$GIT_DIR"
fi
select_node_version_for_platform "$(os_detect)" "$(arch_detect)"
PATH="$(node_dir)/bin:${PREFIX}/bin:${PATH}"
export PATH
+25 -20
View File
@@ -15,7 +15,9 @@ import { formatErrorMessage } from "../infra/errors.js";
import { normalizePluginTargetConfig } from "../plugins/config-state.js";
import { enablePluginInConfig } from "../plugins/enable.js";
import { resolvePluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.js";
import type { PluginRegistry } from "../plugins/registry-types.js";
import { getActivePluginRegistryWorkspaceDirFromState } from "../plugins/runtime-state.js";
import { withPluginRuntimeRegistryScope } from "../plugins/runtime/gateway-request-scope.js";
import { resolveUserPath } from "../utils.js";
import { appendSystemAgentAuditEntry } from "./audit.js";
import {
@@ -139,6 +141,7 @@ async function activateSetupInferenceUnredacted(
let codexInstallOwnership: "unknown" | "owned" | "unowned" = "unknown";
let codexRegistryNeedsReload = false;
let codexRegistryReloaded = false;
let codexProbePluginRegistry: PluginRegistry | undefined;
try {
const plan = await buildTestPlan({
kind: params.kind,
@@ -271,7 +274,7 @@ async function activateSetupInferenceUnredacted(
};
// The Gateway registry predates a runtime installed by this request.
// Refresh and load the exact Codex harness before auth snapshots it.
// Retain the refreshed generation for both owner capture and the live probe.
const refreshPluginRegistry =
deps.refreshPluginRegistryAfterConfigMutation ??
(await import("../plugins/registry-refresh.js")).refreshPluginRegistryAfterConfigMutation;
@@ -285,7 +288,7 @@ async function activateSetupInferenceUnredacted(
logger: { warn: (message) => (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<ReturnType<typeof runSetupInferenceTest>>;
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) {
+62
View File
@@ -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: {
+146
View File
@@ -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