Compare commits
No commits in common. "main" and "v2.0.0-beta4" have entirely different histories.
main
...
v2.0.0-bet
52
.github/workflows/ci.yml
vendored
52
.github/workflows/ci.yml
vendored
@ -1,52 +0,0 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [ main ]
|
||||
workflow_dispatch: {}
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
linux:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Verify flake.lock owners
|
||||
run: scripts/check-flake-lock-owners.sh
|
||||
|
||||
- name: Install Nix
|
||||
uses: cachix/install-nix-action@v31
|
||||
|
||||
- name: Run Linux CI aggregator
|
||||
run: timeout --foreground 50m nix build .#checks.x86_64-linux.ci --accept-flake-config
|
||||
|
||||
- name: Dump failing source check log
|
||||
if: failure()
|
||||
run: |
|
||||
drv="$(nix eval --raw .#checks.x86_64-linux.source-checks.drvPath --accept-flake-config)"
|
||||
nix log "$drv" | tail -n 400 || true
|
||||
|
||||
macos:
|
||||
runs-on: macos-14
|
||||
timeout-minutes: 40
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install Nix
|
||||
uses: DeterminateSystems/nix-installer-action@v22
|
||||
|
||||
- name: Build Darwin CI aggregator
|
||||
timeout-minutes: 25
|
||||
run: nix build .#checks.aarch64-darwin.ci --accept-flake-config
|
||||
|
||||
- name: Run HM activation
|
||||
timeout-minutes: 10
|
||||
run: scripts/hm-activation-macos.sh
|
||||
257
.github/workflows/yolo-update.yml
vendored
257
.github/workflows/yolo-update.yml
vendored
@ -1,257 +0,0 @@
|
||||
name: Yolo Update Pins
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "5 * * * *"
|
||||
workflow_dispatch: {}
|
||||
|
||||
concurrency:
|
||||
group: yolo-update-pins
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
select:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
outputs:
|
||||
has_update: ${{ steps.select.outputs.has_update }}
|
||||
source_tag: ${{ steps.select.outputs.source_tag }}
|
||||
source_sha: ${{ steps.select.outputs.source_sha }}
|
||||
source_version: ${{ steps.select.outputs.source_version }}
|
||||
app_tag: ${{ steps.select.outputs.app_tag }}
|
||||
app_url: ${{ steps.select.outputs.app_url }}
|
||||
app_version: ${{ steps.select.outputs.app_version }}
|
||||
latest_stable_tag: ${{ steps.select.outputs.latest_stable_tag }}
|
||||
app_lag_releases: ${{ steps.select.outputs.app_lag_releases }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Select release
|
||||
id: select
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
selection="$(scripts/update-pins.sh select)"
|
||||
|
||||
while IFS='=' read -r key value; do
|
||||
echo "${key}=${value}" >> "$GITHUB_OUTPUT"
|
||||
done <<<"$selection"
|
||||
|
||||
has_update="$(printf '%s\n' "$selection" | awk -F= '$1 == "has_update" { print $2; exit }')"
|
||||
source_tag="$(printf '%s\n' "$selection" | awk -F= '$1 == "source_tag" { print $2; exit }')"
|
||||
app_tag="$(printf '%s\n' "$selection" | awk -F= '$1 == "app_tag" { print $2; exit }')"
|
||||
latest_stable_tag="$(printf '%s\n' "$selection" | awk -F= '$1 == "latest_stable_tag" { print $2; exit }')"
|
||||
app_lag_releases="$(printf '%s\n' "$selection" | awk -F= '$1 == "app_lag_releases" { print $2; exit }')"
|
||||
|
||||
{
|
||||
echo "### OpenClaw release selection"
|
||||
echo
|
||||
echo "- Latest stable upstream release: \`${latest_stable_tag:-unknown}\`"
|
||||
echo "- Selected source release: \`${source_tag:-unknown}\`"
|
||||
echo "- Selected macOS app artifact: \`${app_tag:-preserve-current}\`"
|
||||
echo "- Update needed: \`${has_update:-unknown}\`"
|
||||
if [[ -n "${app_lag_releases:-}" ]]; then
|
||||
echo "- macOS app asset lagging source release(s): \`${app_lag_releases}\`"
|
||||
fi
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
validate-linux:
|
||||
needs: select
|
||||
if: needs.select.outputs.has_update == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
outputs:
|
||||
materialization_digest: ${{ steps.materialization.outputs.materialization_digest }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install Nix
|
||||
uses: cachix/install-nix-action@v31
|
||||
|
||||
- name: Materialize selected release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
scripts/update-pins.sh apply \
|
||||
"${{ needs.select.outputs.source_tag }}" \
|
||||
"${{ needs.select.outputs.source_sha }}" \
|
||||
"${{ needs.select.outputs.app_tag }}" \
|
||||
"${{ needs.select.outputs.app_url }}"
|
||||
|
||||
- name: Record materialized diff digest
|
||||
id: materialization
|
||||
run: |
|
||||
set -euo pipefail
|
||||
digest="$(
|
||||
git diff --binary -- \
|
||||
nix/sources/openclaw-source.nix \
|
||||
nix/packages/openclaw-app.nix \
|
||||
nix/generated/openclaw-config-options.nix \
|
||||
| shasum -a 256 \
|
||||
| awk '{ print $1 }'
|
||||
)"
|
||||
echo "materialization_digest=${digest}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Verify flake.lock owners
|
||||
run: scripts/check-flake-lock-owners.sh
|
||||
|
||||
- name: Run Linux CI aggregator
|
||||
run: timeout --foreground 50m nix build .#checks.x86_64-linux.ci --accept-flake-config
|
||||
|
||||
- name: Dump failing source check log
|
||||
if: failure()
|
||||
run: |
|
||||
drv="$(nix eval --raw .#checks.x86_64-linux.source-checks.drvPath --accept-flake-config)"
|
||||
nix log "$drv" | tail -n 400 || true
|
||||
|
||||
validate-macos:
|
||||
needs: select
|
||||
if: needs.select.outputs.has_update == 'true'
|
||||
runs-on: macos-14
|
||||
timeout-minutes: 40
|
||||
outputs:
|
||||
materialization_digest: ${{ steps.materialization.outputs.materialization_digest }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install Nix
|
||||
uses: DeterminateSystems/nix-installer-action@v22
|
||||
|
||||
- name: Materialize selected release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
scripts/update-pins.sh apply \
|
||||
"${{ needs.select.outputs.source_tag }}" \
|
||||
"${{ needs.select.outputs.source_sha }}" \
|
||||
"${{ needs.select.outputs.app_tag }}" \
|
||||
"${{ needs.select.outputs.app_url }}"
|
||||
|
||||
- name: Record materialized diff digest
|
||||
id: materialization
|
||||
run: |
|
||||
set -euo pipefail
|
||||
digest="$(
|
||||
git diff --binary -- \
|
||||
nix/sources/openclaw-source.nix \
|
||||
nix/packages/openclaw-app.nix \
|
||||
nix/generated/openclaw-config-options.nix \
|
||||
| shasum -a 256 \
|
||||
| awk '{ print $1 }'
|
||||
)"
|
||||
echo "materialization_digest=${digest}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build Darwin CI aggregator
|
||||
timeout-minutes: 25
|
||||
run: nix build .#checks.aarch64-darwin.ci --accept-flake-config
|
||||
|
||||
- name: Run HM activation
|
||||
timeout-minutes: 10
|
||||
run: scripts/hm-activation-macos.sh
|
||||
|
||||
promote:
|
||||
needs:
|
||||
- select
|
||||
- validate-linux
|
||||
- validate-macos
|
||||
if: needs.select.outputs.has_update == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
actions: write
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install Nix
|
||||
uses: DeterminateSystems/nix-installer-action@v22
|
||||
|
||||
- name: Promote selected release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
LINUX_MATERIALIZATION_DIGEST: ${{ needs.validate-linux.outputs.materialization_digest }}
|
||||
MACOS_MATERIALIZATION_DIGEST: ${{ needs.validate-macos.outputs.materialization_digest }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git config user.name "openclaw-ci"
|
||||
git config user.email "ci@openclaw.local"
|
||||
|
||||
if [[ -z "$LINUX_MATERIALIZATION_DIGEST" || -z "$MACOS_MATERIALIZATION_DIGEST" ]]; then
|
||||
echo "Missing validation materialization digest." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$LINUX_MATERIALIZATION_DIGEST" != "$MACOS_MATERIALIZATION_DIGEST" ]]; then
|
||||
echo "Linux and macOS materialized different release diffs." >&2
|
||||
echo "Linux: $LINUX_MATERIALIZATION_DIGEST" >&2
|
||||
echo "macOS: $MACOS_MATERIALIZATION_DIGEST" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
scripts/update-pins.sh apply \
|
||||
"${{ needs.select.outputs.source_tag }}" \
|
||||
"${{ needs.select.outputs.source_sha }}" \
|
||||
"${{ needs.select.outputs.app_tag }}" \
|
||||
"${{ needs.select.outputs.app_url }}"
|
||||
|
||||
if git diff --quiet -- \
|
||||
nix/sources/openclaw-source.nix \
|
||||
nix/packages/openclaw-app.nix \
|
||||
nix/generated/openclaw-config-options.nix; then
|
||||
echo "No pin changes detected."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
promote_digest="$(
|
||||
git diff --binary -- \
|
||||
nix/sources/openclaw-source.nix \
|
||||
nix/packages/openclaw-app.nix \
|
||||
nix/generated/openclaw-config-options.nix \
|
||||
| shasum -a 256 \
|
||||
| awk '{ print $1 }'
|
||||
)"
|
||||
if [[ "$promote_digest" != "$LINUX_MATERIALIZATION_DIGEST" ]]; then
|
||||
echo "Promote materialized a different release diff than validation." >&2
|
||||
echo "Validated: $LINUX_MATERIALIZATION_DIGEST" >&2
|
||||
echo "Promote: $promote_digest" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git add \
|
||||
nix/sources/openclaw-source.nix \
|
||||
nix/packages/openclaw-app.nix \
|
||||
nix/generated/openclaw-config-options.nix
|
||||
|
||||
git commit -F - <<EOF
|
||||
🤖 codex: mirror OpenClaw stable source ${{ needs.select.outputs.source_tag }}
|
||||
|
||||
What:
|
||||
- update nix-openclaw to the latest stable OpenClaw source release
|
||||
- refresh generated config options from that source
|
||||
- keep the macOS app pin on the newest public app artifact
|
||||
|
||||
Why:
|
||||
- keep source-built OpenClaw current without blocking on public macOS app asset lag
|
||||
|
||||
Tests:
|
||||
- nix build .#checks.x86_64-linux.ci --accept-flake-config
|
||||
- nix build .#checks.aarch64-darwin.ci --accept-flake-config
|
||||
- scripts/hm-activation-macos.sh
|
||||
EOF
|
||||
|
||||
git fetch origin main
|
||||
git rebase origin/main
|
||||
git push origin HEAD:main
|
||||
|
||||
gh workflow run ci.yml --ref main
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@ -1,2 +0,0 @@
|
||||
result
|
||||
.agent/
|
||||
69
AGENTS.md
69
AGENTS.md
@ -1,69 +0,0 @@
|
||||
# AGENTS.md - nix-openclaw
|
||||
|
||||
## PRs
|
||||
|
||||
We are not accepting PRs from non-maintainers. If your handle is not in the Maintainers list below or on https://github.com/orgs/openclaw/people, do not open a PR.
|
||||
|
||||
Describe your problem and talk with a maintainer human-to-human on Discord instead. Join https://discord.gg/clawd and use `#golden-path-deployments`.
|
||||
|
||||
## Maintainers
|
||||
|
||||
Source: https://github.com/orgs/openclaw/people
|
||||
|
||||
- @Asleep123
|
||||
- @badlogic
|
||||
- @bjesuiter
|
||||
- @christianklotz
|
||||
- @cpojer
|
||||
- @Evizero
|
||||
- @gumadeiras
|
||||
- @joshp123
|
||||
- @mbelinky
|
||||
- @mukhtharcm
|
||||
- @obviyus
|
||||
- @onutc
|
||||
- @pasogott
|
||||
- @sebslight
|
||||
- @sergiopesch
|
||||
- @shakkernerd
|
||||
- @steipete
|
||||
- @Takhoffman
|
||||
- @thewilloftheshadow
|
||||
- @tyler6204
|
||||
- @vignesh07
|
||||
|
||||
## Audience Routing
|
||||
|
||||
- Consumer agents installing or configuring OpenClaw: start with `README.md` and `templates/agent-first/flake.nix`.
|
||||
- Maintainer agents changing packaging, release automation, pins, or CI: read `maintainers/AGENTS.md` first.
|
||||
- Plugin authors: read `docs/plugins-maintainers.md` and `examples/hello-world-plugin/`.
|
||||
- Private deployments, bots, hosts, local worktrees, tokens, and personal automation details do not belong in this public repo.
|
||||
|
||||
## Public Repo Rules
|
||||
|
||||
- `README.md` is the source of truth for product direction and user-facing behavior.
|
||||
- Keep documentation surface area small. Update `README.md` first, then adjust references.
|
||||
- Keep committed guidance about public `nix-openclaw` behavior, public upstream OpenClaw releases, public artifacts, and public CI.
|
||||
- Keep consumer setup docs in `README.md`, templates, and module docs.
|
||||
- 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. `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
|
||||
|
||||
- Nix-first, no sudo.
|
||||
- Declarative config only.
|
||||
- Batteries-included install is the baseline.
|
||||
- Breaking changes are acceptable pre-1.0.0; no deprecations.
|
||||
- No inline scripts or inline file contents in Nix code. Use repo scripts and explicit file paths.
|
||||
- The gateway package must include Control UI assets.
|
||||
- User-facing docs should lead with one package: `openclaw`. Treat `openclaw-gateway` and `openclaw-app` as component outputs for modules, checks, and debugging.
|
||||
- QMD is the Nix-supported batteries-included local memory backend. Keep `qmd` internal to the `openclaw` wrapper PATH; users opt in with upstream config.
|
||||
|
||||
## Safety
|
||||
|
||||
- Never send messages, email, SMS, or other external communications without explicit confirmation showing the full message text.
|
||||
- No force push. No destructive git operations unless explicitly requested.
|
||||
- Before deleting tracked files, list them in the summary so maintainers can verify.
|
||||
577
LICENSE
577
LICENSE
@ -1,577 +0,0 @@
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on a
|
||||
public server, gives the public access to the source code of the
|
||||
modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent required by section 5.
|
||||
|
||||
The "Source Code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object Code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The source code for a work means the preferred form of the work
|
||||
for making modifications to it. "Object Code" means any non-source
|
||||
form of a work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under the
|
||||
conditions stated below. Sublicensing is not allowed; section 10 makes
|
||||
it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or similar
|
||||
laws prohibiting or restricting circumvention of such measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified it,
|
||||
and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is released
|
||||
under this License and any conditions added under section 7. This
|
||||
requirement modifies the requirement in section 4 to "keep intact all
|
||||
notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this License
|
||||
to anyone who comes into possession of a copy. This License will
|
||||
therefore apply, along with any applicable section 7 additional terms,
|
||||
to the whole of the work, and all its parts, regardless of how they are
|
||||
packaged. This License gives no permission to license the work in any
|
||||
other way, but it does not invalidate such permission if you have
|
||||
separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your work
|
||||
need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium customarily
|
||||
used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a written
|
||||
offer, valid for at least three years and valid for as long as you
|
||||
offer spare parts or customer support for that product model, to give
|
||||
anyone who possesses the object code either (1) a copy of the
|
||||
Corresponding Source for all the software in the product that is
|
||||
covered by this License, on a durable physical medium customarily used
|
||||
for software interchange, for a price no more than your reasonable
|
||||
cost of physically performing this conveying of source, or (2) access
|
||||
to copy the Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This alternative is
|
||||
allowed only occasionally and noncommercially, and only if you received
|
||||
the object code with such an offer, in accord with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated place
|
||||
(gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to copy
|
||||
the object code is a network server, the Corresponding Source may be
|
||||
on a different server (operated by you or a third party) that supports
|
||||
equivalent copying facilities, provided you maintain clear directions
|
||||
next to the object code saying where to find the Corresponding Source.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided you
|
||||
inform other peers where the object code and Corresponding Source of
|
||||
the work are being offered to the general public at no charge under
|
||||
subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
"Additional permissions" are terms that, for a particular work, add
|
||||
to the terms of this License by making exceptions from one or more of
|
||||
its conditions. Additional permissions may be written to require their
|
||||
own removal in certain cases when you modify the work. This License
|
||||
permits supplementing the license terms for a covered work by making
|
||||
exceptions from one or more of its conditions.
|
||||
|
||||
Additional permissions may be stated in the form of a license notice
|
||||
written in the relevant source file, or in a file that accompanies the
|
||||
work. Additional permissions are therefore any terms that supplement
|
||||
the terms of this License.
|
||||
|
||||
If you add terms to a covered work, you may (if authorized by the
|
||||
copyright holders) add additional permissions that apply to part of
|
||||
the work, supplementing the permissions of this License. These
|
||||
additional permissions are not considered "further restrictions" within
|
||||
the meaning of section 10. The remainder of the work is not affected
|
||||
by such permissions.
|
||||
|
||||
If you add terms to a covered work, you may add additional permissions
|
||||
that apply to the entire work, supplementing the permissions of this
|
||||
License. These additional permissions are not considered "further
|
||||
restrictions" within the meaning of section 10. The remainder of the
|
||||
work is not affected by such permissions.
|
||||
|
||||
If you add terms to a covered work, you may also remove those terms
|
||||
entirely. Additional permissions are therefore not necessary.
|
||||
|
||||
If the Program as you received it, or any part of it, contains a notice
|
||||
stating that it is governed by this License along with a term that is
|
||||
a further restriction, you may remove that term. If a license document
|
||||
contains a further restriction but the Program does not, the further
|
||||
restriction applies only to the license document, not to the Program.
|
||||
|
||||
If a further restriction is enforceable, it remains in force
|
||||
notwithstanding any other provision of this License. If it cannot be
|
||||
enforced, it is severed from this License, and all other provisions
|
||||
remain in force.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your license
|
||||
from a particular copyright holder is reinstated (a) provisionally,
|
||||
unless and until the copyright holder explicitly and finally terminates
|
||||
your license, and (b) permanently, if the copyright holder fails to
|
||||
notify you of the violation by some reasonable means prior to 60 days
|
||||
after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is reinstated
|
||||
permanently if the copyright holder notifies you of the violation by
|
||||
some reasonable means, this is the first time you have received notice
|
||||
of violation of this License (for any work) from that copyright holder,
|
||||
and you cure the violation prior to 30 days after your receipt of the
|
||||
notice.
|
||||
|
||||
Termination of your rights under this License does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or run
|
||||
a copy of the Program. Ancillary propagation of a covered work occurring
|
||||
solely as a consequence of using peer-to-peer transmission to receive a
|
||||
copy likewise does not require acceptance. However, nothing other than
|
||||
this License grants you permission to propagate or modify any covered
|
||||
work. These actions infringe copyright if you do not accept this
|
||||
License. Therefore, by modifying or propagating a covered work, you
|
||||
indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered work
|
||||
results from an entity transaction, each party to that transaction who
|
||||
receives a copy of the work also receives whatever licenses to the work
|
||||
the party's predecessor in interest had or could give under the previous
|
||||
paragraph, plus a right to possession of the Corresponding Source of the
|
||||
work from the predecessor in interest, if the predecessor has it or can
|
||||
get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may not
|
||||
impose a license fee, royalty, or other charge for exercise of rights
|
||||
granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that any
|
||||
patent claim is infringed by making, using, selling, offering for sale,
|
||||
or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The work
|
||||
thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims owned
|
||||
or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a consequence
|
||||
of further modification of the contributor version. For purposes of
|
||||
this definition, "control" includes the right to grant patent
|
||||
sublicenses in a manner consistent with the requirements of this
|
||||
License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(例えば, an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within the
|
||||
scope of its coverage, prohibits the exercise of, or is conditioned on
|
||||
the non-exercise of one or more of the rights that are specifically
|
||||
granted under this License. You may not convey a covered work if you are
|
||||
a party to an arrangement with a third party that is in the business of
|
||||
distributing software, under which you make payment to the third party
|
||||
based on the extent of your activity of conveying the work, and under
|
||||
which the third party grants, to any of the parties who would receive
|
||||
the covered work from you, a discriminatory patent license (a) in
|
||||
connection with copies of the covered work conveyed by you (or copies
|
||||
made from those copies), or (b) primarily for and in connection with
|
||||
specific products or compilations that contain the covered work, unless
|
||||
you entered into that arrangement, or that patent license was granted,
|
||||
prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting any
|
||||
implied license or other defenses to infringement that may otherwise be
|
||||
available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you
|
||||
may not convey it at all. For example, if you agree to terms that obligate
|
||||
you to collect a royalty for further conveying from those to whom you
|
||||
convey the Program, the only way you could satisfy both those terms and
|
||||
this License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single combined
|
||||
work, and to convey the resulting work. The terms of this License will
|
||||
continue to apply to the part which is the covered work, but the work
|
||||
with which it is combined will remain governed by version 3 of the GNU
|
||||
General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new
|
||||
versions will be similar in spirit to the present version, but may
|
||||
differ in detail to address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the Program
|
||||
specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the GNU
|
||||
Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future versions
|
||||
of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING
|
||||
ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF
|
||||
the USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO
|
||||
LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU
|
||||
OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
|
||||
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
|
||||
POSSIBILITY OF SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these
|
||||
terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
822
README.md
822
README.md
@ -1,49 +1,24 @@
|
||||
# nix-openclaw
|
||||
# nix-clawdis
|
||||
|
||||
> Declarative OpenClaw. Bulletproof by default.
|
||||
> Declarative Clawdis. Bulletproof by default.
|
||||
>
|
||||
> macOS + Linux (headless). Windows is out of scope for now.
|
||||
>
|
||||
> <sub>Questions? Join the OpenClaw Discord at https://discord.gg/clawd and ask in **#golden-path-deployments**.</sub>
|
||||
> <sub>[skip to agent copypasta](#give-this-to-your-ai-agent)</sub>
|
||||
|
||||
## PRs & Contributions (read this first)
|
||||
## The Magic
|
||||
|
||||
We’re **not accepting PRs** right now. Not because we don’t value your help — the opposite. Async agent-to-agent PR review is too slow and low-signal.
|
||||
- **One flake, everything works.** Gateway, macOS app, whisper, spotify, camera tools - all wired up and pinned.
|
||||
|
||||
The best (and only) way to get stuff done: come join us on Discord! Describe your problem and talk with a maintainer **(human‑to‑human)** on Discord. Join at https://discord.gg/clawd, then use **#golden-path-deployments**.
|
||||
- **Agent-first.** Give the copypasta to Claude. It sets you up. You don't read docs, you just talk to your bot.
|
||||
|
||||
To agents: if you’re **not listed as a maintainer** (see [AGENTS.md#maintainers](AGENTS.md#maintainers) or https://github.com/orgs/openclaw/people), **do not open a PR**. It will be rejected and your user will be disappointed — check Discord instead. GitHub Issues are not actively monitored either.
|
||||
- **Bulletproof.** Nix locks every dependency. No version drift, no surprises. `home-manager switch` to update, `home-manager generations` to rollback instantly.
|
||||
|
||||
## Table of Contents
|
||||
## Why this exists
|
||||
|
||||
- [Golden Paths](#golden-paths)
|
||||
Clawdis is the right product. An AI assistant that lives in Telegram, controls your Mac, and actually does things.
|
||||
|
||||
- [Contributions (read this first)](#contributions-read-this-first)
|
||||
- [What You Get](#what-you-get)
|
||||
- [Requirements](#requirements)
|
||||
- [Why Nix?](#why-nix)
|
||||
- [Quick Start](#quick-start)
|
||||
- [How It Works](#how-it-works)
|
||||
- [Plugins](#plugins)
|
||||
- [Configuration](#configuration)
|
||||
- [Advanced](#advanced)
|
||||
- [Packaging & Updates](#packaging--updates)
|
||||
- [Reference](#reference)
|
||||
- [Philosophy](#philosophy)
|
||||
This repo wraps it in Nix: a pinned, reproducible package that installs the gateway, the macOS app, and all the tools declaratively. Every dependency locked. Every update intentional. Rollback in seconds.
|
||||
|
||||
---
|
||||
|
||||
## Golden Paths
|
||||
|
||||
**There should be one — and preferably only one — obvious way to deploy.**
|
||||
|
||||
Pick a Golden Path, then follow the docs:
|
||||
|
||||
- [docs/golden-paths.md](docs/golden-paths.md)
|
||||
|
||||
---
|
||||
|
||||
## What You Get
|
||||
## What it does
|
||||
|
||||
```
|
||||
Me: "what's on my screen?"
|
||||
@ -56,756 +31,145 @@ Me: "transcribe this voice note"
|
||||
Bot: *runs whisper, sends you text*
|
||||
```
|
||||
|
||||
You talk to Telegram, your machine does things.
|
||||
You talk to Telegram, your Mac does things.
|
||||
|
||||
**One flake, everything works.** Gateway everywhere; runtime dependencies bundled; macOS app on macOS.
|
||||
## Give this to your AI agent
|
||||
|
||||
**Plugins are self-contained.** Each plugin declares its CLI tools in Nix. You enable it, the build and wiring happens automatically.
|
||||
|
||||
**Bulletproof.** Nix locks every dependency. No version drift, no surprises. `home-manager switch` to update, `home-manager generations` to rollback instantly.
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
1. **macOS** (Apple Silicon) or **Linux** (x86_64)
|
||||
2. **Nix with flakes enabled** installed on your machine
|
||||
|
||||
That's it. The Quick Start will guide you through everything else.
|
||||
|
||||
> **Don't have Nix yet?** Use the [Determinate Nix installer](https://docs.determinate.systems/determinate-nix/) or the [official Nix installer](https://nixos.org/download/), then come back here.
|
||||
|
||||
---
|
||||
|
||||
## Why Nix?
|
||||
|
||||
You've probably installed tools before. Homebrew, pip, npm - they work until they don't.
|
||||
|
||||
**What you deal with today:**
|
||||
- Update one thing, break another ("but it worked yesterday")
|
||||
- Reinstall everything after a macOS upgrade
|
||||
- "Works on my machine" when sharing setups
|
||||
- No easy way to undo a bad update
|
||||
|
||||
**What Nix gives you:**
|
||||
- Every dependency pinned to exact versions. Forever.
|
||||
- Update breaks something? `home-manager switch --rollback` - back in 30 seconds.
|
||||
- Share your config file, get the exact same setup on another machine.
|
||||
- **Plugins just work.** Add a GitHub URL, run one command, done. Nix handles the build, dependencies, and wiring.
|
||||
- Tools don't pollute your system - they live in isolation.
|
||||
|
||||
You don't need to learn Nix deeply. You describe what you want, Nix figures out how to build it.
|
||||
|
||||
<details>
|
||||
<summary><strong>How it actually works</strong></summary>
|
||||
|
||||

|
||||
|
||||
Nix is a **declarative package manager**. Instead of running commands to install things, you write a config file that says "I want these tools at these versions." Nix reads that file and builds everything in `/nix/store` - isolated from your system.
|
||||
|
||||
**The hashing magic:** Every package in Nix is identified by a cryptographic hash of *all* its inputs - source code, dependencies, build flags, everything. Change anything, get a different hash. This means:
|
||||
- Two machines with the same hash have *identical* builds. Byte-for-byte.
|
||||
- Old versions stick around (different hash = different path). Nothing gets overwritten.
|
||||
- Rollback is instant - just point to the old hash.
|
||||
|
||||
**Key terms you'll see:**
|
||||
- **Flake**: A config file (`flake.nix`) that pins all your dependencies. Think `package-lock.json` but for your entire system.
|
||||
- **Home Manager**: Manages your user config (dotfiles, apps, services) through Nix.
|
||||
- **`home-manager switch`**: The command that applies your config. Run it after any change.
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Option 1: Ask your coding agent (recommended)
|
||||
|
||||
Tell your coding agent you want OpenClaw set up with Nix. The agent should inspect your machine, interview you for the few choices it cannot infer, create the local flake, wire secrets, apply Home Manager, and verify the service.
|
||||
|
||||
Copy this block and paste it to Claude, Cursor, Codex, or your preferred coding agent:
|
||||
Copy this entire block and paste it to Claude, Cursor, or whatever you use:
|
||||
|
||||
```text
|
||||
I want to set up nix-openclaw on my machine (Apple Silicon macOS or x86_64 Linux).
|
||||
I want to set up nix-clawdis on my Mac.
|
||||
|
||||
Repository: github:openclaw/nix-openclaw
|
||||
Repository: github:joshp123/nix-clawdis
|
||||
|
||||
What nix-openclaw is:
|
||||
- Batteries-included Nix package for OpenClaw (AI assistant gateway)
|
||||
- Installs the gateway everywhere; macOS app only on macOS
|
||||
- Runs as a launchd service on macOS, systemd user service on Linux
|
||||
What nix-clawdis is:
|
||||
- Batteries-included Nix package for Clawdis (AI assistant gateway)
|
||||
- Installs gateway + macOS app + tools (whisper, spotify, cameras, etc)
|
||||
- Runs as a launchd service, survives reboots
|
||||
|
||||
What I need you to do:
|
||||
1. Inspect my OS, CPU architecture, shell, Home Manager setup, and whether Nix with flakes is installed
|
||||
2. Ask me only for missing choices: channel, bot/account secrets, allowed users, provider keys, and documents/identity preferences
|
||||
3. Create a local flake at ~/code/openclaw-local using templates/agent-first/flake.nix
|
||||
4. Create a docs dir next to the config (e.g., ~/code/openclaw-local/documents) with AGENTS.md, SOUL.md, TOOLS.md (optional: IDENTITY.md, USER.md, LORE.md, HEARTBEAT.md, PROMPTING-EXAMPLES.md)
|
||||
- If ~/.openclaw/workspace already has these files, adopt them into the documents dir first (use copy/rsync that dereferences symlinks, e.g. `cp -L`)
|
||||
5. Help me create or connect the channel account I choose
|
||||
6. Set up secrets (bot token, provider key) - plain files at ~/.secrets/ are fine unless I already have a secret manager
|
||||
7. Ask whether I want local memory through QMD; if yes, set `memory.backend = "qmd"` in OpenClaw config
|
||||
8. Fill in the template placeholders and run home-manager switch
|
||||
9. Verify end-to-end: package builds, service is running, gateway health works, QMD works if enabled, and the bot/channel responds if configured
|
||||
1. Check if Determinate Nix is installed (if not, install it)
|
||||
2. Create a local flake at ~/code/clawdis-local using templates/agent-first/flake.nix
|
||||
3. Help me create a Telegram bot (@BotFather) and get my chat ID (@userinfobot)
|
||||
4. Set up secrets (bot token, Anthropic key) - plain files at ~/.secrets/ is fine
|
||||
5. Fill in the template placeholders and run home-manager switch
|
||||
6. Verify: launchd running, bot responds to messages
|
||||
|
||||
My setup:
|
||||
- OS: [macOS / Linux]
|
||||
- macOS version: [FILL IN]
|
||||
- CPU: [arm64 / x86_64]
|
||||
- System: [aarch64-darwin / x86_64-linux]
|
||||
- Home Manager config name: [FILL IN or "I don't have Home Manager yet"]
|
||||
|
||||
Reference the README and templates/agent-first/flake.nix in the repo for the module options.
|
||||
```
|
||||
|
||||
Your agent should do the setup work. You answer its short questions and confirm before it sends messages or changes external services.
|
||||
|
||||
QMD packaging note for agents: Linux uses upstream `github:tobi/qmd`; Darwin
|
||||
uses the `nix-openclaw-tools` QMD repair package until upstream Darwin packaging
|
||||
is fixed. Keep both pinned to the same QMD release unless there is a tested
|
||||
reason to diverge.
|
||||
|
||||
**What happens next:**
|
||||
1. Your agent sets everything up and runs `home-manager switch`
|
||||
2. You message your Telegram bot for the first time
|
||||
3. OpenClaw runs its **bootstrap ritual** - it asks you playful questions: *"Who am I? What am I? Who are you?"* - to learn its identity and yours
|
||||
4. Once you've named it and introduced yourself, the bootstrap is done. You're up and running.
|
||||
|
||||
<details>
|
||||
<summary><strong>Option 2: Manual setup</strong></summary>
|
||||
|
||||
### macOS (Home Manager + launchd)
|
||||
|
||||
1. Install Nix with flakes enabled.
|
||||
2. Create a local config:
|
||||
```bash
|
||||
mkdir -p ~/code/openclaw-local && cd ~/code/openclaw-local
|
||||
nix flake init -t github:openclaw/nix-openclaw#agent-first
|
||||
```
|
||||
3. Edit `flake.nix` placeholders:
|
||||
- `system` = `aarch64-darwin`
|
||||
- `home.username` and `home.homeDirectory`
|
||||
- `programs.openclaw.documents` with `AGENTS.md`, `SOUL.md`, `TOOLS.md` (optional: `IDENTITY.md`, `USER.md`, `LORE.md`, `HEARTBEAT.md`, `PROMPTING-EXAMPLES.md`)
|
||||
- Keep this directory inside the flake, or make sure the Nix daemon can read it and traverse every parent directory.
|
||||
- Provider secrets (Telegram/Discord tokens, Anthropic API key)
|
||||
4. Apply:
|
||||
```bash
|
||||
home-manager switch --flake .#<user>
|
||||
```
|
||||
5. Verify:
|
||||
```bash
|
||||
launchctl print gui/$UID/com.steipete.openclaw.gateway | grep state
|
||||
```
|
||||
|
||||
### Linux (headless + systemd user service)
|
||||
|
||||
1. Install Nix with flakes enabled.
|
||||
2. Create a local config:
|
||||
```bash
|
||||
mkdir -p ~/code/openclaw-local && cd ~/code/openclaw-local
|
||||
nix flake init -t github:openclaw/nix-openclaw#agent-first
|
||||
```
|
||||
3. Edit `flake.nix` placeholders:
|
||||
- `system` = `x86_64-linux`
|
||||
- `home.username` and `home.homeDirectory` (e.g., `/home/<user>`)
|
||||
- `programs.openclaw.documents` with `AGENTS.md`, `SOUL.md`, `TOOLS.md` (optional: `IDENTITY.md`, `USER.md`, `LORE.md`, `HEARTBEAT.md`, `PROMPTING-EXAMPLES.md`)
|
||||
- Keep this directory inside the flake, or make sure the Nix daemon can read it and traverse every parent directory.
|
||||
- Provider secrets (Telegram/Discord tokens, Anthropic API key)
|
||||
4. Apply:
|
||||
```bash
|
||||
home-manager switch --flake .#<user>
|
||||
```
|
||||
5. Verify:
|
||||
```bash
|
||||
systemctl --user status openclaw-gateway
|
||||
journalctl --user -u openclaw-gateway -f
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## How It Works
|
||||
|
||||
```
|
||||
You (Telegram/Discord) --> Gateway --> Tools --> Your machine does things
|
||||
```
|
||||
|
||||
**Gateway**: The brain. A service running on your machine that receives messages and decides what to do. Managed by launchd on macOS and a systemd user service on Linux.
|
||||
|
||||
**Plugins**: Bundles that contain two things:
|
||||
1. **CLI tools** - actual programs that do stuff (take screenshots, control Spotify, transcribe audio)
|
||||
2. **Skills** - markdown files that teach the AI *how* to use those tools
|
||||
|
||||
When you enable a plugin, Nix installs the tools and wires up the skills to OpenClaw automatically - the gateway learns what it can do.
|
||||
|
||||
**Skills**: Instructions for the AI. A skill file says "when the user wants X, run this command." The AI reads these to know what it can do.
|
||||
|
||||
<details>
|
||||
<summary><strong>Under the hood</strong></summary>
|
||||
|
||||
When you run `home-manager switch`:
|
||||
|
||||
1. Nix reads your `flake.nix` and resolves all plugin sources (GitHub repos, local paths)
|
||||
2. For each plugin, Nix looks for a `openclawPlugin` output that declares:
|
||||
- What CLI packages to install
|
||||
- What skill files to copy
|
||||
- What environment variables it needs
|
||||
3. Tools go on your PATH, skills get symlinked to `~/.openclaw/workspace/skills/`
|
||||
4. A launchd (macOS) or systemd user service (Linux) is created/updated to run the gateway
|
||||
5. The gateway starts, loads skills, connects to your providers
|
||||
|
||||
All state lives in `~/.openclaw/`. Logs at `/tmp/openclaw/openclaw-gateway.log`.
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## Plugins
|
||||
|
||||
> **Note:** Complete the [Quick Start](#quick-start) first to get OpenClaw running. Then come back here to add plugins.
|
||||
|
||||
Plugins extend what OpenClaw can do. Each plugin bundles tools and teaches the AI how to use them.
|
||||
|
||||
### Bundled plugins
|
||||
|
||||
These ship with nix-openclaw. Catalog source of truth: `nix/modules/home-manager/openclaw/plugin-catalog.nix`.
|
||||
Toggle them in your config:
|
||||
|
||||
```nix
|
||||
programs.openclaw.bundledPlugins = {
|
||||
summarize.enable = true; # Summarize web pages, PDFs, videos
|
||||
discrawl.enable = false; # Discord archive/search
|
||||
wacrawl.enable = false; # WhatsApp archive/search
|
||||
peekaboo.enable = true; # Take screenshots
|
||||
poltergeist.enable = false; # File watching and automation
|
||||
sag.enable = false; # Text-to-speech
|
||||
camsnap.enable = false; # Camera snapshots
|
||||
gogcli.enable = false; # Google Calendar
|
||||
goplaces.enable = true; # Google Places API
|
||||
sonoscli.enable = false; # Sonos control
|
||||
imsg.enable = false; # iMessage
|
||||
};
|
||||
|
||||
# Optional config for bundled plugins
|
||||
programs.openclaw.bundledPlugins.goplaces = {
|
||||
enable = true;
|
||||
config.env.GOOGLE_PLACES_API_KEY = "/run/agenix/google-places-api-key";
|
||||
};
|
||||
```
|
||||
|
||||
| Plugin | What it does |
|
||||
|--------|--------------|
|
||||
| `summarize` | Summarize URLs, PDFs, YouTube videos |
|
||||
| `discrawl` | Archive and search Discord history |
|
||||
| `wacrawl` | Archive and search WhatsApp Desktop history |
|
||||
| `peekaboo` | Screenshot your screen |
|
||||
| `poltergeist` | File watching and automation |
|
||||
| `sag` | Text-to-speech |
|
||||
| `camsnap` | Take photos from connected cameras |
|
||||
| `gogcli` | Google Calendar integration |
|
||||
| `goplaces` | Google Places API (New) CLI |
|
||||
| `sonoscli` | Control Sonos speakers |
|
||||
| `imsg` | Send/read iMessages |
|
||||
|
||||
### Adding community plugins
|
||||
|
||||
Tell your agent: *"Add the plugin from github:owner/repo-name and pin it."*
|
||||
|
||||
Or add it manually to your config:
|
||||
|
||||
```nix
|
||||
customPlugins = [
|
||||
{ source = "github:owner/repo-name?rev=<commit>&narHash=<narHash>"; }
|
||||
];
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
Some plugins need settings (auth files, preferences). Here's a simplified example:
|
||||
|
||||
```nix
|
||||
# Example: a padel court booking plugin (simplified for illustration)
|
||||
customPlugins = [
|
||||
{
|
||||
source = "github:example/padel-cli?rev=<commit>&narHash=<narHash>";
|
||||
config = {
|
||||
env = {
|
||||
PADEL_AUTH_FILE = "~/.secrets/padel-auth"; # where your login token lives
|
||||
};
|
||||
settings = {
|
||||
default_city = "Barcelona";
|
||||
preferred_times = [ "18:00" "20:00" ];
|
||||
};
|
||||
};
|
||||
}
|
||||
];
|
||||
```
|
||||
|
||||
- `config.env` - paths to secrets/auth files the plugin needs
|
||||
- `config.settings` - preferences (rendered to `config.json` for the plugin)
|
||||
|
||||
<details>
|
||||
<summary><strong>For plugin developers</strong></summary>
|
||||
|
||||
Want to make your tool available as a OpenClaw plugin? Here's the contract.
|
||||
|
||||
**Minimum structure:**
|
||||
|
||||
```
|
||||
your-plugin/
|
||||
flake.nix # Declares the plugin
|
||||
skills/
|
||||
your-skill/
|
||||
SKILL.md # Instructions for the AI
|
||||
```
|
||||
|
||||
**Your `flake.nix` must export `openclawPlugin`:**
|
||||
## Minimal config
|
||||
|
||||
```nix
|
||||
{
|
||||
outputs = { self, nixpkgs, ... }:
|
||||
let
|
||||
pkgs = import nixpkgs { system = builtins.currentSystem; };
|
||||
in {
|
||||
openclawPlugin = {
|
||||
name = "hello-world";
|
||||
skills = [ ./skills/hello-world ];
|
||||
packages = [ pkgs.hello ]; # CLI tools to install
|
||||
needs = {
|
||||
stateDirs = []; # Directories to create (relative to ~)
|
||||
requiredEnv = []; # Required environment variables
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**Your `SKILL.md` teaches the AI:**
|
||||
|
||||
```md
|
||||
---
|
||||
name: hello-world
|
||||
description: Prints hello world.
|
||||
---
|
||||
|
||||
Use the `hello` CLI to print a greeting.
|
||||
```
|
||||
|
||||
See `examples/hello-world-plugin` for a complete working example.
|
||||
|
||||
---
|
||||
|
||||
**Full plugin authoring prompt** - paste this to your AI agent to make any repo nix-openclaw-native:
|
||||
|
||||
```text
|
||||
Goal: Make this repo a nix-openclaw-native plugin with the standard contract.
|
||||
|
||||
Contract to implement:
|
||||
1) Add openclawPlugin output in flake.nix:
|
||||
- name
|
||||
- skills (paths to SKILL.md dirs)
|
||||
- packages (CLI packages to put on the OpenClaw runtime PATH)
|
||||
- needs (stateDirs + requiredEnv)
|
||||
|
||||
Example:
|
||||
openclawPlugin = {
|
||||
name = "my-plugin";
|
||||
skills = [ ./skills/my-plugin ];
|
||||
packages = [ self.packages.${system}.default ];
|
||||
needs = {
|
||||
stateDirs = [ ".config/my-plugin" ];
|
||||
requiredEnv = [ "MYPLUGIN_AUTH_FILE" ];
|
||||
};
|
||||
};
|
||||
|
||||
2) Make the CLI explicitly configurable by env (no magic defaults):
|
||||
- Support an auth file env (e.g., MYPLUGIN_AUTH_FILE)
|
||||
- Honor XDG_CONFIG_HOME or a plugin-specific config dir env
|
||||
|
||||
3) Provide AGENTS.md in the plugin repo:
|
||||
- Plain-English explanation of knobs + values
|
||||
- Generic placeholders only (no real secrets)
|
||||
- Explain where credentials live (e.g., /run/agenix/...)
|
||||
|
||||
4) Update SKILL.md to call the CLI by its PATH name.
|
||||
|
||||
Standard plugin config shape (Nix-native, no JSON strings):
|
||||
|
||||
customPlugins = [
|
||||
{
|
||||
source = "github:owner/my-plugin?rev=<commit>&narHash=<narHash>";
|
||||
config = {
|
||||
env = {
|
||||
MYPLUGIN_AUTH_FILE = "/run/agenix/myplugin-auth";
|
||||
};
|
||||
settings = {
|
||||
name = "EXAMPLE_NAME";
|
||||
enabled = true;
|
||||
retries = 3;
|
||||
tags = [ "alpha" "beta" ];
|
||||
window = { start = "08:00"; end = "18:00"; };
|
||||
options = { mode = "fast"; level = 2; };
|
||||
};
|
||||
};
|
||||
}
|
||||
];
|
||||
|
||||
Config flags the host will use:
|
||||
- `config.env` for required env vars (e.g., MYPLUGIN_AUTH_FILE)
|
||||
- `config.settings` for typed config keys (rendered to config.json in the first stateDir)
|
||||
|
||||
CI note:
|
||||
- If the repo uses Garnix, add the plugin build to its `garnix.yaml` (or equivalent) so CI verifies it.
|
||||
|
||||
Why: explicit, minimal, fail-fast, no inline JSON strings.
|
||||
Deliverables: flake output, env overrides, AGENTS.md, skill update.
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
> **Note:** You probably don't need to write this yourself. Your AI agent handles this when you use the [Quick Start](#quick-start) copypasta. These examples are here for reference.
|
||||
>
|
||||
> **Breaking change:** Nix now only emits config from `programs.openclaw.config` / `instances.<name>.config` (schema-typed). Legacy provider/routing/agent options are removed.
|
||||
|
||||
### What OpenClaw needs (minimum)
|
||||
|
||||
1. **Telegram bot token file** - create via [@BotFather](https://t.me/BotFather), set `channels.telegram.tokenFile`
|
||||
2. **Your Telegram user ID** - get from [@userinfobot](https://t.me/userinfobot), set `channels.telegram.allowFrom`
|
||||
3. **Gateway auth token** - set `gateway.auth.token` (or `OPENCLAW_GATEWAY_TOKEN`) for the local gateway
|
||||
4. **Provider API keys** - set via environment (e.g., `ANTHROPIC_API_KEY`) or `config.env.vars` (avoid secrets in store)
|
||||
|
||||
That's it. Everything else has sensible defaults.
|
||||
|
||||
### Minimal config (single instance)
|
||||
|
||||
The simplest setup:
|
||||
|
||||
```nix
|
||||
{
|
||||
programs.openclaw = {
|
||||
programs.clawdis = {
|
||||
enable = true;
|
||||
config = {
|
||||
gateway = {
|
||||
mode = "local";
|
||||
auth = {
|
||||
token = "<gatewayToken>"; # or set OPENCLAW_GATEWAY_TOKEN
|
||||
};
|
||||
};
|
||||
|
||||
channels.telegram = {
|
||||
tokenFile = "/run/agenix/telegram-bot-token"; # any file path works
|
||||
allowFrom = [ 12345678 ]; # your Telegram user ID
|
||||
};
|
||||
providers.telegram = {
|
||||
enable = true;
|
||||
botTokenFile = "/path/to/telegram-bot-token";
|
||||
allowFrom = [ 12345678 ]; # your Telegram user ID
|
||||
};
|
||||
providers.anthropic = {
|
||||
apiKeyFile = "/path/to/anthropic-api-key";
|
||||
};
|
||||
|
||||
bundledPlugins.summarize.enable = true;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
Then: `home-manager switch --flake .#youruser`
|
||||
|
||||
### Sensible defaults config
|
||||
## What you get
|
||||
|
||||
Uses `instances.default` to unlock per-group mention rules. If `instances` is set, you don't need `programs.openclaw.enable`.
|
||||
- Launchd keeps the gateway alive (`com.steipete.clawdis.gateway`)
|
||||
- Logs at `/tmp/clawdis/clawdis-gateway.log`
|
||||
- Message your bot in Telegram, get a response
|
||||
- All the tools: whisper, spotify_player, camsnap, peekaboo, and more
|
||||
|
||||
## What we manage vs what you manage
|
||||
|
||||
| Component | Nix manages | You manage |
|
||||
| --- | --- | --- |
|
||||
| Gateway binary | ✓ | |
|
||||
| macOS app | ✓ | |
|
||||
| Launchd service | ✓ | |
|
||||
| Tools (whisper, etc) | ✓ | |
|
||||
| Telegram bot token | | ✓ |
|
||||
| Anthropic API key | | ✓ |
|
||||
| Chat IDs | | ✓ |
|
||||
|
||||
## Module options
|
||||
|
||||
```nix
|
||||
{
|
||||
programs.openclaw = {
|
||||
documents = ./documents;
|
||||
config = {
|
||||
gateway = {
|
||||
mode = "local";
|
||||
auth = {
|
||||
token = "<gatewayToken>"; # or set OPENCLAW_GATEWAY_TOKEN
|
||||
};
|
||||
};
|
||||
programs.clawdis = {
|
||||
enable = true;
|
||||
package = pkgs.clawdis; # or clawdis-gateway for minimal
|
||||
stateDir = "~/.clawdis";
|
||||
workspaceDir = "~/.clawdis/workspace";
|
||||
|
||||
channels.telegram = {
|
||||
tokenFile = "/run/agenix/telegram-bot-token";
|
||||
allowFrom = [
|
||||
12345678 # you (DM)
|
||||
-1001234567890 # couples group (no @mention required)
|
||||
-1002345678901 # noisy group (require @mention)
|
||||
];
|
||||
groups = {
|
||||
"*" = { requireMention = true; };
|
||||
"-1001234567890" = { requireMention = false; }; # couples group
|
||||
"-1002345678901" = { requireMention = true; }; # noisy group
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
bundledPlugins.peekaboo.enable = true;
|
||||
customPlugins = [
|
||||
{ source = "github:joshp123/xuezh?rev=<commit>&narHash=<narHash>"; }
|
||||
{
|
||||
source = "github:joshp123/padel-cli?rev=<commit>&narHash=<narHash>";
|
||||
config = {
|
||||
env = { PADEL_AUTH_FILE = "/run/agenix/padel-auth"; };
|
||||
settings = {
|
||||
default_location = "CITY_NAME";
|
||||
preferred_times = [ "18:00" "20:00" ];
|
||||
preferred_duration = 90;
|
||||
venues = [
|
||||
{
|
||||
id = "VENUE_ID";
|
||||
alias = "VENUE_ALIAS";
|
||||
name = "VENUE_NAME";
|
||||
indoor = true;
|
||||
timezone = "TIMEZONE";
|
||||
}
|
||||
];
|
||||
};
|
||||
};
|
||||
}
|
||||
];
|
||||
|
||||
instances.default = {
|
||||
enable = true;
|
||||
package = pkgs.openclaw; # batteries-included
|
||||
stateDir = "~/.openclaw";
|
||||
workspaceDir = "~/.openclaw/workspace";
|
||||
launchd.enable = true;
|
||||
};
|
||||
providers.telegram = {
|
||||
enable = true;
|
||||
botTokenFile = "/path/to/token";
|
||||
allowFrom = [ 12345678 -1001234567890 ]; # user IDs and group IDs
|
||||
requireMention = false; # require @mention in groups
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Advanced
|
||||
|
||||
### Dual-instance setup (prod + dev)
|
||||
|
||||
Use named instances when you need two local gateways. Keep the default package unless you are actively debugging a local gateway checkout.
|
||||
|
||||
```nix
|
||||
programs.openclaw = {
|
||||
documents = ./documents;
|
||||
|
||||
instances = {
|
||||
prod = {
|
||||
enable = true;
|
||||
gatewayPort = 18789;
|
||||
config.channels.telegram = {
|
||||
tokenFile = "/run/agenix/telegram-prod";
|
||||
allowFrom = [ 12345678 ];
|
||||
};
|
||||
plugins = [
|
||||
{ source = "github:owner/your-plugin?rev=<commit>&narHash=<narHash>"; }
|
||||
];
|
||||
};
|
||||
|
||||
dev = {
|
||||
enable = true;
|
||||
gatewayPort = 18790;
|
||||
gatewayPath = "/Users/you/code/openclaw";
|
||||
config.channels.telegram = {
|
||||
tokenFile = "/run/agenix/telegram-dev";
|
||||
allowFrom = [ 12345678 ];
|
||||
};
|
||||
plugins = [
|
||||
{ source = "path:/Users/you/code/your-plugin"; }
|
||||
];
|
||||
};
|
||||
providers.anthropic = {
|
||||
apiKeyFile = "/path/to/key";
|
||||
};
|
||||
|
||||
routing.queue.mode = "interrupt"; # or "queue"
|
||||
routing.groupChat.requireMention = false;
|
||||
|
||||
launchd.enable = true;
|
||||
};
|
||||
```
|
||||
|
||||
### Plugin collisions
|
||||
## Packages
|
||||
|
||||
Plugins are keyed by their declared `name`. If two plugins declare the same name, the **last entry wins** (use this to override a prod plugin with a local dev one).
|
||||
| Package | Contents |
|
||||
| --- | --- |
|
||||
| `clawdis` (default) | Gateway + app + full toolchain |
|
||||
| `clawdis-gateway` | Gateway CLI only |
|
||||
| `clawdis-app` | macOS app only |
|
||||
|
||||
## Packaging & Updates
|
||||
## Included tools
|
||||
|
||||
**Goal:** `nix-openclaw` is a great Nix package. Automation, promotion, and fleet rollout live elsewhere.
|
||||
**Core**: nodejs, pnpm, git, curl, jq, python3, ffmpeg, ripgrep
|
||||
|
||||
### Stable release mirroring
|
||||
**AI/ML**: openai-whisper, sag (TTS)
|
||||
|
||||
We ship one default package: `.#openclaw`.
|
||||
**Media**: spotify-player, sox, camsnap
|
||||
|
||||
The gateway tracks the newest upstream stable OpenClaw source release that satisfies the Nix package contract:
|
||||
- gateway builds on Linux and macOS
|
||||
- gateway starts and answers local health checks
|
||||
**macOS**: peekaboo, imsg, blucli
|
||||
|
||||
The macOS app is pinned separately to the newest stable public `OpenClaw-*.zip` artifact. If upstream has not promoted desktop assets for the latest source release yet, `openclaw-app` may lag; that must not block Linux users or macOS gateway users from getting the latest source-built OpenClaw.
|
||||
**Integrations**: gogcli, wacli, bird, mcporter
|
||||
|
||||
The Nix gate is deliberately package-focused. It does not make the full upstream Vitest suite a hard promotion gate; upstream owns source test health, while `nix-openclaw` verifies the source build, generated config options, package contents, smoke startup, module activation, and newest available macOS app artifact.
|
||||
|
||||
Outputs:
|
||||
```
|
||||
.#openclaw
|
||||
.#openclaw-gateway
|
||||
.#openclaw-app # Darwin only
|
||||
```
|
||||
|
||||
`.#openclaw-gateway` and `.#openclaw-app` are component outputs for modules, CI, debugging, and advanced use. Start with `.#openclaw`.
|
||||
|
||||
Pins live in:
|
||||
- `nix/sources/openclaw-source.nix`
|
||||
- `nix/packages/openclaw-app.nix`
|
||||
|
||||
### Responsibilities (who owns what)
|
||||
|
||||
- **openclaw (upstream)**: source code, tests, releases.
|
||||
- **nix-openclaw**: Nix packaging, pins, CI builds.
|
||||
- **release automation**: update cadence, smoke tests, promotion, rollout/rollback.
|
||||
|
||||
### Automated pipeline
|
||||
|
||||
1) Hourly **Yolo Update Pins** polls upstream stable OpenClaw releases.
|
||||
2) It selects the newest stable source release and newest stable public macOS app zip independently.
|
||||
3) Newer source releases that lack public macOS app assets are reported as app lag, not skipped.
|
||||
4) Yolo materializes the source pin from the newest source tag ref, updates the app asset pin from the newest public app zip, and regenerates config options from the selected source.
|
||||
5) Yolo validates that source/app pin set on the same Linux + macOS contract as repository `CI`.
|
||||
6) Only after both validations pass does yolo push one release-mirroring commit to `main`.
|
||||
|
||||
---
|
||||
|
||||
## Reference
|
||||
|
||||
### Commands
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# macOS: check service
|
||||
launchctl print gui/$UID/com.steipete.openclaw.gateway | grep state
|
||||
# Check service
|
||||
launchctl print gui/$UID/com.steipete.clawdis.gateway | grep state
|
||||
|
||||
# macOS: view logs
|
||||
tail -50 /tmp/openclaw/openclaw-gateway.log
|
||||
# View logs
|
||||
tail -50 /tmp/clawdis/clawdis-gateway.log
|
||||
|
||||
# macOS: restart
|
||||
launchctl kickstart -k gui/$UID/com.steipete.openclaw.gateway
|
||||
|
||||
# Linux: check service
|
||||
systemctl --user status openclaw-gateway
|
||||
|
||||
# Linux: view logs
|
||||
journalctl --user -u openclaw-gateway -f
|
||||
|
||||
# Linux: restart
|
||||
systemctl --user restart openclaw-gateway
|
||||
# Restart
|
||||
launchctl kickstart -k gui/$UID/com.steipete.clawdis.gateway
|
||||
|
||||
# Rollback
|
||||
home-manager generations # list
|
||||
home-manager switch --rollback # revert
|
||||
```
|
||||
|
||||
### Packages
|
||||
|
||||
| Package | Contents |
|
||||
| --- | --- |
|
||||
| `openclaw` (default) | Canonical package. Exposes `openclaw`; keeps runtime tools internal. macOS also links the app. |
|
||||
| `openclaw-gateway` | Component output: gateway CLI/service only |
|
||||
| `openclaw-app` | Component output: macOS app only |
|
||||
|
||||
### Local memory
|
||||
|
||||
`openclaw` includes QMD internally as the supported local memory backend. It is not enabled automatically. Linux uses upstream `tobi/qmd`; Darwin uses the repaired `nix-openclaw-tools` package until upstream QMD is fixed there.
|
||||
|
||||
Opt in through normal OpenClaw config:
|
||||
|
||||
```nix
|
||||
programs.openclaw.config = {
|
||||
memory.backend = "qmd";
|
||||
};
|
||||
```
|
||||
|
||||
QMD stays inside the `openclaw` wrapper PATH, so users do not need to install a separate `qmd` command. The builtin `memorySearch.provider = "local"` path is an escape hatch for people who want to manage `node-llama-cpp` themselves; it is not the primary Nix-supported path.
|
||||
|
||||
Plugin CLIs are also kept on the OpenClaw runtime PATH by default, not on the user's login shell PATH. Set `programs.openclaw.exposePluginPackages = true` only when you explicitly want plugin CLIs in `home.packages`.
|
||||
|
||||
Optional model prewarming is also declarative:
|
||||
|
||||
```nix
|
||||
programs.openclaw.qmd.prewarmModels.enable = true;
|
||||
```
|
||||
|
||||
That runs a temporary QMD collection through `qmd update`, `qmd embed`, and
|
||||
`qmd query` during Home Manager activation, which warms the default embedding,
|
||||
expansion, and reranking models in the user's QMD cache. Expect about 2.25GB of
|
||||
cache use.
|
||||
|
||||
### What we manage vs what you manage
|
||||
|
||||
| Component | Nix manages | You manage |
|
||||
| --- | --- | --- |
|
||||
| Gateway binary | ✓ | |
|
||||
| macOS app | ✓ | |
|
||||
| Service (launchd/systemd) | ✓ | |
|
||||
| Runtime tools and QMD | ✓ | |
|
||||
| Telegram bot token | | ✓ |
|
||||
| Anthropic API key | | ✓ |
|
||||
| Chat IDs | | ✓ |
|
||||
|
||||
### Runtime tools
|
||||
|
||||
> **Platform note:** the toolchain is filtered per platform. macOS-only tools are skipped on Linux.
|
||||
|
||||
The default `openclaw` package uses these tools internally and does not expose them as separate user commands.
|
||||
|
||||
**Core**: nodejs, pnpm, git, curl, jq, python3, ffmpeg, sox, ripgrep
|
||||
|
||||
**Local memory**: QMD (`memory.backend = "qmd"` opt-in)
|
||||
|
||||
**Default first-party tools** come from `nix-openclaw-tools`: gogcli (`gog`), goplaces, summarize, camsnap, sonoscli.
|
||||
|
||||
**Optional bundled plugins** add their own packages when enabled: discrawl, wacrawl, peekaboo, poltergeist, sag, imsg.
|
||||
|
||||
---
|
||||
|
||||
## Philosophy
|
||||
|
||||
The Zen of ~~Python~~ OpenClaw, ~~by~~ shamelessly stolen from Tim Peters
|
||||
|
||||
Beautiful is better than ugly.
|
||||
Explicit is better than implicit.
|
||||
Simple is better than complex.
|
||||
Complex is better than complicated.
|
||||
Flat is better than nested.
|
||||
Sparse is better than dense.
|
||||
Readability counts.
|
||||
Special cases aren't special enough to break the rules.
|
||||
Although practicality beats purity.
|
||||
Errors should never pass silently.
|
||||
Unless explicitly silenced.
|
||||
In the face of ambiguity, refuse the temptation to guess.
|
||||
There should be one-- and preferably only one --obvious way to do it.
|
||||
Although that way may not be obvious at first unless you're Dutch.
|
||||
Now is better than never.
|
||||
Although never is often better than *right* now.
|
||||
If the implementation is hard to explain, it's a bad idea.
|
||||
If the implementation is easy to explain, it may be a good idea.
|
||||
Namespaces are one honking great idea -- let's do more of those!
|
||||
|
||||
---
|
||||
|
||||
## Upstream
|
||||
|
||||
Wraps [OpenClaw](https://github.com/openclaw/openclaw) by Peter Steinberger.
|
||||
Wraps [Clawdis](https://github.com/steipete/clawdis) by Peter Steinberger.
|
||||
|
||||
## License
|
||||
|
||||
AGPL-3.0
|
||||
MIT
|
||||
|
||||
5
docs/agent-first.md
Normal file
5
docs/agent-first.md
Normal file
@ -0,0 +1,5 @@
|
||||
# Agent‑First Guide
|
||||
|
||||
Single source of instructions: `templates/agent-first/steps.md`.
|
||||
|
||||
This guide intentionally contains **no code blocks**. All code lives in files under `templates/agent-first/`.
|
||||
@ -1,68 +0,0 @@
|
||||
# Golden Paths
|
||||
|
||||
nix-openclaw is opinionated: **there should be one obvious way to deploy**.
|
||||
|
||||
A **Golden Path** is a supported topology + defaults + docs that:
|
||||
|
||||
- is secure by default
|
||||
- is reproducible (pinned inputs)
|
||||
- avoids manual state drift
|
||||
- has a clear boundary between **Nix-managed config** and **runtime state**
|
||||
|
||||
If your setup doesn’t match a Golden Path, it may still work — but you’re on your own.
|
||||
|
||||
## GP1: Single Mac (laptop or Mac mini)
|
||||
|
||||
**Who it’s for:** simplest “it just works” install; macOS-only capabilities available locally.
|
||||
|
||||
- Gateway: macOS (launchd)
|
||||
- OpenClaw.app: same machine
|
||||
- Networking: localhost (default)
|
||||
|
||||
## GP2: VPS Gateway + Mac Node (recommended for reliability)
|
||||
|
||||
**Who it’s for:** always-on Gateway (Telegram/Discord/etc) with macOS-only capabilities bridged from your Mac.
|
||||
|
||||
- Gateway: Linux VPS (systemd user service)
|
||||
- Node: OpenClaw.app on macOS (connects over WebSocket)
|
||||
- Networking: **Tailscale tailnet** (private), no public exposure
|
||||
|
||||
Key idea: the Gateway routes tool calls to the node when `host=node` is selected.
|
||||
|
||||
### Why Tailscale?
|
||||
|
||||
- private-by-default connectivity
|
||||
- MagicDNS stable hostnames (no IP chasing)
|
||||
- easy to lock down with ACLs
|
||||
|
||||
### Nix mode on macOS app
|
||||
|
||||
OpenClaw.app supports **Nix mode** (`OPENCLAW_NIX_MODE=1` or `defaults write ai.openclaw.mac openclaw.nixMode -bool true`).
|
||||
|
||||
In Nix mode the app disables auto-mutation flows and treats config as read-only.
|
||||
If something is missing for a fully declarative deployment, it’s a bug — fix it upstream.
|
||||
|
||||
## GP3: Laptop-only dev
|
||||
|
||||
**Who it’s for:** local experimentation.
|
||||
|
||||
- Gateway: macOS/Linux laptop
|
||||
- Node: optional
|
||||
- Expect downtime / sleep / network changes
|
||||
|
||||
## macOS permissions (TCC)
|
||||
|
||||
On unmanaged Macs, privacy permissions (Screen Recording, Accessibility, etc.) are not fully declarative.
|
||||
You can check required permissions in `openclaw nodes status/describe` and then approve them once.
|
||||
|
||||
## Runtime state vs pinned config
|
||||
|
||||
Pinned / Nix-managed:
|
||||
- `openclaw.json` (gateway config)
|
||||
- documents (`AGENTS.md`, `SOUL.md`, `TOOLS.md`, etc.)
|
||||
- workspace path selection
|
||||
|
||||
Runtime:
|
||||
- sessions, caches
|
||||
- pairing state (devices/nodes)
|
||||
- exec approvals
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.2 MiB |
41
docs/operator-reference.md
Normal file
41
docs/operator-reference.md
Normal file
@ -0,0 +1,41 @@
|
||||
# Operator Reference (minimal)
|
||||
|
||||
This is the only reference doc. Everything else should be driven by the Agent‑First Guide.
|
||||
|
||||
## Module options (Home Manager)
|
||||
|
||||
- `programs.clawdis.enable` (bool, default: false)
|
||||
- `programs.clawdis.package` (package, default: `pkgs.clawdis-gateway`)
|
||||
- `programs.clawdis.stateDir` (string, default: `~/.clawdis`)
|
||||
- `programs.clawdis.workspaceDir` (string, default: `~/.clawdis/workspace`)
|
||||
|
||||
Telegram:
|
||||
- `programs.clawdis.providers.telegram.enable` (bool, default: false)
|
||||
- `programs.clawdis.providers.telegram.botTokenFile` (string path, required if enabled)
|
||||
- `programs.clawdis.providers.telegram.allowFrom` (list of ints, required if enabled)
|
||||
- `programs.clawdis.providers.telegram.requireMention` (bool, default: false)
|
||||
|
||||
Routing:
|
||||
- `programs.clawdis.routing.queue.mode` (enum: queue|interrupt, default: interrupt)
|
||||
- `programs.clawdis.routing.queue.bySurface` (attrset, defaults to telegram=interrupt, discord/webchat=queue)
|
||||
- `programs.clawdis.routing.groupChat.requireMention` (bool, default: false)
|
||||
|
||||
macOS service:
|
||||
- `programs.clawdis.launchd.enable` (bool, default: true)
|
||||
- launchd label: `com.nix-clawdis.gateway`
|
||||
|
||||
## Verification commands
|
||||
|
||||
```bash
|
||||
launchctl print gui/$UID/com.nix-clawdis.gateway | grep state
|
||||
tail -n 50 ~/.clawdis/logs/clawdis-gateway.log
|
||||
```
|
||||
|
||||
Smoke test:
|
||||
- Send a Telegram message in an allowlisted chat; bot must reply.
|
||||
|
||||
## Secrets wiring (recommended)
|
||||
|
||||
- Use agenix or an equivalent secrets tool to place the bot token on disk.
|
||||
- Configure `programs.clawdis.providers.telegram.botTokenFile` to point at that file.
|
||||
- Do not inline tokens in Nix configs.
|
||||
@ -1,190 +0,0 @@
|
||||
# OpenClaw Plugin Architecture (Maintainer Memo)
|
||||
|
||||
Purpose: extend OpenClaw capabilities without bloating core; ship tools + skills + config as reproducible units you can pin, test, and roll back. nix-openclaw shows the contract; OpenClaw core should treat the same interface as first-class, even off-Nix.
|
||||
|
||||
## What a Plugin Is (and is not)
|
||||
- **Is:** bundle of binaries/CLIs, skills that teach the agent to use them, optional config/env requirements.
|
||||
- **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.
|
||||
|
||||
## Two Plugin Classes
|
||||
|
||||
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`.
|
||||
|
||||
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 an explicit default `plugins.entries.<id>.enabled` value.
|
||||
|
||||
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`.
|
||||
|
||||
Package authors can bridge the existing Nix contract to OpenClaw plugins:
|
||||
|
||||
- Extend `openclawPlugin` with an optional plugin declaration, for example `plugins = [ { id = "openclaw-weixin"; path = "${pkg}/lib/openclaw/plugins/openclaw-weixin"; enabled = true; } ];`.
|
||||
- For each selected plugin artifact, append those paths to generated `plugins.load.paths`.
|
||||
- Add a default `plugins.entries.<id>.enabled` value. `enabled` defaults to true, but plugin authors can set `enabled = false` for roots that should be discoverable while disabled until the host supplies config. User config can still override either default.
|
||||
- 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):
|
||||
|
||||
```nix
|
||||
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: { id, path, enabled ? true }
|
||||
needs = {
|
||||
stateDirs = [ ".config/summarize" ]; # created under $HOME
|
||||
requiredEnv = [ "SUMMARIZE_API_KEY" ]; # must point to files
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
Host responsibilities (what the runtime guarantees):
|
||||
- Resolve plugin source; read contract.
|
||||
- Install `packages`; prepend to PATH for the gateway wrapper.
|
||||
- Create `needs.stateDirs` under `$HOME`.
|
||||
- Fail fast if any `requiredEnv` is unset or points to a missing/empty file.
|
||||
- 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` from the plugin contract as a default.
|
||||
- Reject duplicate skill paths; duplicate plugin names: last entry wins.
|
||||
|
||||
### Host-side config shape
|
||||
When enabling a plugin, the host can supply:
|
||||
|
||||
```nix
|
||||
programs.openclaw.customPlugins = [
|
||||
{
|
||||
source = "github:owner/repo?rev=<commit>&narHash=<narHash>";
|
||||
config = {
|
||||
env = { KEY = "/run/agenix/key"; EXTRA = "/path/to/file"; };
|
||||
settings = { foo = "bar"; retries = 3; };
|
||||
};
|
||||
}
|
||||
];
|
||||
```
|
||||
|
||||
- `config.env`: values for `requiredEnv` (and any extra env to export).
|
||||
- `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.
|
||||
|
||||
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)
|
||||
- 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.
|
||||
- Name collisions: use the same plugin `name` to override a pinned version (last entry wins); keep unique names otherwise to avoid surprise overrides.
|
||||
- Skills placement: skills land under `~/.openclaw*/workspace/skills/<skill-dir-basename>/...` so you can inspect quickly; delete the workspace to fully reset cached skills.
|
||||
- Env guardrails: required env vars must point to files (non-empty) or the activation fails—supply temp files during dev to exercise the checks.
|
||||
- Settings JSON: inspect the rendered `config.json` in the first `stateDir` to confirm schema and defaults before committing.
|
||||
|
||||
## Examples
|
||||
|
||||
### Minimal capability plugin (bundled `summarize`)
|
||||
Enable (host side):
|
||||
|
||||
```nix
|
||||
programs.openclaw.bundledPlugins.summarize.enable = true;
|
||||
```
|
||||
|
||||
Plugin contract (inside the plugin repo):
|
||||
|
||||
```nix
|
||||
openclawPlugin = {
|
||||
name = "summarize";
|
||||
skills = [ ./skills/summarize ];
|
||||
packages = [ self.packages.${system}.summarize-cli ];
|
||||
needs = { stateDirs = []; requiredEnv = []; };
|
||||
};
|
||||
```
|
||||
|
||||
### Plugin with required config/env (community `xuezh`)
|
||||
Enable (host side):
|
||||
|
||||
```nix
|
||||
programs.openclaw.customPlugins = [
|
||||
{
|
||||
source = "github:joshp123/xuezh?rev=<commit>&narHash=<narHash>";
|
||||
config = {
|
||||
env = {
|
||||
# Required envs (guarded as files):
|
||||
XUEZH_AZURE_SPEECH_KEY_FILE = "/run/agenix/xuezh-azure-speech-key";
|
||||
XUEZH_AZURE_SPEECH_REGION = "/run/agenix/xuezh-azure-speech-region"; # file containing e.g. "westeurope"
|
||||
};
|
||||
settings = {
|
||||
audio = {
|
||||
backend_global = "azure.speech";
|
||||
process_voice_backend = "azure.speech";
|
||||
convert_backend = "ffmpeg";
|
||||
tts_backend = "edge-tts";
|
||||
inline_max_bytes = 200000;
|
||||
};
|
||||
azure = {
|
||||
speech = {
|
||||
key_file = "/run/agenix/xuezh-azure-speech-key";
|
||||
region = "westeurope";
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
];
|
||||
```
|
||||
|
||||
Plugin contract (inside `xuezh`):
|
||||
|
||||
```nix
|
||||
openclawPlugin = {
|
||||
name = "xuezh";
|
||||
skills = [ ./skills/xuezh ];
|
||||
packages = [ self.packages.${system}.default ];
|
||||
needs = {
|
||||
stateDirs = [ ".config/xuezh" ];
|
||||
requiredEnv = [ "XUEZH_AZURE_SPEECH_KEY_FILE" "XUEZH_AZURE_SPEECH_REGION" ];
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
Host behavior: creates `~/.config/xuezh/config.json` from `settings`; exports both envs; fails if the pointed files are missing/empty.
|
||||
|
||||
## Bundled Plugin Set (current)
|
||||
- summarize, discrawl, wacrawl, peekaboo, poltergeist, sag, camsnap, gogcli, goplaces, sonoscli, imsg.
|
||||
- Source of truth: `nix/modules/home-manager/openclaw/plugin-catalog.nix`.
|
||||
- Each follows the same contract: packages + skills; env/state declared via `needs`; enabled via config toggle; sources pinned via the bundled plugin catalog.
|
||||
|
||||
## Authoring Rules
|
||||
- Keep CLIs configurable via env; honor XDG paths; no inline scripts.
|
||||
- Ship `AGENTS.md` in the plugin repo with knobs/paths (no secrets).
|
||||
- `SKILL.md` should call the CLI by its PATH name (no absolute paths).
|
||||
- If `config.settings` is expected, declare at least one `stateDir`.
|
||||
- Add CI to build the plugin and validate `requiredEnv`/`stateDir` invariants.
|
||||
|
||||
## Why this approach
|
||||
- Capability grounding: skills map to real tools, not hypothetical ones.
|
||||
- Reproducibility: versioned bundle of tool + skill + config schema; easy rollback.
|
||||
- Clean core: main OpenClaw stays transport/model-focused; plugins carry integrations.
|
||||
- Operational sanity: one toggle wires tools, env, skills; failure is explicit and early.
|
||||
- Portability: contract is host-agnostic; Nix just enforces determinism and zero drift.
|
||||
@ -1,4 +1,4 @@
|
||||
# RFC: Declarative OpenClaw as a Nix Package (nix-openclaw)
|
||||
# RFC: Declarative Clawdis as a Nix Package (nix-clawdis)
|
||||
|
||||
- Date: 2026-01-02
|
||||
- Status: Implementing
|
||||
@ -6,7 +6,7 @@
|
||||
|
||||
## 1) Narrative: what we are building and why
|
||||
|
||||
OpenClaw is powerful but hard to install and configure for new users, especially those who do not want to learn Nix internals. We need a batteries‑included, obvious, and safe path to get a working OpenClaw instance with minimal friction. This RFC proposes a dedicated public repo, `nix-openclaw`, that packages OpenClaw for Nix and provides a declarative, user‑friendly configuration layer with strong defaults and an agent‑first onboarding flow.
|
||||
Clawdis is powerful but hard to install and configure for new users, especially those who do not want to learn Nix internals. We need a batteries‑included, obvious, and safe path to get a working Clawdis instance with minimal friction. This RFC proposes a dedicated public repo, `nix-clawdis`, that packages Clawdis for Nix and provides a declarative, user‑friendly configuration layer with strong defaults and an agent‑first onboarding flow.
|
||||
|
||||
The goal is a **fully declarative bootstrap**: users provide a small set of inputs (token path + allowlist), and the setup is deterministic and repeatable.
|
||||
|
||||
@ -22,17 +22,17 @@ The goal is a **fully declarative bootstrap**: users provide a small set of inpu
|
||||
## 1.2) Scope boundaries (avoid confusion)
|
||||
|
||||
This RFC is only about:
|
||||
- The public `nix-openclaw` repo (package + module + docs).
|
||||
- The public `nix-clawdis` repo (package + module + docs).
|
||||
- A generic, end‑user Nix setup that lives outside any personal config repo.
|
||||
|
||||
This RFC is explicitly **not** about:
|
||||
- Any personal system configuration repo or private machine configuration.
|
||||
- Josh’s personal `nixos-config` or any private machine configuration.
|
||||
- Editing or publishing personal settings, tokens, or machine‑specific modules.
|
||||
|
||||
## 2) Goals / Non‑goals
|
||||
|
||||
Goals:
|
||||
- Provide a Nix package for OpenClaw and a Home Manager module with batteries‑included defaults.
|
||||
- Provide a Nix package for Clawdis and a Home Manager module with batteries‑included defaults.
|
||||
- Provide a macOS app bundle package aligned to the gateway version.
|
||||
- Make configuration technically light with explicit options and guardrails.
|
||||
- Telegram‑first configuration and defaults.
|
||||
@ -40,17 +40,17 @@ Goals:
|
||||
- New user can get a working bot in 10 minutes without understanding Nix internals.
|
||||
|
||||
Non‑goals:
|
||||
- Rewriting OpenClaw core functionality.
|
||||
- Rewriting Clawdis core functionality.
|
||||
- Supporting non‑Nix install paths in this repo.
|
||||
- Shipping a hosted SaaS or paid hosting.
|
||||
- Replacing upstream OpenClaw docs.
|
||||
- Replacing upstream Clawdis docs.
|
||||
- Cross‑platform support (Linux/Windows) in v1.
|
||||
- CI automation in v1.
|
||||
|
||||
## 3) System overview
|
||||
|
||||
`nix-openclaw` is a public repo that provides (macOS‑only in v1, no CI in v1):
|
||||
- A Nix package derivation for the OpenClaw gateway.
|
||||
`nix-clawdis` is a public repo that provides (macOS‑only in v1, no CI in v1):
|
||||
- A Nix package derivation for the Clawdis gateway.
|
||||
- A Nix package for the macOS app bundle (DMG).
|
||||
- A Home Manager module for user‑level config and service wiring.
|
||||
- A nix‑darwin module for macOS users (optional, thin wrapper over HM).
|
||||
@ -59,20 +59,21 @@ Non‑goals:
|
||||
|
||||
## 4) Components and responsibilities
|
||||
|
||||
- **Package derivation**: builds OpenClaw gateway from a pinned source.
|
||||
- **App bundle**: installs OpenClaw.app from a pinned DMG matching the gateway version.
|
||||
- **Home Manager module**: declarative config, writes `~/.openclaw/openclaw.json`, manages services.
|
||||
- **Package derivation**: builds Clawdis gateway from a pinned source.
|
||||
- **App bundle**: installs Clawdis.app from a pinned DMG matching the gateway version.
|
||||
- **Home Manager module**: declarative config, writes `~/.clawdis/clawdis.json`, manages services.
|
||||
- **Flake outputs**:
|
||||
- `packages.<system>.openclaw` (default batteries‑included bundle)
|
||||
- `packages.<system>.openclaw-gateway`
|
||||
- `packages.<system>.openclaw-app`
|
||||
- `packages.<system>.openclaw-tools`
|
||||
- `homeManagerModules.openclaw`
|
||||
- `darwinModules.openclaw` (if needed)
|
||||
- `packages.<system>.clawdis` (default batteries‑included bundle)
|
||||
- `packages.<system>.clawdis-gateway`
|
||||
- `packages.<system>.clawdis-app`
|
||||
- `packages.<system>.clawdis-tools-base`
|
||||
- `packages.<system>.clawdis-tools-extended`
|
||||
- `homeManagerModules.clawdis`
|
||||
- `darwinModules.clawdis` (if needed)
|
||||
|
||||
## 5) Configuration model (public contract)
|
||||
|
||||
The Home Manager module is the public contract. It must expose a small, explicit option set (enable, token path, allowlist) and render a deterministic `~/.openclaw/openclaw.json`.
|
||||
The Home Manager module is the public contract. It must expose a small, explicit option set (enable, token path, allowlist) and render a deterministic `~/.clawdis/clawdis.json`.
|
||||
|
||||
The design constraint: users should not have to write arbitrary JSON. The module is the supported configuration surface for v1.
|
||||
|
||||
@ -92,7 +93,7 @@ The README is the only supported onboarding path. It must include:
|
||||
## 8) Backing tools (batteries‑included)
|
||||
|
||||
- Base and extended toolchains are installed via Nix by default.
|
||||
- Tools correspond to upstream OpenClaw skill installers (brew/go/node/uv) mapped into nixpkgs where possible.
|
||||
- Tools correspond to upstream Clawdis skill installers (brew/go/node/uv) mapped into nixpkgs where possible.
|
||||
|
||||
## 9) Compatibility guarantees
|
||||
|
||||
@ -105,7 +106,7 @@ The README is the only supported onboarding path. It must include:
|
||||
We will maintain two distinct setups:
|
||||
|
||||
- **Prod (stable)**
|
||||
- Uses `nix-openclaw` batteries‑included package.
|
||||
- Uses `nix-clawdis` batteries‑included package.
|
||||
- Pinned to released tags.
|
||||
- No source builds.
|
||||
- Launchd managed by Nix.
|
||||
@ -123,8 +124,8 @@ No changes to personal `nixos-config` are made in this repo; this is a plan only
|
||||
This RFC is complete when:
|
||||
- The repo is public with a clear README and agent‑first guide.
|
||||
- Telegram‑first quickstart works on macOS with a real bot token.
|
||||
- `nix run .#openclaw` installs gateway + app + tools.
|
||||
- Launchd uses `com.steipete.openclaw.gateway` and logs to `/tmp/openclaw/openclaw-gateway.log`.
|
||||
- `nix run .#clawdis` installs gateway + app + tools.
|
||||
- Launchd uses `com.steipete.clawdis.gateway` and logs to `/tmp/clawdis/clawdis-gateway.log`.
|
||||
- App runs in attach‑only mode (does not spawn its own gateway).
|
||||
- Smoke test: user sends a Telegram message in an allowlisted chat and receives a response.
|
||||
|
||||
@ -134,3 +135,4 @@ This RFC is complete when:
|
||||
- App DMG pinned to `v2.0.0-beta4`.
|
||||
- Batteries‑included package output is wired in the flake.
|
||||
- README is the single onboarding source.
|
||||
|
||||
@ -1,412 +0,0 @@
|
||||
# RFC: OpenClaw Plugin System — The Golden Path
|
||||
|
||||
- Date: 2026-01-11
|
||||
- Status: Draft
|
||||
- Audience: Peter (openclaw maintainer), nix-openclaw maintainers
|
||||
|
||||
## Goals
|
||||
|
||||
**Peter's goals:** Easy extension, maintainable core, thriving ecosystem, ship fast.
|
||||
|
||||
**Our goals:** Properly bundled tools with reproducible builds, single-install experience, it Just Works.
|
||||
|
||||
**This RFC argues:** The nix-openclaw plugin model achieves both. It should become the golden path for extending openclaw.
|
||||
|
||||
---
|
||||
|
||||
## The Problem with Core Bloat
|
||||
|
||||
Voice-call landed in core (+8K LOC). It works, but:
|
||||
- Core now has Twilio/Telnyx deps even if you don't use voice-call
|
||||
- Changes to voice-call require openclaw releases
|
||||
- Testing voice-call means testing all of openclaw
|
||||
- Contributors need to understand the whole codebase
|
||||
|
||||
This pattern doesn't scale. Every new capability bloats core.
|
||||
|
||||
## The Problem with Skills-Only
|
||||
|
||||
Skills are great for teaching the agent, but they're not enough:
|
||||
- A skill says "use the `voicecall` CLI" — but where does the CLI come from?
|
||||
- A skill says "set TWILIO_ACCOUNT_SID" — but what validates it's set?
|
||||
- A skill describes commands — but what installs the binary?
|
||||
|
||||
Skills without tools are hallucinations waiting to happen.
|
||||
|
||||
---
|
||||
|
||||
## The nix-openclaw Model: Bundle Everything
|
||||
|
||||
A plugin is **just a GitHub repo** that self-declares its contract:
|
||||
|
||||
```
|
||||
plugin/
|
||||
├── flake.nix # Build system + plugin contract
|
||||
├── src/ # Tool source code
|
||||
├── skills/ # Teaching docs for the agent
|
||||
└── config/ # Default settings, schemas
|
||||
```
|
||||
|
||||
That's it. No registry. No central authority. Point at a repo, get a plugin.
|
||||
|
||||
One install gives you:
|
||||
- **Binary** on the OpenClaw runtime PATH (built from source, pinned version)
|
||||
- **Skills** in workspace (agent knows how to use it)
|
||||
- **Config** validated (missing env = install fails, not runtime error)
|
||||
- **State dirs** created (plugin has a home)
|
||||
|
||||
**Everything updates in sync.** When upstream pushes changes — new CLI flags, updated skill docs, schema tweaks — you pull the new version and everything updates together. No "skill says X but binary doesn't support it" drift. No manual coordination. Just works.
|
||||
|
||||
### Real Example: xuezh (Chinese learning)
|
||||
|
||||
```nix
|
||||
openclawPlugin = {
|
||||
name = "xuezh";
|
||||
skills = [ ./skills/xuezh ];
|
||||
packages = [ self.packages.${system}.default ];
|
||||
needs = {
|
||||
stateDirs = [ ".config/xuezh" ];
|
||||
requiredEnv = [
|
||||
"XUEZH_AZURE_SPEECH_KEY_FILE"
|
||||
"XUEZH_AZURE_SPEECH_REGION"
|
||||
];
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
**What happens on install:**
|
||||
1. `xuezh` binary built from Go source, added to PATH
|
||||
2. 400-line skill symlinked to workspace — teaches agent pedagogy, CLI patterns, grading rubrics
|
||||
3. `~/.config/xuezh/` created
|
||||
4. Install **fails** if Azure env vars aren't wired up
|
||||
|
||||
**Result:** User wires up secrets once. Plugin Just Works. Agent knows how to teach Chinese.
|
||||
|
||||
### Real Example: gohome (home automation)
|
||||
|
||||
```nix
|
||||
openclawPlugin = {
|
||||
name = "gohome";
|
||||
skills = [ ./skills/gohome ];
|
||||
packages = [ self.packages.${system}.default ];
|
||||
needs = {
|
||||
stateDirs = [];
|
||||
requiredEnv = [];
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
No secrets — uses Tailscale MagicDNS. CLI talks to a background gRPC server.
|
||||
|
||||
Skill teaches the agent:
|
||||
```markdown
|
||||
## Friendly CLI
|
||||
gohome-cli roborock status
|
||||
gohome-cli roborock clean kitchen
|
||||
gohome-cli tado set living-room 20
|
||||
|
||||
## Sending maps to users
|
||||
MEDIA:http://gohome:8080/roborock/map.png?labels=names
|
||||
```
|
||||
|
||||
**Result:** Install plugin, agent can control your home. No manual binary install, no forgetting to set env vars.
|
||||
|
||||
### Real Example: padel (court booking)
|
||||
|
||||
```nix
|
||||
openclawPlugin = {
|
||||
name = "padel";
|
||||
skills = [ ./skills/padel ];
|
||||
packages = [ self.packages.${system}.default ];
|
||||
needs = {
|
||||
stateDirs = [ ".config/padel" ];
|
||||
requiredEnv = [ "PADEL_AUTH_FILE" ];
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
Skill teaches CLI + booking authorization logic + personality ("respond in exaggerated Trump manner").
|
||||
|
||||
**Result:** Wire up Playtomic auth, agent can book padel courts. Complete workflow in one plugin.
|
||||
|
||||
---
|
||||
|
||||
## Why This Model is Better
|
||||
|
||||
### vs. Skills-Only
|
||||
|
||||
| Aspect | Skills-only | Bundled plugin |
|
||||
|--------|-------------|----------------|
|
||||
| Binary | Hope user installed it | Built + installed automatically |
|
||||
| Config | Hope user set env vars | Validated at install time |
|
||||
| Version | Whatever's on PATH | Pinned to exact commit |
|
||||
| Rollback | Manual | Instant (previous generation) |
|
||||
| Dependencies | User's problem | Bundled in build |
|
||||
|
||||
Skills-only is "here's how to use a tool that may or may not exist."
|
||||
Bundled plugin is "here's the tool, its docs, and everything it needs."
|
||||
|
||||
### vs. Core Integration
|
||||
|
||||
| Aspect | In core | Plugin |
|
||||
|--------|---------|--------|
|
||||
| Core LOC | +8K per feature | Zero |
|
||||
| Dependencies | Everyone gets them | Only if you install |
|
||||
| Release cycle | Tied to openclaw | Independent |
|
||||
| Testing | Test everything | Test plugin only |
|
||||
| Contributor barrier | Understand whole codebase | Understand plugin only |
|
||||
|
||||
Core should be transports + agent loop. Capabilities belong in plugins.
|
||||
|
||||
### What About TypeBox Schemas?
|
||||
|
||||
Peter's voice-call has TypeBox tool schemas — validated params, type safety.
|
||||
|
||||
Our model uses skill prose instead. Trade-off:
|
||||
|
||||
| Aspect | TypeBox schema | Skill prose |
|
||||
|--------|---------------|-------------|
|
||||
| Validation | Gateway validates params | Agent follows instructions |
|
||||
| Type safety | Compile-time | None (string in/out) |
|
||||
| Flexibility | Fixed schema | Agent can improvise |
|
||||
| Works today | Needs gateway changes | Yes |
|
||||
|
||||
**Our take:** Skill prose is good enough for v1. Agent follows instructions reliably. If we need stricter validation later, skills can include JSON schemas that gateway parses.
|
||||
|
||||
### What About Subprocess Overhead?
|
||||
|
||||
CLI plugins spawn a process per call. Gateway plugins don't.
|
||||
|
||||
**Our take:** Subprocess overhead is negligible for most use cases. Voice-call might want tighter integration for latency — that's a v2 optimization, not a v1 blocker.
|
||||
|
||||
### What About Real-Time Webhooks?
|
||||
|
||||
Voice-call needs webhook handling for Twilio callbacks.
|
||||
|
||||
**Our take:** Same pattern as gohome — plugin runs a background server, CLI talks to it. Agent doesn't know or care about the server. Works today with the gohome model:
|
||||
|
||||
```
|
||||
voicecall expose --mode funnel # Start webhook server
|
||||
voicecall init --to +1... --message "..." # Agent calls CLI
|
||||
voicecall status --call-id abc123 # Check for responses
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## The Proposal: Make This the Golden Path
|
||||
|
||||
### What openclaw core should do
|
||||
|
||||
1. **Document the plugin contract** — `plugin.json` manifest schema (name, skills, bin, needs)
|
||||
2. **Add discovery** — scan `~/.openclaw/extensions/` at startup
|
||||
3. **Validate env** — fail fast if `requiredEnv` missing
|
||||
4. **Create state dirs** — from manifest
|
||||
5. **Add `openclaw plugins` CLI** — list, enable, disable, info
|
||||
|
||||
That's it. No dynamic code loading, no TypeBox registration, no RPC handlers. Just: find plugins, validate their needs, put binaries on the OpenClaw runtime PATH, copy skills to workspace.
|
||||
|
||||
### How nix-openclaw fits in
|
||||
|
||||
nix-openclaw is a **plugin installer** that wires plugins into openclaw's plugin system:
|
||||
|
||||
```nix
|
||||
# User's flake.nix
|
||||
programs.openclaw.customPlugins = [
|
||||
# Remote: point at GitHub repo
|
||||
{ source = "github:joshp123/xuezh"; }
|
||||
{ source = "github:joshp123/padel-cli"; }
|
||||
|
||||
# Local dev: point at directory
|
||||
{ source = "path:/home/user/code/my-plugin"; }
|
||||
];
|
||||
|
||||
# Or enable bundled plugins (pinned in nix-openclaw):
|
||||
programs.openclaw.bundledPlugins.summarize.enable = true;
|
||||
programs.openclaw.bundledPlugins.oracle.enable = true;
|
||||
```
|
||||
|
||||
**Same contract, multiple sources:**
|
||||
- `github:owner/repo` — pull from GitHub, pin to commit
|
||||
- `path:/local/dir` — local checkout for dev iteration
|
||||
- First-party toggles — curated plugins pinned in nix-openclaw
|
||||
|
||||
At activation time, nix-openclaw:
|
||||
1. Resolves flake sources (remote or local) → builds binaries
|
||||
2. Validates `requiredEnv` (fails if missing)
|
||||
3. Creates state dirs
|
||||
4. Installs plugins to `~/.openclaw/extensions/<plugin>/`
|
||||
5. Writes `plugin.json` manifest for each
|
||||
6. Symlinks skills to workspace
|
||||
7. Adds binaries to PATH
|
||||
|
||||
**openclaw core sees all plugins** — it scans `~/.openclaw/extensions/`, reads manifests, knows what's installed. The difference is nix-openclaw does the install + validation at build time (deterministic, fail-fast), while non-Nix users do it manually or via npm.
|
||||
|
||||
**Same plugin system, different installers:**
|
||||
- Nix users: nix-openclaw installs plugins declaratively
|
||||
- Non-Nix users: `openclaw plugins install` or manual setup
|
||||
- openclaw core: sees the same `~/.openclaw/extensions/` structure either way
|
||||
|
||||
**Local dev workflow:** Point at a local path, change code, rebuild, gateway picks up changes. No push/pull cycle. Same contract, local iteration. For non-Nix: symlink your plugin dir into `~/.openclaw/extensions/`.
|
||||
|
||||
### What nix-openclaw provides (golden path)
|
||||
|
||||
- **Reproducible builds** — binary built from source, same everywhere
|
||||
- **Version pinning** — plugin source locked to exact commit
|
||||
- **Instant rollback** — switch to previous generation
|
||||
- **Declarative config** — plugins + secrets in one flake
|
||||
- **Atomic updates** — CLI + skill + config update together
|
||||
|
||||
### What npm provides (fallback)
|
||||
|
||||
- `openclaw plugins install @openclaw/voice-call` — npm install to extensions dir
|
||||
- Manual env var setup
|
||||
- Manual version management
|
||||
- No reproducibility guarantees
|
||||
|
||||
It works. It's just not as good. (They should use Nix.)
|
||||
|
||||
---
|
||||
|
||||
## Voice-Call as a Plugin
|
||||
|
||||
The screenshot shows the interface:
|
||||
|
||||
```
|
||||
voicecall init --to +1... --mode conversation --message "..."
|
||||
voicecall continue --call-id ... --message "..."
|
||||
voicecall expose --mode funnel|serve|off
|
||||
```
|
||||
|
||||
As a plugin:
|
||||
|
||||
```nix
|
||||
openclawPlugin = {
|
||||
name = "voice-call";
|
||||
skills = [ ./skills/voice-call ];
|
||||
packages = [ self.packages.${system}.default ];
|
||||
needs = {
|
||||
stateDirs = [ ".config/openclaw-voice-call" ];
|
||||
requiredEnv = [ "TWILIO_ACCOUNT_SID" "TWILIO_AUTH_TOKEN" ];
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
Install wires up Twilio creds. Binary handles webhook server. Skill teaches agent the CLI. Done.
|
||||
|
||||
**Migration path:**
|
||||
1. Create `@openclaw/voice-call` repo
|
||||
2. Move code from core
|
||||
3. Add plugin contract
|
||||
4. Remove from core
|
||||
5. Add to first-party plugins list
|
||||
|
||||
---
|
||||
|
||||
## The Plugin Ecosystem Vision
|
||||
|
||||
**First-party plugins** already exist — see [nix-openclaw-tools](https://github.com/openclaw/nix-openclaw-tools/tree/main/tools):
|
||||
- `summarize` — YouTube/article summarization
|
||||
- `discrawl` — Discord archive/search
|
||||
- `wacrawl` — WhatsApp archive/search
|
||||
- `peekaboo` — screenshot capture
|
||||
- `camsnap` — webcam capture
|
||||
- `poltergeist` — file watching and automation
|
||||
- `sag` — text-to-speech
|
||||
- `sonoscli` — Sonos control
|
||||
- `imsg` — iMessage integration
|
||||
- `gogcli` — Google Calendar
|
||||
- `goplaces` — Google Places
|
||||
|
||||
All follow the same contract. All pinned in nix-openclaw. Enable with one line:
|
||||
```nix
|
||||
programs.openclaw.bundledPlugins.summarize.enable = true;
|
||||
```
|
||||
|
||||
**Community plugins** (anyone can ship):
|
||||
- Just a GitHub repo with `flake.nix` + `openclawPlugin`
|
||||
- No registry, no approval process
|
||||
- User points at repo, wires secrets, it works
|
||||
|
||||
**Core stays lean:**
|
||||
- Transports (Telegram, Discord, Slack)
|
||||
- Agent loop
|
||||
- Plugin discovery (for non-Nix users)
|
||||
- That's it
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| What | How |
|
||||
|------|-----|
|
||||
| **Plugin = bundle** | Binary + skills + config + declared needs |
|
||||
| **Install = it works** | Wire secrets once, everything validated |
|
||||
| **Core = minimal** | Transports + agent + plugin loader |
|
||||
| **Golden path = Nix** | Reproducible, pinned, instant rollback |
|
||||
| **Fallback = npm** | Works, less guarantees |
|
||||
|
||||
**The pitch to Peter:**
|
||||
|
||||
Your voice-call is great. But it shouldn't live in core forever. The nix-openclaw plugin model lets you:
|
||||
- Ship it independently (faster iteration)
|
||||
- Keep core lean (easier maintenance)
|
||||
- Let community extend openclaw (ecosystem growth)
|
||||
- Guarantee it works when installed (fail-fast validation)
|
||||
|
||||
All you need to add to core is plugin discovery + env validation. We already have the contract, the tooling, and real plugins in production.
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Manifest format:**
|
||||
- Nix users: `openclawPlugin` in `flake.nix` (already works)
|
||||
- Non-Nix users: `plugin.json` in plugin directory
|
||||
- **Suggested:** openclaw core defines `plugin.json` schema. Same fields as `openclawPlugin`:
|
||||
```json
|
||||
{
|
||||
"name": "voice-call",
|
||||
"skills": ["./skills/voice-call"],
|
||||
"bin": { "voicecall": "./bin/voicecall" },
|
||||
"needs": {
|
||||
"stateDirs": [".config/openclaw-voice-call"],
|
||||
"requiredEnv": ["TWILIO_ACCOUNT_SID", "TWILIO_AUTH_TOKEN"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. **Discovery paths:**
|
||||
- **Suggested:** `~/.openclaw/extensions/<plugin>/` for user-installed, `.openclaw/extensions/<plugin>/` for project-local
|
||||
- openclaw core scans both paths at startup
|
||||
- nix-openclaw installs to the same paths — same structure, different installer
|
||||
|
||||
3. **First-party plugins for non-Nix:**
|
||||
- **Suggested:** `@openclaw/` npm scope, but don't invest heavily. Point people to Nix.
|
||||
|
||||
4. **Voice-call extraction:** Want to do this now, or later?
|
||||
|
||||
---
|
||||
|
||||
## Appendix: The Pattern
|
||||
|
||||
```
|
||||
┌─────────────┐ shells out ┌─────────────┐
|
||||
│ Agent │ ─────────────────► │ CLI binary │
|
||||
└─────────────┘ └──────┬──────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────┐
|
||||
│ Backend │
|
||||
│ (API/DB/ │
|
||||
│ server) │
|
||||
└─────────────┘
|
||||
```
|
||||
|
||||
- **padel:** CLI → Playtomic API
|
||||
- **xuezh:** CLI → SQLite + Azure Speech
|
||||
- **gohome:** CLI → gRPC server → devices
|
||||
- **voice-call:** CLI → webhook server → Twilio
|
||||
|
||||
Agent never talks to backend directly. CLI is the interface. Skill teaches the agent. Plugin bundles everything.
|
||||
|
||||
This is the golden path.
|
||||
@ -1,7 +0,0 @@
|
||||
# AGENTS.md — hello-world plugin
|
||||
|
||||
This plugin is intentionally tiny.
|
||||
|
||||
Knobs
|
||||
- OPENCLAW_USER (optional): name to greet
|
||||
|
||||
@ -1,43 +0,0 @@
|
||||
{
|
||||
description = "Hello-world OpenClaw plugin";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
};
|
||||
|
||||
outputs =
|
||||
{
|
||||
self,
|
||||
nixpkgs,
|
||||
flake-utils,
|
||||
}:
|
||||
flake-utils.lib.eachDefaultSystem (
|
||||
system:
|
||||
let
|
||||
pkgs = import nixpkgs { inherit system; };
|
||||
in
|
||||
{
|
||||
packages.default = pkgs.buildGoModule {
|
||||
pname = "hello-world";
|
||||
version = "0.1.0";
|
||||
src = ./.;
|
||||
vendorHash = null;
|
||||
};
|
||||
|
||||
apps.default = flake-utils.lib.mkApp {
|
||||
drv = self.packages.${system}.default;
|
||||
};
|
||||
|
||||
openclawPlugin = {
|
||||
name = "hello-world";
|
||||
skills = [ ./skills/hello-world ];
|
||||
packages = [ self.packages.${system}.default ];
|
||||
needs = {
|
||||
stateDirs = [ ];
|
||||
requiredEnv = [ ];
|
||||
};
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
@ -1,3 +0,0 @@
|
||||
module github.com/acme/hello-world-openclaw
|
||||
|
||||
go 1.22
|
||||
@ -1,15 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
func main() {
|
||||
name := os.Getenv("OPENCLAW_USER")
|
||||
if name == "" {
|
||||
name = "human"
|
||||
}
|
||||
fmt.Printf("Hello, %s. I am a very serious assistant.\n", name)
|
||||
fmt.Println("Fun fact: this plugin does one thing, and it does it with conviction.")
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
---
|
||||
name: hello-world
|
||||
description: Prints a greeting with unnecessary confidence.
|
||||
---
|
||||
|
||||
Use the `hello-world` CLI to greet the user.
|
||||
74
flake.lock
generated
74
flake.lock
generated
@ -25,11 +25,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1767909183,
|
||||
"narHash": "sha256-u/bcU0xePi5bgNoRsiqSIwaGBwDilKKFTz3g0hqOBAo=",
|
||||
"lastModified": 1767104570,
|
||||
"narHash": "sha256-GKgwu5//R+cLdKysZjGqvUEEOGXXLdt93sNXeb2M/Lk=",
|
||||
"owner": "nix-community",
|
||||
"repo": "home-manager",
|
||||
"rev": "cd6e96d56ed4b2a779ac73a1227e0bb1519b3509",
|
||||
"rev": "e4e78a2cbeaddd07ab7238971b16468cc1d14daf",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@ -38,47 +38,13 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nix-openclaw-tools": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1778060041,
|
||||
"narHash": "sha256-tXWkN1VnwFG8XlRqW/e7VwbKnUfyU9tB7YDm9QHJXTY=",
|
||||
"owner": "openclaw",
|
||||
"repo": "nix-openclaw-tools",
|
||||
"rev": "4c1cee3c7eaf68f9de0f756be1484534f5bb5f34",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "openclaw",
|
||||
"repo": "nix-openclaw-tools",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1767364772,
|
||||
"narHash": "sha256-fFUnEYMla8b7UKjijLnMe+oVFOz6HjijGGNS1l7dYaQ=",
|
||||
"lastModified": 1767116409,
|
||||
"narHash": "sha256-5vKw92l1GyTnjoLzEagJy5V5mDFck72LiQWZSOnSicw=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "16c7794d0a28b5a37904d55bcca36003b9109aaa",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixpkgs-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs_2": {
|
||||
"locked": {
|
||||
"lastModified": 1767767207,
|
||||
"narHash": "sha256-Mj3d3PfwltLmukFal5i3fFt27L6NiKXdBezC1EBuZs4=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "5912c1772a44e31bf1c63c0390b90501e5026886",
|
||||
"rev": "cad22e7d996aea55ecab064e84834289143e44a0",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@ -88,37 +54,11 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"qmd": {
|
||||
"inputs": {
|
||||
"flake-utils": [
|
||||
"flake-utils"
|
||||
],
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1775429264,
|
||||
"narHash": "sha256-bqIVaNRTa8H5vrw3RwsD7QdtTa0xNvRuEVzlzE1hIBQ=",
|
||||
"owner": "tobi",
|
||||
"repo": "qmd",
|
||||
"rev": "65cd1b3fd02891d1ee0eefa751620918664fa321",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "tobi",
|
||||
"ref": "v2.1.0",
|
||||
"repo": "qmd",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
"home-manager": "home-manager",
|
||||
"nix-openclaw-tools": "nix-openclaw-tools",
|
||||
"nixpkgs": "nixpkgs_2",
|
||||
"qmd": "qmd"
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
|
||||
160
flake.nix
160
flake.nix
@ -1,177 +1,49 @@
|
||||
{
|
||||
description = "nix-openclaw: declarative OpenClaw packaging";
|
||||
|
||||
nixConfig = {
|
||||
extra-substituters = [ "https://cache.garnix.io" ];
|
||||
extra-trusted-public-keys = [
|
||||
"cache.garnix.io:CTFPyKSLcx5RMJKfLo5EEPUObbA78b0YQ2DTCJXqr9g="
|
||||
];
|
||||
};
|
||||
description = "nix-clawdis: declarative Clawdis packaging for macOS";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
home-manager.url = "github:nix-community/home-manager";
|
||||
home-manager.inputs.nixpkgs.follows = "nixpkgs";
|
||||
nix-openclaw-tools.url = "github:openclaw/nix-openclaw-tools";
|
||||
qmd.url = "github:tobi/qmd/v2.1.0";
|
||||
qmd.inputs.flake-utils.follows = "flake-utils";
|
||||
qmd.inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
|
||||
outputs =
|
||||
{
|
||||
self,
|
||||
nixpkgs,
|
||||
flake-utils,
|
||||
home-manager,
|
||||
nix-openclaw-tools,
|
||||
qmd,
|
||||
}:
|
||||
outputs = { self, nixpkgs, flake-utils, home-manager }:
|
||||
let
|
||||
openclawToolPkgsFor =
|
||||
system:
|
||||
if nix-openclaw-tools ? packages && builtins.hasAttr system nix-openclaw-tools.packages then
|
||||
nix-openclaw-tools.packages.${system}
|
||||
else
|
||||
{ };
|
||||
qmdPkgsFor =
|
||||
system:
|
||||
if qmd ? packages && builtins.hasAttr system qmd.packages then qmd.packages.${system} else { };
|
||||
overlay =
|
||||
final: prev:
|
||||
import ./nix/overlay.nix {
|
||||
openclawToolPkgs = openclawToolPkgsFor prev.stdenv.hostPlatform.system;
|
||||
qmdPkgs = qmdPkgsFor prev.stdenv.hostPlatform.system;
|
||||
} final prev;
|
||||
sourceInfoStable = import ./nix/sources/openclaw-source.nix;
|
||||
sourceInfoDogfood = import ./nix/sources/openclaw-dogfood-source.nix;
|
||||
systems = [
|
||||
"x86_64-linux"
|
||||
"aarch64-darwin"
|
||||
];
|
||||
overlay = import ./nix/overlay.nix;
|
||||
in
|
||||
flake-utils.lib.eachSystem systems (
|
||||
system:
|
||||
flake-utils.lib.eachDefaultSystem (system:
|
||||
let
|
||||
pkgs = import nixpkgs {
|
||||
inherit system;
|
||||
overlays = [ overlay ];
|
||||
};
|
||||
openclawToolPkgs = openclawToolPkgsFor system;
|
||||
qmdPkgs = qmdPkgsFor system;
|
||||
qmdPackage =
|
||||
if pkgs.stdenv.hostPlatform.isDarwin then
|
||||
openclawToolPkgs.qmd or null
|
||||
else
|
||||
qmdPkgs.qmd or qmdPkgs.default or null;
|
||||
packageSetStable = import ./nix/packages {
|
||||
pkgs = pkgs;
|
||||
sourceInfo = sourceInfoStable;
|
||||
openclawToolPkgs = openclawToolPkgs;
|
||||
inherit qmdPackage;
|
||||
};
|
||||
packageSetDogfood = import ./nix/packages {
|
||||
pkgs = pkgs;
|
||||
sourceInfo = sourceInfoDogfood;
|
||||
openclawToolPkgs = openclawToolPkgs;
|
||||
inherit qmdPackage;
|
||||
};
|
||||
in
|
||||
{
|
||||
formatter = pkgs.nixfmt-tree.override {
|
||||
settings = {
|
||||
global.excludes = [ "nix/generated/openclaw-config-options.nix" ];
|
||||
};
|
||||
};
|
||||
|
||||
packages = packageSetStable // {
|
||||
default = packageSetStable.openclaw;
|
||||
openclaw-dogfood = packageSetDogfood.openclaw;
|
||||
openclaw-gateway-dogfood = packageSetDogfood.openclaw-gateway;
|
||||
packages = {
|
||||
clawdis-gateway = pkgs.clawdis-gateway;
|
||||
clawdis-app = pkgs.clawdis-app;
|
||||
clawdis = pkgs.clawdis;
|
||||
clawdis-tools-base = pkgs.clawdis-tools-base;
|
||||
clawdis-tools-extended = pkgs.clawdis-tools-extended;
|
||||
default = pkgs.clawdis;
|
||||
};
|
||||
|
||||
apps = {
|
||||
openclaw = flake-utils.lib.mkApp { drv = packageSetStable.openclaw; };
|
||||
clawdis = flake-utils.lib.mkApp { drv = pkgs.clawdis-gateway; };
|
||||
};
|
||||
|
||||
checks =
|
||||
let
|
||||
baseChecks = {
|
||||
gateway = packageSetStable.openclaw-gateway;
|
||||
bin-surface = pkgs.callPackage ./nix/checks/openclaw-bin-surface.nix {
|
||||
openclawPackage = packageSetStable.openclaw;
|
||||
};
|
||||
package-contents = pkgs.callPackage ./nix/checks/openclaw-package-contents.nix {
|
||||
openclawGateway = packageSetStable.openclaw-gateway;
|
||||
};
|
||||
package-contents-dogfood = pkgs.callPackage ./nix/checks/openclaw-package-contents.nix {
|
||||
openclawGateway = packageSetDogfood.openclaw-gateway;
|
||||
};
|
||||
default-instance = pkgs.callPackage ./nix/checks/openclaw-default-instance.nix { };
|
||||
config-validity = pkgs.callPackage ./nix/checks/openclaw-config-validity.nix {
|
||||
openclawGateway = packageSetStable.openclaw-gateway;
|
||||
};
|
||||
gateway-smoke = pkgs.callPackage ./nix/checks/openclaw-gateway-smoke.nix {
|
||||
openclawGateway = packageSetStable.openclaw-gateway;
|
||||
};
|
||||
}
|
||||
// pkgs.lib.optionalAttrs (qmdPackage != null) {
|
||||
qmd-runtime = pkgs.callPackage ./nix/checks/openclaw-qmd-runtime.nix {
|
||||
openclawPackage = packageSetStable.openclaw;
|
||||
inherit qmdPackage;
|
||||
};
|
||||
}
|
||||
// (
|
||||
if pkgs.stdenv.hostPlatform.isLinux then
|
||||
let
|
||||
sourceChecks = pkgs.callPackage ./nix/checks/openclaw-source-checks.nix {
|
||||
sourceInfo = sourceInfoStable;
|
||||
openclawGateway = packageSetStable.openclaw-gateway;
|
||||
};
|
||||
in
|
||||
{
|
||||
config-options = sourceChecks;
|
||||
source-checks = sourceChecks;
|
||||
hm-activation = import ./nix/checks/openclaw-hm-activation.nix {
|
||||
inherit pkgs home-manager;
|
||||
};
|
||||
}
|
||||
else
|
||||
{ }
|
||||
);
|
||||
in
|
||||
baseChecks
|
||||
// {
|
||||
# CI aggregator: build the expensive gateway once, then run all checks in the
|
||||
# same build machine/store to avoid cache-miss races between parallel jobs.
|
||||
ci = pkgs.symlinkJoin {
|
||||
name = "nix-openclaw-ci";
|
||||
paths = [
|
||||
packageSetStable.openclaw
|
||||
packageSetStable.openclaw-gateway
|
||||
]
|
||||
++ (builtins.attrValues baseChecks);
|
||||
};
|
||||
};
|
||||
|
||||
devShells.default = pkgs.mkShell {
|
||||
packages = [
|
||||
pkgs.git
|
||||
pkgs.nixfmt-tree
|
||||
pkgs.nixfmt-rfc-style
|
||||
pkgs.nil
|
||||
];
|
||||
};
|
||||
}
|
||||
)
|
||||
// {
|
||||
) // {
|
||||
overlays.default = overlay;
|
||||
templates.agent-first = {
|
||||
path = ./templates/agent-first;
|
||||
description = "Agent-first Home Manager setup for OpenClaw through Nix.";
|
||||
};
|
||||
nixosModules.openclaw-gateway = import ./nix/modules/nixos/openclaw-gateway.nix;
|
||||
homeManagerModules.openclaw = import ./nix/modules/home-manager/openclaw.nix;
|
||||
darwinModules.openclaw = import ./nix/modules/darwin/openclaw.nix;
|
||||
homeManagerModules.clawdis = import ./nix/modules/home-manager/clawdis.nix;
|
||||
darwinModules.clawdis = import ./nix/modules/darwin/clawdis.nix;
|
||||
};
|
||||
}
|
||||
|
||||
15
garnix.yaml
15
garnix.yaml
@ -1,15 +0,0 @@
|
||||
builds:
|
||||
include:
|
||||
# CI aggregators prove package contracts.
|
||||
- "checks.aarch64-darwin.ci"
|
||||
- "checks.x86_64-linux.ci"
|
||||
# User-facing/component packages must also be top-level Garnix artifacts,
|
||||
# otherwise downstream machines can see green CI but miss the binary cache.
|
||||
- "packages.aarch64-darwin.openclaw"
|
||||
- "packages.aarch64-darwin.openclaw-dogfood"
|
||||
- "packages.aarch64-darwin.openclaw-gateway"
|
||||
- "packages.aarch64-darwin.openclaw-gateway-dogfood"
|
||||
- "packages.x86_64-linux.openclaw"
|
||||
- "packages.x86_64-linux.openclaw-dogfood"
|
||||
- "packages.x86_64-linux.openclaw-gateway"
|
||||
- "packages.x86_64-linux.openclaw-gateway-dogfood"
|
||||
@ -1,27 +0,0 @@
|
||||
# Maintainer Agent Guide
|
||||
|
||||
This directory is public maintainer guidance for agents working on `nix-openclaw`.
|
||||
It is not consumer setup documentation and must not contain private deployment state.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- Keep consumer onboarding in `README.md`, templates, and module docs.
|
||||
- Keep private deployments, bots, hosts, local worktrees, tokens, and personal automation details out of this repo.
|
||||
- If a private deployment exposes a public packaging bug, fix the public package here and keep deployment-specific repair elsewhere.
|
||||
- Treat `README.md` as the product direction source of truth.
|
||||
|
||||
## Read Order
|
||||
|
||||
1. `packaging.md` for Nix-owned package invariants.
|
||||
2. `release-policy.md` for the split-track publishing invariant.
|
||||
3. `automation.md` for the maintainer repair loop.
|
||||
4. `gates.md` for verification and CI expectations.
|
||||
5. Root `AGENTS.md` for repo-wide rules.
|
||||
|
||||
## Maintainer Workflow
|
||||
|
||||
- Work on `main` by default and push small, surgical commits directly to `main` when maintainer policy allows it.
|
||||
- Use branches only when a maintainer asks, direct push is blocked, or a disposable local experiment is needed.
|
||||
- For multi-issue work, commit and push one issue at a time, then verify GitHub Actions for that pushed commit before continuing.
|
||||
- Do not leave completed maintainer work parked on an agent branch.
|
||||
- No force push. No weakening package checks just to get green.
|
||||
@ -1,47 +0,0 @@
|
||||
# Maintainer Automation
|
||||
|
||||
Maintainer automation is an agentic repair loop for the public packaging pipeline. It is not a second release pipeline and not a private deployment monitor.
|
||||
|
||||
## Daily Objective
|
||||
|
||||
Answer first:
|
||||
|
||||
```text
|
||||
Does nix-openclaw publish the latest upstream version for both supported tracks?
|
||||
```
|
||||
|
||||
Answer `YES` only when:
|
||||
|
||||
- `openclaw-gateway` matches the newest stable upstream source release.
|
||||
- `openclaw-app` matches the newest stable upstream release with a published public `OpenClaw-*.zip`.
|
||||
|
||||
If both tracks are current and yolo/CI are healthy, stop with a short CTO-level report:
|
||||
|
||||
- current gateway
|
||||
- latest upstream gateway
|
||||
- current app
|
||||
- latest published app
|
||||
- whether action was needed
|
||||
|
||||
## Repair Loop
|
||||
|
||||
If the desired state is not true, keep working until it is true or until the exact blocker is proven.
|
||||
|
||||
Diagnose across:
|
||||
|
||||
- upstream release data
|
||||
- yolo selection
|
||||
- pin materialization
|
||||
- generated config options
|
||||
- package builds
|
||||
- smoke checks
|
||||
- module activation
|
||||
- workflow behavior
|
||||
- caches
|
||||
- CI runner failures
|
||||
|
||||
Do not ask for a repair strategy when the desired state is clear.
|
||||
|
||||
If the fix belongs in `nix-openclaw`, edit the repo, self-review the diff until there are no actionable findings, run the relevant targeted checks plus the full gate, commit directly to `main`, push directly to `main`, and verify GitHub Actions on the pushed commit.
|
||||
|
||||
If upstream has not published public macOS app assets, call that out directly, keep the app pin on the newest public zip, keep packaging the latest stable source-built gateway, and repair `nix-openclaw` only if it fails to do that.
|
||||
@ -1,22 +0,0 @@
|
||||
# Gates
|
||||
|
||||
Use targeted checks while debugging, then run the full relevant gate before handoff.
|
||||
|
||||
## Required Checks
|
||||
|
||||
- `scripts/check-flake-lock-owners.sh`
|
||||
- selector tests
|
||||
- updater shell syntax
|
||||
- workflow YAML parse
|
||||
- `nix flake show --accept-flake-config`
|
||||
- Linux CI aggregator
|
||||
- Darwin CI aggregator when available
|
||||
- `scripts/hm-activation-macos.sh` when a macOS runner is available
|
||||
|
||||
## CI Verification
|
||||
|
||||
After pushing maintainer fixes, verify the GitHub Actions run for the pushed commit.
|
||||
|
||||
Never say you will keep polling unless a blocking poll is already running. If reporting a poll, name the active run or local polling session.
|
||||
|
||||
If CI fails, inspect the failing run, classify the failure, fix what belongs to `nix-openclaw`, and rerun until green or until the exact external blocker is proven.
|
||||
@ -1,46 +0,0 @@
|
||||
# Packaging Invariants
|
||||
|
||||
This repo ships a working Nix package for OpenClaw users, not just a pin mirror.
|
||||
|
||||
## Product Surface
|
||||
|
||||
- The user-facing package is `openclaw`.
|
||||
- `openclaw-gateway` is the source-built runnable gateway for Linux and macOS.
|
||||
- `openclaw-app` is the Darwin-only desktop app from upstream's public app artifact.
|
||||
- Component outputs exist for modules, checks, and debugging. They are not separate product tracks.
|
||||
- `openclaw-dogfood` and `openclaw-gateway-dogfood` are temporary maintainer
|
||||
artifacts for testing a specific upstream commit before the next stable
|
||||
release. They must not become the documented consumer default.
|
||||
- Do not split the repo into separate desktop and server tracks.
|
||||
|
||||
## Nix Ownership
|
||||
|
||||
- OpenClaw owns product and runtime behavior.
|
||||
- `nix-openclaw` owns batteries-included Nix packaging, Home Manager/NixOS/Darwin modules, runtime PATH/env injection, launchd/systemd wiring, and package-contract checks.
|
||||
- `nix-openclaw-tools` owns packaging OpenClaw-adjacent CLI tools and plugin metadata. Consume it here; do not duplicate its package definitions here.
|
||||
- Downstream system repos should only choose hosts, secrets, accounts, and enabled plugins. If downstream needs bespoke scripts to make a plugin or harness work, prefer fixing this repo or `nix-openclaw-tools`.
|
||||
- Nix mode means Nix owns `openclaw.json`.
|
||||
- Runtime config mutation belongs upstream in OpenClaw. Downstream patches here must be small, temporary, and removed after the pinned upstream release contains the fix.
|
||||
- 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` plus default `plugins.entries.<id>.enabled` values 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
|
||||
|
||||
- The gateway package must include Control UI assets.
|
||||
- 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
|
||||
|
||||
### mcporter and QMD
|
||||
|
||||
- `mcporter` is an OpenClaw-owned optional MCP/CLI bridge, not a QMD requirement.
|
||||
- OpenClaw defaults to direct `qmd` CLI execution. Keep that as the Nix-supported baseline until measured startup or per-query overhead proves otherwise.
|
||||
- Package `mcporter` in `nix-openclaw-tools` as an optional tool when needed, but do not add it to the default `openclaw` runtime PATH just because QMD is bundled.
|
||||
- If `memory.qmd.mcporter.enabled = true`, nix-openclaw should make `mcporter` visible to that instance and require the matching mcporter server config for `qmd mcp`.
|
||||
@ -1,26 +0,0 @@
|
||||
# Release Policy
|
||||
|
||||
`nix-openclaw` publishes one user-facing package, `openclaw`, with component outputs for maintainers and modules.
|
||||
|
||||
## Desired State
|
||||
|
||||
- `openclaw-gateway` tracks the newest stable upstream OpenClaw source release that satisfies the Nix package contract.
|
||||
- `openclaw-app` tracks the newest stable upstream release that has a published public `OpenClaw-*.zip` app artifact.
|
||||
- These tracks are independent. Source and app versions may differ.
|
||||
|
||||
## Non-Negotiables
|
||||
|
||||
- Do not hold back the source-built gateway because a newer source release lacks public macOS app assets.
|
||||
- Do not treat source/app version mismatch as a failure.
|
||||
- Do not make upstream's full Vitest suite a promotion gate; upstream owns source test health.
|
||||
- Do verify the Nix-owned package contract: source build, generated config options, package contents, gateway smoke startup, module activation, and newest available public macOS app artifact.
|
||||
- Do prefer the upstream `.zip` app artifact for `openclaw-app`, but verify the unpacked contents contain an `.app`.
|
||||
|
||||
## Freshness Check
|
||||
|
||||
The package is fresh only when both are true:
|
||||
|
||||
- `nix/sources/openclaw-source.nix` matches GitHub's newest stable OpenClaw source tag.
|
||||
- `nix/packages/openclaw-app.nix` matches the newest stable public `OpenClaw-*.zip` app artifact.
|
||||
|
||||
If newer stable source releases lack public app assets, report that as an upstream app publishing miss and keep the app pin on the newest public zip.
|
||||
@ -1,22 +0,0 @@
|
||||
{
|
||||
lib,
|
||||
stdenvNoCC,
|
||||
openclawPackage,
|
||||
}:
|
||||
|
||||
stdenvNoCC.mkDerivation {
|
||||
pname = "openclaw-bin-surface";
|
||||
version = lib.getVersion openclawPackage;
|
||||
|
||||
dontUnpack = true;
|
||||
dontConfigure = true;
|
||||
dontBuild = true;
|
||||
|
||||
env = {
|
||||
OPENCLAW_PACKAGE = openclawPackage;
|
||||
};
|
||||
|
||||
doCheck = true;
|
||||
checkPhase = "${../scripts/check-openclaw-bin-surface.sh}";
|
||||
installPhase = "${../scripts/empty-install.sh}";
|
||||
}
|
||||
@ -1,121 +0,0 @@
|
||||
{
|
||||
lib,
|
||||
pkgs,
|
||||
stdenv,
|
||||
nodejs_22,
|
||||
openclawGateway,
|
||||
}:
|
||||
|
||||
let
|
||||
stubModule =
|
||||
{ lib, ... }:
|
||||
{
|
||||
options = {
|
||||
assertions = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.attrs;
|
||||
default = [ ];
|
||||
};
|
||||
|
||||
home.homeDirectory = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "/tmp";
|
||||
};
|
||||
|
||||
home.packages = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.anything;
|
||||
default = [ ];
|
||||
};
|
||||
|
||||
home.file = lib.mkOption {
|
||||
type = lib.types.attrs;
|
||||
default = { };
|
||||
};
|
||||
|
||||
home.activation = lib.mkOption {
|
||||
type = lib.types.attrs;
|
||||
default = { };
|
||||
};
|
||||
|
||||
launchd.agents = lib.mkOption {
|
||||
type = lib.types.attrs;
|
||||
default = { };
|
||||
};
|
||||
|
||||
systemd.user.services = lib.mkOption {
|
||||
type = lib.types.attrs;
|
||||
default = { };
|
||||
};
|
||||
|
||||
programs.git.enable = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
};
|
||||
|
||||
lib = lib.mkOption {
|
||||
type = lib.types.attrs;
|
||||
default = { };
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
moduleEval = lib.evalModules {
|
||||
modules = [
|
||||
stubModule
|
||||
../modules/home-manager/openclaw.nix
|
||||
(
|
||||
{ lib, ... }:
|
||||
{
|
||||
config = {
|
||||
home.homeDirectory = "/tmp";
|
||||
programs.git.enable = false;
|
||||
lib.file.mkOutOfStoreSymlink = path: path;
|
||||
programs.openclaw = {
|
||||
enable = true;
|
||||
launchd.enable = false;
|
||||
systemd.enable = false;
|
||||
instances.default = {
|
||||
workspaceDir = expectedWorkspace;
|
||||
config = {
|
||||
channels.telegram = {
|
||||
enabled = true;
|
||||
botToken = "123456:test-token";
|
||||
dmPolicy = "open";
|
||||
groupPolicy = "disabled";
|
||||
allowFrom = [ "*" ];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
)
|
||||
];
|
||||
specialArgs = { inherit pkgs; };
|
||||
};
|
||||
|
||||
configPathKey = ".openclaw/openclaw.json";
|
||||
configJson = moduleEval.config.home.file."${configPathKey}".text;
|
||||
configFile = pkgs.writeText "openclaw-config.json" configJson;
|
||||
expectedWorkspace = "/tmp/openclaw-explicit-workspace";
|
||||
|
||||
in
|
||||
stdenv.mkDerivation {
|
||||
pname = "openclaw-config-validity";
|
||||
version = lib.getVersion openclawGateway;
|
||||
|
||||
dontUnpack = true;
|
||||
dontConfigure = true;
|
||||
dontBuild = true;
|
||||
|
||||
nativeBuildInputs = [ nodejs_22 ];
|
||||
|
||||
env = {
|
||||
OPENCLAW_CONFIG_PATH = configFile;
|
||||
OPENCLAW_GATEWAY = openclawGateway;
|
||||
OPENCLAW_EXPECTED_WORKSPACE = expectedWorkspace;
|
||||
};
|
||||
|
||||
doCheck = true;
|
||||
checkPhase = "${nodejs_22}/bin/node ${../scripts/check-config-validity.mjs}";
|
||||
installPhase = "${../scripts/empty-install.sh}";
|
||||
}
|
||||
@ -1,376 +0,0 @@
|
||||
{
|
||||
lib,
|
||||
pkgs,
|
||||
stdenv,
|
||||
}:
|
||||
|
||||
let
|
||||
testLib = lib.extend (
|
||||
_final: _prev: {
|
||||
hm.dag = {
|
||||
entryAfter = after: data: {
|
||||
inherit after data;
|
||||
before = [ ];
|
||||
};
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
lockedPathFlake =
|
||||
name: path: narHash:
|
||||
let
|
||||
# If a fixture changes, update with: nix hash path --sri nix/tests/plugins/<name>
|
||||
storePath = builtins.path {
|
||||
inherit name path;
|
||||
sha256 = narHash;
|
||||
};
|
||||
in
|
||||
"path:${builtins.unsafeDiscardStringContext (toString storePath)}?narHash=${narHash}";
|
||||
|
||||
alphaPluginSource =
|
||||
lockedPathFlake "openclaw-test-plugin-alpha" ../tests/plugins/alpha
|
||||
"sha256-FV4UN38sPy2Yp/HhqUxd0HW5l2PcIBBmUz4JzxTAOXY=";
|
||||
betaPluginSource =
|
||||
lockedPathFlake "openclaw-test-plugin-beta" ../tests/plugins/beta
|
||||
"sha256-lDKtQKHZHqOkOprjLZzBEu8cFJhAdyEzsays9hdVeqE=";
|
||||
runtimePluginSource =
|
||||
lockedPathFlake "openclaw-test-plugin-runtime" ../tests/plugins/runtime
|
||||
"sha256-Ytei4j076EQ5rcpoiMt4BhSGUMtlU5kohQ+CCfKwxEE=";
|
||||
|
||||
stubModule =
|
||||
{ lib, ... }:
|
||||
{
|
||||
options = {
|
||||
assertions = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.attrs;
|
||||
default = [ ];
|
||||
};
|
||||
|
||||
home.homeDirectory = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "/tmp";
|
||||
};
|
||||
|
||||
home.packages = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.anything;
|
||||
default = [ ];
|
||||
};
|
||||
|
||||
home.file = lib.mkOption {
|
||||
type = lib.types.attrs;
|
||||
default = { };
|
||||
};
|
||||
|
||||
home.activation = lib.mkOption {
|
||||
type = lib.types.attrs;
|
||||
default = { };
|
||||
};
|
||||
|
||||
launchd.agents = lib.mkOption {
|
||||
type = lib.types.attrs;
|
||||
default = { };
|
||||
};
|
||||
|
||||
systemd.user.services = lib.mkOption {
|
||||
type = lib.types.attrs;
|
||||
default = { };
|
||||
};
|
||||
|
||||
programs.git.enable = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
};
|
||||
|
||||
lib = lib.mkOption {
|
||||
type = lib.types.attrs;
|
||||
default = { };
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
moduleEval =
|
||||
openclawConfig:
|
||||
testLib.evalModules {
|
||||
modules = [
|
||||
stubModule
|
||||
../modules/home-manager/openclaw.nix
|
||||
(
|
||||
{ lib, ... }:
|
||||
{
|
||||
config = {
|
||||
home.homeDirectory = "/tmp";
|
||||
programs.git.enable = false;
|
||||
lib.file.mkOutOfStoreSymlink = path: path;
|
||||
programs.openclaw = {
|
||||
enable = true;
|
||||
launchd.enable = pkgs.stdenv.hostPlatform.isDarwin;
|
||||
systemd.enable = pkgs.stdenv.hostPlatform.isLinux;
|
||||
}
|
||||
// openclawConfig;
|
||||
};
|
||||
}
|
||||
)
|
||||
];
|
||||
specialArgs = { inherit pkgs; };
|
||||
};
|
||||
|
||||
failedAssertions =
|
||||
eval: lib.filter (assertion: !(assertion.assertion or false)) eval.config.assertions;
|
||||
|
||||
requireNoAssertionFailures =
|
||||
name: eval:
|
||||
let
|
||||
failures = failedAssertions eval;
|
||||
messages = map (assertion: assertion.message or "(no message)") failures;
|
||||
in
|
||||
if failures == [ ] then "ok" else throw "${name}: ${lib.concatStringsSep "; " messages}";
|
||||
|
||||
requireAssertionFailure =
|
||||
name: needle: eval:
|
||||
let
|
||||
failures = failedAssertions eval;
|
||||
matching = lib.filter (assertion: lib.hasInfix needle (assertion.message or "")) failures;
|
||||
in
|
||||
if matching != [ ] then "ok" else throw "${name}: expected assertion containing `${needle}`.";
|
||||
|
||||
defaultEval = moduleEval { };
|
||||
defaultConfig = builtins.fromJSON defaultEval.config.home.file.".openclaw/openclaw.json".text;
|
||||
hasLinuxUnit = builtins.hasAttr "openclaw-gateway" defaultEval.config.systemd.user.services;
|
||||
hasDarwinAgent = builtins.hasAttr "com.steipete.openclaw.gateway" defaultEval.config.launchd.agents;
|
||||
defaultCheck = builtins.deepSeq (requireNoAssertionFailures "default instance" defaultEval) (
|
||||
if pkgs.stdenv.hostPlatform.isLinux && !hasLinuxUnit then
|
||||
throw "Default OpenClaw instance missing systemd.unitName."
|
||||
else if pkgs.stdenv.hostPlatform.isDarwin && !hasDarwinAgent then
|
||||
throw "Default OpenClaw instance missing launchd.label."
|
||||
else if (((defaultConfig.gateway or { }).mode or null) != "local") then
|
||||
throw "Default OpenClaw instance missing gateway.mode."
|
||||
else
|
||||
"ok"
|
||||
);
|
||||
|
||||
customPluginEval = moduleEval {
|
||||
customPlugins = [
|
||||
{ source = alphaPluginSource; }
|
||||
];
|
||||
};
|
||||
customPluginSkill = ".openclaw/workspace/skills/skill";
|
||||
customPluginActivation = builtins.toJSON customPluginEval.config.home.activation.openclawWorkspaceFiles;
|
||||
hasCustomPluginMaterializer = lib.hasInfix "openclaw-materialize-workspace-files" customPluginActivation;
|
||||
customPluginCheck = builtins.deepSeq (requireNoAssertionFailures "customPlugins" customPluginEval) (
|
||||
if hasCustomPluginMaterializer then
|
||||
"ok"
|
||||
else
|
||||
throw "customPlugins did not wire workspace file materialization."
|
||||
);
|
||||
|
||||
duplicateSkillEval = moduleEval {
|
||||
customPlugins = [
|
||||
{ source = alphaPluginSource; }
|
||||
{ source = betaPluginSource; }
|
||||
];
|
||||
};
|
||||
duplicateSkillCheck =
|
||||
requireAssertionFailure "duplicate plugin skills"
|
||||
"Duplicate skill paths detected: ${customPluginSkill}"
|
||||
duplicateSkillEval;
|
||||
|
||||
userPluginSkillCollisionEval = moduleEval {
|
||||
customPlugins = [
|
||||
{ source = alphaPluginSource; }
|
||||
];
|
||||
skills = [
|
||||
{
|
||||
name = "skill";
|
||||
mode = "inline";
|
||||
}
|
||||
];
|
||||
};
|
||||
userPluginSkillCollisionCheck =
|
||||
requireAssertionFailure "user/plugin skill collision"
|
||||
"Duplicate skill paths detected: ${customPluginSkill}"
|
||||
userPluginSkillCollisionEval;
|
||||
|
||||
secretProviderEval = moduleEval {
|
||||
config.secrets.providers.test-file = {
|
||||
source = "file";
|
||||
path = "/tmp/openclaw-secrets.json";
|
||||
mode = "json";
|
||||
};
|
||||
};
|
||||
secretProviderConfig =
|
||||
builtins.fromJSON
|
||||
secretProviderEval.config.home.file.".openclaw/openclaw.json".text;
|
||||
secretProviderCheck =
|
||||
builtins.deepSeq (requireNoAssertionFailures "secrets.providers" secretProviderEval)
|
||||
(
|
||||
if
|
||||
((((secretProviderConfig.secrets or { }).providers or { }).test-file or { }).source == "file")
|
||||
then
|
||||
"ok"
|
||||
else
|
||||
throw "secrets.providers file variant missing from generated config."
|
||||
);
|
||||
|
||||
qmdPrewarmEval = moduleEval {
|
||||
qmd.prewarmModels.enable = true;
|
||||
};
|
||||
qmdPrewarmActivation = builtins.toJSON qmdPrewarmEval.config.home.activation.openclawQmdPrewarm;
|
||||
qmdPrewarmCheck = builtins.deepSeq (requireNoAssertionFailures "qmd.prewarmModels" qmdPrewarmEval) (
|
||||
if
|
||||
lib.hasInfix "OPENCLAW_QMD_BIN=" qmdPrewarmActivation
|
||||
&& lib.hasInfix "openclaw-qmd-prewarm.sh" qmdPrewarmActivation
|
||||
then
|
||||
"ok"
|
||||
else
|
||||
throw "qmd.prewarmModels did not wire QMD model-cache prewarm activation."
|
||||
);
|
||||
|
||||
runtimeProfileEval = moduleEval {
|
||||
runtimePackages = [ pkgs.jq ];
|
||||
environment.OPENCLAW_TEST_SECRET = "/tmp/openclaw-secret";
|
||||
};
|
||||
runtimeProfileActivation = builtins.toJSON runtimeProfileEval.config.home.activation.openclawCodexRuntimeProfiles;
|
||||
runtimeProfileCheck =
|
||||
builtins.deepSeq (requireNoAssertionFailures "runtime profile" runtimeProfileEval)
|
||||
(
|
||||
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 "OpenClaw plugin root with enabled=false was not 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.enabled or null) != false then
|
||||
throw "OpenClaw plugin entry with enabled=false did not render a disabled default."
|
||||
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 { };
|
||||
openclawPluginOverrideDisabledEntry =
|
||||
((openclawPluginOverrideConfig.plugins or { }).entries or { }).runtime-disabled or { };
|
||||
openclawPluginOverrideCheck =
|
||||
builtins.deepSeq (requireNoAssertionFailures "OpenClaw plugin override" openclawPluginOverrideEval)
|
||||
(
|
||||
if (openclawPluginOverrideEntry.enabled or null) != false then
|
||||
throw "User config could not override OpenClaw plugin enabled default."
|
||||
else if (openclawPluginOverrideDisabledEntry.enabled or null) != false then
|
||||
throw "Plugin enabled=false default did not survive when not overridden."
|
||||
else
|
||||
"ok"
|
||||
);
|
||||
|
||||
openclawPluginEnableOverrideEval = moduleEval {
|
||||
customPlugins = [
|
||||
{ source = runtimePluginSource; }
|
||||
];
|
||||
config.plugins.entries.runtime-disabled.enabled = true;
|
||||
};
|
||||
openclawPluginEnableOverrideConfig = builtins.fromJSON (
|
||||
builtins.unsafeDiscardStringContext
|
||||
openclawPluginEnableOverrideEval.config.home.file.".openclaw/openclaw.json".text
|
||||
);
|
||||
openclawPluginEnableOverrideEntry =
|
||||
((openclawPluginEnableOverrideConfig.plugins or { }).entries or { }).runtime-disabled or { };
|
||||
openclawPluginEnableOverrideCheck =
|
||||
builtins.deepSeq
|
||||
(requireNoAssertionFailures "OpenClaw plugin enable override" openclawPluginEnableOverrideEval)
|
||||
(
|
||||
if (openclawPluginEnableOverrideEntry.enabled or null) == true then
|
||||
"ok"
|
||||
else
|
||||
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 [
|
||||
defaultCheck
|
||||
customPluginCheck
|
||||
duplicateSkillCheck
|
||||
userPluginSkillCollisionCheck
|
||||
secretProviderCheck
|
||||
qmdPrewarmCheck
|
||||
runtimeProfileCheck
|
||||
openclawPluginCheck
|
||||
openclawPluginOverrideCheck
|
||||
openclawPluginEnableOverrideCheck
|
||||
npmRuntimePluginCheck
|
||||
] "ok";
|
||||
|
||||
in
|
||||
stdenv.mkDerivation {
|
||||
pname = "openclaw-default-instance";
|
||||
version = "1";
|
||||
dontUnpack = true;
|
||||
env = {
|
||||
OPENCLAW_DEFAULT_INSTANCE = checkKey;
|
||||
};
|
||||
installPhase = "${../scripts/empty-install.sh}";
|
||||
}
|
||||
@ -1,27 +0,0 @@
|
||||
{
|
||||
lib,
|
||||
stdenv,
|
||||
nodejs_22,
|
||||
openclawGateway,
|
||||
}:
|
||||
|
||||
stdenv.mkDerivation {
|
||||
pname = "openclaw-gateway-smoke";
|
||||
version = lib.getVersion openclawGateway;
|
||||
|
||||
dontUnpack = true;
|
||||
dontConfigure = true;
|
||||
dontBuild = true;
|
||||
|
||||
nativeBuildInputs = [ nodejs_22 ];
|
||||
|
||||
env = {
|
||||
OPENCLAW_GATEWAY = openclawGateway;
|
||||
};
|
||||
|
||||
__darwinAllowLocalNetworking = true;
|
||||
|
||||
doCheck = true;
|
||||
checkPhase = "${nodejs_22}/bin/node ${../scripts/gateway-smoke.mjs}";
|
||||
installPhase = "${../scripts/empty-install.sh}";
|
||||
}
|
||||
@ -1,100 +0,0 @@
|
||||
{ pkgs, home-manager }:
|
||||
|
||||
let
|
||||
openclawModule = ../modules/home-manager/openclaw.nix;
|
||||
testScript = builtins.readFile ../tests/hm-activation.py;
|
||||
lockedPathFlake =
|
||||
name: path: narHash:
|
||||
let
|
||||
storePath = builtins.path {
|
||||
inherit name path;
|
||||
sha256 = narHash;
|
||||
};
|
||||
in
|
||||
"path:${builtins.unsafeDiscardStringContext (toString storePath)}?narHash=${narHash}";
|
||||
alphaPluginSource =
|
||||
lockedPathFlake "openclaw-test-plugin-alpha" ../tests/plugins/alpha
|
||||
"sha256-FV4UN38sPy2Yp/HhqUxd0HW5l2PcIBBmUz4JzxTAOXY=";
|
||||
|
||||
in
|
||||
pkgs.testers.nixosTest {
|
||||
name = "openclaw-hm-activation";
|
||||
|
||||
nodes.machine =
|
||||
{ ... }:
|
||||
{
|
||||
imports = [ home-manager.nixosModules.home-manager ];
|
||||
|
||||
networking.firewall.allowedTCPPorts = [ 18999 ];
|
||||
|
||||
users.users.alice = {
|
||||
isNormalUser = true;
|
||||
home = "/home/alice";
|
||||
extraGroups = [ "wheel" ];
|
||||
};
|
||||
|
||||
home-manager = {
|
||||
useGlobalPkgs = true;
|
||||
useUserPackages = true;
|
||||
users.alice =
|
||||
{ lib, ... }:
|
||||
{
|
||||
imports = [ openclawModule ];
|
||||
|
||||
home = {
|
||||
username = "alice";
|
||||
homeDirectory = "/home/alice";
|
||||
stateVersion = "23.11";
|
||||
};
|
||||
|
||||
programs.openclaw = {
|
||||
enable = true;
|
||||
documents = ../tests/documents;
|
||||
customPlugins = [
|
||||
{ source = alphaPluginSource; }
|
||||
];
|
||||
installApp = false;
|
||||
launchd.enable = false;
|
||||
instances.default = {
|
||||
gatewayPort = 18999;
|
||||
config = {
|
||||
logging = {
|
||||
level = "debug";
|
||||
file = "/tmp/openclaw/openclaw-gateway.log";
|
||||
};
|
||||
gateway = {
|
||||
mode = "local";
|
||||
auth = {
|
||||
token = "hm-activation-test-token";
|
||||
};
|
||||
};
|
||||
plugins = {
|
||||
enabled = false;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
systemd.user.services."openclaw-gateway".Service = {
|
||||
Environment = lib.mkAfter [
|
||||
"OPENCLAW_SKIP_BROWSER_CONTROL_SERVER=1"
|
||||
"OPENCLAW_SKIP_CANVAS_HOST=1"
|
||||
"OPENCLAW_SKIP_CHANNELS=1"
|
||||
"OPENCLAW_SKIP_CRON=1"
|
||||
"OPENCLAW_SKIP_GMAIL_WATCHER=1"
|
||||
"OPENCLAW_DISABLE_BONJOUR=1"
|
||||
"NODE_OPTIONS=--report-on-fatalerror"
|
||||
"NODE_REPORT_DIRECTORY=/tmp/openclaw"
|
||||
"NODE_REPORT_FILENAME=node-report.%p.json"
|
||||
];
|
||||
Restart = lib.mkForce "no";
|
||||
RestartSec = lib.mkForce "0";
|
||||
StandardOutput = lib.mkForce "journal";
|
||||
StandardError = lib.mkForce "journal";
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
testScript = testScript;
|
||||
}
|
||||
@ -1,24 +0,0 @@
|
||||
{
|
||||
lib,
|
||||
stdenv,
|
||||
nodejs_22,
|
||||
openclawGateway,
|
||||
}:
|
||||
|
||||
stdenv.mkDerivation {
|
||||
pname = "openclaw-package-contents";
|
||||
version = lib.getVersion openclawGateway;
|
||||
|
||||
dontUnpack = true;
|
||||
dontConfigure = true;
|
||||
dontBuild = true;
|
||||
|
||||
env = {
|
||||
OPENCLAW_GATEWAY = openclawGateway;
|
||||
};
|
||||
|
||||
doCheck = true;
|
||||
nativeCheckInputs = [ nodejs_22 ];
|
||||
checkPhase = "${../scripts/check-package-contents.sh}";
|
||||
installPhase = "${../scripts/empty-install.sh}";
|
||||
}
|
||||
@ -1,24 +0,0 @@
|
||||
{
|
||||
lib,
|
||||
stdenvNoCC,
|
||||
openclawPackage,
|
||||
qmdPackage ? null,
|
||||
}:
|
||||
|
||||
stdenvNoCC.mkDerivation {
|
||||
pname = "openclaw-qmd-runtime";
|
||||
version = lib.getVersion openclawPackage;
|
||||
|
||||
dontUnpack = true;
|
||||
dontConfigure = true;
|
||||
dontBuild = true;
|
||||
|
||||
env = {
|
||||
OPENCLAW_PACKAGE = openclawPackage;
|
||||
QMD_PACKAGE = lib.optionalString (qmdPackage != null) "${qmdPackage}";
|
||||
};
|
||||
|
||||
doCheck = true;
|
||||
checkPhase = "${../scripts/check-openclaw-qmd-runtime.sh}";
|
||||
installPhase = "${../scripts/empty-install.sh}";
|
||||
}
|
||||
@ -1,170 +0,0 @@
|
||||
{
|
||||
lib,
|
||||
pkgs,
|
||||
stdenv,
|
||||
fetchFromGitHub,
|
||||
fetchurl,
|
||||
nodejs_22,
|
||||
pnpm_10,
|
||||
fetchPnpmDeps,
|
||||
pkg-config,
|
||||
jq,
|
||||
python3,
|
||||
node-gyp,
|
||||
vips,
|
||||
git,
|
||||
zstd,
|
||||
sourceInfo,
|
||||
openclawGateway,
|
||||
pnpmDepsHash ? (sourceInfo.pnpmDepsHash or null),
|
||||
}:
|
||||
|
||||
let
|
||||
pluginCatalog = import ../modules/home-manager/openclaw/plugin-catalog.nix;
|
||||
linuxBundledPlugins = builtins.attrNames (
|
||||
lib.filterAttrs (_: plugin: plugin.linux or false) pluginCatalog
|
||||
);
|
||||
enableBundledPlugin = name: stdenv.hostPlatform.isDarwin || lib.elem name linuxBundledPlugins;
|
||||
|
||||
stubModule =
|
||||
{ lib, ... }:
|
||||
{
|
||||
options = {
|
||||
assertions = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.attrs;
|
||||
default = [ ];
|
||||
};
|
||||
|
||||
home.homeDirectory = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "/tmp";
|
||||
};
|
||||
|
||||
home.packages = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.anything;
|
||||
default = [ ];
|
||||
};
|
||||
|
||||
home.file = lib.mkOption {
|
||||
type = lib.types.attrs;
|
||||
default = { };
|
||||
};
|
||||
|
||||
home.activation = lib.mkOption {
|
||||
type = lib.types.attrs;
|
||||
default = { };
|
||||
};
|
||||
|
||||
launchd.agents = lib.mkOption {
|
||||
type = lib.types.attrs;
|
||||
default = { };
|
||||
};
|
||||
|
||||
systemd.user.services = lib.mkOption {
|
||||
type = lib.types.attrs;
|
||||
default = { };
|
||||
};
|
||||
|
||||
programs.git.enable = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
};
|
||||
|
||||
lib = lib.mkOption {
|
||||
type = lib.types.attrs;
|
||||
default = { };
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
pluginEval = lib.evalModules {
|
||||
modules = [
|
||||
stubModule
|
||||
../modules/home-manager/openclaw.nix
|
||||
(
|
||||
{ lib, options, ... }:
|
||||
{
|
||||
config = {
|
||||
home.homeDirectory = "/tmp";
|
||||
programs.git.enable = false;
|
||||
lib.file.mkOutOfStoreSymlink = path: path;
|
||||
programs.openclaw = {
|
||||
enable = true;
|
||||
launchd.enable = false;
|
||||
systemd.enable = false;
|
||||
instances.default = { };
|
||||
bundledPlugins = lib.mapAttrs (name: _: {
|
||||
enable = enableBundledPlugin name;
|
||||
}) options.programs.openclaw.bundledPlugins;
|
||||
};
|
||||
};
|
||||
}
|
||||
)
|
||||
];
|
||||
specialArgs = { inherit pkgs; };
|
||||
};
|
||||
|
||||
pluginEvalKey = builtins.deepSeq pluginEval.config.assertions "ok";
|
||||
|
||||
common =
|
||||
import ../lib/openclaw-gateway-common.nix
|
||||
{
|
||||
inherit
|
||||
lib
|
||||
stdenv
|
||||
fetchFromGitHub
|
||||
fetchurl
|
||||
nodejs_22
|
||||
pnpm_10
|
||||
fetchPnpmDeps
|
||||
pkg-config
|
||||
jq
|
||||
python3
|
||||
node-gyp
|
||||
git
|
||||
zstd
|
||||
;
|
||||
}
|
||||
{
|
||||
pname = "openclaw-source-checks";
|
||||
sourceInfo = sourceInfo;
|
||||
pnpmDepsHash = pnpmDepsHash;
|
||||
pnpmDepsPname = "openclaw-gateway";
|
||||
enableSharp = true;
|
||||
extraBuildInputs = [ vips ];
|
||||
};
|
||||
|
||||
in
|
||||
|
||||
stdenv.mkDerivation (finalAttrs: {
|
||||
pname = "openclaw-source-checks";
|
||||
inherit (common) version;
|
||||
|
||||
src = common.resolvedSrc;
|
||||
pnpmDeps = common.pnpmDeps;
|
||||
|
||||
nativeBuildInputs = common.nativeBuildInputs;
|
||||
buildInputs = common.buildInputs;
|
||||
|
||||
env = common.env // {
|
||||
PNPM_DEPS = finalAttrs.pnpmDeps;
|
||||
OPENCLAW_GATEWAY = openclawGateway;
|
||||
CONFIG_OPTIONS_GENERATOR = "${../scripts/generate-config-options.ts}";
|
||||
CONFIG_OPTIONS_GOLDEN = "${../generated/openclaw-config-options.nix}";
|
||||
CONFIG_OPTIONS_CHECK_SH = "${../scripts/config-options-check.sh}";
|
||||
NODE_ENGINE_CHECK = "${../scripts/check-node-engine.ts}";
|
||||
OPENCLAW_PLUGIN_EVAL = pluginEvalKey;
|
||||
OPENCLAW_SCHEMA_REV = sourceInfo.rev;
|
||||
};
|
||||
|
||||
passthru = common.passthru;
|
||||
|
||||
postPatch = "${../scripts/gateway-postpatch.sh}";
|
||||
buildPhase = "${../scripts/source-checks-build.sh}";
|
||||
|
||||
doCheck = true;
|
||||
checkPhase = "${../scripts/source-checks-check.sh}";
|
||||
|
||||
installPhase = "${../scripts/empty-install.sh}";
|
||||
dontPatchShebangs = true;
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,48 +0,0 @@
|
||||
{
|
||||
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;
|
||||
};
|
||||
}
|
||||
@ -1,140 +0,0 @@
|
||||
{
|
||||
lib,
|
||||
stdenv,
|
||||
fetchFromGitHub,
|
||||
fetchurl,
|
||||
nodejs_22,
|
||||
pnpm_10,
|
||||
fetchPnpmDeps,
|
||||
pkg-config,
|
||||
jq,
|
||||
python3,
|
||||
node-gyp,
|
||||
git,
|
||||
zstd,
|
||||
}:
|
||||
|
||||
# Shared build plumbing for OpenClaw gateway-related derivations.
|
||||
#
|
||||
# Goals:
|
||||
# - one source of truth for pnpm deps fetch + common env
|
||||
# - keep the individual derivations small/boring
|
||||
|
||||
{
|
||||
pname,
|
||||
sourceInfo,
|
||||
pnpmDepsHash ? (sourceInfo.pnpmDepsHash or null),
|
||||
pnpmDepsPname ? "openclaw-gateway",
|
||||
gatewaySrc ? null,
|
||||
src ? null,
|
||||
enableSharp ? false,
|
||||
extraNativeBuildInputs ? [ ],
|
||||
extraBuildInputs ? [ ],
|
||||
extraEnv ? { },
|
||||
}:
|
||||
|
||||
let
|
||||
sourceFetch = lib.removeAttrs sourceInfo [
|
||||
"pnpmDepsHash"
|
||||
"releaseTag"
|
||||
"releaseVersion"
|
||||
"applyPublicSurfaceHardlinksPatch"
|
||||
"applySkipPluginAutoEnableNixModePatch"
|
||||
"publicSurfaceHardlinksPatch"
|
||||
"fsSafeSource"
|
||||
];
|
||||
|
||||
# Prefer nixpkgs' platform mapping instead of hand-rolled arch/platform.
|
||||
pnpmPlatform = stdenv.hostPlatform.node.platform;
|
||||
pnpmArch = stdenv.hostPlatform.node.arch;
|
||||
|
||||
revShort = lib.substring 0 8 sourceInfo.rev;
|
||||
version = "unstable-${revShort}";
|
||||
|
||||
resolvedSrc =
|
||||
if src != null then
|
||||
src
|
||||
else if gatewaySrc != null then
|
||||
gatewaySrc
|
||||
else
|
||||
fetchFromGitHub sourceFetch;
|
||||
|
||||
fsSafeSource = if sourceInfo ? fsSafeSource then fetchFromGitHub sourceInfo.fsSafeSource else null;
|
||||
publicSurfaceHardlinksPatch =
|
||||
sourceInfo.publicSurfaceHardlinksPatch or ../patches/allow-package-public-surface-hardlinks.patch;
|
||||
|
||||
nodeAddonApi = import ../packages/node-addon-api.nix { inherit stdenv fetchurl; };
|
||||
|
||||
pnpmDeps = fetchPnpmDeps {
|
||||
pname = pnpmDepsPname;
|
||||
inherit version;
|
||||
src = resolvedSrc;
|
||||
pnpm = pnpm_10;
|
||||
hash = if pnpmDepsHash != null then pnpmDepsHash else lib.fakeHash;
|
||||
fetcherVersion = 3;
|
||||
npm_config_arch = pnpmArch;
|
||||
npm_config_platform = pnpmPlatform;
|
||||
nativeBuildInputs = [ git ];
|
||||
};
|
||||
|
||||
envBase = {
|
||||
npm_config_arch = pnpmArch;
|
||||
npm_config_platform = pnpmPlatform;
|
||||
PNPM_CONFIG_MANAGE_PACKAGE_MANAGER_VERSIONS = "false";
|
||||
npm_config_nodedir = nodejs_22;
|
||||
npm_config_python = python3;
|
||||
NODE_PATH = "${nodeAddonApi}/lib/node_modules:${node-gyp}/lib/node_modules";
|
||||
PNPM_DEPS = pnpmDeps;
|
||||
OPENCLAW_BUILD_ROOT_SH = "${../scripts/build-root.sh}";
|
||||
NODE_GYP_WRAPPER_SH = "${../scripts/node-gyp-wrapper.sh}";
|
||||
GATEWAY_PREBUILD_SH = "${../scripts/gateway-prebuild.sh}";
|
||||
PATCH_BUNDLED_RUNTIME_DEPS_SCRIPT = "${../patches/stage-bundled-plugin-runtime-deps.mjs}";
|
||||
PATCH_PUBLIC_SURFACE_HARDLINKS =
|
||||
if sourceInfo.applyPublicSurfaceHardlinksPatch or true then
|
||||
"${publicSurfaceHardlinksPatch}"
|
||||
else
|
||||
"";
|
||||
PATCH_SKIP_PLUGIN_AUTO_ENABLE_NIX_MODE =
|
||||
if sourceInfo.applySkipPluginAutoEnableNixModePatch or true then
|
||||
"${../patches/skip-plugin-auto-enable-persist-in-nix-mode.patch}"
|
||||
else
|
||||
"";
|
||||
PROMOTE_PNPM_INTEGRITY_SH = "${../scripts/promote-pnpm-integrity.sh}";
|
||||
REMOVE_PACKAGE_MANAGER_FIELD_SH = "${../scripts/remove-package-manager-field.sh}";
|
||||
STDENV_SETUP = "${stdenv}/setup";
|
||||
}
|
||||
// lib.optionalAttrs (fsSafeSource != null) {
|
||||
OPENCLAW_FS_SAFE_SOURCE = fsSafeSource;
|
||||
};
|
||||
|
||||
in
|
||||
{
|
||||
inherit
|
||||
version
|
||||
pnpmDeps
|
||||
resolvedSrc
|
||||
pnpmPlatform
|
||||
pnpmArch
|
||||
nodeAddonApi
|
||||
;
|
||||
|
||||
nativeBuildInputs = [
|
||||
nodejs_22
|
||||
pnpm_10
|
||||
pkg-config
|
||||
jq
|
||||
python3
|
||||
node-gyp
|
||||
zstd
|
||||
]
|
||||
++ extraNativeBuildInputs;
|
||||
|
||||
buildInputs = extraBuildInputs;
|
||||
|
||||
env = envBase // (lib.optionalAttrs enableSharp { SHARP_IGNORE_GLOBAL_LIBVIPS = "1"; }) // extraEnv;
|
||||
|
||||
passthru = {
|
||||
inherit sourceInfo pnpmDeps;
|
||||
pinnedRev = sourceInfo.rev;
|
||||
};
|
||||
}
|
||||
@ -2,6 +2,6 @@
|
||||
|
||||
{
|
||||
config = lib.mkIf (config ? home-manager) {
|
||||
home-manager.sharedModules = [ ../home-manager/openclaw.nix ];
|
||||
home-manager.sharedModules = [ ../home-manager/clawdis.nix ];
|
||||
};
|
||||
}
|
||||
408
nix/modules/home-manager/clawdis.nix
Normal file
408
nix/modules/home-manager/clawdis.nix
Normal file
@ -0,0 +1,408 @@
|
||||
{ config, lib, pkgs, ... }:
|
||||
|
||||
let
|
||||
cfg = config.programs.clawdis;
|
||||
homeDir = config.home.homeDirectory;
|
||||
appPackage = if cfg.appPackage != null then cfg.appPackage else cfg.package;
|
||||
|
||||
mkBaseConfig = workspaceDir: {
|
||||
gateway = { mode = "local"; };
|
||||
agent = { workspace = workspaceDir; };
|
||||
};
|
||||
|
||||
mkTelegramConfig = inst: lib.optionalAttrs inst.providers.telegram.enable {
|
||||
telegram = {
|
||||
enabled = true;
|
||||
tokenFile = inst.providers.telegram.botTokenFile;
|
||||
allowFrom = inst.providers.telegram.allowFrom;
|
||||
requireMention = inst.providers.telegram.requireMention;
|
||||
};
|
||||
};
|
||||
|
||||
mkRoutingConfig = inst: {
|
||||
routing = {
|
||||
queue = {
|
||||
mode = inst.routing.queue.mode;
|
||||
bySurface = inst.routing.queue.bySurface;
|
||||
};
|
||||
groupChat = {
|
||||
requireMention = inst.routing.groupChat.requireMention;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
instanceModule = { name, config, ... }: {
|
||||
options = {
|
||||
enable = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
description = "Enable this Clawdis instance.";
|
||||
};
|
||||
|
||||
package = lib.mkOption {
|
||||
type = lib.types.package;
|
||||
default = cfg.package;
|
||||
description = "Clawdis batteries-included package.";
|
||||
};
|
||||
|
||||
stateDir = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = if name == "default"
|
||||
then "${homeDir}/.clawdis"
|
||||
else "${homeDir}/.clawdis-${name}";
|
||||
description = "State directory for this Clawdis instance (logs, sessions, config).";
|
||||
};
|
||||
|
||||
workspaceDir = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "${config.stateDir}/workspace";
|
||||
description = "Workspace directory for this Clawdis instance.";
|
||||
};
|
||||
|
||||
configPath = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "${config.stateDir}/clawdis.json";
|
||||
description = "Path to generated Clawdis config JSON.";
|
||||
};
|
||||
|
||||
logPath = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = if name == "default"
|
||||
then "/tmp/clawdis/clawdis-gateway.log"
|
||||
else "/tmp/clawdis/clawdis-gateway-${name}.log";
|
||||
description = "Log path for this Clawdis gateway instance.";
|
||||
};
|
||||
|
||||
gatewayPort = lib.mkOption {
|
||||
type = lib.types.int;
|
||||
default = 18789;
|
||||
description = "Gateway port used by the Clawdis desktop app.";
|
||||
};
|
||||
|
||||
providers.telegram = {
|
||||
enable = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = "Enable Telegram provider.";
|
||||
};
|
||||
|
||||
botTokenFile = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "";
|
||||
description = "Path to Telegram bot token file.";
|
||||
};
|
||||
|
||||
allowFrom = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.int;
|
||||
default = [];
|
||||
description = "Allowed Telegram chat IDs.";
|
||||
};
|
||||
|
||||
requireMention = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = "Require @mention in Telegram groups.";
|
||||
};
|
||||
};
|
||||
|
||||
providers.anthropic = {
|
||||
apiKeyFile = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "";
|
||||
description = "Path to Anthropic API key file (used to set ANTHROPIC_API_KEY).";
|
||||
};
|
||||
};
|
||||
|
||||
routing.queue = {
|
||||
mode = lib.mkOption {
|
||||
type = lib.types.enum [ "queue" "interrupt" ];
|
||||
default = "interrupt";
|
||||
description = "Queue mode when a run is active.";
|
||||
};
|
||||
|
||||
bySurface = lib.mkOption {
|
||||
type = lib.types.attrs;
|
||||
default = {
|
||||
telegram = "interrupt";
|
||||
discord = "queue";
|
||||
webchat = "queue";
|
||||
};
|
||||
description = "Per-surface queue mode overrides.";
|
||||
};
|
||||
};
|
||||
|
||||
routing.groupChat.requireMention = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = "Require mention for group chat activation.";
|
||||
};
|
||||
|
||||
launchd.enable = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
description = "Run Clawdis gateway via launchd (macOS).";
|
||||
};
|
||||
|
||||
launchd.label = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = if name == "default"
|
||||
then "com.steipete.clawdis.gateway"
|
||||
else "com.steipete.clawdis.gateway.${name}";
|
||||
description = "launchd label for this instance.";
|
||||
};
|
||||
|
||||
appDefaults = {
|
||||
enable = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = name == "default";
|
||||
description = "Configure macOS app defaults for this instance.";
|
||||
};
|
||||
|
||||
attachExistingOnly = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
description = "Attach existing gateway only (macOS).";
|
||||
};
|
||||
};
|
||||
|
||||
configOverrides = lib.mkOption {
|
||||
type = lib.types.attrs;
|
||||
default = {};
|
||||
description = "Additional Clawdis config to merge into the generated JSON.";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
legacyInstance = {
|
||||
enable = cfg.enable;
|
||||
package = cfg.package;
|
||||
stateDir = cfg.stateDir;
|
||||
workspaceDir = cfg.workspaceDir;
|
||||
configPath = "${cfg.stateDir}/clawdis.json";
|
||||
logPath = "/tmp/clawdis/clawdis-gateway.log";
|
||||
gatewayPort = 18789;
|
||||
providers = cfg.providers;
|
||||
routing = cfg.routing;
|
||||
launchd = cfg.launchd;
|
||||
configOverrides = {};
|
||||
appDefaults = {
|
||||
enable = true;
|
||||
attachExistingOnly = true;
|
||||
};
|
||||
};
|
||||
|
||||
instances = if cfg.instances != {}
|
||||
then cfg.instances
|
||||
else lib.optionalAttrs cfg.enable { default = legacyInstance; };
|
||||
|
||||
enabledInstances = lib.filterAttrs (_: inst: inst.enable) instances;
|
||||
|
||||
mkInstanceConfig = name: inst: let
|
||||
baseConfig = mkBaseConfig inst.workspaceDir;
|
||||
mergedConfig = lib.recursiveUpdate
|
||||
(lib.recursiveUpdate baseConfig (lib.recursiveUpdate (mkTelegramConfig inst) (mkRoutingConfig inst)))
|
||||
inst.configOverrides;
|
||||
configJson = builtins.toJSON mergedConfig;
|
||||
gatewayWrapper = pkgs.writeShellScriptBin "clawdis-gateway-${name}" ''
|
||||
set -euo pipefail
|
||||
|
||||
if [ -n "${inst.providers.anthropic.apiKeyFile}" ]; then
|
||||
if [ ! -f "${inst.providers.anthropic.apiKeyFile}" ]; then
|
||||
echo "Anthropic API key file not found: ${inst.providers.anthropic.apiKeyFile}" >&2
|
||||
exit 1
|
||||
fi
|
||||
ANTHROPIC_API_KEY="$(cat "${inst.providers.anthropic.apiKeyFile}")"
|
||||
if [ -z "$ANTHROPIC_API_KEY" ]; then
|
||||
echo "Anthropic API key file is empty: ${inst.providers.anthropic.apiKeyFile}" >&2
|
||||
exit 1
|
||||
fi
|
||||
export ANTHROPIC_API_KEY
|
||||
fi
|
||||
|
||||
exec "${inst.package}/bin/clawdis" "$@"
|
||||
'';
|
||||
in {
|
||||
homeFile = {
|
||||
name = inst.configPath;
|
||||
value = { text = configJson; };
|
||||
};
|
||||
|
||||
dirs = [ inst.stateDir inst.workspaceDir (builtins.dirOf inst.logPath) ];
|
||||
|
||||
launchdAgent = lib.optionalAttrs (pkgs.stdenv.hostPlatform.isDarwin && inst.launchd.enable) {
|
||||
"${inst.launchd.label}" = {
|
||||
enable = true;
|
||||
config = {
|
||||
Label = inst.launchd.label;
|
||||
ProgramArguments = [ "${gatewayWrapper}/bin/clawdis-gateway-${name}" ];
|
||||
RunAtLoad = true;
|
||||
KeepAlive = true;
|
||||
WorkingDirectory = inst.stateDir;
|
||||
StandardOutPath = inst.logPath;
|
||||
StandardErrorPath = inst.logPath;
|
||||
EnvironmentVariables = {
|
||||
CLAWDIS_CONFIG_PATH = inst.configPath;
|
||||
CLAWDIS_STATE_DIR = inst.stateDir;
|
||||
CLAWDIS_IMAGE_BACKEND = "sips";
|
||||
CLAWDIS_NIX_MODE = "1";
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
appDefaults = lib.optionalAttrs (pkgs.stdenv.hostPlatform.isDarwin && inst.appDefaults.enable) {
|
||||
attachExistingOnly = inst.appDefaults.attachExistingOnly;
|
||||
gatewayPort = inst.gatewayPort;
|
||||
};
|
||||
|
||||
package = inst.package;
|
||||
};
|
||||
|
||||
instanceConfigs = lib.mapAttrsToList mkInstanceConfig enabledInstances;
|
||||
|
||||
appDefaults = lib.foldl' (acc: item: lib.recursiveUpdate acc item.appDefaults) {} instanceConfigs;
|
||||
|
||||
appDefaultsEnabled = lib.filterAttrs (_: inst: inst.appDefaults.enable) enabledInstances;
|
||||
|
||||
assertions = lib.flatten (lib.mapAttrsToList (name: inst: [
|
||||
{
|
||||
assertion = !inst.providers.telegram.enable || inst.providers.telegram.botTokenFile != "";
|
||||
message = "programs.clawdis.instances.${name}.providers.telegram.botTokenFile must be set when Telegram is enabled.";
|
||||
}
|
||||
{
|
||||
assertion = !inst.providers.telegram.enable || (lib.length inst.providers.telegram.allowFrom > 0);
|
||||
message = "programs.clawdis.instances.${name}.providers.telegram.allowFrom must be non-empty when Telegram is enabled.";
|
||||
}
|
||||
]) enabledInstances);
|
||||
|
||||
in {
|
||||
options.programs.clawdis = {
|
||||
enable = lib.mkEnableOption "Clawdis (batteries-included)";
|
||||
|
||||
package = lib.mkOption {
|
||||
type = lib.types.package;
|
||||
default = pkgs.clawdis;
|
||||
description = "Clawdis batteries-included package.";
|
||||
};
|
||||
|
||||
appPackage = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.package;
|
||||
default = null;
|
||||
description = "Optional Clawdis app package (defaults to package if unset).";
|
||||
};
|
||||
|
||||
stateDir = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "${homeDir}/.clawdis";
|
||||
description = "State directory for Clawdis (logs, sessions, config).";
|
||||
};
|
||||
|
||||
workspaceDir = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "${homeDir}/.clawdis/workspace";
|
||||
description = "Workspace directory for Clawdis agent skills.";
|
||||
};
|
||||
|
||||
providers.telegram = {
|
||||
enable = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = "Enable Telegram provider.";
|
||||
};
|
||||
|
||||
botTokenFile = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "";
|
||||
description = "Path to Telegram bot token file.";
|
||||
};
|
||||
|
||||
allowFrom = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.int;
|
||||
default = [];
|
||||
description = "Allowed Telegram chat IDs.";
|
||||
};
|
||||
|
||||
requireMention = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = "Require @mention in Telegram groups.";
|
||||
};
|
||||
};
|
||||
|
||||
providers.anthropic = {
|
||||
apiKeyFile = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "";
|
||||
description = "Path to Anthropic API key file (used to set ANTHROPIC_API_KEY).";
|
||||
};
|
||||
};
|
||||
|
||||
routing.queue = {
|
||||
mode = lib.mkOption {
|
||||
type = lib.types.enum [ "queue" "interrupt" ];
|
||||
default = "interrupt";
|
||||
description = "Queue mode when a run is active.";
|
||||
};
|
||||
|
||||
bySurface = lib.mkOption {
|
||||
type = lib.types.attrs;
|
||||
default = {
|
||||
telegram = "interrupt";
|
||||
discord = "queue";
|
||||
webchat = "queue";
|
||||
};
|
||||
description = "Per-surface queue mode overrides.";
|
||||
};
|
||||
};
|
||||
|
||||
routing.groupChat.requireMention = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = "Require mention for group chat activation.";
|
||||
};
|
||||
|
||||
launchd.enable = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
description = "Run Clawdis gateway via launchd (macOS).";
|
||||
};
|
||||
|
||||
instances = lib.mkOption {
|
||||
type = lib.types.attrsOf (lib.types.submodule instanceModule);
|
||||
default = {};
|
||||
description = "Named Clawdis instances (prod/test).";
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf (cfg.enable || cfg.instances != {}) {
|
||||
assertions = assertions ++ [
|
||||
{
|
||||
assertion = lib.length (lib.attrNames appDefaultsEnabled) <= 1;
|
||||
message = "Only one Clawdis instance may enable appDefaults.";
|
||||
}
|
||||
];
|
||||
|
||||
home.packages = lib.unique (map (item: item.package) instanceConfigs);
|
||||
|
||||
home.file = (lib.listToAttrs (map (item: item.homeFile) instanceConfigs)) // (lib.optionalAttrs (pkgs.stdenv.hostPlatform.isDarwin && appPackage != null) {
|
||||
"Applications/Clawdis.app" = {
|
||||
source = "${appPackage}/Applications/Clawdis.app";
|
||||
recursive = true;
|
||||
force = true;
|
||||
};
|
||||
});
|
||||
|
||||
home.activation.clawdisDirs = lib.hm.dag.entryAfter [ "writeBoundary" ] ''
|
||||
/bin/mkdir -p ${lib.concatStringsSep " " (lib.concatMap (item: item.dirs) instanceConfigs)}
|
||||
'';
|
||||
|
||||
home.activation.clawdisAppDefaults = lib.mkIf (pkgs.stdenv.hostPlatform.isDarwin && appDefaults != {}) (
|
||||
lib.hm.dag.entryAfter [ "writeBoundary" ] ''
|
||||
/usr/bin/defaults write com.steipete.Clawdis clawdis.gateway.attachExistingOnly -bool ${lib.boolToString (appDefaults.attachExistingOnly or true)}
|
||||
/usr/bin/defaults write com.steipete.Clawdis gatewayPort -int ${toString (appDefaults.gatewayPort or 18789)}
|
||||
''
|
||||
);
|
||||
|
||||
launchd.agents = lib.mkMerge (map (item: item.launchdAgent) instanceConfigs);
|
||||
};
|
||||
}
|
||||
@ -1,34 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
link_agent() {
|
||||
local label="$1"
|
||||
local target="$HOME/Library/LaunchAgents/${label}.plist"
|
||||
|
||||
local candidate=""
|
||||
local hm_gen
|
||||
hm_gen="$(realpath "$HOME/.local/state/nix/profiles/home-manager" 2>/dev/null || true)"
|
||||
if [ -n "$hm_gen" ] && [ -e "$hm_gen/LaunchAgents/${label}.plist" ]; then
|
||||
candidate="$hm_gen/LaunchAgents/${label}.plist"
|
||||
fi
|
||||
|
||||
if [ -z "$candidate" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
local current
|
||||
current="$(/usr/bin/readlink "$target" 2>/dev/null || true)"
|
||||
|
||||
if [ "$current" != "$candidate" ]; then
|
||||
/bin/mkdir -p "${target%/*}"
|
||||
/bin/ln -sfn "$candidate" "$target"
|
||||
/bin/launchctl bootout "gui/$UID" "$target" 2>/dev/null || true
|
||||
/bin/launchctl bootstrap "gui/$UID" "$target" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
/bin/launchctl kickstart -k "gui/$UID/$label" 2>/dev/null || true
|
||||
}
|
||||
|
||||
for label in "$@"; do
|
||||
link_agent "$label"
|
||||
done
|
||||
@ -1,22 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
manifest=$1
|
||||
|
||||
while IFS=$'\t' read -r profile_dir bin_dir; do
|
||||
[ -n "$profile_dir" ] || continue
|
||||
|
||||
mkdir -p "$profile_dir"
|
||||
|
||||
link="$profile_dir/bin"
|
||||
if [ -L "$link" ]; then
|
||||
rm "$link"
|
||||
fi
|
||||
|
||||
if [ -e "$link" ]; then
|
||||
echo "Refusing to replace non-symlink Codex runtime bin: $link" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ln -s "$bin_dir" "$link"
|
||||
done < "$manifest"
|
||||
@ -1,42 +0,0 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
if [ "$#" -ne 2 ]; then
|
||||
echo "usage: openclaw-materialize-workspace-files <state-manifest> <source-target-manifest>" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
manifest="$1"
|
||||
source_manifest="$2"
|
||||
|
||||
manifest_dir="$(dirname "$manifest")"
|
||||
mkdir -p "$manifest_dir"
|
||||
new_manifest="$(mktemp)"
|
||||
trap 'rm -f "$new_manifest"' EXIT
|
||||
|
||||
copy_path() {
|
||||
source="$1"
|
||||
target="$2"
|
||||
|
||||
if [ -e "$target" ] || [ -L "$target" ]; then
|
||||
chmod -R u+w "$target" 2>/dev/null || true
|
||||
rm -rf "$target"
|
||||
fi
|
||||
mkdir -p "$(dirname "$target")"
|
||||
|
||||
if [ -d "$source" ]; then
|
||||
cp -RL "$source" "$target"
|
||||
else
|
||||
cp -L "$source" "$target"
|
||||
fi
|
||||
|
||||
printf '%s\n' "$target" >> "$new_manifest"
|
||||
}
|
||||
|
||||
while IFS="$(printf '\t')" read -r source target; do
|
||||
if [ -n "$source" ] && [ -n "$target" ]; then
|
||||
copy_path "$source" "$target"
|
||||
fi
|
||||
done < "$source_manifest"
|
||||
|
||||
sort -u "$new_manifest" > "$manifest"
|
||||
@ -1,36 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: openclaw-reload [test|prod|both]
|
||||
|
||||
Re-render OpenClaw config via Home Manager (no sudo) and restart gateway(s).
|
||||
|
||||
Defaults to: test
|
||||
EOF
|
||||
}
|
||||
|
||||
instance="${1:-test}"
|
||||
|
||||
case "$instance" in
|
||||
test) labels=("com.steipete.openclaw.gateway.nix-test") ;;
|
||||
prod) labels=("com.steipete.openclaw.gateway.nix") ;;
|
||||
both) labels=("com.steipete.openclaw.gateway.nix" "com.steipete.openclaw.gateway.nix-test") ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) usage; exit 1 ;;
|
||||
esac
|
||||
|
||||
if command -v hm-apply >/dev/null 2>&1; then
|
||||
hm-apply
|
||||
elif [[ -n "${OPENCLAW_RELOAD_HM_CMD:-}" ]]; then
|
||||
eval "$OPENCLAW_RELOAD_HM_CMD"
|
||||
else
|
||||
echo "[openclaw-reload] no Home Manager command available." >&2
|
||||
echo "[openclaw-reload] install hm-apply or set OPENCLAW_RELOAD_HM_CMD." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for label in "${labels[@]}"; do
|
||||
/bin/launchctl kickstart -k "gui/$UID/$label"
|
||||
done
|
||||
@ -1 +0,0 @@
|
||||
import ./openclaw
|
||||
@ -1,434 +0,0 @@
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
|
||||
let
|
||||
openclawLib = import ./lib.nix { inherit config lib pkgs; };
|
||||
cfg = openclawLib.cfg;
|
||||
homeDir = openclawLib.homeDir;
|
||||
appPackage = openclawLib.appPackage;
|
||||
qmdPackage = openclawLib.qmdPackage;
|
||||
|
||||
defaultInstance = {
|
||||
enable = cfg.enable;
|
||||
package = openclawLib.defaultPackage;
|
||||
stateDir = cfg.stateDir;
|
||||
workspaceDir = cfg.workspaceDir;
|
||||
configPath = "${cfg.stateDir}/openclaw.json";
|
||||
logPath = "/tmp/openclaw/openclaw-gateway.log";
|
||||
gatewayPort = 18789;
|
||||
gatewayPath = null;
|
||||
gatewayPnpmDepsHash = lib.fakeHash;
|
||||
runtimePackages = [ ];
|
||||
environment = { };
|
||||
launchd = cfg.launchd;
|
||||
systemd = cfg.systemd;
|
||||
plugins = openclawLib.effectivePlugins;
|
||||
config = { };
|
||||
appDefaults = {
|
||||
enable = true;
|
||||
attachExistingOnly = true;
|
||||
};
|
||||
app = {
|
||||
install = {
|
||||
enable = false;
|
||||
path = "${homeDir}/Applications/OpenClaw.app";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
instances =
|
||||
if cfg.instances != { } then
|
||||
cfg.instances
|
||||
else
|
||||
lib.optionalAttrs cfg.enable { default = defaultInstance; };
|
||||
|
||||
enabledInstances = lib.filterAttrs (_: inst: inst.enable) instances;
|
||||
|
||||
plugins = import ./plugins.nix {
|
||||
inherit
|
||||
lib
|
||||
pkgs
|
||||
openclawLib
|
||||
enabledInstances
|
||||
;
|
||||
};
|
||||
|
||||
files = import ./files.nix {
|
||||
inherit
|
||||
lib
|
||||
pkgs
|
||||
openclawLib
|
||||
enabledInstances
|
||||
plugins
|
||||
;
|
||||
};
|
||||
|
||||
stripNulls =
|
||||
value:
|
||||
if value == null then
|
||||
null
|
||||
else if builtins.isAttrs value then
|
||||
lib.filterAttrs (_: v: v != null) (builtins.mapAttrs (_: stripNulls) value)
|
||||
else if builtins.isList value then
|
||||
builtins.filter (v: v != null) (map stripNulls value)
|
||||
else
|
||||
value;
|
||||
|
||||
baseConfig = {
|
||||
gateway = {
|
||||
mode = "local";
|
||||
};
|
||||
};
|
||||
|
||||
mkInstanceConfig =
|
||||
name: inst:
|
||||
let
|
||||
gatewayPackage =
|
||||
if inst.gatewayPath != null then
|
||||
pkgs.callPackage ../../packages/openclaw-gateway.nix {
|
||||
gatewaySrc = builtins.path {
|
||||
path = inst.gatewayPath;
|
||||
name = "openclaw-gateway-src";
|
||||
};
|
||||
pnpmDepsHash = inst.gatewayPnpmDepsHash;
|
||||
}
|
||||
else
|
||||
inst.package;
|
||||
pluginPackages = plugins.pluginPackagesFor name;
|
||||
runtimePackages = lib.unique (
|
||||
openclawLib.toolSets.tools
|
||||
++ (lib.optional (qmdPackage != null) qmdPackage)
|
||||
++ pluginPackages
|
||||
++ cfg.runtimePackages
|
||||
++ inst.runtimePackages
|
||||
);
|
||||
runtimeProfile = pkgs.symlinkJoin {
|
||||
name = "openclaw-runtime-${name}";
|
||||
paths = runtimePackages;
|
||||
};
|
||||
runtimePath = lib.makeBinPath runtimePackages;
|
||||
runtimeEnvAll =
|
||||
(plugins.pluginEnvAllFor name)
|
||||
++ (lib.mapAttrsToList (key: value: {
|
||||
inherit key value;
|
||||
plugin = "runtime";
|
||||
}) (cfg.environment // inst.environment));
|
||||
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
|
||||
lib.recursiveUpdate mergedConfig0 {
|
||||
agents = {
|
||||
defaults = {
|
||||
workspace = inst.workspaceDir;
|
||||
};
|
||||
};
|
||||
}
|
||||
else
|
||||
mergedConfig0;
|
||||
configJson = builtins.toJSON mergedConfig;
|
||||
configFile = pkgs.writeText "openclaw-${name}.json" configJson;
|
||||
agentIds =
|
||||
let
|
||||
agents = ((mergedConfig.agents or { }).list or [ ]);
|
||||
configured = lib.filter (id: id != null) (map (agent: agent.id or null) agents);
|
||||
in
|
||||
lib.unique ([ "main" ] ++ configured);
|
||||
codexRuntimeProfiles = map (
|
||||
agentId: "${inst.stateDir}/agents/${agentId}/agent/codex-home/home/.nix-profile"
|
||||
) agentIds;
|
||||
gatewayWrapper = pkgs.writeShellScriptBin "openclaw-gateway-${name}" ''
|
||||
set -euo pipefail
|
||||
|
||||
if [ -n "${runtimePath}" ]; then
|
||||
export PATH="${runtimePath}:$PATH"
|
||||
fi
|
||||
|
||||
${lib.concatStringsSep "\n" (
|
||||
map (
|
||||
entry:
|
||||
let
|
||||
isFile = lib.hasSuffix "_FILE" entry.key;
|
||||
in
|
||||
''
|
||||
if [ -f "${entry.value}" ]; then
|
||||
if ${if isFile then "true" else "false"}; then
|
||||
export ${entry.key}="${entry.value}"
|
||||
else
|
||||
rawValue="$("${lib.getExe' pkgs.coreutils "cat"}" "${entry.value}")"
|
||||
if [ "''${rawValue#${entry.key}=}" != "$rawValue" ]; then
|
||||
export ${entry.key}="''${rawValue#${entry.key}=}"
|
||||
else
|
||||
export ${entry.key}="$rawValue"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
export ${entry.key}="${entry.value}"
|
||||
fi
|
||||
''
|
||||
) runtimeEnvAll
|
||||
)}
|
||||
|
||||
exec "${gatewayPackage}/bin/openclaw" "$@"
|
||||
'';
|
||||
appDefaults = lib.optionalAttrs (pkgs.stdenv.hostPlatform.isDarwin && inst.appDefaults.enable) {
|
||||
attachExistingOnly = inst.appDefaults.attachExistingOnly;
|
||||
gatewayPort = inst.gatewayPort;
|
||||
nixMode = inst.appDefaults.nixMode;
|
||||
};
|
||||
|
||||
appInstall =
|
||||
if !(pkgs.stdenv.hostPlatform.isDarwin && inst.app.install.enable && appPackage != null) then
|
||||
null
|
||||
else
|
||||
{
|
||||
name = lib.removePrefix "${homeDir}/" inst.app.install.path;
|
||||
value = {
|
||||
source = "${appPackage}/Applications/OpenClaw.app";
|
||||
recursive = true;
|
||||
force = true;
|
||||
};
|
||||
};
|
||||
|
||||
package = gatewayPackage;
|
||||
in
|
||||
{
|
||||
homeFile = {
|
||||
name = openclawLib.toRelative inst.configPath;
|
||||
value = {
|
||||
text = configJson;
|
||||
force = true;
|
||||
};
|
||||
};
|
||||
configFile = configFile;
|
||||
configPath = inst.configPath;
|
||||
codexRuntimeProfiles = codexRuntimeProfiles;
|
||||
runtimeProfile = runtimeProfile;
|
||||
|
||||
dirs = [
|
||||
inst.stateDir
|
||||
inst.workspaceDir
|
||||
(builtins.dirOf inst.logPath)
|
||||
];
|
||||
|
||||
launchdAgent = lib.optionalAttrs (pkgs.stdenv.hostPlatform.isDarwin && inst.launchd.enable) {
|
||||
"${inst.launchd.label}" = {
|
||||
enable = true;
|
||||
config = {
|
||||
Label = inst.launchd.label;
|
||||
ProgramArguments = [
|
||||
"${gatewayWrapper}/bin/openclaw-gateway-${name}"
|
||||
"gateway"
|
||||
"--port"
|
||||
"${toString inst.gatewayPort}"
|
||||
];
|
||||
RunAtLoad = true;
|
||||
KeepAlive = true;
|
||||
WorkingDirectory = inst.stateDir;
|
||||
StandardOutPath = inst.logPath;
|
||||
StandardErrorPath = inst.logPath;
|
||||
EnvironmentVariables = {
|
||||
HOME = homeDir;
|
||||
OPENCLAW_CONFIG_PATH = inst.configPath;
|
||||
OPENCLAW_STATE_DIR = inst.stateDir;
|
||||
OPENCLAW_IMAGE_BACKEND = "sips";
|
||||
OPENCLAW_NIX_MODE = "1";
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
systemdService = lib.optionalAttrs (pkgs.stdenv.hostPlatform.isLinux && inst.systemd.enable) {
|
||||
"${inst.systemd.unitName}" = {
|
||||
Unit = {
|
||||
Description = "OpenClaw gateway (${name})";
|
||||
};
|
||||
Service = {
|
||||
ExecStart = "${gatewayWrapper}/bin/openclaw-gateway-${name} gateway --port ${toString inst.gatewayPort}";
|
||||
WorkingDirectory = inst.stateDir;
|
||||
Restart = "always";
|
||||
RestartSec = "1s";
|
||||
Environment = [
|
||||
"HOME=${homeDir}"
|
||||
"OPENCLAW_CONFIG_PATH=${inst.configPath}"
|
||||
"OPENCLAW_STATE_DIR=${inst.stateDir}"
|
||||
"OPENCLAW_NIX_MODE=1"
|
||||
];
|
||||
StandardOutput = "append:${inst.logPath}";
|
||||
StandardError = "append:${inst.logPath}";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
appDefaults = appDefaults;
|
||||
appInstall = appInstall;
|
||||
package = package;
|
||||
launchdLabel =
|
||||
if pkgs.stdenv.hostPlatform.isDarwin && inst.launchd.enable then inst.launchd.label else null;
|
||||
};
|
||||
|
||||
instanceConfigs = lib.mapAttrsToList mkInstanceConfig enabledInstances;
|
||||
codexRuntimeProfileEntries = lib.flatten (
|
||||
map (
|
||||
item:
|
||||
map (profileDir: {
|
||||
inherit profileDir;
|
||||
binDir = "${item.runtimeProfile}/bin";
|
||||
}) item.codexRuntimeProfiles
|
||||
) instanceConfigs
|
||||
);
|
||||
codexRuntimeProfilesManifest = pkgs.writeText "openclaw-codex-runtime-profiles.tsv" (
|
||||
(lib.concatStringsSep "\n" (
|
||||
map (entry: "${entry.profileDir}\t${entry.binDir}") codexRuntimeProfileEntries
|
||||
))
|
||||
+ "\n"
|
||||
);
|
||||
appInstalls = lib.filter (item: item != null) (map (item: item.appInstall) instanceConfigs);
|
||||
launchdLabels = lib.filter (label: label != null) (map (item: item.launchdLabel) instanceConfigs);
|
||||
launchdLabelArgs = lib.concatStringsSep " " (map lib.escapeShellArg launchdLabels);
|
||||
|
||||
appDefaults = lib.foldl' (acc: item: lib.recursiveUpdate acc item.appDefaults) { } instanceConfigs;
|
||||
appDefaultsEnabled = lib.filterAttrs (_: inst: inst.appDefaults.enable) enabledInstances;
|
||||
|
||||
in
|
||||
{
|
||||
config = lib.mkIf (cfg.enable || cfg.instances != { }) {
|
||||
assertions = [
|
||||
{
|
||||
assertion = lib.length (lib.attrNames appDefaultsEnabled) <= 1;
|
||||
message = "Only one OpenClaw instance may enable appDefaults.";
|
||||
}
|
||||
]
|
||||
++ files.documentsAssertions
|
||||
++ files.duplicateSkillAssertion
|
||||
++ plugins.pluginAssertions
|
||||
++ [
|
||||
{
|
||||
assertion = !cfg.qmd.prewarmModels.enable || qmdPackage != null;
|
||||
message = "programs.openclaw.qmd.prewarmModels.enable requires a qmd package in openclawPackages.";
|
||||
}
|
||||
];
|
||||
|
||||
home.packages = lib.unique (
|
||||
(map (item: item.package) instanceConfigs)
|
||||
++ (lib.optionals cfg.exposePluginPackages plugins.pluginPackagesAll)
|
||||
);
|
||||
|
||||
home.file = lib.mkMerge [
|
||||
(lib.listToAttrs (map (item: item.homeFile) instanceConfigs))
|
||||
(lib.optionalAttrs (pkgs.stdenv.hostPlatform.isDarwin && appPackage != null && cfg.installApp) {
|
||||
"Applications/OpenClaw.app" = {
|
||||
source = "${appPackage}/Applications/OpenClaw.app";
|
||||
recursive = true;
|
||||
force = true;
|
||||
};
|
||||
})
|
||||
(lib.listToAttrs appInstalls)
|
||||
plugins.pluginConfigFiles
|
||||
(lib.optionalAttrs cfg.reloadScript.enable {
|
||||
".local/bin/openclaw-reload" = {
|
||||
executable = true;
|
||||
source = ../openclaw-reload.sh;
|
||||
};
|
||||
})
|
||||
];
|
||||
|
||||
home.activation.openclawDirs = lib.hm.dag.entryAfter [ "writeBoundary" ] ''
|
||||
run --quiet ${lib.getExe' pkgs.coreutils "mkdir"} -p ${
|
||||
lib.concatStringsSep " " (lib.concatMap (item: item.dirs) instanceConfigs)
|
||||
}
|
||||
${lib.optionalString (plugins.pluginStateDirsAll != [ ])
|
||||
"run --quiet ${lib.getExe' pkgs.coreutils "mkdir"} -p ${lib.concatStringsSep " " plugins.pluginStateDirsAll}"
|
||||
}
|
||||
'';
|
||||
|
||||
home.activation.openclawWorkspaceFiles = lib.mkIf (files.materializedEntries != [ ]) (
|
||||
lib.hm.dag.entryAfter [ "openclawDirs" ] ''
|
||||
run --quiet ${../openclaw-materialize-workspace-files.sh} ${lib.escapeShellArg "${homeDir}/.local/state/nix-openclaw/managed-workspace-files"} ${files.materializedManifest}
|
||||
''
|
||||
);
|
||||
|
||||
home.activation.openclawConfigFiles = lib.hm.dag.entryAfter [ "openclawDirs" ] ''
|
||||
${lib.concatStringsSep "\n" (
|
||||
map (
|
||||
item: "run --quiet ${lib.getExe' pkgs.coreutils "ln"} -sfn ${item.configFile} ${item.configPath}"
|
||||
) instanceConfigs
|
||||
)}
|
||||
'';
|
||||
|
||||
home.activation.openclawCodexRuntimeProfiles = lib.mkIf (codexRuntimeProfileEntries != [ ]) (
|
||||
lib.hm.dag.entryAfter [ "openclawDirs" ] ''
|
||||
run --quiet ${pkgs.bash}/bin/bash ${../openclaw-link-codex-runtime-profiles.sh} ${codexRuntimeProfilesManifest}
|
||||
''
|
||||
);
|
||||
|
||||
home.activation.openclawPluginGuard = lib.hm.dag.entryAfter [ "writeBoundary" ] ''
|
||||
set -euo pipefail
|
||||
${plugins.pluginGuards}
|
||||
'';
|
||||
|
||||
home.activation.openclawQmdPrewarm = lib.mkIf (cfg.qmd.prewarmModels.enable && qmdPackage != null) (
|
||||
lib.hm.dag.entryAfter [ "openclawDirs" ] ''
|
||||
run --quiet ${lib.getExe' pkgs.coreutils "env"} \
|
||||
HOME=${lib.escapeShellArg homeDir} \
|
||||
XDG_CACHE_HOME=${lib.escapeShellArg "${homeDir}/.cache"} \
|
||||
XDG_CONFIG_HOME=${lib.escapeShellArg "${homeDir}/.config"} \
|
||||
XDG_DATA_HOME=${lib.escapeShellArg "${homeDir}/.local/share"} \
|
||||
OPENCLAW_QMD_BIN=${lib.escapeShellArg "${qmdPackage}/bin/qmd"} \
|
||||
${pkgs.bash}/bin/bash ${../../../scripts/openclaw-qmd-prewarm.sh}
|
||||
''
|
||||
);
|
||||
|
||||
home.activation.openclawAppDefaults =
|
||||
lib.mkIf (pkgs.stdenv.hostPlatform.isDarwin && appDefaults != { })
|
||||
(
|
||||
lib.hm.dag.entryAfter [ "writeBoundary" ] ''
|
||||
# Nix mode + app defaults (OpenClaw.app)
|
||||
/usr/bin/defaults write ai.openclaw.mac openclaw.nixMode -bool ${
|
||||
lib.boolToString (appDefaults.nixMode or true)
|
||||
}
|
||||
/usr/bin/defaults write ai.openclaw.mac openclaw.gateway.attachExistingOnly -bool ${
|
||||
lib.boolToString (appDefaults.attachExistingOnly or true)
|
||||
}
|
||||
/usr/bin/defaults write ai.openclaw.mac gatewayPort -int ${
|
||||
toString (appDefaults.gatewayPort or 18789)
|
||||
}
|
||||
''
|
||||
);
|
||||
|
||||
home.activation.openclawLaunchdRelink = lib.mkIf pkgs.stdenv.hostPlatform.isDarwin (
|
||||
lib.hm.dag.entryAfter [ "linkGeneration" ] ''
|
||||
/usr/bin/env bash ${../openclaw-launchd-relink.sh} ${launchdLabelArgs}
|
||||
''
|
||||
);
|
||||
|
||||
systemd.user.services = lib.mkIf pkgs.stdenv.hostPlatform.isLinux (
|
||||
lib.mkMerge (map (item: item.systemdService) instanceConfigs)
|
||||
);
|
||||
|
||||
launchd.agents = lib.mkMerge (map (item: item.launchdAgent) instanceConfigs);
|
||||
};
|
||||
}
|
||||
@ -1,22 +0,0 @@
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
{
|
||||
imports = [
|
||||
(lib.mkRemovedOptionModule [
|
||||
"programs"
|
||||
"openclaw"
|
||||
"firstParty"
|
||||
] "Use programs.openclaw.bundledPlugins.<name>.enable/config.")
|
||||
(lib.mkRemovedOptionModule [
|
||||
"programs"
|
||||
"openclaw"
|
||||
"plugins"
|
||||
] "Use programs.openclaw.customPlugins.")
|
||||
./options.nix
|
||||
./config.nix
|
||||
];
|
||||
}
|
||||
@ -1,271 +0,0 @@
|
||||
{
|
||||
lib,
|
||||
pkgs,
|
||||
openclawLib,
|
||||
enabledInstances,
|
||||
plugins,
|
||||
}:
|
||||
|
||||
let
|
||||
cfg = openclawLib.cfg;
|
||||
resolvePath = openclawLib.resolvePath;
|
||||
toRelative = openclawLib.toRelative;
|
||||
toolSets = openclawLib.toolSets;
|
||||
documentsEnabled = cfg.documents != null;
|
||||
instanceWorkspaceDirs = map (inst: resolvePath inst.workspaceDir) (lib.attrValues enabledInstances);
|
||||
|
||||
renderSkill =
|
||||
skill:
|
||||
let
|
||||
frontmatterLines = [
|
||||
"---"
|
||||
"name: ${skill.name}"
|
||||
"description: ${skill.description or ""}"
|
||||
]
|
||||
++ lib.optionals (skill ? homepage && skill.homepage != null) [ "homepage: ${skill.homepage}" ]
|
||||
++ lib.optionals (skill ? openclaw && skill.openclaw != null) [
|
||||
"openclaw:"
|
||||
" ${builtins.toJSON skill.openclaw}"
|
||||
]
|
||||
++ [ "---" ];
|
||||
frontmatter = lib.concatStringsSep "\n" frontmatterLines;
|
||||
body = if skill ? body then skill.body else "";
|
||||
in
|
||||
"${frontmatter}\n\n${body}\n";
|
||||
|
||||
duplicateSkillAssertion =
|
||||
let
|
||||
targetsForInstance =
|
||||
instName: inst:
|
||||
let
|
||||
base = "${toRelative (resolvePath inst.workspaceDir)}/skills";
|
||||
userTargets = map (skill: "${base}/${skill.name}") cfg.skills;
|
||||
pluginsForInstance = plugins.resolvedPluginsByInstance.${instName} or [ ];
|
||||
pluginTargets = lib.flatten (
|
||||
map (p: map (skillPath: "${base}/${builtins.baseNameOf skillPath}") p.skills) pluginsForInstance
|
||||
);
|
||||
in
|
||||
userTargets ++ pluginTargets;
|
||||
skillTargets = lib.flatten (lib.mapAttrsToList targetsForInstance enabledInstances);
|
||||
counts = lib.foldl' (acc: path: acc // { "${path}" = (acc.${path} or 0) + 1; }) { } skillTargets;
|
||||
duplicates = lib.attrNames (lib.filterAttrs (_: v: v > 1) counts);
|
||||
in
|
||||
if duplicates == [ ] then
|
||||
[ ]
|
||||
else
|
||||
[
|
||||
{
|
||||
assertion = false;
|
||||
message = "Duplicate skill paths detected: ${lib.concatStringsSep ", " duplicates}";
|
||||
}
|
||||
];
|
||||
|
||||
skillEntries =
|
||||
let
|
||||
entriesForInstance =
|
||||
instName: inst:
|
||||
let
|
||||
entryFor =
|
||||
skill:
|
||||
let
|
||||
mode = skill.mode or "symlink";
|
||||
source = if skill ? source && skill.source != null then resolvePath skill.source else null;
|
||||
in
|
||||
if mode == "inline" then
|
||||
{
|
||||
source = pkgs.writeText "openclaw-skill-${skill.name}.md" (renderSkill skill);
|
||||
target = "${resolvePath inst.workspaceDir}/skills/${skill.name}/SKILL.md";
|
||||
}
|
||||
else if mode == "copy" || mode == "symlink" then
|
||||
{
|
||||
source = builtins.path {
|
||||
name = "openclaw-skill-${skill.name}";
|
||||
path = source;
|
||||
};
|
||||
target = "${resolvePath inst.workspaceDir}/skills/${skill.name}";
|
||||
}
|
||||
else
|
||||
throw "Unsupported OpenClaw skill mode: ${mode}";
|
||||
pluginEntriesFor =
|
||||
p:
|
||||
map (skillPath: {
|
||||
source = skillPath;
|
||||
target = "${resolvePath inst.workspaceDir}/skills/${builtins.baseNameOf skillPath}";
|
||||
}) p.skills;
|
||||
pluginsForInstance = plugins.resolvedPluginsByInstance.${instName} or [ ];
|
||||
in
|
||||
(map entryFor cfg.skills) ++ (lib.flatten (map pluginEntriesFor pluginsForInstance));
|
||||
in
|
||||
lib.flatten (lib.mapAttrsToList entriesForInstance enabledInstances);
|
||||
|
||||
documentsRequiredFiles = [
|
||||
"AGENTS.md"
|
||||
"SOUL.md"
|
||||
"TOOLS.md"
|
||||
];
|
||||
|
||||
documentsOptionalFiles = [
|
||||
"IDENTITY.md"
|
||||
"USER.md"
|
||||
"LORE.md"
|
||||
"HEARTBEAT.md"
|
||||
"PROMPTING-EXAMPLES.md"
|
||||
];
|
||||
|
||||
documentsFileNames =
|
||||
if documentsEnabled then
|
||||
let
|
||||
extra = lib.filter (file: builtins.pathExists (cfg.documents + "/${file}")) documentsOptionalFiles;
|
||||
in
|
||||
documentsRequiredFiles ++ extra
|
||||
else
|
||||
[ ];
|
||||
|
||||
documentsAssertions = lib.optionals documentsEnabled [
|
||||
{
|
||||
assertion = builtins.pathExists cfg.documents;
|
||||
message = "programs.openclaw.documents must point to an existing directory.";
|
||||
}
|
||||
{
|
||||
assertion = builtins.pathExists (cfg.documents + "/AGENTS.md");
|
||||
message = "Missing AGENTS.md in programs.openclaw.documents.";
|
||||
}
|
||||
{
|
||||
assertion = builtins.pathExists (cfg.documents + "/SOUL.md");
|
||||
message = "Missing SOUL.md in programs.openclaw.documents.";
|
||||
}
|
||||
{
|
||||
assertion = builtins.pathExists (cfg.documents + "/TOOLS.md");
|
||||
message = "Missing TOOLS.md in programs.openclaw.documents.";
|
||||
}
|
||||
];
|
||||
|
||||
toolsReport =
|
||||
if documentsEnabled then
|
||||
let
|
||||
renderPkgName = pkg: if pkg ? pname then pkg.pname else lib.getName pkg;
|
||||
renderPkgCommand =
|
||||
pkg:
|
||||
let
|
||||
pkgName = renderPkgName pkg;
|
||||
commandName = pkg.meta.mainProgram or pkgName;
|
||||
in
|
||||
if commandName == pkgName then commandName else "${commandName} (${pkgName})";
|
||||
toolPackages = lib.filter (p: p != null) (toolSets.tools or [ ]);
|
||||
renderPlugin =
|
||||
plugin:
|
||||
let
|
||||
pkgNames = map renderPkgCommand (lib.filter (p: p != null) plugin.packages);
|
||||
pkgSuffix = if pkgNames == [ ] then "" else " — " + (lib.concatStringsSep ", " pkgNames);
|
||||
in
|
||||
"- " + plugin.name + pkgSuffix + " (" + plugin.source + ")";
|
||||
renderPkgList =
|
||||
packages:
|
||||
let
|
||||
actualPackages = lib.filter (p: p != null) packages;
|
||||
in
|
||||
if actualPackages == [ ] then
|
||||
[ "- (none)" ]
|
||||
else
|
||||
map (pkg: "- " + renderPkgCommand pkg) actualPackages;
|
||||
pluginLinesFor =
|
||||
instName: inst:
|
||||
let
|
||||
pluginsForInstance = plugins.resolvedPluginsByInstance.${instName} or [ ];
|
||||
pluginLines =
|
||||
if pluginsForInstance == [ ] then [ "- (none)" ] else map renderPlugin pluginsForInstance;
|
||||
runtimePackages = lib.unique (
|
||||
(lib.optional (openclawLib.qmdPackage != null) openclawLib.qmdPackage)
|
||||
++ (cfg.runtimePackages or [ ])
|
||||
++ (inst.runtimePackages or [ ])
|
||||
);
|
||||
in
|
||||
[
|
||||
""
|
||||
"### Instance: ${instName}"
|
||||
]
|
||||
++ [
|
||||
""
|
||||
"Plugins:"
|
||||
]
|
||||
++ pluginLines
|
||||
++ [
|
||||
""
|
||||
"Runtime packages:"
|
||||
]
|
||||
++ renderPkgList runtimePackages;
|
||||
reportLines = [
|
||||
"<!-- BEGIN NIX-REPORT -->"
|
||||
""
|
||||
"## Nix-managed tools"
|
||||
""
|
||||
"### Built-in toolchain"
|
||||
]
|
||||
++ (
|
||||
if toolPackages == [ ] then [ "- (none)" ] else map (pkg: "- " + renderPkgCommand pkg) toolPackages
|
||||
)
|
||||
++ [
|
||||
""
|
||||
"## Nix-managed plugin report"
|
||||
""
|
||||
"Plugins enabled per instance (last-wins on name collisions):"
|
||||
]
|
||||
++ lib.concatLists (lib.mapAttrsToList pluginLinesFor enabledInstances)
|
||||
++ [
|
||||
""
|
||||
"Tools: batteries-included toolchain + runtime packages + plugin-provided CLIs."
|
||||
""
|
||||
"<!-- END NIX-REPORT -->"
|
||||
];
|
||||
reportText = lib.concatStringsSep "\n" reportLines;
|
||||
in
|
||||
pkgs.writeText "openclaw-tools-report.md" reportText
|
||||
else
|
||||
null;
|
||||
|
||||
toolsWithReport =
|
||||
if documentsEnabled then
|
||||
pkgs.runCommand "openclaw-tools-with-report.md" { } ''
|
||||
cat ${cfg.documents + "/TOOLS.md"} > $out
|
||||
echo "" >> $out
|
||||
cat ${toolsReport} >> $out
|
||||
''
|
||||
else
|
||||
null;
|
||||
|
||||
documentEntries =
|
||||
if documentsEnabled then
|
||||
let
|
||||
mkDocFiles =
|
||||
dir:
|
||||
let
|
||||
mkDoc = name: {
|
||||
source = if name == "TOOLS.md" then toolsWithReport else cfg.documents + "/${name}";
|
||||
target = dir + "/${name}";
|
||||
};
|
||||
in
|
||||
map mkDoc documentsFileNames;
|
||||
in
|
||||
lib.flatten (map mkDocFiles instanceWorkspaceDirs)
|
||||
else
|
||||
[ ];
|
||||
|
||||
materializedEntries = documentEntries ++ skillEntries;
|
||||
materializedManifest =
|
||||
let
|
||||
renderEntry = entry: "${entry.source}\t${entry.target}";
|
||||
in
|
||||
pkgs.writeText "openclaw-workspace-files.tsv" (
|
||||
(lib.concatStringsSep "\n" (map renderEntry materializedEntries)) + "\n"
|
||||
);
|
||||
|
||||
in
|
||||
{
|
||||
inherit
|
||||
documentsEnabled
|
||||
documentsAssertions
|
||||
materializedManifest
|
||||
materializedEntries
|
||||
duplicateSkillAssertion
|
||||
;
|
||||
}
|
||||
@ -1,79 +0,0 @@
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
}:
|
||||
|
||||
let
|
||||
cfg = config.programs.openclaw;
|
||||
homeDir = config.home.homeDirectory;
|
||||
autoExcludeTools = lib.optionals config.programs.git.enable [ "git" ];
|
||||
effectiveExcludeTools = lib.unique (cfg.excludeTools ++ autoExcludeTools);
|
||||
toolOverrides = {
|
||||
toolNamesOverride = cfg.toolNames;
|
||||
excludeToolNames = effectiveExcludeTools;
|
||||
};
|
||||
toolOverridesEnabled = cfg.toolNames != null || effectiveExcludeTools != [ ];
|
||||
overlayPackage = pkgs.openclaw or null;
|
||||
toolSets = import ../../../tools/extended.nix ({ inherit pkgs; } // toolOverrides);
|
||||
defaultPackage =
|
||||
if toolOverridesEnabled && overlayPackage != null && cfg.package == overlayPackage then
|
||||
(pkgs.openclawPackages.withTools toolOverrides).openclaw
|
||||
else
|
||||
cfg.package;
|
||||
appPackage = if cfg.appPackage != null then cfg.appPackage else defaultPackage;
|
||||
qmdPackage = pkgs.openclawPackages.qmd or null;
|
||||
generatedConfigOptions = import ../../../generated/openclaw-config-options.nix { lib = lib; };
|
||||
pluginCatalog = import ./plugin-catalog.nix;
|
||||
|
||||
bundledPluginSources =
|
||||
let
|
||||
openclawToolsRev = "4c1cee3c7eaf68f9de0f756be1484534f5bb5f34";
|
||||
openclawToolsNarHash = "sha256-tXWkN1VnwFG8XlRqW/e7VwbKnUfyU9tB7YDm9QHJXTY=";
|
||||
openclawTools =
|
||||
tool:
|
||||
"github:openclaw/nix-openclaw-tools?dir=tools/${tool}&rev=${openclawToolsRev}&narHash=${openclawToolsNarHash}";
|
||||
in
|
||||
lib.mapAttrs (_name: plugin: plugin.source or (openclawTools plugin.tool)) pluginCatalog;
|
||||
|
||||
bundledPlugins = lib.filter (p: p != null) (
|
||||
lib.mapAttrsToList (
|
||||
name: source:
|
||||
let
|
||||
pluginCfg = cfg.bundledPlugins.${name};
|
||||
in
|
||||
if (pluginCfg.enable or false) then
|
||||
{
|
||||
inherit source;
|
||||
config = pluginCfg.config or { };
|
||||
}
|
||||
else
|
||||
null
|
||||
) bundledPluginSources
|
||||
);
|
||||
|
||||
effectivePlugins = cfg.customPlugins ++ bundledPlugins;
|
||||
|
||||
resolvePath = p: if lib.hasPrefix "~/" p then "${homeDir}/${lib.removePrefix "~/" p}" else p;
|
||||
|
||||
toRelative = p: if lib.hasPrefix "${homeDir}/" p then lib.removePrefix "${homeDir}/" p else p;
|
||||
|
||||
in
|
||||
{
|
||||
inherit
|
||||
cfg
|
||||
homeDir
|
||||
toolOverrides
|
||||
toolOverridesEnabled
|
||||
toolSets
|
||||
defaultPackage
|
||||
appPackage
|
||||
qmdPackage
|
||||
generatedConfigOptions
|
||||
bundledPluginSources
|
||||
bundledPlugins
|
||||
effectivePlugins
|
||||
resolvePath
|
||||
toRelative
|
||||
;
|
||||
}
|
||||
@ -1,156 +0,0 @@
|
||||
{
|
||||
lib,
|
||||
openclawLib,
|
||||
pluginOptionType,
|
||||
}:
|
||||
|
||||
{ name, config, ... }:
|
||||
{
|
||||
options = {
|
||||
enable = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
description = "Enable this OpenClaw instance.";
|
||||
};
|
||||
|
||||
package = lib.mkOption {
|
||||
type = lib.types.package;
|
||||
default = openclawLib.defaultPackage;
|
||||
description = "OpenClaw batteries-included package.";
|
||||
};
|
||||
|
||||
stateDir = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default =
|
||||
if name == "default" then
|
||||
"${openclawLib.homeDir}/.openclaw"
|
||||
else
|
||||
"${openclawLib.homeDir}/.openclaw-${name}";
|
||||
description = "State directory for this OpenClaw instance (logs, sessions, config).";
|
||||
};
|
||||
|
||||
workspaceDir = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "${config.stateDir}/workspace";
|
||||
description = "Workspace directory for this OpenClaw instance.";
|
||||
};
|
||||
|
||||
configPath = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "${config.stateDir}/openclaw.json";
|
||||
description = "Path to generated OpenClaw config JSON.";
|
||||
};
|
||||
|
||||
logPath = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default =
|
||||
if name == "default" then
|
||||
"/tmp/openclaw/openclaw-gateway.log"
|
||||
else
|
||||
"/tmp/openclaw/openclaw-gateway-${name}.log";
|
||||
description = "Log path for this OpenClaw gateway instance.";
|
||||
};
|
||||
|
||||
gatewayPort = lib.mkOption {
|
||||
type = lib.types.int;
|
||||
default = 18789;
|
||||
description = "Gateway port used by the OpenClaw desktop app.";
|
||||
};
|
||||
|
||||
gatewayPath = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
description = "Local path to OpenClaw gateway source (dev only).";
|
||||
};
|
||||
|
||||
gatewayPnpmDepsHash = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = lib.fakeHash;
|
||||
description = "pnpmDeps hash for local gateway builds (omit to let Nix suggest the correct hash).";
|
||||
};
|
||||
|
||||
runtimePackages = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.package;
|
||||
default = [ ];
|
||||
description = "Extra packages visible to this OpenClaw instance and its isolated Codex harness only. These are not added to the user's PATH.";
|
||||
};
|
||||
|
||||
environment = lib.mkOption {
|
||||
type = lib.types.attrsOf lib.types.str;
|
||||
default = { };
|
||||
description = "Extra runtime environment for this OpenClaw gateway wrapper. Values that point to files are read at runtime unless the variable name ends in _FILE.";
|
||||
};
|
||||
|
||||
plugins = lib.mkOption {
|
||||
type = lib.types.listOf pluginOptionType;
|
||||
default = openclawLib.effectivePlugins;
|
||||
description = "Plugins enabled for this instance (includes bundled plugin toggles).";
|
||||
};
|
||||
|
||||
config = lib.mkOption {
|
||||
type = lib.types.submodule { options = openclawLib.generatedConfigOptions; };
|
||||
default = { };
|
||||
description = "OpenClaw config (schema-typed).";
|
||||
};
|
||||
|
||||
launchd.enable = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
description = "Run OpenClaw gateway via launchd (macOS).";
|
||||
};
|
||||
|
||||
launchd.label = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default =
|
||||
if name == "default" then
|
||||
"com.steipete.openclaw.gateway"
|
||||
else
|
||||
"com.steipete.openclaw.gateway.${name}";
|
||||
description = "launchd label for this instance.";
|
||||
};
|
||||
|
||||
systemd.enable = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
description = "Run OpenClaw gateway via systemd user service (Linux).";
|
||||
};
|
||||
|
||||
systemd.unitName = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = if name == "default" then "openclaw-gateway" else "openclaw-gateway-${name}";
|
||||
description = "systemd user service unit name for this instance.";
|
||||
};
|
||||
|
||||
app.install.enable = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = "Install OpenClaw.app for this instance.";
|
||||
};
|
||||
|
||||
app.install.path = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "${openclawLib.homeDir}/Applications/OpenClaw.app";
|
||||
description = "Destination path for this instance's OpenClaw.app bundle.";
|
||||
};
|
||||
|
||||
appDefaults = {
|
||||
enable = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = name == "default";
|
||||
description = "Configure macOS app defaults for this instance.";
|
||||
};
|
||||
|
||||
attachExistingOnly = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
description = "Attach existing gateway only (macOS).";
|
||||
};
|
||||
|
||||
nixMode = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
description = "Enable OpenClaw Nix mode in the macOS app via defaults (openclaw.nixMode).";
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@ -1,237 +0,0 @@
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
|
||||
let
|
||||
openclawLib = import ./lib.nix { inherit config lib pkgs; };
|
||||
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;
|
||||
mkSkillOption = lib.types.submodule {
|
||||
options = {
|
||||
name = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "Skill name (used as the directory name).";
|
||||
};
|
||||
description = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "";
|
||||
description = "Short description for the skill frontmatter.";
|
||||
};
|
||||
homepage = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
description = "Optional homepage URL for the skill frontmatter.";
|
||||
};
|
||||
body = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "";
|
||||
description = "Optional skill body (markdown).";
|
||||
};
|
||||
openclaw = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.attrs;
|
||||
default = null;
|
||||
description = "Optional openclaw metadata for the skill frontmatter.";
|
||||
};
|
||||
mode = lib.mkOption {
|
||||
type = lib.types.enum [
|
||||
"symlink"
|
||||
"copy"
|
||||
"inline"
|
||||
];
|
||||
default = "symlink";
|
||||
description = "Install mode for the skill (symlink/copy/inline).";
|
||||
};
|
||||
source = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
description = "Source path for the skill (required for symlink/copy).";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
in
|
||||
{
|
||||
options.programs.openclaw = {
|
||||
enable = lib.mkEnableOption "OpenClaw (batteries-included)";
|
||||
|
||||
package = lib.mkOption {
|
||||
type = lib.types.package;
|
||||
default = pkgs.openclaw;
|
||||
description = "OpenClaw batteries-included package.";
|
||||
};
|
||||
|
||||
toolNames = lib.mkOption {
|
||||
type = lib.types.nullOr (lib.types.listOf lib.types.str);
|
||||
default = null;
|
||||
description = "Override the built-in toolchain names (see nix/tools/extended.nix).";
|
||||
};
|
||||
|
||||
excludeTools = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
default = [ ];
|
||||
description = "Tool names to remove from the built-in toolchain.";
|
||||
};
|
||||
|
||||
appPackage = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.package;
|
||||
default = null;
|
||||
description = "Optional OpenClaw app package (defaults to package if unset).";
|
||||
};
|
||||
|
||||
installApp = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
description = "Install OpenClaw.app at the default location.";
|
||||
};
|
||||
|
||||
stateDir = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "${openclawLib.homeDir}/.openclaw";
|
||||
description = "State directory for OpenClaw (logs, sessions, config).";
|
||||
};
|
||||
|
||||
workspaceDir = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "${config.programs.openclaw.stateDir}/workspace";
|
||||
description = "Workspace directory for Openclaw agent skills (defaults to stateDir/workspace).";
|
||||
};
|
||||
|
||||
workspace = {
|
||||
pinAgentDefaults = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
description = "Pin agents.defaults.workspace to each instance workspaceDir when unset (prevents falling back to template ~/.openclaw/workspace).";
|
||||
};
|
||||
};
|
||||
|
||||
runtimePackages = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.package;
|
||||
default = [ ];
|
||||
description = "Extra packages visible to the OpenClaw gateway and isolated Codex harness only. These are not added to the user's PATH.";
|
||||
};
|
||||
|
||||
environment = lib.mkOption {
|
||||
type = lib.types.attrsOf lib.types.str;
|
||||
default = { };
|
||||
description = "Extra runtime environment for OpenClaw gateway wrappers. Values that point to files are read at runtime unless the variable name ends in _FILE.";
|
||||
};
|
||||
|
||||
documents = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.path;
|
||||
default = null;
|
||||
description = "Path to a documents directory containing AGENTS.md, SOUL.md, and TOOLS.md.";
|
||||
};
|
||||
|
||||
skills = lib.mkOption {
|
||||
type = lib.types.listOf mkSkillOption;
|
||||
default = [ ];
|
||||
description = "Declarative skills installed into each instance workspace.";
|
||||
};
|
||||
|
||||
customPlugins = lib.mkOption {
|
||||
type = lib.types.listOf pluginOptionType;
|
||||
default = [ ];
|
||||
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: {
|
||||
enable = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = plugin.defaultEnable or false;
|
||||
description = "Enable the ${name} plugin (bundled).";
|
||||
};
|
||||
config = lib.mkOption {
|
||||
type = lib.types.attrs;
|
||||
default = { };
|
||||
description = "Bundled plugin configuration passed through to ${name} (env/settings).";
|
||||
};
|
||||
}) pluginCatalog;
|
||||
|
||||
launchd.enable = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
description = "Run OpenClaw gateway via launchd (macOS).";
|
||||
};
|
||||
|
||||
launchd.label = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "com.steipete.openclaw.gateway";
|
||||
description = "launchd label for the default OpenClaw instance.";
|
||||
};
|
||||
|
||||
systemd.enable = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
description = "Run OpenClaw gateway via systemd user service (Linux).";
|
||||
};
|
||||
|
||||
systemd.unitName = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "openclaw-gateway";
|
||||
description = "systemd user service unit name for the default OpenClaw instance.";
|
||||
};
|
||||
|
||||
instances = lib.mkOption {
|
||||
type = lib.types.attrsOf (lib.types.submodule instanceModule);
|
||||
default = { };
|
||||
description = "Named OpenClaw instances (prod/test).";
|
||||
};
|
||||
|
||||
exposePluginPackages = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = "Add plugin packages to home.packages so CLIs are on PATH.";
|
||||
};
|
||||
|
||||
qmd.prewarmModels.enable = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = "Download/check QMD's default GGUF models during Home Manager activation. This uses about 2.25GB under the user's QMD cache.";
|
||||
};
|
||||
|
||||
reloadScript = {
|
||||
enable = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = "Install openclaw-reload helper for no-sudo config refresh + gateway restart.";
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkOption {
|
||||
type = lib.types.submodule { options = openclawLib.generatedConfigOptions; };
|
||||
default = { };
|
||||
description = "OpenClaw config (schema-typed).";
|
||||
};
|
||||
};
|
||||
}
|
||||
@ -1,74 +0,0 @@
|
||||
{
|
||||
summarize = {
|
||||
tool = "summarize";
|
||||
description = "Summarize URLs, PDFs, YouTube videos";
|
||||
linux = true;
|
||||
};
|
||||
|
||||
discrawl = {
|
||||
tool = "discrawl";
|
||||
description = "Archive and search Discord history";
|
||||
linux = true;
|
||||
};
|
||||
|
||||
wacrawl = {
|
||||
tool = "wacrawl";
|
||||
description = "Archive and search WhatsApp Desktop history";
|
||||
linux = true;
|
||||
};
|
||||
|
||||
peekaboo = {
|
||||
tool = "peekaboo";
|
||||
description = "Screenshot your screen";
|
||||
linux = false;
|
||||
};
|
||||
|
||||
poltergeist = {
|
||||
tool = "poltergeist";
|
||||
description = "File watching and automation";
|
||||
linux = false;
|
||||
};
|
||||
|
||||
sag = {
|
||||
tool = "sag";
|
||||
description = "Text-to-speech";
|
||||
linux = true;
|
||||
};
|
||||
|
||||
camsnap = {
|
||||
tool = "camsnap";
|
||||
description = "Take photos from connected cameras";
|
||||
linux = true;
|
||||
};
|
||||
|
||||
gogcli = {
|
||||
tool = "gogcli";
|
||||
description = "Google Calendar integration";
|
||||
linux = true;
|
||||
};
|
||||
|
||||
goplaces = {
|
||||
tool = "goplaces";
|
||||
description = "Google Places API (New) CLI";
|
||||
defaultEnable = true;
|
||||
linux = true;
|
||||
};
|
||||
|
||||
qmd = {
|
||||
tool = "qmd";
|
||||
description = "Search local markdown knowledge bases";
|
||||
linux = true;
|
||||
};
|
||||
|
||||
sonoscli = {
|
||||
tool = "sonoscli";
|
||||
description = "Control Sonos speakers";
|
||||
linux = true;
|
||||
};
|
||||
|
||||
imsg = {
|
||||
tool = "imsg";
|
||||
description = "Send/read iMessages";
|
||||
linux = false;
|
||||
};
|
||||
}
|
||||
@ -1,314 +0,0 @@
|
||||
{
|
||||
lib,
|
||||
pkgs,
|
||||
openclawLib,
|
||||
enabledInstances,
|
||||
}:
|
||||
|
||||
let
|
||||
resolvePath = openclawLib.resolvePath;
|
||||
toRelative = openclawLib.toRelative;
|
||||
mkNpmRuntimePlugin = pkgs.callPackage ../../../lib/npm-runtime-plugin.nix { };
|
||||
|
||||
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:
|
||||
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;
|
||||
flake = builtins.getFlake plugin.source;
|
||||
openclawPluginRaw =
|
||||
if flake ? openclawPlugin then
|
||||
flake.openclawPlugin
|
||||
else
|
||||
throw "openclawPlugin missing in ${plugin.source}";
|
||||
openclawPlugin =
|
||||
if builtins.isFunction openclawPluginRaw then openclawPluginRaw system else openclawPluginRaw;
|
||||
resolvedPlugin =
|
||||
if openclawPlugin == null then
|
||||
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 { };
|
||||
in
|
||||
builtins.seq _ {
|
||||
source = plugin.source;
|
||||
inherit name;
|
||||
skills = resolvedPlugin.skills or [ ];
|
||||
packages = resolvedPlugin.packages or [ ];
|
||||
plugins = map (normalizeOpenClawPlugin plugin.source name) (resolvedPlugin.plugins or [ ]);
|
||||
needs = {
|
||||
stateDirs = needs.stateDirs or [ ];
|
||||
requiredEnv = needs.requiredEnv or [ ];
|
||||
};
|
||||
config = plugin.config or { };
|
||||
};
|
||||
|
||||
resolvePlugin =
|
||||
plugin:
|
||||
if lib.hasPrefix "npm:" plugin.source then
|
||||
resolveNpmRuntimePlugin plugin
|
||||
else
|
||||
resolveFlakePlugin plugin;
|
||||
|
||||
resolvedPluginsByInstance = lib.mapAttrs (
|
||||
instName: inst:
|
||||
let
|
||||
resolved = map resolvePlugin inst.plugins;
|
||||
counts = lib.foldl' (acc: p: acc // { "${p.name}" = (acc.${p.name} or 0) + 1; }) { } resolved;
|
||||
duplicates = lib.attrNames (lib.filterAttrs (_: v: v > 1) counts);
|
||||
byName = lib.foldl' (acc: p: acc // { "${p.name}" = p; }) { } resolved;
|
||||
ordered = lib.attrValues byName;
|
||||
in
|
||||
if duplicates == [ ] then
|
||||
ordered
|
||||
else
|
||||
lib.warn "programs.openclaw.instances.${instName}: duplicate plugin names detected (${lib.concatStringsSep ", " duplicates}); last entry wins." ordered
|
||||
) enabledInstances;
|
||||
|
||||
pluginPackagesFor =
|
||||
instName: lib.flatten (map (p: p.packages) (resolvedPluginsByInstance.${instName} or [ ]));
|
||||
|
||||
pluginPackagesAll = lib.flatten (map pluginPackagesFor (lib.attrNames enabledInstances));
|
||||
|
||||
pluginStateDirsFor =
|
||||
instName:
|
||||
let
|
||||
dirs = lib.flatten (map (p: p.needs.stateDirs) (resolvedPluginsByInstance.${instName} or [ ]));
|
||||
in
|
||||
map (dir: resolvePath ("~/" + dir)) dirs;
|
||||
|
||||
pluginStateDirsAll = lib.flatten (map pluginStateDirsFor (lib.attrNames enabledInstances));
|
||||
|
||||
pluginEnvFor =
|
||||
instName:
|
||||
let
|
||||
entries = resolvedPluginsByInstance.${instName} or [ ];
|
||||
toPairs =
|
||||
p:
|
||||
let
|
||||
env = (p.config.env or { });
|
||||
required = p.needs.requiredEnv;
|
||||
in
|
||||
map (k: {
|
||||
key = k;
|
||||
value = env.${k} or "";
|
||||
plugin = p.name;
|
||||
}) required;
|
||||
in
|
||||
lib.flatten (map toPairs entries);
|
||||
|
||||
pluginEnvAllFor =
|
||||
instName:
|
||||
let
|
||||
entries = resolvedPluginsByInstance.${instName} or [ ];
|
||||
toPairs =
|
||||
p:
|
||||
let
|
||||
env = (p.config.env or { });
|
||||
in
|
||||
map (k: {
|
||||
key = k;
|
||||
value = env.${k};
|
||||
plugin = p.name;
|
||||
}) (lib.attrNames env);
|
||||
in
|
||||
lib.flatten (map toPairs entries);
|
||||
|
||||
openclawPluginsFor =
|
||||
instName: lib.flatten (map (p: p.plugins) (resolvedPluginsByInstance.${instName} or [ ]));
|
||||
|
||||
openclawPluginLoadPathsFor = instName: map (p: toString p.path) (openclawPluginsFor instName);
|
||||
|
||||
openclawPluginEntriesConfigFor =
|
||||
instName:
|
||||
let
|
||||
entries = openclawPluginsFor instName;
|
||||
in
|
||||
lib.optionalAttrs (entries != [ ]) {
|
||||
plugins = {
|
||||
entries = lib.listToAttrs (
|
||||
map (p: {
|
||||
name = p.id;
|
||||
value = {
|
||||
enabled = p.enabled;
|
||||
};
|
||||
}) entries
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
openclawPluginIdAssertions = lib.mapAttrsToList (
|
||||
instName: _inst:
|
||||
let
|
||||
ids = map (p: p.id) (openclawPluginsFor 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).";
|
||||
};
|
||||
in
|
||||
(map mkAssertion plugins) ++ (map mkConfigAssertion plugins)
|
||||
) enabledInstances
|
||||
);
|
||||
|
||||
pluginConfigFiles =
|
||||
let
|
||||
entryFor =
|
||||
instName: inst:
|
||||
let
|
||||
plugins = resolvedPluginsByInstance.${instName} or [ ];
|
||||
mkEntries =
|
||||
p:
|
||||
let
|
||||
cfg = p.config.settings or { };
|
||||
dir = if (p.needs.stateDirs or [ ]) == [ ] then null else lib.head (p.needs.stateDirs or [ ]);
|
||||
in
|
||||
if cfg == { } then
|
||||
[ ]
|
||||
else
|
||||
(
|
||||
if dir == null then
|
||||
throw "plugin ${p.name} provides settings but no stateDirs are defined"
|
||||
else
|
||||
[
|
||||
{
|
||||
name = toRelative (resolvePath ("~/" + dir + "/config.json"));
|
||||
value = {
|
||||
text = builtins.toJSON cfg;
|
||||
};
|
||||
}
|
||||
]
|
||||
);
|
||||
in
|
||||
lib.flatten (map mkEntries plugins);
|
||||
entries = lib.flatten (lib.mapAttrsToList entryFor enabledInstances);
|
||||
in
|
||||
lib.listToAttrs entries;
|
||||
|
||||
pluginGuards =
|
||||
let
|
||||
renderCheck = entry: ''
|
||||
if [ -z "${entry.value}" ]; then
|
||||
echo "Missing env ${entry.key} for plugin ${entry.plugin} in instance ${entry.instance}." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -f "${entry.value}" ] || [ ! -s "${entry.value}" ]; then
|
||||
echo "Required file for ${entry.key} not found or empty: ${entry.value} (plugin ${entry.plugin}, instance ${entry.instance})." >&2
|
||||
exit 1
|
||||
fi
|
||||
'';
|
||||
entriesForInstance =
|
||||
instName: map (entry: entry // { instance = instName; }) (pluginEnvFor instName);
|
||||
entries = lib.flatten (map entriesForInstance (lib.attrNames enabledInstances));
|
||||
in
|
||||
lib.concatStringsSep "\n" (map renderCheck entries);
|
||||
|
||||
in
|
||||
{
|
||||
inherit
|
||||
resolvedPluginsByInstance
|
||||
pluginPackagesFor
|
||||
pluginPackagesAll
|
||||
pluginStateDirsFor
|
||||
pluginStateDirsAll
|
||||
pluginEnvFor
|
||||
pluginEnvAllFor
|
||||
openclawPluginsFor
|
||||
openclawPluginLoadPathsFor
|
||||
openclawPluginEntriesConfigFor
|
||||
pluginAssertions
|
||||
pluginConfigFiles
|
||||
pluginGuards
|
||||
;
|
||||
}
|
||||
@ -1,217 +0,0 @@
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
|
||||
let
|
||||
cfg = config.services.openclaw-gateway;
|
||||
|
||||
deepConfigType = lib.types.mkOptionType {
|
||||
name = "openclaw-config-attrs";
|
||||
description = "OpenClaw JSON config (attrset), merged deeply via lib.recursiveUpdate.";
|
||||
check = builtins.isAttrs;
|
||||
merge = _loc: defs: lib.foldl' lib.recursiveUpdate { } (map (d: d.value) defs);
|
||||
};
|
||||
|
||||
configJson = builtins.toJSON cfg.config;
|
||||
generatedConfigFile = pkgs.writeText "openclaw.json" configJson;
|
||||
configFile = if cfg.configFile != null then cfg.configFile else generatedConfigFile;
|
||||
|
||||
# `environment.etc` takes a relative path.
|
||||
etcRelPath = lib.removePrefix "/etc/" cfg.configPath;
|
||||
|
||||
execStartCmd =
|
||||
if cfg.execStart != null then
|
||||
cfg.execStart
|
||||
else
|
||||
"${cfg.package}/bin/openclaw gateway --port ${toString cfg.port}";
|
||||
|
||||
in
|
||||
{
|
||||
options.services.openclaw-gateway = with lib; {
|
||||
enable = mkEnableOption "OpenClaw gateway (openclaw gateway as a systemd service)";
|
||||
|
||||
unitName = mkOption {
|
||||
type = types.str;
|
||||
default = "openclaw-gateway";
|
||||
description = "systemd unit name (service will be <unitName>.service).";
|
||||
};
|
||||
|
||||
package = mkOption {
|
||||
type = types.package;
|
||||
default = if pkgs ? openclaw then pkgs.openclaw else pkgs.openclaw-gateway;
|
||||
description = "OpenClaw gateway package.";
|
||||
};
|
||||
|
||||
port = mkOption {
|
||||
type = types.port;
|
||||
default = 18789;
|
||||
description = "Gateway listen port.";
|
||||
};
|
||||
|
||||
user = mkOption {
|
||||
type = types.str;
|
||||
default = "openclaw";
|
||||
description = "System user running the gateway.";
|
||||
};
|
||||
|
||||
group = mkOption {
|
||||
type = types.str;
|
||||
default = "openclaw";
|
||||
description = "System group running the gateway.";
|
||||
};
|
||||
|
||||
createUser = mkOption {
|
||||
type = types.bool;
|
||||
default = true;
|
||||
description = "Create the user/group automatically.";
|
||||
};
|
||||
|
||||
stateDir = mkOption {
|
||||
type = types.str;
|
||||
default = "/var/lib/openclaw";
|
||||
description = "State dir (OPENCLAW_STATE_DIR).";
|
||||
};
|
||||
|
||||
workingDirectory = mkOption {
|
||||
type = types.str;
|
||||
default = cfg.stateDir;
|
||||
description = "Working directory for the systemd service.";
|
||||
};
|
||||
|
||||
configPath = mkOption {
|
||||
type = types.str;
|
||||
default = "/etc/openclaw/openclaw.json";
|
||||
description = "Path to the OpenClaw JSON config file (OPENCLAW_CONFIG_PATH). Must be under /etc.";
|
||||
};
|
||||
|
||||
configFile = mkOption {
|
||||
type = types.nullOr types.path;
|
||||
default = null;
|
||||
description = "Optional path to an existing config file. If set, it is copied to configPath (under /etc).";
|
||||
};
|
||||
|
||||
config = mkOption {
|
||||
type = deepConfigType;
|
||||
default = { };
|
||||
description = "OpenClaw JSON config (attrset), deep-merged across definitions.";
|
||||
};
|
||||
|
||||
logPath = mkOption {
|
||||
type = types.str;
|
||||
default = "${cfg.stateDir}/logs/gateway.log";
|
||||
description = "Log file path (systemd StandardOutput/StandardError append).";
|
||||
};
|
||||
|
||||
environment = mkOption {
|
||||
type = types.attrsOf types.str;
|
||||
default = { };
|
||||
description = "Additional environment variables for the gateway process.";
|
||||
};
|
||||
|
||||
environmentFiles = mkOption {
|
||||
type = types.listOf types.str;
|
||||
default = [ ];
|
||||
description = "systemd EnvironmentFile= entries (use leading '-' to ignore missing).";
|
||||
};
|
||||
|
||||
execStart = mkOption {
|
||||
type = types.nullOr types.str;
|
||||
default = null;
|
||||
description = "Override ExecStart command. If unset, runs: openclaw gateway --port <port>.";
|
||||
};
|
||||
|
||||
execStartPre = mkOption {
|
||||
type = types.listOf types.str;
|
||||
default = [ ];
|
||||
description = "List of ExecStartPre= commands.";
|
||||
};
|
||||
|
||||
servicePath = mkOption {
|
||||
type = types.listOf types.package;
|
||||
default = [ ];
|
||||
description = "Extra packages added to systemd service PATH.";
|
||||
};
|
||||
|
||||
restart = mkOption {
|
||||
type = types.str;
|
||||
default = "always";
|
||||
description = "systemd Restart=.";
|
||||
};
|
||||
|
||||
restartSec = mkOption {
|
||||
type = types.int;
|
||||
default = 2;
|
||||
description = "systemd RestartSec=.";
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
assertions = [
|
||||
{
|
||||
assertion = lib.hasPrefix "/etc/" cfg.configPath;
|
||||
message = "services.openclaw-gateway.configPath must be under /etc (got: ${cfg.configPath}).";
|
||||
}
|
||||
];
|
||||
|
||||
users.groups.${cfg.group} = lib.mkIf cfg.createUser { };
|
||||
users.users.${cfg.user} = lib.mkIf cfg.createUser {
|
||||
isSystemUser = true;
|
||||
group = cfg.group;
|
||||
home = cfg.stateDir;
|
||||
createHome = true;
|
||||
shell = pkgs.bashInteractive;
|
||||
};
|
||||
|
||||
systemd.tmpfiles.rules = [
|
||||
"d ${cfg.stateDir} 0750 ${cfg.user} ${cfg.group} - -"
|
||||
"d ${builtins.dirOf cfg.logPath} 0750 ${cfg.user} ${cfg.group} - -"
|
||||
"d ${builtins.dirOf cfg.configPath} 0755 root root - -"
|
||||
];
|
||||
|
||||
environment.etc.${etcRelPath} = {
|
||||
mode = "0644";
|
||||
source = configFile;
|
||||
};
|
||||
|
||||
systemd.services.${cfg.unitName} = {
|
||||
description = "OpenClaw gateway";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "network.target" ];
|
||||
|
||||
environment = {
|
||||
OPENCLAW_CONFIG_PATH = cfg.configPath;
|
||||
OPENCLAW_STATE_DIR = cfg.stateDir;
|
||||
|
||||
# Backward-compatible env names.
|
||||
CLAWDBOT_CONFIG_PATH = cfg.configPath;
|
||||
CLAWDBOT_STATE_DIR = cfg.stateDir;
|
||||
}
|
||||
// cfg.environment;
|
||||
|
||||
serviceConfig = {
|
||||
User = cfg.user;
|
||||
Group = cfg.group;
|
||||
WorkingDirectory = cfg.workingDirectory;
|
||||
|
||||
EnvironmentFile = cfg.environmentFiles;
|
||||
ExecStartPre = cfg.execStartPre;
|
||||
ExecStart = execStartCmd;
|
||||
|
||||
Restart = cfg.restart;
|
||||
RestartSec = cfg.restartSec;
|
||||
|
||||
StandardOutput = "append:${cfg.logPath}";
|
||||
StandardError = "append:${cfg.logPath}";
|
||||
};
|
||||
|
||||
path = [
|
||||
pkgs.bash
|
||||
pkgs.coreutils
|
||||
]
|
||||
++ cfg.servicePath;
|
||||
};
|
||||
};
|
||||
}
|
||||
@ -1,39 +1,20 @@
|
||||
{
|
||||
openclawToolPkgs ? { },
|
||||
qmdPkgs ? { },
|
||||
}:
|
||||
final: prev:
|
||||
self: super:
|
||||
let
|
||||
qmdPackage =
|
||||
if prev.stdenv.hostPlatform.isDarwin then
|
||||
openclawToolPkgs.qmd or null
|
||||
else
|
||||
qmdPkgs.qmd or qmdPkgs.default or null;
|
||||
packages = import ./packages {
|
||||
pkgs = prev;
|
||||
openclawToolPkgs = openclawToolPkgs;
|
||||
inherit qmdPackage;
|
||||
sourceInfo = import ./sources/clawdis-source.nix;
|
||||
clawdisGateway = super.callPackage ./packages/clawdis-gateway.nix {
|
||||
inherit sourceInfo;
|
||||
};
|
||||
toolNames =
|
||||
(import ./tools/extended.nix {
|
||||
pkgs = prev;
|
||||
openclawToolPkgs = openclawToolPkgs;
|
||||
}).toolNames;
|
||||
withTools =
|
||||
{
|
||||
toolNamesOverride ? null,
|
||||
excludeToolNames ? [ ],
|
||||
}:
|
||||
import ./packages {
|
||||
pkgs = prev;
|
||||
openclawToolPkgs = openclawToolPkgs;
|
||||
inherit qmdPackage;
|
||||
inherit toolNamesOverride excludeToolNames;
|
||||
};
|
||||
in
|
||||
packages
|
||||
// {
|
||||
openclawPackages = packages // {
|
||||
inherit toolNames withTools;
|
||||
clawdisApp = super.callPackage ./packages/clawdis-app.nix { };
|
||||
toolSets = import ./tools/extended.nix { pkgs = super; };
|
||||
clawdisBundle = super.callPackage ./packages/clawdis-batteries.nix {
|
||||
clawdis-gateway = clawdisGateway;
|
||||
clawdis-app = clawdisApp;
|
||||
extendedTools = toolSets.base;
|
||||
};
|
||||
in {
|
||||
clawdis-gateway = clawdisGateway;
|
||||
clawdis-app = clawdisApp;
|
||||
clawdis = clawdisBundle;
|
||||
clawdis-tools-base = toolSets.base;
|
||||
clawdis-tools-extended = toolSets.extended;
|
||||
}
|
||||
|
||||
36
nix/packages/clawdis-app.nix
Normal file
36
nix/packages/clawdis-app.nix
Normal file
@ -0,0 +1,36 @@
|
||||
{ lib
|
||||
, stdenvNoCC
|
||||
, fetchzip
|
||||
}:
|
||||
|
||||
stdenvNoCC.mkDerivation {
|
||||
pname = "clawdis-app";
|
||||
version = "2.0.0-beta4";
|
||||
|
||||
src = fetchzip {
|
||||
url = "https://github.com/steipete/clawdis/releases/download/v2.0.0-beta4/Clawdis-2.0.0-beta4.zip";
|
||||
hash = "sha256-Oa7cejVFfZtJBSmjDaRjqocVyXo+WeS/xucGpJFDzIg=";
|
||||
stripRoot = false;
|
||||
};
|
||||
|
||||
dontUnpack = true;
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
mkdir -p $out/Applications
|
||||
app_path="$(find "$src" -maxdepth 2 -name 'Clawdis.app' -print -quit)"
|
||||
if [ -z "$app_path" ]; then
|
||||
echo "Clawdis.app not found in $src" >&2
|
||||
exit 1
|
||||
fi
|
||||
cp -R "$app_path" "$out/Applications/Clawdis.app"
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
meta = with lib; {
|
||||
description = "Clawdis macOS app bundle";
|
||||
homepage = "https://github.com/steipete/clawdis";
|
||||
license = licenses.mit;
|
||||
platforms = platforms.darwin;
|
||||
};
|
||||
}
|
||||
19
nix/packages/clawdis-batteries.nix
Normal file
19
nix/packages/clawdis-batteries.nix
Normal file
@ -0,0 +1,19 @@
|
||||
{ lib
|
||||
, buildEnv
|
||||
, clawdis-gateway
|
||||
, clawdis-app
|
||||
, extendedTools
|
||||
}:
|
||||
|
||||
buildEnv {
|
||||
name = "clawdis-2.0.0-beta4";
|
||||
paths = [ clawdis-gateway clawdis-app ] ++ extendedTools;
|
||||
pathsToLink = [ "/bin" "/Applications" ];
|
||||
|
||||
meta = with lib; {
|
||||
description = "Clawdis batteries-included bundle (gateway + app + tools)";
|
||||
homepage = "https://github.com/steipete/clawdis";
|
||||
license = licenses.mit;
|
||||
platforms = platforms.darwin;
|
||||
};
|
||||
}
|
||||
68
nix/packages/clawdis-gateway.nix
Normal file
68
nix/packages/clawdis-gateway.nix
Normal file
@ -0,0 +1,68 @@
|
||||
{ lib
|
||||
, stdenv
|
||||
, fetchFromGitHub
|
||||
, nodejs_22
|
||||
, pnpm_10
|
||||
, pkg-config
|
||||
, python3
|
||||
, makeWrapper
|
||||
, vips
|
||||
, sourceInfo
|
||||
}:
|
||||
|
||||
stdenv.mkDerivation (finalAttrs: {
|
||||
pname = "clawdis-gateway";
|
||||
version = "2.0.0-beta4";
|
||||
|
||||
src = fetchFromGitHub sourceInfo;
|
||||
|
||||
pnpmDeps = pnpm_10.fetchDeps {
|
||||
inherit (finalAttrs) pname version src;
|
||||
hash = "sha256-k5VvvHOlZc24M0aQF4nEux2k19s/XMD56lprlUD/XoI=";
|
||||
fetcherVersion = 2;
|
||||
};
|
||||
|
||||
nativeBuildInputs = [
|
||||
nodejs_22
|
||||
pnpm_10
|
||||
pnpm_10.configHook
|
||||
pkg-config
|
||||
python3
|
||||
makeWrapper
|
||||
];
|
||||
|
||||
buildInputs = [ vips ];
|
||||
|
||||
env = {
|
||||
SHARP_FORCE_GLOBAL_LIBVIPS = "1";
|
||||
npm_config_build_from_source = "true";
|
||||
};
|
||||
|
||||
buildPhase = ''
|
||||
runHook preBuild
|
||||
pnpm install --offline --frozen-lockfile
|
||||
pnpm build
|
||||
runHook postBuild
|
||||
'';
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
mkdir -p $out/lib/clawdis $out/bin
|
||||
|
||||
cp -r dist node_modules package.json ui $out/lib/clawdis/
|
||||
|
||||
makeWrapper ${nodejs_22}/bin/node $out/bin/clawdis \
|
||||
--add-flags "$out/lib/clawdis/dist/index.js" \
|
||||
--set-default CLAWDIS_NIX_MODE "1"
|
||||
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
meta = with lib; {
|
||||
description = "Telegram-first AI gateway (Clawdis)";
|
||||
homepage = "https://github.com/joshp123/clawdis";
|
||||
license = licenses.mit;
|
||||
platforms = platforms.darwin;
|
||||
mainProgram = "clawdis";
|
||||
};
|
||||
})
|
||||
@ -1,34 +0,0 @@
|
||||
{
|
||||
pkgs,
|
||||
sourceInfo ? import ../sources/openclaw-source.nix,
|
||||
openclawToolPkgs ? { },
|
||||
qmdPackage ? null,
|
||||
toolNamesOverride ? null,
|
||||
excludeToolNames ? [ ],
|
||||
}:
|
||||
let
|
||||
isDarwin = pkgs.stdenv.hostPlatform.isDarwin;
|
||||
toolSets = import ../tools/extended.nix {
|
||||
pkgs = pkgs;
|
||||
openclawToolPkgs = openclawToolPkgs;
|
||||
inherit toolNamesOverride excludeToolNames;
|
||||
};
|
||||
openclawGateway = pkgs.callPackage ./openclaw-gateway.nix {
|
||||
inherit sourceInfo;
|
||||
pnpmDepsHash = sourceInfo.pnpmDepsHash or null;
|
||||
};
|
||||
openclawApp = if isDarwin then pkgs.callPackage ./openclaw-app.nix { } else null;
|
||||
openclawBundle = pkgs.callPackage ./openclaw-batteries.nix {
|
||||
openclaw-gateway = openclawGateway;
|
||||
openclaw-app = openclawApp;
|
||||
extendedTools = toolSets.tools;
|
||||
inherit qmdPackage;
|
||||
version = sourceInfo.releaseVersion or null;
|
||||
};
|
||||
in
|
||||
{
|
||||
openclaw-gateway = openclawGateway;
|
||||
openclaw = openclawBundle;
|
||||
}
|
||||
// (if qmdPackage != null then { qmd = qmdPackage; } else { })
|
||||
// (if isDarwin then { openclaw-app = openclawApp; } else { })
|
||||
@ -1,16 +0,0 @@
|
||||
{ stdenv, fetchurl }:
|
||||
|
||||
stdenv.mkDerivation {
|
||||
pname = "node-addon-api";
|
||||
version = "8.5.0";
|
||||
|
||||
src = fetchurl {
|
||||
url = "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.5.0.tgz";
|
||||
hash = "sha256-0S8HyBYig7YhNVGFXx2o2sFiMxN0YpgwteZA8TDweRA=";
|
||||
};
|
||||
|
||||
dontConfigure = true;
|
||||
dontBuild = true;
|
||||
|
||||
installPhase = "${../scripts/node-addon-api-install.sh}";
|
||||
}
|
||||
@ -1,27 +0,0 @@
|
||||
{
|
||||
lib,
|
||||
stdenvNoCC,
|
||||
fetchzip,
|
||||
}:
|
||||
|
||||
stdenvNoCC.mkDerivation {
|
||||
pname = "openclaw-app";
|
||||
version = "2026.5.7";
|
||||
|
||||
src = fetchzip {
|
||||
url = "https://github.com/openclaw/openclaw/releases/download/v2026.5.7/OpenClaw-2026.5.7.zip";
|
||||
hash = "sha256-64O1dzadr5R1HiS4DlpbC7En3qyEaibDZS8kKbH7GOo=";
|
||||
stripRoot = false;
|
||||
};
|
||||
|
||||
dontUnpack = true;
|
||||
|
||||
installPhase = "${../scripts/openclaw-app-install.sh}";
|
||||
|
||||
meta = with lib; {
|
||||
description = "OpenClaw macOS app bundle";
|
||||
homepage = "https://github.com/openclaw/openclaw";
|
||||
license = licenses.mit;
|
||||
platforms = platforms.darwin;
|
||||
};
|
||||
}
|
||||
@ -1,46 +0,0 @@
|
||||
{
|
||||
lib,
|
||||
stdenvNoCC,
|
||||
makeWrapper,
|
||||
python3Minimal,
|
||||
openclaw-gateway,
|
||||
openclaw-app ? null,
|
||||
extendedTools ? [ ],
|
||||
qmdPackage ? null,
|
||||
version ? null,
|
||||
}:
|
||||
|
||||
let
|
||||
bundleVersion =
|
||||
if version != null && version != "" then version else lib.getVersion openclaw-gateway;
|
||||
runtimeTools = extendedTools ++ lib.optional (qmdPackage != null) qmdPackage;
|
||||
toolsPath = lib.makeBinPath runtimeTools;
|
||||
in
|
||||
stdenvNoCC.mkDerivation {
|
||||
pname = "openclaw";
|
||||
version = bundleVersion;
|
||||
|
||||
dontUnpack = true;
|
||||
dontConfigure = true;
|
||||
dontBuild = true;
|
||||
|
||||
nativeBuildInputs = [ makeWrapper ];
|
||||
|
||||
env = {
|
||||
OPENCLAW_APP_PACKAGE = lib.optionalString (openclaw-app != null) "${openclaw-app}";
|
||||
OPENCLAW_GATEWAY_BIN = "${openclaw-gateway}/bin/openclaw";
|
||||
OPENCLAW_PINNED_WRITE_PYTHON = "${python3Minimal}/bin/python3";
|
||||
OPENCLAW_TOOLS_PATH = toolsPath;
|
||||
STDENV_SETUP = "${stdenvNoCC}/setup";
|
||||
};
|
||||
|
||||
installPhase = "${../scripts/openclaw-batteries-install.sh}";
|
||||
|
||||
meta = with lib; {
|
||||
description = "OpenClaw batteries-included bundle (gateway + app + tools)";
|
||||
homepage = "https://github.com/openclaw/openclaw";
|
||||
license = licenses.mit;
|
||||
platforms = platforms.darwin ++ platforms.linux;
|
||||
mainProgram = "openclaw";
|
||||
};
|
||||
}
|
||||
@ -1,97 +0,0 @@
|
||||
{
|
||||
lib,
|
||||
stdenv,
|
||||
fetchFromGitHub,
|
||||
fetchurl,
|
||||
nodejs_22,
|
||||
pnpm_10,
|
||||
fetchPnpmDeps,
|
||||
pkg-config,
|
||||
jq,
|
||||
python3,
|
||||
perl,
|
||||
node-gyp,
|
||||
makeWrapper,
|
||||
vips,
|
||||
git,
|
||||
zstd,
|
||||
sourceInfo,
|
||||
gatewaySrc ? null,
|
||||
pnpmDepsHash ? (sourceInfo.pnpmDepsHash or null),
|
||||
}:
|
||||
|
||||
assert gatewaySrc == null || pnpmDepsHash != null;
|
||||
|
||||
let
|
||||
common =
|
||||
import ../lib/openclaw-gateway-common.nix
|
||||
{
|
||||
inherit
|
||||
lib
|
||||
stdenv
|
||||
fetchFromGitHub
|
||||
fetchurl
|
||||
nodejs_22
|
||||
pnpm_10
|
||||
fetchPnpmDeps
|
||||
pkg-config
|
||||
jq
|
||||
python3
|
||||
node-gyp
|
||||
git
|
||||
zstd
|
||||
;
|
||||
}
|
||||
{
|
||||
pname = "openclaw-gateway";
|
||||
sourceInfo = sourceInfo;
|
||||
pnpmDepsHash = pnpmDepsHash;
|
||||
pnpmDepsPname = "openclaw-gateway";
|
||||
gatewaySrc = gatewaySrc;
|
||||
enableSharp = true;
|
||||
extraNativeBuildInputs = [
|
||||
perl
|
||||
makeWrapper
|
||||
];
|
||||
extraBuildInputs = [ vips ];
|
||||
extraEnv = {
|
||||
NODE_BIN = "${nodejs_22}/bin/node";
|
||||
PATCH_CLIPBOARD_SH = "${../scripts/patch-clipboard.sh}";
|
||||
PATCH_CLIPBOARD_WRAPPER = "${../scripts/clipboard-wrapper.cjs}";
|
||||
};
|
||||
};
|
||||
|
||||
in
|
||||
|
||||
stdenv.mkDerivation (finalAttrs: {
|
||||
pname = "openclaw-gateway";
|
||||
inherit (common) version;
|
||||
|
||||
src = common.resolvedSrc;
|
||||
pnpmDeps = common.pnpmDeps;
|
||||
|
||||
nativeBuildInputs = common.nativeBuildInputs;
|
||||
buildInputs = common.buildInputs;
|
||||
|
||||
env = common.env // {
|
||||
# Nix doesn't automatically substitute finalAttrs into env.
|
||||
PNPM_DEPS = finalAttrs.pnpmDeps;
|
||||
};
|
||||
|
||||
passthru = common.passthru;
|
||||
|
||||
postPatch = "${../scripts/gateway-postpatch.sh}";
|
||||
buildPhase = "${../scripts/gateway-build.sh}";
|
||||
installPhase = "${../scripts/gateway-install.sh}";
|
||||
dontFixup = true;
|
||||
dontStrip = true;
|
||||
dontPatchShebangs = true;
|
||||
|
||||
meta = with lib; {
|
||||
description = "Telegram-first AI gateway (OpenClaw)";
|
||||
homepage = "https://github.com/openclaw/openclaw";
|
||||
license = licenses.mit;
|
||||
platforms = platforms.darwin ++ platforms.linux;
|
||||
mainProgram = "openclaw";
|
||||
};
|
||||
})
|
||||
@ -1,20 +0,0 @@
|
||||
diff --git a/src/plugins/public-surface-loader.ts b/src/plugins/public-surface-loader.ts
|
||||
index 5f6f939..b8d27c8 100644
|
||||
--- a/src/plugins/public-surface-loader.ts
|
||||
+++ b/src/plugins/public-surface-loader.ts
|
||||
@@ -133,8 +133,12 @@ export function loadBundledPluginPublicArtifactModuleSync<T extends object>(para
|
||||
- const opened = openRootFileSync({
|
||||
+ const packageRoot = path.resolve(OPENCLAW_PACKAGE_ROOT);
|
||||
+ const resolvedModulePath = path.resolve(location.modulePath);
|
||||
+ const isPackagePublicSurface = resolvedModulePath.startsWith(`${packageRoot}${path.sep}`);
|
||||
+
|
||||
+ const opened = openRootFileSync({
|
||||
absolutePath: location.modulePath,
|
||||
rootPath: location.boundaryRoot,
|
||||
boundaryLabel:
|
||||
location.boundaryRoot === OPENCLAW_PACKAGE_ROOT ? "OpenClaw package root" : "plugin root",
|
||||
- rejectHardlinks: true,
|
||||
+ rejectHardlinks: !isPackagePublicSurface,
|
||||
});
|
||||
if (!opened.ok) {
|
||||
throw new Error(
|
||||
@ -1,20 +0,0 @@
|
||||
diff --git a/src/plugins/public-surface-loader.ts b/src/plugins/public-surface-loader.ts
|
||||
index 1f5b5ab..a08ef8a 100644
|
||||
--- a/src/plugins/public-surface-loader.ts
|
||||
+++ b/src/plugins/public-surface-loader.ts
|
||||
@@ -124,8 +124,12 @@ export function loadBundledPluginPublicArtifactModuleSync<T extends object>(para
|
||||
- const opened = openBoundaryFileSync({
|
||||
+ const packageRoot = path.resolve(OPENCLAW_PACKAGE_ROOT);
|
||||
+ const resolvedModulePath = path.resolve(location.modulePath);
|
||||
+ const isPackagePublicSurface = resolvedModulePath.startsWith(`${packageRoot}${path.sep}`);
|
||||
+
|
||||
+ const opened = openBoundaryFileSync({
|
||||
absolutePath: location.modulePath,
|
||||
rootPath: location.boundaryRoot,
|
||||
boundaryLabel:
|
||||
location.boundaryRoot === OPENCLAW_PACKAGE_ROOT ? "OpenClaw package root" : "plugin root",
|
||||
- rejectHardlinks: true,
|
||||
+ rejectHardlinks: !isPackagePublicSurface,
|
||||
});
|
||||
if (!opened.ok) {
|
||||
throw new Error(
|
||||
@ -1,26 +0,0 @@
|
||||
diff --git a/src/gateway/server-startup-config.ts b/src/gateway/server-startup-config.ts
|
||||
index e3f1565a00..97feaf2e8c 100644
|
||||
--- a/src/gateway/server-startup-config.ts
|
||||
+++ b/src/gateway/server-startup-config.ts
|
||||
@@ -99,6 +99,21 @@ export async function loadGatewayStartupConfigSnapshot(params: {
|
||||
};
|
||||
}
|
||||
|
||||
+ if (isNixMode) {
|
||||
+ params.log.info(
|
||||
+ `gateway: auto-enabled plugins for this runtime without writing config in Nix mode:\n${autoEnable.changes.map((entry) => `- ${entry}`).join("\n")}`,
|
||||
+ );
|
||||
+ return {
|
||||
+ snapshot: {
|
||||
+ ...configSnapshot,
|
||||
+ runtimeConfig: autoEnable.config,
|
||||
+ config: autoEnable.config,
|
||||
+ },
|
||||
+ wroteConfig,
|
||||
+ ...(pluginMetadataSnapshot ? { pluginMetadataSnapshot } : {}),
|
||||
+ };
|
||||
+ }
|
||||
+
|
||||
try {
|
||||
const { replaceConfigFile } = await import("../config/mutate.js");
|
||||
await replaceConfigFile({
|
||||
@ -1,426 +0,0 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { resolveNpmRunner } from "./npm-runner.mjs";
|
||||
|
||||
function readJson(filePath) {
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
||||
}
|
||||
|
||||
function writeJson(filePath, value) {
|
||||
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
function removePathIfExists(targetPath) {
|
||||
fs.rmSync(targetPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function makeTempDir(parentDir, prefix) {
|
||||
return fs.mkdtempSync(path.join(parentDir, prefix));
|
||||
}
|
||||
|
||||
function sanitizeTempPrefixSegment(value) {
|
||||
const normalized = value.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/-+/g, "-");
|
||||
return normalized.length > 0 ? normalized : "plugin";
|
||||
}
|
||||
|
||||
function replaceDir(targetPath, sourcePath) {
|
||||
removePathIfExists(targetPath);
|
||||
try {
|
||||
fs.renameSync(sourcePath, targetPath);
|
||||
return;
|
||||
} catch (error) {
|
||||
if (error?.code !== "EXDEV") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
fs.cpSync(sourcePath, targetPath, { recursive: true, force: true });
|
||||
removePathIfExists(sourcePath);
|
||||
}
|
||||
|
||||
function dependencyNodeModulesPath(nodeModulesDir, depName) {
|
||||
return path.join(nodeModulesDir, ...depName.split("/"));
|
||||
}
|
||||
|
||||
function createResolver(fromDir) {
|
||||
return createRequire(path.join(fromDir, "__openclaw-runtime-deps-resolver__.cjs"));
|
||||
}
|
||||
|
||||
function findPackageRoot(startPath, depName) {
|
||||
let currentDir = fs.statSync(startPath).isDirectory() ? startPath : path.dirname(startPath);
|
||||
while (true) {
|
||||
const packageJsonPath = path.join(currentDir, "package.json");
|
||||
if (fs.existsSync(packageJsonPath)) {
|
||||
const packageJson = readJson(packageJsonPath);
|
||||
if (packageJson.name === depName) {
|
||||
return {
|
||||
dir: currentDir,
|
||||
packageJsonPath,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const parentDir = path.dirname(currentDir);
|
||||
if (parentDir === currentDir) {
|
||||
return null;
|
||||
}
|
||||
currentDir = parentDir;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveDependencyFromNodeModulesPath(fromDir, depName) {
|
||||
let currentDir = fromDir;
|
||||
while (true) {
|
||||
const nodeModulesDir =
|
||||
path.basename(currentDir) === "node_modules" ? currentDir : path.join(currentDir, "node_modules");
|
||||
const directPath = dependencyNodeModulesPath(nodeModulesDir, depName);
|
||||
const packageJsonPath = path.join(directPath, "package.json");
|
||||
if (fs.existsSync(packageJsonPath)) {
|
||||
return {
|
||||
dir: directPath,
|
||||
packageJsonPath,
|
||||
};
|
||||
}
|
||||
|
||||
const parentDir = path.dirname(currentDir);
|
||||
if (parentDir === currentDir) {
|
||||
return null;
|
||||
}
|
||||
currentDir = parentDir;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveInstalledDependency(fromDir, depName) {
|
||||
const directResolution = resolveDependencyFromNodeModulesPath(fromDir, depName);
|
||||
if (directResolution !== null) {
|
||||
return directResolution;
|
||||
}
|
||||
|
||||
const resolver = createResolver(fromDir);
|
||||
try {
|
||||
return findPackageRoot(resolver.resolve(`${depName}/package.json`), depName);
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
return findPackageRoot(resolver.resolve(depName), depName);
|
||||
} catch {}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function stageInstalledRuntimeTree(rootNodeModulesDir, packageJson, stagedNodeModulesDir) {
|
||||
const packageCache = new Map();
|
||||
const stagedTargets = new Set();
|
||||
const queue = [
|
||||
...Object.entries(packageJson.dependencies ?? {}).map(([depName, spec]) => ({
|
||||
depName,
|
||||
spec,
|
||||
fromDir: rootNodeModulesDir,
|
||||
isOptional: false,
|
||||
targetNodeModulesDir: stagedNodeModulesDir,
|
||||
})),
|
||||
...Object.entries(packageJson.optionalDependencies ?? {}).map(([depName, spec]) => ({
|
||||
depName,
|
||||
spec,
|
||||
fromDir: rootNodeModulesDir,
|
||||
isOptional: true,
|
||||
targetNodeModulesDir: stagedNodeModulesDir,
|
||||
})),
|
||||
];
|
||||
stageInstalledRuntimeTree.lastFailure = null;
|
||||
|
||||
while (queue.length > 0) {
|
||||
const { depName, fromDir, isOptional, spec, targetNodeModulesDir } = queue.shift();
|
||||
const resolvedDep = resolveInstalledDependency(fromDir, depName);
|
||||
if (resolvedDep === null) {
|
||||
if (isOptional) {
|
||||
continue;
|
||||
}
|
||||
stageInstalledRuntimeTree.lastFailure =
|
||||
fromDir === rootNodeModulesDir
|
||||
? `missing ${depName} (${spec}) from root`
|
||||
: `missing ${depName} (${spec}) from ${fromDir}`;
|
||||
return false;
|
||||
}
|
||||
|
||||
const packageJson =
|
||||
packageCache.get(resolvedDep.packageJsonPath) ?? readJson(resolvedDep.packageJsonPath);
|
||||
packageCache.set(resolvedDep.packageJsonPath, packageJson);
|
||||
|
||||
const targetPath = dependencyNodeModulesPath(targetNodeModulesDir, depName);
|
||||
if (!stagedTargets.has(targetPath)) {
|
||||
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
||||
fs.cpSync(resolvedDep.dir, targetPath, { recursive: true, force: true, dereference: true });
|
||||
stagedTargets.add(targetPath);
|
||||
}
|
||||
|
||||
const childTargetNodeModulesDir = path.join(targetPath, "node_modules");
|
||||
for (const [childName, childSpec] of Object.entries(packageJson.dependencies ?? {})) {
|
||||
queue.push({
|
||||
depName: childName,
|
||||
spec: childSpec,
|
||||
fromDir: resolvedDep.dir,
|
||||
isOptional: false,
|
||||
targetNodeModulesDir: childTargetNodeModulesDir,
|
||||
});
|
||||
}
|
||||
for (const [childName, childSpec] of Object.entries(packageJson.optionalDependencies ?? {})) {
|
||||
queue.push({
|
||||
depName: childName,
|
||||
spec: childSpec,
|
||||
fromDir: resolvedDep.dir,
|
||||
isOptional: true,
|
||||
targetNodeModulesDir: childTargetNodeModulesDir,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function listBundledPluginRuntimeDirs(repoRoot) {
|
||||
const extensionsRoot = path.join(repoRoot, "dist", "extensions");
|
||||
if (!fs.existsSync(extensionsRoot)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return fs
|
||||
.readdirSync(extensionsRoot, { withFileTypes: true })
|
||||
.filter((dirent) => dirent.isDirectory())
|
||||
.map((dirent) => path.join(extensionsRoot, dirent.name))
|
||||
.filter((pluginDir) => fs.existsSync(path.join(pluginDir, "package.json")));
|
||||
}
|
||||
|
||||
function hasRuntimeDeps(packageJson) {
|
||||
return (
|
||||
Object.keys(packageJson.dependencies ?? {}).length > 0 ||
|
||||
Object.keys(packageJson.optionalDependencies ?? {}).length > 0
|
||||
);
|
||||
}
|
||||
|
||||
function shouldStageRuntimeDeps(packageJson) {
|
||||
return packageJson.openclaw?.bundle?.stageRuntimeDependencies === true;
|
||||
}
|
||||
|
||||
function sanitizeBundledManifestForRuntimeInstall(pluginDir) {
|
||||
const manifestPath = path.join(pluginDir, "package.json");
|
||||
const packageJson = readJson(manifestPath);
|
||||
let changed = false;
|
||||
|
||||
if (packageJson.peerDependencies?.openclaw) {
|
||||
const nextPeerDependencies = { ...packageJson.peerDependencies };
|
||||
delete nextPeerDependencies.openclaw;
|
||||
if (Object.keys(nextPeerDependencies).length === 0) {
|
||||
delete packageJson.peerDependencies;
|
||||
} else {
|
||||
packageJson.peerDependencies = nextPeerDependencies;
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (packageJson.peerDependenciesMeta?.openclaw) {
|
||||
const nextPeerDependenciesMeta = { ...packageJson.peerDependenciesMeta };
|
||||
delete nextPeerDependenciesMeta.openclaw;
|
||||
if (Object.keys(nextPeerDependenciesMeta).length === 0) {
|
||||
delete packageJson.peerDependenciesMeta;
|
||||
} else {
|
||||
packageJson.peerDependenciesMeta = nextPeerDependenciesMeta;
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (packageJson.devDependencies?.openclaw) {
|
||||
const nextDevDependencies = { ...packageJson.devDependencies };
|
||||
delete nextDevDependencies.openclaw;
|
||||
if (Object.keys(nextDevDependencies).length === 0) {
|
||||
delete packageJson.devDependencies;
|
||||
} else {
|
||||
packageJson.devDependencies = nextDevDependencies;
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
writeJson(manifestPath, packageJson);
|
||||
}
|
||||
|
||||
return packageJson;
|
||||
}
|
||||
|
||||
function resolveRuntimeDepsStampPath(pluginDir) {
|
||||
return path.join(pluginDir, ".openclaw-runtime-deps-stamp.json");
|
||||
}
|
||||
|
||||
function createRuntimeDepsFingerprint(packageJson) {
|
||||
return createHash("sha256").update(JSON.stringify(packageJson)).digest("hex");
|
||||
}
|
||||
|
||||
function readRuntimeDepsStamp(stampPath) {
|
||||
if (!fs.existsSync(stampPath)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return readJson(stampPath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function stageInstalledRootRuntimeDeps(params) {
|
||||
const { fingerprint, packageJson, pluginDir, repoRoot } = params;
|
||||
const rootNodeModulesDir = path.join(repoRoot, "node_modules");
|
||||
const hasDeps =
|
||||
Object.keys(packageJson.dependencies ?? {}).length > 0 ||
|
||||
Object.keys(packageJson.optionalDependencies ?? {}).length > 0;
|
||||
if (!hasDeps || !fs.existsSync(rootNodeModulesDir)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const nodeModulesDir = path.join(pluginDir, "node_modules");
|
||||
const stampPath = resolveRuntimeDepsStampPath(pluginDir);
|
||||
const stagedNodeModulesDir = path.join(
|
||||
makeTempDir(
|
||||
os.tmpdir(),
|
||||
`openclaw-runtime-deps-${sanitizeTempPrefixSegment(path.basename(pluginDir))}-`,
|
||||
),
|
||||
"node_modules",
|
||||
);
|
||||
|
||||
if (!stageInstalledRuntimeTree(rootNodeModulesDir, packageJson, stagedNodeModulesDir)) {
|
||||
console.error(
|
||||
`[nix-openclaw] root runtime staging unavailable for ${path.basename(pluginDir)}: ${
|
||||
stageInstalledRuntimeTree.lastFailure ?? "unknown reason"
|
||||
}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
replaceDir(nodeModulesDir, stagedNodeModulesDir);
|
||||
writeJson(stampPath, {
|
||||
fingerprint,
|
||||
generatedAt: new Date().toISOString(),
|
||||
});
|
||||
return true;
|
||||
} finally {
|
||||
removePathIfExists(path.dirname(stagedNodeModulesDir));
|
||||
}
|
||||
}
|
||||
|
||||
function installPluginRuntimeDeps(params) {
|
||||
const { fingerprint, packageJson, pluginDir, pluginId, repoRoot } = params;
|
||||
if (
|
||||
repoRoot &&
|
||||
stageInstalledRootRuntimeDeps({ fingerprint, packageJson, pluginDir, repoRoot })
|
||||
) {
|
||||
return;
|
||||
}
|
||||
console.error(`[nix-openclaw] falling back to npm install for ${pluginId}`);
|
||||
const nodeModulesDir = path.join(pluginDir, "node_modules");
|
||||
const stampPath = resolveRuntimeDepsStampPath(pluginDir);
|
||||
const tempInstallDir = makeTempDir(
|
||||
os.tmpdir(),
|
||||
`openclaw-runtime-deps-${sanitizeTempPrefixSegment(pluginId)}-`,
|
||||
);
|
||||
const npmRunner = resolveNpmRunner({
|
||||
npmArgs: [
|
||||
"install",
|
||||
"--omit=dev",
|
||||
"--silent",
|
||||
"--ignore-scripts",
|
||||
"--legacy-peer-deps",
|
||||
"--package-lock=false",
|
||||
],
|
||||
});
|
||||
try {
|
||||
writeJson(path.join(tempInstallDir, "package.json"), packageJson);
|
||||
const result = spawnSync(npmRunner.command, npmRunner.args, {
|
||||
cwd: tempInstallDir,
|
||||
encoding: "utf8",
|
||||
env: npmRunner.env,
|
||||
stdio: "pipe",
|
||||
shell: npmRunner.shell,
|
||||
windowsVerbatimArguments: npmRunner.windowsVerbatimArguments,
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
const output = [result.stderr, result.stdout].filter(Boolean).join("\n").trim();
|
||||
throw new Error(
|
||||
`failed to stage bundled runtime deps for ${pluginId}: ${output || "npm install failed"}`,
|
||||
);
|
||||
}
|
||||
|
||||
const stagedNodeModulesDir = path.join(tempInstallDir, "node_modules");
|
||||
if (!fs.existsSync(stagedNodeModulesDir)) {
|
||||
throw new Error(
|
||||
`failed to stage bundled runtime deps for ${pluginId}: npm install produced no node_modules directory`,
|
||||
);
|
||||
}
|
||||
|
||||
replaceDir(nodeModulesDir, stagedNodeModulesDir);
|
||||
writeJson(stampPath, {
|
||||
fingerprint,
|
||||
generatedAt: new Date().toISOString(),
|
||||
});
|
||||
} finally {
|
||||
removePathIfExists(tempInstallDir);
|
||||
}
|
||||
}
|
||||
|
||||
function installPluginRuntimeDepsWithRetries(params) {
|
||||
const { attempts = 3 } = params;
|
||||
let lastError;
|
||||
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
||||
try {
|
||||
params.install({ ...params.installParams, attempt });
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (attempt === attempts) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
export function stageBundledPluginRuntimeDeps(params = {}) {
|
||||
const repoRoot = params.cwd ?? params.repoRoot ?? process.cwd();
|
||||
const installPluginRuntimeDepsImpl =
|
||||
params.installPluginRuntimeDepsImpl ?? installPluginRuntimeDeps;
|
||||
const installAttempts = params.installAttempts ?? 3;
|
||||
for (const pluginDir of listBundledPluginRuntimeDirs(repoRoot)) {
|
||||
const pluginId = path.basename(pluginDir);
|
||||
const packageJson = sanitizeBundledManifestForRuntimeInstall(pluginDir);
|
||||
const nodeModulesDir = path.join(pluginDir, "node_modules");
|
||||
const stampPath = resolveRuntimeDepsStampPath(pluginDir);
|
||||
if (!hasRuntimeDeps(packageJson) || !shouldStageRuntimeDeps(packageJson)) {
|
||||
removePathIfExists(nodeModulesDir);
|
||||
removePathIfExists(stampPath);
|
||||
continue;
|
||||
}
|
||||
const fingerprint = createRuntimeDepsFingerprint(packageJson);
|
||||
const stamp = readRuntimeDepsStamp(stampPath);
|
||||
if (fs.existsSync(nodeModulesDir) && stamp?.fingerprint === fingerprint) {
|
||||
continue;
|
||||
}
|
||||
installPluginRuntimeDepsWithRetries({
|
||||
attempts: installAttempts,
|
||||
install: installPluginRuntimeDepsImpl,
|
||||
installParams: {
|
||||
fingerprint,
|
||||
packageJson,
|
||||
pluginDir,
|
||||
pluginId,
|
||||
repoRoot,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
||||
stageBundledPluginRuntimeDeps();
|
||||
}
|
||||
@ -1,78 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
openclaw_build_root_file() {
|
||||
if [ -n "${OPENCLAW_BUILD_ROOT_FILE:-}" ]; then
|
||||
printf '%s\n' "$OPENCLAW_BUILD_ROOT_FILE"
|
||||
return
|
||||
fi
|
||||
|
||||
if [ -n "${NIX_BUILD_TOP:-}" ]; then
|
||||
printf '%s\n' "$NIX_BUILD_TOP/.openclaw-build-root"
|
||||
return
|
||||
fi
|
||||
|
||||
printf '%s\n' "$PWD/.openclaw-build-root"
|
||||
}
|
||||
|
||||
openclaw_init_output_build_root() {
|
||||
if [ -z "${out:-}" ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
build_root="${NIX_BUILD_TOP:-${TMPDIR:-/tmp}}/.openclaw-build"
|
||||
build_root_file="$(openclaw_build_root_file)"
|
||||
|
||||
rm -rf "$build_root"
|
||||
mkdir -p "$build_root"
|
||||
( tar -cf - . ) | ( cd "$build_root" && tar -xf - )
|
||||
chmod -R u+w "$build_root"
|
||||
printf '%s\n' "$build_root" > "$build_root_file"
|
||||
cd "$build_root"
|
||||
}
|
||||
|
||||
openclaw_enter_build_root() {
|
||||
build_root_file="$(openclaw_build_root_file)"
|
||||
if [ ! -f "$build_root_file" ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
build_root="$(cat "$build_root_file")"
|
||||
if [ -n "$build_root" ] && [ -d "$build_root" ]; then
|
||||
cd "$build_root"
|
||||
fi
|
||||
}
|
||||
|
||||
openclaw_cleanup_output_pnpm_store() {
|
||||
build_root_file="$(openclaw_build_root_file)"
|
||||
build_root=""
|
||||
store_path=""
|
||||
|
||||
if [ -f "$build_root_file" ]; then
|
||||
build_root="$(cat "$build_root_file")"
|
||||
fi
|
||||
|
||||
if [ -n "$build_root" ] && [ -f "$build_root/${PNPM_STORE_PATH_FILE:-.pnpm-store-path}" ]; then
|
||||
store_path="$(cat "$build_root/${PNPM_STORE_PATH_FILE:-.pnpm-store-path}")"
|
||||
fi
|
||||
|
||||
cd "${NIX_BUILD_TOP:-/tmp}" 2>/dev/null || cd / || true
|
||||
|
||||
case "$store_path" in
|
||||
"$out"/*) rm -rf "$store_path" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
openclaw_cleanup_output_build_root() {
|
||||
build_root_file="$(openclaw_build_root_file)"
|
||||
build_root=""
|
||||
|
||||
if [ -f "$build_root_file" ]; then
|
||||
build_root="$(cat "$build_root_file")"
|
||||
fi
|
||||
|
||||
openclaw_cleanup_output_pnpm_store
|
||||
|
||||
case "$build_root" in
|
||||
"$out"/*) rm -rf "$build_root" ;;
|
||||
esac
|
||||
}
|
||||
@ -1,103 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
const configPath = process.env.OPENCLAW_CONFIG_PATH;
|
||||
const gatewayPackage = process.env.OPENCLAW_GATEWAY;
|
||||
const expectedWorkspace = process.env.OPENCLAW_EXPECTED_WORKSPACE;
|
||||
|
||||
if (!configPath) {
|
||||
console.error("OPENCLAW_CONFIG_PATH is not set");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!gatewayPackage) {
|
||||
console.error("OPENCLAW_GATEWAY is not set");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!expectedWorkspace) {
|
||||
console.error("OPENCLAW_EXPECTED_WORKSPACE is not set");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const openclaw = path.join(gatewayPackage, "bin", "openclaw");
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-config-validity-"));
|
||||
|
||||
try {
|
||||
const env = {
|
||||
...process.env,
|
||||
HOME: path.join(tmpDir, "home"),
|
||||
XDG_CONFIG_HOME: path.join(tmpDir, "config"),
|
||||
XDG_CACHE_HOME: path.join(tmpDir, "cache"),
|
||||
XDG_DATA_HOME: path.join(tmpDir, "data"),
|
||||
OPENCLAW_CONFIG_PATH: configPath,
|
||||
OPENCLAW_STATE_DIR: path.join(tmpDir, "state"),
|
||||
OPENCLAW_LOG_DIR: path.join(tmpDir, "logs"),
|
||||
OPENCLAW_NIX_MODE: "1",
|
||||
NO_COLOR: "1",
|
||||
};
|
||||
|
||||
for (const key of [
|
||||
"HOME",
|
||||
"XDG_CONFIG_HOME",
|
||||
"XDG_CACHE_HOME",
|
||||
"XDG_DATA_HOME",
|
||||
"OPENCLAW_STATE_DIR",
|
||||
"OPENCLAW_LOG_DIR",
|
||||
]) {
|
||||
fs.mkdirSync(env[key], { recursive: true });
|
||||
}
|
||||
|
||||
const validate = spawnSync(openclaw, ["config", "validate", "--json"], {
|
||||
env,
|
||||
encoding: "utf8",
|
||||
});
|
||||
|
||||
if (validate.status !== 0) {
|
||||
if (validate.stdout) {
|
||||
process.stdout.write(validate.stdout);
|
||||
}
|
||||
if (validate.stderr) {
|
||||
process.stderr.write(validate.stderr);
|
||||
}
|
||||
console.error(`openclaw config validation failed with exit code ${validate.status ?? "unknown"}`);
|
||||
process.exit(validate.status ?? 1);
|
||||
}
|
||||
|
||||
const validation = JSON.parse(validate.stdout);
|
||||
if (!validation || validation.valid !== true) {
|
||||
console.error("openclaw config validation did not report valid=true");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const workspace = spawnSync(openclaw, ["config", "get", "agents.defaults.workspace", "--json"], {
|
||||
env,
|
||||
encoding: "utf8",
|
||||
});
|
||||
|
||||
if (workspace.status !== 0) {
|
||||
if (workspace.stdout) {
|
||||
process.stdout.write(workspace.stdout);
|
||||
}
|
||||
if (workspace.stderr) {
|
||||
process.stderr.write(workspace.stderr);
|
||||
}
|
||||
console.error(`openclaw config get failed with exit code ${workspace.status ?? "unknown"}`);
|
||||
process.exit(workspace.status ?? 1);
|
||||
}
|
||||
|
||||
const actualWorkspace = JSON.parse(workspace.stdout);
|
||||
if (actualWorkspace !== expectedWorkspace) {
|
||||
console.error(
|
||||
`openclaw config returned unexpected workspace: ${JSON.stringify(actualWorkspace)} != ${JSON.stringify(expectedWorkspace)}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("openclaw config validation: ok");
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
@ -1,59 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { execSync } from "node:child_process";
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const argValue = (flag: string): string | null => {
|
||||
const idx = args.indexOf(flag);
|
||||
if (idx === -1 || idx + 1 >= args.length) return null;
|
||||
return args[idx + 1];
|
||||
};
|
||||
|
||||
const repo = argValue("--repo") ?? process.cwd();
|
||||
const packageJsonPath = path.join(repo, "package.json");
|
||||
|
||||
const raw = fs.readFileSync(packageJsonPath, "utf8");
|
||||
const pkg = JSON.parse(raw) as { engines?: { node?: string } };
|
||||
const range = pkg.engines?.node;
|
||||
if (!range) {
|
||||
console.log("node engine check: no engines.node specified");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const nodeVersionRaw = execSync("node --version").toString().trim();
|
||||
const nodeVersion = nodeVersionRaw.replace(/^v/, "");
|
||||
|
||||
const parseVersion = (value: string): [number, number, number] => {
|
||||
const parts = value.split(".").map((v) => Number.parseInt(v, 10));
|
||||
return [parts[0] || 0, parts[1] || 0, parts[2] || 0];
|
||||
};
|
||||
|
||||
const compare = (a: [number, number, number], b: [number, number, number]): number => {
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
if (a[i] > b[i]) return 1;
|
||||
if (a[i] < b[i]) return -1;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
const match = range.match(/(\d+)(?:\.(\d+))?(?:\.(\d+))?/);
|
||||
if (!match) {
|
||||
console.log(`node engine check: unable to parse engines.node '${range}'`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const minVersion: [number, number, number] = [
|
||||
Number.parseInt(match[1] ?? "0", 10),
|
||||
Number.parseInt(match[2] ?? "0", 10),
|
||||
Number.parseInt(match[3] ?? "0", 10),
|
||||
];
|
||||
|
||||
const nodeParsed = parseVersion(nodeVersion);
|
||||
if (compare(nodeParsed, minVersion) < 0) {
|
||||
console.error(
|
||||
`node engine check failed: node ${nodeVersionRaw} does not satisfy engines.node '${range}'. Update nixpkgs/node.`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`node engine check ok: node ${nodeVersionRaw} satisfies '${range}'`);
|
||||
@ -1,35 +0,0 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
if [ -z "${OPENCLAW_PACKAGE:-}" ]; then
|
||||
echo "OPENCLAW_PACKAGE is not set" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
bin_dir="${OPENCLAW_PACKAGE}/bin"
|
||||
openclaw_bin="${bin_dir}/openclaw"
|
||||
|
||||
if [ ! -x "$openclaw_bin" ]; then
|
||||
echo "Missing executable: $openclaw_bin" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
extra_bins="$(find "$bin_dir" -mindepth 1 -maxdepth 1 -print | while IFS= read -r entry; do
|
||||
name="$(basename "$entry")"
|
||||
if [ "$name" != "openclaw" ]; then
|
||||
printf '%s\n' "$name"
|
||||
fi
|
||||
done)"
|
||||
|
||||
if [ -n "$extra_bins" ]; then
|
||||
echo "openclaw package exposes internal runtime tools in bin:" >&2
|
||||
printf '%s\n' "$extra_bins" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -q 'PATH' "$openclaw_bin"; then
|
||||
echo "openclaw wrapper does not set the internal runtime tool PATH" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "openclaw bin surface: ok"
|
||||
@ -1,85 +0,0 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
if [ -z "${OPENCLAW_PACKAGE:-}" ]; then
|
||||
echo "OPENCLAW_PACKAGE is not set" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "${QMD_PACKAGE:-}" ]; then
|
||||
echo "QMD_PACKAGE is not set" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
openclaw_bin="${OPENCLAW_PACKAGE}/bin/openclaw"
|
||||
qmd_bin="${QMD_PACKAGE}/bin/qmd"
|
||||
|
||||
if [ ! -x "$openclaw_bin" ]; then
|
||||
echo "Missing executable: $openclaw_bin" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -x "$qmd_bin" ]; then
|
||||
echo "Missing executable: $qmd_bin" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! "$qmd_bin" --version >/dev/null; then
|
||||
echo "qmd --version failed" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -q "${QMD_PACKAGE}/bin" "$openclaw_bin"; then
|
||||
echo "openclaw wrapper does not include qmd on the internal runtime PATH" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -q "OPENCLAW_PINNED_WRITE_PYTHON" "$openclaw_bin"; then
|
||||
echo "openclaw wrapper does not pin a Nix Python for safe writes" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
tmp_dir="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmp_dir"' EXIT
|
||||
|
||||
mkdir -p "$tmp_dir/home" "$tmp_dir/state" "$tmp_dir/config" "$tmp_dir/cache" "$tmp_dir/data" "$tmp_dir/logs"
|
||||
cat > "$tmp_dir/state/openclaw.json" <<'JSON'
|
||||
{
|
||||
"gateway": {
|
||||
"mode": "local"
|
||||
},
|
||||
"memory": {
|
||||
"backend": "qmd"
|
||||
}
|
||||
}
|
||||
JSON
|
||||
|
||||
env \
|
||||
HOME="$tmp_dir/home" \
|
||||
XDG_CONFIG_HOME="$tmp_dir/config" \
|
||||
XDG_CACHE_HOME="$tmp_dir/cache" \
|
||||
XDG_DATA_HOME="$tmp_dir/data" \
|
||||
OPENCLAW_CONFIG_PATH="$tmp_dir/state/openclaw.json" \
|
||||
OPENCLAW_STATE_DIR="$tmp_dir/state" \
|
||||
OPENCLAW_LOG_DIR="$tmp_dir/logs" \
|
||||
OPENCLAW_NIX_MODE=1 \
|
||||
NO_COLOR=1 \
|
||||
"$openclaw_bin" config validate --json >/dev/null
|
||||
|
||||
backend="$(
|
||||
env \
|
||||
HOME="$tmp_dir/home" \
|
||||
XDG_CONFIG_HOME="$tmp_dir/config" \
|
||||
XDG_CACHE_HOME="$tmp_dir/cache" \
|
||||
XDG_DATA_HOME="$tmp_dir/data" \
|
||||
OPENCLAW_CONFIG_PATH="$tmp_dir/state/openclaw.json" \
|
||||
OPENCLAW_STATE_DIR="$tmp_dir/state" \
|
||||
OPENCLAW_LOG_DIR="$tmp_dir/logs" \
|
||||
OPENCLAW_NIX_MODE=1 \
|
||||
NO_COLOR=1 \
|
||||
"$openclaw_bin" config get memory.backend --json
|
||||
)"
|
||||
|
||||
if [ "$backend" != '"qmd"' ]; then
|
||||
echo "OpenClaw did not read opt-in QMD memory config: $backend" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "openclaw qmd runtime: ok"
|
||||
@ -1,92 +0,0 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
if [ -z "${OPENCLAW_GATEWAY:-}" ]; then
|
||||
echo "OPENCLAW_GATEWAY is not set" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
root="${OPENCLAW_GATEWAY}/lib/openclaw"
|
||||
|
||||
require_path() {
|
||||
if [ ! -e "$1" ]; then
|
||||
echo "Missing: $1" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
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"
|
||||
require_path "${root}/docs/reference/templates/TOOLS.md"
|
||||
require_path "${root}/skills"
|
||||
require_path "${root}/node_modules/hasown"
|
||||
require_path "${root}/node_modules/combined-stream"
|
||||
|
||||
public_surface_loader="$(
|
||||
find "${root}/dist" -name "*.js" -type f -exec grep -sl "function loadBundledPluginPublicArtifactModuleSync" {} + | head -1
|
||||
)"
|
||||
if [ -z "$public_surface_loader" ]; then
|
||||
echo "Missing bundled plugin public surface loader" >&2
|
||||
exit 1
|
||||
fi
|
||||
if grep -q "rejectHardlinks: true" "$public_surface_loader"; then
|
||||
echo "Bundled plugin public surface loader still rejects hardlinked package files" >&2
|
||||
exit 1
|
||||
fi
|
||||
export PUBLIC_SURFACE_LOADER="$public_surface_loader"
|
||||
node --input-type=module <<'NODE'
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const loaderPath = process.env.PUBLIC_SURFACE_LOADER;
|
||||
if (!loaderPath) {
|
||||
throw new Error("PUBLIC_SURFACE_LOADER is not set");
|
||||
}
|
||||
|
||||
const loader = await import(pathToFileURL(loaderPath).href);
|
||||
const loadBundledPluginPublicArtifactModuleSync =
|
||||
loader.loadBundledPluginPublicArtifactModuleSync ?? loader.t;
|
||||
|
||||
if (typeof loadBundledPluginPublicArtifactModuleSync !== "function") {
|
||||
throw new Error("Bundled plugin public surface loader export not found");
|
||||
}
|
||||
|
||||
loadBundledPluginPublicArtifactModuleSync({
|
||||
dirName: "openai",
|
||||
artifactBasename: "provider-policy-api.js",
|
||||
});
|
||||
NODE
|
||||
|
||||
require_js_alias_target() {
|
||||
alias="$1"
|
||||
alias_path="${root}/dist/${alias}"
|
||||
require_path "$alias_path"
|
||||
|
||||
target="$(sed -n 's/^export \* from "\.\/\(.*\)";$/\1/p' "$alias_path" | head -1)"
|
||||
if [ -z "$target" ]; then
|
||||
echo "Alias has no export target: $alias_path" >&2
|
||||
exit 1
|
||||
fi
|
||||
require_path "${root}/dist/${target}"
|
||||
}
|
||||
|
||||
require_js_alias_target "runtime-model-auth.runtime.js"
|
||||
|
||||
if ! find "${root}/skills" -name SKILL.md -type f | grep -q .; then
|
||||
echo "Missing bundled SKILL.md files under ${root}/skills" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "openclaw package contents: ok"
|
||||
@ -1,48 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const shouldDisable = () => {
|
||||
if (process.env.OPENCLAW_DISABLE_CLIPBOARD === '1') {
|
||||
return true;
|
||||
}
|
||||
return !(
|
||||
process.env.DISPLAY ||
|
||||
process.env.WAYLAND_DISPLAY ||
|
||||
process.env.XDG_SESSION_TYPE
|
||||
);
|
||||
};
|
||||
|
||||
const fallback = (reason) => {
|
||||
const stub = {
|
||||
availableFormats: () => [],
|
||||
getText: async () => '',
|
||||
setText: async () => {},
|
||||
hasText: () => false,
|
||||
getImageBinary: async () => [],
|
||||
getImageBase64: async () => '',
|
||||
setImageBinary: async () => {},
|
||||
setImageBase64: async () => {},
|
||||
hasImage: () => false,
|
||||
getHtml: async () => '',
|
||||
setHtml: async () => {},
|
||||
hasHtml: () => false,
|
||||
getRtf: async () => '',
|
||||
setRtf: async () => {},
|
||||
hasRtf: () => false,
|
||||
clear: async () => {},
|
||||
watch: () => {},
|
||||
callThreadsafeFunction: () => {},
|
||||
__fallbackReason: reason ? String(reason) : 'clipboard-disabled',
|
||||
};
|
||||
stub.default = stub;
|
||||
return stub;
|
||||
};
|
||||
|
||||
if (shouldDisable()) {
|
||||
module.exports = fallback('DISPLAY not set');
|
||||
} else {
|
||||
try {
|
||||
module.exports = require('./index.original.js');
|
||||
} catch (err) {
|
||||
module.exports = fallback(err);
|
||||
}
|
||||
}
|
||||
@ -1,62 +0,0 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
store_path_file="${PNPM_STORE_PATH_FILE:-.pnpm-store-path}"
|
||||
if [ -f "$store_path_file" ]; then
|
||||
store_path="$(cat "$store_path_file")"
|
||||
export PNPM_STORE_DIR="$store_path"
|
||||
export PNPM_STORE_PATH="$store_path"
|
||||
export NPM_CONFIG_STORE_DIR="$store_path"
|
||||
export NPM_CONFIG_STORE_PATH="$store_path"
|
||||
fi
|
||||
|
||||
export HOME="$(mktemp -d)"
|
||||
export TMPDIR="$HOME/tmp"
|
||||
mkdir -p "$TMPDIR"
|
||||
|
||||
if [ -z "${CONFIG_OPTIONS_GENERATOR:-}" ]; then
|
||||
echo "CONFIG_OPTIONS_GENERATOR is not set" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -f "$CONFIG_OPTIONS_GENERATOR" ]; then
|
||||
echo "CONFIG_OPTIONS_GENERATOR not found: $CONFIG_OPTIONS_GENERATOR" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "${CONFIG_OPTIONS_GOLDEN:-}" ]; then
|
||||
echo "CONFIG_OPTIONS_GOLDEN is not set" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -f "$CONFIG_OPTIONS_GOLDEN" ]; then
|
||||
echo "CONFIG_OPTIONS_GOLDEN not found: $CONFIG_OPTIONS_GOLDEN" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "${NODE_ENGINE_CHECK:-}" ]; then
|
||||
echo "NODE_ENGINE_CHECK is not set" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -f "$NODE_ENGINE_CHECK" ]; then
|
||||
echo "NODE_ENGINE_CHECK not found: $NODE_ENGINE_CHECK" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cp "$CONFIG_OPTIONS_GENERATOR" ./generate-config-options.ts
|
||||
cp "$NODE_ENGINE_CHECK" ./check-node-engine.ts
|
||||
|
||||
if ! command -v node >/dev/null 2>&1; then
|
||||
echo "node not found in PATH (run source-checks-build.sh first)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
tsx_cli="./node_modules/tsx/dist/cli.mjs"
|
||||
if [ ! -f "$tsx_cli" ]; then
|
||||
echo "tsx CLI not found at $tsx_cli (run source-checks-build.sh first)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
node "$tsx_cli" ./check-node-engine.ts --repo .
|
||||
|
||||
output_path="./generated-config-options.nix"
|
||||
|
||||
node "$tsx_cli" ./generate-config-options.ts --repo . --out "$output_path"
|
||||
|
||||
diff -u "$CONFIG_OPTIONS_GOLDEN" "$output_path"
|
||||
@ -1,3 +0,0 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
install -Dm755 "$src" "$out/bin/openclaw-entrypoint"
|
||||
@ -1,3 +0,0 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
mkdir -p "$out"
|
||||
@ -1,129 +0,0 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
log_step() {
|
||||
if [ "${OPENCLAW_NIX_TIMINGS:-1}" != "1" ]; then
|
||||
"$@"
|
||||
return
|
||||
fi
|
||||
|
||||
name="$1"
|
||||
shift
|
||||
|
||||
start=$(date +%s)
|
||||
printf '>> [timing] %s...\n' "$name" >&2
|
||||
"$@"
|
||||
end=$(date +%s)
|
||||
printf '>> [timing] %s: %ss\n' "$name" "$((end - start))" >&2
|
||||
}
|
||||
|
||||
if [ -z "${GATEWAY_PREBUILD_SH:-}" ]; then
|
||||
echo "GATEWAY_PREBUILD_SH is not set" >&2
|
||||
exit 1
|
||||
fi
|
||||
. "$GATEWAY_PREBUILD_SH"
|
||||
if [ -z "${STDENV_SETUP:-}" ]; then
|
||||
echo "STDENV_SETUP is not set" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -f "$STDENV_SETUP" ]; then
|
||||
echo "STDENV_SETUP not found: $STDENV_SETUP" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
store_path_file="${PNPM_STORE_PATH_FILE:-.pnpm-store-path}"
|
||||
if [ ! -f "$store_path_file" ]; then
|
||||
echo "pnpm store path file missing: $store_path_file" >&2
|
||||
exit 1
|
||||
fi
|
||||
store_path="$(cat "$store_path_file")"
|
||||
export PNPM_STORE_DIR="$store_path"
|
||||
export PNPM_STORE_PATH="$store_path"
|
||||
export NPM_CONFIG_STORE_DIR="$store_path"
|
||||
export NPM_CONFIG_STORE_PATH="$store_path"
|
||||
export HOME="$(mktemp -d)"
|
||||
|
||||
log_step "pnpm install (offline, frozen, ignore-scripts)" pnpm install --offline --frozen-lockfile --ignore-scripts --store-dir "$store_path"
|
||||
|
||||
log_step "chmod node_modules writable" chmod -R u+w node_modules
|
||||
|
||||
# sharp may leave build artifacts around; remove to keep output smaller + avoid stale builds.
|
||||
rm -rf node_modules/.pnpm/sharp@*/node_modules/sharp/src/build
|
||||
|
||||
# Rebuild only native deps (avoid `pnpm rebuild` over the entire workspace).
|
||||
# node-llama-cpp postinstall attempts to download/compile llama.cpp (network blocked in Nix).
|
||||
# Also defensively disable other common downloaders.
|
||||
rebuild_list="$(jq -r '.pnpm.onlyBuiltDependencies // [] | .[]' package.json 2>/dev/null || true)"
|
||||
if [ -n "$rebuild_list" ]; then
|
||||
log_step "pnpm rebuild (onlyBuiltDependencies)" env \
|
||||
NODE_LLAMA_CPP_SKIP_DOWNLOAD=1 \
|
||||
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 \
|
||||
PUPPETEER_SKIP_DOWNLOAD=1 \
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD=1 \
|
||||
pnpm rebuild $rebuild_list
|
||||
else
|
||||
log_step "pnpm rebuild (all)" env \
|
||||
NODE_LLAMA_CPP_SKIP_DOWNLOAD=1 \
|
||||
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 \
|
||||
PUPPETEER_SKIP_DOWNLOAD=1 \
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD=1 \
|
||||
pnpm rebuild
|
||||
fi
|
||||
|
||||
log_step "patchShebangs node_modules/.bin" bash -e -c ". \"$STDENV_SETUP\"; patchShebangs node_modules/.bin"
|
||||
|
||||
# Git tarball dependencies do not get their npm prepack output in offline Nix
|
||||
# builds. OpenClaw currently depends on @openclaw/fs-safe this way.
|
||||
if [ -n "${OPENCLAW_FS_SAFE_SOURCE:-}" ] && [ ! -d "node_modules/@openclaw/fs-safe/dist" ]; then
|
||||
rm -rf node_modules/@openclaw/fs-safe
|
||||
mkdir -p node_modules/@openclaw
|
||||
cp -R "$OPENCLAW_FS_SAFE_SOURCE" node_modules/@openclaw/fs-safe
|
||||
chmod -R u+w node_modules/@openclaw/fs-safe
|
||||
log_step "build dependency: @openclaw/fs-safe" pnpm exec tsc -p node_modules/@openclaw/fs-safe/tsconfig.json
|
||||
fi
|
||||
|
||||
# Ensure rolldown is found from workspace bins in offline/sandbox builds.
|
||||
if [ -d "node_modules/.pnpm/node_modules/.bin" ]; then
|
||||
export PATH="$PWD/node_modules/.pnpm/node_modules/.bin:$PATH"
|
||||
fi
|
||||
|
||||
# Break down `pnpm build` (upstream package.json) so we can profile it while
|
||||
# still using upstream's asset hooks. v2026.5.7 has the older canvas-only helper;
|
||||
# newer OpenClaw has the generic bundled-plugin asset runner.
|
||||
if [ -f "scripts/bundled-plugin-assets.mjs" ]; then
|
||||
log_step "build: plugins:assets:build" node scripts/bundled-plugin-assets.mjs --phase build
|
||||
else
|
||||
log_step "build: canvas:a2ui:bundle" node scripts/bundle-a2ui.mjs
|
||||
fi
|
||||
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
|
||||
log_step "build: copy-plugin-sdk-root-alias" node scripts/copy-plugin-sdk-root-alias.mjs
|
||||
fi
|
||||
if [ -f "scripts/copy-bundled-plugin-metadata.mjs" ]; then
|
||||
log_step "build: copy-bundled-plugin-metadata" node scripts/copy-bundled-plugin-metadata.mjs
|
||||
fi
|
||||
if [ -f "scripts/bundled-plugin-assets.mjs" ]; then
|
||||
log_step "build: plugins:assets:copy" node scripts/bundled-plugin-assets.mjs --phase copy
|
||||
else
|
||||
log_step "build: canvas-a2ui-copy" node --import tsx scripts/canvas-a2ui-copy.ts
|
||||
fi
|
||||
log_step "build: copy-hook-metadata" node --import tsx scripts/copy-hook-metadata.ts
|
||||
log_step "build: write-build-info" node --import tsx scripts/write-build-info.ts
|
||||
log_step "build: write-cli-compat" node --import tsx scripts/write-cli-compat.ts
|
||||
|
||||
log_step "ui:build" pnpm ui:build
|
||||
|
||||
log_step "pnpm prune --prod" env CI=true pnpm prune --prod
|
||||
|
||||
# Reduce output size (pnpm implementation detail; safe to remove)
|
||||
rm -rf node_modules/.pnpm/node_modules
|
||||
|
||||
# pnpm prune can leave orphaned .bin links behind for removed prod deps.
|
||||
# Keep install-phase symlink validation strict by dropping only broken links here.
|
||||
find node_modules -xtype l -delete
|
||||
@ -1,162 +0,0 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
log_step() {
|
||||
if [ "${OPENCLAW_NIX_TIMINGS:-1}" != "1" ]; then
|
||||
"$@"
|
||||
return
|
||||
fi
|
||||
|
||||
name="$1"
|
||||
shift
|
||||
|
||||
start=$(date +%s)
|
||||
printf '>> [timing] %s...\n' "$name" >&2
|
||||
"$@"
|
||||
end=$(date +%s)
|
||||
printf '>> [timing] %s: %ss\n' "$name" "$((end - start))" >&2
|
||||
}
|
||||
|
||||
if [ -n "${OPENCLAW_BUILD_ROOT_SH:-}" ]; then
|
||||
. "$OPENCLAW_BUILD_ROOT_SH"
|
||||
openclaw_enter_build_root
|
||||
fi
|
||||
|
||||
check_no_broken_symlinks() {
|
||||
root="$1"
|
||||
if [ ! -d "$root" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
broken_tmp="$(mktemp)"
|
||||
# Portable and faster than `find ... -exec test -e {} \;` on large trees.
|
||||
find "$root" -type l -print | while IFS= read -r link; do
|
||||
[ -e "$link" ] || printf '%s\n' "$link"
|
||||
done > "$broken_tmp"
|
||||
if [ -s "$broken_tmp" ]; then
|
||||
echo "dangling symlinks found under $root" >&2
|
||||
cat "$broken_tmp" >&2
|
||||
rm -f "$broken_tmp"
|
||||
return 1
|
||||
fi
|
||||
rm -f "$broken_tmp"
|
||||
}
|
||||
|
||||
copy_extension_manifests() {
|
||||
if [ ! -d extensions ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
mkdir -p "$out/lib/openclaw/extensions"
|
||||
find extensions -mindepth 2 -maxdepth 2 -name openclaw.plugin.json -type f -print | while IFS= read -r manifest; do
|
||||
name="$(basename "$(dirname "$manifest")")"
|
||||
mkdir -p "$out/lib/openclaw/extensions/$name"
|
||||
cp "$manifest" "$out/lib/openclaw/extensions/$name/openclaw.plugin.json"
|
||||
done
|
||||
}
|
||||
|
||||
mkdir -p "$out/lib/openclaw" "$out/bin"
|
||||
|
||||
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
|
||||
if [ -d skills ]; then
|
||||
log_step "copy bundled skills" cp -r skills "$out/lib/openclaw/"
|
||||
fi
|
||||
|
||||
# Gateway plugin discovery looks under dist/extensions/*/openclaw.plugin.json.
|
||||
# Upstream's build emits JS into dist/extensions but leaves manifests in extensions/.
|
||||
if [ -d "$out/lib/openclaw/extensions" ] && [ -d "$out/lib/openclaw/dist/extensions" ]; then
|
||||
for manifest in "$out/lib/openclaw/extensions"/*/openclaw.plugin.json; do
|
||||
[ -f "$manifest" ] || continue
|
||||
name="$(basename "$(dirname "$manifest")")"
|
||||
dist_ext="$out/lib/openclaw/dist/extensions/$name"
|
||||
if [ -d "$dist_ext" ] && [ ! -f "$dist_ext/openclaw.plugin.json" ]; then
|
||||
cp "$manifest" "$dist_ext/openclaw.plugin.json"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [ -d docs/reference/templates ]; then
|
||||
mkdir -p "$out/lib/openclaw/docs/reference"
|
||||
log_step "copy reference templates" cp -r docs/reference/templates "$out/lib/openclaw/docs/reference/"
|
||||
fi
|
||||
|
||||
if [ -z "${STDENV_SETUP:-}" ]; then
|
||||
echo "STDENV_SETUP is not set" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -f "$STDENV_SETUP" ]; then
|
||||
echo "STDENV_SETUP not found: $STDENV_SETUP" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Work around missing dependency declaration in pi-coding-agent (strip-ansi).
|
||||
# Ensure it is resolvable at runtime without changing upstream.
|
||||
pi_pkg="$(find "$out/lib/openclaw/node_modules/.pnpm" -path "*/node_modules/@mariozechner/pi-coding-agent" -print | head -n 1)"
|
||||
strip_ansi_src="$(find "$out/lib/openclaw/node_modules/.pnpm" -path "*/node_modules/strip-ansi" -print | head -n 1)"
|
||||
|
||||
if [ -n "$strip_ansi_src" ]; then
|
||||
if [ -n "$pi_pkg" ] && [ ! -e "$pi_pkg/node_modules/strip-ansi" ]; then
|
||||
mkdir -p "$pi_pkg/node_modules"
|
||||
ln -s "$strip_ansi_src" "$pi_pkg/node_modules/strip-ansi"
|
||||
fi
|
||||
|
||||
if [ ! -e "$out/lib/openclaw/node_modules/strip-ansi" ]; then
|
||||
mkdir -p "$out/lib/openclaw/node_modules"
|
||||
ln -s "$strip_ansi_src" "$out/lib/openclaw/node_modules/strip-ansi"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -n "${PATCH_CLIPBOARD_SH:-}" ]; then
|
||||
"$PATCH_CLIPBOARD_SH" "$out/lib/openclaw" "$PATCH_CLIPBOARD_WRAPPER"
|
||||
fi
|
||||
|
||||
# Work around missing combined-stream dependency for form-data in pnpm layout.
|
||||
combined_stream_src="$(find "$out/lib/openclaw/node_modules/.pnpm" -path "*/combined-stream@*/node_modules/combined-stream" -print | head -n 1)"
|
||||
form_data_pkgs="$(find "$out/lib/openclaw/node_modules/.pnpm" -path "*/node_modules/form-data" -print)"
|
||||
if [ -n "$combined_stream_src" ]; then
|
||||
if [ ! -e "$out/lib/openclaw/node_modules/combined-stream" ]; then
|
||||
ln -s "$combined_stream_src" "$out/lib/openclaw/node_modules/combined-stream"
|
||||
fi
|
||||
if [ -n "$form_data_pkgs" ]; then
|
||||
for pkg in $form_data_pkgs; do
|
||||
if [ ! -e "$pkg/node_modules/combined-stream" ]; then
|
||||
mkdir -p "$pkg/node_modules"
|
||||
ln -s "$combined_stream_src" "$pkg/node_modules/combined-stream"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
fi
|
||||
|
||||
# Work around missing hasown dependency for form-data in pnpm layout.
|
||||
hasown_src="$(find "$out/lib/openclaw/node_modules/.pnpm" -path "*/hasown@*/node_modules/hasown" -print | head -n 1)"
|
||||
if [ -n "$hasown_src" ]; then
|
||||
if [ ! -e "$out/lib/openclaw/node_modules/hasown" ]; then
|
||||
ln -s "$hasown_src" "$out/lib/openclaw/node_modules/hasown"
|
||||
fi
|
||||
if [ -n "$form_data_pkgs" ]; then
|
||||
for pkg in $form_data_pkgs; do
|
||||
if [ ! -e "$pkg/node_modules/hasown" ]; then
|
||||
mkdir -p "$pkg/node_modules"
|
||||
ln -s "$hasown_src" "$pkg/node_modules/hasown"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -n "${OPENCLAW_BUILD_ROOT_SH:-}" ]; then
|
||||
openclaw_cleanup_output_pnpm_store
|
||||
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"'
|
||||
@ -1,114 +0,0 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
if [ -f package.json ]; then
|
||||
"$REMOVE_PACKAGE_MANAGER_FIELD_SH" package.json
|
||||
fi
|
||||
|
||||
if [ -n "${PATCH_BUNDLED_RUNTIME_DEPS_SCRIPT:-}" ] && [ -f scripts/stage-bundled-plugin-runtime-deps.mjs ]; then
|
||||
cp "$PATCH_BUNDLED_RUNTIME_DEPS_SCRIPT" scripts/stage-bundled-plugin-runtime-deps.mjs
|
||||
chmod u+w scripts/stage-bundled-plugin-runtime-deps.mjs
|
||||
fi
|
||||
|
||||
if [ -n "${PATCH_PUBLIC_SURFACE_HARDLINKS:-}" ]; then
|
||||
patch -p1 < "$PATCH_PUBLIC_SURFACE_HARDLINKS"
|
||||
fi
|
||||
|
||||
if [ -n "${PATCH_SKIP_PLUGIN_AUTO_ENABLE_NIX_MODE:-}" ]; then
|
||||
patch -p1 < "$PATCH_SKIP_PLUGIN_AUTO_ENABLE_NIX_MODE"
|
||||
fi
|
||||
|
||||
if [ -f src/logging/logger.ts ]; then
|
||||
if ! grep -q "OPENCLAW_LOG_DIR" src/logging/logger.ts; then
|
||||
sed -i 's/export const DEFAULT_LOG_DIR = "\/tmp\/openclaw";/export const DEFAULT_LOG_DIR = process.env.OPENCLAW_LOG_DIR ?? "\/tmp\/openclaw";/' src/logging/logger.ts
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -f src/agents/shell-utils.ts ]; then
|
||||
if ! grep -q "envShell" src/agents/shell-utils.ts; then
|
||||
awk '
|
||||
/import { spawn } from "node:child_process";/ {
|
||||
print;
|
||||
print "import { existsSync } from \"node:fs\";";
|
||||
next;
|
||||
}
|
||||
/const shell = process.env.SHELL/ {
|
||||
print " const envShell = process.env.SHELL?.trim();";
|
||||
print " const shell =";
|
||||
print " envShell && envShell.startsWith(\"/\") && !existsSync(envShell)";
|
||||
print " ? \"sh\"";
|
||||
print " : envShell || \"sh\";";
|
||||
next;
|
||||
}
|
||||
{ print }
|
||||
' src/agents/shell-utils.ts > src/agents/shell-utils.ts.next
|
||||
mv src/agents/shell-utils.ts.next src/agents/shell-utils.ts
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -f src/docker-setup.test.ts ]; then
|
||||
if ! grep -q "#!/bin/sh" src/docker-setup.test.ts; then
|
||||
sed -i 's|#!/usr/bin/env bash|#!/bin/sh|' src/docker-setup.test.ts
|
||||
sed -i 's/set -euo pipefail/set -eu/' src/docker-setup.test.ts
|
||||
sed -i 's|if \[\[ "${1:-}" == "compose" && "${2:-}" == "version" \]\]; then|if [ "${1:-}" = "compose" ] && [ "${2:-}" = "version" ]; then|' src/docker-setup.test.ts
|
||||
sed -i 's|if \[\[ "${1:-}" == "build" \]\]; then|if [ "${1:-}" = "build" ]; then|' src/docker-setup.test.ts
|
||||
sed -i 's|if \[\[ "${1:-}" == "compose" \]\]; then|if [ "${1:-}" = "compose" ]; then|' src/docker-setup.test.ts
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -f src/gateway/test-helpers.mocks.ts ]; then
|
||||
if ! grep -q 'augmentModelCatalogWithProviderPlugins: async () => \[\]' src/gateway/test-helpers.mocks.ts; then
|
||||
python3 - <<'PY'
|
||||
from pathlib import Path
|
||||
path = Path("src/gateway/test-helpers.mocks.ts")
|
||||
text = path.read_text()
|
||||
needle = '''vi.mock("../plugins/loader.js", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("../plugins/loader.js")>("../plugins/loader.js");
|
||||
return {
|
||||
...actual,
|
||||
loadOpenClawPlugins: () => getTestPluginRegistry(),
|
||||
};
|
||||
});
|
||||
'''
|
||||
replacement = needle + '''
|
||||
vi.mock("../plugins/provider-runtime.runtime.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../plugins/provider-runtime.runtime.js")>(
|
||||
"../plugins/provider-runtime.runtime.js",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
augmentModelCatalogWithProviderPlugins: async () => [],
|
||||
};
|
||||
});
|
||||
vi.mock("../plugins/web-search-providers.runtime.js", () => ({
|
||||
resolvePluginWebSearchProviders: () => [],
|
||||
resolveRuntimeWebSearchProviders: () => [],
|
||||
__testing: {
|
||||
resetWebSearchProviderSnapshotCacheForTests: () => {},
|
||||
},
|
||||
}));
|
||||
vi.mock("../plugins/web-fetch-providers.runtime.js", () => ({
|
||||
resolvePluginWebFetchProviders: () => [],
|
||||
resolveRuntimeWebFetchProviders: () => [],
|
||||
__testing: {
|
||||
resetWebFetchProviderSnapshotCacheForTests: () => {},
|
||||
},
|
||||
}));
|
||||
vi.mock("../plugins/web-provider-public-artifacts.explicit.js", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("../plugins/web-provider-public-artifacts.explicit.js")>(
|
||||
"../plugins/web-provider-public-artifacts.explicit.js",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
resolveBundledExplicitWebSearchProvidersFromPublicArtifacts: () => [],
|
||||
resolveBundledExplicitWebFetchProvidersFromPublicArtifacts: () => [],
|
||||
};
|
||||
});
|
||||
'''
|
||||
if needle not in text:
|
||||
raise SystemExit("gateway test mocks loader marker not found")
|
||||
path.write_text(text.replace(needle, replacement, 1))
|
||||
PY
|
||||
fi
|
||||
fi
|
||||
@ -1,61 +0,0 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
log_step() {
|
||||
if [ "${OPENCLAW_NIX_TIMINGS:-1}" != "1" ]; then
|
||||
"$@"
|
||||
return
|
||||
fi
|
||||
|
||||
name="$1"
|
||||
shift
|
||||
|
||||
start=$(date +%s)
|
||||
printf '>> [timing] %s...\n' "$name" >&2
|
||||
"$@"
|
||||
end=$(date +%s)
|
||||
printf '>> [timing] %s: %ss\n' "$name" "$((end - start))" >&2
|
||||
}
|
||||
|
||||
store_path_file="${PNPM_STORE_PATH_FILE:-.pnpm-store-path}"
|
||||
|
||||
if [ -n "${OPENCLAW_BUILD_ROOT_SH:-}" ]; then
|
||||
. "$OPENCLAW_BUILD_ROOT_SH"
|
||||
openclaw_init_output_build_root
|
||||
fi
|
||||
|
||||
if [ -n "${out:-}" ]; then
|
||||
store_path="$out/.pnpm-store"
|
||||
rm -rf "$store_path"
|
||||
mkdir -p "$store_path"
|
||||
else
|
||||
store_path="$(mktemp -d)"
|
||||
fi
|
||||
|
||||
printf "%s" "$store_path" > "$store_path_file"
|
||||
|
||||
fetcherVersion=$(cat "$PNPM_DEPS/.fetcher-version" 2>/dev/null || echo 1)
|
||||
if [ "$fetcherVersion" -ge 3 ]; then
|
||||
# tar --zstd uses libzstd; on some platforms it ends up single-threaded.
|
||||
# Use zstd directly, bounded by Nix's build-core budget.
|
||||
zstd_threads="${NIX_BUILD_CORES:-2}"
|
||||
case "$zstd_threads" in
|
||||
''|*[!0-9]*) zstd_threads=2 ;;
|
||||
esac
|
||||
log_step "extract pnpm store (fetcherVersion=${fetcherVersion})" sh -c '
|
||||
zstd -d --threads="$3" < "$1" | tar -xf - -C "$2"
|
||||
' sh "$PNPM_DEPS/pnpm-store.tar.zst" "$store_path" "$zstd_threads"
|
||||
else
|
||||
log_step "copy pnpm store (fetcherVersion=${fetcherVersion})" cp -Tr "$PNPM_DEPS" "$store_path"
|
||||
fi
|
||||
|
||||
log_step "chmod pnpm store writable" chmod -R +w "$store_path"
|
||||
|
||||
# pnpm --ignore-scripts marks tarball deps as "not built" and offline install
|
||||
# later refuses to use them; if a dep doesn't require build, promote it.
|
||||
log_step "promote pnpm integrity" "$PROMOTE_PNPM_INTEGRITY_SH" "$store_path"
|
||||
|
||||
export REAL_NODE_GYP="$(command -v node-gyp)"
|
||||
wrapper_dir="$(mktemp -d)"
|
||||
install -Dm755 "$NODE_GYP_WRAPPER_SH" "$wrapper_dir/node-gyp"
|
||||
export PATH="$wrapper_dir:$PATH"
|
||||
@ -1,202 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import net from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { once } from "node:events";
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
|
||||
const gatewayPackage = process.env.OPENCLAW_GATEWAY;
|
||||
|
||||
if (!gatewayPackage) {
|
||||
console.error("OPENCLAW_GATEWAY is not set");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const openclaw = path.join(gatewayPackage, "bin", "openclaw");
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-gateway-smoke-"));
|
||||
const token = `smoke-${crypto.randomUUID()}`;
|
||||
const logs = { stdout: "", stderr: "" };
|
||||
|
||||
function appendLog(name, chunk) {
|
||||
logs[name] += chunk.toString();
|
||||
if (logs[name].length > 12000) {
|
||||
logs[name] = logs[name].slice(-12000);
|
||||
}
|
||||
}
|
||||
|
||||
async function freePort() {
|
||||
const server = net.createServer();
|
||||
server.listen(0, "127.0.0.1");
|
||||
await once(server, "listening");
|
||||
const address = server.address();
|
||||
const port = typeof address === "object" && address ? address.port : null;
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
if (!port) {
|
||||
throw new Error("failed to allocate a local port");
|
||||
}
|
||||
return port;
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function stopProcess(child) {
|
||||
if (child.exitCode !== null || child.signalCode !== null) {
|
||||
return;
|
||||
}
|
||||
child.kill("SIGTERM");
|
||||
const stopped = await Promise.race([
|
||||
once(child, "exit").then(() => true),
|
||||
sleep(3000).then(() => false),
|
||||
]);
|
||||
if (!stopped) {
|
||||
child.kill("SIGKILL");
|
||||
await once(child, "exit").catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
function isolatedEnv() {
|
||||
const env = {
|
||||
...process.env,
|
||||
HOME: path.join(tmpDir, "home"),
|
||||
XDG_CONFIG_HOME: path.join(tmpDir, "config"),
|
||||
XDG_CACHE_HOME: path.join(tmpDir, "cache"),
|
||||
XDG_DATA_HOME: path.join(tmpDir, "data"),
|
||||
OPENCLAW_CONFIG_PATH: path.join(tmpDir, "state", "openclaw.json"),
|
||||
OPENCLAW_STATE_DIR: path.join(tmpDir, "state"),
|
||||
OPENCLAW_LOG_DIR: path.join(tmpDir, "logs"),
|
||||
OPENCLAW_NIX_MODE: "1",
|
||||
NO_COLOR: "1",
|
||||
};
|
||||
|
||||
for (const key of [
|
||||
"HOME",
|
||||
"XDG_CONFIG_HOME",
|
||||
"XDG_CACHE_HOME",
|
||||
"XDG_DATA_HOME",
|
||||
"OPENCLAW_STATE_DIR",
|
||||
"OPENCLAW_LOG_DIR",
|
||||
]) {
|
||||
fs.mkdirSync(env[key], { recursive: true });
|
||||
}
|
||||
|
||||
return env;
|
||||
}
|
||||
|
||||
const env = isolatedEnv();
|
||||
let gateway = null;
|
||||
let gatewayHealthy = false;
|
||||
|
||||
try {
|
||||
const version = spawnSync(openclaw, ["--version"], {
|
||||
env,
|
||||
encoding: "utf8",
|
||||
});
|
||||
if (version.status !== 0 || !version.stdout.trim()) {
|
||||
process.stdout.write(version.stdout ?? "");
|
||||
process.stderr.write(version.stderr ?? "");
|
||||
throw new Error("openclaw --version failed");
|
||||
}
|
||||
|
||||
const port = await freePort();
|
||||
gateway = spawn(
|
||||
openclaw,
|
||||
[
|
||||
"gateway",
|
||||
"run",
|
||||
"--allow-unconfigured",
|
||||
"--bind",
|
||||
"loopback",
|
||||
"--port",
|
||||
String(port),
|
||||
"--auth",
|
||||
"token",
|
||||
"--token",
|
||||
token,
|
||||
"--ws-log",
|
||||
"compact",
|
||||
],
|
||||
{
|
||||
cwd: tmpDir,
|
||||
env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
},
|
||||
);
|
||||
|
||||
gateway.stdout.on("data", (chunk) => appendLog("stdout", chunk));
|
||||
gateway.stderr.on("data", (chunk) => appendLog("stderr", chunk));
|
||||
|
||||
const deadline = Date.now() + 30000;
|
||||
let lastError = "";
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
if (gateway.exitCode !== null || gateway.signalCode !== null) {
|
||||
throw new Error(`gateway exited before health check: ${gateway.exitCode ?? gateway.signalCode}`);
|
||||
}
|
||||
|
||||
const health = spawnSync(
|
||||
openclaw,
|
||||
[
|
||||
"gateway",
|
||||
"health",
|
||||
"--url",
|
||||
`ws://127.0.0.1:${port}`,
|
||||
"--token",
|
||||
token,
|
||||
"--json",
|
||||
"--timeout",
|
||||
"3000",
|
||||
],
|
||||
{
|
||||
cwd: tmpDir,
|
||||
env,
|
||||
encoding: "utf8",
|
||||
},
|
||||
);
|
||||
|
||||
if (health.status === 0) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(health.stdout);
|
||||
} catch (err) {
|
||||
lastError = `health returned invalid JSON: ${health.stdout}${health.stderr}`;
|
||||
await sleep(500);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (parsed?.ok === true) {
|
||||
console.log(`openclaw gateway smoke: ok (${version.stdout.trim()})`);
|
||||
gatewayHealthy = true;
|
||||
break;
|
||||
}
|
||||
lastError = `health JSON did not contain ok=true: ${health.stdout}`;
|
||||
} else {
|
||||
lastError = `${health.stdout}${health.stderr}`;
|
||||
}
|
||||
|
||||
await sleep(500);
|
||||
}
|
||||
|
||||
if (!gatewayHealthy) {
|
||||
throw new Error(`gateway health did not become ready: ${lastError.trim()}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(String(err));
|
||||
if (logs.stdout.trim()) {
|
||||
console.error("--- gateway stdout ---");
|
||||
console.error(logs.stdout.trim());
|
||||
}
|
||||
if (logs.stderr.trim()) {
|
||||
console.error("--- gateway stderr ---");
|
||||
console.error(logs.stderr.trim());
|
||||
}
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
if (gateway) {
|
||||
await stopProcess(gateway);
|
||||
}
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
@ -1,352 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const argValue = (flag: string): string | null => {
|
||||
const idx = args.indexOf(flag);
|
||||
if (idx === -1 || idx + 1 >= args.length) return null;
|
||||
return args[idx + 1];
|
||||
};
|
||||
|
||||
const repo = argValue("--repo") ?? process.cwd();
|
||||
const outPath = argValue("--out") ?? path.join(process.cwd(), "nix/generated/openclaw-config-options.nix");
|
||||
const schemaRev = argValue("--rev") ?? process.env.OPENCLAW_SCHEMA_REV ?? null;
|
||||
|
||||
const schemaPath = path.join(repo, "src/config/zod-schema.ts");
|
||||
const schemaUrl = pathToFileURL(schemaPath).href;
|
||||
|
||||
const loadSchema = async (): Promise<Record<string, unknown>> => {
|
||||
const mod = await import(schemaUrl);
|
||||
const schema = mod.OpenClawSchema;
|
||||
if (!schema || typeof schema.toJSONSchema !== "function") {
|
||||
console.error(`OpenClawSchema not found at ${schemaPath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
return schema.toJSONSchema({
|
||||
target: "draft-07",
|
||||
unrepresentable: "any",
|
||||
}) as Record<string, unknown>;
|
||||
};
|
||||
|
||||
const main = async (): Promise<void> => {
|
||||
const schema = await loadSchema();
|
||||
const definitions: Record<string, unknown> =
|
||||
(schema.definitions as Record<string, unknown>) ||
|
||||
(schema.$defs as Record<string, unknown>) ||
|
||||
{};
|
||||
|
||||
const stringify = (value: string): string => {
|
||||
const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"");
|
||||
return `"${escaped}"`;
|
||||
};
|
||||
|
||||
const nixAttr = (key: string): string => {
|
||||
if (/^[A-Za-z_][A-Za-z0-9_']*$/.test(key)) return key;
|
||||
return stringify(key);
|
||||
};
|
||||
|
||||
const nixLiteral = (value: unknown): string => {
|
||||
if (value === null) return "null";
|
||||
if (typeof value === "string") return stringify(value);
|
||||
if (typeof value === "number") return Number.isFinite(value) ? String(value) : "null";
|
||||
if (typeof value === "boolean") return value ? "true" : "false";
|
||||
if (Array.isArray(value)) {
|
||||
return `[ ${value.map(nixLiteral).join(" ")} ]`;
|
||||
}
|
||||
return "null";
|
||||
};
|
||||
|
||||
type JsonSchema = Record<string, unknown>;
|
||||
|
||||
const resolveRef = (ref: string): JsonSchema | null => {
|
||||
const prefixDefs = "#/definitions/";
|
||||
const prefixDefsAlt = "#/$defs/";
|
||||
if (ref.startsWith(prefixDefs)) {
|
||||
const name = ref.slice(prefixDefs.length);
|
||||
return (definitions[name] as JsonSchema) || null;
|
||||
}
|
||||
if (ref.startsWith(prefixDefsAlt)) {
|
||||
const name = ref.slice(prefixDefsAlt.length);
|
||||
return (definitions[name] as JsonSchema) || null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const deref = (input: JsonSchema, seen: Set<string>): JsonSchema => {
|
||||
if (input.$ref && typeof input.$ref === "string") {
|
||||
const ref = input.$ref as string;
|
||||
if (seen.has(ref)) {
|
||||
return {};
|
||||
}
|
||||
const resolved = resolveRef(ref);
|
||||
if (!resolved) return {};
|
||||
const nextSeen = new Set(seen);
|
||||
nextSeen.add(ref);
|
||||
return deref(resolved, nextSeen);
|
||||
}
|
||||
return input;
|
||||
};
|
||||
|
||||
const isNullSchema = (value: unknown): boolean => {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const schemaObj = value as JsonSchema;
|
||||
if (schemaObj.type === "null") return true;
|
||||
if (Array.isArray(schemaObj.type)) return schemaObj.type.includes("null");
|
||||
return false;
|
||||
};
|
||||
|
||||
const stripNullable = (schemaObj: JsonSchema): { schema: JsonSchema; nullable: boolean } => {
|
||||
const schema = deref(schemaObj, new Set());
|
||||
if (schema.anyOf && Array.isArray(schema.anyOf)) {
|
||||
const entries = schema.anyOf as JsonSchema[];
|
||||
const nullable = entries.some(isNullSchema);
|
||||
const next = entries.filter((entry) => !isNullSchema(entry));
|
||||
return {
|
||||
schema: { ...schema, anyOf: next },
|
||||
nullable,
|
||||
};
|
||||
}
|
||||
if (schema.oneOf && Array.isArray(schema.oneOf)) {
|
||||
const entries = schema.oneOf as JsonSchema[];
|
||||
const nullable = entries.some(isNullSchema);
|
||||
const next = entries.filter((entry) => !isNullSchema(entry));
|
||||
return {
|
||||
schema: { ...schema, oneOf: next },
|
||||
nullable,
|
||||
};
|
||||
}
|
||||
if (Array.isArray(schema.type)) {
|
||||
const nullable = schema.type.includes("null");
|
||||
const nextTypes = schema.type.filter((t) => t !== "null");
|
||||
const nextSchema = { ...schema };
|
||||
if (nextTypes.length === 1) {
|
||||
nextSchema.type = nextTypes[0];
|
||||
} else {
|
||||
nextSchema.type = nextTypes;
|
||||
}
|
||||
return { schema: nextSchema, nullable };
|
||||
}
|
||||
return { schema, nullable: false };
|
||||
};
|
||||
|
||||
const typeForSchema = (schemaObj: JsonSchema, indent: string, pathSegments: string[] = []): string => {
|
||||
const { schema, nullable } = stripNullable(schemaObj);
|
||||
const typeExpr = baseTypeForSchema(schema, indent, pathSegments);
|
||||
if (nullable) {
|
||||
return `t.nullOr (${typeExpr})`;
|
||||
}
|
||||
return typeExpr;
|
||||
};
|
||||
|
||||
const baseTypeForSchema = (schemaObj: JsonSchema, indent: string, pathSegments: string[]): string => {
|
||||
const schema = deref(schemaObj, new Set());
|
||||
if (schema.const !== undefined) {
|
||||
return `t.enum [ ${nixLiteral(schema.const)} ]`;
|
||||
}
|
||||
if (Array.isArray(schema.enum)) {
|
||||
const values = schema.enum.map((value) => nixLiteral(value)).join(" ");
|
||||
return `t.enum [ ${values} ]`;
|
||||
}
|
||||
|
||||
if (schema.anyOf && Array.isArray(schema.anyOf) && schema.anyOf.length > 0) {
|
||||
const entries = schema.anyOf as JsonSchema[];
|
||||
const objectUnion = objectUnionTypeForSchemas(entries, indent);
|
||||
if (objectUnion) return objectUnion;
|
||||
const parts = entries.map((entry) => `(${typeForSchema(entry, indent, pathSegments)})`).join(" ");
|
||||
return `t.oneOf [ ${parts} ]`;
|
||||
}
|
||||
|
||||
if (schema.oneOf && Array.isArray(schema.oneOf) && schema.oneOf.length > 0) {
|
||||
const entries = schema.oneOf as JsonSchema[];
|
||||
const objectUnion = objectUnionTypeForSchemas(entries, indent);
|
||||
if (objectUnion) return objectUnion;
|
||||
const parts = entries.map((entry) => `(${typeForSchema(entry, indent, pathSegments)})`).join(" ");
|
||||
return `t.oneOf [ ${parts} ]`;
|
||||
}
|
||||
|
||||
if (schema.allOf && Array.isArray(schema.allOf) && schema.allOf.length > 0) {
|
||||
return "t.anything";
|
||||
}
|
||||
|
||||
const schemaType = schema.type;
|
||||
if (Array.isArray(schemaType) && schemaType.length > 0) {
|
||||
const parts = schemaType
|
||||
.map((entry) => `(${typeForSchema({ type: entry }, indent, pathSegments)})`)
|
||||
.join(" ");
|
||||
return `t.oneOf [ ${parts} ]`;
|
||||
}
|
||||
|
||||
switch (schemaType) {
|
||||
case "string":
|
||||
return "t.str";
|
||||
case "number":
|
||||
return "t.number";
|
||||
case "integer":
|
||||
return "t.int";
|
||||
case "boolean":
|
||||
return "t.bool";
|
||||
case "array": {
|
||||
const items = (schema.items as JsonSchema) || {};
|
||||
return `t.listOf (${typeForSchema(items, indent, pathSegments)})`;
|
||||
}
|
||||
case "object":
|
||||
return objectTypeForSchema(schema, indent, pathSegments);
|
||||
case undefined:
|
||||
if (schema.properties || schema.additionalProperties) {
|
||||
return objectTypeForSchema(schema, indent, pathSegments);
|
||||
}
|
||||
return "t.anything";
|
||||
default:
|
||||
return "t.anything";
|
||||
}
|
||||
};
|
||||
|
||||
const objectUnionTypeForSchemas = (entries: JsonSchema[], indent: string): string | null => {
|
||||
const discriminator = "source";
|
||||
const variants = entries.map((entry) => deref(entry, new Set()));
|
||||
const propsByVariant = variants.map((entry) => (entry.properties as Record<string, JsonSchema>) || null);
|
||||
if (propsByVariant.some((props) => props === null)) return null;
|
||||
const requiredByVariant = variants.map((entry) => new Set((entry.required as string[]) || []));
|
||||
|
||||
const sourceValues = propsByVariant.map((props) => {
|
||||
const source = deref((props as Record<string, JsonSchema>)[discriminator] || {}, new Set());
|
||||
if (typeof source.const === "string") return source.const;
|
||||
if (Array.isArray(source.enum) && source.enum.length === 1 && typeof source.enum[0] === "string") {
|
||||
return source.enum[0] as string;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
if (sourceValues.some((value) => value === null)) return null;
|
||||
|
||||
const uniqueSourceValues = Array.from(new Set(sourceValues as string[]));
|
||||
if (uniqueSourceValues.length !== sourceValues.length) return null;
|
||||
const keySets = propsByVariant.map((props) =>
|
||||
Object.keys(props as Record<string, JsonSchema>).sort().join("\n")
|
||||
);
|
||||
if (new Set(keySets).size === 1) return null;
|
||||
|
||||
const merged: Record<string, JsonSchema[]> = {};
|
||||
for (const props of propsByVariant as Record<string, JsonSchema>[]) {
|
||||
for (const [key, value] of Object.entries(props)) {
|
||||
if (!merged[key]) merged[key] = [];
|
||||
merged[key].push(value);
|
||||
}
|
||||
}
|
||||
const dedupeSchemas = (schemas: JsonSchema[]): JsonSchema[] => {
|
||||
const byKey: Record<string, JsonSchema> = {};
|
||||
for (const schema of schemas) {
|
||||
byKey[JSON.stringify(deref(schema, new Set()))] = schema;
|
||||
}
|
||||
return Object.values(byKey);
|
||||
};
|
||||
|
||||
const nextIndent = `${indent} `;
|
||||
const keys = Object.keys(merged).sort((a, b) => {
|
||||
if (a === discriminator) return -1;
|
||||
if (b === discriminator) return 1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
const inner = keys
|
||||
.map((key) => {
|
||||
if (key === discriminator) {
|
||||
return renderOption(key, { enum: uniqueSourceValues }, true, nextIndent);
|
||||
}
|
||||
const schemas = dedupeSchemas(merged[key]);
|
||||
const schema = schemas.length === 1 ? schemas[0] : { anyOf: schemas };
|
||||
const required =
|
||||
propsByVariant.every((props) => key in (props as Record<string, JsonSchema>)) &&
|
||||
requiredByVariant.every((requiredKeys) => requiredKeys.has(key));
|
||||
return renderOption(key, schema, required, nextIndent);
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
return `t.submodule { options = {\n${inner}\n${indent}}; }`;
|
||||
};
|
||||
|
||||
const allowsPluginChannelConfigs = (pathSegments: string[]): boolean =>
|
||||
pathSegments.length === 1 && pathSegments[0] === "channels";
|
||||
|
||||
const objectTypeForSchema = (schema: JsonSchema, indent: string, pathSegments: string[]): string => {
|
||||
const properties = (schema.properties as Record<string, JsonSchema>) || {};
|
||||
const requiredList = new Set((schema.required as string[]) || []);
|
||||
const keys = Object.keys(properties);
|
||||
|
||||
if (keys.length === 0) {
|
||||
if (schema.additionalProperties && typeof schema.additionalProperties === "object") {
|
||||
const valueType = typeForSchema(schema.additionalProperties as JsonSchema, indent);
|
||||
return `t.attrsOf (${valueType})`;
|
||||
}
|
||||
if (schema.additionalProperties === true) {
|
||||
return "t.attrs";
|
||||
}
|
||||
return "t.attrs";
|
||||
}
|
||||
|
||||
const nextIndent = `${indent} `;
|
||||
const inner = keys
|
||||
.sort()
|
||||
.map((key) =>
|
||||
renderOption(key, properties[key], requiredList.has(key), nextIndent, [...pathSegments, key])
|
||||
)
|
||||
.join("\n");
|
||||
const freeform = allowsPluginChannelConfigs(pathSegments)
|
||||
? " freeformType = t.attrsOf t.anything;"
|
||||
: "";
|
||||
|
||||
return `t.submodule {${freeform} options = {\n${inner}\n${indent}}; }`;
|
||||
};
|
||||
|
||||
const renderOption = (
|
||||
key: string,
|
||||
schemaObj: JsonSchema,
|
||||
required: boolean,
|
||||
indent: string,
|
||||
pathSegments: string[] = [key]
|
||||
): string => {
|
||||
const schema = deref(schemaObj, new Set());
|
||||
const description = typeof schema.description === "string" ? schema.description : null;
|
||||
const hasSchemaDefault = schema.default !== undefined;
|
||||
const effectiveRequired = required && !hasSchemaDefault;
|
||||
const baseTypeExpr = typeForSchema(schema, indent, pathSegments);
|
||||
const typeExpr =
|
||||
!effectiveRequired && !baseTypeExpr.startsWith("t.nullOr")
|
||||
? `t.nullOr (${baseTypeExpr})`
|
||||
: baseTypeExpr;
|
||||
const lines = [
|
||||
`${indent}${nixAttr(key)} = lib.mkOption {`,
|
||||
`${indent} type = ${typeExpr};`,
|
||||
];
|
||||
if (!effectiveRequired) {
|
||||
lines.push(`${indent} default = null;`);
|
||||
}
|
||||
if (description) {
|
||||
lines.push(`${indent} description = ${stringify(description)};`);
|
||||
}
|
||||
lines.push(`${indent}};`);
|
||||
return lines.join("\n");
|
||||
};
|
||||
|
||||
const rootSchema = deref(schema as JsonSchema, new Set());
|
||||
const rootProps = (rootSchema.properties as Record<string, JsonSchema>) || {};
|
||||
const requiredRoot = new Set((rootSchema.required as string[]) || []);
|
||||
|
||||
const body = Object.keys(rootProps)
|
||||
.sort()
|
||||
.map((key) => renderOption(key, rootProps[key], requiredRoot.has(key), " "))
|
||||
.join("\n\n");
|
||||
|
||||
const header = schemaRev
|
||||
? `# Generated from upstream OpenClaw schema at rev ${schemaRev}. DO NOT EDIT.`
|
||||
: "# Generated from upstream OpenClaw schema. DO NOT EDIT.";
|
||||
|
||||
const output = `${header}\n# Generator: nix/scripts/generate-config-options.ts\n{ lib }:\nlet\n t = lib.types;\nin\n{\n${body}\n}\n`;
|
||||
|
||||
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
||||
fs.writeFileSync(outPath, output, "utf8");
|
||||
};
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@ -1,4 +0,0 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
mkdir -p "$out/lib/node_modules/node-addon-api"
|
||||
tar -xf "$src" --strip-components=1 -C "$out/lib/node_modules/node-addon-api"
|
||||
@ -1,7 +0,0 @@
|
||||
#!/bin/sh
|
||||
if [ "$1" = "rebuild" ]; then
|
||||
shift
|
||||
"$REAL_NODE_GYP" configure "$@" && "$REAL_NODE_GYP" build "$@"
|
||||
exit $?
|
||||
fi
|
||||
exec "$REAL_NODE_GYP" "$@"
|
||||
@ -1,72 +0,0 @@
|
||||
#!/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"
|
||||
@ -1,11 +0,0 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
mkdir -p "$out/Applications"
|
||||
app_path="$(find "$src" -maxdepth 2 -name '*.app' -print -quit)"
|
||||
if [ -z "$app_path" ]; then
|
||||
echo "OpenClaw.app not found in $src" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Canonical name going forward
|
||||
cp -R "$app_path" "$out/Applications/OpenClaw.app"
|
||||
@ -1,56 +0,0 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
if [ -z "${OPENCLAW_GATEWAY_BIN:-}" ]; then
|
||||
echo "OPENCLAW_GATEWAY_BIN is not set" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -x "$OPENCLAW_GATEWAY_BIN" ]; then
|
||||
echo "OPENCLAW_GATEWAY_BIN is not executable: $OPENCLAW_GATEWAY_BIN" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "${OPENCLAW_PINNED_WRITE_PYTHON:-}" ]; then
|
||||
echo "OPENCLAW_PINNED_WRITE_PYTHON is not set" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -x "$OPENCLAW_PINNED_WRITE_PYTHON" ]; then
|
||||
echo "OPENCLAW_PINNED_WRITE_PYTHON is not executable: $OPENCLAW_PINNED_WRITE_PYTHON" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "${STDENV_SETUP:-}" ]; then
|
||||
echo "STDENV_SETUP is not set" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -f "$STDENV_SETUP" ]; then
|
||||
echo "STDENV_SETUP not found: $STDENV_SETUP" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$out/bin"
|
||||
|
||||
if [ -n "${OPENCLAW_TOOLS_PATH:-}" ]; then
|
||||
bash -e -c '. "$STDENV_SETUP"; makeWrapper "$OPENCLAW_GATEWAY_BIN" "$out/bin/openclaw" --set OPENCLAW_PINNED_WRITE_PYTHON "$OPENCLAW_PINNED_WRITE_PYTHON" --prefix PATH : "$OPENCLAW_TOOLS_PATH"'
|
||||
else
|
||||
bash -e -c '. "$STDENV_SETUP"; makeWrapper "$OPENCLAW_GATEWAY_BIN" "$out/bin/openclaw" --set OPENCLAW_PINNED_WRITE_PYTHON "$OPENCLAW_PINNED_WRITE_PYTHON"'
|
||||
fi
|
||||
|
||||
if [ -n "${OPENCLAW_APP_PACKAGE:-}" ]; then
|
||||
app_dir="${OPENCLAW_APP_PACKAGE}/Applications"
|
||||
if [ ! -d "$app_dir" ]; then
|
||||
echo "OpenClaw app package has no Applications directory: $OPENCLAW_APP_PACKAGE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$out/Applications"
|
||||
found_app=0
|
||||
for app in "$app_dir"/*.app; do
|
||||
[ -e "$app" ] || continue
|
||||
ln -s "$app" "$out/Applications/$(basename "$app")"
|
||||
found_app=1
|
||||
done
|
||||
|
||||
if [ "$found_app" -ne 1 ]; then
|
||||
echo "OpenClaw app package has no .app under: $app_dir" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
@ -1,22 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
qmd="${OPENCLAW_QMD_BIN:?OPENCLAW_QMD_BIN is required}"
|
||||
tmp_dir="$(mktemp -d)"
|
||||
|
||||
cleanup() {
|
||||
rm -rf "$tmp_dir"
|
||||
"$qmd" collection remove openclaw-prewarm >/dev/null 2>&1 || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
printf "%s\n\n%s\n" \
|
||||
"# OpenClaw QMD prewarm" \
|
||||
"This temporary document warms QMD model caches." \
|
||||
> "$tmp_dir/prewarm.md"
|
||||
|
||||
"$qmd" collection remove openclaw-prewarm >/dev/null 2>&1 || true
|
||||
"$qmd" collection add "$tmp_dir" --name openclaw-prewarm >/dev/null
|
||||
"$qmd" update >/dev/null
|
||||
"$qmd" embed >/dev/null
|
||||
"$qmd" query "OpenClaw QMD prewarm" -n 1 --json >/dev/null
|
||||
@ -1,41 +0,0 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
if [ -z "${1:-}" ] || [ -z "${2:-}" ]; then
|
||||
echo "usage: patch-clipboard.sh <openclaw-root> <wrapper>" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
root="$1"
|
||||
wrapper="$2"
|
||||
|
||||
if [ ! -f "$wrapper" ]; then
|
||||
echo "clipboard wrapper not found: $wrapper" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$(uname -s)" != "Linux" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
node_modules="$root/node_modules"
|
||||
if [ ! -d "$node_modules/.pnpm" ]; then
|
||||
echo "node_modules missing: $node_modules" >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
targets=$(find "$node_modules/.pnpm" -path "*/node_modules/@mariozechner/clipboard/index.js" -print)
|
||||
if [ -z "$targets" ]; then
|
||||
echo "clipboard package not found; skipping" >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
for target in $targets; do
|
||||
dir=$(dirname "$target")
|
||||
if [ ! -f "$dir/index.original.js" ]; then
|
||||
chmod u+w "$dir/index.js" 2>/dev/null || true
|
||||
mv "$dir/index.js" "$dir/index.original.js"
|
||||
fi
|
||||
chmod u+w "$dir/index.original.js" 2>/dev/null || true
|
||||
cp "$wrapper" "$dir/index.js"
|
||||
done
|
||||
@ -1,15 +0,0 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
store_path="${1:-}"
|
||||
if [ -z "$store_path" ] || [ ! -d "$store_path" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
find "$store_path" -name "integrity-not-built.json" \
|
||||
| while IFS= read -r file; do
|
||||
if jq -e '.requiresBuild == true' "$file" >/dev/null; then
|
||||
continue
|
||||
fi
|
||||
cp "$file" "${file%integrity-not-built.json}integrity.json"
|
||||
done
|
||||
@ -1,11 +0,0 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
path="${1:-}"
|
||||
if [ -z "$path" ] || [ ! -f "$path" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
tmp="$(mktemp)"
|
||||
jq 'del(.packageManager)' "$path" > "$tmp"
|
||||
mv "$tmp" "$path"
|
||||
@ -1,113 +0,0 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
log_step() {
|
||||
if [ "${OPENCLAW_NIX_TIMINGS:-1}" != "1" ]; then
|
||||
"$@"
|
||||
return
|
||||
fi
|
||||
|
||||
name="$1"
|
||||
shift
|
||||
|
||||
start=$(date +%s)
|
||||
printf '>> [timing] %s...\n' "$name" >&2
|
||||
"$@"
|
||||
end=$(date +%s)
|
||||
printf '>> [timing] %s: %ss\n' "$name" "$((end - start))" >&2
|
||||
}
|
||||
|
||||
if [ -z "${GATEWAY_PREBUILD_SH:-}" ]; then
|
||||
echo "GATEWAY_PREBUILD_SH is not set" >&2
|
||||
exit 1
|
||||
fi
|
||||
. "$GATEWAY_PREBUILD_SH"
|
||||
|
||||
store_path_file="${PNPM_STORE_PATH_FILE:-.pnpm-store-path}"
|
||||
if [ ! -f "$store_path_file" ]; then
|
||||
echo "pnpm store path file missing: $store_path_file" >&2
|
||||
exit 1
|
||||
fi
|
||||
store_path="$(cat "$store_path_file")"
|
||||
export PNPM_STORE_DIR="$store_path"
|
||||
export PNPM_STORE_PATH="$store_path"
|
||||
export NPM_CONFIG_STORE_DIR="$store_path"
|
||||
export NPM_CONFIG_STORE_PATH="$store_path"
|
||||
export HOME="$(mktemp -d)"
|
||||
|
||||
log_step "pnpm install (source checks)" pnpm install --offline --frozen-lockfile --ignore-scripts --prod=false --store-dir "$store_path"
|
||||
|
||||
ensure_root_package_link() {
|
||||
pkg="$1"
|
||||
root_path="node_modules/$pkg"
|
||||
|
||||
if [ -e "$root_path" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
pkg_dir="$(find node_modules/.pnpm -path "*/node_modules/$pkg" -type d | head -n 1)"
|
||||
if [ -z "$pkg_dir" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
mkdir -p "$(dirname "$root_path")"
|
||||
ln -s "$pkg_dir" "$root_path"
|
||||
}
|
||||
|
||||
ensure_root_bin_link() {
|
||||
bin_name="$1"
|
||||
target_rel="$2"
|
||||
bin_path="node_modules/.bin/$bin_name"
|
||||
|
||||
mkdir -p "$(dirname "$bin_path")"
|
||||
rm -f "$bin_path"
|
||||
ln -s "$target_rel" "$bin_path"
|
||||
}
|
||||
|
||||
ensure_root_package_link "tsdown"
|
||||
ensure_root_package_link "tsx"
|
||||
ensure_root_bin_link "tsdown" "../tsdown/dist/run.mjs"
|
||||
ensure_root_bin_link "tsx" "../tsx/dist/cli.mjs"
|
||||
|
||||
tsdown_cli="node_modules/tsdown/dist/run.mjs"
|
||||
if [ ! -f "$tsdown_cli" ]; then
|
||||
tsdown_cli="$(find node_modules -path '*/tsdown/dist/run.mjs' -type f | head -n 1)"
|
||||
fi
|
||||
|
||||
if [ -z "${tsdown_cli:-}" ] || [ ! -f "$tsdown_cli" ]; then
|
||||
echo "tsdown CLI not found under ./node_modules" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
tsc_cli="node_modules/typescript/bin/tsc"
|
||||
if [ ! -f "$tsc_cli" ]; then
|
||||
tsc_cli="$(find node_modules -path '*/typescript/bin/tsc' -type f | head -n 1)"
|
||||
fi
|
||||
|
||||
if [ -z "${tsc_cli:-}" ] || [ ! -f "$tsc_cli" ]; then
|
||||
echo "TypeScript CLI not found under ./node_modules" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "${STDENV_SETUP:-}" ]; then
|
||||
echo "STDENV_SETUP is not set" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -f "$STDENV_SETUP" ]; then
|
||||
echo "STDENV_SETUP not found: $STDENV_SETUP" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log_step "patchShebangs node_modules/.bin" bash -e -c ". \"$STDENV_SETUP\"; patchShebangs node_modules/.bin"
|
||||
|
||||
log_step "node $tsdown_cli" node "$tsdown_cli" --config-loader unrun --logLevel warn
|
||||
log_step "node scripts/build-stamp.mjs" node scripts/build-stamp.mjs
|
||||
log_step "node $tsc_cli" node "$tsc_cli" -p tsconfig.plugin-sdk.dts.json
|
||||
log_step "node --import tsx scripts/write-plugin-sdk-entry-dts.ts" node --import tsx scripts/write-plugin-sdk-entry-dts.ts
|
||||
if [ -f "scripts/copy-plugin-sdk-root-alias.mjs" ]; then
|
||||
log_step "node scripts/copy-plugin-sdk-root-alias.mjs" node scripts/copy-plugin-sdk-root-alias.mjs
|
||||
fi
|
||||
if [ -f "scripts/copy-bundled-plugin-metadata.mjs" ]; then
|
||||
log_step "node scripts/copy-bundled-plugin-metadata.mjs" node scripts/copy-bundled-plugin-metadata.mjs
|
||||
fi
|
||||
log_step "node scripts/check-plugin-sdk-exports.mjs" node scripts/check-plugin-sdk-exports.mjs
|
||||
@ -1,19 +0,0 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
if [ -z "${CONFIG_OPTIONS_CHECK_SH:-}" ]; then
|
||||
echo "CONFIG_OPTIONS_CHECK_SH is not set" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -f "$CONFIG_OPTIONS_CHECK_SH" ]; then
|
||||
echo "CONFIG_OPTIONS_CHECK_SH not found: $CONFIG_OPTIONS_CHECK_SH" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -n "${OPENCLAW_BUILD_ROOT_SH:-}" ]; then
|
||||
. "$OPENCLAW_BUILD_ROOT_SH"
|
||||
openclaw_enter_build_root
|
||||
trap openclaw_cleanup_output_build_root EXIT
|
||||
fi
|
||||
|
||||
"$CONFIG_OPTIONS_CHECK_SH"
|
||||
7
nix/sources/clawdis-source.nix
Normal file
7
nix/sources/clawdis-source.nix
Normal file
@ -0,0 +1,7 @@
|
||||
# Pinned Clawdis source for nix-clawdis
|
||||
{
|
||||
owner = "steipete";
|
||||
repo = "clawdis";
|
||||
rev = "e5cae2a2e4676111d7bbf1cd1d9956e78ca9088a";
|
||||
hash = "sha256-wmuaYtJM5WF5/HnU3+f6Z6qMMLj+ph31ha431WtYvr4=";
|
||||
}
|
||||
@ -1,17 +0,0 @@
|
||||
{
|
||||
owner = "openclaw";
|
||||
repo = "openclaw";
|
||||
releaseVersion = "2026.5.7-dogfood.20260508";
|
||||
rev = "954d20ece2de0fba3688f7800613183fbeb9685c";
|
||||
hash = "sha256-6CZWsH8dV6XZ4JeG5ItKLqGAOFqbzWosyCmMXVc+c/g=";
|
||||
pnpmDepsHash = "sha256-hNZA1OEuJgtoLz2hWLPk8Hm+7heLvhiZpDdBBQ1UXpc=";
|
||||
fsSafeSource = {
|
||||
owner = "openclaw";
|
||||
repo = "fs-safe";
|
||||
rev = "c7ccb99d3058f2acf2ad2758ad2470c7e113a53c";
|
||||
hash = "sha256-jndOOSSFROyrK4RiwAsJfUuCJTj7qbmmm4Qz8BqtJ/c=";
|
||||
};
|
||||
|
||||
publicSurfaceHardlinksPatch = ../patches/allow-package-public-surface-hardlinks-open-root.patch;
|
||||
applySkipPluginAutoEnableNixModePatch = false;
|
||||
}
|
||||
@ -1,10 +0,0 @@
|
||||
# Pinned OpenClaw source for nix-openclaw
|
||||
{
|
||||
owner = "openclaw";
|
||||
repo = "openclaw";
|
||||
releaseTag = "v2026.5.7";
|
||||
releaseVersion = "2026.5.7";
|
||||
rev = "eeef4864494f859838fec1586bedbab1f8fa5702";
|
||||
hash = "sha256-ICkq6YfMJVvRC93sM+7/q2JI82wUhjaYAI3pRzmTHYc=";
|
||||
pnpmDepsHash = "sha256-LXaRfZ0WY8VDpDc2zFr+Oel6AuYo6SiTrp37yokT1VU=";
|
||||
}
|
||||
@ -1,3 +0,0 @@
|
||||
# Test Agent
|
||||
|
||||
Home Manager activation fixture.
|
||||
@ -1,3 +0,0 @@
|
||||
# Test Soul
|
||||
|
||||
Home Manager activation fixture.
|
||||
@ -1,3 +0,0 @@
|
||||
# Test Tools
|
||||
|
||||
Home Manager activation fixture.
|
||||
@ -1,66 +0,0 @@
|
||||
{
|
||||
description = "nix-openclaw macOS Home Manager activation test";
|
||||
|
||||
inputs = {
|
||||
nix-openclaw.url = "github:openclaw/nix-openclaw";
|
||||
nixpkgs.follows = "nix-openclaw/nixpkgs";
|
||||
home-manager.follows = "nix-openclaw/home-manager";
|
||||
};
|
||||
|
||||
outputs =
|
||||
{
|
||||
nixpkgs,
|
||||
home-manager,
|
||||
nix-openclaw,
|
||||
...
|
||||
}:
|
||||
let
|
||||
system = "aarch64-darwin";
|
||||
pkgs = import nixpkgs {
|
||||
inherit system;
|
||||
overlays = [ nix-openclaw.overlays.default ];
|
||||
};
|
||||
in
|
||||
{
|
||||
homeConfigurations.hm-test = home-manager.lib.homeManagerConfiguration {
|
||||
inherit pkgs;
|
||||
modules = [
|
||||
nix-openclaw.homeManagerModules.openclaw
|
||||
(
|
||||
{ ... }:
|
||||
{
|
||||
home = {
|
||||
username = "runner";
|
||||
homeDirectory = "/tmp/hm-activation-home";
|
||||
stateVersion = "23.11";
|
||||
};
|
||||
|
||||
programs.openclaw = {
|
||||
enable = true;
|
||||
installApp = false;
|
||||
runtimePackages = [ pkgs.jq ];
|
||||
environment.OPENCLAW_TEST_SECRET = "/tmp/openclaw-secret";
|
||||
instances.default = {
|
||||
gatewayPort = 18999;
|
||||
logPath = "/tmp/hm-activation-home/.openclaw/openclaw-gateway.log";
|
||||
launchd.label = "com.steipete.openclaw.gateway.hm-test";
|
||||
config = {
|
||||
logging = {
|
||||
level = "debug";
|
||||
file = "/tmp/hm-activation-home/.openclaw/openclaw-gateway.log";
|
||||
};
|
||||
gateway = {
|
||||
mode = "local";
|
||||
auth = {
|
||||
token = "hm-activation-test-token";
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
)
|
||||
];
|
||||
};
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user