Skip to content
28 changes: 23 additions & 5 deletions src/OpenClaw.Launcher/ClawCtlCommandLine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,21 @@

namespace OpenClaw.Launcher;

internal sealed record ClawCtlHandlers
{
public required Func<CancellationToken, Task<int>> Setup { get; init; }
public required Func<CancellationToken, Task<int>> Status { get; init; }
public required Func<bool, CancellationToken, Task<int>> Teardown { get; init; }
}

// The clawctl command tree. Only the package-readiness surface belongs here:
// doctor, gateway, uninstall, and every other OpenClaw command is owned by the
// bundled CLI and reached through `openclaw`, which forwards its arguments
// without parsing them.
internal static class ClawCtlCommandLine
{
public const string SetupCommandName = "setup";
public const string StatusCommandName = "status";

// Response-file expansion is off. A leading `@` means nothing to clawctl,
// so it is reported as an unrecognized argument instead of silently reading
Expand All @@ -24,10 +32,10 @@ internal static class ClawCtlCommandLine

// Setup guidance is available without preparing the runtime.
public static string RootDescription =>
"Prepare the bundled Node.js runtime and verify the packaged OpenClaw application." +
"Set up and manage the packaged OpenClaw application." +
Environment.NewLine +
Environment.NewLine +
"Run `clawctl setup` to extract or repair the bundled runtime." +
"Run `clawctl setup` to prepare the bundled runtime and isolated session." +
Environment.NewLine +
Environment.NewLine +
"Run `openclaw <arguments>` to invoke the OpenClaw CLI.";
Expand All @@ -38,14 +46,24 @@ internal static class ClawCtlCommandLine

// runSetup stays a delegate so the command tree owns parsing and help while
// Program keeps the readiness operation and its test seams.
public static RootCommand Create(Func<CancellationToken, Task<int>> runSetup)
public static RootCommand Create(ClawCtlHandlers handlers)
{
ArgumentNullException.ThrowIfNull(handlers);
Command setup = new(SetupCommandName, SetupDescription);
setup.SetAction((_, cancellationToken) => runSetup(cancellationToken));
setup.SetAction((_, cancellationToken) => handlers.Setup(cancellationToken));
Command status = new(StatusCommandName, "Show the recorded isolated session without changing it.");
status.SetAction((_, cancellationToken) => handlers.Status(cancellationToken));
Option<bool> force = 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.SetAction((parsed, cancellationToken) =>
handlers.Teardown(parsed.GetValue(force), cancellationToken));

RootCommand root = new(RootDescription)
{
setup
setup,
status,
teardown
};

// Bare `clawctl` is a discovery request, not a usage error, so the root
Expand Down
165 changes: 165 additions & 0 deletions src/OpenClaw.Launcher/HostPaths.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
using System.Runtime.InteropServices;

namespace OpenClaw.Launcher;

/// <summary>
/// The running process's MSIX package identity.
/// </summary>
internal static class PackageIdentity
{
private const int ErrorInsufficientBuffer = 122;
private const int AppModelErrorNoPackage = 15700;

/// <summary>
/// Prefix required by MXC when a packaged caller provisions a sandbox.
/// </summary>
public const string ApplicationIdPrefix = "PFN:";

/// <summary>
/// The package family name, or null when running unpackaged.
/// </summary>
/// <remarks>
/// Unpackaged is a normal development configuration, not an error, so it
/// is reported as null rather than thrown.
/// </remarks>
public static string? TryGetPackageFamilyName()
{
if (!OperatingSystem.IsWindows())
{
return null;
}

uint length = 0;
int result = GetCurrentPackageFamilyName(ref length, null);
if (result == AppModelErrorNoPackage)
{
return null;
}

if (result != ErrorInsufficientBuffer || length == 0)
{
throw new InvalidOperationException(
$"Unable to determine package identity (error {result}).");
}

var value = new char[length];
result = GetCurrentPackageFamilyName(ref length, value);
if (result != 0)
{
throw new InvalidOperationException(
$"Unable to determine package identity (error {result}).");
}

return new string(value, 0, checked((int)length - 1));
}

/// <summary>
/// Builds the MXC application id for a package family name.
/// </summary>
/// <remarks>
/// The backend fixes this value for the sandbox lifetime, so it must be the
/// real family name of the calling package. This package and the internal
/// MSIX therefore never share a session.
/// </remarks>
public static string ToApplicationId(string packageFamilyName) =>
ApplicationIdPrefix + packageFamilyName;

[DllImport("kernel32.dll", CharSet = CharSet.Unicode)]
private static extern int GetCurrentPackageFamilyName(
ref uint packageFamilyNameLength,
[Out, MarshalAs(UnmanagedType.LPArray, SizeParamIndex = 0)]
char[]? packageFamilyName);
}

/// <summary>
/// Where this installation keeps its own writable state.
/// </summary>
/// <remarks>
/// Every writable path is derived here so nothing duplicates the packaged
/// versus unpackaged decision, and so tests can redirect the root instead of
/// touching real profile state.
/// </remarks>
internal sealed class HostPaths
{
public const string UnpackagedDirectoryName = "OpenClawGatewayMSIX";

private HostPaths(string stateRoot, string? packageFamilyName)
{
StateRoot = stateRoot;
PackageFamilyName = packageFamilyName;
}

public string StateRoot { get; }

public string? PackageFamilyName { get; }

public string LogPath => Path.Combine(StateRoot, "Logs", "openclaw.log");

public string SessionStatePath => Path.Combine(StateRoot, "session.json");

/// <summary>
/// The marker written only after explicit setup completes all of its
/// session, launch-configuration, and sign-in-recovery steps.
/// </summary>
public string SetupStatePath => Path.Combine(StateRoot, "setup.json");

/// <summary>
/// The script the logon task runs.
/// </summary>
/// <remarks>
/// The task deliberately does not invoke the app alias directly. This file
/// is rewritten without elevation on every install, whereas changing the
/// task's own arguments requires re-registering it. Routing through it
/// keeps the launch command free to change, and keeps the Startup-folder
/// lane calling the same single definition instead of a second one that
/// can drift.
/// </remarks>
public string GatewayLauncherPath =>
Path.Combine(StateRoot, "gateway-launcher.cmd");

/// <summary>
/// Where the recorded gateway process and its persistence choices live.
/// </summary>
public string GatewayStatePath => Path.Combine(StateRoot, "gateway.json");

/// <summary>
/// The gateway's launch configuration, kept separate from its recorded
/// process so that stopping the gateway never discards the user's port.
/// </summary>
public string GatewayConfigurationPath =>
Path.Combine(StateRoot, "gateway-config.json");

public static HostPaths Create() =>
Create(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
PackageIdentity.TryGetPackageFamilyName());

internal static HostPaths Create(string localAppData, string? packageFamilyName)
{
if (string.IsNullOrWhiteSpace(localAppData))
{
throw new InvalidOperationException(
"The local application data directory is unavailable.");
}

// A packaged process writes inside its own LocalState so the state is
// removed with the package and cannot collide with another
// installation's.
string stateRoot = packageFamilyName is null
? Path.Combine(localAppData, UnpackagedDirectoryName)
: Path.Combine(
localAppData,
"Packages",
packageFamilyName,
"LocalState",
UnpackagedDirectoryName);

return new HostPaths(stateRoot, packageFamilyName);
}

/// <summary>
/// Builds paths rooted at an arbitrary directory, for tests and diagnosis.
/// </summary>
internal static HostPaths ForRoot(string stateRoot, string? packageFamilyName = null) =>
new(Path.GetFullPath(stateRoot), packageFamilyName);
}
2 changes: 2 additions & 0 deletions src/OpenClaw.Launcher/HostStartup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ internal sealed class HostStartup

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

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

public static HostStartup CreateProduction() => new()
{
Entrypoint = HostEntrypointResolver.Resolve(),
Expand Down
81 changes: 81 additions & 0 deletions src/OpenClaw.Launcher/Mxc/MxcSessionContracts.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;

namespace OpenClaw.Launcher.Mxc;

internal sealed record MxcBackendProbe(
Expand Down Expand Up @@ -65,6 +68,84 @@ public static MxcSandboxId Parse(string value)
return new MxcSandboxId(value, value[..separator]);
}

/// <summary>
/// Reports the application this identity was issued to, when the backend
/// encodes one.
/// </summary>
/// <remarks>
/// <para>
/// The value stays opaque to this package: it is replayed verbatim and is
/// never rebuilt from parts. This reads the owning application only so a
/// caller can refuse an identity that provably belongs to someone else
/// before asking the backend to act on it. A record naming this
/// installation does not establish that the identity inside it does.
/// </para>
/// <para>
/// False is returned whenever the payload cannot be read, including a
/// backend or format this package does not recognize. Absence of evidence
/// is not evidence of a foreign owner, and inventing a failure here would
/// strand every existing session the moment the backend changed its
/// encoding.
/// </para>
/// </remarks>
public bool TryGetOwningApplicationId([NotNullWhen(true)] out string? applicationId)
{
applicationId = null;
if (!IsIsolationSession)
{
return false;
}

int separator = Value.IndexOf(':', StringComparison.Ordinal);
string payload = Value[(separator + 1)..];
if (payload.Length == 0)
{
return false;
}

// The payload is base64url without padding.
string normalized = payload.Replace('-', '+').Replace('_', '/');
normalized = (normalized.Length % 4) switch
{
2 => normalized + "==",
3 => normalized + "=",
0 => normalized,
_ => string.Empty
};
if (normalized.Length == 0)
{
return false;
}

byte[] decoded;
try
{
decoded = Convert.FromBase64String(normalized);
}
catch (FormatException)
{
return false;
}

try
{
using JsonDocument document = JsonDocument.Parse(decoded);
if (document.RootElement.ValueKind != JsonValueKind.Object ||
!document.RootElement.TryGetProperty("appId", out JsonElement appId) ||
appId.ValueKind != JsonValueKind.String)
{
return false;
}

applicationId = appId.GetString();
return !string.IsNullOrWhiteSpace(applicationId);
}
catch (JsonException)
{
return false;
}
}

public override string ToString() => Value;
}

Expand Down
4 changes: 4 additions & 0 deletions src/OpenClaw.Launcher/OpenClaw.Launcher.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@
PrivateAssets="all" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\OpenClaw.SessionProtocol\OpenClaw.SessionProtocol.csproj" />
</ItemGroup>

<ItemGroup>
<AppxManifest Include="Package.appxmanifest">
<SubType>Designer</SubType>
Expand Down
12 changes: 9 additions & 3 deletions src/OpenClaw.Launcher/OpenClawRuntimeEnvironment.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ internal static class OpenClawRuntimeEnvironment
public const string SupervisorModeVariable = "OPENCLAW_SUPERVISOR_MODE";
public const string ServiceRepairPolicyVariable = "OPENCLAW_SERVICE_REPAIR_POLICY";
public const string NoAutoUpdateVariable = "OPENCLAW_NO_AUTO_UPDATE";
public const string GatewayIsolationVariable = "CLAWCTL_GATEWAY_ISOLATION";

public const string ExternalValue = "external";
public const string NoAutoUpdateValue = "1";
Expand All @@ -30,21 +31,26 @@ internal static class OpenClawRuntimeEnvironment
/// <summary>
/// The variables to apply, as an ordinary dictionary.
/// </summary>
public static IReadOnlyDictionary<string, string> Build() =>
public static IReadOnlyDictionary<string, string> Build(
GatewayIsolationMode gatewayIsolationMode = GatewayIsolationMode.Disabled) =>
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
[SupervisorModeVariable] = ExternalValue,
[ServiceRepairPolicyVariable] = ExternalValue,
[NoAutoUpdateVariable] = NoAutoUpdateValue,
[GatewayIsolationVariable] = gatewayIsolationMode.ToEnvironmentValue(),
};

public static IReadOnlyDictionary<string, string> Build(
bool isInteractive,
Func<string, string?> readEnvironmentVariable)
Func<string, string?> readEnvironmentVariable,
GatewayIsolationMode gatewayIsolationMode = GatewayIsolationMode.Disabled)
{
ArgumentNullException.ThrowIfNull(readEnvironmentVariable);

Dictionary<string, string> result = new(Build(), StringComparer.OrdinalIgnoreCase);
Dictionary<string, string> result = new(
Build(gatewayIsolationMode),
StringComparer.OrdinalIgnoreCase);
string? forceColor = readEnvironmentVariable(ForceColorVariable);
string? wtSession = readEnvironmentVariable(WindowsTerminalSessionVariable);
string? noColor = readEnvironmentVariable(NoColorVariable);
Expand Down
Loading
Loading