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
11 changes: 6 additions & 5 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,11 +201,12 @@ bypassable, and required CI checks remain authoritative.
dependency packages that carry native artifacts into agent LocalState and
redirects resolution to them. That set is discovered by scanning, never
hard-coded, and everything else keeps executing from the package. The agent
account owns its own `PATH` and `NODE_OPTIONS`: name the directory or the
option in the launch request and let the guest compose them, because
host-supplied environment values are assigned over the agent's. Reclaim a
staged root only when nothing is running from it; every launch holds its root
for its lifetime, and a held root is left whole for a later setup.
account owns its own `PATH` and `NODE_OPTIONS`: name the runtime directory in
the launch request, place the preload on Node's argument vector, and let the
preload append itself to the agent's options for child processes. Do not
assign the invoking host's values over the agent's. Reclaim a staged root
only when nothing is running from it; every launch holds its root for its
lifetime, and a held root is left whole for a later setup.
- Keep x64 and ARM64 behavior synchronized across the workflow matrix, scripts,
project runtime identifiers, manifest content, and signing validation.
- Restore `src\OpenClaw.SessionHost\OpenClaw.SessionHost.csproj` separately
Expand Down
17 changes: 9 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -247,14 +247,15 @@ list, so an upstream revision that introduces a new native dependency is staged
automatically. Whole owning package directories are copied rather than
individual binaries, because a package locates its sibling libraries and helper
executables relative to its own directory. A packaged preload then redirects
both CommonJS and ESM resolution to the staged copies, delivered through
`NODE_OPTIONS` so that the Node.js workers OpenClaw starts inherit it. The
launcher names that preload rather than composing the variable, and the guest
appends it to the agent account's own `NODE_OPTIONS`, so an option the agent
set survives and the invoking host's value never reaches it. Staging is
idempotent, keyed by package content, and reclaims the superseded copy after an
upgrade once nothing is still running from it; a launch holds its root for its
whole lifetime, and a root that is still held is left whole for a later setup.
both CommonJS and ESM resolution to the staged copies. The launcher places that
preload on the agent Node.js argument vector so OpenClaw retains it when an
agent invokes `openclaw` again. Once loaded, the preload appends itself to the
agent account's own `NODE_OPTIONS` for ordinary Node.js workers, so an option
the agent set survives and the invoking host's value never reaches it. Staging
is idempotent, keyed by package content, and reclaims the superseded copy after
an upgrade once nothing is still running from it; a launch holds its root for
its whole lifetime, and a root that is still held is left whole for a later
setup.

Run setup before using `openclaw`, `clawctl pwsh`, or gateway-service start.
There is no session-free mode: `openclaw` runs inside the session recorded by
Expand Down
2 changes: 1 addition & 1 deletion src/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ Read the repository-root `AGENTS.md` before this file. This scope owns the three
- `openclaw` arguments belong to upstream. Forward the original vector unchanged and keep System.CommandLine scoped to `clawctl`.
- Sessions are mandatory and explicit. `clawctl setup` provisions and records one; `openclaw` starts only the recorded session and never provisions implicitly.
- Keep packaged `app\openclaw.mjs` immutable and execute it from the package inside the session. Install Node.js in the agent profile and prepend that runtime to the agent process path. Mirror only packages discovered to carry `.node`, `.dll`, or `.exe` artifacts into agent LocalState, preserving each owning package directory; never hard-code the package set.
- The launch request names the staged native root and preload option. The session host composes them with the agent account's `PATH` and `NODE_OPTIONS`; invoking-host values must not leak into the guest.
- The launch request names the staged native root and places the preload on the agent Node.js argument vector so OpenClaw's reconstructed agent CLI retains it. The preload appends itself to the agent account's `NODE_OPTIONS` for ordinary child processes; invoking-host values must not leak into the guest.
- Every launch using a staged native root holds it for its entire lifetime. Leave a held superseded root intact for a later setup to reclaim; a successful rename or delete is not proof that no process is using it.
- Stage the session helper into the shared workspace during setup; the agent cannot execute it in place from another package identity's WindowsApps directory.
- Preserve caller working directory and package-qualified entrypoint resolution. Unrecognized entrypoint names fall back only as documented and tested.
Expand Down
10 changes: 4 additions & 6 deletions src/OpenClaw.Launcher/Gateway/GatewayRuntime.cs
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ Task<GatewayStartRequest> CreateRequestAsync(CancellationToken cancellationToken
session.Backend,
log,
buildEnvironment: BuildGatewayEnvironment,
buildNodeOptionsSuffix: BuildGatewayNodeOptionsSuffix,
buildNodeArgumentsPrefix: BuildGatewayNodeArgumentsPrefix,
getNativeRootPath: session.GetAgentNativeRoot,
isCurrentRecord: IsCurrentSessionRecord),
session.GatewayState,
Expand Down Expand Up @@ -240,13 +240,11 @@ IReadOnlyDictionary<string, string> BuildGatewayEnvironment()
nativeRoot));
}

// The preload is named, not merged into the environment above: the
// agent's NODE_OPTIONS is the agent's, and this process's is the
// invoking host's.
string? BuildGatewayNodeOptionsSuffix() =>
// Runtime arguments survive OpenClaw's reconstructed agent CLI.
IReadOnlyList<string>? BuildGatewayNodeArgumentsPrefix() =>
options.PackagedApplicationDirectory is { Length: > 0 } &&
session.GetAgentNativeRoot() is { Length: > 0 }
? OpenClawRuntimeEnvironment.BuildNativeRedirectNodeOption(
? OpenClawRuntimeEnvironment.BuildNativeRedirectNodeArguments(
Program.ResolveNativeRedirectPreloadPath())
: null;

Expand Down
16 changes: 11 additions & 5 deletions src/OpenClaw.Launcher/Gateway/SessionGatewayClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -69,13 +69,13 @@ internal sealed class SessionGatewayClient : ISessionGatewayClient
private readonly IMxcSessionClient _backend;
private readonly Action<string> _log;
private readonly Func<IReadOnlyDictionary<string, string>> _buildEnvironment;
private readonly Func<string?> _buildNodeOptionsSuffix;
private readonly Func<IReadOnlyList<string>?> _buildNodeArgumentsPrefix;
private readonly Func<string?> _getNativeRootPath;
private readonly Func<SessionRecord, bool> _isCurrentRecord;

public SessionGatewayClient(IMxcSessionClient backend, Action<string> log,
Func<IReadOnlyDictionary<string, string>>? buildEnvironment = null,
Func<string?>? buildNodeOptionsSuffix = null,
Func<IReadOnlyList<string>?>? buildNodeArgumentsPrefix = null,
Func<string?>? getNativeRootPath = null,
Func<SessionRecord, bool>? isCurrentRecord = null)
{
Expand All @@ -85,7 +85,7 @@ public SessionGatewayClient(IMxcSessionClient backend, Action<string> log,
_log = log;
_buildEnvironment = buildEnvironment ??
OpenClawRuntimeEnvironment.Build;
_buildNodeOptionsSuffix = buildNodeOptionsSuffix ?? (() => null);
_buildNodeArgumentsPrefix = buildNodeArgumentsPrefix ?? (() => null);
_getNativeRootPath = getNativeRootPath ?? (() => null);
_isCurrentRecord = isCurrentRecord ?? (_ => true);
}
Expand Down Expand Up @@ -118,7 +118,14 @@ public async Task<GatewayStartOutcome> StartAsync(
// --port is added only when the user pinned one. Passing a port always
// would outrank `gateway.port` in OpenClaw's own configuration and
// silently move the gateway away from where its clients look.
List<string> arguments = [entryPoint, "gateway", "run"];
var arguments = new List<string>();
if (_buildNodeArgumentsPrefix() is { } nodeArgumentsPrefix)
{
arguments.AddRange(nodeArgumentsPrefix);
}
arguments.Add(entryPoint);
arguments.Add("gateway");
arguments.Add("run");
if (request.Port is int port)
{
arguments.Add("--port");
Expand All @@ -136,7 +143,6 @@ public async Task<GatewayStartOutcome> StartAsync(
PathPrefix = Path.GetDirectoryName(request.NodePath)
?? throw new SessionException(
"The agent's Node.js runtime has no parent directory."),
NodeOptionsSuffix = _buildNodeOptionsSuffix(),
NativeRootPath = _getNativeRootPath(),
LogPath = logPath,
StatusPath = statusPath
Expand Down
27 changes: 10 additions & 17 deletions src/OpenClaw.Launcher/OpenClawRuntimeEnvironment.cs
Original file line number Diff line number Diff line change
Expand Up @@ -129,9 +129,10 @@ public static IReadOnlyDictionary<string, string> Build(
/// staged native dependency packages.
/// </summary>
/// <remarks>
/// The preload that reads them is not here. It belongs in
/// <c>NODE_OPTIONS</c>, which the agent account owns; see
/// <see cref="BuildNativeRedirectNodeOption"/>.
/// The preload that reads them is not here. The launcher places it on the
/// Node.js argument vector so OpenClaw's reconstructed agent CLI retains
/// the runtime hook; the preload then propagates itself to ordinary child
/// processes through the agent-owned <c>NODE_OPTIONS</c>.
/// </remarks>
public static IReadOnlyDictionary<string, string> BuildNativeRedirect(
string applicationDirectory,
Expand All @@ -148,32 +149,24 @@ public static IReadOnlyDictionary<string, string> BuildNativeRedirect(
}

/// <summary>
/// Builds the Node.js option that loads the native dependency redirect.
/// Builds the Node.js arguments that load the native dependency redirect.
/// </summary>
/// <remarks>
/// <para>
/// Delivered through <c>NODE_OPTIONS</c> rather than the command line
/// because OpenClaw starts its own Node.js workers and child services,
/// which inherit the environment but not this process's arguments. Those
/// children load the same native addons, so the redirect has to reach them
/// too.
/// </para>
/// <para>
/// Only the option is produced here. Appending it to an existing
/// <c>NODE_OPTIONS</c> happens where the agent's process environment is
/// built, because this process's own <c>NODE_OPTIONS</c> belongs to the
/// invoking host and says nothing about the agent's.
/// OpenClaw reconstructs its current Node.js invocation when an agent calls
/// <c>openclaw</c>. Runtime arguments survive that reconstruction, whereas
/// relying only on ambient <c>NODE_OPTIONS</c> does not.
/// </para>
/// <para>
/// The preload is named as a percent-encoded file URL, which keeps the
/// space in "Program Files" out of the option string.
/// </para>
/// </remarks>
public static string BuildNativeRedirectNodeOption(string preloadPath)
public static IReadOnlyList<string> BuildNativeRedirectNodeArguments(string preloadPath)
{
ArgumentException.ThrowIfNullOrWhiteSpace(preloadPath);

return $"--import {new Uri(preloadPath).AbsoluteUri}";
return ["--import", new Uri(preloadPath).AbsoluteUri];
}

/// <summary>
Expand Down
43 changes: 27 additions & 16 deletions src/OpenClaw.Launcher/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -326,7 +326,7 @@ await runtime.StartForExecutionAsync(CancellationToken.None)
applicationDirectory,
interactive,
environmentReader),
NodeOptionsSuffix = BuildNativeRedirectNodeOption(runtime),
NodeArgumentsPrefix = BuildNativeRedirectNodeArguments(runtime),
NativeRootPath = runtime.GetAgentNativeRoot()
},
CancellationToken.None).ConfigureAwait(false);
Expand Down Expand Up @@ -394,10 +394,11 @@ await guidance.EvaluateAsync(
/// account owns.
/// </summary>
/// <remarks>
/// Built in one place so every launch path - foreground, gateway, and the
/// agent's own shell - resolves native addons the same way. The redirect's
/// preload is not here: it belongs in the agent's <c>NODE_OPTIONS</c>, and
/// this process's own <c>NODE_OPTIONS</c> is the host's, not the agent's.
/// Built in one place so foreground and gateway launch paths resolve native
/// addons the same way. The agent shell carries equivalent values through
/// its command shim because agent tooling may replace process environment
/// values before invoking <c>openclaw</c>. The redirect's preload is not
/// here: it belongs on the agent Node.js argument vector.
/// </remarks>
private static IReadOnlyDictionary<string, string> BuildRuntimeEnvironment(
Session.SessionRuntime runtime,
Expand All @@ -420,12 +421,13 @@ private static IReadOnlyDictionary<string, string> BuildRuntimeEnvironment(
}

/// <summary>
/// The Node.js option that loads the redirect, or <see langword="null"/>
/// The Node.js arguments that load the redirect, or <see langword="null"/>
/// when setup staged nothing to redirect to.
/// </summary>
private static string? BuildNativeRedirectNodeOption(Session.SessionRuntime runtime) =>
private static IReadOnlyList<string>? BuildNativeRedirectNodeArguments(
Session.SessionRuntime runtime) =>
runtime.GetAgentNativeRoot() is { Length: > 0 }
? OpenClawRuntimeEnvironment.BuildNativeRedirectNodeOption(
? OpenClawRuntimeEnvironment.BuildNativeRedirectNodeArguments(
ResolveNativeRedirectPreloadPath())
: null;

Expand Down Expand Up @@ -663,12 +665,16 @@ await Gateway.GatewayRuntime.Create(
new Session.SessionCommandRequest(
runtime.RequireStagedHelper(record),
nodePath,
[Path.Combine(applicationDirectory, "openclaw.mjs"), "dashboard", "--json"],
[
.. BuildNativeRedirectNodeArguments(runtime) ?? [],
Path.Combine(applicationDirectory, "openclaw.mjs"),
"dashboard",
"--json"
],
record.WorkspacePath!)
{
PathPrefix = Path.GetDirectoryName(nodePath),
AdditionalEnvironment = dashboardEnvironment,
NodeOptionsSuffix = BuildNativeRedirectNodeOption(runtime),
NativeRootPath = runtime.GetAgentNativeRoot()
},
"Resolving the Control UI handoff in the isolated session.",
Expand Down Expand Up @@ -1251,6 +1257,10 @@ record = await runtime.Coordinator.StartRecordedAsync(cancellationToken)
"The installed agent command shim has no parent directory."),
installedTools.ShimPath!);
Session.AgentShell shell = Session.AgentShellResolver.Resolve(File.Exists);
string? nativeRootPath = runtime.GetAgentNativeRoot();
string? nativePreloadUrl = nativeRootPath is { Length: > 0 }
? new Uri(ResolveNativeRedirectPreloadPath()).AbsoluteUri
: null;

return await runtime.Executor.ExecuteCommandAsync(
record,
Expand All @@ -1265,14 +1275,15 @@ record = await runtime.Coordinator.StartRecordedAsync(cancellationToken)
record.WorkspacePath!)
{
AdditionalEnvironment = Session.SessionExecutor.MergeEnvironment(
BuildRuntimeEnvironment(
runtime,
applicationDirectory,
OpenClawRuntimeEnvironment.Build(
WindowsHostConsole.Instance.IsInteractive,
Environment.GetEnvironmentVariable),
Session.AgentToolShim.BuildEnvironment(agentNodePath, applicationDirectory)),
NodeOptionsSuffix = BuildNativeRedirectNodeOption(runtime),
NativeRootPath = runtime.GetAgentNativeRoot()
Session.AgentToolShim.BuildEnvironment(
agentNodePath,
applicationDirectory,
nativeRootPath,
nativePreloadUrl)),
NativeRootPath = nativeRootPath
},
$"Opening {shell.DisplayName} in the isolated session.",
shell.DisplayName,
Expand Down
29 changes: 27 additions & 2 deletions src/OpenClaw.Launcher/Session/AgentToolShim.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,43 @@ internal static class AgentToolShim
{
internal const string NodeVariable = "OPENCLAW_SHIM_NODE";
internal const string EntryPointVariable = "OPENCLAW_SHIM_ENTRY";
internal const string NativeApplicationRootVariable =
"OPENCLAW_SHIM_NATIVE_APP_ROOT";
internal const string NativeStagedRootVariable =
"OPENCLAW_SHIM_NATIVE_STAGED_ROOT";
internal const string NativePreloadUrlVariable =
"OPENCLAW_SHIM_NATIVE_PRELOAD_URL";

public static IReadOnlyDictionary<string, string> BuildEnvironment(
string nodePath,
string applicationDirectory)
string applicationDirectory,
string? nativeRootPath = null,
string? nativePreloadUrl = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(nodePath);
ArgumentException.ThrowIfNullOrWhiteSpace(applicationDirectory);

return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
var environment = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
[NodeVariable] = nodePath,
[EntryPointVariable] = Path.Combine(applicationDirectory, "openclaw.mjs"),
};

if (string.IsNullOrWhiteSpace(nativeRootPath))
{
return environment;
}

if (string.IsNullOrWhiteSpace(nativePreloadUrl))
{
throw new ArgumentException(
"A native redirect preload is required with a staged native root.",
nameof(nativePreloadUrl));
}

environment[NativeApplicationRootVariable] = applicationDirectory;
environment[NativeStagedRootVariable] = nativeRootPath;
environment[NativePreloadUrlVariable] = nativePreloadUrl;
return environment;
}
}
11 changes: 10 additions & 1 deletion src/OpenClaw.Launcher/Session/SessionExecutor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ internal sealed record SessionExecutionRequest(
/// </summary>
public IReadOnlyDictionary<string, string>? AdditionalEnvironment { get; init; }

/// <summary>Node.js runtime arguments placed before the application entrypoint.</summary>
public IReadOnlyList<string>? NodeArgumentsPrefix { get; init; }

/// <summary>
/// Node.js options the agent appends to its own <c>NODE_OPTIONS</c>.
/// </summary>
Expand Down Expand Up @@ -599,7 +602,13 @@ private static List<string> BuildNodeArguments(
SessionExecutionRequest request)
{
string entryPoint = Path.Combine(request.ApplicationDirectory, "openclaw.mjs");
var arguments = new List<string>(request.Arguments.Count + 1) { entryPoint };
var arguments = new List<string>(
(request.NodeArgumentsPrefix?.Count ?? 0) + request.Arguments.Count + 1);
if (request.NodeArgumentsPrefix is not null)
{
arguments.AddRange(request.NodeArgumentsPrefix);
}
arguments.Add(entryPoint);
arguments.AddRange(request.Arguments);
return arguments;
}
Expand Down
8 changes: 8 additions & 0 deletions src/OpenClaw.Launcher/node/native-redirect.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,14 @@ const appRoot = process.env.OPENCLAW_NATIVE_APP_ROOT;
const stagedRoot = process.env.OPENCLAW_NATIVE_STAGED_ROOT;

if (appRoot && stagedRoot) {
const preloadOption = `--import ${import.meta.url}`;
const inheritedNodeOptions = process.env.NODE_OPTIONS;
if (!inheritedNodeOptions?.includes(preloadOption)) {
process.env.NODE_OPTIONS = inheritedNodeOptions?.trim()
? `${inheritedNodeOptions} ${preloadOption}`
: preloadOption;
}

const from = join(appRoot, "node_modules") + sep;
const to = join(stagedRoot, "node_modules") + sep;

Expand Down
Loading
Loading