fix: package OpenClaw runtime plugin tree

Install and validate OpenClaw's dist-runtime tree so bundled runtime plugins such as ACPX are present in the Nix gateway output.

Extend the existing plugin flake contract with immutable OpenClaw plugin roots, wire those roots into generated config, and add eval fixtures proving default enablement, user overrides, and disabled entries.

Document the boundary: curated plugin artifacts are CI/Garnix-cached by nix-openclaw, while arbitrary npm or ClawHub specs need deterministic lock/hash-backed Nix artifacts cached by the user's store/cache instead of runtime npm installs.

Tests: nix build --accept-flake-config .#checks.x86_64-linux.default-instance --no-link --print-out-paths; nix eval --accept-flake-config --raw .#checks.aarch64-darwin.package-contents.drvPath; nix build --accept-flake-config .#checks.aarch64-darwin.package-contents --no-link --dry-run; nix build --impure --accept-flake-config .#darwinConfigurations.mac-mini.system --no-link --override-input nix-openclaw path:/Users/josh/code/nix/nix-openclaw --dry-run

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
joshp123 2026-05-08 12:00:05 +08:00
parent 63ff54b656
commit e7d60654b8
15 changed files with 241 additions and 43 deletions

View File

@ -48,6 +48,8 @@ Source: https://github.com/orgs/openclaw/people
- Keep maintainer runbooks in `maintainers/`.
- 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.
- 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.
## Packaging Defaults

View File

@ -7,21 +7,23 @@ Purpose: extend OpenClaw capabilities without bloating core; ship tools + skills
- **Not:** new transports/providers; model plumbing; secrets baked in; inline scripts or ad-hoc package-manager installs; a place for random config outside its scope.
- Why not skills-only: skills without binaries can hallucinate capability. Plugins ground skills in real tools and deliver versioned, reproducible functionality.
## Native OpenClaw Plugin Gap
## Two Plugin Classes
OpenClaw also has native runtime plugins: a plugin directory with `openclaw.plugin.json` plus a JS/TS entry loaded by the gateway. Channel plugins such as `openclaw-weixin` live in this layer.
Nix capability plugins are the tool/skill/env bundles described below. They do not use OpenClaw's JavaScript plugin loader. They are the right shape for CLIs such as `goplaces`, `gog`, `qmd`, `xuezh`, `camsnap`, and `summarize`.
Current nix-openclaw `customPlugins` only implements the Nix-native capability bundle contract: package binaries on the gateway PATH, materialize skills, create state dirs, validate env files, and render optional tool settings. It does not yet tell OpenClaw to load a native plugin directory or enable the matching `plugins.entries.<id>` entry.
OpenClaw plugins are runtime plugin directories with `openclaw.plugin.json` plus built JavaScript loaded by the gateway. They include bundled upstream plugins, official external plugins from OpenClaw's catalog or ClawHub, and third-party plugins. In Nix-managed deployments, these should be immutable plugin roots, not runtime npm installs hidden in host config.
Current nix-openclaw `customPlugins` implements both sides of the contract: package binaries on the gateway PATH, materialize skills, create state dirs, validate env files, render optional tool settings, and wire declared OpenClaw plugin roots into `plugins.load.paths` with a default `plugins.entries.<id>.enabled = true`.
PR #81 (`fix: copy plugin manifests into dist/extensions`) was related but not the missing external-plugin feature. It fixed bundled upstream plugin manifests missing from the packaged gateway `dist/extensions/*/openclaw.plugin.json` tree. Current packaging already copies those manifests and checks them in `openclaw-package-contents`.
The next feature slice should bridge the existing Nix contract to OpenClaw native plugins:
Package authors can bridge the existing Nix contract to OpenClaw plugins:
- Extend `openclawPlugin` with an optional native plugin declaration, for example `nativePlugins = [ { id = "openclaw-weixin"; path = "${pkg}/lib/openclaw/plugins/openclaw-weixin"; enable = true; } ];`.
- Extend `openclawPlugin` with an optional plugin declaration, for example `plugins = [ { id = "openclaw-weixin"; path = "${pkg}/lib/openclaw/plugins/openclaw-weixin"; enable = true; } ];`.
- For each enabled instance, append those paths to generated `plugins.load.paths`.
- Add default `plugins.entries.<id>.enabled = true`, with user config still able to override it.
- Keep native plugin config in `programs.openclaw.config` / `instances.<name>.config` so upstream schema validation remains the source of truth.
- Add a fixture shaped like `openclaw-weixin` so `customPlugins = [{ source = ...; }]` proves both package/skill wiring and native plugin load wiring.
- Keep OpenClaw plugin config in `programs.openclaw.config` / `instances.<name>.config` so upstream schema validation remains the source of truth.
- Add a fixture shaped like `openclaw-weixin` so `customPlugins = [{ source = ...; }]` proves both package/skill wiring and OpenClaw plugin load wiring.
## Interface Contract (reference implementation: nix-openclaw)
Every plugin artifact exposes the same fields (flake output `openclawPlugin` today, but the shape is host-agnostic):
@ -31,6 +33,7 @@ openclawPlugin = {
name = "summarize"; # unique; last-wins on collision
skills = [ ./skills/summarize ]; # dirs containing SKILL.md
packages = [ pkgs.summarize-cli ]; # binaries placed on the OpenClaw runtime PATH
plugins = [ ]; # optional OpenClaw plugin roots
needs = {
stateDirs = [ ".config/summarize" ]; # created under $HOME
requiredEnv = [ "SUMMARIZE_API_KEY" ]; # must point to files
@ -46,6 +49,7 @@ Host responsibilities (what the runtime guarantees):
- Copy/symlink each `skills` entry into `workspace/skills/<skill-dir-basename>/...`.
- If host config provides `config.settings`, render it to `config.json` in the first `stateDir`.
- Export `config.env` (plus required envs) into the gateway wrapper.
- Add declared OpenClaw plugin roots to `plugins.load.paths`, and set `plugins.entries.<id>.enabled = true` as a default.
- Reject duplicate skill paths; duplicate plugin names: last entry wins.
### Host-side config shape
@ -67,6 +71,10 @@ programs.openclaw.customPlugins = [
- `config.settings`: JSON-rendered into `config.json` inside the first `stateDir`.
- Invariant: providing `settings` requires at least one `stateDir`.
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.
## 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.
- Rebuild loop: change plugin → `home-manager switch` (or host-equivalent) → gateway restarts with new PATH/skills/config; no manual copying.

View File

@ -21,6 +21,8 @@ This repo ships a working Nix package for OpenClaw users, not just a pin mirror.
- Generated config options come from the upstream core schema.
- Plugin-owned extension surfaces, such as `channels.<plugin-id>`, must remain accepted by the Home Manager module even when core does not type every plugin key.
- Runtime tool injection belongs here. If a plugin or battery is enabled, the active OpenClaw harness must see its CLI tools and required environment without asking downstream to expose those tools globally on the user PATH.
- OpenClaw plugin roots belong here too. The Home Manager module consumes `openclawPlugin.plugins` declarations from plugin flakes and writes `plugins.load.paths`/`plugins.entries` into the generated config.
- Raw npm/ClawHub plugin names are not batteries-included deployment config. Curated plugins packaged here must be exposed through packages/checks so CI/Garnix caches them. Arbitrary user specs need a deterministic lock/hash-backed Nix builder so Nix reuses the user's store/cache and only rebuilds when the spec, lock, or hash changes.
## Build Contract
@ -28,6 +30,7 @@ This repo ships a working Nix package for OpenClaw users, not just a pin mirror.
- No inline scripts or inline file contents in Nix code. Use repo scripts and explicit file paths.
- Keep runtime tools internal to the `openclaw` wrapper unless they are intentionally part of the public package surface.
- QMD is the Nix-supported batteries-included local memory backend. Keep `qmd` internal to the `openclaw` wrapper PATH; users opt in with upstream config.
- ACPX is the first bundled OpenClaw plugin proof. It is consumed from OpenClaw's built `dist-runtime/extensions/acpx` tree, not installed or repaired by npm at runtime.
- Keep files under 400 lines unless a maintainer explicitly accepts the larger file.
## Investigations

View File

@ -33,6 +33,9 @@ let
betaPluginSource =
lockedPathFlake "openclaw-test-plugin-beta" ../tests/plugins/beta
"sha256-lDKtQKHZHqOkOprjLZzBEu8cFJhAdyEzsays9hdVeqE=";
runtimePluginSource =
lockedPathFlake "openclaw-test-plugin-runtime" ../tests/plugins/runtime
"sha256-JquqpIqcXWwwQPXVHsNQEme8xksXBk1A0lXc/pxsnhE=";
stubModule =
{ lib, ... }:
@ -227,14 +230,66 @@ let
runtimeProfileCheck =
builtins.deepSeq (requireNoAssertionFailures "runtime profile" runtimeProfileEval)
(
if
lib.hasInfix "openclaw-link-codex-runtime-profiles.sh" runtimeProfileActivation
then
if lib.hasInfix "openclaw-link-codex-runtime-profiles.sh" runtimeProfileActivation then
"ok"
else
throw "runtimePackages did not wire the Codex runtime profile activation."
);
openclawPluginEval = moduleEval {
customPlugins = [
{ source = runtimePluginSource; }
];
config.plugins.load.paths = [
"/tmp/user-openclaw-plugin"
];
};
openclawPluginConfig = builtins.fromJSON (
builtins.unsafeDiscardStringContext
openclawPluginEval.config.home.file.".openclaw/openclaw.json".text
);
openclawPluginLoadPaths = ((openclawPluginConfig.plugins or { }).load or { }).paths or [ ];
openclawPluginEntry = ((openclawPluginConfig.plugins or { }).entries or { }).runtime-test or { };
openclawPluginDisabledEntry =
((openclawPluginConfig.plugins or { }).entries or { }).runtime-disabled or null;
openclawPluginCheck =
builtins.deepSeq (requireNoAssertionFailures "OpenClaw plugin load" openclawPluginEval)
(
if !(lib.any (path: lib.hasSuffix "/plugin" path) openclawPluginLoadPaths) then
throw "OpenClaw plugin root was not added to plugins.load.paths."
else if lib.any (path: lib.hasSuffix "/disabled-plugin" path) openclawPluginLoadPaths then
throw "Disabled OpenClaw plugin root was added to plugins.load.paths."
else if !(lib.elem "/tmp/user-openclaw-plugin" openclawPluginLoadPaths) then
throw "User-defined plugins.load.paths entry was not preserved."
else if (openclawPluginEntry.enabled or false) != true then
throw "OpenClaw plugin entry default was not enabled."
else if openclawPluginDisabledEntry != null then
throw "Disabled OpenClaw plugin entry was added to generated config."
else
"ok"
);
openclawPluginOverrideEval = moduleEval {
customPlugins = [
{ source = runtimePluginSource; }
];
config.plugins.entries.runtime-test.enabled = false;
};
openclawPluginOverrideConfig = builtins.fromJSON (
builtins.unsafeDiscardStringContext
openclawPluginOverrideEval.config.home.file.".openclaw/openclaw.json".text
);
openclawPluginOverrideEntry =
((openclawPluginOverrideConfig.plugins or { }).entries or { }).runtime-test or { };
openclawPluginOverrideCheck =
builtins.deepSeq (requireNoAssertionFailures "OpenClaw plugin override" openclawPluginOverrideEval)
(
if (openclawPluginOverrideEntry.enabled or null) == false then
"ok"
else
throw "User config could not override OpenClaw plugin enabled default."
);
checkKey = builtins.deepSeq [
defaultCheck
customPluginCheck
@ -243,6 +298,8 @@ let
secretProviderCheck
qmdPrewarmCheck
runtimeProfileCheck
openclawPluginCheck
openclawPluginOverrideCheck
] "ok";
in

View File

@ -117,11 +117,26 @@ let
inherit key value;
plugin = "runtime";
}) (cfg.environment // inst.environment));
mergedConfig0 = stripNulls (
lib.recursiveUpdate (lib.recursiveUpdate baseConfig (stripNulls cfg.config)) (
stripNulls inst.config
)
userConfig = stripNulls (lib.recursiveUpdate (stripNulls cfg.config) (stripNulls inst.config));
pluginEntryConfig = plugins.openclawPluginEntriesConfigFor name;
openclawPluginLoadPaths = plugins.openclawPluginLoadPathsFor name;
mergedConfigWithoutLoadPaths = stripNulls (
lib.recursiveUpdate (lib.recursiveUpdate baseConfig pluginEntryConfig) userConfig
);
existingOpenClawPluginLoadPaths = (
((mergedConfigWithoutLoadPaths.plugins or { }).load or { }).paths or [ ]
);
mergedConfig0 =
if openclawPluginLoadPaths == [ ] then
mergedConfigWithoutLoadPaths
else
lib.recursiveUpdate mergedConfigWithoutLoadPaths {
plugins = {
load = {
paths = lib.unique (openclawPluginLoadPaths ++ existingOpenClawPluginLoadPaths);
};
};
};
existingWorkspace = (((mergedConfig0.agents or { }).defaults or { }).workspace or null);
mergedConfig =
if (cfg.workspace.pinAgentDefaults or true) && existingWorkspace == null then

View File

@ -83,7 +83,7 @@
options = {
source = lib.mkOption {
type = lib.types.str;
description = "Plugin source pointer (e.g., github:owner/repo or path:/...).";
description = "Plugin flake source pointer (e.g., github:owner/repo or path:/...).";
};
config = lib.mkOption {
type = lib.types.attrs;

View File

@ -137,7 +137,7 @@ in
options = {
source = lib.mkOption {
type = lib.types.str;
description = "Plugin source pointer (e.g., github:owner/repo or path:/...).";
description = "Plugin flake source pointer (e.g., github:owner/repo or path:/...).";
};
config = lib.mkOption {
type = lib.types.attrs;

View File

@ -12,8 +12,8 @@ let
resolvePlugin =
plugin:
let
flake = builtins.getFlake plugin.source;
system = pkgs.stdenv.hostPlatform.system;
flake = builtins.getFlake plugin.source;
openclawPluginRaw =
if flake ? openclawPlugin then
flake.openclawPlugin
@ -26,13 +26,27 @@ let
throw "openclawPlugin is null in ${plugin.source} for ${system}"
else
openclawPlugin;
name = resolvedPlugin.name or (throw "openclawPlugin.name missing in ${plugin.source}");
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");
in
{
inherit id path;
enable = entry.enable or true;
source = plugin.source;
plugin = name;
};
in
{
source = plugin.source;
name = resolvedPlugin.name or (throw "openclawPlugin.name missing in ${plugin.source}");
inherit name;
skills = resolvedPlugin.skills or [ ];
packages = resolvedPlugin.packages or [ ];
plugins = map normalizeOpenClawPlugin (resolvedPlugin.plugins or [ ]);
needs = {
stateDirs = needs.stateDirs or [ ];
requiredEnv = needs.requiredEnv or [ ];
@ -104,31 +118,72 @@ let
in
lib.flatten (map toPairs entries);
pluginAssertions = lib.flatten (
lib.mapAttrsToList (
instName: inst:
let
plugins = resolvedPluginsByInstance.${instName} or [ ];
envFor = p: (p.config.env or { });
missingFor = p: lib.filter (req: !(builtins.hasAttr req (envFor p))) p.needs.requiredEnv;
configMissingStateDir = p: (p.config.settings or { }) != { } && (p.needs.stateDirs or [ ]) == [ ];
mkAssertion =
p:
let
missing = missingFor p;
in
{
assertion = missing == [ ];
message = "programs.openclaw.instances.${instName}: plugin ${p.name} missing required env: ${lib.concatStringsSep ", " missing}";
openclawPluginsFor =
instName: lib.flatten (map (p: p.plugins) (resolvedPluginsByInstance.${instName} or [ ]));
enabledOpenClawPluginsFor = instName: lib.filter (p: p.enable) (openclawPluginsFor instName);
openclawPluginLoadPathsFor =
instName: map (p: toString p.path) (enabledOpenClawPluginsFor instName);
openclawPluginEntriesConfigFor =
instName:
let
entries = enabledOpenClawPluginsFor instName;
in
lib.optionalAttrs (entries != [ ]) {
plugins = {
entries = lib.listToAttrs (
map (p: {
name = p.id;
value = {
enabled = true;
};
}) entries
);
};
};
openclawPluginIdAssertions = lib.mapAttrsToList (
instName: _inst:
let
ids = map (p: p.id) (enabledOpenClawPluginsFor instName);
counts = lib.foldl' (acc: id: acc // { "${id}" = (acc.${id} or 0) + 1; }) { } ids;
duplicates = lib.attrNames (lib.filterAttrs (_: v: v > 1) counts);
in
{
assertion = duplicates == [ ];
message = "programs.openclaw.instances.${instName}: duplicate OpenClaw plugin ids detected: ${lib.concatStringsSep ", " duplicates}";
}
) enabledInstances;
pluginAssertions =
openclawPluginIdAssertions
++ lib.flatten (
lib.mapAttrsToList (
instName: inst:
let
plugins = resolvedPluginsByInstance.${instName} or [ ];
envFor = p: (p.config.env or { });
missingFor = p: lib.filter (req: !(builtins.hasAttr req (envFor p))) p.needs.requiredEnv;
configMissingStateDir = p: (p.config.settings or { }) != { } && (p.needs.stateDirs or [ ]) == [ ];
mkAssertion =
p:
let
missing = missingFor p;
in
{
assertion = missing == [ ];
message = "programs.openclaw.instances.${instName}: plugin ${p.name} missing required env: ${lib.concatStringsSep ", " missing}";
};
mkConfigAssertion = p: {
assertion = !(configMissingStateDir p);
message = "programs.openclaw.instances.${instName}: plugin ${p.name} provides settings but declares no stateDirs (needed for config.json).";
};
mkConfigAssertion = p: {
assertion = !(configMissingStateDir p);
message = "programs.openclaw.instances.${instName}: plugin ${p.name} provides settings but declares no stateDirs (needed for config.json).";
};
in
(map mkAssertion plugins) ++ (map mkConfigAssertion plugins)
) enabledInstances
);
in
(map mkAssertion plugins) ++ (map mkConfigAssertion plugins)
) enabledInstances
);
pluginConfigFiles =
let
@ -192,6 +247,9 @@ in
pluginStateDirsAll
pluginEnvFor
pluginEnvAllFor
openclawPluginsFor
openclawPluginLoadPathsFor
openclawPluginEntriesConfigFor
pluginAssertions
pluginConfigFiles
pluginGuards

View File

@ -19,6 +19,14 @@ require_path "${root}/extensions"
require_path "${root}/extensions/memory-core"
require_path "${root}/extensions/memory-core/openclaw.plugin.json"
require_path "${root}/dist/extensions/memory-core/openclaw.plugin.json"
require_path "${root}/dist-runtime/extensions"
require_path "${root}/dist-runtime/extensions/memory-core/openclaw.plugin.json"
require_path "${root}/dist-runtime/extensions/acpx/openclaw.plugin.json"
require_path "${root}/dist-runtime/extensions/acpx/package.json"
require_path "${root}/dist-runtime/extensions/acpx/index.js"
require_path "${root}/dist-runtime/extensions/acpx/error-format.mjs"
require_path "${root}/dist-runtime/extensions/acpx/mcp-command-line.mjs"
require_path "${root}/dist-runtime/extensions/acpx/mcp-proxy.mjs"
require_path "${root}/docs/reference/templates"
require_path "${root}/docs/reference/templates/AGENTS.md"
require_path "${root}/docs/reference/templates/SOUL.md"

View File

@ -84,6 +84,9 @@ log_step "build: canvas:a2ui:tsc" pnpm exec tsc -p vendor/a2ui/renderers/lit/tsc
log_step "build: canvas:a2ui:rolldown" node node_modules/rolldown/bin/cli.mjs -c apps/shared/OpenClawKit/Tools/CanvasA2UI/rolldown.config.mjs
log_step "build: tsdown" pnpm exec tsdown
log_step "build: runtime-postbuild" node scripts/runtime-postbuild.mjs
if [ -f "scripts/stage-bundled-plugin-runtime.mjs" ]; then
log_step "build: stage bundled plugin runtime" node scripts/stage-bundled-plugin-runtime.mjs
fi
log_step "build: plugin-sdk dts" pnpm build:plugin-sdk:dts
log_step "build: write-plugin-sdk-entry-dts" node --import tsx scripts/write-plugin-sdk-entry-dts.ts
if [ -f "scripts/copy-plugin-sdk-root-alias.mjs" ]; then

View File

@ -57,7 +57,11 @@ copy_extension_manifests() {
mkdir -p "$out/lib/openclaw" "$out/bin"
log_step "copy build outputs" cp -R dist node_modules package.json "$out/lib/openclaw/"
set -- dist node_modules package.json
if [ -d dist-runtime ]; then
set -- "$@" dist-runtime
fi
log_step "copy build outputs" cp -R "$@" "$out/lib/openclaw/"
if [ -d extensions ]; then
log_step "copy extension manifests" copy_extension_manifests
fi
@ -151,5 +155,8 @@ if [ -n "${OPENCLAW_BUILD_ROOT_SH:-}" ]; then
fi
log_step "validate node_modules symlinks" check_no_broken_symlinks "$out/lib/openclaw/node_modules"
if [ -d "$out/lib/openclaw/dist-runtime" ]; then
log_step "validate dist-runtime symlinks" check_no_broken_symlinks "$out/lib/openclaw/dist-runtime"
fi
log_step "wrap openclaw" bash -e -c '. "$STDENV_SETUP"; makeWrapper "$NODE_BIN" "$out/bin/openclaw" --add-flags "$out/lib/openclaw/dist/index.js" --set-default OPENCLAW_NIX_MODE "1"'

View File

@ -0,0 +1,4 @@
{
"id": "runtime-disabled",
"name": "Runtime Disabled"
}

View File

@ -0,0 +1,23 @@
{
outputs =
{ self }:
{
openclawPlugin = {
name = "runtime";
skills = [ ];
packages = [ ];
needs = { };
plugins = [
{
id = "runtime-test";
path = "${self.outPath}/plugin";
}
{
id = "runtime-disabled";
path = "${self.outPath}/disabled-plugin";
enable = false;
}
];
};
};
}

View File

@ -0,0 +1,3 @@
export default {
activate() {}
};

View File

@ -0,0 +1,7 @@
{
"id": "runtime-test",
"name": "Runtime Test",
"activation": {
"onStartup": true
}
}