Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,8 @@ the read-only application directory the workspace.

| Command | Behavior |
|---|---|
| `clawctl setup` | Extract the bundled Node.js runtime when needed and confirm packaged `app\openclaw.mjs` exists. |
| `clawctl setup` | On a session-capable Windows build, provision the isolated agent session, install its bundled Node.js runtime, and confirm packaged `app\openclaw.mjs` exists. |
| `clawctl setup --no-isolation` | On a session-capable Windows build, prepare the host runtime without provisioning the isolated session. It still refuses unsupported Windows builds. |
| `clawctl --version` | Print the packaged launcher version. |

Bare `clawctl`, `clawctl -h`, and `clawctl --help` print help without changing
Expand Down
32 changes: 27 additions & 5 deletions src/OpenClaw.Launcher/ClawCtlCommandLine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ internal sealed record ClawCtlHandlers
public required Func<CancellationToken, Task<int>> GatewayStop { get; init; }
}

internal sealed record SetupOptions(bool NoIsolation);
internal sealed record SetupOptions(bool Fresh, bool Force, bool NoIsolation = false);

// The clawctl command tree. Only the package-readiness surface belongs here:
// doctor, gateway, uninstall, and every other OpenClaw command is owned by the
Expand Down Expand Up @@ -62,9 +62,31 @@ public static RootCommand Create(ClawCtlHandlers handlers)
{
Description = "Prepare the bundled runtime without provisioning an isolated session."
};
Option<bool> fresh = new("--fresh")
{
Description = "Remove this installation's owned session and local state before setting it up again."
};
setup.Options.Add(noIsolation);
Option<bool> force = new("--force")
{
Description = "Continue with package-local cleanup when owned external cleanup cannot be confirmed. Requires --fresh."
};
setup.Options.Add(fresh);
setup.Options.Add(force);
setup.Validators.Add(result =>
{
if (result.GetValue(force) && !result.GetValue(fresh))
{
result.AddError("Option '--force' requires option '--fresh'.");
}
});
setup.SetAction((parsed, cancellationToken) =>
handlers.Setup(new SetupOptions(parsed.GetValue(noIsolation)), cancellationToken));
handlers.Setup(
new SetupOptions(
parsed.GetValue(fresh),
parsed.GetValue(force),
parsed.GetValue(noIsolation)),
cancellationToken));
Command status = new(
StatusCommandName,
"Show the isolated-session record and MXC-observed provision state without provisioning a replacement.");
Expand All @@ -79,11 +101,11 @@ public static RootCommand Create(ClawCtlHandlers handlers)
collectLogs.Options.Add(outputPath);
collectLogs.SetAction((parsed, cancellationToken) =>
handlers.CollectLogs(parsed.GetValue(outputPath), cancellationToken));
Option<bool> force = new("--force") { Description = "Skip confirmation and remove the owned session." };
Option<bool> teardownForce = new("--force") { Description = "Skip confirmation and remove the owned session." };
Command teardown = new("teardown", "Stop and remove the owned isolated session.");
teardown.Options.Add(force);
teardown.Options.Add(teardownForce);
teardown.SetAction((parsed, cancellationToken) =>
handlers.Teardown(parsed.GetValue(force), cancellationToken));
handlers.Teardown(parsed.GetValue(teardownForce), cancellationToken));
Command powerShell = new(
"pwsh",
"Open an interactive PowerShell session inside the isolated agent.");
Expand Down
145 changes: 145 additions & 0 deletions src/OpenClaw.Launcher/Gateway/GatewayControlOutput.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
using System.Text;
using OpenClaw.Launcher.Session;

namespace OpenClaw.Launcher.Gateway;

/// <summary>Writes gateway command results without trusting guest-produced log text.</summary>
internal static class GatewayControlOutput
{
private const int MaximumLogTailBytes = 16 * 1024;
private const int LogTailLineCount = 10;

public static async Task WriteStatusAsync(
TextWriter output,
GatewayStatusReport result,
string? workspacePath,
CancellationToken cancellationToken)
{
await output.WriteLineAsync(result.Message).ConfigureAwait(false);
await WriteDetailAsync(output, result.Detail).ConfigureAwait(false);
if (result.State is GatewayState.Stopped or GatewayState.Unhealthy)
{
await WriteLogTailAsync(
output,
workspacePath,
result.Record?.LogPath,
cancellationToken).ConfigureAwait(false);
}
}

public static async Task WriteStopAsync(
TextWriter output,
GatewayStopResult result)
{
await output.WriteLineAsync(result.Message).ConfigureAwait(false);
await WriteDetailAsync(output, result.Detail).ConfigureAwait(false);
}

private static async Task WriteDetailAsync(TextWriter output, string? detail)
{
if (!string.IsNullOrWhiteSpace(detail))
{
await output.WriteLineAsync(detail).ConfigureAwait(false);
}
}

private static async Task WriteLogTailAsync(
TextWriter output,
string? workspacePath,
string? logPath,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(workspacePath) ||
string.IsNullOrWhiteSpace(logPath))
{
return;
}

try
{
using FileStream stream = TrustedPath.OpenRead(workspacePath, logPath);
if (stream.Length == 0)
{
return;
}

stream.Seek(Math.Max(0, stream.Length - MaximumLogTailBytes), SeekOrigin.Begin);
byte[] bytes = new byte[checked((int)Math.Min(MaximumLogTailBytes, stream.Length - stream.Position))];
int offset = 0;
while (offset < bytes.Length)
{
int read = await stream.ReadAsync(bytes.AsMemory(offset), cancellationToken)
.ConfigureAwait(false);
if (read == 0)
{
break;
}

offset += read;
}

string text = Encoding.UTF8.GetString(bytes, 0, offset);
string[] lines = text.Replace("\r\n", "\n", StringComparison.Ordinal)
.Split('\n', StringSplitOptions.RemoveEmptyEntries);
string[] tail =
[
.. lines.TakeLast(LogTailLineCount)
.Select(Sanitize)
.Where(line => line.Length > 0)
];
if (tail.Length == 0)
{
return;
}

await output.WriteLineAsync($"Gateway log tail ({logPath}):").ConfigureAwait(false);
foreach (string line in tail)
{
await output.WriteLineAsync(line).ConfigureAwait(false);
}
}
catch (Exception exception) when (
exception is IOException or UnauthorizedAccessException or SessionException)
{
// A guest-writable diagnostic must not change a status command's result.
await output.WriteLineAsync($"Gateway log unavailable: {logPath}").ConfigureAwait(false);
}
}

private static string Sanitize(string value)
{
StringBuilder builder = new(value.Length);
bool afterEscape = false;
bool inControlSequence = false;
foreach (char character in value)
{
if (afterEscape)
{
inControlSequence = character == '[';
afterEscape = false;
continue;
}

if (inControlSequence)
{
if (character is >= '@' and <= '~')
{
inControlSequence = false;
}

continue;
}

if (character == '\x1b')
{
afterEscape = true;
}
else if (!char.IsControl(character))
{
builder.Append(character);
}
}

return builder.ToString();
}
}
9 changes: 6 additions & 3 deletions src/OpenClaw.Launcher/Gateway/GatewayRuntime.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ private GatewayRuntime(

public string HelperPath { get; }

internal HostPaths Paths => _paths;

internal string? GetRecordedWorkspacePath() =>
_session.Coordinator.GetRecordedStatus().Record?.WorkspacePath;

private SessionRuntime Session => _session;

private static bool FileExists(string path) => File.Exists(path);
Expand Down Expand Up @@ -197,9 +202,7 @@ Task<GatewayStartRequest> CreateRequestAsync(CancellationToken cancellationToken
applicationDirectory,
launch.Port)
{
WorkingDirectory = launch.WorkingDirectory ?? sessionRecord.WorkspacePath
?? throw new SessionException(
"The isolated session has no shared workspace for the gateway.")
WorkingDirectory = launch.WorkingDirectory
});
}

Expand Down
3 changes: 3 additions & 0 deletions src/OpenClaw.Launcher/HostDiagnosticLog.cs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,9 @@ public void Write(string message)
"Timed out waiting to append to the diagnostic log.");
}

// A fresh setup deliberately clears the installation state
// root. Recreate the log directory before reopening the file.
Directory.CreateDirectory(System.IO.Path.GetDirectoryName(Path)!);
using var stream = new FileStream(
Path,
FileMode.Append,
Expand Down
5 changes: 3 additions & 2 deletions src/OpenClaw.Launcher/HostStartup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ internal sealed class HostStartup

public Program.LaunchOpenClawAsync? LaunchOpenClaw { get; init; }

public Func<Action<string>, Session.SessionRuntime>? CreateSessionRuntime { get; init; }
public Session.IInstallationLifecycle? InstallationLifecycle { get; init; }

public Func<string, string?>? ReadEnvironmentVariable { get; init; }

Expand All @@ -36,6 +36,7 @@ internal sealed class HostStartup
BaseDirectory = AppContext.BaseDirectory,
Output = Console.Out,
Error = Console.Error,
InstallNodeRuntime = NodeRuntimeInstaller.EnsureInstalled
InstallNodeRuntime = NodeRuntimeInstaller.EnsureInstalled,
InstallationLifecycle = Session.InstallationLifecycle.Production
};
}
Loading
Loading