mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
1758008a91
commit
915c25d713
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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?
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"}"#,
|
||||
"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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user