feat: package npm runtime plugins for Nix

Add a hash-backed npm runtime plugin path that lowers OpenClaw-style npm sources into immutable plugin roots and wires them through the existing Home Manager plugin resolver. Keep flake-backed customPlugins unchanged and document the boundary for agents and maintainers.

Tests: nix build .#checks.aarch64-darwin.default-instance --no-link; nix flake check --no-build; git diff --check

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
joshp123 2026-05-08 18:29:45 +08:00
parent 30002b7ded
commit 11d69d8a1c
9 changed files with 301 additions and 59 deletions

View File

@ -49,7 +49,7 @@ Source: https://github.com/orgs/openclaw/people
- Never add internal ExecPlans or agent scratch history to this repo. `.agent/` is ignored for this reason. - Never add internal ExecPlans or agent scratch history to this repo. `.agent/` is ignored for this reason.
- If a private deployment exposes a public packaging bug, fix the public package here and keep deployment-specific repair elsewhere. - If a private deployment exposes a public packaging bug, fix the public package here and keep deployment-specific repair elsewhere.
- OpenClaw plugin loading belongs here: package curated runtime plugin roots as Nix artifacts, expose curated outputs through package/check outputs for Garnix, and let host repos only enable/configure them. - OpenClaw plugin loading belongs here: package curated runtime plugin roots as Nix artifacts, expose curated outputs through package/check outputs for Garnix, and let host repos only enable/configure them.
- Do not make host config run npm/ClawHub installs at runtime for the batteries-included path. Arbitrary plugin specs need a lock/hash-backed Nix derivation so Nix caches them locally or in the user's configured cache. - Do not make host config run npm/ClawHub installs at runtime for the batteries-included path. `customPlugins.source = "npm:..."` is allowed only when nix-openclaw turns it into an immutable, hash-backed store path and wires it through OpenClaw's normal `plugins.load.paths`.
## Packaging Defaults ## Packaging Defaults

View File

@ -320,6 +320,23 @@ customPlugins = [
Then run `home-manager switch` to install. Then run `home-manager switch` to install.
For an OpenClaw native plugin published to npm, keep the source shape close to
OpenClaw's own install command and let Nix build the immutable plugin root:
```nix
customPlugins = [
{
source = "npm:@scope/openclaw-plugin@1.2.3";
id = "openclaw-plugin";
hash = lib.fakeHash; # replace with the sha256 Nix reports
}
];
```
Use this for OpenClaw runtime plugins with `openclaw.plugin.json` /
`package.json.openclaw`. It does not run npm at gateway startup; Nix builds and
caches the plugin root, then adds it to OpenClaw's `plugins.load.paths`.
### Plugins with configuration ### Plugins with configuration
Some plugins need settings (auth files, preferences). Here's a simplified example: Some plugins need settings (auth files, preferences). Here's a simplified example:

View File

@ -73,7 +73,25 @@ programs.openclaw.customPlugins = [
Do not add raw npm package names to host config for the batteries-included path. Curated plugins packaged by this repo or `nix-openclaw-tools` should be exposed through package/check outputs so Garnix caches them. Do not add raw npm package names to host config for the batteries-included path. Curated plugins packaged by this repo or `nix-openclaw-tools` should be exposed through package/check outputs so Garnix caches them.
Arbitrary user plugins are a separate product surface. A future config like `plugins = [ "scope/plugin@npm:1.2.3" ]` must resolve through a lock/hash-backed npm/ClawHub builder that produces a normal Nix store path. That means our Garnix does not promise to cache every user plugin, but the user's machine also does not rebuild it on every run: Nix reuses the local store or configured binary cache until the spec, lock, or hash changes. OpenClaw must not reinstall it on every gateway start. OpenClaw native npm plugins use the same host list with an OpenClaw-style source:
```nix
programs.openclaw.customPlugins = [
{
source = "npm:@scope/openclaw-plugin@1.2.3";
id = "openclaw-plugin";
hash = lib.fakeHash; # replace with the sha256 Nix reports
}
];
```
- `source`: currently supports registry npm specs with an explicit `npm:` prefix.
- `id`: required because the Home Manager module must enable the plugin at eval time without importing the built JavaScript package.
- `hash`: recursive output hash for the immutable plugin root; leave as `lib.fakeHash` to have Nix report the expected hash, then commit that value.
- Runtime plugin config belongs in `programs.openclaw.config.plugins.entries.<id>.config`, not in `customPlugins.config`.
- The module adds the built root to `plugins.load.paths` and writes a default `plugins.entries.<id>.enabled` value. OpenClaw owns runtime loading after that.
Curated npm plugins can be added to this repo or `nix-openclaw-tools` so Garnix caches them. Arbitrary user npm specs are still deterministic Nix artifacts, but this repo's cache cannot cover every user's private plugin choice. The user's local store or configured binary cache reuses the artifact until the source or hash changes. OpenClaw must not reinstall it on every gateway start.
## Dev workflow (fast iteration) ## Dev workflow (fast iteration)
- Worktree: build and test plugins outside the core repo; point OpenClaw at a local path source during impure local dev (e.g., `source = "path:/Users/you/code/my-plugin"`). Committed config uses pinned refs. - Worktree: build and test plugins outside the core repo; point OpenClaw at a local path source during impure local dev (e.g., `source = "path:/Users/you/code/my-plugin"`). Committed config uses pinned refs.

View File

@ -319,6 +319,37 @@ let
throw "User config could not override OpenClaw plugin enabled=false default." throw "User config could not override OpenClaw plugin enabled=false default."
); );
npmRuntimePluginEval = moduleEval {
customPlugins = [
{
source = "npm:@tencent-weixin/openclaw-weixin@2.4.2";
id = "openclaw-weixin";
hash = lib.fakeHash;
}
];
};
npmRuntimePluginConfig = builtins.fromJSON (
builtins.unsafeDiscardStringContext
npmRuntimePluginEval.config.home.file.".openclaw/openclaw.json".text
);
npmRuntimePluginLoadPaths = ((npmRuntimePluginConfig.plugins or { }).load or { }).paths or [ ];
npmRuntimePluginEntry =
((npmRuntimePluginConfig.plugins or { }).entries or { }).openclaw-weixin or { };
npmRuntimePluginCheck =
builtins.deepSeq (requireNoAssertionFailures "npm OpenClaw runtime plugin" npmRuntimePluginEval)
(
if
!(lib.any (
path: lib.hasInfix "openclaw-runtime-plugin-openclaw-weixin" path
) npmRuntimePluginLoadPaths)
then
throw "npm OpenClaw runtime plugin root was not added to plugins.load.paths."
else if (npmRuntimePluginEntry.enabled or false) != true then
throw "npm OpenClaw runtime plugin entry default was not enabled."
else
"ok"
);
checkKey = builtins.deepSeq [ checkKey = builtins.deepSeq [
defaultCheck defaultCheck
customPluginCheck customPluginCheck
@ -330,6 +361,7 @@ let
openclawPluginCheck openclawPluginCheck
openclawPluginOverrideCheck openclawPluginOverrideCheck
openclawPluginEnableOverrideCheck openclawPluginEnableOverrideCheck
npmRuntimePluginCheck
] "ok"; ] "ok";
in in

View File

@ -0,0 +1,48 @@
{
lib,
stdenvNoCC,
nodejs_22,
}:
{
id,
source,
hash ? lib.fakeHash,
}:
let
npmSpec =
if lib.hasPrefix "npm:" source then
lib.removePrefix "npm:" source
else
throw "OpenClaw runtime npm plugin source must start with `npm:`: ${source}";
safeName = lib.replaceStrings [ "@" "/" ":" ] [ "" "-" "-" ] id;
in
stdenvNoCC.mkDerivation {
pname = "openclaw-runtime-plugin-${safeName}";
version = "1";
nativeBuildInputs = [ nodejs_22 ];
dontUnpack = true;
dontConfigure = true;
dontBuild = true;
outputHashMode = "recursive";
outputHashAlgo = "sha256";
outputHash = hash;
env = {
OPENCLAW_RUNTIME_PLUGIN_ID = id;
OPENCLAW_RUNTIME_PLUGIN_NPM_SPEC = npmSpec;
};
installPhase = "${../scripts/npm-runtime-plugin-install.sh}";
meta = with lib; {
description = "Nix-packaged OpenClaw runtime plugin ${id} from ${source}";
homepage = "https://github.com/openclaw/openclaw";
license = licenses.mit;
platforms = platforms.darwin ++ platforms.linux;
};
}

View File

@ -1,4 +1,8 @@
{ lib, openclawLib }: {
lib,
openclawLib,
pluginOptionType,
}:
{ name, config, ... }: { name, config, ... }:
{ {
@ -78,21 +82,7 @@
}; };
plugins = lib.mkOption { plugins = lib.mkOption {
type = lib.types.listOf ( type = lib.types.listOf pluginOptionType;
lib.types.submodule {
options = {
source = lib.mkOption {
type = lib.types.str;
description = "Plugin flake source pointer (e.g., github:owner/repo or path:/...).";
};
config = lib.mkOption {
type = lib.types.attrs;
default = { };
description = "Plugin-specific configuration (env/files/etc).";
};
};
}
);
default = openclawLib.effectivePlugins; default = openclawLib.effectivePlugins;
description = "Plugins enabled for this instance (includes bundled plugin toggles)."; description = "Plugins enabled for this instance (includes bundled plugin toggles).";
}; };

View File

@ -7,7 +7,35 @@
let let
openclawLib = import ./lib.nix { inherit config lib pkgs; }; openclawLib = import ./lib.nix { inherit config lib pkgs; };
instanceModule = import ./options-instance.nix { inherit lib openclawLib; }; pluginOptionType = lib.types.submodule {
options = {
source = lib.mkOption {
type = lib.types.str;
description = "Plugin source. Use a plugin flake source (github:/path:) or an OpenClaw npm install source (npm:@scope/package@version).";
};
config = lib.mkOption {
type = lib.types.attrs;
default = { };
description = "Nix capability plugin configuration (env/files/etc). Runtime OpenClaw plugin config belongs under programs.openclaw.config.plugins.entries.<id>.config.";
};
id = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = "OpenClaw runtime plugin id. Required for npm: sources so Nix can enable the plugin without build-time introspection.";
};
enabled = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Default enabled state for an OpenClaw runtime plugin entry.";
};
hash = lib.mkOption {
type = lib.types.str;
default = lib.fakeHash;
description = "Recursive output hash for npm: runtime plugin sources. Use the hash Nix reports when this is left as lib.fakeHash.";
};
};
};
instanceModule = import ./options-instance.nix { inherit lib openclawLib pluginOptionType; };
pluginCatalog = import ./plugin-catalog.nix; pluginCatalog = import ./plugin-catalog.nix;
mkSkillOption = lib.types.submodule { mkSkillOption = lib.types.submodule {
options = { options = {
@ -132,23 +160,9 @@ in
}; };
customPlugins = lib.mkOption { customPlugins = lib.mkOption {
type = lib.types.listOf ( type = lib.types.listOf pluginOptionType;
lib.types.submodule {
options = {
source = lib.mkOption {
type = lib.types.str;
description = "Plugin flake source pointer (e.g., github:owner/repo or path:/...).";
};
config = lib.mkOption {
type = lib.types.attrs;
default = { };
description = "Plugin-specific configuration (env/files/etc).";
};
};
}
);
default = [ ]; default = [ ];
description = "Custom/community plugins (merged with bundled plugin toggles)."; description = "Custom/community plugins (merged with bundled plugin toggles). Flake sources provide Nix capability plugins; npm: sources provide OpenClaw runtime plugins.";
}; };
bundledPlugins = lib.mapAttrs (name: plugin: { bundledPlugins = lib.mapAttrs (name: plugin: {

View File

@ -8,10 +8,75 @@
let let
resolvePath = openclawLib.resolvePath; resolvePath = openclawLib.resolvePath;
toRelative = openclawLib.toRelative; toRelative = openclawLib.toRelative;
mkNpmRuntimePlugin = pkgs.callPackage ../../../lib/npm-runtime-plugin.nix { };
resolvePlugin = normalizeOpenClawPlugin =
pluginSource: name: entry:
let
id = entry.id or (throw "openclawPlugin ${name}: plugins entry missing id");
path = entry.path or (throw "openclawPlugin ${name}: plugins.${id} missing path");
enabled =
if entry ? enable && !(entry ? enabled) then
throw "openclawPlugin ${name}: plugins.${id}.enable is not supported; use enabled"
else if entry ? enabled then
if builtins.isBool entry.enabled then
entry.enabled
else
throw "openclawPlugin ${name}: plugins.${id}.enabled must be a boolean"
else
true;
in
{
inherit id path enabled;
source = pluginSource;
plugin = name;
};
resolveNpmRuntimePlugin =
plugin: plugin:
let let
id = plugin.id or (throw "OpenClaw npm runtime plugin ${plugin.source} requires id");
path = mkNpmRuntimePlugin {
inherit id;
source = plugin.source;
hash = plugin.hash or lib.fakeHash;
};
in
if (plugin.config or { }) != { } then
throw "OpenClaw npm runtime plugin ${plugin.source} must put runtime config under programs.openclaw.config.plugins.entries.${id}.config, not customPlugins.config"
else
{
source = plugin.source;
name = id;
skills = [ ];
packages = [ ];
plugins = [
{
inherit id path;
enabled = plugin.enabled or true;
source = plugin.source;
plugin = id;
}
];
needs = {
stateDirs = [ ];
requiredEnv = [ ];
};
config = { };
};
resolveFlakePlugin =
plugin:
let
_ =
if (plugin.id or null) != null then
throw "Plugin ${plugin.source}: id is only valid for npm: OpenClaw runtime plugin sources"
else if (plugin.hash or lib.fakeHash) != lib.fakeHash then
throw "Plugin ${plugin.source}: hash is only valid for npm: OpenClaw runtime plugin sources"
else if (plugin.enabled or true) != true then
throw "Plugin ${plugin.source}: enabled is only valid for npm: OpenClaw runtime plugin sources"
else
null;
system = pkgs.stdenv.hostPlatform.system; system = pkgs.stdenv.hostPlatform.system;
flake = builtins.getFlake plugin.source; flake = builtins.getFlake plugin.source;
openclawPluginRaw = openclawPluginRaw =
@ -28,34 +93,13 @@ let
openclawPlugin; openclawPlugin;
name = resolvedPlugin.name or (throw "openclawPlugin.name missing in ${plugin.source}"); name = resolvedPlugin.name or (throw "openclawPlugin.name missing in ${plugin.source}");
needs = resolvedPlugin.needs or { }; needs = resolvedPlugin.needs or { };
normalizeOpenClawPlugin =
entry:
let
id = entry.id or (throw "openclawPlugin ${name}: plugins entry missing id");
path = entry.path or (throw "openclawPlugin ${name}: plugins.${id} missing path");
enabled =
if entry ? enable && !(entry ? enabled) then
throw "openclawPlugin ${name}: plugins.${id}.enable is not supported; use enabled"
else if entry ? enabled then
if builtins.isBool entry.enabled then
entry.enabled
else
throw "openclawPlugin ${name}: plugins.${id}.enabled must be a boolean"
else
true;
in
{
inherit id path enabled;
source = plugin.source;
plugin = name;
};
in in
{ builtins.seq _ {
source = plugin.source; source = plugin.source;
inherit name; inherit name;
skills = resolvedPlugin.skills or [ ]; skills = resolvedPlugin.skills or [ ];
packages = resolvedPlugin.packages or [ ]; packages = resolvedPlugin.packages or [ ];
plugins = map normalizeOpenClawPlugin (resolvedPlugin.plugins or [ ]); plugins = map (normalizeOpenClawPlugin plugin.source name) (resolvedPlugin.plugins or [ ]);
needs = { needs = {
stateDirs = needs.stateDirs or [ ]; stateDirs = needs.stateDirs or [ ];
requiredEnv = needs.requiredEnv or [ ]; requiredEnv = needs.requiredEnv or [ ];
@ -63,6 +107,13 @@ let
config = plugin.config or { }; config = plugin.config or { };
}; };
resolvePlugin =
plugin:
if lib.hasPrefix "npm:" plugin.source then
resolveNpmRuntimePlugin plugin
else
resolveFlakePlugin plugin;
resolvedPluginsByInstance = lib.mapAttrs ( resolvedPluginsByInstance = lib.mapAttrs (
instName: inst: instName: inst:
let let

View File

@ -0,0 +1,72 @@
#!/bin/sh
set -e
spec="${OPENCLAW_RUNTIME_PLUGIN_NPM_SPEC:?OPENCLAW_RUNTIME_PLUGIN_NPM_SPEC is required}"
id="${OPENCLAW_RUNTIME_PLUGIN_ID:?OPENCLAW_RUNTIME_PLUGIN_ID is required}"
package_name="$(
node -e '
const spec = process.env.OPENCLAW_RUNTIME_PLUGIN_NPM_SPEC || "";
const withoutProtocol = spec.startsWith("npm:") ? spec.slice(4) : spec;
const at = withoutProtocol.startsWith("@")
? withoutProtocol.indexOf("@", 1)
: withoutProtocol.indexOf("@");
const name = at === -1 ? withoutProtocol : withoutProtocol.slice(0, at);
if (!name || name.startsWith("git+") || name.includes("://")) {
process.exit(1);
}
process.stdout.write(name);
'
)" || {
echo "Only registry npm package specs are supported for OpenClaw runtime plugins: $spec" >&2
exit 1
}
export HOME="$TMPDIR/home"
export npm_config_cache="$TMPDIR/npm-cache"
export npm_config_ignore_scripts=true
export npm_config_audit=false
export npm_config_fund=false
export npm_config_update_notifier=false
project="$TMPDIR/openclaw-runtime-plugin"
mkdir -p "$HOME" "$npm_config_cache" "$project"
cd "$project"
npm init -y >/dev/null
npm install --ignore-scripts --omit=dev --no-audit --no-fund --package-lock=false "$spec"
package_dir="node_modules/$package_name"
if [ ! -d "$package_dir" ]; then
echo "npm install did not produce $package_dir for $spec" >&2
exit 1
fi
if [ ! -f "$package_dir/openclaw.plugin.json" ] && [ ! -f "$package_dir/package.json" ]; then
echo "npm package $spec does not look like an OpenClaw runtime plugin" >&2
exit 1
fi
mkdir -p "$out"
cp -R "$package_dir/." "$out/"
if [ -d node_modules ]; then
mkdir -p "$out/node_modules"
cp -R node_modules/. "$out/node_modules/"
rm -rf "$out/node_modules/$package_name"
fi
find "$out" -name .package-lock.json -type f -delete
if [ ! -f "$out/openclaw.plugin.json" ]; then
node -e '
const fs = require("fs");
const path = require("path");
const pkg = JSON.parse(fs.readFileSync(path.join(process.env.out, "package.json"), "utf8"));
const entries = pkg.openclaw?.runtimeExtensions || pkg.openclaw?.extensions || [];
if (!Array.isArray(entries) || entries.length === 0) process.exit(1);
'
fi
printf '%s\n' "$spec" > "$out/.nix-openclaw-npm-spec"
printf '%s\n' "$id" > "$out/.nix-openclaw-plugin-id"