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
20 changes: 10 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ terminal the hint uses the crab identity and the same warning/accent palette as
| `clawctl setup` | Confirm packaged `app\openclaw.mjs` exists, provision or reuse the owned isolated session, and install the bundled Node.js runtime in the agent profile. It also configures gateway sign-in recovery without starting a gateway. On a machine that cannot host a session it fails with the Windows requirement described under [Requirements](#requirements). |
| `clawctl setup --fresh [--force]` | Remove this installation's owned session and package-local state, then run setup again. Without `--force`, incomplete external cleanup stops before local state is erased. `--force` is valid only with `--fresh`; it preserves an explicit warning when cleanup of owned external resources cannot be confirmed, but still stops if bounded local deletion fails. |
| `clawctl status` | Report the recorded isolated session, installed Node.js runtime, gateway, sign-in recovery, and file-only config readiness when the gateway is not running. It asks the backend to start the recorded provision as its status probe, so it is not a passive diagnostic, but it does not provision a replacement or start the gateway. Use `clawctl gateway-service status` to inspect the gateway alone. |
| `clawctl open` | Open the running managed gateway's Control UI in the default browser. Requires completed `clawctl setup` and an already-running gateway; it probes those prerequisites and fails rather than starting the gateway. Packaged OpenClaw resolves the endpoint, TLS, Control UI base path, and authenticated one-time browser handoff. Authenticated URLs and tokens are not printed. |
| `clawctl teardown --force` | Confirm deletion, then stop and deprovision the owned session and remove its data and setup state. The MSIX remains installed. |
| `clawctl pwsh` | Open an interactive PowerShell session inside the agent session. |
| `clawctl collect-logs [--output <path>]` | Create a redacted host-and-agent diagnostics ZIP. |
Expand Down Expand Up @@ -159,19 +160,18 @@ clawctl gateway-service start

Gateway: ✓ listening
Port: 18789

Token: openclaw gateway auth-token --show
```

OpenClaw owns the endpoint configuration, including TLS and a custom Control UI
base path. The Windows package therefore does not construct an HTTP URL that
might contradict that configuration. It reports no port when multiple
unclassified listeners remain. JSON follows the same rule: `gateway.port` is
present only when identified, and no URL is promised.
Reaching the Control UI needs the shared gateway token, which
`openclaw gateway auth-token --show` reveals. `--json` carries the identified
port but not that command: a script should run it rather than parse a
suggestion.
base path. `clawctl status` and `clawctl gateway-service start` therefore do
not construct an HTTP URL that might contradict that configuration. They report
no port when multiple unclassified listeners remain; JSON follows the same
rule, with `gateway.port` present only when identified and no URL promised.

`clawctl open` uses packaged OpenClaw's verified, authenticated browser handoff
instead of constructing a URL from that port. It does not start or recover the
gateway: start it first with `clawctl gateway-service start` if the probe says
it is not running. The command does not print authenticated URLs or tokens.

Help and version requests take precedence over the rest of the command line.
`clawctl --version bogus` reports the build identity and exits `0` rather than
Expand Down
33 changes: 30 additions & 3 deletions scripts/LocalPackage.psm1
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,10 @@ function New-LocalPackageLayout {
$manifest.Save($manifestPath)

Copy-Item -LiteralPath $HostExecutable -Destination (Join-Path $LayoutDirectory 'openclaw.exe') -Force
$nodeScripts = Join-Path $LayoutDirectory 'node'
if (Test-Path -LiteralPath $nodeScripts) { Remove-Item -LiteralPath $nodeScripts -Recurse -Force }
Copy-Item -LiteralPath (Join-Path $RepositoryRoot 'src\OpenClaw.Launcher\node') `
-Destination $nodeScripts -Recurse
$images = Join-Path $LayoutDirectory 'Images'
if (Test-Path -LiteralPath $images) { Remove-Item -LiteralPath $images -Recurse -Force }
Copy-Item -LiteralPath (Join-Path $RepositoryRoot 'src\OpenClaw.Launcher\Images') `
Expand Down Expand Up @@ -474,10 +478,24 @@ function Test-LocalPackageOwnership {
[IO.Path]::GetFullPath($LayoutDirectory).TrimEnd('\')
}

function Get-LocalPackageFileInventory {
param([string]$Directory)

return @(
Get-ChildItem -LiteralPath $Directory -File -Recurse |
Sort-Object FullName |
ForEach-Object {
$relativePath = [IO.Path]::GetRelativePath($Directory, $_.FullName)
"$relativePath`:$((Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash)"
}
)
}

function Test-LocalPackageLayout {
param(
[string]$LayoutDirectory,
[string]$PayloadDirectory,
[string]$NodeScriptsDirectory,
[string]$RuntimeArchiveName,
[string]$Architecture
)
Expand All @@ -491,7 +509,8 @@ function Test-LocalPackageLayout {
"session-host\$Architecture\openclaw-session-host.exe",
"mxc\$Architecture\wxc-exec.exe",
"mxc\$Architecture\plm.exe",
"mxc\$Architecture\mxc-runtime.json"
"mxc\$Architecture\mxc-runtime.json",
'node\native-redirect.mjs'
)) {
if (-not (Test-Path -LiteralPath (Join-Path $LayoutDirectory $relative) -PathType Leaf)) {
return $false
Expand All @@ -500,6 +519,11 @@ function Test-LocalPackageLayout {
if (-not (Test-Path -LiteralPath (Join-Path $LayoutDirectory 'Images') -PathType Container)) {
return $false
}
$expectedNodeScripts = Get-LocalPackageFileInventory -Directory $NodeScriptsDirectory
$actualNodeScripts = Get-LocalPackageFileInventory -Directory (Join-Path $LayoutDirectory 'node')
if ([string]::Join("`n", $actualNodeScripts) -cne [string]::Join("`n", $expectedNodeScripts)) {
return $false
}
$link = Join-Path $LayoutDirectory 'app'
if (-not (Test-Path -LiteralPath $link -PathType Container)) { return $false }
$item = Get-Item -LiteralPath $link -Force
Expand Down Expand Up @@ -865,6 +889,8 @@ function Invoke-LocalPackageDeployment {
Sort-Object FullName |
ForEach-Object { "$($_.Name):$((Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash)" }
)
$nodeScriptsDirectory = Join-Path $root 'src\OpenClaw.Launcher\node'
$nodeScriptHashes = Get-LocalPackageFileInventory -Directory $nodeScriptsDirectory
$fingerprint = Get-LocalPackageFingerprint (@(
$Architecture
$payload.Directory
Expand All @@ -874,7 +900,7 @@ function Invoke-LocalPackageDeployment {
$sessionHostInfo.Length.ToString()
$sessionHostHash
(Get-FileHash -LiteralPath $manifestSource -Algorithm SHA256).Hash
) + $imageHashes + $mxcHashes)
) + $imageHashes + $nodeScriptHashes + $mxcHashes)
$setupSatisfied = $SkipSetup -or ($null -ne $previous -and $previous['setupComplete'] -eq $true)
if (-not $Force -and $null -ne $previous -and $null -ne $installed -and
$installed.IsDevelopmentMode -and
Expand All @@ -885,6 +911,7 @@ function Invoke-LocalPackageDeployment {
$setupSatisfied -and
(Test-LocalPackageLayout -LayoutDirectory $layoutDirectory `
-PayloadDirectory $payload.Directory `
-NodeScriptsDirectory $nodeScriptsDirectory `
-RuntimeArchiveName ([IO.Path]::GetFileName($runtimeArchive)) `
-Architecture $Architecture)) {
$total.Stop()
Expand Down Expand Up @@ -920,7 +947,7 @@ function Invoke-LocalPackageDeployment {
$sessionHostInfo.Length.ToString()
$sessionHostHash
(Get-FileHash -LiteralPath $manifestSource -Algorithm SHA256).Hash
) + $imageHashes + $mxcHashes)
) + $imageHashes + $nodeScriptHashes + $mxcHashes)
}

$manifestPath = Invoke-LocalPackagePhase $progress 'Assemble layout' {
Expand Down
15 changes: 14 additions & 1 deletion scripts/Test-Deploy-LocalPackage.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ function New-Fixture {
$project = Join-Path $root 'src\OpenClaw.Launcher'
New-Item -Path (Join-Path $project 'Images') -ItemType Directory -Force | Out-Null
[IO.File]::WriteAllText((Join-Path $project 'Images\StoreLogo.png'), 'fixture image')
New-Item -Path (Join-Path $project 'node') -ItemType Directory -Force | Out-Null
[IO.File]::WriteAllText((Join-Path $project 'node\native-redirect.mjs'), 'fixture redirect')
[IO.File]::WriteAllText((Join-Path $project 'Package.appxmanifest'), @'
<?xml version="1.0" encoding="utf-8"?>
<Package xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10">
Expand Down Expand Up @@ -246,6 +248,8 @@ try {
Assert-True ((Get-Content (Join-Path $layout 'app\openclaw.mjs') -Raw) -eq 'first payload') 'Layout does not expose the payload application.'
Assert-True ((Get-Item (Join-Path $layout 'app')).Attributes -band [IO.FileAttributes]::ReparsePoint) 'The layout copied the application instead of linking it.'
Assert-True (Test-Path (Join-Path $layout 'openclaw.exe')) 'Layout is missing the launcher.'
Assert-True (Test-Path (Join-Path $layout 'node\native-redirect.mjs')) `
'Layout is missing the native redirect script.'
Assert-True (
Test-Path (Join-Path $layout 'session-host\x64\openclaw-session-host.exe')
) 'Layout is missing the session host.'
Expand Down Expand Up @@ -422,7 +426,7 @@ try {
Assert-True ($afterSkip.Changed -and $sk.Setups -eq 1) 'A run after -SkipSetup did not complete the setup it skipped.'

# A damaged live layout must not be reported as up to date.
foreach ($break in @('openclaw.exe', 'Images', 'app', 'runtime')) {
foreach ($break in @('openclaw.exe', 'Images', 'app', 'runtime', 'node\native-redirect.mjs')) {
$d = New-Fixture
$deployed = Invoke-Fixture $d
$target = Join-Path $deployed.LayoutDirectory $break
Expand All @@ -432,6 +436,15 @@ try {
Assert-True (Test-Path -LiteralPath $target) "A layout missing '$break' was not repaired."
}

$modifiedNodeScript = New-Fixture
$modifiedDeployment = Invoke-Fixture $modifiedNodeScript
$redirectScript = Join-Path $modifiedDeployment.LayoutDirectory 'node\native-redirect.mjs'
[IO.File]::WriteAllText($redirectScript, 'corrupt redirect')
$repairedNodeScript = Invoke-Fixture $modifiedNodeScript
Assert-True $repairedNodeScript.Changed 'A modified redirect script was reported as up to date.'
Assert-True ((Get-Content -LiteralPath $redirectScript -Raw) -eq 'fixture redirect') `
'A modified redirect script was not repaired.'

# The layout runtime copy is replaced, not trusted by name.
$c = New-Fixture
$cDeployed = Invoke-Fixture $c
Expand Down
12 changes: 12 additions & 0 deletions src/OpenClaw.Launcher/ClawCtlCommandLine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ internal sealed record ClawCtlHandlers
public required Func<CancellationToken, Task<int>> Status { get; init; }
public required Func<string?, CancellationToken, Task<int>> CollectLogs { get; init; }
public required Func<bool, CancellationToken, Task<int>> Teardown { get; init; }
public Func<CancellationToken, Task<int>> Open { get; init; } = _ => Task.FromResult(1);
public required Func<CancellationToken, Task<int>> PowerShell { get; init; }
public required Func<bool, CancellationToken, Task<int>> GatewayStart { get; init; }
public required Func<CancellationToken, Task<int>> GatewayStatus { get; init; }
Expand All @@ -34,6 +35,7 @@ internal static class ClawCtlCommandLine
public const string SetupCommandName = "setup";
public const string StatusCommandName = "status";
public const string CollectLogsCommandName = "collect-logs";
public const string OpenCommandName = "open";

// Response-file expansion is off. A leading `@` means nothing to clawctl,
// so it is reported as an unrecognized argument instead of silently reading
Expand Down Expand Up @@ -142,6 +144,15 @@ public static RootCommand Create(
outputOptions.NoColor = parsed.GetValue(noColor);
return handlers.Teardown(parsed.GetValue(teardownForce), cancellationToken);
});
Command open = new(
OpenCommandName,
"Open the running gateway's Control UI in the default browser.");
open.SetAction((parsed, cancellationToken) =>
{
outputOptions.Json = parsed.GetValue(json);
outputOptions.NoColor = parsed.GetValue(noColor);
return handlers.Open(cancellationToken);
});
Command powerShell = new(
"pwsh",
"Open PowerShell inside the isolated agent. `openclaw` and `node` " +
Expand Down Expand Up @@ -195,6 +206,7 @@ public static RootCommand Create(
status,
collectLogs,
teardown,
open,
powerShell,
gateway
};
Expand Down
24 changes: 20 additions & 4 deletions src/OpenClaw.Launcher/ClawCtlConsole.cs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ internal static void WriteResult(
case TeardownCommandResult teardown:
WriteTeardown(view, teardown);
break;
case OpenCommandResult open:
WriteOpen(view, open);
break;
case GatewayCommandResult gateway:
WriteGateway(view, gateway);
break;
Expand Down Expand Up @@ -520,6 +523,7 @@ private static void WriteGateway(ResultView view, GatewayCommandResult result)
{
view.Row("URL", new Text(result.Url));
}

else if (result.Port is not null)
{
view.Row(
Expand All @@ -540,12 +544,12 @@ private static void WriteGateway(ResultView view, GatewayCommandResult result)

WriteReadiness(view, result.Readiness);

// Reaching the Control UI needs the shared token, and the command that
// reveals it belongs to OpenClaw rather than to this package.
if (result.State == Gateway.GatewayState.Running && result.Port is not null)
// The authenticated handoff is owned by OpenClaw and must not expose
// a reusable token at the console.
if (result.State == Gateway.GatewayState.Running)
{
view.Blank();
view.Command("Token", "openclaw gateway auth-token --show");
view.Command("Open Control UI", "clawctl open");
}

if (result.State == Gateway.GatewayState.NotStarted &&
Expand All @@ -558,6 +562,18 @@ private static void WriteGateway(ResultView view, GatewayCommandResult result)
}
}

private static void WriteOpen(ResultView view, OpenCommandResult result)
{
if (result.State is { } state)
{
view.Row("Gateway", state == Gateway.GatewayState.Running
? Status(view, StatusKind.Success, "listening")
: Status(view, StatusKind.Failure, state.ToString()));
}

view.Detail(result.Message);
}

private static void WriteReadiness(
ResultView view,
Session.AgentConfigReadinessStatus? readiness)
Expand Down
13 changes: 13 additions & 0 deletions src/OpenClaw.Launcher/ClawCtlJson.cs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ internal static void WriteResult(TextWriter output, IClawCtlResult result)
StatusCommandResult status => FromStatus(status),
CollectLogsCommandResult logs => FromCollectLogs(logs),
TeardownCommandResult teardown => FromTeardown(teardown),
OpenCommandResult open => FromOpen(open),
GatewayCommandResult gateway => FromGateway(gateway),
_ => throw new ArgumentOutOfRangeException(
nameof(result),
Expand Down Expand Up @@ -232,6 +233,18 @@ private static ClawCtlJsonDocument FromGateway(GatewayCommandResult result) =>
"cli_error",
NormalizeMessage(result.Detail ?? result.Message)));

private static ClawCtlJsonDocument FromOpen(OpenCommandResult result) =>
new(
result.ExitCode == 0,
SchemaVersion,
result.Command,
Gateway: result.State is { } state
? new ClawCtlJsonGateway(DescribeGateway(state))
: null,
Error: result.ExitCode == 0
? null
: new ClawCtlJsonError("cli_error", NormalizeMessage(result.Message)));

private static void Write(TextWriter output, ClawCtlJsonDocument document) =>
output.WriteLine(JsonSerializer.Serialize(
document,
Expand Down
8 changes: 8 additions & 0 deletions src/OpenClaw.Launcher/ClawCtlResults.cs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,14 @@ internal sealed record TeardownCommandResult(
public int ExitCode => Teardown.Succeeded ? 0 : 1;
}

internal sealed record OpenCommandResult(
GatewayState? State,
string Message,
int ExitCode) : IClawCtlResult
{
public string Command => "open";
}

internal sealed record GatewayCommandResult(
string Action,
GatewayState State,
Expand Down
43 changes: 43 additions & 0 deletions src/OpenClaw.Launcher/Gateway/ControlUiHandoff.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
using System.Text.Json;
using System.Text.Json.Serialization;

namespace OpenClaw.Launcher.Gateway;

internal sealed record ControlUiHandoff(bool Ok, string? BrowserUrl);

[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
[JsonSerializable(typeof(ControlUiHandoff))]
internal sealed partial class ControlUiHandoffJsonContext : JsonSerializerContext;

internal static class ControlUiHandoffParser
{
internal static bool TryParse(
string output,
IReadOnlyCollection<int> observedPorts,
out string? browserUrl)
{
browserUrl = null;
try
{
ControlUiHandoff? handoff = JsonSerializer.Deserialize(
output,
ControlUiHandoffJsonContext.Default.ControlUiHandoff);
if (handoff is null || !handoff.Ok ||
string.IsNullOrWhiteSpace(handoff.BrowserUrl) ||
!Uri.TryCreate(handoff.BrowserUrl, UriKind.Absolute, out Uri? candidate) ||
(candidate.Scheme != Uri.UriSchemeHttp && candidate.Scheme != Uri.UriSchemeHttps) ||
!candidate.IsLoopback ||
!observedPorts.Contains(candidate.Port))
{
return false;
}

browserUrl = handoff.BrowserUrl;
return true;
}
catch (JsonException)
{
return false;
}
}
}
Loading
Loading