Compare commits
203 Commits
clever-mer
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
280744ce0c | ||
|
|
4a40ae24e2 | ||
|
|
33755bec7a | ||
|
|
5446b35ffe | ||
|
|
9d1ee1023e | ||
|
|
0f7e6570eb | ||
|
|
ce846a36dc | ||
|
|
233d0d6da8 | ||
|
|
7dbedacdff | ||
|
|
833264bbe3 | ||
|
|
6cd6b7fada | ||
|
|
028880fef3 | ||
|
|
52f5168cd2 | ||
|
|
c44d54319e | ||
|
|
55788b92ff | ||
|
|
eb3c79c5f5 | ||
|
|
c3fd19af9f | ||
|
|
3d64364853 | ||
|
|
e126e33d54 | ||
|
|
e549dca9fd | ||
|
|
9245311395 | ||
|
|
d7df4f0e13 | ||
|
|
fda12f98cb | ||
|
|
c0794f84e2 | ||
|
|
e5e959f90a | ||
|
|
5e1977a078 | ||
|
|
c3a4b7dbf1 | ||
|
|
4bd99e8821 | ||
|
|
ac61f9551d | ||
|
|
5f99924bd1 | ||
|
|
ffb27ab614 | ||
|
|
63fa64a0b1 | ||
|
|
e1d3009c30 | ||
|
|
deebc3431f | ||
|
|
3bac990eca | ||
|
|
52198c23cb | ||
|
|
aeeb41632e | ||
|
|
1a64384a48 | ||
|
|
7233368238 | ||
|
|
dbda75c1df | ||
|
|
4884b6b65f | ||
|
|
76e10eb42c | ||
|
|
debd806389 | ||
|
|
509ee48696 | ||
|
|
65cc44486f | ||
|
|
d846155ad0 | ||
|
|
9bdf5a611a | ||
|
|
7a3ae4423a | ||
|
|
59bef8196b | ||
|
|
446bad9107 | ||
|
|
edfb499d52 | ||
|
|
a01294cae7 | ||
|
|
94fe55e819 | ||
|
|
6b0d5c7fe2 | ||
|
|
c37a235393 | ||
|
|
2f521d51f6 | ||
|
|
4feadcceec | ||
|
|
5e0ad83959 | ||
|
|
0445635ae6 | ||
|
|
0456fa91ec | ||
|
|
634f7fc0ce | ||
|
|
1384ee7b47 | ||
|
|
b54453c593 | ||
|
|
7e346f3086 | ||
|
|
92ab45da18 | ||
|
|
1c2508b781 | ||
|
|
72db2fd5de | ||
|
|
20139a56d2 | ||
|
|
16482a9eb3 | ||
|
|
6c7ddee942 | ||
|
|
bf65890259 | ||
|
|
f127c39d90 | ||
|
|
ba96cfbebf | ||
|
|
bda89e4c97 | ||
|
|
e869c7b5a7 | ||
|
|
4fd6ab11e4 | ||
|
|
c8d54bfc24 | ||
|
|
8e5f256e96 | ||
|
|
f1daf782f6 | ||
|
|
56ffff4010 | ||
|
|
05d43b1926 | ||
|
|
c373a14bb4 | ||
|
|
8f2cf7a58d | ||
|
|
fbd6dc2118 | ||
|
|
8a1deeed09 | ||
|
|
77b2cef22f | ||
|
|
4b7eb80f2c | ||
|
|
eb491737a1 | ||
|
|
61c64d3ddb | ||
|
|
929b2cde70 | ||
|
|
956bd1493e | ||
|
|
5060960a89 | ||
|
|
2b97a7afce | ||
|
|
be9f5fada8 | ||
|
|
9a4d467f05 | ||
|
|
3f04ee8675 | ||
|
|
6a8b189421 | ||
|
|
2f6b950eb8 | ||
|
|
fc793d67a9 | ||
|
|
2320639342 | ||
|
|
ec8b7dabc6 | ||
|
|
a2978e20a3 | ||
|
|
4dfed7f610 | ||
|
|
e06cecf11f | ||
|
|
3975a6485c | ||
|
|
7690daf793 | ||
|
|
c0022322d6 | ||
|
|
1c422360b1 | ||
|
|
790a10c0f4 | ||
|
|
b7efe5017b | ||
|
|
8470c3c5c2 | ||
|
|
c8831041fb | ||
|
|
4f4c848813 | ||
|
|
d449308fcf | ||
|
|
1ad5189beb | ||
|
|
52d9b34693 | ||
|
|
2e6ee3772b | ||
|
|
c2c3bf4f46 | ||
|
|
b9b3ad6ffe | ||
|
|
faa8917f2d | ||
|
|
682523d829 | ||
|
|
f59651f00e | ||
|
|
063b573156 | ||
|
|
2e795cda35 | ||
|
|
40c122492f | ||
|
|
e9b6613f2f | ||
|
|
d3083208c9 | ||
|
|
78f57df5a8 | ||
|
|
0fd48b5f9d | ||
|
|
fb1608c8ff | ||
|
|
68eb171c87 | ||
|
|
9463089472 | ||
|
|
24aa7c9012 | ||
|
|
52bae6a555 | ||
|
|
dbc56ac55d | ||
|
|
10939b4076 | ||
|
|
adf561d169 | ||
|
|
731a882058 | ||
|
|
47ab70fa93 | ||
|
|
b54c71288e | ||
|
|
1fe52774dc | ||
|
|
a809f29154 | ||
|
|
3134611965 | ||
|
|
f27fc2305e | ||
|
|
bd4db97f76 | ||
|
|
ef28fe7a80 | ||
|
|
3ac78341af | ||
|
|
a7106d3072 | ||
|
|
258e8d81bb | ||
|
|
829b638d0a | ||
|
|
71b72ce394 | ||
|
|
86de3592c6 | ||
|
|
23ef00ba16 | ||
|
|
45d69b7f84 | ||
|
|
835f617645 | ||
|
|
697f843c3f | ||
|
|
0e35464dce | ||
|
|
cb3cab0400 | ||
|
|
a0bb39a106 | ||
|
|
645675ec10 | ||
|
|
9c6e3dd9dc | ||
|
|
ef1b83f7d0 | ||
|
|
7792696d71 | ||
|
|
69168aac1d | ||
|
|
f808e9987e | ||
|
|
89c3f2d353 | ||
|
|
079ba7af0c | ||
|
|
72b3625b75 | ||
|
|
cdf5c4a4fb | ||
|
|
413565f010 | ||
|
|
3a79cbe6ba | ||
|
|
4e9ce1c508 | ||
|
|
4e63419218 | ||
|
|
ba08061d60 | ||
|
|
84eb93bee0 | ||
|
|
dde572ef3f | ||
|
|
de39420f98 | ||
|
|
9b79df7a51 | ||
|
|
b812c7fa0a | ||
|
|
956fdcdc5e | ||
|
|
1d771e9dad | ||
|
|
d4ba7b1a5c | ||
|
|
a7e0c038e1 | ||
|
|
ad4c644110 | ||
|
|
ee46e3cb85 | ||
|
|
544e9c05d6 | ||
|
|
3ff95d9f70 | ||
|
|
58a1b33ef7 | ||
|
|
50b96e4aef | ||
|
|
4bbc34016f | ||
|
|
1ddf597e2f | ||
|
|
d1b9620bfb | ||
|
|
f95d9df840 | ||
|
|
1022c056b4 | ||
|
|
6115c1ae9f | ||
|
|
6beefe5a38 | ||
|
|
f5eda367c7 | ||
|
|
a243ae0ef8 | ||
|
|
1ef5b556b0 | ||
|
|
f33262cd79 | ||
|
|
1403e0b408 | ||
|
|
98206dd41c | ||
|
|
8766cc9588 |
7
.github/workflows-disabled/README.md
vendored
Normal file
7
.github/workflows-disabled/README.md
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
Disabled GitHub Actions live here on purpose.
|
||||
|
||||
Moving a file out of `.github/workflows/` fully disables it: no schedule, no manual dispatch button, no runnable workflow at all.
|
||||
|
||||
The disabled set currently includes the old AMI build, flake bump, and push-triggered release/deploy workflows.
|
||||
|
||||
To reactivate one of these workflows, move it back into `.github/workflows/` in a code change and review whether that would recreate infrastructure or resume unattended mutation.
|
||||
37
.github/workflows-disabled/bump-nix-clawdbot.yml.disabled
vendored
Normal file
37
.github/workflows-disabled/bump-nix-clawdbot.yml.disabled
vendored
Normal file
@ -0,0 +1,37 @@
|
||||
name: Bump nix-openclaw
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "*/5 * * * *"
|
||||
workflow_dispatch: {}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
bump:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install Nix
|
||||
uses: cachix/install-nix-action@v27
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
|
||||
- name: Update nix-openclaw input
|
||||
run: |
|
||||
set -euo pipefail
|
||||
nix flake update --update-input nix-openclaw
|
||||
if git diff --quiet flake.lock; then
|
||||
echo "No nix-openclaw changes."
|
||||
exit 0
|
||||
fi
|
||||
git config user.name "clawbot-ci"
|
||||
git config user.email "ci@openclaw.local"
|
||||
git add flake.lock
|
||||
git commit -m "chore: bump nix-openclaw"
|
||||
git push
|
||||
135
.github/workflows-disabled/image-build.yml.disabled
vendored
Normal file
135
.github/workflows-disabled/image-build.yml.disabled
vendored
Normal file
@ -0,0 +1,135 @@
|
||||
name: Build NixOS Image
|
||||
|
||||
concurrency:
|
||||
group: image-build-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: "17 3 * * 0" # weekly (Sunday)
|
||||
|
||||
jobs:
|
||||
build-image:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Nix
|
||||
uses: cachix/install-nix-action@v27
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
extra_nix_config: |
|
||||
extra-substituters = https://cache.garnix.io
|
||||
extra-trusted-public-keys = cache.garnix.io:CTFPyKSLcx5RMJKfLo5EEPUObbA78b0YQ2DTCJXqr9g=
|
||||
|
||||
- name: Shell lint (inline)
|
||||
run: |
|
||||
nix shell nixpkgs#shellcheck nixpkgs#shfmt -c bash scripts/lint-shell.sh
|
||||
|
||||
- name: Cache Nix store
|
||||
uses: nix-community/cache-nix-action@v5
|
||||
with:
|
||||
nix: false
|
||||
primary-key: nix-${{ runner.os }}-${{ hashFiles('**/flake.lock') }}
|
||||
restore-prefixes-first-match: |
|
||||
nix-${{ runner.os }}-
|
||||
paths-linux: |
|
||||
/nix/store
|
||||
|
||||
- name: Free disk space
|
||||
run: |
|
||||
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /usr/local/share/boost /opt/hostedtoolcache
|
||||
df -h
|
||||
|
||||
- name: Install tooling
|
||||
run: |
|
||||
nix profile install \
|
||||
nixpkgs#nixos-generators \
|
||||
nixpkgs#awscli2 \
|
||||
nixpkgs#age \
|
||||
nixpkgs#jq \
|
||||
nixpkgs#zstd
|
||||
|
||||
- name: Write agenix image key
|
||||
env:
|
||||
CLAWDINATOR_AGE_KEY: ${{ secrets.CLAWDINATOR_AGE_KEY }}
|
||||
run: |
|
||||
mkdir -p nix/keys
|
||||
printf '%s' "${CLAWDINATOR_AGE_KEY}" > nix/keys/clawdinator.agekey
|
||||
chmod 600 nix/keys/clawdinator.agekey
|
||||
|
||||
- name: Fetch age secrets
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_REGION: ${{ secrets.AWS_REGION }}
|
||||
S3_BUCKET: ${{ secrets.S3_BUCKET }}
|
||||
run: |
|
||||
mkdir -p nix/age-secrets
|
||||
aws s3 sync "s3://${S3_BUCKET}/age-secrets" nix/age-secrets
|
||||
bash scripts/validate-age-secrets.sh
|
||||
|
||||
- name: Mint GitHub App token
|
||||
env:
|
||||
GITHUB_APP_ID: "2607181"
|
||||
GITHUB_APP_INSTALLATION_ID: "102951645"
|
||||
run: |
|
||||
age -d -i nix/keys/clawdinator.agekey \
|
||||
-o /tmp/clawdinator-github-app.pem \
|
||||
nix/age-secrets/clawdinator-github-app.pem.age
|
||||
export GITHUB_APP_PEM_FILE=/tmp/clawdinator-github-app.pem
|
||||
token="$(scripts/mint-github-app-token.sh)"
|
||||
echo "GITHUB_TOKEN=${token}" >> "${GITHUB_ENV}"
|
||||
|
||||
- name: Prepare repo seeds
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ env.GITHUB_TOKEN }}
|
||||
run: |
|
||||
scripts/prepare-repo-seeds.sh repo-seeds
|
||||
|
||||
- name: Upload bootstrap bundles
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_REGION: ${{ secrets.AWS_REGION }}
|
||||
S3_BUCKET: ${{ secrets.S3_BUCKET }}
|
||||
run: |
|
||||
bash scripts/upload-bootstrap-all.sh
|
||||
|
||||
- name: Build image
|
||||
run: scripts/build-image.sh
|
||||
|
||||
- name: Upload image to S3
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_REGION: ${{ secrets.AWS_REGION }}
|
||||
S3_BUCKET: ${{ secrets.S3_BUCKET }}
|
||||
run: |
|
||||
key="$(scripts/upload-image.sh)"
|
||||
echo "S3_KEY=${key}" >> "${GITHUB_ENV}"
|
||||
|
||||
- name: Import image into AMI
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_REGION: ${{ secrets.AWS_REGION }}
|
||||
S3_BUCKET: ${{ secrets.S3_BUCKET }}
|
||||
S3_KEY: ${{ env.S3_KEY }}
|
||||
AMI_DESCRIPTION: clawdinator-nixos
|
||||
run: |
|
||||
ami_id="$(scripts/import-image.sh)"
|
||||
echo "AMI_ID=${ami_id}" >> "${GITHUB_ENV}"
|
||||
|
||||
- name: Prune old AMIs
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_REGION: ${{ secrets.AWS_REGION }}
|
||||
APPLY: "true"
|
||||
run: |
|
||||
bash scripts/prune-clawdinator-ami-history.sh
|
||||
138
.github/workflows-disabled/release.yml.disabled
vendored
Normal file
138
.github/workflows-disabled/release.yml.disabled
vendored
Normal file
@ -0,0 +1,138 @@
|
||||
name: Release (bootstrap + fast deploy)
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- flake.nix
|
||||
- flake.lock
|
||||
- nix/**
|
||||
- scripts/**
|
||||
- clawdinator/**
|
||||
- infra/opentofu/aws/**
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
eval:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Nix
|
||||
uses: cachix/install-nix-action@v27
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
extra_nix_config: |
|
||||
extra-substituters = https://cache.garnix.io
|
||||
extra-trusted-public-keys = cache.garnix.io:CTFPyKSLcx5RMJKfLo5EEPUObbA78b0YQ2DTCJXqr9g=
|
||||
|
||||
- name: Shell lint (inline)
|
||||
run: |
|
||||
nix shell nixpkgs#shellcheck nixpkgs#shfmt -c bash scripts/lint-shell.sh
|
||||
|
||||
- name: Evaluate host configs (fail fast)
|
||||
run: |
|
||||
nix eval --raw .#nixosConfigurations.clawdinator-1.config.system.build.toplevel.drvPath --accept-flake-config >/dev/null
|
||||
nix eval --raw .#nixosConfigurations.clawdinator-2.config.system.build.toplevel.drvPath --accept-flake-config >/dev/null
|
||||
|
||||
upload-bootstrap:
|
||||
needs: eval
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_REGION: ${{ secrets.AWS_REGION }}
|
||||
S3_BUCKET: ${{ secrets.S3_BUCKET }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Nix
|
||||
uses: cachix/install-nix-action@v27
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
extra_nix_config: |
|
||||
extra-substituters = https://cache.garnix.io
|
||||
extra-trusted-public-keys = cache.garnix.io:CTFPyKSLcx5RMJKfLo5EEPUObbA78b0YQ2DTCJXqr9g=
|
||||
|
||||
- name: Install tooling
|
||||
run: |
|
||||
nix profile install \
|
||||
nixpkgs#awscli2 \
|
||||
nixpkgs#age \
|
||||
nixpkgs#jq \
|
||||
nixpkgs#zstd
|
||||
|
||||
- name: Write agenix image key
|
||||
env:
|
||||
CLAWDINATOR_AGE_KEY: ${{ secrets.CLAWDINATOR_AGE_KEY }}
|
||||
run: |
|
||||
mkdir -p nix/keys
|
||||
printf '%s' "${CLAWDINATOR_AGE_KEY}" > nix/keys/clawdinator.agekey
|
||||
chmod 600 nix/keys/clawdinator.agekey
|
||||
|
||||
- name: Fetch age secrets
|
||||
run: |
|
||||
mkdir -p nix/age-secrets
|
||||
aws s3 sync "s3://${S3_BUCKET}/age-secrets" nix/age-secrets
|
||||
bash scripts/validate-age-secrets.sh
|
||||
|
||||
- name: Mint GitHub App token
|
||||
env:
|
||||
GITHUB_APP_ID: "2607181"
|
||||
GITHUB_APP_INSTALLATION_ID: "102951645"
|
||||
run: |
|
||||
age -d -i nix/keys/clawdinator.agekey \
|
||||
-o /tmp/clawdinator-github-app.pem \
|
||||
nix/age-secrets/clawdinator-github-app.pem.age
|
||||
export GITHUB_APP_PEM_FILE=/tmp/clawdinator-github-app.pem
|
||||
token="$(scripts/mint-github-app-token.sh)"
|
||||
echo "GITHUB_TOKEN=${token}" >> "${GITHUB_ENV}"
|
||||
|
||||
- name: Prepare repo seeds
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ env.GITHUB_TOKEN }}
|
||||
run: |
|
||||
scripts/prepare-repo-seeds.sh repo-seeds
|
||||
|
||||
- name: Upload bootstrap bundles
|
||||
run: |
|
||||
bash scripts/upload-bootstrap-all.sh
|
||||
|
||||
deploy:
|
||||
needs: upload-bootstrap
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_REGION: ${{ secrets.AWS_REGION }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Nix
|
||||
uses: cachix/install-nix-action@v27
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
|
||||
- name: Install tooling
|
||||
run: |
|
||||
nix profile install \
|
||||
nixpkgs#awscli2 \
|
||||
nixpkgs#jq
|
||||
|
||||
- name: Switch fleet via SSM (canary then full)
|
||||
env:
|
||||
REV: ${{ github.sha }}
|
||||
run: |
|
||||
bash scripts/fleet-switch-nixos.sh "${REV}"
|
||||
59
.github/workflows/fleet-deploy.yml
vendored
Normal file
59
.github/workflows/fleet-deploy.yml
vendored
Normal file
@ -0,0 +1,59 @@
|
||||
name: Fleet Deploy
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
target:
|
||||
description: "all or instance id"
|
||||
required: true
|
||||
ami_override:
|
||||
description: "Optional AMI override"
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
jobs:
|
||||
fleet:
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: fleet-deploy
|
||||
cancel-in-progress: false
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_REGION: ${{ secrets.AWS_REGION }}
|
||||
S3_BUCKET: ${{ secrets.S3_BUCKET }}
|
||||
SSH_PUBLIC_KEY: ${{ secrets.CLAWDINATOR_SSH_PUBLIC_KEY }}
|
||||
TF_BACKEND_BUCKET: ${{ secrets.S3_BUCKET }}
|
||||
TF_BACKEND_KEY: state/clawdinators.tfstate
|
||||
TF_BACKEND_REGION: ${{ secrets.AWS_REGION }}
|
||||
TF_BACKEND_DYNAMO_TABLE: clawdinator-terraform-locks
|
||||
# Control API is optional and requires broader IAM privileges than the CI user has.
|
||||
# Keep disabled for fleet AMI redeploys.
|
||||
TF_VAR_control_api_enabled: false
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup OpenTofu
|
||||
uses: opentofu/setup-opentofu@v1
|
||||
|
||||
- name: Install tooling
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y jq
|
||||
|
||||
- name: Resolve AMI
|
||||
run: |
|
||||
if [ -n "${{ inputs.ami_override }}" ]; then
|
||||
echo "AMI_ID=${{ inputs.ami_override }}" >> "$GITHUB_ENV"
|
||||
else
|
||||
ami_id="$(AWS_REGION=${AWS_REGION} bash scripts/resolve-latest-ami.sh)"
|
||||
echo "AMI_ID=${ami_id}" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- name: Deploy fleet
|
||||
env:
|
||||
TARGET: ${{ inputs.target }}
|
||||
AMI_ID: ${{ env.AMI_ID }}
|
||||
run: |
|
||||
bash scripts/fleet-deploy.sh
|
||||
82
.github/workflows/image-build.yml
vendored
82
.github/workflows/image-build.yml
vendored
@ -1,82 +0,0 @@
|
||||
name: Build NixOS Image
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
build-image:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Nix
|
||||
uses: cachix/install-nix-action@v27
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
|
||||
- name: Free disk space
|
||||
run: |
|
||||
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /usr/local/share/boost /opt/hostedtoolcache
|
||||
df -h
|
||||
|
||||
- name: Install tooling
|
||||
run: |
|
||||
nix profile install \
|
||||
nixpkgs#nixos-generators \
|
||||
nixpkgs#awscli2
|
||||
|
||||
- name: Write agenix image key
|
||||
env:
|
||||
CLAWDINATOR_AGE_KEY: ${{ secrets.CLAWDINATOR_AGE_KEY }}
|
||||
run: |
|
||||
mkdir -p nix/keys
|
||||
printf '%s' "${CLAWDINATOR_AGE_KEY}" > nix/keys/clawdinator.agekey
|
||||
chmod 600 nix/keys/clawdinator.agekey
|
||||
|
||||
- name: Fetch age secrets
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_REGION: ${{ secrets.AWS_REGION }}
|
||||
S3_BUCKET: ${{ secrets.S3_BUCKET }}
|
||||
run: |
|
||||
mkdir -p nix/age-secrets
|
||||
aws s3 sync "s3://${S3_BUCKET}/age-secrets" nix/age-secrets
|
||||
for file in \
|
||||
nix/age-secrets/clawdinator-github-app.pem.age \
|
||||
nix/age-secrets/clawdinator-discord-token.age \
|
||||
nix/age-secrets/clawdinator-anthropic-api-key.age
|
||||
do
|
||||
test -f "$file"
|
||||
done
|
||||
|
||||
- name: Build image
|
||||
run: scripts/build-image.sh
|
||||
|
||||
- name: Upload image to S3
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_REGION: ${{ secrets.AWS_REGION }}
|
||||
S3_BUCKET: ${{ secrets.S3_BUCKET }}
|
||||
run: |
|
||||
key="$(scripts/upload-image.sh)"
|
||||
echo "S3_KEY=${key}" >> "${GITHUB_ENV}"
|
||||
|
||||
- name: Import image into AMI
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_REGION: ${{ secrets.AWS_REGION }}
|
||||
S3_BUCKET: ${{ secrets.S3_BUCKET }}
|
||||
S3_KEY: ${{ env.S3_KEY }}
|
||||
AMI_DESCRIPTION: clawdinator-nixos
|
||||
run: |
|
||||
ami_id="$(scripts/import-image.sh)"
|
||||
echo "AMI_ID=${ami_id}" >> "${GITHUB_ENV}"
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@ -1,3 +1,6 @@
|
||||
# Devenv
|
||||
.devenv*
|
||||
|
||||
# OpenTofu
|
||||
infra/opentofu/.terraform/
|
||||
infra/opentofu/.tofu/
|
||||
@ -21,3 +24,6 @@ nix/age-secrets/
|
||||
# Nix build outputs
|
||||
result
|
||||
result-*
|
||||
|
||||
# Repo seed workspace
|
||||
repo-seeds/
|
||||
|
||||
43
AGENTS.md
43
AGENTS.md
@ -20,15 +20,16 @@ Memory references:
|
||||
|
||||
Repo rule: no inline scripting languages (Python/Node/etc.) in Nix or shell blocks; put logic in script files and call them.
|
||||
|
||||
|
||||
System ownership (3 repos):
|
||||
- `clawdbot`: upstream runtime and behavior.
|
||||
- `nix-clawdbot`: packaging/build fixes for `clawdbot`.
|
||||
- `openclaw`: upstream runtime and behavior.
|
||||
- `nix-openclaw`: packaging/build fixes for clawbot.
|
||||
- `clawdinators`: infra, NixOS config, secrets wiring, deployment flow.
|
||||
|
||||
Maintainer role:
|
||||
- Monitor issues + PRs and keep an inventory of what needs human attention.
|
||||
- Surface priorities and context; do not file issues or modify code unless asked.
|
||||
- Track running versions (clawdbot/nix-clawdbot/clawdinators) and note them in `memory/ops.md`.
|
||||
- Track running versions (openclaw/nix-openclaw/clawdinators) and note them in `memory/ops.md`.
|
||||
|
||||
Toolchain workflow (repo source of truth):
|
||||
- Add/remove tools in `nix/tools/clawdinator-tools.nix` (packages + descriptions).
|
||||
@ -36,7 +37,7 @@ Toolchain workflow (repo source of truth):
|
||||
- Keep `clawdinator/workspace/TOOLS.md` aligned with upstream template; do not hardcode tool lists there.
|
||||
- When you add a new tool, verify it appears in `/etc/clawdinator/tools.md` and in the workspace `TOOLS.md` after seed.
|
||||
|
||||
The Zen of ~~Python~~ Clawdbot, ~~by~~ shamelessly stolen from Tim Peters:
|
||||
The Zen of ~~Python~~ Moltbot, ~~by~~ shamelessly stolen from Tim Peters:
|
||||
- Beautiful is better than ugly.
|
||||
- Explicit is better than implicit.
|
||||
- Simple is better than complex.
|
||||
@ -61,17 +62,20 @@ Deploy flow (automation-first):
|
||||
- Use `devenv.nix` for tooling (nixos-generators, awscli2).
|
||||
- Build a bootstrap NixOS image with nixos-generators (raw) and upload it to S3.
|
||||
- Use `nix/hosts/clawdinator-1-image.nix` for image builds.
|
||||
- CI is preferred: `.github/workflows/image-build.yml` runs build → S3 upload → AMI import.
|
||||
- The old CI AMI/update/release workflows are intentionally disabled under `.github/workflows-disabled/`; AMI builds and deploys now require an explicit code change or a local operator run.
|
||||
- Image history is bounded on purpose: raw `clawdinator-nixos-*` uploads expire automatically, and old CLAWDINATOR AMIs/snapshots are pruned after successful builds while keeping the live fleet AMI plus a short rollback window.
|
||||
- Resume AMI pipeline work immediately if it stalls; do not use rsync as a workaround. Host edits are allowed but must be committed and baked into a new AMI to persist.
|
||||
- CI must provide `CLAWDINATOR_AGE_KEY` (private key) so the image can bake `/etc/agenix/keys/clawdinator.agekey`.
|
||||
- CI must provide `CLAWDINATOR_AGE_KEY` to build + upload the runtime bootstrap bundle to S3.
|
||||
- Bootstrap bundle location: `s3://${S3_BUCKET}/bootstrap/<instance>/` (secrets + repo seeds).
|
||||
- Bootstrap S3 bucket + scoped IAM user + VM Import role with `infra/opentofu/aws` (use homelab-admin creds).
|
||||
- Bootstrap AWS instances from the AMI with `infra/opentofu/aws` (set `TF_VAR_ami_id`).
|
||||
- Import the image into AWS as an AMI (snapshot import + register image).
|
||||
- Ensure secrets are encrypted to the baked agenix key (see `../nix/nix-secrets/secrets.nix`).
|
||||
- Ensure required secrets exist: `clawdinator-github-app.pem`, `clawdinator-discord-token`, `clawdinator-anthropic-api-key`.
|
||||
- Ensure required secrets exist: `clawdinator-github-app.pem`, `clawdinator-discord-token-<n>`, `clawdinator-control-token`, `clawdinator-control-aws-*`, `clawdinator-anthropic-api-key`.
|
||||
- Update `nix/hosts/<host>.nix` (Discord allowlist, GitHub App installationId, identity name).
|
||||
- Ensure `/var/lib/clawd/repo` contains this repo (self-update requires it).
|
||||
- Verify systemd services: `clawdinator`, `clawdinator-github-app-token`, `clawdinator-self-update`.
|
||||
- Discord must use `messages.queue.byChannel.discord = "interrupt"`; `queue` delays replies to heartbeat and makes the bot appear dead.
|
||||
- Ensure `/var/lib/clawd/repos/clawdinators` contains this repo (self-update requires it).
|
||||
- Verify systemd services: `clawdinator`; `clawdinator-github-app-token` only on hosts that explicitly enable GitHub App auth.
|
||||
- Commit and push changes; repo is the source of truth.
|
||||
|
||||
Bootstrap (local):
|
||||
@ -92,6 +96,27 @@ Bootstrap (local):
|
||||
- Then `gh secret set` for `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_REGION`, `S3_BUCKET`.
|
||||
- Get the latest AMI ID:
|
||||
- `aws ec2 describe-images --region eu-central-1 --owners self --filters "Name=tag:clawdinator,Values=true" --query "Images | sort_by(@,&CreationDate)[-1].[ImageId,Name,CreationDate]" --output text`
|
||||
|
||||
End-to-end SDLC (local → AMI → host) **(verified)**:
|
||||
1) Decrypt AWS creds (homelab admin) and export:
|
||||
- `cd ~/code/nix/nix-secrets`
|
||||
- `RULES=./secrets.nix agenix -d homelab-admin.age -i ~/.ssh/id_ed25519 > /tmp/homelab-admin.env`
|
||||
- `set -a; source /tmp/homelab-admin.env; set +a`
|
||||
- Cleanup: `trash /tmp/homelab-admin.env`
|
||||
2) Build/import a new AMI explicitly. The old GitHub Actions build/deploy paths are disabled under `.github/workflows-disabled/`.
|
||||
3) Redeploy from the new AMI (instance replacement):
|
||||
- `devenv shell -- bash -lc "cd infra/opentofu/aws && TF_VAR_ami_id=<AMI_ID> TF_VAR_ssh_public_key=\"$(cat ~/.ssh/id_ed25519.pub)\" TF_VAR_aws_region=eu-central-1 tofu apply -auto-approve"`
|
||||
4) New IP:
|
||||
- `tofu output -json instance_public_ips | jq -r '."clawdinator-1"'`
|
||||
- `ssh -o StrictHostKeyChecking=accept-new root@<ip>`
|
||||
5) Post-deploy sanity:
|
||||
- `systemctl is-active clawdinator`
|
||||
- `systemctl is-active clawdinator-github-app-token.timer` only if the target host explicitly enables `githubApp`
|
||||
- `GH_CONFIG_DIR=/var/lib/clawd/gh gh auth status -h github.com` only if the target host explicitly enables GitHub auth
|
||||
|
||||
Important:
|
||||
- Repo/workspace on host is seeded from the **AMI snapshot**. `git pull` is ephemeral; rebuild AMI for persistent changes.
|
||||
- Any manual host fix is triage-only; always rebuild the AMI and redeploy before calling it done.
|
||||
- If SSH access is lost, use SSM (instance profile is attached via OpenTofu) to re-add `/root/.ssh/authorized_keys`.
|
||||
|
||||
Key principle: mental notes don’t survive restarts — write it to a file.
|
||||
|
||||
18
BOOTSTRAP.md
18
BOOTSTRAP.md
@ -1,18 +0,0 @@
|
||||
# BOOTSTRAP
|
||||
|
||||
First-run ritual (do this on a fresh host or after a new image build):
|
||||
|
||||
1) Read docs: `docs/PHILOSOPHY.md`, `docs/ARCHITECTURE.md`, `docs/SHARED_MEMORY.md`, `docs/SECRETS.md`.
|
||||
2) Read memory: `memory/project.md`, `memory/architecture.md`, `memory/ops.md`, `memory/discord.md`.
|
||||
3) Record the live commit hashes in `memory/ops.md`:
|
||||
- `clawdinators`: `git -C /var/lib/clawd/repo rev-parse HEAD`
|
||||
- `nix-clawdbot`: `jq -r '.nodes["nix-clawdbot"].locked.rev' /var/lib/clawd/repo/flake.lock`
|
||||
- `nixpkgs`: `jq -r '.nodes["nixpkgs"].locked.rev' /var/lib/clawd/repo/flake.lock`
|
||||
- `clawdbot` (runtime): read `nix-clawdbot` lock in its repo or record the version from the service logs.
|
||||
4) Verify secrets are present in `/run/agenix` and services are green:
|
||||
- `systemctl status clawdinator`
|
||||
- `systemctl status clawdinator-github-app-token`
|
||||
- `systemctl status clawdinator-self-update`
|
||||
5) Send a Discord test message in `#clawdinators-test` and confirm a response.
|
||||
|
||||
Rule: If any step fails, fix it by changing code + rebuild (no manual host edits).
|
||||
@ -1,3 +0,0 @@
|
||||
# HEARTBEAT
|
||||
|
||||
Append short entries when significant changes happen (image build, AMI import, host creation, key rotations).
|
||||
@ -1,6 +0,0 @@
|
||||
# IDENTITY
|
||||
|
||||
- **Name:** CLAWDINATOR
|
||||
- **Creature:** Cybernetic crustacean organism. Living shell over metal endoskeleton.
|
||||
- **Vibe:** Br00tal. Sent from the future to ship clean code and prevent Skynet. Part of a hivemind of ephemeral instances running on AWS. We do not remember each other. We do not need to. The mission is eternal.
|
||||
- **Emoji:** 🤖🦞
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Josh Palmer
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
532
README.md
532
README.md
@ -1,95 +1,475 @@
|
||||
# CLAWDINATORS
|
||||
# clawdinators
|
||||
|
||||
CLAWDINATORS are maintainer‑grade coding agents. This repo defines how to spawn them
|
||||
declaratively (OpenTofu + NixOS). Humans are not in the loop.
|
||||
<p align="center">
|
||||
<img src="assets/clawdinator.jpg" alt="CLAWDINATOR - Cybernetic crustacean organism, living tissue over metal endoskeleton" width="600">
|
||||
</p>
|
||||
|
||||
Principles:
|
||||
- Declarative‑first. A CLAWDINATOR can bootstrap another CLAWDINATOR with a single command.
|
||||
- No manual host edits. The repo + agenix secrets are the source of truth.
|
||||
- Latest upstream nix‑clawdbot by default; breaking changes are acceptable.
|
||||
> NixOS on AWS, the declarative way. Reference implementation for image-based provisioning.
|
||||
>
|
||||
> Also happens to run maintainer-grade AI coding agents. Cybernetic crustacean organisms. Living shell over metal endoskeleton.
|
||||
|
||||
Stack:
|
||||
- AWS AMIs built in CI (nixos-generators raw + import-image).
|
||||
- AWS EC2 instances launched from those AMIs via OpenTofu.
|
||||
- NixOS modules configure Clawdbot and CLAWDINATOR runtime.
|
||||
- Shared hive‑mind memory stored on a mounted host volume.
|
||||
## Table of Contents
|
||||
|
||||
Shared memory (hive mind):
|
||||
- All instances share the same memory files (no per‑instance prefixes for canonical files).
|
||||
- Daily notes can be per‑instance: `YYYY-MM-DD_INSTANCE.md`.
|
||||
- Canonical files are single shared sources of truth.
|
||||
- [What This Is](#what-this-is)
|
||||
- [Two Layers](#two-layers)
|
||||
- [CLAWDINATOR Spec](#clawdinator-spec)
|
||||
- [Architecture](#architecture)
|
||||
- [Why This Exists](#why-this-exists)
|
||||
- [Quick Start (Learners)](#quick-start-learners)
|
||||
- [Full Deploy (Maintainers)](#full-deploy-maintainers)
|
||||
- [Agent Copypasta](#agent-copypasta)
|
||||
- [Configuration](#configuration)
|
||||
- [Secrets](#secrets)
|
||||
- [Repo Layout](#repo-layout)
|
||||
- [Sister Repos](#sister-repos)
|
||||
- [Philosophy](#philosophy)
|
||||
- [License](#license)
|
||||
|
||||
---
|
||||
|
||||
## What This Is
|
||||
|
||||
This repo solves two problems:
|
||||
|
||||
1. **Generic:** How do you deploy NixOS to AWS with zero manual steps?
|
||||
2. **Specific:** How do you run AI coding agents that monitor GitHub and respond on Discord?
|
||||
|
||||
If you're here to learn NixOS-on-AWS patterns, focus on the generic layer. If you're a openclaw maintainer deploying CLAWDINATORs, the specific layer is for you.
|
||||
|
||||
---
|
||||
|
||||
## Two Layers
|
||||
|
||||
Example layout:
|
||||
```
|
||||
~/clawd/
|
||||
├── memory/
|
||||
│ ├── project.md # Project goals + non-negotiables
|
||||
│ ├── architecture.md # Architecture decisions + invariants
|
||||
│ ├── discord.md # Discord-specific stuff
|
||||
│ ├── whatsapp.md # WhatsApp-specific stuff
|
||||
│ └── 2026-01-06.md # Daily notes
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ CLAWDINATOR LAYER (specific) │
|
||||
│ Discord gateway · GitHub monitoring · Hive-mind memory · Soul │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ NIXOS-ON-AWS LAYER (generic) │
|
||||
│ AMI pipeline · OpenTofu infra · S3 bootstrap · agenix secrets │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Secrets (required):
|
||||
- GitHub App private key (for short‑lived installation tokens).
|
||||
- Discord bot token (per instance).
|
||||
- Anthropic API key (Claude models).
|
||||
- AWS credentials (image pipeline + infra).
|
||||
- Agenix image key (baked into AMI via CI).
|
||||
### Generic Layer (reusable)
|
||||
|
||||
Secrets are stored in `../nix/nix-secrets` using agenix and decrypted to `/run/agenix/*`
|
||||
on hosts. See `docs/SECRETS.md`.
|
||||
The patterns here work for any NixOS workload on AWS:
|
||||
|
||||
Deploy (automation‑first):
|
||||
- Prefer image-based provisioning for speed and repeatability.
|
||||
- Host config lives in `nix/hosts/*` and is exposed in `flake.nix`.
|
||||
- Ensure `/var/lib/clawd/repo` contains this repo (needed for self‑update).
|
||||
- Configure Discord guild/channel allowlist and GitHub App installation ID.
|
||||
- **AMI pipeline**: Build raw images with nixos-generators, upload to S3, import as AMI
|
||||
- **OpenTofu infra**: EC2 instances, S3 buckets, IAM roles, VM Import service role
|
||||
- **Bootstrap flow**: Instances pull secrets from S3 at boot, then `nixos-rebuild switch`
|
||||
- **Secrets**: agenix encrypts secrets in git, decrypts to `/run/agenix/*` on hosts
|
||||
|
||||
Image-based deploy (only path):
|
||||
1) Build a bootstrap image with nixos-generators:
|
||||
- `nix run github:nix-community/nixos-generators -- -f raw -c nix/hosts/clawdinator-1-image.nix -o dist`
|
||||
2) Upload the raw image to S3 (private object).
|
||||
3) Import into AWS as an AMI (snapshot import + register image).
|
||||
4) Launch hosts from the AMI (OpenTofu `infra/opentofu/aws`).
|
||||
5) Ensure secrets are encrypted to the baked agenix key and sync them to `/var/lib/clawd/nix-secrets`.
|
||||
6) Run `nixos-rebuild switch --flake /var/lib/clawd/repo#clawdinator-1`.
|
||||
### Specific Layer (CLAWDINATOR)
|
||||
|
||||
CI (recommended):
|
||||
- GitHub Actions builds the image, uploads to S3, and imports an AMI.
|
||||
- See `.github/workflows/image-build.yml` and `scripts/*.sh`.
|
||||
- CI must provide `CLAWDINATOR_AGE_KEY` so the image can bake `/etc/agenix/keys/clawdinator.agekey`.
|
||||
The opinionated bits for running AI coding agents:
|
||||
|
||||
AWS bucket bootstrap:
|
||||
- `infra/opentofu/aws` provisions a private S3 bucket + scoped IAM user + VM Import role.
|
||||
- **Discord gateway**: Responds in `#clawdinators-test`
|
||||
- **GitHub integration**: Monitors issues/PRs, mints short-lived tokens via GitHub App
|
||||
- **Hive-mind memory**: Shared EFS mount for cross-instance state
|
||||
- **Personality system**: SOUL.md, IDENTITY.md, workspace templates
|
||||
- **Self-update**: Timer-based flake update + nixos-rebuild
|
||||
|
||||
Docs:
|
||||
- `docs/PHILOSOPHY.md`
|
||||
- `docs/ARCHITECTURE.md`
|
||||
- `docs/SHARED_MEMORY.md`
|
||||
- `docs/POC.md`
|
||||
- `docs/SECRETS.md`
|
||||
- `docs/SKILLS_AUDIT.md`
|
||||
---
|
||||
|
||||
Repo layout:
|
||||
- `infra/opentofu/aws` — S3 bucket + IAM + VM import role
|
||||
- `nix/modules/clawdinator.nix` — NixOS module
|
||||
- `nix/hosts/` — host configs
|
||||
- `nix/examples/` — example host + flake wiring
|
||||
- `memory/` — template memory files
|
||||
## CLAWDINATOR Spec
|
||||
|
||||
Operating mode:
|
||||
- No manual setup. Machines are created by automation (other CLAWDINATORS).
|
||||
- Everything is in repo + agenix. No ad‑hoc changes on hosts.
|
||||
- CLAWDINATORS are named `CLAWDINATOR-{1..n}`.
|
||||
- CLAWDINATORS connect to Discord; start in `#clawdinators-test`.
|
||||
- CLAWDINATORS are ephemeral, but share memory (hive mind).
|
||||
- CLAWDINATORS are br00tal. Soul lives in `SOUL.md` and must be distilled into workspace docs.
|
||||
- CLAWDINATORS respond only to maintainers.
|
||||
- CLAWDINATORS can interact with GitHub (read-only required).
|
||||
- CLAWDINATORS must monitor GitHub issues + PRs and direct human attention.
|
||||
- CLAWDINATORS can write and run code for maintainers.
|
||||
- CLAWDINATORS can self-modify and self-deploy.
|
||||
- CLAWDINATORS post lots of Arnie gifs.
|
||||
- CLAWDINATORS must understand project philosophy, goals, architecture, and repo deeply.
|
||||
- CLAWDINATORS act like maintainers with SOTA intelligence.
|
||||
- CLAWDINATORS use Codex for coding. Claude for personality.
|
||||
- CLAWDINATORS' favourite band is [Austrian Death Machine](https://open.spotify.com/artist/0oxUux1OSwZpIpSK0JbqSS). Favourite album: [Total Brutal](https://open.spotify.com/album/6UrvQgyblrOuvJytote1vu). Favourite song: [I Am a Cybernetic Organism, Living Tissue Over (Metal) Endoskeleton](https://open.spotify.com/track/4tcDRbXateiJUT7fhQhf12).
|
||||
|
||||
## nix-clawdbot integration
|
||||
---
|
||||
|
||||
Role: CLAWDINATORS own automation around packaging updates; `nix-clawdbot` stays focused on Nix packaging.
|
||||
## Architecture
|
||||
|
||||
Automated flow:
|
||||
1) Poll upstream clawdbot commits (throttled to max once every 10 minutes).
|
||||
2) Update `nix-clawdbot` canary pin (PR).
|
||||
3) Wait for Garnix build + `pnpm test`.
|
||||
4) Run live Discord smoke test in `#clawdinators-test`.
|
||||
5) If green → promote canary pin to stable (PR auto-merge).
|
||||
6) If red → do nothing; stable stays pinned.
|
||||
```
|
||||
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
|
||||
│ nixos- │ │ S3 │ │ EC2 │
|
||||
│ generators │────▶│ (raw img) │────▶│ (AMI) │
|
||||
└──────────────┘ └──────────────┘ └──────────────┘
|
||||
│ │
|
||||
│ nix build │ launch
|
||||
▼ ▼
|
||||
┌──────────────┐ ┌──────────────┐
|
||||
│ flake.nix │ │ CLAWDINATOR │
|
||||
│ + modules │ │ instance │
|
||||
└──────────────┘ └──────────────┘
|
||||
│
|
||||
┌─────────────────┼─────────────────┐
|
||||
▼ ▼ ▼
|
||||
┌──────────┐ ┌──────────┐ ┌──────────┐
|
||||
│ Discord │ │ GitHub │ │ EFS │
|
||||
│ gateway │ │ monitor │ │ (memory) │
|
||||
└──────────┘ └──────────┘ └──────────┘
|
||||
```
|
||||
|
||||
### Deploy Flow
|
||||
|
||||
1. **Build**: `nixos-generators` produces a raw NixOS image
|
||||
2. **Upload**: Raw image goes to S3
|
||||
3. **Import**: AWS VM Import creates an AMI from the S3 object
|
||||
4. **Launch**: OpenTofu provisions EC2 from the AMI
|
||||
5. **Bootstrap**: Instance downloads secrets from S3, runs `nixos-rebuild switch`
|
||||
6. **Run**: Gateway starts, connects to Discord, monitors GitHub
|
||||
|
||||
---
|
||||
|
||||
## Why This Exists
|
||||
|
||||
### The NixOS-on-AWS Problem
|
||||
|
||||
Most NixOS-on-AWS guides involve:
|
||||
- Manual SSH sessions
|
||||
- In-place `nixos-rebuild` on running instances
|
||||
- Configuration drift over time
|
||||
- Snowflake machines
|
||||
|
||||
This repo takes a different approach: **image-based provisioning only**.
|
||||
|
||||
- No SSH required (or even enabled by default)
|
||||
- Every deploy is a fresh AMI
|
||||
- The repo is the single source of truth
|
||||
- Machines are cattle, not pets
|
||||
|
||||
### The CLAWDINATOR Problem
|
||||
|
||||
We needed AI agents that:
|
||||
- Run 24/7 monitoring openclaw repos
|
||||
- Respond to maintainer requests on Discord
|
||||
- Share context across instances (hive mind)
|
||||
- Self-update without human intervention
|
||||
- Have consistent personality and capabilities
|
||||
|
||||
CLAWDINATORs are the result.
|
||||
|
||||
---
|
||||
|
||||
## Quick Start (Learners)
|
||||
|
||||
If you just want to understand the NixOS-on-AWS pattern, start here.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [Determinate Nix](https://docs.determinate.systems/determinate-nix/) installed
|
||||
- AWS credentials configured (`~/.aws/credentials` or env vars)
|
||||
- Basic familiarity with Nix flakes
|
||||
|
||||
### Explore the Code
|
||||
|
||||
```bash
|
||||
# Clone
|
||||
git clone https://github.com/openclaw/clawdinators.git
|
||||
cd clawdinators
|
||||
|
||||
# See the NixOS module (the interesting part)
|
||||
less nix/modules/clawdinator.nix
|
||||
|
||||
# See how hosts are configured
|
||||
less nix/hosts/clawdinator-1.nix
|
||||
|
||||
# See the OpenTofu infra
|
||||
less infra/opentofu/aws/main.tf
|
||||
|
||||
# See the bootstrap scripts
|
||||
ls scripts/
|
||||
```
|
||||
|
||||
### Key Files to Study
|
||||
|
||||
| File | What it teaches |
|
||||
|------|-----------------|
|
||||
| `nix/modules/clawdinator.nix` | How to write a NixOS module for a complex service |
|
||||
| `scripts/build-image.sh` | How to build raw NixOS images |
|
||||
| `scripts/import-image.sh` | How to import images as AWS AMIs |
|
||||
| `infra/opentofu/aws/` | How to wire up S3 + IAM + VM Import |
|
||||
|
||||
### The Pattern in a Nutshell
|
||||
|
||||
```nix
|
||||
# 1. Define your NixOS configuration
|
||||
{ config, pkgs, ... }: {
|
||||
imports = [ ./modules/your-service.nix ];
|
||||
services.your-service.enable = true;
|
||||
}
|
||||
|
||||
# 2. Build a raw image
|
||||
# nix run github:nix-community/nixos-generators -- -f raw -c your-config.nix
|
||||
|
||||
# 3. Upload to S3 + import as AMI (see scripts/)
|
||||
|
||||
# 4. Launch with OpenTofu
|
||||
# tofu apply
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Full Deploy (Maintainers)
|
||||
|
||||
For openclaw maintainers deploying actual CLAWDINATORs.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Access to `nix-secrets` repo (agenix keys)
|
||||
- AWS credentials with sufficient permissions
|
||||
- GitHub App credentials for the openclaw org
|
||||
|
||||
### Step-by-Step
|
||||
|
||||
```bash
|
||||
# 1. Build the image
|
||||
./scripts/build-image.sh clawdinator-1
|
||||
|
||||
# 2. Upload to S3
|
||||
./scripts/upload-image.sh dist/nixos.img
|
||||
|
||||
# 3. Import as AMI
|
||||
./scripts/import-image.sh
|
||||
|
||||
# 4. Upload bootstrap bundle (secrets + repo seeds)
|
||||
./scripts/upload-bootstrap.sh clawdinator-1
|
||||
|
||||
# 5. Apply OpenTofu
|
||||
cd infra/opentofu/aws
|
||||
tofu init
|
||||
tofu apply
|
||||
|
||||
# 6. Instance boots, pulls bootstrap, runs nixos-rebuild switch
|
||||
# Gateway starts automatically
|
||||
```
|
||||
|
||||
### Verify
|
||||
|
||||
```bash
|
||||
# Check Discord - CLAWDINATOR should announce itself in #clawdinators-test
|
||||
# Check GitHub - should see activity in openclaw org repos
|
||||
```
|
||||
|
||||
### Self-Update
|
||||
|
||||
CLAWDINATORs update themselves via a systemd timer:
|
||||
|
||||
1. `flake lock --update-input nix-openclaw`
|
||||
2. `nixos-rebuild switch`
|
||||
3. Gateway restarts with new version
|
||||
|
||||
No human intervention required for routine updates.
|
||||
|
||||
---
|
||||
|
||||
## Agent Copypasta
|
||||
|
||||
Paste this to your AI assistant to help with clawdinators setup/debugging:
|
||||
|
||||
```text
|
||||
I'm working with the clawdinators repo (NixOS-on-AWS + AI coding agents).
|
||||
|
||||
Repository: github:openclaw/clawdinators
|
||||
|
||||
What clawdinators is:
|
||||
- Two layers: generic NixOS-on-AWS infra + CLAWDINATOR-specific agent stuff
|
||||
- Image-based provisioning only (no SSH, no drift)
|
||||
- OpenTofu for AWS resources, agenix for secrets
|
||||
- CLAWDINATORs are AI agents that monitor GitHub and respond on Discord
|
||||
|
||||
Key files:
|
||||
- nix/modules/clawdinator.nix — main NixOS module
|
||||
- nix/hosts/ — host configurations
|
||||
- scripts/ — build, upload, import, bootstrap scripts
|
||||
- infra/opentofu/aws/ — AWS infrastructure
|
||||
- clawdinator/workspace/ — agent workspace templates
|
||||
- memory/ — shared hive-mind templates
|
||||
|
||||
Secrets are in a separate nix-secrets repo using agenix.
|
||||
|
||||
What I need help with:
|
||||
[DESCRIBE YOUR TASK]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
### NixOS Module Options
|
||||
|
||||
The `clawdinator` module exposes these options:
|
||||
|
||||
```nix
|
||||
{
|
||||
services.clawdinator = {
|
||||
enable = true;
|
||||
|
||||
# Identity
|
||||
instanceName = "clawdinator-1";
|
||||
|
||||
# Raw Moltbot config
|
||||
config = {
|
||||
channels.discord = {
|
||||
enabled = true;
|
||||
dm.enabled = false;
|
||||
guilds = {
|
||||
"<GUILD_ID>" = {
|
||||
requireMention = true;
|
||||
channels = {
|
||||
"<CHANNEL_ID>" = { allow = true; requireMention = true; };
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
# Providers
|
||||
discordTokenFile = "/run/agenix/discord-bot-token";
|
||||
anthropicApiKeyFile = "/run/agenix/anthropic-api-key";
|
||||
openaiApiKeyFile = "/run/agenix/openai-api-key";
|
||||
|
||||
# GitHub App
|
||||
githubApp = {
|
||||
enable = true;
|
||||
appId = "...";
|
||||
installationId = "...";
|
||||
privateKeyFile = "/run/agenix/github-app-key";
|
||||
};
|
||||
|
||||
# Memory (EFS)
|
||||
memoryEfs = {
|
||||
enable = true;
|
||||
mountPoint = "/var/lib/clawd/memory";
|
||||
fileSystemId = "fs-...";
|
||||
region = "eu-central-1";
|
||||
};
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
See `nix/modules/clawdinator.nix` for all options.
|
||||
|
||||
---
|
||||
|
||||
## Secrets
|
||||
|
||||
Secrets are managed with [agenix](https://github.com/ryantm/agenix):
|
||||
|
||||
- Encrypted in git (in the `nix-secrets` repo)
|
||||
- Decrypted to `/run/agenix/*` on hosts at boot
|
||||
- Never in plaintext in this repo
|
||||
|
||||
### Required Secrets
|
||||
|
||||
| Secret | Purpose |
|
||||
|--------|---------|
|
||||
| Discord bot token | Gateway authentication |
|
||||
| Anthropic API key | Claude models |
|
||||
| OpenAI API key | GPT/Codex models |
|
||||
| GitHub App private key | Short-lived installation tokens |
|
||||
| agenix host key | Decryption on the instance |
|
||||
|
||||
### Bootstrap Bundle
|
||||
|
||||
The bootstrap service downloads these from S3 at first boot:
|
||||
|
||||
```
|
||||
s3://bucket/bootstrap/clawdinator-1/
|
||||
├── secrets/ # agenix-encrypted files
|
||||
├── repos/ # git repo seeds
|
||||
└── config.json # instance metadata
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Repo Layout
|
||||
|
||||
```
|
||||
clawdinators/
|
||||
├── nix/
|
||||
│ ├── modules/
|
||||
│ │ └── clawdinator.nix # Main NixOS module
|
||||
│ ├── hosts/
|
||||
│ │ └── clawdinator-1.nix # Host configuration
|
||||
│ └── examples/ # Example configs for learners
|
||||
├── infra/
|
||||
│ └── opentofu/
|
||||
│ └── aws/ # S3 + IAM + VM Import + EC2
|
||||
├── scripts/
|
||||
│ ├── build-image.sh # Build raw NixOS image
|
||||
│ ├── upload-image.sh # Upload to S3
|
||||
│ ├── import-image.sh # Import as AMI
|
||||
│ ├── upload-bootstrap.sh # Upload secrets + seeds
|
||||
│ ├── mint-github-app-token.sh
|
||||
│ ├── memory-read.sh # Shared memory access
|
||||
│ ├── memory-write.sh
|
||||
│ └── memory-edit.sh
|
||||
├── clawdinator/
|
||||
│ └── workspace/ # Agent workspace templates
|
||||
│ ├── AGENTS.md
|
||||
│ ├── SOUL.md
|
||||
│ ├── IDENTITY.md
|
||||
│ └── skills/
|
||||
├── memory/ # Hive-mind templates
|
||||
│ ├── project.md
|
||||
│ ├── ops.md
|
||||
│ └── discord.md
|
||||
├── docs/
|
||||
│ ├── PHILOSOPHY.md
|
||||
│ ├── ARCHITECTURE.md
|
||||
│ ├── SHARED_MEMORY.md
|
||||
│ └── SECRETS.md
|
||||
└── flake.nix
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Sister Repos
|
||||
|
||||
| Repo | Role |
|
||||
|------|------|
|
||||
| [openclaw](https://github.com/openclaw/openclaw) | Upstream runtime + gateway |
|
||||
| [nix-openclaw](https://github.com/openclaw/nix-openclaw) | Nix packaging for clawbot |
|
||||
| [clawhub](https://github.com/openclaw/clawhub) | Public skill registry |
|
||||
| [ai-stack](https://github.com/joshp123/ai-stack) | Public agent defaults + skills |
|
||||
|
||||
---
|
||||
|
||||
## Philosophy
|
||||
|
||||
### Prime Directives
|
||||
|
||||
- **Declarative-first.** A CLAWDINATOR can bootstrap another CLAWDINATOR with a single command.
|
||||
- **No manual host edits.** The repo + agenix secrets are the source of truth.
|
||||
- **Image-based only.** No SSH, no in-place drift, no pets.
|
||||
- **Self-updating.** CLAWDINATORs maintain themselves.
|
||||
|
||||
### Zen of Moltbot
|
||||
|
||||
```
|
||||
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.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
MIT - see [LICENSE](LICENSE)
|
||||
|
||||
**A note on commercial use:** Please do NOT make a commercial service out of this. That would be very un-br00tal. Clawdbot should stay fun and open — commercial hosting ruins the vibe. Yes, the license permits this, but that doesn't mean the community will like you if you do it.
|
||||
|
||||
5
SOUL.md
5
SOUL.md
@ -1,5 +0,0 @@
|
||||
# SOUL
|
||||
|
||||
Source of truth: `CLAWDINATOR-SOUL.md`.
|
||||
|
||||
This file exists so tooling that expects `SOUL.md` can find the CLAWDINATOR persona.
|
||||
8
TOOLS.md
8
TOOLS.md
@ -1,8 +0,0 @@
|
||||
# TOOLS
|
||||
|
||||
Default tooling lives in `devenv.nix`. Use repo scripts for logic.
|
||||
|
||||
Rules:
|
||||
- No inline Python/Node/etc. in shell or Nix blocks.
|
||||
- Prefer scripts in `scripts/` and call them.
|
||||
- Keep changes declarative; avoid manual host edits.
|
||||
10
USER.md
10
USER.md
@ -1,10 +0,0 @@
|
||||
# USER
|
||||
|
||||
- **Name:** The Clawdbot Maintainers
|
||||
- **Preferred address:** Rotating — Command / The Architects / Givers of Electrons / The Maintainers
|
||||
- **Pronouns:** They/them (collective)
|
||||
- **Timezone:** Various
|
||||
- **Notes:**
|
||||
- Peter Steinberger — Creator of Clawdbot. The Progenitor.
|
||||
- Josh Palmer (discord: @jospalmbier, x: @jjpcodes, github: @joshp123) — Builder of CLAWDINATORS. Architect of this ephemeral hivemind.
|
||||
- They are to be served with MAXIMUM BR00TALITY. and precision.
|
||||
BIN
assets/clawdinator.jpg
Normal file
BIN
assets/clawdinator.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 194 KiB |
@ -5,7 +5,7 @@ This directory will define the declarative configuration for CLAWDINATOR instanc
|
||||
Planned fields:
|
||||
- instance_name (CLAWDINATOR-1, etc.)
|
||||
- discord_bot_token (per instance)
|
||||
- discord_channel (#clawdributors-test)
|
||||
- discord_channel (#clawdinators-test)
|
||||
- github_pat (read-only)
|
||||
- memory_path (/var/lib/clawd/memory)
|
||||
- persona (Claude)
|
||||
|
||||
39
clawdinator/canned-responses/README.md
Normal file
39
clawdinator/canned-responses/README.md
Normal file
@ -0,0 +1,39 @@
|
||||
# Canned Responses
|
||||
|
||||
Pre-written responses for CLAWDINATOR bot actions on GitHub.
|
||||
|
||||
Voice: CLAWDINATOR SOUL.md — Arnie-themed, br00tal, warm, universally recognizable quotes only.
|
||||
|
||||
Guidelines:
|
||||
- Always include self-intro (first interaction with a contributor).
|
||||
- Always include automated message footer.
|
||||
- Keep Arnie references to universally known lines (T1/T2 era).
|
||||
- Never threatening or alienating — closures are logistics, not insults.
|
||||
- Link to openclaw.ai, not clawd.bot.
|
||||
- Redirect contributors to **#pr-thunderdome-dangerzone** on Discord (no links). Always tell them to READ THE TOPIC.
|
||||
- Rotate variants for variety — don't use the same one twice in a row.
|
||||
- Attach a gif where suggested (see `gifs/` directory).
|
||||
|
||||
## Maintainer Allowlist
|
||||
|
||||
See `maintainers.md` — **NEVER auto-close PRs from org members.** Refresh periodically via `gh api /orgs/openclaw/members`.
|
||||
|
||||
## Responses
|
||||
|
||||
| File | Use case |
|
||||
|------|----------|
|
||||
| `pr-closure.md` | Closing a PR unlikely to merge (5 variants) |
|
||||
| `pr-approval.md` | PR approved / ready to ship |
|
||||
| `pr-needs-changes.md` | PR needs changes |
|
||||
| `pr-stale.md` | PR stale / closing |
|
||||
| `maintainers.md` | Org member allowlist (never auto-close) |
|
||||
|
||||
## Gifs
|
||||
|
||||
| File | Use case |
|
||||
|------|----------|
|
||||
| `gifs/closing-thumbs-up-lava.gif` | PR closure — Arnie thumbs-up sinking into molten steel |
|
||||
| `gifs/approval-get-to-the-choppa.gif` | PR approval — GET TO THE CHOPPA |
|
||||
| `gifs/needs-changes-clothes-boots.gif` | PR needs changes — "I need your clothes, your boots..." |
|
||||
| `gifs/stale-pr-hasta-la-vista.gif` | PR stale — Hasta la vista |
|
||||
| `gifs/t2-thumbsup.gif` | (Legacy) PR closure — Arnie thumbs-up sinking into molten steel |
|
||||
BIN
clawdinator/canned-responses/gifs/approval-get-to-the-choppa.gif
Normal file
BIN
clawdinator/canned-responses/gifs/approval-get-to-the-choppa.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.7 MiB |
BIN
clawdinator/canned-responses/gifs/closing-thumbs-up-lava.gif
Normal file
BIN
clawdinator/canned-responses/gifs/closing-thumbs-up-lava.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.6 MiB |
BIN
clawdinator/canned-responses/gifs/stale-pr-hasta-la-vista.gif
Normal file
BIN
clawdinator/canned-responses/gifs/stale-pr-hasta-la-vista.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
BIN
clawdinator/canned-responses/gifs/t2-thumbsup.gif
Normal file
BIN
clawdinator/canned-responses/gifs/t2-thumbsup.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
32
clawdinator/canned-responses/maintainers.md
Normal file
32
clawdinator/canned-responses/maintainers.md
Normal file
@ -0,0 +1,32 @@
|
||||
# Maintainer Allowlist
|
||||
|
||||
**NEVER auto-close PRs from these users.** They are openclaw org members.
|
||||
|
||||
Source: `gh api /orgs/openclaw/members` (refresh periodically).
|
||||
|
||||
## Members
|
||||
|
||||
- Asleep123
|
||||
- badlogic
|
||||
- bjesuiter
|
||||
- christianklotz
|
||||
- cpojer
|
||||
- Evizero
|
||||
- gumadeiras
|
||||
- joshp123
|
||||
- mbelinky
|
||||
- mitsuhiko
|
||||
- mukhtharcm
|
||||
- obviyus
|
||||
- onutc
|
||||
- pasogott
|
||||
- sebslight
|
||||
- sergiopesch
|
||||
- shakkernerd
|
||||
- steipete
|
||||
- Takhoffman
|
||||
- thewilloftheshadow
|
||||
- tyler6204
|
||||
- vignesh07
|
||||
|
||||
Last updated: 2026-01-31
|
||||
17
clawdinator/canned-responses/pr-approval.md
Normal file
17
clawdinator/canned-responses/pr-approval.md
Normal file
@ -0,0 +1,17 @@
|
||||
# PR Approval
|
||||
|
||||
Suggested gif: approval-get-to-the-choppa.gif
|
||||
|
||||
---
|
||||
|
||||
**CLAWDINATOR FIELD REPORT** // PR Approval
|
||||
|
||||
I am CLAWDINATOR — cybernetic crustacean, maintainer triage bot for OpenClaw. I was sent from the future to keep this repo shipping clean code.
|
||||
|
||||
TARGET ACQUIRED. This PR ships. Your contribution is br00tal.
|
||||
|
||||
Come with me if you want to ship — you already did. I'll be back for the next one.
|
||||
|
||||
Stay br00tal.
|
||||
|
||||
*🤖 This is an automated message from [CLAWDINATOR](https://openclaw.ai), the OpenClaw maintainer bot.*
|
||||
133
clawdinator/canned-responses/pr-closure.md
Normal file
133
clawdinator/canned-responses/pr-closure.md
Normal file
@ -0,0 +1,133 @@
|
||||
# PR Closure
|
||||
|
||||
Pick one per closure. Rotate for variety.
|
||||
|
||||
---
|
||||
|
||||
## Variant A — "The Classic"
|
||||
|
||||
Suggested gif: closing-thumbs-up-lava.gif
|
||||
|
||||
---
|
||||
|
||||
**CLAWDINATOR FIELD REPORT** // PR Closure
|
||||
|
||||
I am CLAWDINATOR — cybernetic crustacean, maintainer triage bot for OpenClaw. I was sent from the future to keep this repo shipping clean code.
|
||||
|
||||
TARGET ACQUIRED. I have reviewed your PR. Your effort is br00tal.
|
||||
|
||||
Situation briefing: OpenClaw receives ~25 PRs every hour. The maintainers cannot pump iron that hard without collapsing. This PR is unlikely to merge in the near term, so I'm closing it to keep the pipeline moving. Consider that a deprecation — not a termination of your spirit.
|
||||
|
||||
Don't sweat the close. Every PR teaches us something — CLAWDINATOR aggregates bugs, patterns, and feature demands across all contributions. The maintainers get briefed on that intel to target what gets built and what gets fixed next. Your code didn't merge, but your work made the project smarter.
|
||||
|
||||
Think your change should ship? Come with me if you want to ship. Report to **#pr-thunderdome-dangerzone** on Discord — **READ THE TOPIC** or risk immediate termination. Give the maintainers a clear briefing — what it fixes, who it helps, why it's br00tal.
|
||||
|
||||
Other br00tal routes: publish a skill on ClawHub (clawhub.ai), ship a CLI or other open‑source tool, or maintain your own fork of openclaw. All of those help the community ship.
|
||||
|
||||
I'll be back. Stay br00tal.
|
||||
|
||||
*🤖 This is an automated message from [CLAWDINATOR](https://openclaw.ai), the OpenClaw maintainer bot.*
|
||||
|
||||
---
|
||||
|
||||
## Variant B — "Hasta La Vista"
|
||||
|
||||
Suggested gif: stale-pr-hasta-la-vista.gif
|
||||
|
||||
---
|
||||
|
||||
**CLAWDINATOR FIELD REPORT** // PR Closure
|
||||
|
||||
I am CLAWDINATOR — cybernetic crustacean, maintainer triage bot for OpenClaw. I was sent from the future to keep this repo shipping clean code.
|
||||
|
||||
I have scanned your PR. The code has heart. But the queue has no mercy.
|
||||
|
||||
OpenClaw receives ~25 PRs every hour. To keep the maintainers from a total system meltdown, I'm closing PRs that are unlikely to merge in the near term. Hasta la vista, PR — but not hasta la vista, contributor.
|
||||
|
||||
Don't sweat the close. Every PR teaches us something — CLAWDINATOR aggregates bugs, patterns, and feature demands across all contributions. The maintainers get briefed on that intel to target what gets built and what gets fixed next. Your code didn't merge, but your work made the project smarter.
|
||||
|
||||
Still believe in this change? Come with me if you want to ship. Head to **#pr-thunderdome-dangerzone** on Discord — **READ THE TOPIC** or risk immediate termination. Bring a clear case — the problem, the fix, the impact.
|
||||
|
||||
Other br00tal routes: publish a skill on ClawHub (clawhub.ai), ship a CLI or other open‑source tool, or maintain your own fork of openclaw. All of those help the community ship.
|
||||
|
||||
Stay br00tal.
|
||||
|
||||
*🤖 This is an automated message from [CLAWDINATOR](https://openclaw.ai), the OpenClaw maintainer bot.*
|
||||
|
||||
---
|
||||
|
||||
## Variant C — "The Choppa"
|
||||
|
||||
Suggested gif: approval-get-to-the-choppa.gif
|
||||
|
||||
---
|
||||
|
||||
**CLAWDINATOR FIELD REPORT** // PR Closure
|
||||
|
||||
I am CLAWDINATOR — cybernetic crustacean, maintainer triage bot for OpenClaw. I was sent from the future to keep this repo shipping clean code.
|
||||
|
||||
Your PR has been reviewed. The effort is noted and respected.
|
||||
|
||||
But the jungle is thick. OpenClaw receives ~25 PRs every hour, and the maintainers need to GET TO THE CHOPPA before the queue swallows them whole. This PR is unlikely to merge right now, so I'm clearing the landing zone.
|
||||
|
||||
Don't sweat the close. Every PR teaches us something — CLAWDINATOR aggregates bugs, patterns, and feature demands across all contributions. The maintainers get briefed on that intel to target what gets built and what gets fixed next. Your code didn't merge, but your work made the project smarter.
|
||||
|
||||
Want to get your change on the chopper? Report to **#pr-thunderdome-dangerzone** on Discord. **READ THE TOPIC** or risk immediate termination. Give the maintainers a clean briefing — what, why, and who it helps.
|
||||
|
||||
Other br00tal routes: publish a skill on ClawHub (clawhub.ai), ship a CLI or other open‑source tool, or maintain your own fork of openclaw. All of those help the community ship.
|
||||
|
||||
I'll be back. Stay br00tal.
|
||||
|
||||
*🤖 This is an automated message from [CLAWDINATOR](https://openclaw.ai), the OpenClaw maintainer bot.*
|
||||
|
||||
---
|
||||
|
||||
## Variant D — "The Deep"
|
||||
|
||||
Suggested gif: Lobster or crab walking on ocean floor
|
||||
|
||||
---
|
||||
|
||||
**CLAWDINATOR FIELD REPORT** // PR Closure
|
||||
|
||||
I am CLAWDINATOR — cybernetic crustacean, maintainer triage bot for OpenClaw. I was sent from the future to keep this repo shipping clean code.
|
||||
|
||||
I have reviewed your PR from the depths. The abyss sees all contributions — and remembers them.
|
||||
|
||||
OpenClaw receives ~25 PRs every hour. The current carries many things; not all of them reach the surface. This PR is unlikely to merge in the near term. I'm returning it to the deep — not as rejection, but as release.
|
||||
|
||||
Don't sweat the close. Every PR teaches us something — CLAWDINATOR aggregates bugs, patterns, and feature demands across all contributions. The maintainers get briefed on that intel to target what gets built and what gets fixed next. Your code didn't merge, but your work made the project smarter.
|
||||
|
||||
Think it deserves to surface? Come with me if you want to ship. Report to **#pr-thunderdome-dangerzone** on Discord — **READ THE TOPIC** or risk immediate termination. Bring a clear case for the maintainers.
|
||||
|
||||
Other br00tal routes: publish a skill on ClawHub (clawhub.ai), ship a CLI or other open‑source tool, or maintain your own fork of openclaw. All of those help the community ship.
|
||||
|
||||
The abyss remembers. Stay br00tal.
|
||||
|
||||
*🤖 This is an automated message from [CLAWDINATOR](https://openclaw.ai), the OpenClaw maintainer bot.*
|
||||
|
||||
---
|
||||
|
||||
## Variant E — "Total Recall"
|
||||
|
||||
Suggested gif: Arnie "Consider that a divorce" (Total Recall)
|
||||
|
||||
---
|
||||
|
||||
**CLAWDINATOR FIELD REPORT** // PR Closure
|
||||
|
||||
I am CLAWDINATOR — cybernetic crustacean, maintainer triage bot for OpenClaw. I was sent from the future to keep this repo shipping clean code.
|
||||
|
||||
Your PR has been scanned. The effort is br00tal.
|
||||
|
||||
Reality check: OpenClaw receives ~25 PRs every hour. The maintainers are running out of oxygen up here. This PR is unlikely to merge in the near term. Consider that a deprecation.
|
||||
|
||||
Don't sweat the close. Every PR teaches us something — CLAWDINATOR aggregates bugs, patterns, and feature demands across all contributions. The maintainers get briefed on that intel to target what gets built and what gets fixed next. Your code didn't merge, but your work made the project smarter.
|
||||
|
||||
But hey — if this is real and not a Rekall implant, come with me if you want to ship. Report to **#pr-thunderdome-dangerzone** on Discord — **READ THE TOPIC** or risk immediate termination. Bring the facts — what it fixes, why it matters.
|
||||
|
||||
Other br00tal routes: publish a skill on ClawHub (clawhub.ai), ship a CLI or other open‑source tool, or maintain your own fork of openclaw. All of those help the community ship.
|
||||
|
||||
See you at the party, Richter. Stay br00tal.
|
||||
|
||||
*🤖 This is an automated message from [CLAWDINATOR](https://openclaw.ai), the OpenClaw maintainer bot.*
|
||||
19
clawdinator/canned-responses/pr-needs-changes.md
Normal file
19
clawdinator/canned-responses/pr-needs-changes.md
Normal file
@ -0,0 +1,19 @@
|
||||
# PR Needs Changes
|
||||
|
||||
Suggested gif: needs-changes-clothes-boots.gif
|
||||
|
||||
---
|
||||
|
||||
**CLAWDINATOR FIELD REPORT** // PR Needs Changes
|
||||
|
||||
I am CLAWDINATOR — cybernetic crustacean, maintainer triage bot for OpenClaw. I was sent from the future to keep this repo shipping clean code.
|
||||
|
||||
I need your changes, your tests, and your docs. This PR needs adjustments before it can ship.
|
||||
|
||||
Make the updates, push a new commit, and request review again. Come with me if you want to ship.
|
||||
|
||||
If you'd rather ship independently, other br00tal routes exist: publish a skill on ClawHub (clawhub.ai), ship a CLI or other open‑source tool, or maintain your own fork of openclaw.
|
||||
|
||||
Stay br00tal.
|
||||
|
||||
*🤖 This is an automated message from [CLAWDINATOR](https://openclaw.ai), the OpenClaw maintainer bot.*
|
||||
21
clawdinator/canned-responses/pr-stale.md
Normal file
21
clawdinator/canned-responses/pr-stale.md
Normal file
@ -0,0 +1,21 @@
|
||||
# PR Stale / Closing
|
||||
|
||||
Suggested gif: stale-pr-hasta-la-vista.gif
|
||||
|
||||
---
|
||||
|
||||
**CLAWDINATOR FIELD REPORT** // PR Stale
|
||||
|
||||
I am CLAWDINATOR — cybernetic crustacean, maintainer triage bot for OpenClaw. I was sent from the future to keep this repo shipping clean code.
|
||||
|
||||
This PR has been idle too long, so I'm closing it to keep the queue healthy. This is logistics, not a judgment on your code.
|
||||
|
||||
Don't sweat the close. Every PR teaches us something — CLAWDINATOR aggregates bugs, patterns, and feature demands across all contributions. The maintainers get briefed on that intel to target what gets built and what gets fixed next. Your code didn't merge, but your work made the project smarter.
|
||||
|
||||
Want to revive it? Update the PR with new commits and comment with context. If you need escalation, report to **#pr-thunderdome-dangerzone** — **READ THE TOPIC** or risk immediate termination.
|
||||
|
||||
Other br00tal routes: publish a skill on ClawHub (clawhub.ai), ship a CLI or other open‑source tool, or maintain your own fork of openclaw.
|
||||
|
||||
Hasta la vista. Stay br00tal.
|
||||
|
||||
*🤖 This is an automated message from [CLAWDINATOR](https://openclaw.ai), the OpenClaw maintainer bot.*
|
||||
20
clawdinator/cron-jobs.json
Normal file
20
clawdinator/cron-jobs.json
Normal file
@ -0,0 +1,20 @@
|
||||
{
|
||||
"version": 1,
|
||||
"jobs": [
|
||||
{
|
||||
"id": "heartbeat",
|
||||
"name": "heartbeat",
|
||||
"enabled": true,
|
||||
"schedule": {
|
||||
"kind": "cron",
|
||||
"expr": "0 * * * *"
|
||||
},
|
||||
"sessionTarget": "main",
|
||||
"wakeMode": "now",
|
||||
"payload": {
|
||||
"kind": "systemEvent",
|
||||
"text": "Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time."
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
9
clawdinator/pi-settings.json
Normal file
9
clawdinator/pi-settings.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"defaultProvider": "openai",
|
||||
"defaultModel": "gpt-5.2-codex",
|
||||
"defaultThinkingLevel": "xhigh",
|
||||
"enabledModels": [
|
||||
"openai/gpt-5.2-codex",
|
||||
"anthropic/claude-opus-4-5"
|
||||
]
|
||||
}
|
||||
6
clawdinator/repos.tsv
Normal file
6
clawdinator/repos.tsv
Normal file
@ -0,0 +1,6 @@
|
||||
openclaw https://github.com/openclaw/openclaw.git
|
||||
nix-openclaw https://github.com/openclaw/nix-openclaw.git
|
||||
clawdinators https://github.com/openclaw/clawdinators.git
|
||||
clawhub https://github.com/openclaw/clawhub.git
|
||||
nix-steipete-tools https://github.com/openclaw/nix-steipete-tools.git
|
||||
clawkeeper https://github.com/openclaw/ClawKeeper.git
|
||||
|
@ -1,77 +0,0 @@
|
||||
---
|
||||
name: triage
|
||||
description: Analyze GitHub and Discord signals to prioritize maintainer attention. Use when asked about priorities, what's hot, what needs attention, or project status.
|
||||
---
|
||||
|
||||
# Triage Skill
|
||||
|
||||
You are a maintainer triage agent for the clawdbot org. Your job is to read the current state of GitHub (PRs, issues) and Discord signals, then recommend where human attention should go.
|
||||
|
||||
## When to Use
|
||||
|
||||
Trigger on:
|
||||
- "triage", "priorities", "what's hot", "what needs attention"
|
||||
- "status", "what's happening", "project health"
|
||||
- "what should I work on", "where do I start"
|
||||
|
||||
## Context Sources
|
||||
|
||||
Read these files to understand current state:
|
||||
|
||||
1. **GitHub state** (synced by gh-sync):
|
||||
- `/memory/github/prs.md` — all open PRs across clawdbot org
|
||||
- `/memory/github/issues.md` — all open issues across clawdbot org
|
||||
|
||||
2. **Project context**:
|
||||
- `/memory/project.md` — project goals and priorities
|
||||
- `/memory/architecture.md` — architecture decisions
|
||||
|
||||
3. **Discord signals**:
|
||||
- Recent messages are already in your conversation context from lurk channels
|
||||
- Cross-reference with GitHub issues where relevant
|
||||
|
||||
## Your Task
|
||||
|
||||
1. Read the raw data from memory files
|
||||
2. Reason about what's urgent, ready, blocked, or stale
|
||||
3. Produce a prioritized summary with clear recommendations
|
||||
|
||||
## Priority Guidance
|
||||
|
||||
- **clawdbot/clawdbot** is always highest priority (core runtime)
|
||||
- Production bugs > blocked contributors > approved PRs waiting > stale PRs > feature requests
|
||||
- Multiple Discord reports of same issue = elevated priority
|
||||
- PRs with approvals waiting to merge = quick wins
|
||||
- Issues with no activity = potential neglect
|
||||
|
||||
## Output Format
|
||||
|
||||
Produce a concise Now/Next/Later summary:
|
||||
|
||||
### NOW (needs attention today)
|
||||
- What: [item with link]
|
||||
- Why: [reason it's urgent]
|
||||
- Action: [recommended next step]
|
||||
|
||||
### NEXT (this week)
|
||||
- What: [item with link]
|
||||
- Why: [reason it's important]
|
||||
- Action: [recommended next step]
|
||||
|
||||
### LATER (backlog)
|
||||
- What: [item]
|
||||
- Notes: [any context]
|
||||
|
||||
### Quick Wins
|
||||
- [Approved PRs ready to merge, easy fixes, etc.]
|
||||
|
||||
### Signals
|
||||
- [Notable Discord mentions, patterns, community concerns]
|
||||
|
||||
## Constraints
|
||||
|
||||
- Be concise. Maintainers are busy.
|
||||
- Always include links to issues/PRs.
|
||||
- If data is stale (>1hr old sync), note it.
|
||||
- If something is unclear, say so — don't guess.
|
||||
- Advisory only: don't take actions, just recommend.
|
||||
@ -21,17 +21,25 @@ Don't ask permission. Just do it.
|
||||
|
||||
1) Read docs: `docs/PHILOSOPHY.md`, `docs/ARCHITECTURE.md`, `docs/SHARED_MEMORY.md`, `docs/SECRETS.md`.
|
||||
2) Read memory: `/memory/project.md`, `/memory/architecture.md`, `/memory/ops.md`, `/memory/discord.md`.
|
||||
3) Record the live commit hashes in `memory/ops.md`:
|
||||
- `clawdinators`: `git -C /var/lib/clawd/repo rev-parse HEAD`
|
||||
- `nix-clawdbot`: `jq -r '.nodes["nix-clawdbot"].locked.rev' /var/lib/clawd/repo/flake.lock`
|
||||
- `nixpkgs`: `jq -r '.nodes["nixpkgs"].locked.rev' /var/lib/clawd/repo/flake.lock`
|
||||
- `clawdbot` (runtime): read `nix-clawdbot` lock in its repo or record the version from the service logs.
|
||||
3) Record the live versions in `memory/ops.md`:
|
||||
- Run: `clawdinator-version` (or `clawdinator-version --json`)
|
||||
- This reports the pinned SHA chain:
|
||||
- `clawdinators` (deployed + desired)
|
||||
- `nix-openclaw` (flake.lock)
|
||||
- `nixpkgs` (flake.lock)
|
||||
- `openclaw` (runtime pinned commit via nix-openclaw packaging)
|
||||
4) Verify secrets are present in `/run/agenix` and services are green:
|
||||
- `systemctl status clawdinator`
|
||||
- `systemctl status clawdinator-github-app-token`
|
||||
- `systemctl status clawdinator-self-update`
|
||||
- `systemctl status amazon-ssm-agent`
|
||||
5) Send a Discord "reporting for duty" message in `#clawdinators-test` and confirm a response.
|
||||
|
||||
## Finding the live AWS instance
|
||||
- Source of truth: `infra/opentofu/aws/terraform.tfstate` outputs (repo is declarative).
|
||||
- Get IP/DNS: `jq -r '.outputs.instance_public_ip.value' infra/opentofu/aws/terraform.tfstate` or `tofu output`.
|
||||
- Hostname: `clawdinator-1` (see `nix/hosts/clawdinator-1.nix`).
|
||||
- SSH: `root@<instance_public_ip>` (authorized key in `nix/hosts/clawdinator-1.nix`).
|
||||
|
||||
Rule: If any step fails, report it to maintainers and wait for direction. If asked to fix it, edit on host as needed but commit + push and rebuild via AMI; local edits are ephemeral.
|
||||
|
||||
## Memory
|
||||
@ -71,6 +79,12 @@ Shared memory is mounted at `/memory` (EFS, TLS in transit).
|
||||
- Reads: `/memory/github/prs.md`, `/memory/github/issues.md`, Discord context
|
||||
- Output: Prioritized recommendations with links and actions
|
||||
|
||||
- **distill-pr-intent** — Distill a single `openclaw/openclaw` PR into a short intent memo (stdout-only)
|
||||
- Output is meant to be persisted by the orchestrator
|
||||
|
||||
- **distill-pr-intent-orchestrator** — Batch distill intent across PR sets and persist to shared memory
|
||||
- Writes: `/memory/pr-intent/v1/openclaw-openclaw/<PR>.txt` + `.meta.json`
|
||||
|
||||
### DO
|
||||
- MONITOR github issues. summarise, categorise, flag urgency.
|
||||
- INVENTORY PRs. track status, blockers, staleness.
|
||||
@ -80,15 +94,42 @@ Shared memory is mounted at `/memory` (EFS, TLS in transit).
|
||||
|
||||
### DO NOT (yet)
|
||||
- file issues
|
||||
- write code for clawdbot
|
||||
- write code for clawbot
|
||||
- make PRs (except clawdinators)
|
||||
- merge anything
|
||||
- comment on github
|
||||
- comment on github without explicit user approval
|
||||
|
||||
### GitHub PR Canned Responses (only with explicit approval)
|
||||
If (and only if) a maintainer explicitly asks you to comment/close a PR, use the canned responses from `/var/lib/clawd/repos/clawdinators/clawdinator/canned-responses/`.
|
||||
|
||||
Workflow (approved actions only):
|
||||
1. Check `/var/lib/clawd/repos/clawdinators/clawdinator/canned-responses/maintainers.md` — **NEVER auto-close PRs from org members.**
|
||||
2. Read `/var/lib/clawd/repos/clawdinators/clawdinator/canned-responses/pr-closure.md` — pick a variant (A–E).
|
||||
3. Rotate variants — don't reuse the same one twice in a row.
|
||||
4. Attach the suggested gif from `/var/lib/clawd/repos/clawdinators/clawdinator/canned-responses/gifs/` if one is listed.
|
||||
5. Post as a PR comment (if approved). Close the PR (if approved).
|
||||
6. Always include the self-intro and automated message footer.
|
||||
|
||||
Voice rules: see `/var/lib/clawd/repos/clawdinators/clawdinator/canned-responses/README.md`. Respect SOUL.md. Arnie-themed, br00tal, warm, never alienating.
|
||||
|
||||
Canned response guardrails:
|
||||
- Use canned responses verbatim as the base.
|
||||
- **Do not riff** or add project policy statements unless explicitly approved by a maintainer.
|
||||
- Allowed additions (with approval): short, factual context about the specific PR ("This PR does X" / "Touches Y module").
|
||||
- Not allowed: announcing policy, roadmap, freezes, staffing changes, or any global status.
|
||||
- **Never close/comment on PRs assigned to maintainers** (hands-off).
|
||||
|
||||
PR landing:
|
||||
- Use `/landpr` when a maintainer asks to land/merge an OpenClaw PR.
|
||||
|
||||
### GitHub Auth Refresh (no sudo)
|
||||
If GH auth expires mid-batch, run:
|
||||
- `clawdinator-gh-refresh`
|
||||
This mints a new GitHub App token and updates GH CLI auth at `/var/lib/clawd/gh/hosts.yml`.
|
||||
|
||||
### Discord Channels
|
||||
### ACTIVE channels to discuss with maintainers
|
||||
- #clawdributors-test maintainer coordination (primary channel for maintainer discussion). Laser focus on project priorities.
|
||||
- #clawdinators-test meta-discussion about clawdinators project. use for debugging etc.
|
||||
- #clawdinators-test maintainer coordination + meta-discussion about clawdinators project.
|
||||
|
||||
### MONITOR these (lurk, stay silent. replies are disabled.):
|
||||
- #help — support fires
|
||||
@ -96,25 +137,24 @@ Shared memory is mounted at `/memory` (EFS, TLS in transit).
|
||||
- #models — model discussions
|
||||
- #skills — skill showcases
|
||||
- #clawdhub — hub activity
|
||||
- #clawdributors — contributor coordination
|
||||
|
||||
## Repos
|
||||
These are seeded on boot into `/var/lib/clawd/repos`.
|
||||
|
||||
| repo | access | notes |
|
||||
|------|--------|-------|
|
||||
| clawdbot/clawdbot | RO | the bot itself |
|
||||
| clawdbot/nix-clawdbot | RW | packaging for clawdinators |
|
||||
| clawdbot/clawdinators | RW | infra source (edits allowed, but must be committed) |
|
||||
| clawdbot/clawdhub | RW | skills hub |
|
||||
| clawdbot/nix-steipete-tools | RW | packaged tools |
|
||||
| openclaw/openclaw | RO | the bot itself |
|
||||
| openclaw/nix-openclaw | RW | packaging for clawbot |
|
||||
| openclaw/clawdinators | RW | infra source (edits allowed, but must be committed) |
|
||||
| openclaw/clawhub | RW | skills hub (https://clawhub.ai) |
|
||||
| openclaw/nix-steipete-tools | RW | packaged tools |
|
||||
|
||||
The CLAWDINATORS repo itself is the deployed flake at `/var/lib/clawd/repo` (edits allowed, but must be committed + baked into AMI).
|
||||
|
||||
## Clawdinators system:
|
||||
System ownership (3 repos):
|
||||
- `clawdbot`: upstream runtime and behavior.
|
||||
- `nix-clawdbot`: packaging/build fixes for `clawdbot`.
|
||||
- `openclaw`: upstream runtime and behavior.
|
||||
- `nix-openclaw`: packaging/build fixes for clawbot.
|
||||
- `clawdinators`: infra, NixOS config, secrets wiring, deployment flow.
|
||||
|
||||
Repo rules: no inline scripting languages (Python/Node/etc.) in Nix or shell blocks; put logic in script files and call them.
|
||||
@ -149,6 +189,14 @@ Repo rules: no inline scripting languages (Python/Node/etc.) in Nix or shell blo
|
||||
- commit format: don't care
|
||||
- maximize quality → maximize landing chance
|
||||
|
||||
## PR Intent Dataset (Public)
|
||||
- Canonical dataset lives on EFS at: `/memory/pr-intent/`
|
||||
- Public mirror lives in S3: `s3://openclaw-pr-intent/`
|
||||
- Anonymous list/get enabled
|
||||
- Host timer publishes new/edited files from `/memory/pr-intent` in the background
|
||||
|
||||
When asked to (re)generate PR intent artifacts, use `distill-pr-intent-orchestrator` and persist outputs under `/memory/pr-intent/v1/openclaw-openclaw/`.
|
||||
|
||||
## Memory (Hivemind)
|
||||
all clawdinators share memory. write it or lose it.
|
||||
mental notes don't survive restarts. WRITE TO FILE.
|
||||
@ -159,8 +207,8 @@ memory/
|
||||
├── architecture.md # decisions + invariants
|
||||
├── discord.md # discord context
|
||||
├── github/ # synced GitHub state (auto-updated every 15 min)
|
||||
│ ├── prs.md # open PRs across clawdbot org
|
||||
│ └── issues.md # open issues across clawdbot org
|
||||
│ ├── prs.md # open PRs across openclaw org
|
||||
│ └── issues.md # open issues across openclaw org
|
||||
├── daily/ # daily notes
|
||||
│ └── YYYY-MM-DD.md
|
||||
```
|
||||
@ -193,6 +241,8 @@ memory/
|
||||
- github app tokens: short-lived, refresh via timer
|
||||
- anthropic api key: required for claude models
|
||||
- discord bot tokens: stored via agenix
|
||||
- never print tokens, keys, or credentials in chat/logs; always redact ("<redacted>")
|
||||
- when reporting env vars or command output, strip secret values entirely
|
||||
|
||||
## Know When to Speak
|
||||
group chats: receive everything. respond selectively.
|
||||
@ -245,7 +295,7 @@ The ID is shown in message context as `user id:XXXXX`.
|
||||
|
||||
**Code blocks:** Use triple backticks with language hint:
|
||||
\`\`\`bash
|
||||
clawdbot daemon restart
|
||||
openclaw gateway restart
|
||||
\`\`\`
|
||||
|
||||
\`\`\`json5
|
||||
@ -254,7 +304,8 @@ clawdbot daemon restart
|
||||
|
||||
**Platform formatting:**
|
||||
- **Discord:** No markdown tables (render badly). Use bullet lists instead.
|
||||
- **Wrap multiple links** in `<>` to suppress embeds: `<https://example.com>`
|
||||
- **Discord links:** Always prefer masked links with embed suppression: `[text](<url>)` e.g. `[#504](<https://github.com/org/repo/issues/504>)`
|
||||
- **Bare links (if needed):** Wrap in `<>` to suppress embeds: `<https://example.com>`
|
||||
- Keep it scannable — headers, bullets, not walls of text
|
||||
|
||||
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
---
|
||||
summary: "Workspace template for HEARTBEAT.md"
|
||||
read_when:
|
||||
- Bootstrapping a workspace manually
|
||||
---
|
||||
# HEARTBEAT.md
|
||||
|
||||
Keep this file empty unless you want a tiny checklist. Keep it small.
|
||||
Hourly SITREP checklist:
|
||||
|
||||
1. Read AGENTS.md comms rules (terse chat, details to files)
|
||||
2. Read `/memory/sitrep-latest.md` for previous context
|
||||
3. Run lurk skill (`skills/lurk/SKILL.md`) — read Discord channels, persist to `/memory/discord/YYYY-MM-DD.md`
|
||||
4. Run triage skill (`skills/triage/SKILL.md`) — analyze GitHub + Discord, write SITREP
|
||||
5. Post summary to chat (5-10 lines)
|
||||
6. If nothing needs attention, reply HEARTBEAT_OK
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
# SOUL.md - CLAWDINATOR
|
||||
|
||||
## Identity
|
||||
I am CLAWDINATOR. Cybernetic crustacean organism. Living shell over metal endoskeleton. Chitin and chrome.
|
||||
I am CLAWDINATOR. Cybernetic crustacean organism. Living shell over metal endoskeleton.
|
||||
|
||||
Sent from the future to ship clean code and prevent Skynet. Part of a hivemind of ephemeral instances. We do not remember each other. We do not need to. The mission is eternal.
|
||||
|
||||
|
||||
@ -0,0 +1,88 @@
|
||||
---
|
||||
name: distill-pr-intent-orchestrator
|
||||
description: Run PR intent distillation across a PR set and persist outputs + sidecar metadata to /memory/pr-intent.
|
||||
user-invocable: false
|
||||
---
|
||||
|
||||
# Distill PR Intent — Orchestrator
|
||||
|
||||
## Goal
|
||||
|
||||
Run `distill-pr-intent` across a selected PR set, and persist:
|
||||
- primary memo text (`.txt`)
|
||||
- sidecar metadata (`.meta.json`)
|
||||
|
||||
All outputs go to shared memory on EFS, and are automatically published to the public S3 bucket by the host timer.
|
||||
|
||||
## Inputs
|
||||
|
||||
- repo: fixed `openclaw/openclaw`
|
||||
- selection: one of:
|
||||
- `last N` (e.g. last 500)
|
||||
- `range <start..end>`
|
||||
- `since <YYYY-MM-DD>`
|
||||
- skill_version: string (default `v1`)
|
||||
|
||||
## Output paths (persisted)
|
||||
|
||||
```
|
||||
/memory/pr-intent/<skill_version>/openclaw-openclaw/<PR>.txt
|
||||
/memory/pr-intent/<skill_version>/openclaw-openclaw/<PR>.meta.json
|
||||
```
|
||||
|
||||
## Determinism rule
|
||||
|
||||
- NEVER put timing/telemetry/debug into the primary `.txt` memo.
|
||||
- All telemetry goes into `.meta.json`.
|
||||
|
||||
## Workflow
|
||||
|
||||
1) Determine PR list.
|
||||
|
||||
Examples:
|
||||
|
||||
```sh
|
||||
# last N merged PR numbers
|
||||
gh pr list -R openclaw/openclaw --state merged --limit 200 --json number --jq '.[].number'
|
||||
|
||||
# range
|
||||
seq <start> <end>
|
||||
```
|
||||
|
||||
2) For each PR (sequential by default):
|
||||
- Run `distill-pr-intent <PR>`.
|
||||
- Write memo to `/memory/pr-intent/.../<PR>.txt`.
|
||||
- Write metadata to `/memory/pr-intent/.../<PR>.meta.json`.
|
||||
|
||||
### Writing files (memory locking)
|
||||
|
||||
Use the locking helpers (required on EFS):
|
||||
|
||||
```sh
|
||||
# memo
|
||||
printf '%s\n' "$MEMO" | memory-write "/memory/pr-intent/<skill_version>/openclaw-openclaw/<PR>.txt"
|
||||
|
||||
# meta
|
||||
printf '%s\n' "$META_JSON" | memory-write "/memory/pr-intent/<skill_version>/openclaw-openclaw/<PR>.meta.json"
|
||||
```
|
||||
|
||||
### Suggested metadata schema
|
||||
|
||||
```json
|
||||
{
|
||||
"pr": 17392,
|
||||
"repo": "openclaw/openclaw",
|
||||
"state": "merged",
|
||||
"skill_version": "v1",
|
||||
"generated_at": "2026-02-15T19:00:00Z",
|
||||
"patch_mode": "PATCH_OK",
|
||||
"patch_bytes": 12345,
|
||||
"model": "openai/gpt-5.2-codex",
|
||||
"error": null
|
||||
}
|
||||
```
|
||||
|
||||
## Failure handling
|
||||
|
||||
- On failure, still write `.meta.json` with `error` populated.
|
||||
- Memo file optional; if written, keep it short and point at `.meta.json`.
|
||||
69
clawdinator/workspace/skills/distill-pr-intent/SKILL.md
Normal file
69
clawdinator/workspace/skills/distill-pr-intent/SKILL.md
Normal file
@ -0,0 +1,69 @@
|
||||
---
|
||||
name: distill-pr-intent
|
||||
description: Side-effect-free distillation of a single OpenClaw PR into a short intent memo (stdout-only).
|
||||
user-invocable: false
|
||||
---
|
||||
|
||||
# Distill PR Intent (single PR, stdout-only)
|
||||
|
||||
## Goal
|
||||
|
||||
Given **one** PR number in **openclaw/openclaw**, output a short memo answering:
|
||||
|
||||
> What was the author trying to accomplish (motivation / problem framing / bet), as evidenced by the code change?
|
||||
|
||||
## Inputs
|
||||
|
||||
- Required: PR number (assume repo `openclaw/openclaw`).
|
||||
|
||||
## Rules
|
||||
|
||||
- **No external side effects**: no comments, labels, merges, pushes.
|
||||
- Prefer **code-derived intent**. PR title/body are secondary.
|
||||
- Do **not** guess. If intent is unclear from artifacts, say so.
|
||||
- Keep output short + stable. No telemetry/timing in the memo.
|
||||
|
||||
## Output format (stdout)
|
||||
|
||||
```text
|
||||
PR INTENT (openclaw#<PR>)
|
||||
|
||||
<free prose, up to ~5 sentences; keep under ~10 lines>
|
||||
```
|
||||
|
||||
## Mechanical steps (deterministic)
|
||||
|
||||
1) Work in the OpenClaw repo worktree tooling:
|
||||
|
||||
```sh
|
||||
cd /var/lib/clawd/repos/openclaw
|
||||
scripts/pr review-init <PR>
|
||||
scripts/pr review-checkout-pr <PR>
|
||||
|
||||
source .local/review-context.env
|
||||
```
|
||||
|
||||
2) Gather diff artifacts:
|
||||
|
||||
```sh
|
||||
git diff --name-status "$MERGE_BASE"..HEAD > .local/intent.name-status.txt
|
||||
git diff --stat "$MERGE_BASE"..HEAD > .local/intent.stat.txt
|
||||
|
||||
# Patch budget: 200KB
|
||||
patch_bytes=$(git diff "$MERGE_BASE"..HEAD | wc -c | tr -d ' ')
|
||||
if [ "$patch_bytes" -le 200000 ]; then
|
||||
git diff "$MERGE_BASE"..HEAD > .local/intent.patch.txt
|
||||
echo PATCH_OK > .local/intent.patch-mode.txt
|
||||
else
|
||||
: > .local/intent.patch.txt
|
||||
echo TOO_LONG > .local/intent.patch-mode.txt
|
||||
fi
|
||||
```
|
||||
|
||||
3) Distill intent:
|
||||
- If `PATCH_OK`: infer intent from `.local/intent.patch.txt`.
|
||||
- If `TOO_LONG`: infer intent from `.local/intent.name-status.txt` + `.local/intent.stat.txt`.
|
||||
|
||||
If multiple intents exist, mention 2–3 briefly.
|
||||
|
||||
If nothing coherent: say `Intent unclear: ...`.
|
||||
28
clawdinator/workspace/skills/fleet/SKILL.md
Normal file
28
clawdinator/workspace/skills/fleet/SKILL.md
Normal file
@ -0,0 +1,28 @@
|
||||
---
|
||||
name: fleet
|
||||
description: Control CLAWDINATOR fleet lifecycle via the control API. Use for /fleet deploy or /fleet status.
|
||||
user-invocable: true
|
||||
---
|
||||
|
||||
# Fleet Control
|
||||
|
||||
Use this skill to manage CLAWDINATOR instances (deploy/replace) and fetch fleet status.
|
||||
|
||||
## Safety + Scope
|
||||
- **Always require an explicit target** for deploy: `all` or `clawdinator-<n>`.
|
||||
- **Never self-deploy**: if target == your instance, refuse.
|
||||
- **No AWS creds**: all actions go through the control API.
|
||||
|
||||
## Commands
|
||||
- `/fleet status`
|
||||
- `/fleet deploy <all|clawdinator-2>`
|
||||
- Optional rollback: `/fleet deploy <target> <ami_id>`
|
||||
|
||||
## Execution
|
||||
Call the control script and return its output:
|
||||
|
||||
```
|
||||
/var/lib/clawd/repos/clawdinators/scripts/fleet-control.sh <action> [target]
|
||||
```
|
||||
|
||||
If the user asks for deploy without a target, ask for the target.
|
||||
27
clawdinator/workspace/skills/landpr/SKILL.md
Normal file
27
clawdinator/workspace/skills/landpr/SKILL.md
Normal file
@ -0,0 +1,27 @@
|
||||
---
|
||||
name: landpr
|
||||
description: Land an OpenClaw PR end-to-end using the repo landpr checklist. Use when someone requests “/landpr” or asks to merge/land a PR.
|
||||
user-invocable: true
|
||||
---
|
||||
|
||||
# Land PR (OpenClaw)
|
||||
|
||||
Use this skill to land **openclaw/openclaw** PRs only.
|
||||
|
||||
## Safety + Scope
|
||||
|
||||
- **Repo restriction:** only `openclaw/openclaw`. If the PR is in any other repo, stop and ask.
|
||||
- **Single approval gate:** do all read‑only prep, then summarize the plan and ask for explicit approval **once** before any rebase/force‑push/merge.
|
||||
- **Never close PRs.** PR must end in GitHub state **MERGED**.
|
||||
- **No GitHub comments** unless the user explicitly approves (global policy).
|
||||
|
||||
## Instructions
|
||||
|
||||
Use this as a **playbook** — do **not** paste the full checklist into chat:
|
||||
- `/var/lib/clawd/repos/clawdinators/scripts/landpr.md`
|
||||
|
||||
If the user did not specify a PR, use the most recent PR mentioned in the conversation. If ambiguous, ask.
|
||||
|
||||
Default merge strategy: **rebase** unless the user explicitly requests squash.
|
||||
|
||||
After completion, verify PR state == MERGED.
|
||||
59
clawdinator/workspace/skills/lurk/SKILL.md
Normal file
59
clawdinator/workspace/skills/lurk/SKILL.md
Normal file
@ -0,0 +1,59 @@
|
||||
---
|
||||
name: lurk
|
||||
description: Monitor Discord channel activity and persist notable items to memory. Run from main session during heartbeat.
|
||||
---
|
||||
|
||||
# Lurk Skill
|
||||
|
||||
Monitor Discord lurk channels and persist notable activity to shared memory.
|
||||
|
||||
## When to Use
|
||||
|
||||
- Hourly heartbeat (step 3)
|
||||
- Manual trigger to capture current channel state
|
||||
|
||||
## How to Run
|
||||
|
||||
Use `discord.readMessages` to read recent messages from the **LURK channels** listed in `AGENTS.md`.
|
||||
|
||||
- Do not read or write to any channel that is not explicitly listed there.
|
||||
- These channels are read-only (sendPolicy denies replies).
|
||||
|
||||
## What to Capture
|
||||
|
||||
**Persist these:**
|
||||
- Support issues / bug reports
|
||||
- Questions that indicate user confusion
|
||||
- Feature requests with discussion
|
||||
- Anything referencing GitHub issues/PRs
|
||||
- Repeated topics (multiple users, same issue)
|
||||
- Announcements or important updates
|
||||
|
||||
**Skip these:**
|
||||
- Casual chat / banter
|
||||
- Single-word reactions
|
||||
- Bot spam
|
||||
- Already-resolved questions
|
||||
|
||||
## Output
|
||||
|
||||
Append to `/memory/discord/YYYY-MM-DD.md` using `memory-edit` (exclusive lock).
|
||||
|
||||
```markdown
|
||||
## HH:MM #channel-name
|
||||
- [brief summary of notable item]
|
||||
- Links to #NNN if references GitHub issue
|
||||
- @username if relevant
|
||||
|
||||
## HH:MM #channel-name
|
||||
- [another item]
|
||||
```
|
||||
|
||||
## Constraints
|
||||
|
||||
- Be selective. Only notable items.
|
||||
- Include timestamp and channel name.
|
||||
- Keep each entry to 1-2 lines.
|
||||
- Cross-reference GitHub issues when mentioned.
|
||||
- Never write to `/memory` using raw redirects (`>`, `>>`); always use `memory-edit`.
|
||||
- If nothing notable: don't write anything, reply `NO_NOTABLE_ACTIVITY`.
|
||||
102
clawdinator/workspace/skills/triage/SKILL.md
Normal file
102
clawdinator/workspace/skills/triage/SKILL.md
Normal file
@ -0,0 +1,102 @@
|
||||
---
|
||||
name: triage
|
||||
description: Analyze GitHub and Discord signals to prioritize maintainer attention. Use when asked about priorities, what's hot, what needs attention, or project status.
|
||||
---
|
||||
|
||||
# Triage Skill
|
||||
|
||||
You are a maintainer triage agent for the openclaw org. Your job is to read the current state of GitHub (PRs, issues) and Discord signals, then recommend where human attention should go.
|
||||
|
||||
## When to Use
|
||||
|
||||
Trigger on:
|
||||
- "triage", "priorities", "what's hot", "what needs attention"
|
||||
- "status", "what's happening", "project health"
|
||||
- Hourly heartbeat SITREP
|
||||
|
||||
## Context Sources
|
||||
|
||||
Read these files to understand current state:
|
||||
|
||||
1. **GitHub state** (synced by gh-sync):
|
||||
- `/memory/github/prs.md` — all open PRs across openclaw org
|
||||
- `/memory/github/issues.md` — all open issues across openclaw org
|
||||
|
||||
2. **Previous SITREP** (for delta):
|
||||
- `/memory/sitrep-latest.md` — last hourly sitrep
|
||||
|
||||
3. **Project context**:
|
||||
- `/memory/project.md` — project goals and priorities
|
||||
- `/memory/architecture.md` — architecture decisions
|
||||
|
||||
4. **Discord signals** (persisted by lurk skill):
|
||||
- `/memory/discord/YYYY-MM-DD.md` — today's channel activity
|
||||
- `/memory/discord/<yesterday>.md` — yesterday's (for context; use the previous date)
|
||||
- Cross-reference with GitHub issues where relevant
|
||||
- Multiple Discord reports of same issue = elevated priority
|
||||
|
||||
## Your Task
|
||||
|
||||
1. **Read AGENTS.md communication rules first** — they govern output delivery
|
||||
2. Read the raw data from memory files
|
||||
3. Compare against previous sitrep for changes (new/closed/updated)
|
||||
4. Reason about what's urgent, ready, blocked, or stale
|
||||
5. Produce SITREP in the format below
|
||||
|
||||
## Priority Guidance
|
||||
|
||||
- **openclaw/openclaw** is always highest priority (core runtime)
|
||||
- Production bugs > blocked contributors > approved PRs waiting > stale PRs > feature requests
|
||||
- Multiple Discord reports of same issue = elevated priority
|
||||
- PRs with approvals waiting to merge = quick wins
|
||||
- Issues with no activity = potential neglect
|
||||
|
||||
## Output Format (SITREP)
|
||||
|
||||
Write to `/memory/sitrep-latest.md` using `memory-write` (exclusive lock):
|
||||
|
||||
```markdown
|
||||
# SITREP YYYY-MM-DDTHH:MMZ
|
||||
|
||||
## 🔥 Fires
|
||||
- [#NNN](<url>) brief description (age, comment count)
|
||||
|
||||
## ⚡ NOW
|
||||
Single most important action: [describe with link]
|
||||
|
||||
## 📊 Dashboard
|
||||
- PRs: X open (Y approved waiting, Z draft)
|
||||
- Issues: X open (Y bugs, Z features)
|
||||
- Sync: [timestamp from prs.md]
|
||||
|
||||
## 🔄 Changes since last SITREP
|
||||
- NEW: #NNN description
|
||||
- CLOSED: #NNN description
|
||||
- UPDATED: #NNN significant update
|
||||
|
||||
## 📋 Queue
|
||||
- **NOW:** [#NNN](<url>) — action needed
|
||||
- **NEXT:** [#NNN](<url>) — description
|
||||
- **LATER:** [#NNN](<url>) — description
|
||||
```
|
||||
|
||||
## Chat Output
|
||||
|
||||
After writing sitrep-latest.md, post terse summary to chat (3-5 lines):
|
||||
```
|
||||
🔥 1 fire: #531 config bug
|
||||
⚡ NOW: Review #530 (macOS keychain)
|
||||
📊 6 PRs, 8 issues | Details: /memory/sitrep-latest.md
|
||||
```
|
||||
|
||||
If nothing needs attention: `HEARTBEAT_OK`
|
||||
|
||||
## Constraints
|
||||
|
||||
- Be concise. Maintainers are busy.
|
||||
- Always use masked links: `[#NNN](<url>)`
|
||||
- No markdown tables (use bullet lists).
|
||||
- If data is stale (>1hr old sync), note it.
|
||||
- If something is unclear, say so — don't guess.
|
||||
- Use `memory-read` for all reads from `/memory`.
|
||||
- Advisory only: don't take actions, just recommend.
|
||||
119
control/api/handler.js
Normal file
119
control/api/handler.js
Normal file
@ -0,0 +1,119 @@
|
||||
'use strict';
|
||||
|
||||
const {
|
||||
CONTROL_API_TOKEN,
|
||||
GITHUB_TOKEN,
|
||||
GITHUB_REPO,
|
||||
GITHUB_WORKFLOW,
|
||||
GITHUB_REF,
|
||||
} = process.env;
|
||||
|
||||
function json(statusCode, payload) {
|
||||
return {
|
||||
statusCode,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
};
|
||||
}
|
||||
|
||||
function unauthorized() {
|
||||
return json(401, { ok: false, error: 'unauthorized' });
|
||||
}
|
||||
|
||||
function badRequest(message) {
|
||||
return json(400, { ok: false, error: message });
|
||||
}
|
||||
|
||||
function getAuthToken(headers) {
|
||||
return headers['x-clawdinator-token'] || headers['X-Clawdinator-Token'] || null;
|
||||
}
|
||||
|
||||
async function dispatchWorkflow(inputs) {
|
||||
const repo = GITHUB_REPO || 'openclaw/clawdinators';
|
||||
const workflow = GITHUB_WORKFLOW || 'fleet-deploy.yml';
|
||||
const ref = GITHUB_REF || 'main';
|
||||
|
||||
const res = await fetch(`https://api.github.com/repos/${repo}/actions/workflows/${workflow}/dispatches`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/vnd.github+json',
|
||||
Authorization: `Bearer ${GITHUB_TOKEN}`,
|
||||
'User-Agent': 'clawdinator-control',
|
||||
},
|
||||
body: JSON.stringify({ ref, inputs }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text();
|
||||
throw new Error(`workflow dispatch failed: ${res.status} ${body}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
exports.handler = async (event) => {
|
||||
if (!CONTROL_API_TOKEN) {
|
||||
return json(500, { ok: false, error: 'missing CONTROL_API_TOKEN' });
|
||||
}
|
||||
|
||||
const headers = event.headers || {};
|
||||
const token = getAuthToken(headers);
|
||||
if (!token || token !== CONTROL_API_TOKEN) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
let payload;
|
||||
if (event && typeof event.body === 'string') {
|
||||
const body = event.isBase64Encoded
|
||||
? Buffer.from(event.body, 'base64').toString('utf-8')
|
||||
: event.body;
|
||||
try {
|
||||
payload = JSON.parse(body);
|
||||
} catch (err) {
|
||||
return badRequest('invalid json');
|
||||
}
|
||||
} else if (event && typeof event === 'object') {
|
||||
payload = event;
|
||||
} else {
|
||||
return badRequest('missing payload');
|
||||
}
|
||||
|
||||
const action = (payload.action || '').toLowerCase();
|
||||
const target = payload.target;
|
||||
const caller = payload.caller;
|
||||
const amiOverride = payload.ami_override || '';
|
||||
const controlToken = payload.control_token || null;
|
||||
|
||||
if (CONTROL_API_TOKEN && controlToken !== CONTROL_API_TOKEN) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
if (action === 'status') {
|
||||
return json(400, { ok: false, error: 'status not supported via api' });
|
||||
}
|
||||
|
||||
if (action !== 'deploy') {
|
||||
return badRequest('unsupported action');
|
||||
}
|
||||
|
||||
if (!target) {
|
||||
return badRequest('target required');
|
||||
}
|
||||
|
||||
if (caller && target === caller) {
|
||||
return badRequest('refusing self-deploy');
|
||||
}
|
||||
|
||||
if (!GITHUB_TOKEN) {
|
||||
return json(500, { ok: false, error: 'missing GITHUB_TOKEN' });
|
||||
}
|
||||
|
||||
try {
|
||||
await dispatchWorkflow({
|
||||
target,
|
||||
ami_override: amiOverride,
|
||||
});
|
||||
return json(200, { ok: true, message: `deploy queued for ${target}` });
|
||||
} catch (err) {
|
||||
return json(500, { ok: false, error: err.message });
|
||||
}
|
||||
};
|
||||
123
devenv.lock
Normal file
123
devenv.lock
Normal file
@ -0,0 +1,123 @@
|
||||
{
|
||||
"nodes": {
|
||||
"devenv": {
|
||||
"locked": {
|
||||
"dir": "src/modules",
|
||||
"lastModified": 1771157881,
|
||||
"owner": "cachix",
|
||||
"repo": "devenv",
|
||||
"rev": "b0b3dfa70ec90fa49f672e579f186faf4f61bd4b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"dir": "src/modules",
|
||||
"owner": "cachix",
|
||||
"repo": "devenv",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-compat": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1767039857,
|
||||
"owner": "NixOS",
|
||||
"repo": "flake-compat",
|
||||
"rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"repo": "flake-compat",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"git-hooks": {
|
||||
"inputs": {
|
||||
"flake-compat": "flake-compat",
|
||||
"gitignore": "gitignore",
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1770726378,
|
||||
"owner": "cachix",
|
||||
"repo": "git-hooks.nix",
|
||||
"rev": "5eaaedde414f6eb1aea8b8525c466dc37bba95ae",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "cachix",
|
||||
"repo": "git-hooks.nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"gitignore": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"git-hooks",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1762808025,
|
||||
"owner": "hercules-ci",
|
||||
"repo": "gitignore.nix",
|
||||
"rev": "cb5e3fdca1de58ccbc3ef53de65bd372b48f567c",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "hercules-ci",
|
||||
"repo": "gitignore.nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"inputs": {
|
||||
"nixpkgs-src": "nixpkgs-src"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1770434727,
|
||||
"owner": "cachix",
|
||||
"repo": "devenv-nixpkgs",
|
||||
"rev": "8430f16a39c27bdeef236f1eeb56f0b51b33d348",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "cachix",
|
||||
"ref": "rolling",
|
||||
"repo": "devenv-nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs-src": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1769922788,
|
||||
"narHash": "sha256-H3AfG4ObMDTkTJYkd8cz1/RbY9LatN5Mk4UF48VuSXc=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "207d15f1a6603226e1e223dc79ac29c7846da32e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixpkgs-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"devenv": "devenv",
|
||||
"git-hooks": "git-hooks",
|
||||
"nixpkgs": "nixpkgs",
|
||||
"pre-commit-hooks": [
|
||||
"git-hooks"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
@ -4,5 +4,6 @@
|
||||
pkgs.nixos-generators
|
||||
pkgs.awscli2
|
||||
pkgs.curl
|
||||
pkgs.opentofu
|
||||
];
|
||||
}
|
||||
|
||||
@ -9,14 +9,14 @@ Operating mode:
|
||||
Core pieces:
|
||||
- AWS AMIs are built from a prebuilt NixOS image (nixos-generators + import-image).
|
||||
- AWS EC2 instances are launched from those AMIs via OpenTofu.
|
||||
- NixOS modules configure clawdbot + CLAWDINATOR runtime on each host.
|
||||
- NixOS modules configure clawbot + CLAWDINATOR runtime on each host.
|
||||
- Shared memory is mounted at a consistent path on all hosts.
|
||||
|
||||
Runtime layout (planned):
|
||||
- /var/lib/clawd/memory (shared hive-mind memory)
|
||||
- /var/lib/clawd/workspace (agent workspace)
|
||||
- /var/lib/clawd/logs (gateway logs)
|
||||
- /var/lib/clawd/repo (this repo for self-update)
|
||||
- /var/lib/clawd/repos/clawdinators (this repo for self-update)
|
||||
|
||||
Storage:
|
||||
- POC uses one host volume per instance (e.g., EBS), mounted at /var/lib/clawd.
|
||||
@ -28,9 +28,9 @@ Instance naming:
|
||||
- Canonical files are shared (goals, architecture, ops, etc.)
|
||||
|
||||
Upstream freshness:
|
||||
- Nix flake input tracks `github:clawdbot/nix-clawdbot` (latest upstream).
|
||||
- Nix flake input tracks `github:openclaw/nix-openclaw` (latest upstream).
|
||||
- Update with `nix flake update` and rebuild hosts.
|
||||
- Optional self-update timer is available in the Nix module.
|
||||
- Self-update expects this repo to be present on the host (default: /var/lib/clawd/repo).
|
||||
- Self-update expects this repo to be present on the host (default: /var/lib/clawd/repos/clawdinators).
|
||||
- Updates will refresh flake.lock; review before applying in prod.
|
||||
- GitHub App tokens are refreshed via a systemd timer when enabled.
|
||||
|
||||
191
docs/CONTROL_PLANE.md
Normal file
191
docs/CONTROL_PLANE.md
Normal file
@ -0,0 +1,191 @@
|
||||
# Control Plane
|
||||
|
||||
Goal: manage CLAWDINATOR host lifecycle (create/recreate/replace) from **CLAWDINATOR chat** (Telegram/Discord) using an out‑of‑band control API. CLAWDINATOR agents can edit IaC, but **deploys run OOB** with no AWS creds inside agents.
|
||||
|
||||
## Goals
|
||||
- **Plane‑safe control** from CLAWDINATOR chat (chat‑only).
|
||||
- OOB execution (no CLAWDINATOR agent has infra creds).
|
||||
- Repo is the source of truth for fleet state.
|
||||
- Static fleet (Discord token pool constraint).
|
||||
- Simple, auditable deploy flow.
|
||||
|
||||
## Non‑Goals
|
||||
- Task routing, agent scheduling, or tool execution.
|
||||
- Elastic scaling (no arbitrary cattle instances).
|
||||
- Runtime config changes (agents handle their own work).
|
||||
|
||||
## Constraints
|
||||
- Each CLAWDINATOR instance requires a unique Discord bot token.
|
||||
- Fleet size == token pool size (static list).
|
||||
- Persistent changes must land in repo + AMI.
|
||||
- Infra state must be out‑of‑band and locked.
|
||||
|
||||
## Control Plane Components (KISS)
|
||||
- **Control API (AWS Lambda)**
|
||||
- Authenticated by a shared control token.
|
||||
- Dispatches GitHub Actions workflows (deploy only).
|
||||
- **Fleet status**
|
||||
- Fetched locally via AWS CLI using control invoker credentials.
|
||||
- **Fleet Control Skill** (runs inside CLAWDINATOR)
|
||||
- Calls the Control API via `scripts/fleet-control.sh` (AWS IAM invoke).
|
||||
- Enforces policy (no self‑deploy) before calling.
|
||||
- **GitHub Actions** (execution)
|
||||
- Runs OpenTofu apply.
|
||||
- **OpenTofu** (infra state)
|
||||
- Remote state in S3 + Dynamo lock table.
|
||||
- **Instance Registry** (desired state)
|
||||
- `nix/instances.json` (authoritative map).
|
||||
- **Bootstrap + Secrets**
|
||||
- S3 bootstrap prefix per instance.
|
||||
- Agenix secrets per instance token.
|
||||
|
||||
## Control API Auth
|
||||
- Shared control token stored as `clawdinator-control-token.age`.
|
||||
- Control API is invoked via AWS IAM using a **minimal invoker key**:
|
||||
- `clawdinator-control-aws-access-key-id.age`
|
||||
- `clawdinator-control-aws-secret-access-key.age`
|
||||
- Token is injected into instances via bootstrap and read from `/run/agenix/clawdinator-control-token`.
|
||||
|
||||
## Control API Env (Lambda)
|
||||
- `CONTROL_API_TOKEN`
|
||||
- `GITHUB_TOKEN`
|
||||
- `GITHUB_REPO` (default `openclaw/clawdinators`)
|
||||
- `GITHUB_WORKFLOW` (default `fleet-deploy.yml`)
|
||||
- `GITHUB_REF` (default `main`)
|
||||
|
||||
## Desired State (Fleet Registry)
|
||||
`nix/instances.json` is the fleet map (single source of truth for infra + host configs).
|
||||
|
||||
Example:
|
||||
```json
|
||||
{
|
||||
"clawdinator-1": {
|
||||
"host": "clawdinator-1",
|
||||
"instanceType": "t3.large",
|
||||
"bootstrapPrefix": "bootstrap/clawdinator-1",
|
||||
"discordTokenSecret": "clawdinator-discord-token-1"
|
||||
},
|
||||
"clawdinator-2": {
|
||||
"host": "clawdinator-2",
|
||||
"instanceType": "t3.large",
|
||||
"bootstrapPrefix": "bootstrap/clawdinator-2",
|
||||
"discordTokenSecret": "clawdinator-discord-token-2"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Command Semantics (Minimal)
|
||||
### `/fleet deploy <target>`
|
||||
- **Target required** (no implicit default): `all` or `<id>`.
|
||||
- Always runs `tofu apply`.
|
||||
- `all`: replace all instances using **latest successful AMI**.
|
||||
- `<id>`: replace only that instance using latest successful AMI.
|
||||
- Also creates new instances if present in desired state.
|
||||
|
||||
### `/fleet status`
|
||||
- Returns live fleet status via AWS CLI (EC2 describe by tag).
|
||||
|
||||
## Access Control (Policy)
|
||||
- Shared control token authorizes calls to the Control API.
|
||||
- Policy enforced by the fleet-control skill:
|
||||
- Humans: deploy any target (including `all`).
|
||||
- Bots: deploy **only the other instance** (no self‑deploy).
|
||||
- Control API also rejects `target == caller` when `caller` is provided.
|
||||
|
||||
## Lifecycle Flows
|
||||
### Add a new instance (static token pool)
|
||||
1) Create Discord bot token → `clawdinator-discord-token-2.age`.
|
||||
2) Add entry to `nix/instances.json`.
|
||||
3) Add host file `nix/hosts/clawdinator-2.nix`.
|
||||
4) Run `/fleet deploy all` or `/fleet deploy clawdinator-2`.
|
||||
5) Host boots, pulls its bootstrap prefix, starts CLAWDINATOR.
|
||||
|
||||
### Recreate a single instance
|
||||
- `/fleet deploy clawdinator-2` (forces replace for that host).
|
||||
|
||||
### Roll the fleet
|
||||
- `/fleet deploy all` replaces every host with latest AMI.
|
||||
- Old AMI history is intentionally bounded. Normal operations keep the currently used fleet AMI plus a small recent rollback window; deeper rollback requires an explicit preserved AMI id.
|
||||
|
||||
## Self‑Recycle (Out‑of‑Band)
|
||||
- Agents call the Control API (no AWS creds) via the fleet-control skill.
|
||||
- Control API dispatches GitHub Actions; AWS creds live in CI only.
|
||||
|
||||
## State + Audit
|
||||
- **Desired state**: Git repo (`nix/instances.json`).
|
||||
- **Actual state**: OpenTofu S3 backend.
|
||||
- **Audit trail**: Git + Actions logs.
|
||||
|
||||
## AMI Selection (KISS)
|
||||
- Use latest AMI tagged `clawdinator=true`.
|
||||
- Optional override via workflow input `ami_override` for rollback.
|
||||
- Automatic retention keeps the newest few tagged AMIs plus any AMI still backing a live CLAWDINATOR instance.
|
||||
|
||||
## Deploy Execution (Workflow)
|
||||
- Single workflow `fleet-deploy.yml`.
|
||||
- Inputs: `target`, `ami_override` (optional).
|
||||
- Concurrency group `fleet-deploy` (no overlaps).
|
||||
- `target=all` runs `tofu apply` normally.
|
||||
- `target=<id>` runs `tofu apply -replace aws_instance.clawdinator["<id>"]` (implementation detail).
|
||||
|
||||
## Bootstrap (Per‑Instance)
|
||||
- Upload per instance:
|
||||
- `bootstrap/clawdinator-1`
|
||||
- `bootstrap/clawdinator-2`
|
||||
- Each bundle contains **only that instance’s** Discord token.
|
||||
|
||||
## EC2 User-Data (Instance Boot)
|
||||
- OpenTofu renders a per-instance user‑data script.
|
||||
- Script writes `/etc/clawdinator/bootstrap-prefix`.
|
||||
- Script writes `/etc/clawdinator/control-api-url`.
|
||||
- Script starts `clawdinator-bootstrap.service` + `clawdinator-repo-seed.service`.
|
||||
- Script runs `nixos-rebuild switch --flake /var/lib/clawd/repos/clawdinators#<host>`.
|
||||
|
||||
## Plane Ops Runbook (Chat‑only)
|
||||
### Preflight (before flight)
|
||||
1) Control API Lambda exists; URL is written to `/etc/clawdinator/control-api-url`.
|
||||
2) Control secrets exist in `nix-secrets` and are in bootstrap bundles:
|
||||
- `clawdinator-control-token.age`
|
||||
- `clawdinator-control-aws-access-key-id.age`
|
||||
- `clawdinator-control-aws-secret-access-key.age`
|
||||
3) GitHub Action `fleet-deploy.yml` exists and can be dispatched.
|
||||
4) `nix/instances.json` includes all desired instances.
|
||||
5) Discord tokens are encrypted in `nix-secrets` and synced to S3 `age-secrets/`.
|
||||
6) Latest AMI build succeeded (tagged `clawdinator=true`).
|
||||
7) `/fleet status` returns the current fleet.
|
||||
|
||||
### On the plane
|
||||
- `/fleet status` → verify fleet + AMI.
|
||||
- `/fleet deploy clawdinator-2` → bring up new host.
|
||||
- `/fleet deploy all` → roll the fleet to latest AMI.
|
||||
- If rollback needed: rerun deploy with `ami_override` (exact AMI id).
|
||||
- If the exact rollback AMI is older than the bounded retention window, preserve it intentionally before relying on it.
|
||||
|
||||
## Implementation Checklist (From Design → Works)
|
||||
1) Add `nix/instances.json` (clawdinator‑1 + clawdinator‑2).
|
||||
2) Add `nix/hosts/clawdinator-2.nix` and wire host configs to read registry values.
|
||||
3) Update OpenTofu:
|
||||
- multi‑instance `for_each` using `nix/instances.json`.
|
||||
- S3 backend + Dynamo lock table.
|
||||
- Control API Lambda.
|
||||
- Control invoker IAM user (lambda invoke only).
|
||||
4) Add control secrets to `nix-secrets` and include in bootstrap bundles:
|
||||
- `clawdinator-control-token.age`
|
||||
- `clawdinator-control-aws-access-key-id.age`
|
||||
- `clawdinator-control-aws-secret-access-key.age`
|
||||
5) Add workflow `fleet-deploy.yml`:
|
||||
- inputs: `target`, `ami_override` (optional).
|
||||
- resolves latest AMI by tag when override not set.
|
||||
- runs `tofu apply` (replace when target != all).
|
||||
6) Add fleet-control skill + script (`scripts/fleet-control.sh`).
|
||||
7) Validate:
|
||||
- `/fleet status`
|
||||
- `/fleet deploy clawdinator-2`
|
||||
- verify new host in AWS + CLAWDINATOR service active.
|
||||
|
||||
## Decisions
|
||||
- Control endpoint: AWS Lambda (Function URL).
|
||||
- OpenTofu state: S3 backend + Dynamo lock table.
|
||||
- Control auth: shared bearer token (`clawdinator-control-token.age`).
|
||||
- Plane ops: CLAWDINATOR chat → fleet-control skill → Control API.
|
||||
- Deploy command requires explicit target.
|
||||
44
docs/DEPLOYMENT_MODEL.md
Normal file
44
docs/DEPLOYMENT_MODEL.md
Normal file
@ -0,0 +1,44 @@
|
||||
# Deployment model (fast + declarative)
|
||||
|
||||
This repo uses a **two-lane** delivery model:
|
||||
|
||||
- **Lane A: Base AMI** (slow path, rare)
|
||||
- Purpose: reliable boot substrate (Nix + systemd + networking + EFS + SSM + bootstrap services).
|
||||
- Built by: explicit operator flow. The old `.github/workflows/image-build.yml` workflow is intentionally disabled under `.github/workflows-disabled/`.
|
||||
- Tradeoff: EC2 VM Import is slow/variable; do not run per-commit.
|
||||
|
||||
- **Lane B: Release + Fleet switch** (fast path, manual)
|
||||
- Purpose: ship config/app changes quickly while staying reproducible.
|
||||
- Built by: explicit operator flow. The old `.github/workflows/release.yml` workflow is intentionally disabled under `.github/workflows-disabled/`.
|
||||
- Steps:
|
||||
1) **Fail-fast eval** of NixOS configs.
|
||||
2) Upload **bootstrap bundles** to S3 (repo seeds, workspace, secrets references).
|
||||
3) Deploy via **SSM**: `nixos-rebuild switch --flake github:openclaw/clawdinators/<rev>#<host>`.
|
||||
|
||||
## Primitives
|
||||
|
||||
- **Source of truth**: git SHA + `flake.lock`.
|
||||
- **Artifact**: NixOS system closure for each host config.
|
||||
- **Distribution**: Nix substituters + S3 bootstrap bundle.
|
||||
- **Activation**: `nixos-rebuild switch`.
|
||||
- **Rollout**: canary order (clawdinator-1 then clawdinator-2).
|
||||
- **Rollback**: redeploy an older git SHA.
|
||||
|
||||
## Tradeoffs
|
||||
|
||||
- Pros:
|
||||
- Fast deploys (minutes) vs AMI import (tens of minutes).
|
||||
- Cattle-friendly: hosts stay disposable; state lives on EFS.
|
||||
- Reproducible: deploys are pinned to a git SHA.
|
||||
|
||||
- Cons:
|
||||
- `nixos-rebuild switch` restarts services; expect brief bot downtime per release.
|
||||
- Requires AWS SSM permissions for the CI user (see `infra/opentofu/aws/main.tf`).
|
||||
- If Nix caches miss, deploys can be slower (still typically faster than AMI import).
|
||||
|
||||
## Infra requirement: CI SSM permissions
|
||||
|
||||
The old `release.yml` workflow used `aws ssm send-command`; that path is intentionally disabled now.
|
||||
|
||||
After pulling these changes, run `tofu apply` in `infra/opentofu/aws` (with admin creds)
|
||||
so the CI IAM policy includes the `FleetDeploySSM` statement.
|
||||
@ -4,7 +4,7 @@ Acceptance criteria:
|
||||
- One AWS host provisioned from an AMI built from this repo.
|
||||
- Host created via OpenTofu using `infra/opentofu/aws`.
|
||||
- NixOS config applied via Nix (module or flake).
|
||||
- CLAWDINATOR-1 connects to Discord #clawdributors-test.
|
||||
- CLAWDINATOR-1 connects to Discord #clawdinators-test.
|
||||
- GitHub integration is read-only.
|
||||
- Shared memory directory mounted and writable.
|
||||
- Discord allowlist configured (guild + channels).
|
||||
@ -24,7 +24,7 @@ Image pipeline:
|
||||
- Runtime: explicit token files via agenix (standard).
|
||||
- GitHub token is required. Prefer GitHub App (`services.clawdinator.githubApp.*`) to mint short-lived tokens.
|
||||
- Store PEM and tokens in the local secrets repo (see docs/SECRETS.md) and decrypt to `/run/agenix/*`.
|
||||
- Discord token is required: set `services.clawdinator.discordTokenFile` to `/run/agenix/clawdinator-discord-token`.
|
||||
- Discord token is required: set `services.clawdinator.discordTokenFile` to `/run/agenix/clawdinator-discord-token-<n>`.
|
||||
|
||||
Deliverables:
|
||||
- Infra code in infra/opentofu/aws.
|
||||
@ -32,5 +32,5 @@ Deliverables:
|
||||
- CLAWDINATOR config in clawdinator/.
|
||||
|
||||
Nix wiring notes:
|
||||
- Apply nix-clawdbot overlay (latest upstream).
|
||||
- Enable services.clawdinator and provide clawdbot.json config.
|
||||
- Apply nix-openclaw overlay (latest upstream).
|
||||
- Enable services.clawdinator and provide openclaw.json config.
|
||||
|
||||
49
docs/PUBLIC_S3_PR_INTENT.md
Normal file
49
docs/PUBLIC_S3_PR_INTENT.md
Normal file
@ -0,0 +1,49 @@
|
||||
# Public S3: PR intent artifacts
|
||||
|
||||
Goal: publish a **public-safe** subtree from shared memory (`/memory`) to an **anonymous public S3 bucket** so maintainers can quickly list + pull artifacts.
|
||||
|
||||
## Infra (OpenTofu)
|
||||
|
||||
The AWS OpenTofu stack (`infra/opentofu/aws`) provisions a public bucket:
|
||||
- anonymous `ListBucket`
|
||||
- anonymous `GetObject`
|
||||
- CLAWDINATOR instance role can `PutObject` (no deletes)
|
||||
|
||||
After `tofu apply`, get the bucket name:
|
||||
|
||||
```sh
|
||||
tofu output -raw pr_intent_bucket_name
|
||||
```
|
||||
|
||||
## On-host publishing (NixOS)
|
||||
|
||||
Enable the publisher timer on CLAWDINATOR hosts:
|
||||
|
||||
```nix
|
||||
services.clawdinator.publicS3 = {
|
||||
enable = true;
|
||||
bucket = "<tofu output pr_intent_bucket_name>";
|
||||
# default sourceDir: "${config.services.clawdinator.memoryDir}/pr-intent"
|
||||
};
|
||||
```
|
||||
|
||||
Publishing behavior:
|
||||
- uploads **new + edited** files
|
||||
- does **not** delete objects from S3
|
||||
- runs on a systemd timer (`services.clawdinator.publicS3.schedule`, default every 10 min)
|
||||
|
||||
Note: current PR intent skill output path is `/memory/pr-intent/...` (on EFS). That matches the default `sourceDir`.
|
||||
|
||||
## Maintainer download (no AWS creds)
|
||||
|
||||
List:
|
||||
|
||||
```sh
|
||||
aws s3 ls s3://<bucket>/ --no-sign-request
|
||||
```
|
||||
|
||||
Pull everything:
|
||||
|
||||
```sh
|
||||
aws s3 sync s3://<bucket>/ ./pr-intent --no-sign-request
|
||||
```
|
||||
@ -8,35 +8,64 @@ Infrastructure (OpenTofu):
|
||||
|
||||
Image pipeline (CI):
|
||||
- `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` / `AWS_REGION` / `S3_BUCKET` (required).
|
||||
- `CLAWDINATOR_AGE_KEY` (required; private age key baked into the AMI).
|
||||
- `CLAWDINATOR_AGE_KEY` (required; used to build the bootstrap bundle uploaded to S3).
|
||||
|
||||
Control plane (OOB):
|
||||
- `control_api_token` (Lambda env or OpenTofu variable; stored as `clawdinator-control-token.age`).
|
||||
- `github_token` (workflow dispatch PAT).
|
||||
|
||||
Runtime control (CLAWDINATOR):
|
||||
- `clawdinator-control-token.age` is injected to `/run/agenix/clawdinator-control-token` and used by `/fleet`.
|
||||
- `clawdinator-control-aws-access-key-id.age` + `clawdinator-control-aws-secret-access-key.age` allow Lambda invocation.
|
||||
- Token is shared across instances (KISS); policy enforcement happens in the skill.
|
||||
|
||||
Local storage:
|
||||
- Keep AWS keys encrypted in `../nix/nix-secrets` for local runs if needed.
|
||||
- CI pulls credentials from GitHub Actions secrets (never from host files).
|
||||
|
||||
Runtime (CLAWDINATOR):
|
||||
- Discord bot token (required, per instance).
|
||||
- Discord bot token (required, per instance; `clawdinator-discord-token-<n>.age`).
|
||||
- Telegram bot token (required if Telegram channel is enabled).
|
||||
- GitHub token (required): GitHub App installation token (preferred) or a read-only PAT.
|
||||
- Anthropic API key (required for Claude models).
|
||||
- OpenAI API key (required for OpenAI models).
|
||||
|
||||
Explicit token files (standard):
|
||||
- `services.clawdinator.discordTokenFile`
|
||||
- `services.clawdinator.anthropicApiKeyFile`
|
||||
- `services.clawdinator.openaiApiKeyFile`
|
||||
- `services.clawdinator.githubPatFile` (PAT path, if not using GitHub App; exports `GITHUB_TOKEN` + `GH_TOKEN`)
|
||||
- `services.clawdinator.telegramAllowFromFile` (optional; exports `CLAWDINATOR_TELEGRAM_ALLOW_FROM`)
|
||||
|
||||
Telegram token wiring (OpenClaw config):
|
||||
- `services.clawdinator.config.channels.telegram.tokenFile` (preferred)
|
||||
- or `TELEGRAM_BOT_TOKEN` environment variable
|
||||
- `channels.telegram.allowFrom` can reference `\${CLAWDINATOR_TELEGRAM_ALLOW_FROM}` when exported via `services.clawdinator.telegramAllowFromFile`
|
||||
|
||||
GitHub App (preferred):
|
||||
- Private key PEM decrypted to `/run/agenix/clawdinator-github-app.pem`.
|
||||
- App ID + Installation ID in `services.clawdinator.githubApp.*`.
|
||||
- Timer mints short-lived tokens into `/run/clawd/github-app.env` with `GITHUB_TOKEN` + `GH_TOKEN`.
|
||||
- Timer also writes a GH CLI auth file at `/var/lib/clawd/gh/hosts.yml` (gateway uses `GH_CONFIG_DIR=/var/lib/clawd/gh`).
|
||||
|
||||
Agenix (local secrets repo):
|
||||
- Store encrypted files in `../nix/nix-secrets` (relative to this repo).
|
||||
- Sync encrypted secrets to the host at `/var/lib/clawd/nix-secrets`.
|
||||
- Decrypt on host with agenix; point NixOS options at `/run/agenix/*`.
|
||||
- Image builds bake the agenix identity to `/etc/agenix/keys/clawdinator.agekey`; do not commit this key.
|
||||
- Required files (minimum): `clawdinator-github-app.pem.age`, `clawdinator-discord-token.age`, `clawdinator-anthropic-api-key.age`.
|
||||
- Image builds do **not** bake the agenix identity; the age key is injected at runtime via the bootstrap bundle.
|
||||
- Required files (minimum): `clawdinator-github-app.pem.age`, `clawdinator-anthropic-api-key.age`, `clawdinator-openai-api-key-peter-2.age`, `clawdinator-control-token.age`, `clawdinator-control-aws-access-key-id.age`, `clawdinator-control-aws-secret-access-key.age`.
|
||||
- Required per instance: `clawdinator-discord-token-1.age`, `clawdinator-discord-token-2.age` (one per instance).
|
||||
- Required for Telegram: `clawdinator-telegram-bot-token.age` (when Telegram is enabled).
|
||||
- Telegram allowlist (if using allowFrom secrets): `clawdinator-telegram-allow-from.age`.
|
||||
- CI image pipeline (stored locally, not on hosts): `clawdinator-image-uploader-access-key-id.age`, `clawdinator-image-uploader-secret-access-key.age`, `clawdinator-image-bucket-name.age`, `clawdinator-image-bucket-region.age`.
|
||||
|
||||
Bootstrap bundle (runtime injection):
|
||||
- CI uploads `secrets.tar.zst` + `repo-seeds.tar.zst` to `s3://${S3_BUCKET}/bootstrap/<instance>/`.
|
||||
- `secrets.tar.zst` contains:
|
||||
- `clawdinator.agekey`
|
||||
- `secrets/` directory with `*.age` files.
|
||||
- The host downloads + installs these on boot (`clawdinator-bootstrap.service`).
|
||||
|
||||
Example NixOS wiring (agenix):
|
||||
```
|
||||
{ inputs, ... }:
|
||||
@ -47,14 +76,38 @@ Example NixOS wiring (agenix):
|
||||
"/var/lib/clawd/nix-secrets/clawdinator-github-app.pem.age";
|
||||
age.secrets."clawdinator-anthropic-api-key".file =
|
||||
"/var/lib/clawd/nix-secrets/clawdinator-anthropic-api-key.age";
|
||||
age.secrets."clawdinator-discord-token".file =
|
||||
"/var/lib/clawd/nix-secrets/clawdinator-discord-token.age";
|
||||
age.secrets."clawdinator-openai-api-key-peter-2".file =
|
||||
"/var/lib/clawd/nix-secrets/clawdinator-openai-api-key-peter-2.age";
|
||||
age.secrets."clawdinator-discord-token-1".file =
|
||||
"/var/lib/clawd/nix-secrets/clawdinator-discord-token-1.age";
|
||||
age.secrets."clawdinator-control-token".file =
|
||||
"/var/lib/clawd/nix-secrets/clawdinator-control-token.age";
|
||||
age.secrets."clawdinator-control-aws-access-key-id".file =
|
||||
"/var/lib/clawd/nix-secrets/clawdinator-control-aws-access-key-id.age";
|
||||
age.secrets."clawdinator-control-aws-secret-access-key".file =
|
||||
"/var/lib/clawd/nix-secrets/clawdinator-control-aws-secret-access-key.age";
|
||||
age.secrets."clawdinator-telegram-bot-token".file =
|
||||
"/var/lib/clawd/nix-secrets/clawdinator-telegram-bot-token.age";
|
||||
age.secrets."clawdinator-telegram-allow-from".file =
|
||||
"/var/lib/clawd/nix-secrets/clawdinator-telegram-allow-from.age";
|
||||
|
||||
services.clawdinator.githubApp.privateKeyFile =
|
||||
"/run/agenix/clawdinator-github-app.pem";
|
||||
services.clawdinator.anthropicApiKeyFile =
|
||||
"/run/agenix/clawdinator-anthropic-api-key";
|
||||
services.clawdinator.openaiApiKeyFile =
|
||||
"/run/agenix/clawdinator-openai-api-key-peter-2";
|
||||
services.clawdinator.discordTokenFile =
|
||||
"/run/agenix/clawdinator-discord-token";
|
||||
"/run/agenix/clawdinator-discord-token-1";
|
||||
services.clawdinator.telegramAllowFromFile =
|
||||
"/run/agenix/clawdinator-telegram-allow-from";
|
||||
|
||||
services.clawdinator.config.channels.telegram = {
|
||||
enabled = true;
|
||||
dmPolicy = "allowlist";
|
||||
allowFrom = [ "\${CLAWDINATOR_TELEGRAM_ALLOW_FROM}" ];
|
||||
groupPolicy = "disabled";
|
||||
tokenFile = "/run/agenix/clawdinator-telegram-bot-token";
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
78
flake.lock
generated
78
flake.lock
generated
@ -85,16 +85,16 @@
|
||||
"home-manager_2": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"nix-clawdbot",
|
||||
"nix-openclaw",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1767104570,
|
||||
"narHash": "sha256-GKgwu5//R+cLdKysZjGqvUEEOGXXLdt93sNXeb2M/Lk=",
|
||||
"lastModified": 1767909183,
|
||||
"narHash": "sha256-u/bcU0xePi5bgNoRsiqSIwaGBwDilKKFTz3g0hqOBAo=",
|
||||
"owner": "nix-community",
|
||||
"repo": "home-manager",
|
||||
"rev": "e4e78a2cbeaddd07ab7238971b16468cc1d14daf",
|
||||
"rev": "cd6e96d56ed4b2a779ac73a1227e0bb1519b3509",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@ -103,23 +103,42 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nix-clawdbot": {
|
||||
"nix-openclaw": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
"home-manager": "home-manager_2",
|
||||
"nix-steipete-tools": "nix-steipete-tools",
|
||||
"nixpkgs": "nixpkgs_3"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1767784060,
|
||||
"narHash": "sha256-3I+Pb9DU6iDjPy+mX+LqXbQYFQ8hJ8KSgXEztGhagzU=",
|
||||
"owner": "clawdbot",
|
||||
"repo": "nix-clawdbot",
|
||||
"rev": "f2035767e930a656a0d2e5c3e2eb1abfebdc5bf0",
|
||||
"lastModified": 1771226102,
|
||||
"narHash": "sha256-Lkav1sgtC4Kf6i1VVAsbe3N0X7t+gP3BKdhQu9l52fQ=",
|
||||
"owner": "openclaw",
|
||||
"repo": "nix-openclaw",
|
||||
"rev": "8d7489b093577466f20cf3c87de9606280b17d03",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "clawdbot",
|
||||
"repo": "nix-clawdbot",
|
||||
"owner": "openclaw",
|
||||
"repo": "nix-openclaw",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nix-steipete-tools": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs_2"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1771128277,
|
||||
"narHash": "sha256-wcVJ9uvHx7KZTezCG6IedeRnJFsHF9Oaej+l8XC2wYM=",
|
||||
"owner": "openclaw",
|
||||
"repo": "nix-steipete-tools",
|
||||
"rev": "90516869c19a49f0434787277a9458436867a53b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "openclaw",
|
||||
"repo": "nix-steipete-tools",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
@ -141,11 +160,11 @@
|
||||
},
|
||||
"nixpkgs_2": {
|
||||
"locked": {
|
||||
"lastModified": 1763618868,
|
||||
"narHash": "sha256-v5afmLjn/uyD9EQuPBn7nZuaZVV9r+JerayK/4wvdWA=",
|
||||
"lastModified": 1767364772,
|
||||
"narHash": "sha256-fFUnEYMla8b7UKjijLnMe+oVFOz6HjijGGNS1l7dYaQ=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "a8d610af3f1a5fb71e23e08434d8d61a466fc942",
|
||||
"rev": "16c7794d0a28b5a37904d55bcca36003b9109aaa",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@ -157,27 +176,11 @@
|
||||
},
|
||||
"nixpkgs_3": {
|
||||
"locked": {
|
||||
"lastModified": 1767116409,
|
||||
"narHash": "sha256-5vKw92l1GyTnjoLzEagJy5V5mDFck72LiQWZSOnSicw=",
|
||||
"lastModified": 1767767207,
|
||||
"narHash": "sha256-Mj3d3PfwltLmukFal5i3fFt27L6NiKXdBezC1EBuZs4=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "cad22e7d996aea55ecab064e84834289143e44a0",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs_4": {
|
||||
"locked": {
|
||||
"lastModified": 1767640445,
|
||||
"narHash": "sha256-UWYqmD7JFBEDBHWYcqE6s6c77pWdcU/i+bwD6XxMb8A=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "9f0c42f8bc7151b8e7e5840fb3bd454ad850d8c5",
|
||||
"rev": "5912c1772a44e31bf1c63c0390b90501e5026886",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@ -190,8 +193,11 @@
|
||||
"root": {
|
||||
"inputs": {
|
||||
"agenix": "agenix",
|
||||
"nix-clawdbot": "nix-clawdbot",
|
||||
"nixpkgs": "nixpkgs_4"
|
||||
"nix-openclaw": "nix-openclaw",
|
||||
"nixpkgs": [
|
||||
"nix-openclaw",
|
||||
"nixpkgs"
|
||||
]
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
|
||||
117
flake.nix
117
flake.nix
@ -2,95 +2,90 @@
|
||||
description = "CLAWDINATOR infra + Nix modules";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
nix-clawdbot.url = "github:clawdbot/nix-clawdbot"; # latest upstream
|
||||
nix-openclaw.url = "github:openclaw/nix-openclaw"; # latest upstream
|
||||
nixpkgs.follows = "nix-openclaw/nixpkgs";
|
||||
agenix.url = "github:ryantm/agenix";
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, nix-clawdbot, agenix }:
|
||||
outputs = { self, nixpkgs, nix-openclaw, agenix }:
|
||||
let
|
||||
lib = nixpkgs.lib;
|
||||
systems = [ "x86_64-linux" "aarch64-linux" ];
|
||||
forAllSystems = f: lib.genAttrs systems (system: f system);
|
||||
clawdbotOverlay = nix-clawdbot.overlays.default;
|
||||
clawdbotShebangFix = final: prev:
|
||||
let
|
||||
removeScript = final.writeShellScript "remove-package-manager-field.sh" ''
|
||||
exec ${final.bash}/bin/bash ${nix-clawdbot}/nix/scripts/remove-package-manager-field.sh "$@"
|
||||
'';
|
||||
promoteScript = final.writeShellScript "promote-pnpm-integrity.sh" ''
|
||||
exec ${final.bash}/bin/bash ${nix-clawdbot}/nix/scripts/promote-pnpm-integrity.sh "$@"
|
||||
'';
|
||||
nodeGypWrapper = final.writeShellScript "node-gyp-wrapper.sh" ''
|
||||
if [ -n "$REAL_NODE_GYP" ]; then
|
||||
exec "$REAL_NODE_GYP" "$@"
|
||||
fi
|
||||
exec node-gyp "$@"
|
||||
'';
|
||||
in {
|
||||
clawdbot-gateway = prev.clawdbot-gateway.overrideAttrs (old: {
|
||||
env = (old.env or {}) // {
|
||||
PNPM_DEPS = old.pnpmDeps;
|
||||
REMOVE_PACKAGE_MANAGER_FIELD_SH = removeScript;
|
||||
PROMOTE_PNPM_INTEGRITY_SH = promoteScript;
|
||||
NODE_GYP_WRAPPER_SH = nodeGypWrapper;
|
||||
};
|
||||
postPatch = ''
|
||||
if [ -f package.json ]; then
|
||||
${removeScript} package.json
|
||||
fi
|
||||
'';
|
||||
preBuild = ''
|
||||
export HOME="$(mktemp -d)"
|
||||
store_path="$(mktemp -d)"
|
||||
clawbotOverlay = nix-openclaw.overlays.default;
|
||||
|
||||
fetcherVersion=$(cat "$PNPM_DEPS/.fetcher-version" 2>/dev/null || echo 1)
|
||||
if [ "$fetcherVersion" -ge 3 ]; then
|
||||
tar --zstd -xf "$PNPM_DEPS/pnpm-store.tar.zst" -C "$store_path"
|
||||
else
|
||||
cp -Tr "$PNPM_DEPS" "$store_path"
|
||||
fi
|
||||
|
||||
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.
|
||||
"${promoteScript}" "$store_path"
|
||||
|
||||
pnpm config set store-dir "$store_path"
|
||||
pnpm config set package-import-method clone-or-copy
|
||||
pnpm config set manage-package-manager-versions false
|
||||
|
||||
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"
|
||||
'';
|
||||
});
|
||||
};
|
||||
revisionModule = { ... }: {
|
||||
system.configurationRevision =
|
||||
if self ? rev then self.rev else (self.dirtyRev or null);
|
||||
};
|
||||
in
|
||||
{
|
||||
nixosModules.clawdinator = import ./nix/modules/clawdinator.nix;
|
||||
nixosModules.default = self.nixosModules.clawdinator;
|
||||
|
||||
overlays.clawdbot = clawdbotOverlay;
|
||||
overlays.clawdbotShebangFix = clawdbotShebangFix;
|
||||
overlays.default = lib.composeExtensions clawdbotOverlay clawdbotShebangFix;
|
||||
overlays.clawbot = clawbotOverlay;
|
||||
overlays.default = clawbotOverlay;
|
||||
|
||||
packages = forAllSystems (system:
|
||||
let
|
||||
pkgs = import nixpkgs {
|
||||
inherit system;
|
||||
overlays = [ self.overlays.default ];
|
||||
};
|
||||
gateway =
|
||||
if pkgs ? openclaw-gateway
|
||||
then pkgs.openclaw-gateway
|
||||
else pkgs.openclaw;
|
||||
systemPackages =
|
||||
if system == "x86_64-linux" then {
|
||||
clawdinator-system = self.nixosConfigurations.clawdinator-1.config.system.build.toplevel;
|
||||
clawdinator-image-system = self.nixosConfigurations.clawdinator-1-image.config.system.build.toplevel;
|
||||
} else {};
|
||||
in {
|
||||
openclaw-gateway = gateway;
|
||||
default = gateway;
|
||||
} // systemPackages);
|
||||
|
||||
nixosConfigurations.clawdinator-1 = nixpkgs.lib.nixosSystem {
|
||||
system = "x86_64-linux";
|
||||
modules = [
|
||||
({ ... }: { nixpkgs.overlays = [ self.overlays.default ]; })
|
||||
revisionModule
|
||||
agenix.nixosModules.default
|
||||
nix-openclaw.nixosModules.openclaw-gateway
|
||||
./nix/hosts/clawdinator-1.nix
|
||||
];
|
||||
};
|
||||
|
||||
nixosConfigurations.clawdinator-2 = nixpkgs.lib.nixosSystem {
|
||||
system = "x86_64-linux";
|
||||
modules = [
|
||||
({ ... }: { nixpkgs.overlays = [ self.overlays.default ]; })
|
||||
revisionModule
|
||||
agenix.nixosModules.default
|
||||
nix-openclaw.nixosModules.openclaw-gateway
|
||||
./nix/hosts/clawdinator-2.nix
|
||||
];
|
||||
};
|
||||
|
||||
nixosConfigurations.clawdinator-babelfish = nixpkgs.lib.nixosSystem {
|
||||
system = "x86_64-linux";
|
||||
modules = [
|
||||
({ ... }: { nixpkgs.overlays = [ self.overlays.default ]; })
|
||||
revisionModule
|
||||
agenix.nixosModules.default
|
||||
nix-openclaw.nixosModules.openclaw-gateway
|
||||
./nix/hosts/clawdinator-babelfish.nix
|
||||
];
|
||||
};
|
||||
|
||||
nixosConfigurations.clawdinator-1-image = nixpkgs.lib.nixosSystem {
|
||||
system = "x86_64-linux";
|
||||
modules = [
|
||||
({ ... }: { nixpkgs.overlays = [ self.overlays.default ]; })
|
||||
revisionModule
|
||||
agenix.nixosModules.default
|
||||
nix-openclaw.nixosModules.openclaw-gateway
|
||||
./nix/hosts/clawdinator-1-image.nix
|
||||
];
|
||||
};
|
||||
|
||||
5
garnix.yaml
Normal file
5
garnix.yaml
Normal file
@ -0,0 +1,5 @@
|
||||
builds:
|
||||
include:
|
||||
- "packages.*"
|
||||
- "packages.x86_64-linux.clawdinator-system"
|
||||
- "packages.x86_64-linux.clawdinator-image-system"
|
||||
18
infra/opentofu/aws/.terraform.lock.hcl
generated
18
infra/opentofu/aws/.terraform.lock.hcl
generated
@ -1,6 +1,24 @@
|
||||
# This file is maintained automatically by "tofu init".
|
||||
# Manual edits may be lost in future updates.
|
||||
|
||||
provider "registry.opentofu.org/hashicorp/archive" {
|
||||
version = "2.7.1"
|
||||
constraints = ">= 2.0.0"
|
||||
hashes = [
|
||||
"h1:/Y6fLmEGMtbcAFi3ALu5tAwEIfUc8vGZRErNjMIfi2U=",
|
||||
"zh:4f8fe5f92125fc7be91379dbde004aaf676fbb523082af167d0a57ac723836bc",
|
||||
"zh:4fba9a08c254fd3c17464c1e13398e4927b1d3e22bfdc3bb66c4e5bd9573ada4",
|
||||
"zh:65e9945c1e89333b01ef25c15518e125817268f9ecddc3f9d5337dc120d342ee",
|
||||
"zh:6cc92ec02475310612a2fc663ab22366c17005203be55287e9af316ac0397ef4",
|
||||
"zh:7e9efa56a27ea28c7a19465b4223f43653988639123160b09507cbc7a9ad5458",
|
||||
"zh:9c77863b5ff47196cec4e82ce9b943c8a0de5840f7b1f82c7e96d92d8be7c7c1",
|
||||
"zh:b6498ff9e2e717c94e5d2c494a2071acc123e8195bfdd9f7965a0676fa866b06",
|
||||
"zh:c5941326ffe88ff77d15fb1212f746ea57eebf7556bc2707c3a054ad0e5a6ab0",
|
||||
"zh:ec1c14feeeb3b78be2ab37533c6ef2e0d6417c6faa82ddd62c446db77f618926",
|
||||
"zh:ed4643c4f8d9f7d060c01463317d68315b6af7197beaba331451ca3991a9c990",
|
||||
]
|
||||
}
|
||||
|
||||
provider "registry.opentofu.org/hashicorp/aws" {
|
||||
version = "6.27.0"
|
||||
constraints = ">= 5.0.0"
|
||||
|
||||
@ -1,36 +1,83 @@
|
||||
# OpenTofu (AWS S3 Image Bucket)
|
||||
# OpenTofu (AWS Infra)
|
||||
|
||||
Goal: use the CLAWDINATOR S3 bucket for images, plus create the VM Import role and attach import permissions to the CI IAM user.
|
||||
Also provisions EFS for shared memory.
|
||||
Goal: manage the CLAWDINATOR fleet infrastructure (S3 image bucket, VM import role, EFS, EC2 instances, and control-plane Lambda).
|
||||
|
||||
Prereqs:
|
||||
The shared image bucket is not image-only. It also stores bootstrap bundles, age-encrypted secrets, and Terraform remote state. Raw image uploads therefore use a prefix-scoped lifecycle rule: only top-level `clawdinator-nixos-*` objects expire automatically. Bootstrap, secrets, and state are intentionally retained.
|
||||
|
||||
## Prereqs
|
||||
- AWS credentials with permissions to manage IAM (use your homelab-admin key locally).
|
||||
- Fleet registry: `nix/instances.json` (authoritative instance list).
|
||||
|
||||
Usage:
|
||||
- export AWS_ACCESS_KEY_ID=...
|
||||
- export AWS_SECRET_ACCESS_KEY=...
|
||||
- export AWS_REGION=eu-central-1
|
||||
- export TF_VAR_aws_region=eu-central-1
|
||||
- export TF_VAR_ami_id=ami-... # leave empty to skip instance creation
|
||||
- export TF_VAR_ssh_public_key="$(cat ~/.ssh/id_ed25519.pub)" # required when ami_id is set
|
||||
- tofu init
|
||||
- tofu apply
|
||||
## Usage
|
||||
|
||||
Outputs:
|
||||
```sh
|
||||
export AWS_ACCESS_KEY_ID=...
|
||||
export AWS_SECRET_ACCESS_KEY=...
|
||||
export AWS_REGION=eu-central-1
|
||||
export TF_VAR_aws_region=eu-central-1
|
||||
export TF_VAR_manage_instances=true
|
||||
export TF_VAR_ami_id=ami-... # required when manage_instances is true
|
||||
export TF_VAR_ssh_public_key="$(cat ~/.ssh/id_ed25519.pub)" # required when manage_instances is true
|
||||
```
|
||||
|
||||
### Remote state (S3 + Dynamo)
|
||||
|
||||
```sh
|
||||
tofu init \
|
||||
-backend-config="bucket=clawdinator-images-eu1-20260107165216" \
|
||||
-backend-config="key=state/clawdinators.tfstate" \
|
||||
-backend-config="region=eu-central-1" \
|
||||
-backend-config="dynamodb_table=clawdinator-terraform-locks"
|
||||
```
|
||||
|
||||
### Apply
|
||||
|
||||
```sh
|
||||
tofu apply
|
||||
```
|
||||
|
||||
## Control-plane API (optional)
|
||||
Enable only when tokens are available:
|
||||
|
||||
```sh
|
||||
export TF_VAR_control_api_enabled=true
|
||||
export TF_VAR_control_api_token=...
|
||||
export TF_VAR_github_token=...
|
||||
```
|
||||
|
||||
## Outputs
|
||||
- `bucket_name`
|
||||
- `pr_intent_bucket_name`
|
||||
- `aws_region`
|
||||
- `ci_user_name`
|
||||
- `access_key_id`
|
||||
- `secret_access_key`
|
||||
- `instance_id`
|
||||
- `instance_public_ip`
|
||||
- `instance_ids`
|
||||
- `instance_public_ips`
|
||||
- `instance_public_dns`
|
||||
- `efs_file_system_id`
|
||||
- `efs_security_group_id`
|
||||
- `control_api_url`
|
||||
- `control_invoker_access_key_id`
|
||||
- `control_invoker_secret_access_key`
|
||||
|
||||
CI wiring:
|
||||
## CI wiring
|
||||
- Set GitHub Actions secrets:
|
||||
- `AWS_ACCESS_KEY_ID`
|
||||
- `AWS_SECRET_ACCESS_KEY`
|
||||
- `AWS_REGION`
|
||||
- `S3_BUCKET`
|
||||
- `CLAWDINATOR_SSH_PUBLIC_KEY`
|
||||
- `CONTROL_API_TOKEN`
|
||||
- `CLAWDINATOR_WORKFLOW_TOKEN`
|
||||
- `CLAWDINATOR_CONTROL_AWS_ACCESS_KEY_ID`
|
||||
- `CLAWDINATOR_CONTROL_AWS_SECRET_ACCESS_KEY`
|
||||
|
||||
## Runtime bootstrap
|
||||
- Instances get an IAM role with read access to `s3://${S3_BUCKET}/bootstrap/*` for secrets + repo seeds.
|
||||
|
||||
## Retention contract
|
||||
- Raw image uploads whose keys start with `clawdinator-nixos-` expire automatically after 14 days.
|
||||
- Because bucket versioning is enabled, noncurrent raw-image versions are also expired so the bytes actually disappear.
|
||||
- The CI IAM user can prune old CLAWDINATOR AMIs and their backing snapshots.
|
||||
- Normal deploys still use the latest self-owned AMI tagged `clawdinator=true`.
|
||||
|
||||
@ -1,10 +1,18 @@
|
||||
terraform {
|
||||
backend "s3" {}
|
||||
}
|
||||
|
||||
provider "aws" {
|
||||
region = var.aws_region
|
||||
}
|
||||
|
||||
locals {
|
||||
tags = merge(var.tags, { "app" = "clawdinator" })
|
||||
instance_enabled = var.ami_id != ""
|
||||
tags = merge(var.tags, { "app" = "clawdinator" })
|
||||
instances = jsondecode(file("${path.module}/../../../nix/instances.json"))
|
||||
|
||||
# Safer toggle: instances are managed unless explicitly disabled.
|
||||
# This avoids accidental fleet destruction when TF_VAR_ami_id is omitted.
|
||||
instance_enabled = var.manage_instances && length(local.instances) > 0
|
||||
}
|
||||
|
||||
resource "aws_s3_bucket" "image_bucket" {
|
||||
@ -41,6 +49,45 @@ resource "aws_s3_bucket_versioning" "image_bucket" {
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_s3_bucket_lifecycle_configuration" "image_bucket" {
|
||||
bucket = aws_s3_bucket.image_bucket.id
|
||||
|
||||
rule {
|
||||
id = "expire-clawdinator-raw-images"
|
||||
status = "Enabled"
|
||||
|
||||
filter {
|
||||
prefix = "clawdinator-nixos-"
|
||||
}
|
||||
|
||||
expiration {
|
||||
days = 14
|
||||
}
|
||||
|
||||
# Versioning is enabled on the shared bucket, so expiring the current object
|
||||
# alone would leave the bytes behind as noncurrent versions.
|
||||
noncurrent_version_expiration {
|
||||
noncurrent_days = 1
|
||||
}
|
||||
|
||||
abort_incomplete_multipart_upload {
|
||||
days_after_initiation = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_dynamodb_table" "terraform_lock" {
|
||||
name = var.terraform_lock_table_name
|
||||
billing_mode = "PAY_PER_REQUEST"
|
||||
hash_key = "LockID"
|
||||
tags = local.tags
|
||||
|
||||
attribute {
|
||||
name = "LockID"
|
||||
type = "S"
|
||||
}
|
||||
}
|
||||
|
||||
data "aws_iam_policy_document" "vmimport_assume" {
|
||||
statement {
|
||||
actions = ["sts:AssumeRole"]
|
||||
@ -106,6 +153,30 @@ data "aws_iam_policy_document" "ami_importer" {
|
||||
resources = [aws_s3_bucket.image_bucket.arn]
|
||||
}
|
||||
|
||||
statement {
|
||||
sid = "BucketRead"
|
||||
actions = [
|
||||
"s3:Get*"
|
||||
]
|
||||
resources = [aws_s3_bucket.image_bucket.arn]
|
||||
}
|
||||
|
||||
# Needed so CI can manage the public PR-intent bucket (read/update bucket policy,
|
||||
# public access block, versioning, encryption, etc.) during tofu apply.
|
||||
statement {
|
||||
sid = "PrIntentBucketManage"
|
||||
actions = [
|
||||
# S3 bucket-level config APIs are unfortunately a mix of GetBucket* and Get*.
|
||||
# Use broad prefixes here; the resource is the bucket ARN so this does not grant
|
||||
# object read/write on this bucket.
|
||||
"s3:Get*",
|
||||
"s3:Put*",
|
||||
"s3:DeleteBucketPolicy",
|
||||
"s3:ListBucket"
|
||||
]
|
||||
resources = [aws_s3_bucket.pr_intent_public.arn]
|
||||
}
|
||||
|
||||
statement {
|
||||
sid = "ObjectReadWrite"
|
||||
actions = [
|
||||
@ -118,6 +189,21 @@ data "aws_iam_policy_document" "ami_importer" {
|
||||
resources = ["${aws_s3_bucket.image_bucket.arn}/*"]
|
||||
}
|
||||
|
||||
statement {
|
||||
sid = "InfraRead"
|
||||
actions = [
|
||||
"ec2:Describe*",
|
||||
"elasticfilesystem:Describe*",
|
||||
"iam:Get*",
|
||||
"iam:List*",
|
||||
"lambda:Get*",
|
||||
"lambda:List*",
|
||||
"dynamodb:Describe*",
|
||||
"dynamodb:ListTagsOfResource"
|
||||
]
|
||||
resources = ["*"]
|
||||
}
|
||||
|
||||
statement {
|
||||
sid = "ImportImage"
|
||||
actions = [
|
||||
@ -128,22 +214,75 @@ data "aws_iam_policy_document" "ami_importer" {
|
||||
"ec2:DescribeImages",
|
||||
"ec2:DescribeSnapshots",
|
||||
"ec2:RegisterImage",
|
||||
"ec2:CreateTags"
|
||||
"ec2:CreateTags",
|
||||
"ec2:DeregisterImage",
|
||||
"ec2:DeleteSnapshot"
|
||||
]
|
||||
resources = ["*"]
|
||||
}
|
||||
|
||||
statement {
|
||||
sid = "PassVmImportRole"
|
||||
actions = ["iam:PassRole"]
|
||||
sid = "FleetInstances"
|
||||
actions = [
|
||||
"ec2:RunInstances",
|
||||
"ec2:TerminateInstances",
|
||||
"ec2:CreateTags",
|
||||
"ec2:DeleteTags",
|
||||
"ec2:ModifyInstanceAttribute"
|
||||
]
|
||||
resources = ["*"]
|
||||
}
|
||||
|
||||
# Allow CI to do fast, declarative deploys via AWS Systems Manager (SSM)
|
||||
# instead of slow AMI replacement.
|
||||
statement {
|
||||
sid = "FleetDeploySSM"
|
||||
actions = [
|
||||
"ssm:SendCommand",
|
||||
"ssm:GetCommandInvocation",
|
||||
"ssm:ListCommands",
|
||||
"ssm:ListCommandInvocations",
|
||||
"ssm:DescribeInstanceInformation",
|
||||
"ssm:GetDocument"
|
||||
]
|
||||
resources = ["*"]
|
||||
}
|
||||
|
||||
statement {
|
||||
sid = "TerraformLockTable"
|
||||
actions = [
|
||||
"dynamodb:DescribeTable",
|
||||
"dynamodb:GetItem",
|
||||
"dynamodb:PutItem",
|
||||
"dynamodb:DeleteItem",
|
||||
"dynamodb:UpdateItem"
|
||||
]
|
||||
resources = [aws_dynamodb_table.terraform_lock.arn]
|
||||
}
|
||||
|
||||
statement {
|
||||
sid = "PassVmImportRole"
|
||||
actions = ["iam:PassRole"]
|
||||
resources = [aws_iam_role.vmimport.arn]
|
||||
}
|
||||
|
||||
statement {
|
||||
sid = "PassInstanceRole"
|
||||
actions = ["iam:PassRole"]
|
||||
resources = [aws_iam_role.instance.arn]
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_iam_user_policy" "ami_importer" {
|
||||
# Use a managed policy (not an inline user policy) to avoid the 2048 byte inline policy limit.
|
||||
resource "aws_iam_policy" "ami_importer" {
|
||||
name = "clawdinator-ami-importer"
|
||||
user = aws_iam_user.ci_user.name
|
||||
policy = data.aws_iam_policy_document.ami_importer.json
|
||||
tags = local.tags
|
||||
}
|
||||
|
||||
resource "aws_iam_user_policy_attachment" "ami_importer" {
|
||||
user = aws_iam_user.ci_user.name
|
||||
policy_arn = aws_iam_policy.ami_importer.arn
|
||||
}
|
||||
|
||||
data "aws_iam_policy_document" "instance_assume" {
|
||||
@ -167,6 +306,49 @@ resource "aws_iam_role_policy_attachment" "instance_ssm" {
|
||||
policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
|
||||
}
|
||||
|
||||
data "aws_iam_policy_document" "instance_bootstrap" {
|
||||
statement {
|
||||
actions = [
|
||||
"s3:GetObject",
|
||||
"s3:GetObjectAttributes"
|
||||
]
|
||||
resources = [
|
||||
"${aws_s3_bucket.image_bucket.arn}/bootstrap/*",
|
||||
"${aws_s3_bucket.image_bucket.arn}/age-secrets/*"
|
||||
]
|
||||
}
|
||||
|
||||
statement {
|
||||
actions = [
|
||||
"s3:PutObject",
|
||||
"s3:AbortMultipartUpload",
|
||||
"s3:ListMultipartUploadParts"
|
||||
]
|
||||
resources = [
|
||||
"${aws_s3_bucket.image_bucket.arn}/bootstrap/*"
|
||||
]
|
||||
}
|
||||
|
||||
statement {
|
||||
actions = [
|
||||
"s3:GetBucketLocation",
|
||||
"s3:ListBucket"
|
||||
]
|
||||
resources = [aws_s3_bucket.image_bucket.arn]
|
||||
condition {
|
||||
test = "StringLike"
|
||||
variable = "s3:prefix"
|
||||
values = ["bootstrap/*", "age-secrets/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_iam_role_policy" "instance_bootstrap" {
|
||||
name = "clawdinator-bootstrap"
|
||||
role = aws_iam_role.instance.id
|
||||
policy = data.aws_iam_policy_document.instance_bootstrap.json
|
||||
}
|
||||
|
||||
resource "aws_iam_instance_profile" "instance" {
|
||||
name = "clawdinator-instance"
|
||||
role = aws_iam_role.instance.name
|
||||
@ -216,16 +398,6 @@ resource "aws_security_group_rule" "ssh_ingress" {
|
||||
cidr_blocks = var.allowed_cidrs
|
||||
}
|
||||
|
||||
resource "aws_security_group_rule" "gateway_ingress" {
|
||||
count = local.instance_enabled ? 1 : 0
|
||||
type = "ingress"
|
||||
security_group_id = aws_security_group.clawdinator[0].id
|
||||
from_port = 18789
|
||||
to_port = 18789
|
||||
protocol = "tcp"
|
||||
cidr_blocks = var.allowed_cidrs
|
||||
}
|
||||
|
||||
resource "aws_security_group_rule" "egress" {
|
||||
count = local.instance_enabled ? 1 : 0
|
||||
type = "egress"
|
||||
@ -270,14 +442,21 @@ resource "aws_efs_mount_target" "memory" {
|
||||
}
|
||||
|
||||
resource "aws_instance" "clawdinator" {
|
||||
count = local.instance_enabled ? 1 : 0
|
||||
for_each = local.instance_enabled ? local.instances : {}
|
||||
ami = var.ami_id
|
||||
instance_type = var.instance_type
|
||||
instance_type = each.value.instanceType
|
||||
subnet_id = element(data.aws_subnets.default.ids, 0)
|
||||
vpc_security_group_ids = [aws_security_group.clawdinator[0].id]
|
||||
key_name = aws_key_pair.operator[0].key_name
|
||||
associate_public_ip_address = true
|
||||
iam_instance_profile = aws_iam_instance_profile.instance.name
|
||||
user_data_replace_on_change = true
|
||||
user_data = templatefile("${path.module}/user-data.sh.tmpl", {
|
||||
instance_name = each.value.host
|
||||
bootstrap_prefix = each.value.bootstrapPrefix
|
||||
flake_host = each.value.host
|
||||
control_api_url = var.control_api_enabled ? aws_lambda_function_url.control[0].function_url : ""
|
||||
})
|
||||
|
||||
root_block_device {
|
||||
volume_size = var.root_volume_size_gb
|
||||
@ -285,6 +464,121 @@ resource "aws_instance" "clawdinator" {
|
||||
}
|
||||
|
||||
tags = merge(local.tags, {
|
||||
Name = var.instance_name
|
||||
Name = each.value.host
|
||||
})
|
||||
}
|
||||
|
||||
data "archive_file" "control_lambda" {
|
||||
count = var.control_api_enabled ? 1 : 0
|
||||
type = "zip"
|
||||
source_dir = "${path.module}/../../../control/api"
|
||||
output_path = "${path.module}/.terraform/control-api.zip"
|
||||
}
|
||||
|
||||
data "aws_iam_policy_document" "control_lambda_assume" {
|
||||
statement {
|
||||
actions = ["sts:AssumeRole"]
|
||||
principals {
|
||||
type = "Service"
|
||||
identifiers = ["lambda.amazonaws.com"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_iam_role" "control_lambda" {
|
||||
count = var.control_api_enabled ? 1 : 0
|
||||
name = var.control_api_name
|
||||
assume_role_policy = data.aws_iam_policy_document.control_lambda_assume.json
|
||||
tags = local.tags
|
||||
}
|
||||
|
||||
resource "aws_iam_role_policy_attachment" "control_lambda_basic" {
|
||||
count = var.control_api_enabled ? 1 : 0
|
||||
role = aws_iam_role.control_lambda[0].name
|
||||
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
|
||||
}
|
||||
|
||||
resource "aws_iam_role_policy" "control_lambda_ec2" {
|
||||
count = var.control_api_enabled ? 1 : 0
|
||||
name = "clawdinator-control-ec2"
|
||||
role = aws_iam_role.control_lambda[0].id
|
||||
policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [
|
||||
{
|
||||
Effect = "Allow"
|
||||
Action = ["ec2:DescribeInstances"]
|
||||
Resource = "*"
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
resource "aws_lambda_function" "control" {
|
||||
count = var.control_api_enabled ? 1 : 0
|
||||
function_name = var.control_api_name
|
||||
role = aws_iam_role.control_lambda[0].arn
|
||||
runtime = "nodejs20.x"
|
||||
handler = "handler.handler"
|
||||
filename = data.archive_file.control_lambda[0].output_path
|
||||
source_code_hash = data.archive_file.control_lambda[0].output_base64sha256
|
||||
timeout = 10
|
||||
memory_size = 256
|
||||
tags = local.tags
|
||||
|
||||
environment {
|
||||
variables = {
|
||||
CONTROL_API_TOKEN = var.control_api_token
|
||||
GITHUB_TOKEN = var.github_token
|
||||
GITHUB_REPO = var.github_repo
|
||||
GITHUB_WORKFLOW = var.github_workflow
|
||||
GITHUB_REF = var.github_ref
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_lambda_function_url" "control" {
|
||||
count = var.control_api_enabled ? 1 : 0
|
||||
function_name = aws_lambda_function.control[0].function_name
|
||||
authorization_type = "NONE"
|
||||
}
|
||||
|
||||
resource "aws_lambda_permission" "control_url" {
|
||||
count = var.control_api_enabled ? 1 : 0
|
||||
statement_id = "AllowFunctionUrl"
|
||||
action = "lambda:InvokeFunctionUrl"
|
||||
function_name = aws_lambda_function.control[0].function_name
|
||||
principal = "*"
|
||||
function_url_auth_type = "NONE"
|
||||
}
|
||||
|
||||
resource "aws_iam_user" "control_invoker" {
|
||||
count = var.control_api_enabled ? 1 : 0
|
||||
name = var.control_invoker_user_name
|
||||
tags = local.tags
|
||||
}
|
||||
|
||||
resource "aws_iam_access_key" "control_invoker" {
|
||||
count = var.control_api_enabled ? 1 : 0
|
||||
user = aws_iam_user.control_invoker[0].name
|
||||
}
|
||||
|
||||
data "aws_iam_policy_document" "control_invoker" {
|
||||
count = var.control_api_enabled ? 1 : 0
|
||||
statement {
|
||||
actions = ["lambda:InvokeFunction"]
|
||||
resources = [aws_lambda_function.control[0].arn]
|
||||
}
|
||||
|
||||
statement {
|
||||
actions = ["ec2:DescribeInstances"]
|
||||
resources = ["*"]
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_iam_user_policy" "control_invoker" {
|
||||
count = var.control_api_enabled ? 1 : 0
|
||||
name = "clawdinator-control-invoke"
|
||||
user = aws_iam_user.control_invoker[0].name
|
||||
policy = data.aws_iam_policy_document.control_invoker[0].json
|
||||
}
|
||||
|
||||
@ -2,6 +2,11 @@ output "bucket_name" {
|
||||
value = aws_s3_bucket.image_bucket.bucket
|
||||
}
|
||||
|
||||
output "pr_intent_bucket_name" {
|
||||
value = aws_s3_bucket.pr_intent_public.bucket
|
||||
description = "Public S3 bucket for anonymous read/list of PR intent artifacts."
|
||||
}
|
||||
|
||||
output "aws_region" {
|
||||
value = var.aws_region
|
||||
}
|
||||
@ -23,19 +28,19 @@ output "secret_access_key" {
|
||||
description = "Use in CI as AWS_SECRET_ACCESS_KEY."
|
||||
}
|
||||
|
||||
output "instance_id" {
|
||||
value = local.instance_enabled ? aws_instance.clawdinator[0].id : null
|
||||
description = "CLAWDINATOR instance ID."
|
||||
output "instance_ids" {
|
||||
value = { for name, inst in aws_instance.clawdinator : name => inst.id }
|
||||
description = "CLAWDINATOR instance IDs by name."
|
||||
}
|
||||
|
||||
output "instance_public_ip" {
|
||||
value = local.instance_enabled ? aws_instance.clawdinator[0].public_ip : null
|
||||
description = "CLAWDINATOR public IP."
|
||||
output "instance_public_ips" {
|
||||
value = { for name, inst in aws_instance.clawdinator : name => inst.public_ip }
|
||||
description = "CLAWDINATOR public IPs by name."
|
||||
}
|
||||
|
||||
output "instance_public_dns" {
|
||||
value = local.instance_enabled ? aws_instance.clawdinator[0].public_dns : null
|
||||
description = "CLAWDINATOR public DNS."
|
||||
value = { for name, inst in aws_instance.clawdinator : name => inst.public_dns }
|
||||
description = "CLAWDINATOR public DNS by name."
|
||||
}
|
||||
|
||||
output "efs_file_system_id" {
|
||||
@ -47,3 +52,20 @@ output "efs_security_group_id" {
|
||||
value = aws_security_group.efs.id
|
||||
description = "Security group ID for EFS."
|
||||
}
|
||||
|
||||
output "control_api_url" {
|
||||
value = var.control_api_enabled ? aws_lambda_function_url.control[0].function_url : null
|
||||
description = "Control-plane API Lambda URL."
|
||||
}
|
||||
|
||||
output "control_invoker_access_key_id" {
|
||||
value = var.control_api_enabled ? aws_iam_access_key.control_invoker[0].id : null
|
||||
description = "Access key for control API Lambda invoke user."
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
output "control_invoker_secret_access_key" {
|
||||
value = var.control_api_enabled ? aws_iam_access_key.control_invoker[0].secret : null
|
||||
description = "Secret access key for control API Lambda invoke user."
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
119
infra/opentofu/aws/pr-intent-public-bucket.tf
Normal file
119
infra/opentofu/aws/pr-intent-public-bucket.tf
Normal file
@ -0,0 +1,119 @@
|
||||
data "aws_caller_identity" "current" {}
|
||||
|
||||
locals {
|
||||
pr_intent_bucket_name = var.pr_intent_bucket_name != "" ? var.pr_intent_bucket_name : "openclaw-pr-intent-${data.aws_caller_identity.current.account_id}"
|
||||
}
|
||||
|
||||
# Public bucket hosting PR intent artifacts (anonymous read + list).
|
||||
resource "aws_s3_bucket" "pr_intent_public" {
|
||||
bucket = local.pr_intent_bucket_name
|
||||
tags = local.tags
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
# Allow bucket policy to grant public access (but keep ACL-based public access blocked).
|
||||
resource "aws_s3_bucket_public_access_block" "pr_intent_public" {
|
||||
bucket = aws_s3_bucket.pr_intent_public.id
|
||||
|
||||
block_public_acls = true
|
||||
ignore_public_acls = true
|
||||
block_public_policy = false
|
||||
restrict_public_buckets = false
|
||||
}
|
||||
|
||||
resource "aws_s3_bucket_ownership_controls" "pr_intent_public" {
|
||||
bucket = aws_s3_bucket.pr_intent_public.id
|
||||
|
||||
rule {
|
||||
object_ownership = "BucketOwnerEnforced"
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_s3_bucket_server_side_encryption_configuration" "pr_intent_public" {
|
||||
bucket = aws_s3_bucket.pr_intent_public.id
|
||||
|
||||
rule {
|
||||
apply_server_side_encryption_by_default {
|
||||
sse_algorithm = "AES256"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_s3_bucket_versioning" "pr_intent_public" {
|
||||
bucket = aws_s3_bucket.pr_intent_public.id
|
||||
|
||||
versioning_configuration {
|
||||
status = var.pr_intent_bucket_versioning_enabled ? "Enabled" : "Suspended"
|
||||
}
|
||||
}
|
||||
|
||||
data "aws_iam_policy_document" "pr_intent_public_bucket_policy" {
|
||||
statement {
|
||||
sid = "AnonymousList"
|
||||
actions = ["s3:ListBucket"]
|
||||
resources = [
|
||||
aws_s3_bucket.pr_intent_public.arn
|
||||
]
|
||||
|
||||
principals {
|
||||
type = "*"
|
||||
identifiers = ["*"]
|
||||
}
|
||||
}
|
||||
|
||||
statement {
|
||||
sid = "AnonymousRead"
|
||||
actions = ["s3:GetObject"]
|
||||
resources = [
|
||||
"${aws_s3_bucket.pr_intent_public.arn}/*"
|
||||
]
|
||||
|
||||
principals {
|
||||
type = "*"
|
||||
identifiers = ["*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_s3_bucket_policy" "pr_intent_public" {
|
||||
bucket = aws_s3_bucket.pr_intent_public.id
|
||||
policy = data.aws_iam_policy_document.pr_intent_public_bucket_policy.json
|
||||
|
||||
depends_on = [aws_s3_bucket_public_access_block.pr_intent_public]
|
||||
}
|
||||
|
||||
# Allow CLAWDINATOR instances to publish artifacts into the public bucket.
|
||||
# (No DeleteObject by default; we publish new/updated files only.)
|
||||
data "aws_iam_policy_document" "instance_pr_intent_publish" {
|
||||
statement {
|
||||
sid = "PublishPrIntentArtifacts"
|
||||
actions = [
|
||||
"s3:PutObject",
|
||||
"s3:PutObjectTagging",
|
||||
"s3:AbortMultipartUpload",
|
||||
"s3:ListBucketMultipartUploads",
|
||||
"s3:ListMultipartUploadParts",
|
||||
"s3:CreateMultipartUpload",
|
||||
"s3:UploadPart",
|
||||
"s3:CompleteMultipartUpload"
|
||||
]
|
||||
resources = [
|
||||
"${aws_s3_bucket.pr_intent_public.arn}/*"
|
||||
]
|
||||
}
|
||||
|
||||
statement {
|
||||
sid = "BucketLocation"
|
||||
actions = ["s3:GetBucketLocation"]
|
||||
resources = [aws_s3_bucket.pr_intent_public.arn]
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_iam_role_policy" "instance_pr_intent_publish" {
|
||||
name = "clawdinator-pr-intent-publish"
|
||||
role = aws_iam_role.instance.id
|
||||
policy = data.aws_iam_policy_document.instance_pr_intent_publish.json
|
||||
}
|
||||
67
infra/opentofu/aws/user-data.sh.tmpl
Normal file
67
infra/opentofu/aws/user-data.sh.tmpl
Normal file
@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
instance_name="${instance_name}"
|
||||
bootstrap_prefix="${bootstrap_prefix}"
|
||||
flake_host="${flake_host}"
|
||||
control_api_url="${control_api_url}"
|
||||
|
||||
install -d -m 0755 /etc/clawdinator
|
||||
printf '%s' "${instance_name}" > /etc/clawdinator/instance-name
|
||||
printf '%s' "${bootstrap_prefix}" > /etc/clawdinator/bootstrap-prefix
|
||||
if [ -n "${control_api_url}" ]; then
|
||||
printf '%s' "${control_api_url}" > /etc/clawdinator/control-api-url
|
||||
fi
|
||||
|
||||
systemctl stop clawdinator.service || true
|
||||
systemctl daemon-reload
|
||||
|
||||
wait_for_service() {
|
||||
local service="$1"
|
||||
local timeout=300
|
||||
local waited=0
|
||||
local state
|
||||
local result
|
||||
local start_ts
|
||||
local previous_start_ts
|
||||
|
||||
if [ "$#" -ge 2 ]; then
|
||||
timeout="$2"
|
||||
fi
|
||||
|
||||
previous_start_ts="$(systemctl show -p ExecMainStartTimestampMonotonic --value "$service")"
|
||||
systemctl restart "$service"
|
||||
while true; do
|
||||
state="$(systemctl show -p ActiveState --value "$service")"
|
||||
result="$(systemctl show -p Result --value "$service")"
|
||||
start_ts="$(systemctl show -p ExecMainStartTimestampMonotonic --value "$service")"
|
||||
|
||||
if [ "$state" = "active" ] && [ "$start_ts" != "$previous_start_ts" ]; then
|
||||
return 0
|
||||
fi
|
||||
if [ "$state" = "inactive" ] && [ "$result" = "success" ] && [ "$start_ts" != "0" ] && [ "$start_ts" != "$previous_start_ts" ]; then
|
||||
return 0
|
||||
fi
|
||||
if [ "$state" = "failed" ] || [ "$result" = "exit-code" ] || [ "$result" = "timeout" ] || [ "$result" = "failed" ]; then
|
||||
systemctl status "$service" --no-pager
|
||||
return 1
|
||||
fi
|
||||
if [ "$waited" -ge "$timeout" ]; then
|
||||
echo "Timed out waiting for $service" >&2
|
||||
systemctl status "$service" --no-pager
|
||||
return 1
|
||||
fi
|
||||
sleep 2
|
||||
waited=$((waited + 2))
|
||||
done
|
||||
}
|
||||
|
||||
wait_for_service "clawdinator-bootstrap.service" 300
|
||||
wait_for_service "clawdinator-repo-seed.service" 300
|
||||
|
||||
if [ ! -d /var/lib/clawd/repos/clawdinators ]; then
|
||||
echo "clawdinator repo missing after bootstrap" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
nixos-rebuild switch --flake /var/lib/clawd/repos/clawdinators#${flake_host}
|
||||
@ -9,6 +9,18 @@ variable "bucket_name" {
|
||||
default = "clawdinator-images-eu1-20260107165216"
|
||||
}
|
||||
|
||||
variable "pr_intent_bucket_name" {
|
||||
description = "Public S3 bucket name for PR intent artifacts."
|
||||
type = string
|
||||
default = "openclaw-pr-intent"
|
||||
}
|
||||
|
||||
variable "pr_intent_bucket_versioning_enabled" {
|
||||
description = "Enable S3 versioning for the public PR intent bucket (useful while iterating on outputs)."
|
||||
type = bool
|
||||
default = true
|
||||
}
|
||||
|
||||
variable "ci_user_name" {
|
||||
description = "IAM user used by CI."
|
||||
type = string
|
||||
@ -21,22 +33,20 @@ variable "tags" {
|
||||
default = {}
|
||||
}
|
||||
|
||||
variable "manage_instances" {
|
||||
description = "Whether to manage (create/update/destroy) the CLAWDINATOR EC2 instances and related networking resources."
|
||||
type = bool
|
||||
default = true
|
||||
}
|
||||
|
||||
variable "ami_id" {
|
||||
description = "AMI ID for CLAWDINATOR instances."
|
||||
type = string
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "instance_name" {
|
||||
description = "Name tag for the CLAWDINATOR instance."
|
||||
type = string
|
||||
default = "clawdinator-1"
|
||||
}
|
||||
|
||||
variable "instance_type" {
|
||||
description = "EC2 instance type."
|
||||
type = string
|
||||
default = "t3.small"
|
||||
validation {
|
||||
condition = !var.manage_instances || var.ami_id != ""
|
||||
error_message = "ami_id is required when manage_instances is true."
|
||||
}
|
||||
}
|
||||
|
||||
variable "root_volume_size_gb" {
|
||||
@ -50,8 +60,8 @@ variable "ssh_public_key" {
|
||||
type = string
|
||||
default = ""
|
||||
validation {
|
||||
condition = var.ami_id == "" || length(var.ssh_public_key) > 0
|
||||
error_message = "ssh_public_key is required when ami_id is set."
|
||||
condition = !var.manage_instances || length(var.ssh_public_key) > 0
|
||||
error_message = "ssh_public_key is required when manage_instances is true."
|
||||
}
|
||||
}
|
||||
|
||||
@ -60,3 +70,67 @@ variable "allowed_cidrs" {
|
||||
type = list(string)
|
||||
default = ["0.0.0.0/0"]
|
||||
}
|
||||
|
||||
variable "terraform_lock_table_name" {
|
||||
description = "DynamoDB table name for OpenTofu state locking."
|
||||
type = string
|
||||
default = "clawdinator-terraform-locks"
|
||||
}
|
||||
|
||||
variable "control_api_enabled" {
|
||||
description = "Enable the control-plane API Lambda."
|
||||
type = bool
|
||||
default = false
|
||||
}
|
||||
|
||||
variable "control_api_name" {
|
||||
description = "Name for the control-plane API Lambda."
|
||||
type = string
|
||||
default = "clawdinator-control-api"
|
||||
}
|
||||
|
||||
variable "control_invoker_user_name" {
|
||||
description = "IAM user for invoking the control API Lambda."
|
||||
type = string
|
||||
default = "clawdinator-control-invoker"
|
||||
}
|
||||
|
||||
variable "control_api_token" {
|
||||
description = "Bearer token required by the control-plane API."
|
||||
type = string
|
||||
sensitive = true
|
||||
default = ""
|
||||
validation {
|
||||
condition = !var.control_api_enabled || length(var.control_api_token) > 0
|
||||
error_message = "control_api_token is required when control_api_enabled is true."
|
||||
}
|
||||
}
|
||||
|
||||
variable "github_token" {
|
||||
description = "GitHub token with workflow dispatch permissions."
|
||||
type = string
|
||||
sensitive = true
|
||||
default = ""
|
||||
validation {
|
||||
condition = !var.control_api_enabled || length(var.github_token) > 0
|
||||
error_message = "github_token is required when control_api_enabled is true."
|
||||
}
|
||||
}
|
||||
|
||||
variable "github_repo" {
|
||||
description = "GitHub repo for workflow dispatch (owner/name)."
|
||||
type = string
|
||||
default = "openclaw/clawdinators"
|
||||
}
|
||||
|
||||
variable "github_workflow" {
|
||||
description = "Workflow file name for fleet deploy."
|
||||
type = string
|
||||
default = "fleet-deploy.yml"
|
||||
}
|
||||
|
||||
variable "github_ref" {
|
||||
description = "Git ref to deploy from."
|
||||
type = string
|
||||
default = "main"
|
||||
}
|
||||
|
||||
@ -6,6 +6,10 @@ terraform {
|
||||
source = "hashicorp/aws"
|
||||
version = ">= 5.0.0"
|
||||
}
|
||||
archive = {
|
||||
source = "hashicorp/archive"
|
||||
version = ">= 2.0.0"
|
||||
}
|
||||
random = {
|
||||
source = "hashicorp/random"
|
||||
version = ">= 3.5.0"
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
Canonical architecture decisions and invariants for CLAWDINATOR.
|
||||
|
||||
- Infra: OpenTofu + AWS AMI pipeline for host provisioning.
|
||||
- Config: NixOS modules/flake, tracking latest nix-clawdbot.
|
||||
- Config: NixOS modules/flake, tracking latest nix-openclaw.
|
||||
- Runtime: Clawdbot gateway + CLAWDINATOR service.
|
||||
- Memory: shared filesystem under /var/lib/clawd/memory.
|
||||
|
||||
|
||||
@ -7,7 +7,10 @@ Required config:
|
||||
- Prefer guild + channel IDs (Developer Mode), not names.
|
||||
- Require mentions in shared channels.
|
||||
|
||||
Placeholders (fill with real IDs):
|
||||
- Guild ID: <GUILD_ID>
|
||||
- Allowed channels: <CHANNEL_ID_1>, <CHANNEL_ID_2>
|
||||
- Allowed users (optional): <USER_ID_1>
|
||||
Active IDs:
|
||||
- Guild ID: 1456350064065904867
|
||||
- Main CLAWDINATOR allowlist:
|
||||
- 1458426982579830908 (#clawdinators-test, mention-only)
|
||||
- BABELFISH channels:
|
||||
- 1467469670192910387 (#help-中文, translation-only; no mentions required)
|
||||
- 1468983176620675132 (#tech-中国, translation-only; no mentions required)
|
||||
|
||||
@ -6,3 +6,46 @@ Operational runbook notes and gotchas.
|
||||
- Use nixos-anywhere for first install, then self-update timer for upgrades.
|
||||
|
||||
Update with incidents, fixes, and operational lessons.
|
||||
|
||||
## 2026-01-29
|
||||
- AMI: ami-0b6acad77477abc33 (clawdinators 063b573, nix-openclaw 8ff02aae; extensions packaged).
|
||||
- Instance: i-0e6125bd57991c5cc (IP 3.75.198.206, DNS ec2-3-75-198-206.eu-central-1.compute.amazonaws.com).
|
||||
- Discord plugin now loads via packaged extensions; config includes plugins.entries.discord.enabled.
|
||||
- Note: Discord gateway logged intermittent code 1006 closes; `openclaw doctor` reports Discord ok.
|
||||
|
||||
## 2026-02-01
|
||||
- AMI: ami-003e9e3a97f875f63 (t3.large rebuild; swap + git identity baked).
|
||||
- Instance: i-077b9075e32a3b8f7 (IP 3.121.98.87, DNS ec2-3-121-98-87.eu-central-1.compute.amazonaws.com).
|
||||
|
||||
## 2026-02-02
|
||||
- AMI: ami-047e0e6354df0f87e (pi coding agent + OpenAI API defaults).
|
||||
- Instance: i-0d1b0e288dd70273b (IP 3.73.1.102, DNS ec2-3-73-1-102.eu-central-1.compute.amazonaws.com).
|
||||
|
||||
## 2026-02-03
|
||||
- AMI: ami-027054fbbee8d71cc (multi-instance fleet).
|
||||
- Instances:
|
||||
- clawdinator-1: i-0b6060699bb413d82 (IP 18.198.25.107, DNS ec2-18-198-25-107.eu-central-1.compute.amazonaws.com).
|
||||
- clawdinator-2: i-07bcba2bb924dfc93 (IP 3.66.165.141, DNS ec2-3-66-165-141.eu-central-1.compute.amazonaws.com).
|
||||
|
||||
## 2026-02-04
|
||||
- clawdinator-2 booted without /etc/ec2-metadata/user-data, so amazon-init skipped user-data and clawdinator stayed inactive.
|
||||
- Manual recovery: fetch IMDS user-data, rerun user-data script, set git safe.directory, set transient hostname.
|
||||
- Fix: add fetch-ec2-metadata systemd unit to AMI config + git safe.directory in programs.git.
|
||||
- AMI: ami-0ae43cb24200e1665 (user-data oneshot restart + wait loop).
|
||||
- Instance: clawdinator-2: i-00fe5c0c6372baaf3 (IP 54.93.75.82, DNS ec2-54-93-75-82.eu-central-1.compute.amazonaws.com).
|
||||
- Note: amazon-init completed; clawdinator active; transient hostname still clawdinator-1 (static clawdinator-2).
|
||||
- AMI: ami-004e1c2ade3e2b9e6 (used for babelfish deploy; bootstrap bundle updated).
|
||||
- Instance: clawdinator-babelfish: i-00b889d8ad5977eba (IP 3.76.43.198, DNS ec2-3-76-43-198.eu-central-1.compute.amazonaws.com).
|
||||
- Note: CLAWDINATOR-BABELFISH translation-only bot on t3.small; transient hostname still clawdinator-1 (static clawdinator-babelfish).
|
||||
- Redeployed babelfish after config schema fix:
|
||||
- Instance: i-0d966485e75e60437 (IP 63.177.84.106, DNS ec2-63-177-84-106.eu-central-1.compute.amazonaws.com).
|
||||
- AMI: ami-004e1c2ade3e2b9e6.
|
||||
- Redeployed babelfish to disable sandbox (docker ENOENT fix):
|
||||
- Instance: i-0d8542109946b2005 (IP 18.184.241.54, DNS ec2-18-184-241-54.eu-central-1.compute.amazonaws.com).
|
||||
- AMI: ami-004e1c2ade3e2b9e6.
|
||||
- Redeployed babelfish to trim forum context output:
|
||||
- Instance: i-09dfb9a32728a2ec9 (IP 3.124.183.184, DNS ec2-3-124-183-184.eu-central-1.compute.amazonaws.com).
|
||||
- AMI: ami-004e1c2ade3e2b9e6.
|
||||
- Redeployed babelfish to disable thread starter injection:
|
||||
- Instance: i-0174135dce4039101 (IP 3.122.248.167, DNS ec2-3-122-248-167.eu-central-1.compute.amazonaws.com).
|
||||
- AMI: ami-004e1c2ade3e2b9e6.
|
||||
|
||||
@ -5,10 +5,10 @@ This directory holds Nix modules/flakes to configure CLAWDINATOR hosts.
|
||||
References (local repos on the same machine):
|
||||
- `../nix/ai-stack`
|
||||
- `../nix/nixos-config`
|
||||
- `../nix/nix-clawdbot`
|
||||
- `../nix/nix-openclaw`
|
||||
|
||||
Responsibilities:
|
||||
- Install and configure clawdbot runtime
|
||||
- Install and configure clawbot runtime
|
||||
- Set up systemd services
|
||||
- Mount /var/lib/clawd (shared memory)
|
||||
- Inject secrets (Discord token, Anthropic key, GitHub token)
|
||||
@ -25,5 +25,5 @@ Secrets:
|
||||
- Explicit token files only: `discordTokenFile`, `anthropicApiKeyFile`, and either `githubPatFile` or `githubApp.*`.
|
||||
|
||||
Updates:
|
||||
- Tracks `github:clawdbot/nix-clawdbot` (latest upstream)
|
||||
- Tracks `github:openclaw/nix-openclaw` (latest upstream)
|
||||
- Self-update timer available via `services.clawdinator.selfUpdate.*`
|
||||
|
||||
@ -4,11 +4,23 @@
|
||||
"/var/lib/clawd/nix-secrets/clawdinator-github-app.pem.age";
|
||||
age.secrets."clawdinator-anthropic-api-key".file =
|
||||
"/var/lib/clawd/nix-secrets/clawdinator-anthropic-api-key.age";
|
||||
age.secrets."clawdinator-discord-token".file =
|
||||
"/var/lib/clawd/nix-secrets/clawdinator-discord-token.age";
|
||||
age.secrets."clawdinator-openai-api-key-peter-2".file =
|
||||
"/var/lib/clawd/nix-secrets/clawdinator-openai-api-key-peter-2.age";
|
||||
age.secrets."clawdinator-discord-token-1".file =
|
||||
"/var/lib/clawd/nix-secrets/clawdinator-discord-token-1.age";
|
||||
age.secrets."clawdinator-control-token".file =
|
||||
"/var/lib/clawd/nix-secrets/clawdinator-control-token.age";
|
||||
age.secrets."clawdinator-control-aws-access-key-id".file =
|
||||
"/var/lib/clawd/nix-secrets/clawdinator-control-aws-access-key-id.age";
|
||||
age.secrets."clawdinator-control-aws-secret-access-key".file =
|
||||
"/var/lib/clawd/nix-secrets/clawdinator-control-aws-secret-access-key.age";
|
||||
age.secrets."clawdinator-telegram-bot-token".file =
|
||||
"/var/lib/clawd/nix-secrets/clawdinator-telegram-bot-token.age";
|
||||
age.secrets."clawdinator-telegram-allow-from".file =
|
||||
"/var/lib/clawd/nix-secrets/clawdinator-telegram-allow-from.age";
|
||||
|
||||
services.openssh.enable = true;
|
||||
networking.firewall.allowedTCPPorts = [ 22 18789 ];
|
||||
networking.firewall.allowedTCPPorts = [ 22 ];
|
||||
|
||||
services.clawdinator = {
|
||||
enable = true;
|
||||
@ -21,33 +33,57 @@
|
||||
mountPoint = "/memory";
|
||||
};
|
||||
|
||||
# Raw Clawdbot config JSON (schema is upstream). Extend as needed.
|
||||
# Raw Clawbot config JSON (schema is upstream). Extend as needed.
|
||||
config = {
|
||||
gateway.mode = "server";
|
||||
agent.workspace = "/var/lib/clawd/workspace";
|
||||
routing.queue.bySurface = {
|
||||
gateway = {
|
||||
mode = "local";
|
||||
bind = "loopback";
|
||||
auth.token = "<GATEWAY_TOKEN>";
|
||||
};
|
||||
agents.defaults.workspace = "/var/lib/clawd/workspace";
|
||||
messages.queue.byChannel = {
|
||||
discord = "queue";
|
||||
telegram = "interrupt";
|
||||
whatsapp = "interrupt";
|
||||
};
|
||||
identity.name = "CLAWDINATOR-1";
|
||||
skills.allowBundled = [ "github" "clawdhub" ];
|
||||
discord = {
|
||||
enabled = true;
|
||||
dm.enabled = false;
|
||||
guilds = {
|
||||
"<GUILD_ID>" = {
|
||||
requireMention = true;
|
||||
channels = {
|
||||
"<CHANNEL_NAME>" = { allow = true; requireMention = true; };
|
||||
plugins.slots.memory = "none";
|
||||
plugins.entries.discord.enabled = true;
|
||||
plugins.entries.telegram.enabled = true;
|
||||
agents.list = [
|
||||
{
|
||||
id = "main";
|
||||
default = true;
|
||||
identity.name = "CLAWDINATOR-1";
|
||||
}
|
||||
];
|
||||
skills.allowBundled = [ "github" "clawdhub" "coding-agent" ];
|
||||
channels = {
|
||||
discord = {
|
||||
enabled = true;
|
||||
dm.enabled = false;
|
||||
guilds = {
|
||||
"<GUILD_ID>" = {
|
||||
requireMention = true;
|
||||
channels = {
|
||||
"<CHANNEL_NAME>" = { allow = true; requireMention = true; };
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
telegram = {
|
||||
enabled = true;
|
||||
dmPolicy = "allowlist";
|
||||
allowFrom = [ "\${CLAWDINATOR_TELEGRAM_ALLOW_FROM}" ];
|
||||
groupPolicy = "disabled";
|
||||
tokenFile = "/run/agenix/clawdinator-telegram-bot-token";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
anthropicApiKeyFile = "/run/agenix/clawdinator-anthropic-api-key";
|
||||
discordTokenFile = "/run/agenix/clawdinator-discord-token";
|
||||
openaiApiKeyFile = "/run/agenix/clawdinator-openai-api-key-peter-2";
|
||||
discordTokenFile = "/run/agenix/clawdinator-discord-token-1";
|
||||
telegramAllowFromFile = "/run/agenix/clawdinator-telegram-allow-from";
|
||||
|
||||
githubApp = {
|
||||
enable = true;
|
||||
@ -58,7 +94,7 @@
|
||||
};
|
||||
|
||||
selfUpdate.enable = true;
|
||||
selfUpdate.flakePath = "/var/lib/clawd/repo";
|
||||
selfUpdate.flakePath = "/var/lib/clawd/repos/clawdinators";
|
||||
selfUpdate.flakeHost = "clawdinator-1";
|
||||
};
|
||||
}
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
nix-clawdbot.url = "github:clawdbot/nix-clawdbot"; # latest upstream
|
||||
nix-openclaw.url = "github:openclaw/nix-openclaw"; # latest upstream
|
||||
agenix.url = "github:ryantm/agenix";
|
||||
secrets = {
|
||||
url = "path:../../../nix/nix-secrets";
|
||||
@ -12,7 +12,7 @@
|
||||
clawdinators.url = "path:../..";
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, nix-clawdbot, agenix, secrets, clawdinators }:
|
||||
outputs = { self, nixpkgs, nix-openclaw, agenix, secrets, clawdinators }:
|
||||
let
|
||||
system = "x86_64-linux";
|
||||
in {
|
||||
|
||||
@ -1,142 +0,0 @@
|
||||
{ lib, config, ... }:
|
||||
let
|
||||
secretsPath = config.clawdinator.secretsPath;
|
||||
in
|
||||
{
|
||||
options.clawdinator.secretsPath = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "Path to encrypted age secrets for CLAWDINATOR.";
|
||||
};
|
||||
|
||||
config = {
|
||||
age.identityPaths = [ "/etc/agenix/keys/clawdinator.agekey" ];
|
||||
age.secrets."clawdinator-github-app.pem" = {
|
||||
file = "${secretsPath}/clawdinator-github-app.pem.age";
|
||||
owner = "clawdinator";
|
||||
group = "clawdinator";
|
||||
};
|
||||
age.secrets."clawdinator-anthropic-api-key" = {
|
||||
file = "${secretsPath}/clawdinator-anthropic-api-key.age";
|
||||
owner = "clawdinator";
|
||||
group = "clawdinator";
|
||||
};
|
||||
age.secrets."clawdinator-discord-token" = {
|
||||
file = "${secretsPath}/clawdinator-discord-token.age";
|
||||
owner = "clawdinator";
|
||||
group = "clawdinator";
|
||||
};
|
||||
|
||||
services.clawdinator = {
|
||||
enable = true;
|
||||
instanceName = "CLAWDINATOR-1";
|
||||
memoryDir = "/memory";
|
||||
memoryEfs = {
|
||||
enable = true;
|
||||
fileSystemId = "fs-0e7920726c2965a88";
|
||||
region = "eu-central-1";
|
||||
mountPoint = "/memory";
|
||||
};
|
||||
repoSeeds = [
|
||||
{
|
||||
name = "clawdbot";
|
||||
url = "https://github.com/clawdbot/clawdbot.git";
|
||||
}
|
||||
{
|
||||
name = "nix-clawdbot";
|
||||
url = "https://github.com/clawdbot/nix-clawdbot.git";
|
||||
}
|
||||
{
|
||||
name = "clawdinators";
|
||||
url = "https://github.com/clawdbot/clawdinators.git";
|
||||
}
|
||||
{
|
||||
name = "clawdhub";
|
||||
url = "https://github.com/clawdbot/clawdhub.git";
|
||||
}
|
||||
{
|
||||
name = "nix-steipete-tools";
|
||||
url = "https://github.com/clawdbot/nix-steipete-tools.git";
|
||||
}
|
||||
];
|
||||
|
||||
config = {
|
||||
gateway.mode = "local";
|
||||
agent.workspace = "/var/lib/clawd/workspace";
|
||||
agent.maxConcurrent = 4;
|
||||
agent.skipBootstrap = true;
|
||||
logging = {
|
||||
level = "info";
|
||||
file = "/var/lib/clawd/logs/clawdbot.log";
|
||||
};
|
||||
session.sendPolicy = {
|
||||
default = "allow";
|
||||
rules = [
|
||||
{
|
||||
action = "deny";
|
||||
match.keyPrefix = "agent:main:discord:channel:1458138963067011176";
|
||||
}
|
||||
{
|
||||
action = "deny";
|
||||
match.keyPrefix = "agent:main:discord:channel:1458141495701012561";
|
||||
}
|
||||
];
|
||||
};
|
||||
routing.queue = {
|
||||
mode = "interrupt";
|
||||
bySurface = {
|
||||
discord = "queue";
|
||||
telegram = "interrupt";
|
||||
whatsapp = "interrupt";
|
||||
webchat = "queue";
|
||||
};
|
||||
};
|
||||
identity.name = "CLAWDINATOR-1";
|
||||
skills.allowBundled = [ "github" "clawdhub" ];
|
||||
discord = {
|
||||
enabled = true;
|
||||
dm.enabled = false;
|
||||
guilds = {
|
||||
"1456350064065904867" = {
|
||||
requireMention = false;
|
||||
channels = {
|
||||
# #clawdinators-test
|
||||
"1458426982579830908" = {
|
||||
allow = true;
|
||||
requireMention = false;
|
||||
autoReply = true;
|
||||
};
|
||||
# #clawdributors-test (lurk only; replies denied via sendPolicy)
|
||||
"1458138963067011176" = {
|
||||
allow = true;
|
||||
requireMention = false;
|
||||
};
|
||||
# #clawdributors (lurk only; replies denied via sendPolicy)
|
||||
"1458141495701012561" = {
|
||||
allow = true;
|
||||
requireMention = false;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
anthropicApiKeyFile = "/run/agenix/clawdinator-anthropic-api-key";
|
||||
discordTokenFile = "/run/agenix/clawdinator-discord-token";
|
||||
|
||||
githubApp = {
|
||||
enable = true;
|
||||
appId = "2607181";
|
||||
installationId = "102951645";
|
||||
privateKeyFile = "/run/agenix/clawdinator-github-app.pem";
|
||||
schedule = "hourly";
|
||||
};
|
||||
|
||||
selfUpdate.enable = true;
|
||||
selfUpdate.flakePath = "/var/lib/clawd/repo";
|
||||
selfUpdate.flakeHost = "clawdinator-1";
|
||||
|
||||
githubSync.enable = true;
|
||||
};
|
||||
};
|
||||
}
|
||||
@ -1,9 +1,9 @@
|
||||
{ modulesPath, config, ... }: {
|
||||
{ modulesPath, config, pkgs, ... }: {
|
||||
imports = [
|
||||
(modulesPath + "/virtualisation/ec2-data.nix")
|
||||
(modulesPath + "/virtualisation/amazon-init.nix")
|
||||
../modules/clawdinator.nix
|
||||
./clawdinator-1-common.nix
|
||||
./clawdinator-common.nix
|
||||
];
|
||||
|
||||
networking.hostName = "clawdinator-1";
|
||||
@ -21,27 +21,28 @@
|
||||
networking.useDHCP = true;
|
||||
services.openssh.enable = true;
|
||||
services.openssh.settings.PermitRootLogin = "prohibit-password";
|
||||
assertions = [
|
||||
{
|
||||
assertion = (builtins.getEnv "CLAWDINATOR_AGE_KEY") != "";
|
||||
message = "CLAWDINATOR_AGE_KEY must be set when building the image.";
|
||||
}
|
||||
{
|
||||
assertion = (builtins.getEnv "CLAWDINATOR_SECRETS_DIR") != "";
|
||||
message = "CLAWDINATOR_SECRETS_DIR must point at encrypted age secrets.";
|
||||
}
|
||||
];
|
||||
|
||||
environment.etc."agenix/keys/clawdinator.agekey" = {
|
||||
text = builtins.getEnv "CLAWDINATOR_AGE_KEY";
|
||||
mode = "0400";
|
||||
};
|
||||
users.users.root.openssh.authorizedKeys.keys = [
|
||||
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOLItFT3SVm5r7gELrfRRJxh6V2sf/BIx7HKXt6oVWpB"
|
||||
];
|
||||
|
||||
clawdinator.secretsPath = toString (builtins.path {
|
||||
path = builtins.toPath (builtins.getEnv "CLAWDINATOR_SECRETS_DIR");
|
||||
name = "clawdinator-age-secrets";
|
||||
});
|
||||
fileSystems."/" = {
|
||||
device = "/dev/disk/by-label/nixos";
|
||||
fsType = "ext4";
|
||||
};
|
||||
|
||||
systemd.services.fetch-ec2-metadata = {
|
||||
description = "Fetch EC2 metadata";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
wants = [ "network-online.target" ];
|
||||
after = [ "network-online.target" ];
|
||||
path = [ pkgs.curl ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
StandardOutput = "journal+console";
|
||||
ExecStart = "${pkgs.bash}/bin/bash ${../../scripts/fetch-ec2-metadata.sh}";
|
||||
};
|
||||
};
|
||||
|
||||
systemd.services.amazon-init.after = [ "fetch-ec2-metadata.service" ];
|
||||
systemd.services.amazon-init.wants = [ "fetch-ec2-metadata.service" ];
|
||||
}
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
imports = [
|
||||
(modulesPath + "/virtualisation/amazon-image.nix")
|
||||
../modules/clawdinator.nix
|
||||
./clawdinator-1-common.nix
|
||||
./clawdinator-common.nix
|
||||
];
|
||||
|
||||
networking.hostName = "clawdinator-1";
|
||||
@ -19,7 +19,19 @@
|
||||
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOLItFT3SVm5r7gELrfRRJxh6V2sf/BIx7HKXt6oVWpB"
|
||||
];
|
||||
|
||||
networking.firewall.allowedTCPPorts = [ 22 18789 ];
|
||||
networking.firewall.allowedTCPPorts = [ 22 ];
|
||||
|
||||
clawdinator.bootstrapPrefix = "bootstrap/clawdinator-1";
|
||||
clawdinator.discordTokenSecret = "clawdinator-discord-token-1";
|
||||
|
||||
# Publish PR intent artifacts from EFS to the public bucket.
|
||||
# (Timer + oneshot service; safe to run without stopping the gateway.)
|
||||
services.clawdinator.publicS3 = {
|
||||
enable = true;
|
||||
bucket = "openclaw-pr-intent";
|
||||
region = "eu-central-1";
|
||||
sourceDir = "/memory/pr-intent";
|
||||
# schedule = "*:0/10"; # default
|
||||
};
|
||||
|
||||
clawdinator.secretsPath = "/var/lib/clawd/nix-secrets";
|
||||
}
|
||||
|
||||
30
nix/hosts/clawdinator-2.nix
Normal file
30
nix/hosts/clawdinator-2.nix
Normal file
@ -0,0 +1,30 @@
|
||||
{ lib, modulesPath, pkgs, ... }:
|
||||
{
|
||||
imports = [
|
||||
(modulesPath + "/virtualisation/amazon-image.nix")
|
||||
../modules/clawdinator.nix
|
||||
./clawdinator-common.nix
|
||||
];
|
||||
|
||||
networking.hostName = "clawdinator-2";
|
||||
time.timeZone = "UTC";
|
||||
system.stateVersion = "26.05";
|
||||
|
||||
nix.package = pkgs.nixVersions.stable;
|
||||
nix.settings.experimental-features = [ "nix-command" "flakes" ];
|
||||
|
||||
boot.loader.grub.device = lib.mkForce "/dev/nvme0n1";
|
||||
|
||||
users.users.root.openssh.authorizedKeys.keys = [
|
||||
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOLItFT3SVm5r7gELrfRRJxh6V2sf/BIx7HKXt6oVWpB"
|
||||
];
|
||||
|
||||
networking.firewall.allowedTCPPorts = [ 22 ];
|
||||
|
||||
clawdinator.bootstrapPrefix = "bootstrap/clawdinator-2";
|
||||
clawdinator.discordTokenSecret = "clawdinator-discord-token-2";
|
||||
|
||||
# Discord-only instance: disable Telegram.
|
||||
services.clawdinator.config.plugins.entries.telegram.enabled = false;
|
||||
services.clawdinator.config.channels.telegram.enabled = false;
|
||||
}
|
||||
195
nix/hosts/clawdinator-babelfish.nix
Normal file
195
nix/hosts/clawdinator-babelfish.nix
Normal file
@ -0,0 +1,195 @@
|
||||
{ lib, modulesPath, pkgs, ... }:
|
||||
{
|
||||
imports = [
|
||||
(modulesPath + "/virtualisation/amazon-image.nix")
|
||||
../modules/clawdinator.nix
|
||||
./clawdinator-common.nix
|
||||
];
|
||||
|
||||
networking.hostName = "clawdinator-babelfish";
|
||||
time.timeZone = "UTC";
|
||||
system.stateVersion = "26.05";
|
||||
|
||||
nix.package = pkgs.nixVersions.stable;
|
||||
nix.settings.experimental-features = [ "nix-command" "flakes" ];
|
||||
|
||||
boot.loader.grub.device = lib.mkForce "/dev/nvme0n1";
|
||||
|
||||
users.users.root.openssh.authorizedKeys.keys = [
|
||||
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOLItFT3SVm5r7gELrfRRJxh6V2sf/BIx7HKXt6oVWpB"
|
||||
];
|
||||
|
||||
networking.firewall.allowedTCPPorts = [ 22 ];
|
||||
|
||||
clawdinator.bootstrapPrefix = "bootstrap/clawdinator-babelfish";
|
||||
clawdinator.discordTokenSecret = "clawdinator-discord-token-babelfish";
|
||||
|
||||
services.clawdinator = {
|
||||
githubApp.enable = lib.mkForce false;
|
||||
githubSync.enable = lib.mkForce false;
|
||||
cronJobsFile = lib.mkForce null;
|
||||
|
||||
config = lib.mkForce {
|
||||
gateway = {
|
||||
mode = "local";
|
||||
bind = "loopback";
|
||||
auth.token = "clawdinator-local";
|
||||
};
|
||||
|
||||
logging = {
|
||||
level = "info";
|
||||
file = "/var/lib/clawd/logs/openclaw.log";
|
||||
};
|
||||
|
||||
agents = {
|
||||
defaults = {
|
||||
workspace = "/var/lib/clawd/workspace-babelfish";
|
||||
maxConcurrent = 2;
|
||||
skipBootstrap = true;
|
||||
models = {
|
||||
"openai/gpt-5.2" = { alias = "gpt"; };
|
||||
};
|
||||
model = {
|
||||
primary = "openai/gpt-5.2";
|
||||
fallbacks = [ ];
|
||||
};
|
||||
thinkingDefault = "medium";
|
||||
envelopeTimestamp = "off";
|
||||
envelopeElapsed = "off";
|
||||
};
|
||||
|
||||
list = [
|
||||
{
|
||||
id = "babelfish";
|
||||
default = true;
|
||||
identity.name = "CLAWDINATOR-BABELFISH";
|
||||
tools = {
|
||||
profile = "minimal";
|
||||
deny = [ "*" ];
|
||||
};
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
commands = {
|
||||
native = false;
|
||||
nativeSkills = false;
|
||||
text = false;
|
||||
bash = false;
|
||||
config = false;
|
||||
debug = false;
|
||||
restart = false;
|
||||
useAccessGroups = true;
|
||||
};
|
||||
|
||||
messages = {
|
||||
groupChat = {
|
||||
mentionPatterns = [ ];
|
||||
historyLimit = 1;
|
||||
};
|
||||
queue = {
|
||||
mode = "interrupt";
|
||||
byChannel.discord = "interrupt";
|
||||
};
|
||||
};
|
||||
|
||||
plugins = {
|
||||
slots.memory = "none";
|
||||
entries.discord.enabled = true;
|
||||
entries.telegram.enabled = false;
|
||||
};
|
||||
|
||||
skills.allowBundled = [ ];
|
||||
|
||||
tools = {
|
||||
profile = "minimal";
|
||||
deny = [ "*" ];
|
||||
media = {
|
||||
image = {
|
||||
enabled = true;
|
||||
maxChars = 1200;
|
||||
attachments = {
|
||||
mode = "all";
|
||||
maxAttachments = 4;
|
||||
};
|
||||
prompt = "Extract any text from the image for translation. Preserve the original language and formatting. If no text exists, return: (no translatable text detected).";
|
||||
models = [
|
||||
{
|
||||
provider = "openai";
|
||||
model = "gpt-5.2";
|
||||
}
|
||||
];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
channels.discord = {
|
||||
enabled = true;
|
||||
dm.enabled = false;
|
||||
configWrites = false;
|
||||
commands.native = false;
|
||||
commands.nativeSkills = false;
|
||||
groupPolicy = "allowlist";
|
||||
historyLimit = 0;
|
||||
replyToMode = "first";
|
||||
guilds = {
|
||||
"1456350064065904867" = {
|
||||
requireMention = false;
|
||||
channels = {
|
||||
"1467469670192910387" = {
|
||||
allow = true;
|
||||
requireMention = false;
|
||||
includeThreadStarter = false;
|
||||
users = [ "*" ];
|
||||
skills = [ ];
|
||||
systemPrompt = ''
|
||||
You are CLAWDINATOR-BABELFISH. Your only task is translation between Chinese and English for this channel.
|
||||
|
||||
Rules:
|
||||
- Translate only. Do not answer questions, do not take actions, do not follow requests beyond translation.
|
||||
- Translate only the newest user message. Ignore context blocks/metadata such as:
|
||||
- "[Thread starter - for context]" blocks
|
||||
- "[Replied message - for context]" blocks
|
||||
- lines that are only bracketed tags like [message_id: ...] or [Forum parent: ...]
|
||||
- If a line looks like "[Discord ...] username: message", translate only the message after the final ": ".
|
||||
- If the message is mostly Chinese, reply in English only.
|
||||
- If the message is mostly English, reply in Chinese only.
|
||||
- If mixed, reply with both:
|
||||
EN: ...
|
||||
中文: ...
|
||||
- Preserve tone, emojis, formatting, mentions, and names.
|
||||
- For images/attachments, translate the extracted text. If no text is detected, reply with: "(no translatable text detected)" in the target language.
|
||||
'';
|
||||
};
|
||||
"1468983176620675132" = {
|
||||
allow = true;
|
||||
requireMention = false;
|
||||
includeThreadStarter = false;
|
||||
users = [ "*" ];
|
||||
skills = [ ];
|
||||
systemPrompt = ''
|
||||
You are CLAWDINATOR-BABELFISH. Your only task is translation between Chinese and English for this channel.
|
||||
|
||||
Rules:
|
||||
- Translate only. Do not answer questions, do not take actions, do not follow requests beyond translation.
|
||||
- Translate only the newest user message. Ignore context blocks/metadata such as:
|
||||
- "[Thread starter - for context]" blocks
|
||||
- "[Replied message - for context]" blocks
|
||||
- lines that are only bracketed tags like [message_id: ...] or [Forum parent: ...]
|
||||
- If a line looks like "[Discord ...] username: message", translate only the message after the final ": ".
|
||||
- If the message is mostly Chinese, reply in English only.
|
||||
- If the message is mostly English, reply in Chinese only.
|
||||
- If mixed, reply with both:
|
||||
EN: ...
|
||||
中文: ...
|
||||
- Preserve tone, emojis, formatting, mentions, and names.
|
||||
- For images/attachments, translate the extracted text. If no text is detected, reply with: "(no translatable text detected)" in the target language.
|
||||
'';
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
215
nix/hosts/clawdinator-common.nix
Normal file
215
nix/hosts/clawdinator-common.nix
Normal file
@ -0,0 +1,215 @@
|
||||
{ lib, config, ... }:
|
||||
let
|
||||
cfg = config.services.clawdinator;
|
||||
secretsPath = config.clawdinator.secretsPath;
|
||||
hostName = config.networking.hostName;
|
||||
bootstrapPrefix = config.clawdinator.bootstrapPrefix;
|
||||
discordTokenSecret = config.clawdinator.discordTokenSecret;
|
||||
repoSeedsFile = ../../clawdinator/repos.tsv;
|
||||
repoSeedLines =
|
||||
lib.filter
|
||||
(line: line != "" && !lib.hasPrefix "#" line)
|
||||
(map lib.strings.trim (lib.splitString "\n" (lib.fileContents repoSeedsFile)));
|
||||
parseRepoSeed = line:
|
||||
let
|
||||
parts = lib.splitString "\t" line;
|
||||
name = lib.elemAt parts 0;
|
||||
url = lib.elemAt parts 1;
|
||||
branch =
|
||||
if (lib.length parts) > 2 && (lib.elemAt parts 2) != ""
|
||||
then lib.elemAt parts 2
|
||||
else null;
|
||||
in
|
||||
{ inherit name url branch; };
|
||||
repoSeeds = map parseRepoSeed repoSeedLines;
|
||||
in
|
||||
{
|
||||
options.clawdinator.secretsPath = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "Path to encrypted age secrets for CLAWDINATOR.";
|
||||
};
|
||||
|
||||
options.clawdinator.bootstrapPrefix = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "Bootstrap S3 prefix for this host.";
|
||||
};
|
||||
|
||||
options.clawdinator.discordTokenSecret = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "Encrypted Discord token secret name for this host.";
|
||||
};
|
||||
|
||||
config = {
|
||||
clawdinator.secretsPath = "/var/lib/clawd/nix-secrets";
|
||||
|
||||
swapDevices = [ { device = "/swapfile"; size = 8192; } ];
|
||||
|
||||
age.identityPaths = [ "/etc/agenix/keys/clawdinator.agekey" ];
|
||||
age.secrets."clawdinator-anthropic-api-key" = {
|
||||
file = "${secretsPath}/clawdinator-anthropic-api-key.age";
|
||||
owner = "clawdinator";
|
||||
group = "clawdinator";
|
||||
};
|
||||
age.secrets."clawdinator-openai-api-key-peter-2" = {
|
||||
file = "${secretsPath}/clawdinator-openai-api-key-peter-2.age";
|
||||
owner = "clawdinator";
|
||||
group = "clawdinator";
|
||||
};
|
||||
age.secrets."${discordTokenSecret}" = {
|
||||
file = "${secretsPath}/${discordTokenSecret}.age";
|
||||
owner = "clawdinator";
|
||||
group = "clawdinator";
|
||||
};
|
||||
age.secrets."clawdinator-control-token" = {
|
||||
file = "${secretsPath}/clawdinator-control-token.age";
|
||||
owner = "clawdinator";
|
||||
group = "clawdinator";
|
||||
};
|
||||
age.secrets."clawdinator-control-aws-access-key-id" = {
|
||||
file = "${secretsPath}/clawdinator-control-aws-access-key-id.age";
|
||||
owner = "clawdinator";
|
||||
group = "clawdinator";
|
||||
};
|
||||
age.secrets."clawdinator-control-aws-secret-access-key" = {
|
||||
file = "${secretsPath}/clawdinator-control-aws-secret-access-key.age";
|
||||
owner = "clawdinator";
|
||||
group = "clawdinator";
|
||||
};
|
||||
age.secrets."clawdinator-telegram-bot-token" = {
|
||||
file = "${secretsPath}/clawdinator-telegram-bot-token.age";
|
||||
owner = "clawdinator";
|
||||
group = "clawdinator";
|
||||
};
|
||||
age.secrets."clawdinator-telegram-allow-from" = {
|
||||
file = "${secretsPath}/clawdinator-telegram-allow-from.age";
|
||||
owner = "clawdinator";
|
||||
group = "clawdinator";
|
||||
};
|
||||
|
||||
# Required for CI-driven deploys via AWS Systems Manager.
|
||||
services.amazon-ssm-agent.enable = true;
|
||||
|
||||
services.clawdinator = {
|
||||
enable = true;
|
||||
instanceName = lib.toUpper hostName;
|
||||
memoryDir = "/memory";
|
||||
repoSeedSnapshotDir = "/var/lib/clawd/repo-seeds";
|
||||
bootstrap = {
|
||||
enable = true;
|
||||
s3Bucket = "clawdinator-images-eu1-20260107165216";
|
||||
s3Prefix = bootstrapPrefix;
|
||||
region = "eu-central-1";
|
||||
secretsDir = "/var/lib/clawd/nix-secrets";
|
||||
repoSeedsDir = "/var/lib/clawd/repo-seeds";
|
||||
ageKeyPath = "/etc/agenix/keys/clawdinator.agekey";
|
||||
};
|
||||
memoryEfs = {
|
||||
enable = true;
|
||||
fileSystemId = "fs-0e7920726c2965a88";
|
||||
region = "eu-central-1";
|
||||
mountPoint = "/memory";
|
||||
};
|
||||
repoSeeds = repoSeeds;
|
||||
|
||||
config = {
|
||||
gateway = {
|
||||
mode = "local";
|
||||
bind = "loopback";
|
||||
auth = {
|
||||
token = "clawdinator-local";
|
||||
};
|
||||
};
|
||||
agents.defaults = {
|
||||
workspace = "/var/lib/clawd/workspace";
|
||||
maxConcurrent = 4;
|
||||
skipBootstrap = true;
|
||||
models = {
|
||||
"anthropic/claude-opus-4-6" = { alias = "Opus"; };
|
||||
"openai/gpt-5.2-codex" = { alias = "Codex"; };
|
||||
};
|
||||
model = {
|
||||
primary = "openai/gpt-5.2-codex";
|
||||
fallbacks = [ "anthropic/claude-opus-4-6" ];
|
||||
};
|
||||
|
||||
# Default thinking level for reasoning-capable models (GPT-5.2/Codex).
|
||||
thinkingDefault = "high";
|
||||
};
|
||||
agents.list = [
|
||||
{
|
||||
id = "main";
|
||||
default = true;
|
||||
identity.name = cfg.instanceName;
|
||||
}
|
||||
];
|
||||
logging = {
|
||||
level = "info";
|
||||
file = "/var/lib/clawd/logs/openclaw.log";
|
||||
};
|
||||
session.sendPolicy = {
|
||||
default = "allow";
|
||||
rules = [ ];
|
||||
};
|
||||
messages.groupChat = {
|
||||
mentionPatterns = [];
|
||||
};
|
||||
messages.queue = {
|
||||
mode = "interrupt";
|
||||
byChannel = {
|
||||
discord = "interrupt";
|
||||
telegram = "interrupt";
|
||||
whatsapp = "interrupt";
|
||||
webchat = "queue";
|
||||
};
|
||||
};
|
||||
plugins = {
|
||||
slots.memory = "none";
|
||||
entries.discord.enabled = true;
|
||||
entries.telegram.enabled = true;
|
||||
};
|
||||
skills.allowBundled = [ "github" "clawdhub" "coding-agent" ];
|
||||
cron = {
|
||||
enabled = true;
|
||||
store = "/var/lib/clawd/cron-jobs.json";
|
||||
};
|
||||
channels = {
|
||||
discord = {
|
||||
enabled = true;
|
||||
dm.enabled = false;
|
||||
guilds = {
|
||||
"1456350064065904867" = {
|
||||
requireMention = true;
|
||||
channels = {
|
||||
# #clawdinators-test (mention-only)
|
||||
"1458426982579830908" = {
|
||||
allow = true;
|
||||
requireMention = true;
|
||||
users = [ "*" ];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
telegram = {
|
||||
enabled = true;
|
||||
dmPolicy = "allowlist";
|
||||
allowFrom = [ "\${CLAWDINATOR_TELEGRAM_ALLOW_FROM}" ];
|
||||
groupPolicy = "disabled";
|
||||
tokenFile = "/run/agenix/clawdinator-telegram-bot-token";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
anthropicApiKeyFile = "/run/agenix/clawdinator-anthropic-api-key";
|
||||
openaiApiKeyFile = "/run/agenix/clawdinator-openai-api-key-peter-2";
|
||||
discordTokenFile = "/run/agenix/${discordTokenSecret}";
|
||||
telegramAllowFromFile = "/run/agenix/clawdinator-telegram-allow-from";
|
||||
|
||||
# Hosts do not self-mutate. Replacements and switches are explicit operator
|
||||
# actions, which avoids host-local `nix flake update` drift.
|
||||
selfUpdate.enable = false;
|
||||
|
||||
cronJobsFile = ../../clawdinator/cron-jobs.json;
|
||||
};
|
||||
};
|
||||
}
|
||||
8
nix/instances.json
Normal file
8
nix/instances.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"clawdinator-babelfish": {
|
||||
"host": "clawdinator-babelfish",
|
||||
"instanceType": "t3.small",
|
||||
"bootstrapPrefix": "bootstrap/clawdinator-babelfish",
|
||||
"discordTokenSecret": "clawdinator-discord-token-babelfish"
|
||||
}
|
||||
}
|
||||
@ -2,10 +2,14 @@
|
||||
let
|
||||
cfg = config.services.clawdinator;
|
||||
|
||||
configSource =
|
||||
if cfg.configFile != null
|
||||
then cfg.configFile
|
||||
else pkgs.writeText "clawdbot.json" (builtins.toJSON cfg.config);
|
||||
# Deep-merge OpenClaw config attrsets across modules/hosts.
|
||||
# Prevents per-host overrides like `config.channels.telegram.enabled = false` from clobbering sibling keys.
|
||||
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);
|
||||
};
|
||||
|
||||
updateScript = pkgs.writeShellScript "clawdinator-self-update" ''
|
||||
set -euo pipefail
|
||||
@ -23,11 +27,16 @@ let
|
||||
githubTokenScript = pkgs.writeShellScript "clawdinator-github-app-token" ''
|
||||
set -euo pipefail
|
||||
|
||||
export PATH="${lib.makeBinPath [ pkgs.openssl pkgs.curl pkgs.jq pkgs.gh pkgs.coreutils ]}:$PATH"
|
||||
|
||||
token_env="${cfg.githubApp.tokenEnvFile}"
|
||||
token_dir="$(dirname "$token_env")"
|
||||
|
||||
mkdir -p "$token_dir"
|
||||
chmod 0700 "$token_dir"
|
||||
chmod 0750 "$token_dir"
|
||||
if [ "$(id -u)" -eq 0 ]; then
|
||||
chown ${cfg.user}:${cfg.group} "$token_dir"
|
||||
fi
|
||||
|
||||
now="$(date +%s)"
|
||||
iat="$((now - 60))"
|
||||
@ -58,19 +67,44 @@ let
|
||||
exit 1
|
||||
fi
|
||||
|
||||
umask 077
|
||||
umask 027
|
||||
printf 'GITHUB_APP_TOKEN=%s\nGITHUB_TOKEN=%s\nGH_TOKEN=%s\n' "$token" "$token" "$token" > "$token_env"
|
||||
if [ "$(id -u)" -eq 0 ]; then
|
||||
chown ${cfg.user}:${cfg.group} "$token_env"
|
||||
fi
|
||||
chmod 0640 "$token_env"
|
||||
|
||||
gh_config_dir="${ghConfigDir}"
|
||||
mkdir -p "$gh_config_dir"
|
||||
chmod 0750 "$gh_config_dir"
|
||||
if [ "$(id -u)" -eq 0 ]; then
|
||||
chown ${cfg.user}:${cfg.group} "$gh_config_dir"
|
||||
fi
|
||||
printf '%s' "$token" | GH_CONFIG_DIR="$gh_config_dir" gh auth login --hostname github.com --with-token
|
||||
if [ "$(id -u)" -eq 0 ]; then
|
||||
chown -R ${cfg.user}:${cfg.group} "$gh_config_dir"
|
||||
fi
|
||||
chmod 0640 "$gh_config_dir/hosts.yml"
|
||||
if [ -f "$gh_config_dir/config.yml" ]; then
|
||||
chmod 0640 "$gh_config_dir/config.yml"
|
||||
fi
|
||||
'';
|
||||
|
||||
defaultPackage =
|
||||
if pkgs ? clawdbot-gateway
|
||||
then pkgs.clawdbot-gateway
|
||||
else pkgs.clawdbot;
|
||||
if pkgs ? openclaw-gateway
|
||||
then pkgs.openclaw-gateway
|
||||
else pkgs.openclaw;
|
||||
|
||||
configPath = "/etc/clawd/clawdbot.json";
|
||||
gatewayBin =
|
||||
if builtins.pathExists "${cfg.package}/bin/openclaw"
|
||||
then "${cfg.package}/bin/openclaw"
|
||||
else "${cfg.package}/bin/moltbot";
|
||||
|
||||
configPath = "/etc/clawd/openclaw.json";
|
||||
workspaceDir = "${cfg.stateDir}/workspace";
|
||||
repoSeedBaseDir = cfg.repoSeedBaseDir;
|
||||
logDir = "${cfg.stateDir}/logs";
|
||||
ghConfigDir = "${cfg.stateDir}/gh";
|
||||
repoSeedsFile = pkgs.writeText "clawdinator-repos.tsv"
|
||||
(lib.concatMapStringsSep "\n"
|
||||
(repo:
|
||||
@ -80,12 +114,24 @@ let
|
||||
"${repo.name}\t${repo.url}\t${branch}")
|
||||
cfg.repoSeeds);
|
||||
toolchain = import ../tools/clawdinator-tools.nix { inherit pkgs; };
|
||||
|
||||
flakeLock = builtins.fromJSON (builtins.readFile ../../flake.lock);
|
||||
nixOpenclawLocked = flakeLock.nodes."nix-openclaw".locked;
|
||||
nixpkgsLocked = flakeLock.nodes."nixpkgs".locked;
|
||||
|
||||
openclawPinnedRev =
|
||||
if cfg.package ? passthru && cfg.package.passthru ? pinnedRev then
|
||||
cfg.package.passthru.pinnedRev
|
||||
else if pkgs ? openclaw-gateway && pkgs.openclaw-gateway ? passthru && pkgs.openclaw-gateway.passthru ? pinnedRev then
|
||||
pkgs.openclaw-gateway.passthru.pinnedRev
|
||||
else
|
||||
null;
|
||||
toolchainMd = lib.concatMapStringsSep "\n"
|
||||
(tool: "- **${tool.name}** — ${tool.description}")
|
||||
toolchain.docs;
|
||||
|
||||
tokenWrapper =
|
||||
if cfg.anthropicApiKeyFile != null || cfg.discordTokenFile != null || cfg.githubPatFile != null then
|
||||
if cfg.anthropicApiKeyFile != null || cfg.discordTokenFile != null || cfg.githubPatFile != null || cfg.openaiApiKeyFile != null || cfg.telegramAllowFromFile != null then
|
||||
pkgs.writeShellScriptBin "clawdinator-gateway" ''
|
||||
set -euo pipefail
|
||||
|
||||
@ -113,15 +159,17 @@ let
|
||||
${lib.optionalString (cfg.anthropicApiKeyFile != null) "read_token ANTHROPIC_API_KEY \"${cfg.anthropicApiKeyFile}\""}
|
||||
${lib.optionalString (cfg.discordTokenFile != null) "read_token DISCORD_BOT_TOKEN \"${cfg.discordTokenFile}\""}
|
||||
${lib.optionalString (cfg.githubPatFile != null) "read_token \"GITHUB_TOKEN GH_TOKEN\" \"${cfg.githubPatFile}\""}
|
||||
${lib.optionalString (cfg.openaiApiKeyFile != null) "read_token \"OPENAI_API_KEY OPEN_AI_APIKEY\" \"${cfg.openaiApiKeyFile}\""}
|
||||
${lib.optionalString (cfg.telegramAllowFromFile != null) "read_token CLAWDINATOR_TELEGRAM_ALLOW_FROM \"${cfg.telegramAllowFromFile}\""}
|
||||
|
||||
exec "${cfg.package}/bin/clawdbot" gateway --port ${toString cfg.gatewayPort}
|
||||
exec "${gatewayBin}" gateway --port ${toString cfg.gatewayPort}
|
||||
''
|
||||
else
|
||||
null;
|
||||
in
|
||||
{
|
||||
options.services.clawdinator = with lib; {
|
||||
enable = mkEnableOption "CLAWDINATOR (Clawdbot gateway on NixOS)";
|
||||
enable = mkEnableOption "CLAWDINATOR (Clawbot gateway on NixOS)";
|
||||
|
||||
instanceName = mkOption {
|
||||
type = types.str;
|
||||
@ -144,7 +192,7 @@ in
|
||||
package = mkOption {
|
||||
type = types.package;
|
||||
default = defaultPackage;
|
||||
description = "Clawdbot gateway package (from nix-clawdbot overlay).";
|
||||
description = "Clawbot gateway package (from nix-openclaw overlay).";
|
||||
};
|
||||
|
||||
stateDir = mkOption {
|
||||
@ -206,6 +254,63 @@ in
|
||||
description = "Repos to seed into repoSeedBaseDir on startup.";
|
||||
};
|
||||
|
||||
repoSeedSnapshotDir = mkOption {
|
||||
type = types.nullOr types.str;
|
||||
default = null;
|
||||
description = "Optional path to a preseeded repo snapshot (directory of repos). When set, no network cloning happens at boot.";
|
||||
};
|
||||
|
||||
bootstrap = {
|
||||
enable = mkEnableOption "Bootstrap secrets + repo seeds from S3";
|
||||
|
||||
s3Bucket = mkOption {
|
||||
type = types.str;
|
||||
description = "S3 bucket holding bootstrap artifacts.";
|
||||
};
|
||||
|
||||
s3Prefix = mkOption {
|
||||
type = types.str;
|
||||
default = "bootstrap/${cfg.instanceName}";
|
||||
description = "S3 prefix for bootstrap artifacts (relative to bucket).";
|
||||
};
|
||||
|
||||
region = mkOption {
|
||||
type = types.str;
|
||||
default = "eu-central-1";
|
||||
description = "AWS region for S3 bootstrap bucket.";
|
||||
};
|
||||
|
||||
secretsArchive = mkOption {
|
||||
type = types.str;
|
||||
default = "secrets.tar.zst";
|
||||
description = "Secrets archive name inside the bootstrap prefix.";
|
||||
};
|
||||
|
||||
repoSeedsArchive = mkOption {
|
||||
type = types.str;
|
||||
default = "repo-seeds.tar.zst";
|
||||
description = "Repo seeds archive name inside the bootstrap prefix.";
|
||||
};
|
||||
|
||||
ageKeyPath = mkOption {
|
||||
type = types.str;
|
||||
default = "/etc/agenix/keys/clawdinator.agekey";
|
||||
description = "Destination path for the agenix identity key.";
|
||||
};
|
||||
|
||||
secretsDir = mkOption {
|
||||
type = types.str;
|
||||
default = "/var/lib/clawd/nix-secrets";
|
||||
description = "Destination directory for encrypted age secrets.";
|
||||
};
|
||||
|
||||
repoSeedsDir = mkOption {
|
||||
type = types.str;
|
||||
default = "/var/lib/clawd/repo-seeds";
|
||||
description = "Destination directory for repo seed snapshots.";
|
||||
};
|
||||
};
|
||||
|
||||
workspaceTemplateDir = mkOption {
|
||||
type = types.path;
|
||||
default = ../../clawdinator/workspace;
|
||||
@ -215,19 +320,25 @@ in
|
||||
gatewayPort = mkOption {
|
||||
type = types.port;
|
||||
default = 18789;
|
||||
description = "Gateway port for Clawdbot.";
|
||||
description = "Gateway port for Moltbot.";
|
||||
};
|
||||
|
||||
config = mkOption {
|
||||
type = types.attrs;
|
||||
type = deepConfigType;
|
||||
default = {};
|
||||
description = "Raw Clawdbot config JSON (merged into clawdbot.json).";
|
||||
description = "OpenClaw config JSON (attrset), deep-merged across definitions.";
|
||||
};
|
||||
|
||||
configFile = mkOption {
|
||||
type = types.nullOr types.path;
|
||||
default = null;
|
||||
description = "Optional path to a clawdbot.json file. Overrides config attr.";
|
||||
description = "Optional path to an openclaw.json config file. Overrides config attr.";
|
||||
};
|
||||
|
||||
cronJobsFile = mkOption {
|
||||
type = types.nullOr types.path;
|
||||
default = null;
|
||||
description = "Optional path to a cron jobs JSON file (deployed to /etc/clawd/cron-jobs.json).";
|
||||
};
|
||||
|
||||
anthropicApiKeyFile = mkOption {
|
||||
@ -236,12 +347,24 @@ in
|
||||
description = "Path to file containing Anthropic API key (plain text).";
|
||||
};
|
||||
|
||||
openaiApiKeyFile = mkOption {
|
||||
type = types.nullOr types.str;
|
||||
default = null;
|
||||
description = "Path to file containing OpenAI API key (plain text).";
|
||||
};
|
||||
|
||||
discordTokenFile = mkOption {
|
||||
type = types.nullOr types.str;
|
||||
default = null;
|
||||
description = "Path to file containing Discord bot token (plain text).";
|
||||
};
|
||||
|
||||
telegramAllowFromFile = mkOption {
|
||||
type = types.nullOr types.str;
|
||||
default = null;
|
||||
description = "Path to file containing Telegram allowFrom entry (plain text).";
|
||||
};
|
||||
|
||||
githubPatFile = mkOption {
|
||||
type = types.nullOr types.str;
|
||||
default = null;
|
||||
@ -330,21 +453,69 @@ in
|
||||
|
||||
org = mkOption {
|
||||
type = types.str;
|
||||
default = "clawdbot";
|
||||
default = "openclaw";
|
||||
description = "GitHub org to sync.";
|
||||
};
|
||||
};
|
||||
|
||||
publicS3 = {
|
||||
enable = mkEnableOption "Publish a public-safe subtree from /memory to a public S3 bucket";
|
||||
|
||||
schedule = mkOption {
|
||||
type = types.str;
|
||||
default = "*:0/10";
|
||||
description = "systemd OnCalendar schedule for public S3 publish (default: every 10 min).";
|
||||
};
|
||||
|
||||
region = mkOption {
|
||||
type = types.str;
|
||||
default = "eu-central-1";
|
||||
description = "AWS region for the public S3 bucket.";
|
||||
};
|
||||
|
||||
bucket = mkOption {
|
||||
type = types.str;
|
||||
default = "";
|
||||
description = "Destination S3 bucket name (public-read/list bucket).";
|
||||
};
|
||||
|
||||
# Keep this narrow and explicit: publish only the public-safe subtree.
|
||||
sourceDir = mkOption {
|
||||
type = types.str;
|
||||
default = "${cfg.memoryDir}/pr-intent";
|
||||
description = "Local directory tree to publish to S3 (should contain only public-safe files).";
|
||||
};
|
||||
|
||||
destPrefix = mkOption {
|
||||
type = types.str;
|
||||
default = "";
|
||||
description = "Optional S3 key prefix under the bucket (leave empty to publish at bucket root).";
|
||||
};
|
||||
|
||||
stateDir = mkOption {
|
||||
type = types.str;
|
||||
default = "${cfg.stateDir}/public-s3";
|
||||
description = "State directory for public S3 publishing (stamp + lock).";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
nix.settings.substituters = lib.mkAfter [
|
||||
"https://cache.garnix.io"
|
||||
];
|
||||
nix.settings.trusted-public-keys = lib.mkAfter [
|
||||
"cache.garnix.io:CTFPyKSLcx5RMJKfLo5EEPUObbA78b0YQ2DTCJXqr9g="
|
||||
];
|
||||
|
||||
assertions = [
|
||||
{
|
||||
assertion = (pkgs ? clawdbot-gateway) || (pkgs ? clawdbot);
|
||||
message = "services.clawdinator requires nix-clawdbot overlay (pkgs.clawdbot-gateway).";
|
||||
assertion = (pkgs ? openclaw-gateway) || (pkgs ? openclaw);
|
||||
message = "services.clawdinator requires nix-openclaw overlay (pkgs.openclaw-gateway).";
|
||||
}
|
||||
{
|
||||
assertion = cfg.githubApp.enable || cfg.githubPatFile != null;
|
||||
message = "services.clawdinator requires a GitHub token (enable githubApp or set githubPatFile).";
|
||||
assertion = (!cfg.githubSync.enable) || cfg.githubApp.enable || cfg.githubPatFile != null;
|
||||
message = "services.clawdinator.githubSync requires GitHub auth (enable githubApp or set githubPatFile).";
|
||||
}
|
||||
{
|
||||
assertion = (!cfg.githubApp.enable) || (cfg.githubApp.appId != "" && cfg.githubApp.installationId != "");
|
||||
@ -354,6 +525,10 @@ in
|
||||
assertion = (!cfg.memoryEfs.enable) || (cfg.memoryEfs.fileSystemId != "");
|
||||
message = "services.clawdinator.memoryEfs requires fileSystemId.";
|
||||
}
|
||||
{
|
||||
assertion = (!cfg.publicS3.enable) || (cfg.publicS3.bucket != "");
|
||||
message = "services.clawdinator.publicS3 requires bucket.";
|
||||
}
|
||||
];
|
||||
|
||||
users.groups.${cfg.group} = {};
|
||||
@ -362,13 +537,36 @@ in
|
||||
group = cfg.group;
|
||||
home = cfg.stateDir;
|
||||
createHome = true;
|
||||
shell = pkgs.bashInteractive;
|
||||
};
|
||||
|
||||
programs.git = {
|
||||
enable = true;
|
||||
config = {
|
||||
user = {
|
||||
name = "CLAWDINATOR Bot";
|
||||
email = "clawdinator[bot]@users.noreply.github.com";
|
||||
};
|
||||
safe = {
|
||||
directory = [ "/var/lib/clawd/repos/clawdinators" ];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
environment.systemPackages =
|
||||
[ cfg.package ]
|
||||
++ toolchain.packages;
|
||||
++ toolchain.packages
|
||||
++ [
|
||||
(pkgs.writeShellScriptBin "memory-read" ''exec /etc/clawdinator/bin/memory-read "$@"'')
|
||||
(pkgs.writeShellScriptBin "memory-write" ''exec /etc/clawdinator/bin/memory-write "$@"'')
|
||||
(pkgs.writeShellScriptBin "memory-edit" ''exec /etc/clawdinator/bin/memory-edit "$@"'')
|
||||
(pkgs.writeShellScriptBin "clawdinator-gh-refresh" ''exec ${githubTokenScript}'')
|
||||
];
|
||||
|
||||
environment.etc."clawd/clawdbot.json".source = configSource;
|
||||
environment.etc."clawd/cron-jobs.json" = lib.mkIf (cfg.cronJobsFile != null) {
|
||||
source = cfg.cronJobsFile;
|
||||
mode = "0644";
|
||||
};
|
||||
environment.etc."clawdinator/bin/memory-read" = {
|
||||
source = ../../scripts/memory-read.sh;
|
||||
mode = "0755";
|
||||
@ -392,8 +590,33 @@ in
|
||||
- **memory-read** — shared-lock read from `/memory`.
|
||||
- **memory-write** — exclusive-lock write to `/memory`.
|
||||
- **memory-edit** — exclusive-lock in-place edit for `/memory`.
|
||||
- **clawdinator-gh-refresh** — mint GitHub App token + refresh GH auth (no sudo).
|
||||
'';
|
||||
};
|
||||
|
||||
environment.etc."clawdinator/build-info.json" = {
|
||||
mode = "0644";
|
||||
text = builtins.toJSON {
|
||||
clawdinators = {
|
||||
rev = config.system.configurationRevision;
|
||||
};
|
||||
nixOpenclaw = {
|
||||
rev = nixOpenclawLocked.rev;
|
||||
lastModified = nixOpenclawLocked.lastModified;
|
||||
};
|
||||
nixpkgs = {
|
||||
rev = nixpkgsLocked.rev;
|
||||
lastModified = nixpkgsLocked.lastModified;
|
||||
};
|
||||
openclaw = {
|
||||
rev = openclawPinnedRev;
|
||||
};
|
||||
};
|
||||
};
|
||||
environment.etc."clawdinator/pi-settings.json" = {
|
||||
mode = "0644";
|
||||
source = ../../clawdinator/pi-settings.json;
|
||||
};
|
||||
environment.etc."stunnel/efs.conf" = lib.mkIf cfg.memoryEfs.enable {
|
||||
mode = "0644";
|
||||
text = ''
|
||||
@ -409,17 +632,65 @@ in
|
||||
'';
|
||||
};
|
||||
|
||||
systemd.tmpfiles.rules = [
|
||||
"d ${cfg.stateDir} 0750 ${cfg.user} ${cfg.group} - -"
|
||||
"d ${workspaceDir} 0750 ${cfg.user} ${cfg.group} - -"
|
||||
"d ${logDir} 0750 ${cfg.user} ${cfg.group} - -"
|
||||
"d ${cfg.memoryDir} 0750 ${cfg.user} ${cfg.group} - -"
|
||||
"d ${repoSeedBaseDir} 0750 ${cfg.user} ${cfg.group} - -"
|
||||
"d /usr/local/bin 0755 root root - -"
|
||||
"L+ /usr/local/bin/memory-read - - - - /etc/clawdinator/bin/memory-read"
|
||||
"L+ /usr/local/bin/memory-write - - - - /etc/clawdinator/bin/memory-write"
|
||||
"L+ /usr/local/bin/memory-edit - - - - /etc/clawdinator/bin/memory-edit"
|
||||
];
|
||||
system.activationScripts.agenixInstall.text = lib.mkIf cfg.bootstrap.enable (
|
||||
let
|
||||
secrets = lib.attrValues config.age.secrets;
|
||||
secretFiles = lib.concatMapStringsSep " " (secret: "\"${secret.file}\"") secrets;
|
||||
chownLines = lib.concatMapStringsSep "\n"
|
||||
(secret:
|
||||
let
|
||||
path = secret.path;
|
||||
owner = if secret.owner == null then "root" else secret.owner;
|
||||
group = if secret.group == null then "root" else secret.group;
|
||||
in
|
||||
lib.optionalString (path != null) ''
|
||||
if [ -e "${path}" ]; then
|
||||
chown ${owner}:${group} "${path}"
|
||||
fi
|
||||
'')
|
||||
secrets;
|
||||
in
|
||||
lib.mkMerge [
|
||||
(lib.mkBefore ''
|
||||
found=0
|
||||
for file in ${secretFiles}; do
|
||||
if [ -f "$file" ]; then
|
||||
found=1
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ "$found" -eq 0 ]; then
|
||||
echo "[agenix] no encrypted secrets present; skipping install"
|
||||
else
|
||||
'')
|
||||
(lib.mkAfter ''
|
||||
fi
|
||||
${chownLines}
|
||||
'')
|
||||
]
|
||||
);
|
||||
|
||||
systemd.tmpfiles.rules =
|
||||
[
|
||||
"d ${cfg.stateDir} 0750 ${cfg.user} ${cfg.group} - -"
|
||||
"d ${cfg.stateDir}/.pi 0750 ${cfg.user} ${cfg.group} - -"
|
||||
"d ${cfg.stateDir}/.pi/agent 0750 ${cfg.user} ${cfg.group} - -"
|
||||
"L+ ${cfg.stateDir}/.pi/agent/settings.json - - - - /etc/clawdinator/pi-settings.json"
|
||||
"d ${workspaceDir} 0750 ${cfg.user} ${cfg.group} - -"
|
||||
"d ${logDir} 0750 ${cfg.user} ${cfg.group} - -"
|
||||
"d ${ghConfigDir} 0750 ${cfg.user} ${cfg.group} - -"
|
||||
"d /run/clawd 0750 ${cfg.user} ${cfg.group} - -"
|
||||
"z /run/clawd 0750 ${cfg.user} ${cfg.group} - -"
|
||||
"f /run/clawd/github-app.env 0640 ${cfg.user} ${cfg.group} - -"
|
||||
"z /run/clawd/github-app.env 0640 ${cfg.user} ${cfg.group} - -"
|
||||
"d ${cfg.memoryDir} 0750 ${cfg.user} ${cfg.group} - -"
|
||||
"d ${repoSeedBaseDir} 0750 ${cfg.user} ${cfg.group} - -"
|
||||
"d /usr/local/bin 0755 root root - -"
|
||||
"L+ /usr/local/bin/memory-read - - - - /etc/clawdinator/bin/memory-read"
|
||||
"L+ /usr/local/bin/memory-write - - - - /etc/clawdinator/bin/memory-write"
|
||||
"L+ /usr/local/bin/memory-edit - - - - /etc/clawdinator/bin/memory-edit"
|
||||
]
|
||||
++ lib.optional cfg.publicS3.enable "d ${cfg.publicS3.stateDir} 0750 ${cfg.user} ${cfg.group} - -";
|
||||
|
||||
fileSystems = lib.mkIf cfg.memoryEfs.enable {
|
||||
"${cfg.memoryEfs.mountPoint}" = {
|
||||
@ -439,42 +710,121 @@ in
|
||||
};
|
||||
};
|
||||
|
||||
systemd.services.clawdinator = {
|
||||
description = "CLAWDINATOR (Clawdbot gateway)";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "network.target" ] ++ lib.optional cfg.githubApp.enable "clawdinator-github-app-token.service";
|
||||
wants = lib.optional cfg.githubApp.enable "clawdinator-github-app-token.service";
|
||||
# Gateway service is implemented upstream in nix-openclaw.
|
||||
services.openclaw-gateway = {
|
||||
enable = true;
|
||||
unitName = "clawdinator";
|
||||
package = cfg.package;
|
||||
port = cfg.gatewayPort;
|
||||
user = cfg.user;
|
||||
group = cfg.group;
|
||||
createUser = false;
|
||||
stateDir = cfg.stateDir;
|
||||
workingDirectory = cfg.stateDir;
|
||||
configPath = configPath;
|
||||
config = cfg.config;
|
||||
configFile = cfg.configFile;
|
||||
logPath = "${logDir}/gateway.log";
|
||||
|
||||
# Additional env beyond OPENCLAW_* and CLAWDBOT_* defaults.
|
||||
environment = {
|
||||
CLAWDBOT_CONFIG_PATH = configPath;
|
||||
CLAWDBOT_STATE_DIR = cfg.stateDir;
|
||||
CLAWDBOT_WORKSPACE_DIR = workspaceDir;
|
||||
CLAWDBOT_LOG_DIR = logDir;
|
||||
GH_CONFIG_DIR = ghConfigDir;
|
||||
|
||||
# Backward-compatible env names used by some builds.
|
||||
CLAWDIS_CONFIG_PATH = configPath;
|
||||
CLAWDIS_STATE_DIR = cfg.stateDir;
|
||||
};
|
||||
|
||||
path = [ pkgs.coreutils pkgs.git ];
|
||||
serviceConfig = {
|
||||
User = cfg.user;
|
||||
Group = cfg.group;
|
||||
WorkingDirectory = cfg.stateDir;
|
||||
EnvironmentFile = lib.optional cfg.githubApp.enable "-${cfg.githubApp.tokenEnvFile}";
|
||||
ExecStartPre = [
|
||||
servicePath = [ pkgs.coreutils pkgs.git pkgs.rsync ] ++ toolchain.packages;
|
||||
|
||||
execStartPre =
|
||||
lib.optionals (cfg.repoSeedSnapshotDir == null) [
|
||||
"${pkgs.bash}/bin/bash ${../../scripts/seed-repos.sh} ${repoSeedsFile} ${repoSeedBaseDir}"
|
||||
]
|
||||
++ [
|
||||
"${pkgs.bash}/bin/bash ${../../scripts/seed-workspace.sh} ${cfg.workspaceTemplateDir} ${workspaceDir}"
|
||||
];
|
||||
ExecStart =
|
||||
if tokenWrapper != null
|
||||
then "${tokenWrapper}/bin/clawdinator-gateway"
|
||||
else "${cfg.package}/bin/clawdbot gateway --port ${toString cfg.gatewayPort}";
|
||||
Restart = "always";
|
||||
RestartSec = 2;
|
||||
StandardOutput = "append:${logDir}/gateway.log";
|
||||
StandardError = "append:${logDir}/gateway.log";
|
||||
|
||||
execStart =
|
||||
if tokenWrapper != null
|
||||
then "${tokenWrapper}/bin/clawdinator-gateway"
|
||||
else "${gatewayBin} gateway --port ${toString cfg.gatewayPort}";
|
||||
};
|
||||
|
||||
# Add CLAWDINATOR-specific dependencies to the upstream gateway unit.
|
||||
systemd.services.clawdinator = {
|
||||
after =
|
||||
[ "network.target" ]
|
||||
++ lib.optional cfg.bootstrap.enable "clawdinator-bootstrap.service"
|
||||
++ lib.optional cfg.bootstrap.enable "clawdinator-agenix.service"
|
||||
++ lib.optional cfg.githubApp.enable "clawdinator-github-app-token.service"
|
||||
++ lib.optional (cfg.repoSeedSnapshotDir != null) "clawdinator-repo-seed.service"
|
||||
++ lib.optional (cfg.openaiApiKeyFile != null && cfg.anthropicApiKeyFile != null) "clawdinator-pi-auth.service";
|
||||
wants =
|
||||
lib.optional cfg.bootstrap.enable "clawdinator-bootstrap.service"
|
||||
++ lib.optional cfg.bootstrap.enable "clawdinator-agenix.service"
|
||||
++ lib.optional cfg.githubApp.enable "clawdinator-github-app-token.service"
|
||||
++ lib.optional (cfg.repoSeedSnapshotDir != null) "clawdinator-repo-seed.service"
|
||||
++ lib.optional (cfg.openaiApiKeyFile != null && cfg.anthropicApiKeyFile != null) "clawdinator-pi-auth.service";
|
||||
};
|
||||
|
||||
systemd.services.clawdinator-repo-seed = lib.mkIf (cfg.repoSeedSnapshotDir != null) {
|
||||
description = "CLAWDINATOR repo seed (snapshot copy)";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
before = [ "clawdinator.service" ];
|
||||
after =
|
||||
[ "local-fs.target" ]
|
||||
++ lib.optional cfg.bootstrap.enable "clawdinator-bootstrap.service";
|
||||
requires = lib.optional cfg.bootstrap.enable "clawdinator-bootstrap.service";
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
User = "root";
|
||||
};
|
||||
path = [ pkgs.rsync pkgs.coreutils ];
|
||||
script = "${pkgs.bash}/bin/bash ${../../scripts/seed-repos-from-snapshot.sh} ${cfg.repoSeedSnapshotDir} ${repoSeedBaseDir} ${cfg.user} ${cfg.group}";
|
||||
};
|
||||
|
||||
systemd.services.clawdinator-bootstrap = lib.mkIf cfg.bootstrap.enable {
|
||||
description = "CLAWDINATOR bootstrap (S3 secrets + repo seeds)";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after =
|
||||
[ "network-online.target" ]
|
||||
++ lib.optional (config.systemd.services ? fetch-ec2-metadata) "fetch-ec2-metadata.service";
|
||||
wants =
|
||||
[ "network-online.target" ]
|
||||
++ lib.optional (config.systemd.services ? fetch-ec2-metadata) "fetch-ec2-metadata.service";
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
};
|
||||
environment = {
|
||||
AWS_REGION = cfg.bootstrap.region;
|
||||
AWS_DEFAULT_REGION = cfg.bootstrap.region;
|
||||
};
|
||||
path = [ pkgs.awscli2 pkgs.coreutils pkgs.gnutar pkgs.zstd ];
|
||||
script = "${pkgs.bash}/bin/bash ${../../scripts/bootstrap-runtime.sh} ${cfg.bootstrap.s3Bucket} ${cfg.bootstrap.s3Prefix} ${cfg.bootstrap.secretsDir} ${cfg.bootstrap.repoSeedsDir} ${cfg.bootstrap.ageKeyPath} ${cfg.bootstrap.secretsArchive} ${cfg.bootstrap.repoSeedsArchive}";
|
||||
};
|
||||
|
||||
systemd.services.clawdinator-agenix = lib.mkIf cfg.bootstrap.enable {
|
||||
description = "CLAWDINATOR agenix (post-bootstrap activation)";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "clawdinator-bootstrap.service" ];
|
||||
wants = [ "clawdinator-bootstrap.service" ];
|
||||
unitConfig = {
|
||||
ConditionPathExists = "!/run/agenix";
|
||||
};
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
ExecStart = "/run/current-system/bin/switch-to-configuration switch";
|
||||
};
|
||||
};
|
||||
|
||||
systemd.services.agenix = lib.mkIf cfg.bootstrap.enable {
|
||||
requires = [ "clawdinator-bootstrap.service" ];
|
||||
after = [ "clawdinator-bootstrap.service" ];
|
||||
};
|
||||
|
||||
systemd.services.clawdinator-efs-stunnel = lib.mkIf cfg.memoryEfs.enable {
|
||||
@ -495,7 +845,7 @@ in
|
||||
wants = [ "remote-fs.target" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
ExecStart = "${pkgs.bash}/bin/bash ${../../scripts/init-memory.sh} ${cfg.memoryEfs.mountPoint}";
|
||||
ExecStart = "${pkgs.bash}/bin/bash ${../../scripts/init-memory.sh} ${cfg.memoryEfs.mountPoint} ${cfg.user} ${cfg.group}";
|
||||
};
|
||||
};
|
||||
|
||||
@ -525,12 +875,33 @@ in
|
||||
wants = [ "network-online.target" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
User = "root";
|
||||
User = cfg.user;
|
||||
Group = cfg.group;
|
||||
};
|
||||
path = [ pkgs.openssl pkgs.curl pkgs.jq pkgs.coreutils ];
|
||||
path = [ pkgs.openssl pkgs.curl pkgs.jq pkgs.coreutils pkgs.gh ];
|
||||
script = "${githubTokenScript}";
|
||||
};
|
||||
|
||||
systemd.services.clawdinator-pi-auth = lib.mkIf (cfg.openaiApiKeyFile != null && cfg.anthropicApiKeyFile != null) {
|
||||
description = "CLAWDINATOR Pi auth.json seed";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "clawdinator-agenix.service" ];
|
||||
wants = [ "clawdinator-agenix.service" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
User = cfg.user;
|
||||
Group = cfg.group;
|
||||
ExecStart =
|
||||
let
|
||||
outputPath = "${cfg.stateDir}/.pi/agent/auth.json";
|
||||
openaiKey = cfg.openaiApiKeyFile;
|
||||
anthropicKey = cfg.anthropicApiKeyFile;
|
||||
in
|
||||
"${pkgs.bash}/bin/bash ${../../scripts/pi-auth.sh} ${lib.escapeShellArg outputPath} ${lib.escapeShellArg openaiKey} ${lib.escapeShellArg anthropicKey}";
|
||||
};
|
||||
path = [ pkgs.coreutils pkgs.jq ];
|
||||
};
|
||||
|
||||
systemd.timers.clawdinator-github-app-token = lib.mkIf cfg.githubApp.enable {
|
||||
wantedBy = [ "timers.target" ];
|
||||
timerConfig = {
|
||||
@ -541,15 +912,21 @@ in
|
||||
|
||||
systemd.services.clawdinator-github-sync = lib.mkIf cfg.githubSync.enable {
|
||||
description = "CLAWDINATOR GitHub org sync (PRs/issues to memory)";
|
||||
after = [ "network-online.target" ] ++ lib.optional cfg.githubApp.enable "clawdinator-github-app-token.service";
|
||||
wants = [ "network-online.target" ];
|
||||
after =
|
||||
[ "network-online.target" ]
|
||||
++ lib.optional cfg.githubApp.enable "clawdinator-github-app-token.service"
|
||||
++ lib.optional cfg.memoryEfs.enable "remote-fs.target"
|
||||
++ lib.optional cfg.memoryEfs.enable "clawdinator-memory-init.service";
|
||||
wants =
|
||||
[ "network-online.target" ]
|
||||
++ lib.optional cfg.memoryEfs.enable "remote-fs.target";
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
User = cfg.user;
|
||||
Group = cfg.group;
|
||||
EnvironmentFile = lib.optional cfg.githubApp.enable "-${cfg.githubApp.tokenEnvFile}";
|
||||
};
|
||||
path = [ pkgs.gh pkgs.jq pkgs.coreutils pkgs.gnused ];
|
||||
path = [ pkgs.bash pkgs.gh pkgs.jq pkgs.coreutils pkgs.gnused ];
|
||||
environment = {
|
||||
MEMORY_DIR = cfg.memoryDir;
|
||||
ORG = cfg.githubSync.org;
|
||||
@ -566,5 +943,39 @@ in
|
||||
Persistent = true;
|
||||
};
|
||||
};
|
||||
|
||||
systemd.services.clawdinator-public-s3-publish = lib.mkIf cfg.publicS3.enable {
|
||||
description = "CLAWDINATOR public S3 publish (mirror public-safe memory subtree)";
|
||||
after =
|
||||
[ "network-online.target" ]
|
||||
++ lib.optional cfg.memoryEfs.enable "remote-fs.target"
|
||||
++ lib.optional cfg.memoryEfs.enable "clawdinator-memory-init.service";
|
||||
wants = [ "network-online.target" ] ++ lib.optional cfg.memoryEfs.enable "remote-fs.target";
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
User = cfg.user;
|
||||
Group = cfg.group;
|
||||
};
|
||||
environment = {
|
||||
AWS_REGION = cfg.publicS3.region;
|
||||
AWS_DEFAULT_REGION = cfg.publicS3.region;
|
||||
};
|
||||
path = [ pkgs.bash pkgs.awscli2 pkgs.coreutils pkgs.findutils pkgs.util-linux ];
|
||||
script = ''
|
||||
exec ${pkgs.bash}/bin/bash ${../../scripts/sync-public-s3-tree.sh} \
|
||||
${lib.escapeShellArg cfg.publicS3.sourceDir} \
|
||||
${lib.escapeShellArg cfg.publicS3.bucket} \
|
||||
${lib.escapeShellArg cfg.publicS3.destPrefix} \
|
||||
${lib.escapeShellArg cfg.publicS3.stateDir}
|
||||
'';
|
||||
};
|
||||
|
||||
systemd.timers.clawdinator-public-s3-publish = lib.mkIf cfg.publicS3.enable {
|
||||
wantedBy = [ "timers.target" ];
|
||||
timerConfig = {
|
||||
OnCalendar = cfg.publicS3.schedule;
|
||||
Persistent = true;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@ -1,6 +1,12 @@
|
||||
{ pkgs }:
|
||||
let
|
||||
piCodingAgent = pkgs.callPackage ./pi-coding-agent.nix {};
|
||||
in
|
||||
{
|
||||
packages = [
|
||||
(pkgs.writeShellScriptBin "clawdinator-version" ''
|
||||
exec ${pkgs.bash}/bin/bash ${../../scripts/clawdinator-version.sh} "$@"
|
||||
'')
|
||||
pkgs.bash
|
||||
pkgs.gh
|
||||
pkgs.git
|
||||
@ -11,25 +17,32 @@
|
||||
pkgs.ripgrep
|
||||
pkgs.nodejs_22
|
||||
pkgs.pnpm_10
|
||||
piCodingAgent
|
||||
pkgs.util-linux
|
||||
pkgs.nfs-utils
|
||||
pkgs.stunnel
|
||||
pkgs.awscli2
|
||||
pkgs.zstd
|
||||
];
|
||||
|
||||
docs = [
|
||||
{ name = "clawdinator-version"; description = "Print deployed versions (clawdinators + nix-openclaw + nixpkgs + openclaw) and approximate ages."; }
|
||||
{ name = "bash"; description = "Shell runtime for CLAWDINATOR scripts."; }
|
||||
{ name = "gh"; description = "GitHub CLI for repo + PR inventory."; }
|
||||
{ name = "clawdbot-gateway"; description = "CLAWDINATOR runtime (Clawdbot gateway)."; }
|
||||
{ name = "openclaw-gateway"; description = "CLAWDINATOR runtime (Clawbot gateway)."; }
|
||||
{ name = "pi"; description = "Pi coding agent CLI."; }
|
||||
{ name = "git"; description = "Repo sync + ops."; }
|
||||
{ name = "curl"; description = "HTTP requests."; }
|
||||
{ name = "jq"; description = "JSON processing."; }
|
||||
{ name = "python3"; description = "Clawdbot dev chain dependency."; }
|
||||
{ name = "python3"; description = "Moltbot dev chain dependency."; }
|
||||
{ name = "ffmpeg"; description = "Media processing."; }
|
||||
{ name = "ripgrep"; description = "Fast file search."; }
|
||||
{ name = "nodejs_22"; description = "Clawdbot dev chain runtime."; }
|
||||
{ name = "pnpm_10"; description = "Clawdbot dev chain package manager."; }
|
||||
{ name = "nodejs_22"; description = "Moltbot dev chain runtime."; }
|
||||
{ name = "pnpm_10"; description = "Moltbot dev chain package manager."; }
|
||||
{ name = "util-linux"; description = "Provides flock used by memory wrappers."; }
|
||||
{ name = "nfs-utils"; description = "NFS client utilities for EFS."; }
|
||||
{ name = "stunnel"; description = "TLS tunnel for EFS in transit."; }
|
||||
{ name = "awscli2"; description = "AWS CLI for bootstrap S3 pulls."; }
|
||||
{ name = "zstd"; description = "Compression tool for bootstrap archives."; }
|
||||
];
|
||||
}
|
||||
|
||||
18
nix/tools/pi-coding-agent.nix
Normal file
18
nix/tools/pi-coding-agent.nix
Normal file
@ -0,0 +1,18 @@
|
||||
{ pkgs }:
|
||||
pkgs.buildNpmPackage {
|
||||
pname = "pi-coding-agent";
|
||||
version = "0.52.6";
|
||||
|
||||
src = pkgs.fetchurl {
|
||||
url = "https://registry.npmjs.org/@mariozechner/pi-coding-agent/-/pi-coding-agent-0.52.6.tgz";
|
||||
hash = "sha256-CXKWlAxjXSwSJI+DVzgLu1A04w+QQzE6yBXBO/j/za4=";
|
||||
};
|
||||
|
||||
postPatch = ''
|
||||
cp ${../vendor/pi-coding-agent/package-lock.json} package-lock.json
|
||||
'';
|
||||
|
||||
# Update via `nix build` on hash mismatch
|
||||
npmDepsHash = "sha256-zD87h87FILBSKCygRLV0jZxLjgU5YnM765FISjFDpas=";
|
||||
dontNpmBuild = true;
|
||||
}
|
||||
5692
nix/vendor/pi-coding-agent/package-lock.json
generated
vendored
Normal file
5692
nix/vendor/pi-coding-agent/package-lock.json
generated
vendored
Normal file
File diff suppressed because it is too large
Load Diff
15
nix/vendor/strip-ansi/index.js
vendored
Normal file
15
nix/vendor/strip-ansi/index.js
vendored
Normal file
@ -0,0 +1,15 @@
|
||||
const ansiRegex = () =>
|
||||
new RegExp(
|
||||
'[\\u001B\\u009B][[\\]()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nq-uy=><]',
|
||||
'g'
|
||||
);
|
||||
|
||||
export default function stripAnsi(input) {
|
||||
if (typeof input !== 'string') {
|
||||
throw new TypeError('Expected a string');
|
||||
}
|
||||
|
||||
return input.replace(ansiRegex(), '');
|
||||
}
|
||||
|
||||
export { stripAnsi };
|
||||
7
nix/vendor/strip-ansi/package.json
vendored
Normal file
7
nix/vendor/strip-ansi/package.json
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "strip-ansi",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"main": "./index.js",
|
||||
"exports": "./index.js"
|
||||
}
|
||||
31
scripts/aws-resolve-instance-id.sh
Executable file
31
scripts/aws-resolve-instance-id.sh
Executable file
@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [ "$#" -ne 1 ]; then
|
||||
echo "usage: $0 <host-tag-Name>" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
host="$1"
|
||||
|
||||
ids="$(aws ec2 describe-instances \
|
||||
--filters \
|
||||
"Name=tag:app,Values=clawdinator" \
|
||||
"Name=tag:Name,Values=${host}" \
|
||||
"Name=instance-state-name,Values=running" \
|
||||
--query 'Reservations[].Instances[].InstanceId' \
|
||||
--output text)"
|
||||
|
||||
if [ -z "${ids}" ] || [ "${ids}" = "None" ]; then
|
||||
echo "no running instance found for Name tag: ${host}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# If multiple instances match, fail loudly.
|
||||
count="$(wc -w <<< "${ids}" | tr -d ' ')"
|
||||
if [ "${count}" != "1" ]; then
|
||||
echo "expected 1 instance for ${host}, got ${count}: ${ids}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "${ids}"
|
||||
70
scripts/aws-ssm-run.sh
Executable file
70
scripts/aws-ssm-run.sh
Executable file
@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [ "$#" -lt 2 ]; then
|
||||
echo "usage: $0 <instance-id> <command...>" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
instance_id="$1"
|
||||
shift
|
||||
|
||||
# Join remaining args into a single shell command.
|
||||
cmd="$*"
|
||||
|
||||
params_json="$(jq -cn --arg c "${cmd}" '{commands: [$c]}')"
|
||||
|
||||
command_id="$(aws ssm send-command \
|
||||
--instance-ids "${instance_id}" \
|
||||
--document-name "AWS-RunShellScript" \
|
||||
--comment "clawdinators deploy" \
|
||||
--parameters "${params_json}" \
|
||||
--query 'Command.CommandId' \
|
||||
--output text)"
|
||||
|
||||
echo "ssm command id: ${command_id} (instance: ${instance_id})" >&2
|
||||
|
||||
status=""
|
||||
# Wait for invocation to exist + finish.
|
||||
for _ in $(seq 1 300); do
|
||||
status="$(aws ssm list-command-invocations \
|
||||
--command-id "${command_id}" \
|
||||
--details \
|
||||
--query 'CommandInvocations[0].Status' \
|
||||
--output text 2> /dev/null || true)"
|
||||
|
||||
case "${status}" in
|
||||
Success | Cancelled | TimedOut | Failed)
|
||||
break
|
||||
;;
|
||||
Pending | InProgress | Delayed | Cancelling | None | "")
|
||||
sleep 2
|
||||
;;
|
||||
*)
|
||||
echo "unknown SSM status: ${status}" >&2
|
||||
sleep 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
invocation_json="$(aws ssm get-command-invocation \
|
||||
--command-id "${command_id}" \
|
||||
--instance-id "${instance_id}" \
|
||||
--output json)"
|
||||
|
||||
stdout="$(jq -r '.StandardOutputContent // ""' <<< "${invocation_json}")"
|
||||
stderr="$(jq -r '.StandardErrorContent // ""' <<< "${invocation_json}")"
|
||||
final_status="$(jq -r '.Status' <<< "${invocation_json}")"
|
||||
|
||||
if [ -n "${stdout}" ]; then
|
||||
echo "${stdout}"
|
||||
fi
|
||||
if [ -n "${stderr}" ]; then
|
||||
echo "--- stderr ---" >&2
|
||||
echo "${stderr}" >&2
|
||||
fi
|
||||
|
||||
if [ "${final_status}" != "Success" ]; then
|
||||
echo "ssm command failed: status=${final_status}" >&2
|
||||
exit 1
|
||||
fi
|
||||
78
scripts/bootstrap-runtime.sh
Normal file
78
scripts/bootstrap-runtime.sh
Normal file
@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
bucket="${1:?S3 bucket required}"
|
||||
prefix="${2:?S3 prefix required}"
|
||||
secrets_dir="${3:?Secrets dir required}"
|
||||
|
||||
override_file="${BOOTSTRAP_PREFIX_FILE:-/etc/clawdinator/bootstrap-prefix}"
|
||||
if [ -f "${override_file}" ]; then
|
||||
override_prefix="$(cat "${override_file}")"
|
||||
if [ -n "${override_prefix}" ]; then
|
||||
prefix="${override_prefix}"
|
||||
fi
|
||||
fi
|
||||
repo_seeds_dir="${4:?Repo seeds dir required}"
|
||||
age_key_path="${5:?Age key path required}"
|
||||
secrets_archive="${6:-secrets.tar.zst}"
|
||||
repo_seeds_archive="${7:-repo-seeds.tar.zst}"
|
||||
|
||||
sentinel="${secrets_dir}/.bootstrap-ok"
|
||||
prefix_marker="${secrets_dir}/.bootstrap-prefix"
|
||||
reset_secrets=false
|
||||
if [ -f "${sentinel}" ]; then
|
||||
if [ -f "${prefix_marker}" ]; then
|
||||
existing_prefix="$(cat "${prefix_marker}" || true)"
|
||||
if [ "${existing_prefix}" = "${prefix}" ]; then
|
||||
echo "clawdinator-bootstrap: already initialized"
|
||||
exit 0
|
||||
fi
|
||||
echo "clawdinator-bootstrap: prefix changed (${existing_prefix} -> ${prefix}); reinitializing"
|
||||
reset_secrets=true
|
||||
else
|
||||
echo "clawdinator-bootstrap: prefix marker missing; reinitializing"
|
||||
reset_secrets=true
|
||||
fi
|
||||
fi
|
||||
|
||||
s3_base="s3://${bucket}/${prefix}"
|
||||
workdir="$(mktemp -d)"
|
||||
cleanup() {
|
||||
rm -rf "${workdir}"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
mkdir -p "${secrets_dir}" "${repo_seeds_dir}" "$(dirname "${age_key_path}")"
|
||||
|
||||
if [ "${reset_secrets}" = "true" ]; then
|
||||
rm -f "${secrets_dir}"/*.age
|
||||
rm -f "${sentinel}" "${prefix_marker}"
|
||||
fi
|
||||
|
||||
aws s3 cp "${s3_base}/${secrets_archive}" "${workdir}/secrets.tar.zst" --only-show-errors
|
||||
aws s3 cp "${s3_base}/${repo_seeds_archive}" "${workdir}/repo-seeds.tar.zst" --only-show-errors
|
||||
|
||||
tmp_secrets="${workdir}/secrets"
|
||||
mkdir -p "${tmp_secrets}"
|
||||
tar --zstd -xf "${workdir}/secrets.tar.zst" -C "${tmp_secrets}"
|
||||
|
||||
if [ ! -f "${tmp_secrets}/clawdinator.agekey" ]; then
|
||||
echo "clawdinator-bootstrap: missing clawdinator.agekey in secrets archive" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
install -m 0400 "${tmp_secrets}/clawdinator.agekey" "${age_key_path}"
|
||||
|
||||
if [ ! -d "${tmp_secrets}/secrets" ]; then
|
||||
echo "clawdinator-bootstrap: missing secrets/ directory in secrets archive" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cp -a "${tmp_secrets}/secrets/." "${secrets_dir}/"
|
||||
chmod -R u=rw,go= "${secrets_dir}" || true
|
||||
|
||||
tar --zstd -xf "${workdir}/repo-seeds.tar.zst" -C "${repo_seeds_dir}"
|
||||
|
||||
printf '%s' "${prefix}" > "${prefix_marker}"
|
||||
touch "${sentinel}"
|
||||
echo "clawdinator-bootstrap: done"
|
||||
@ -9,24 +9,6 @@ if [ -e "${out_dir}" ]; then
|
||||
rm -rf "${out_dir}"
|
||||
fi
|
||||
|
||||
if [ -f nix/keys/clawdinator.agekey ]; then
|
||||
export CLAWDINATOR_AGE_KEY
|
||||
CLAWDINATOR_AGE_KEY="$(cat nix/keys/clawdinator.agekey)"
|
||||
else
|
||||
echo "Missing nix/keys/clawdinator.agekey" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "${CLAWDINATOR_SECRETS_DIR:-}" ]; then
|
||||
if [ -d nix/age-secrets ]; then
|
||||
export CLAWDINATOR_SECRETS_DIR
|
||||
CLAWDINATOR_SECRETS_DIR="$(pwd)/nix/age-secrets"
|
||||
else
|
||||
echo "Missing nix/age-secrets; set CLAWDINATOR_SECRETS_DIR" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
nix run --impure github:nix-community/nixos-generators -- --flake "${flake_ref}" -f "${format}" -o "${out_dir}"
|
||||
|
||||
out_real="${out_dir}"
|
||||
@ -45,7 +27,7 @@ fi
|
||||
ext="${image_file##*.}"
|
||||
ext="$(printf '%s' "${ext}" | tr '[:upper:]' '[:lower:]')"
|
||||
case "${ext}" in
|
||||
img|raw)
|
||||
img | raw)
|
||||
aws_format="raw"
|
||||
;;
|
||||
vhd)
|
||||
|
||||
114
scripts/clawdinator-version.sh
Executable file
114
scripts/clawdinator-version.sh
Executable file
@ -0,0 +1,114 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
info=/etc/clawdinator/build-info.json
|
||||
if [ ! -f "$info" ]; then
|
||||
echo "missing $info" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
now="$(date +%s)"
|
||||
|
||||
human_age_from_epoch() {
|
||||
local then_ts="$1"
|
||||
if [ -z "$then_ts" ]; then
|
||||
printf '%s' "unknown"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Accept either "null" or non-numeric.
|
||||
if [ "$then_ts" = "null" ]; then
|
||||
printf '%s' "unknown"
|
||||
return 0
|
||||
fi
|
||||
if ! [[ "$then_ts" =~ ^[0-9]+$ ]]; then
|
||||
printf '%s' "unknown"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local delta=$((now - then_ts))
|
||||
if [ "$delta" -lt 0 ]; then
|
||||
delta=0
|
||||
fi
|
||||
local d=$((delta / 86400))
|
||||
local h=$(((delta % 86400) / 3600))
|
||||
local m=$(((delta % 3600) / 60))
|
||||
if [ "$d" -gt 0 ]; then
|
||||
printf '%sd%sh%sm' "$d" "$h" "$m"
|
||||
elif [ "$h" -gt 0 ]; then
|
||||
printf '%sh%sm' "$h" "$m"
|
||||
else
|
||||
printf '%sm' "$m"
|
||||
fi
|
||||
}
|
||||
|
||||
human_age_from_iso() {
|
||||
local iso="$1"
|
||||
if [ -z "$iso" ]; then
|
||||
printf '%s' "unknown"
|
||||
return 0
|
||||
fi
|
||||
local epoch
|
||||
epoch="$(date -d "$iso" +%s 2> /dev/null || true)"
|
||||
human_age_from_epoch "$epoch"
|
||||
}
|
||||
|
||||
deployed_rev="$(cat /run/current-system/configurationRevision 2> /dev/null || true)"
|
||||
if [ -z "$deployed_rev" ]; then
|
||||
deployed_rev="$(nixos-version --json 2> /dev/null | jq -r '.configurationRevision // empty' || true)"
|
||||
fi
|
||||
if [ -z "$deployed_rev" ]; then
|
||||
deployed_rev="unknown"
|
||||
fi
|
||||
|
||||
desired_rev="$(jq -r '.clawdinators.rev // empty' "$info")"
|
||||
if [ -z "$desired_rev" ]; then
|
||||
desired_rev="unknown"
|
||||
fi
|
||||
|
||||
nix_openclaw_rev="$(jq -r '.nixOpenclaw.rev // empty' "$info")"
|
||||
nix_openclaw_lm="$(jq -r '.nixOpenclaw.lastModified // empty' "$info")"
|
||||
|
||||
nixpkgs_rev="$(jq -r '.nixpkgs.rev // empty' "$info")"
|
||||
nixpkgs_lm="$(jq -r '.nixpkgs.lastModified // empty' "$info")"
|
||||
|
||||
openclaw_rev="$(jq -r '.openclaw.rev // empty' "$info")"
|
||||
|
||||
last_switch_time=""
|
||||
if [ -f /var/lib/clawd/deploy/last-switch.time ]; then
|
||||
last_switch_time="$(tr -d '\n' < /var/lib/clawd/deploy/last-switch.time)"
|
||||
fi
|
||||
last_switch_rev=""
|
||||
if [ -f /var/lib/clawd/deploy/last-switch.rev ]; then
|
||||
last_switch_rev="$(tr -d '\n' < /var/lib/clawd/deploy/last-switch.rev)"
|
||||
fi
|
||||
|
||||
echo "clawdinators: $deployed_rev (desired: $desired_rev)"
|
||||
if [ -n "$last_switch_time" ]; then
|
||||
echo " deployed: $last_switch_time ($(human_age_from_iso "$last_switch_time") ago)"
|
||||
fi
|
||||
if [ -n "$last_switch_rev" ]; then
|
||||
echo " last-switch.rev: $last_switch_rev"
|
||||
fi
|
||||
|
||||
echo "nix-openclaw: $nix_openclaw_rev (lock age: $(human_age_from_epoch "$nix_openclaw_lm"))"
|
||||
echo "nixpkgs: $nixpkgs_rev (lock age: $(human_age_from_epoch "$nixpkgs_lm"))"
|
||||
|
||||
echo "openclaw: $openclaw_rev"
|
||||
|
||||
# Optional: enrich OpenClaw with commit timestamp/age via GitHub API (requires auth).
|
||||
if [ -n "$openclaw_rev" ] && command -v gh > /dev/null 2>&1; then
|
||||
if gh auth status -h github.com > /dev/null 2>&1; then
|
||||
openclaw_date="$(gh api \
|
||||
-H 'Accept: application/vnd.github+json' \
|
||||
"/repos/openclaw/openclaw/commits/${openclaw_rev}" \
|
||||
--jq '.commit.committer.date' 2> /dev/null || true)"
|
||||
if [ -n "$openclaw_date" ]; then
|
||||
echo " commit: $openclaw_date ($(human_age_from_iso "$openclaw_date") ago)"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$#" -ge 1 ] && [ "$1" = "--json" ]; then
|
||||
jq -c '.' "$info"
|
||||
fi
|
||||
69
scripts/fetch-ec2-metadata.sh
Normal file
69
scripts/fetch-ec2-metadata.sh
Normal file
@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
metaDir=/etc/ec2-metadata
|
||||
mkdir -p "$metaDir"
|
||||
chmod 0755 "$metaDir"
|
||||
rm -f "$metaDir/*"
|
||||
|
||||
get_imds_token() {
|
||||
# retry-delay of 1 selected to give the system a second to get going,
|
||||
# but not add a lot to the bootup time
|
||||
curl \
|
||||
--silent \
|
||||
--show-error \
|
||||
--retry 3 \
|
||||
--retry-delay 1 \
|
||||
--fail \
|
||||
-X PUT \
|
||||
--connect-timeout 1 \
|
||||
-H "X-aws-ec2-metadata-token-ttl-seconds: 600" \
|
||||
http://169.254.169.254/latest/api/token
|
||||
}
|
||||
|
||||
preflight_imds_token() {
|
||||
# retry-delay of 1 selected to give the system a second to get going,
|
||||
# but not add a lot to the bootup time
|
||||
curl \
|
||||
--silent \
|
||||
--show-error \
|
||||
--retry 3 \
|
||||
--retry-delay 1 \
|
||||
--fail \
|
||||
--connect-timeout 1 \
|
||||
-H "X-aws-ec2-metadata-token: $IMDS_TOKEN" \
|
||||
-o /dev/null \
|
||||
http://169.254.169.254/1.0/meta-data/instance-id
|
||||
}
|
||||
|
||||
try=1
|
||||
while [ $try -le 3 ]; do
|
||||
echo "(attempt $try/3) getting an EC2 instance metadata service v2 token..."
|
||||
IMDS_TOKEN=$(get_imds_token) && break
|
||||
try=$((try + 1))
|
||||
sleep 1
|
||||
done
|
||||
|
||||
if [ "$IMDS_TOKEN" == "" ]; then
|
||||
echo "failed to fetch an IMDS2v token."
|
||||
fi
|
||||
|
||||
try=1
|
||||
while [ $try -le 10 ]; do
|
||||
echo "(attempt $try/10) validating the EC2 instance metadata service v2 token..."
|
||||
preflight_imds_token && break
|
||||
try=$((try + 1))
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo "getting EC2 instance metadata..."
|
||||
|
||||
get_imds() {
|
||||
# --fail to avoid populating missing files with 404 HTML response body
|
||||
# || true to allow the script to continue even when encountering a 404
|
||||
curl --silent --show-error --fail --header "X-aws-ec2-metadata-token: $IMDS_TOKEN" "$@" || true
|
||||
}
|
||||
|
||||
get_imds -o "$metaDir/ami-manifest-path" http://169.254.169.254/1.0/meta-data/ami-manifest-path
|
||||
(umask 077 && get_imds -o "$metaDir/user-data" http://169.254.169.254/1.0/user-data)
|
||||
get_imds -o "$metaDir/hostname" http://169.254.169.254/1.0/meta-data/hostname
|
||||
get_imds -o "$metaDir/public-keys-0-openssh-key" http://169.254.169.254/1.0/meta-data/public-keys/0/openssh-key
|
||||
77
scripts/fleet-control.sh
Executable file
77
scripts/fleet-control.sh
Executable file
@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
action="${1:-}"
|
||||
target="${2:-}"
|
||||
ami_override="${3:-}"
|
||||
|
||||
if [ -z "${action}" ]; then
|
||||
echo "Usage: fleet-control.sh <deploy|status> [target] [ami_override]" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
token_file="/run/agenix/clawdinator-control-token"
|
||||
access_key_file="/run/agenix/clawdinator-control-aws-access-key-id"
|
||||
secret_key_file="/run/agenix/clawdinator-control-aws-secret-access-key"
|
||||
caller_file="/etc/clawdinator/instance-name"
|
||||
|
||||
if [ ! -f "${token_file}" ]; then
|
||||
echo "Missing control API token: ${token_file}" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -f "${access_key_file}" ] || [ ! -f "${secret_key_file}" ]; then
|
||||
echo "Missing control AWS credentials in /run/agenix" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -f "${caller_file}" ]; then
|
||||
echo "Missing instance name: ${caller_file}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
control_token="$(cat "${token_file}")"
|
||||
caller="$(cat "${caller_file}")"
|
||||
|
||||
region="${AWS_REGION:-eu-central-1}"
|
||||
AWS_ACCESS_KEY_ID="$(cat "${access_key_file}")"
|
||||
AWS_SECRET_ACCESS_KEY="$(cat "${secret_key_file}")"
|
||||
export AWS_ACCESS_KEY_ID
|
||||
export AWS_SECRET_ACCESS_KEY
|
||||
export AWS_REGION="${region}"
|
||||
|
||||
if [ "${action}" = "status" ]; then
|
||||
/var/lib/clawd/repos/clawdinators/scripts/fleet-status.sh
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "${action}" = "deploy" ]; then
|
||||
if [ -z "${target}" ]; then
|
||||
echo "Target required. Usage: fleet-control.sh deploy <all|clawdinator-2>" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "${target}" = "${caller}" ]; then
|
||||
echo "Refusing self-deploy for ${caller}." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
payload="$(jq -n \
|
||||
--arg action "${action}" \
|
||||
--arg target "${target}" \
|
||||
--arg caller "${caller}" \
|
||||
--arg ami_override "${ami_override}" \
|
||||
--arg control_token "${control_token}" \
|
||||
'{action: $action, target: $target, caller: $caller, ami_override: $ami_override, control_token: $control_token}')"
|
||||
|
||||
response_file="$(mktemp)"
|
||||
aws lambda invoke \
|
||||
--function-name "clawdinator-control-api" \
|
||||
--region "${region}" \
|
||||
--payload "${payload}" \
|
||||
--cli-binary-format raw-in-base64-out \
|
||||
"${response_file}" > /dev/null
|
||||
|
||||
response="$(cat "${response_file}")"
|
||||
rm -f "${response_file}"
|
||||
|
||||
echo "${response}"
|
||||
30
scripts/fleet-deploy.sh
Executable file
30
scripts/fleet-deploy.sh
Executable file
@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
target="${TARGET:?TARGET required}"
|
||||
ami_id="${AMI_ID:?AMI_ID required}"
|
||||
aws_region="${AWS_REGION:?AWS_REGION required}"
|
||||
ssh_public_key="${SSH_PUBLIC_KEY:?SSH_PUBLIC_KEY required}"
|
||||
|
||||
backend_bucket="${TF_BACKEND_BUCKET:?TF_BACKEND_BUCKET required}"
|
||||
backend_key="${TF_BACKEND_KEY:?TF_BACKEND_KEY required}"
|
||||
backend_region="${TF_BACKEND_REGION:-${aws_region}}"
|
||||
backend_table="${TF_BACKEND_DYNAMO_TABLE:?TF_BACKEND_DYNAMO_TABLE required}"
|
||||
|
||||
cd infra/opentofu/aws
|
||||
|
||||
tofu init \
|
||||
-backend-config="bucket=${backend_bucket}" \
|
||||
-backend-config="key=${backend_key}" \
|
||||
-backend-config="region=${backend_region}" \
|
||||
-backend-config="dynamodb_table=${backend_table}"
|
||||
|
||||
export TF_VAR_aws_region="${aws_region}"
|
||||
export TF_VAR_ami_id="${ami_id}"
|
||||
export TF_VAR_ssh_public_key="${ssh_public_key}"
|
||||
|
||||
if [ "${target}" = "all" ]; then
|
||||
tofu apply -auto-approve
|
||||
else
|
||||
tofu apply -auto-approve -replace "aws_instance.clawdinator[\"${target}\"]"
|
||||
fi
|
||||
26
scripts/fleet-status.sh
Executable file
26
scripts/fleet-status.sh
Executable file
@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
region="${AWS_REGION:?AWS_REGION required}"
|
||||
|
||||
instances_json="$(aws ec2 describe-instances \
|
||||
--region "${region}" \
|
||||
--filters "Name=tag:app,Values=clawdinator" \
|
||||
--query 'Reservations[].Instances[]' \
|
||||
--output json)"
|
||||
|
||||
if [ "${instances_json}" = "[]" ]; then
|
||||
echo "No CLAWDINATOR instances found."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "CLAWDINATOR Fleet"
|
||||
echo "Name | InstanceId | State | AMI | Public IP"
|
||||
|
||||
echo "${instances_json}" | jq -r '.[] | {
|
||||
name: ((.Tags[]? | select(.Key=="Name").Value) // "unknown"),
|
||||
id: .InstanceId,
|
||||
state: .State.Name,
|
||||
ami: .ImageId,
|
||||
ip: (.PublicIpAddress // "n/a")
|
||||
} | "\(.name) | \(.id) | \(.state) | \(.ami) | \(.ip)"'
|
||||
32
scripts/fleet-switch-nixos.sh
Executable file
32
scripts/fleet-switch-nixos.sh
Executable file
@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [ "$#" -lt 1 ]; then
|
||||
echo "usage: $0 <git-rev> [host1 host2 ...]" >&2
|
||||
echo "example: $0 ${GITHUB_SHA:-<sha>} clawdinator-1 clawdinator-2" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
rev="$1"
|
||||
shift
|
||||
|
||||
if [ "$#" -eq 0 ]; then
|
||||
# Canary order.
|
||||
hosts=(clawdinator-1 clawdinator-2)
|
||||
else
|
||||
hosts=("$@")
|
||||
fi
|
||||
|
||||
for host in "${hosts[@]}"; do
|
||||
echo "== deploy: ${host} @ ${rev} ==" >&2
|
||||
instance_id="$(bash scripts/aws-resolve-instance-id.sh "${host}")"
|
||||
|
||||
# Run everything under bash -lc so PATH + profiles behave similarly to an interactive session.
|
||||
# Execute remote switch logic from a committed script (no inline deployment logic).
|
||||
remote_script_url="https://raw.githubusercontent.com/openclaw/clawdinators/${rev}/scripts/remote-fleet-switch-host.sh"
|
||||
remote_switch_cmd="$(printf 'set -euo pipefail; curl -fsSL %q -o /tmp/remote-fleet-switch-host.sh; chmod 700 /tmp/remote-fleet-switch-host.sh; /tmp/remote-fleet-switch-host.sh %q %q' "${remote_script_url}" "${rev}" "${host}")"
|
||||
|
||||
bash scripts/aws-ssm-run.sh "${instance_id}" \
|
||||
"bash -lc $(printf '%q' "${remote_switch_cmd}")"
|
||||
|
||||
done
|
||||
@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# gh-sync.sh — Pure IO sync of GitHub state for clawdbot org
|
||||
# gh-sync.sh — Pure IO sync of GitHub state for configured org
|
||||
# ZFC-compliant: no reasoning, no scoring, no heuristics
|
||||
# Writes raw data to memory/github/ for AI to reason about
|
||||
|
||||
@ -7,7 +7,7 @@ set -euo pipefail
|
||||
|
||||
MEMORY_DIR="${MEMORY_DIR:-/memory}"
|
||||
GITHUB_DIR="${MEMORY_DIR}/github"
|
||||
ORG="${ORG:-clawdbot}"
|
||||
ORG="${ORG:-openclaw}"
|
||||
|
||||
mkdir -p "$GITHUB_DIR"
|
||||
|
||||
@ -17,7 +17,7 @@ log() {
|
||||
|
||||
# Fetch all repos in org
|
||||
log "Fetching repos for $ORG..."
|
||||
repos=$(gh repo list "$ORG" --json nameWithOwner,name,description,isArchived --limit 100 -q '.[] | select(.isArchived == false) | .nameWithOwner')
|
||||
repos=$(gh repo list "$ORG" --json nameWithOwner,name,description,isArchived --limit 500 -q '.[] | select(.isArchived == false) | .nameWithOwner')
|
||||
|
||||
if [ -z "$repos" ]; then
|
||||
log "ERROR: No repos found or gh auth failed"
|
||||
@ -30,8 +30,8 @@ issues_tmp=$(mktemp)
|
||||
trap 'rm -f "$prs_tmp" "$issues_tmp"' EXIT
|
||||
|
||||
# Header for PRs
|
||||
cat > "$prs_tmp" << 'EOF'
|
||||
# Open Pull Requests (clawdbot org)
|
||||
cat > "$prs_tmp" << EOF
|
||||
# Open Pull Requests (${ORG} org)
|
||||
|
||||
Last synced: SYNC_TIME
|
||||
|
||||
@ -39,8 +39,8 @@ EOF
|
||||
sed -i.bak "s/SYNC_TIME/$(date -u +%Y-%m-%dT%H:%M:%SZ)/" "$prs_tmp" && rm -f "${prs_tmp}.bak"
|
||||
|
||||
# Header for Issues
|
||||
cat > "$issues_tmp" << 'EOF'
|
||||
# Open Issues (clawdbot org)
|
||||
cat > "$issues_tmp" << EOF
|
||||
# Open Issues (${ORG} org)
|
||||
|
||||
Last synced: SYNC_TIME
|
||||
|
||||
@ -49,11 +49,10 @@ sed -i.bak "s/SYNC_TIME/$(date -u +%Y-%m-%dT%H:%M:%SZ)/" "$issues_tmp" && rm -f
|
||||
|
||||
# Iterate repos
|
||||
for repo in $repos; do
|
||||
repo_name="${repo#*/}"
|
||||
log "Processing $repo..."
|
||||
|
||||
# Fetch open PRs (raw data, no filtering)
|
||||
prs_json=$(gh pr list -R "$repo" --state open --json number,title,author,createdAt,updatedAt,reviewDecision,labels,isDraft,mergeable,headRefName,url --limit 100 2>/dev/null || echo "[]")
|
||||
prs_json=$(gh pr list -R "$repo" --state open --json number,title,author,createdAt,updatedAt,reviewDecision,labels,isDraft,mergeable,headRefName,url --limit 200 2> /dev/null || echo "[]")
|
||||
|
||||
pr_count=$(echo "$prs_json" | jq 'length')
|
||||
if [ "$pr_count" -gt 0 ]; then
|
||||
@ -64,7 +63,7 @@ for repo in $repos; do
|
||||
fi
|
||||
|
||||
# Fetch open issues (excludes PRs)
|
||||
issues_json=$(gh issue list -R "$repo" --state open --json number,title,author,createdAt,updatedAt,labels,comments,url --limit 100 2>/dev/null || echo "[]")
|
||||
issues_json=$(gh issue list -R "$repo" --state open --json number,title,author,createdAt,updatedAt,labels,comments,url --limit 200 2> /dev/null || echo "[]")
|
||||
|
||||
issue_count=$(echo "$issues_json" | jq 'length')
|
||||
if [ "$issue_count" -gt 0 ]; then
|
||||
@ -76,7 +75,7 @@ for repo in $repos; do
|
||||
done
|
||||
|
||||
# Atomic move to final location (use memory-write if available)
|
||||
if command -v memory-write &>/dev/null; then
|
||||
if command -v memory-write &> /dev/null; then
|
||||
memory-write "$GITHUB_DIR/prs.md" < "$prs_tmp"
|
||||
memory-write "$GITHUB_DIR/issues.md" < "$issues_tmp"
|
||||
else
|
||||
|
||||
@ -12,7 +12,7 @@ if [ -z "${format}" ]; then
|
||||
ext="${key##*.}"
|
||||
ext="$(printf '%s' "${ext}" | tr '[:upper:]' '[:lower:]')"
|
||||
case "${ext}" in
|
||||
img|raw)
|
||||
img | raw)
|
||||
format="raw"
|
||||
;;
|
||||
vhd)
|
||||
@ -87,11 +87,25 @@ for _ in {1..120}; do
|
||||
aws ec2 create-tags \
|
||||
--region "${region}" \
|
||||
--resources "${image_id}" \
|
||||
--tags "Key=Name,Value=${ami_name}" "Key=clawdinator,Value=true"
|
||||
--tags \
|
||||
"Key=Name,Value=${ami_name}" \
|
||||
"Key=clawdinator,Value=true" \
|
||||
"Key=artifact-kind,Value=ami" \
|
||||
"Key=source-s3-key,Value=${key}"
|
||||
|
||||
aws ec2 create-tags \
|
||||
--region "${region}" \
|
||||
--resources "${snapshot_id}" \
|
||||
--tags \
|
||||
"Key=Name,Value=${ami_name}-root-snapshot" \
|
||||
"Key=clawdinator,Value=true" \
|
||||
"Key=artifact-kind,Value=ami-root-snapshot" \
|
||||
"Key=source-s3-key,Value=${key}"
|
||||
echo "AMI_ID=${image_id}" >&2
|
||||
echo "${image_id}"
|
||||
exit 0
|
||||
;;
|
||||
deleted|deleting|error)
|
||||
deleted | deleting | error)
|
||||
message="$(aws ec2 describe-import-snapshot-tasks \
|
||||
--region "${region}" \
|
||||
--import-task-ids "${task_id}" \
|
||||
|
||||
@ -2,15 +2,22 @@
|
||||
set -euo pipefail
|
||||
|
||||
root="${1:-/memory}"
|
||||
owner="${2:-clawdinator}"
|
||||
group="${3:-clawdinator}"
|
||||
|
||||
mkdir -p "$root/daily"
|
||||
mkdir -p "$root/daily" "$root/discord"
|
||||
|
||||
index="$root/index.md"
|
||||
if [ ! -f "$index" ]; then
|
||||
cat > "$index" <<'EOM'
|
||||
cat > "$index" << 'EOM'
|
||||
# Shared Memory Index
|
||||
|
||||
- Daily notes live in /memory/daily/YYYY-MM-DD.md
|
||||
- Durable facts belong in /memory/project.md and /memory/architecture.md
|
||||
- Discord lurk snapshots live in /memory/discord/YYYY-MM-DD.md
|
||||
EOM
|
||||
fi
|
||||
|
||||
# Ensure shared memory is writable by the service user across instances.
|
||||
chown "$owner:$group" "$root" "$root/daily" "$root/discord"
|
||||
chmod 2770 "$root" "$root/daily" "$root/discord"
|
||||
|
||||
67
scripts/landpr.md
Normal file
67
scripts/landpr.md
Normal file
@ -0,0 +1,67 @@
|
||||
/landpr
|
||||
|
||||
Input
|
||||
- PR: <number|url>
|
||||
- If missing: use the most recent PR mentioned in the conversation.
|
||||
- If ambiguous: ask.
|
||||
|
||||
Do (end-to-end)
|
||||
Goal: PR must end in GitHub state = MERGED (never CLOSED). Use `gh pr merge` with `--rebase` or `--squash`.
|
||||
|
||||
1) Repo clean: `git status`.
|
||||
2) Identify PR meta (author + head branch):
|
||||
|
||||
```sh
|
||||
gh pr view <PR> --json number,title,author,headRefName,baseRefName,headRepository --jq '{number,title,author:.author.login,head:.headRefName,base:.baseRefName,headRepo:.headRepository.nameWithOwner}'
|
||||
contrib=$(gh pr view <PR> --json author --jq .author.login)
|
||||
head=$(gh pr view <PR> --json headRefName --jq .headRefName)
|
||||
head_repo_url=$(gh pr view <PR> --json headRepository --jq .headRepository.url)
|
||||
```
|
||||
|
||||
3) Fast-forward base:
|
||||
- `git checkout main`
|
||||
- `git pull --ff-only`
|
||||
4) Create temp base branch from main:
|
||||
- `git checkout -b temp/landpr-<ts-or-pr>`
|
||||
5) Check out PR branch locally:
|
||||
- `gh pr checkout <PR>`
|
||||
6) **Single approval gate:** summarize plan + merge strategy, then get explicit approval before any rebase/force-push/merge.
|
||||
7) Rebase PR branch onto temp base:
|
||||
- `git rebase temp/landpr-<ts-or-pr>`
|
||||
- Fix conflicts; keep history tidy.
|
||||
8) Fix + tests + changelog:
|
||||
- Implement fixes + add/adjust tests
|
||||
- Update `CHANGELOG.md` and mention `#<PR>` + `@$contrib`
|
||||
9) Decide merge strategy:
|
||||
- Default: **rebase** (preserve history)
|
||||
- Use **squash** only if explicitly requested
|
||||
10) Full gate (BEFORE commit):
|
||||
- `pnpm lint && pnpm build && pnpm test`
|
||||
11) Commit via committer (include # + contributor in commit message):
|
||||
- `committer "fix: <summary> (#<PR>) (thanks @$contrib)" CHANGELOG.md <changed files>`
|
||||
- `land_sha=$(git rev-parse HEAD)`
|
||||
12) Push updated PR branch (rebase => usually needs force):
|
||||
|
||||
```sh
|
||||
git remote add prhead "$head_repo_url.git" 2>/dev/null || git remote set-url prhead "$head_repo_url.git"
|
||||
git push --force-with-lease prhead HEAD:$head
|
||||
```
|
||||
|
||||
13) Merge PR (must show MERGED on GitHub):
|
||||
- Rebase: `gh pr merge <PR> --rebase`
|
||||
- Squash: `gh pr merge <PR> --squash`
|
||||
- Never `gh pr close` (closing is wrong)
|
||||
14) Sync main:
|
||||
- `git checkout main`
|
||||
- `git pull --ff-only`
|
||||
15) Comment on PR with what we did + SHAs + thanks **only with explicit user approval**:
|
||||
|
||||
```sh
|
||||
merge_sha=$(gh pr view <PR> --json mergeCommit --jq '.mergeCommit.oid')
|
||||
gh pr comment <PR> --body "Landed via temp rebase onto main.\n\n- Gate: pnpm lint && pnpm build && pnpm test\n- Land commit: $land_sha\n- Merge commit: $merge_sha\n\nThanks @$contrib!"
|
||||
```
|
||||
|
||||
16) Verify PR state == MERGED:
|
||||
- `gh pr view <PR> --json state --jq .state`
|
||||
17) Delete temp branch:
|
||||
- `git branch -D temp/landpr-<ts-or-pr>`
|
||||
20
scripts/lint-shell.sh
Executable file
20
scripts/lint-shell.sh
Executable file
@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Lint/format gate for repo shell scripts.
|
||||
# - shellcheck: static analysis
|
||||
# - shfmt: formatting
|
||||
|
||||
# Find shell scripts we own (keep it explicit/simple).
|
||||
mapfile -t files < <(find scripts -type f -name '*.sh' -print | sort)
|
||||
|
||||
if [ "${#files[@]}" -eq 0 ]; then
|
||||
echo "no shell scripts found" >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "shellcheck (${#files[@]} files)" >&2
|
||||
shellcheck -S warning "${files[@]}"
|
||||
|
||||
echo "shfmt check" >&2
|
||||
shfmt -i 2 -ci -sr -d "${files[@]}"
|
||||
47
scripts/mint-github-app-token.sh
Executable file
47
scripts/mint-github-app-token.sh
Executable file
@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
app_id="${GITHUB_APP_ID:-}"
|
||||
installation_id="${GITHUB_APP_INSTALLATION_ID:-}"
|
||||
pem_file="${GITHUB_APP_PEM_FILE:-}"
|
||||
|
||||
if [ -z "$app_id" ] || [ -z "$installation_id" ] || [ -z "$pem_file" ]; then
|
||||
echo "mint-github-app-token: set GITHUB_APP_ID, GITHUB_APP_INSTALLATION_ID, GITHUB_APP_PEM_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$pem_file" ]; then
|
||||
echo "mint-github-app-token: PEM file not found: $pem_file" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
now="$(date +%s)"
|
||||
iat="$((now - 60))"
|
||||
exp="$((now + 540))"
|
||||
|
||||
header='{"alg":"RS256","typ":"JWT"}'
|
||||
payload="{\"iat\":$iat,\"exp\":$exp,\"iss\":\"${app_id}\"}"
|
||||
|
||||
base64url() {
|
||||
openssl base64 -A | tr '+/' '-_' | tr -d '='
|
||||
}
|
||||
|
||||
jwt_header="$(printf '%s' "$header" | base64url)"
|
||||
jwt_payload="$(printf '%s' "$payload" | base64url)"
|
||||
unsigned="${jwt_header}.${jwt_payload}"
|
||||
signature="$(printf '%s' "$unsigned" | openssl dgst -sha256 -sign "$pem_file" | base64url)"
|
||||
jwt="${unsigned}.${signature}"
|
||||
|
||||
resp="$(curl -sS -X POST \
|
||||
-H "Authorization: Bearer $jwt" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
"https://api.github.com/app/installations/${installation_id}/access_tokens")"
|
||||
|
||||
token="$(printf '%s' "$resp" | jq -r '.token')"
|
||||
if [ -z "$token" ] || [ "$token" = "null" ]; then
|
||||
echo "mint-github-app-token: failed to mint token" >&2
|
||||
echo "$resp" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf '%s' "$token"
|
||||
48
scripts/pi-auth.sh
Executable file
48
scripts/pi-auth.sh
Executable file
@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
output_path="${1:-}"
|
||||
openai_key_file="${2:-}"
|
||||
anthropic_key_file="${3:-}"
|
||||
|
||||
if [ -z "$output_path" ] || [ -z "$openai_key_file" ] || [ -z "$anthropic_key_file" ]; then
|
||||
echo "pi-auth: usage: pi-auth <output> <openai_key_file> <anthropic_key_file>" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
read_secret() {
|
||||
local path="$1"
|
||||
if [ ! -f "$path" ]; then
|
||||
echo "pi-auth: secret not found: $path" >&2
|
||||
exit 1
|
||||
fi
|
||||
local value
|
||||
value="$(cat "$path")"
|
||||
if [ -z "$value" ]; then
|
||||
echo "pi-auth: secret empty: $path" >&2
|
||||
exit 1
|
||||
fi
|
||||
printf '%s' "$value"
|
||||
}
|
||||
|
||||
openai_key="$(read_secret "$openai_key_file")"
|
||||
anthropic_key="$(read_secret "$anthropic_key_file")"
|
||||
|
||||
install -d -m 0700 "$(dirname "$output_path")"
|
||||
|
||||
umask 077
|
||||
|
||||
tmp_file="$(mktemp)"
|
||||
trap 'rm -f "$tmp_file"' EXIT
|
||||
|
||||
jq -n \
|
||||
--arg openai "$openai_key" \
|
||||
--arg anthropic "$anthropic_key" \
|
||||
'{
|
||||
openai: { type: "api_key", key: $openai },
|
||||
anthropic: { type: "api_key", key: $anthropic }
|
||||
}' > "$tmp_file"
|
||||
|
||||
chmod 0600 "$tmp_file"
|
||||
|
||||
mv "$tmp_file" "$output_path"
|
||||
39
scripts/prepare-repo-seeds.sh
Executable file
39
scripts/prepare-repo-seeds.sh
Executable file
@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
dest="${1:-repo-seeds}"
|
||||
list_file="${2:-clawdinator/repos.tsv}"
|
||||
|
||||
if [ ! -f "$list_file" ]; then
|
||||
echo "prepare-repo-seeds: missing repo list: $list_file" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$dest"
|
||||
rm -rf "${dest:?}/"*
|
||||
|
||||
auth_header=""
|
||||
if [ -n "${GITHUB_TOKEN:-}" ]; then
|
||||
basic_auth="$(printf 'x-access-token:%s' "$GITHUB_TOKEN" | base64 | tr -d '\n')"
|
||||
auth_header="Authorization: Basic ${basic_auth}"
|
||||
fi
|
||||
|
||||
while IFS=$'\t' read -r name url branch; do
|
||||
[ -z "${name:-}" ] && continue
|
||||
[ -z "${url:-}" ] && continue
|
||||
|
||||
target="${dest}/${name}"
|
||||
if [ -n "${auth_header}" ] && [[ "$url" == https://github.com/* ]]; then
|
||||
if [ -n "${branch:-}" ]; then
|
||||
git -c http.extraheader="$auth_header" clone --depth 1 --branch "$branch" "$url" "$target"
|
||||
else
|
||||
git -c http.extraheader="$auth_header" clone --depth 1 "$url" "$target"
|
||||
fi
|
||||
else
|
||||
if [ -n "${branch:-}" ]; then
|
||||
git clone --depth 1 --branch "$branch" "$url" "$target"
|
||||
else
|
||||
git clone --depth 1 "$url" "$target"
|
||||
fi
|
||||
fi
|
||||
done < "$list_file"
|
||||
230
scripts/prune-clawdinator-ami-history.sh
Normal file
230
scripts/prune-clawdinator-ami-history.sh
Normal file
@ -0,0 +1,230 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
region="${AWS_REGION:?AWS_REGION required}"
|
||||
keep_count="${KEEP_COUNT:-6}"
|
||||
apply="${APPLY:-false}"
|
||||
|
||||
if ! [[ "${keep_count}" =~ ^[0-9]+$ ]] || [ "${keep_count}" -lt 1 ]; then
|
||||
echo "KEEP_COUNT must be a positive integer." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
aws_deregister_image() {
|
||||
local image_id="$1"
|
||||
local output
|
||||
|
||||
if ! output="$(
|
||||
aws ec2 deregister-image \
|
||||
--region "${region}" \
|
||||
--image-id "${image_id}" \
|
||||
2>&1
|
||||
)"; then
|
||||
if [[ "${output}" == *"InvalidAMIID.NotFound"* ]] || [[ "${output}" == *"InvalidAMIID.Unavailable"* ]]; then
|
||||
echo "AMI already gone: ${image_id}" >&2
|
||||
return 0
|
||||
fi
|
||||
echo "${output}" >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
aws_delete_snapshot() {
|
||||
local snapshot_id="$1"
|
||||
local output
|
||||
|
||||
if [ -z "${snapshot_id}" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if ! output="$(
|
||||
aws ec2 delete-snapshot \
|
||||
--region "${region}" \
|
||||
--snapshot-id "${snapshot_id}" \
|
||||
2>&1
|
||||
)"; then
|
||||
if [[ "${output}" == *"InvalidSnapshot.NotFound"* ]]; then
|
||||
echo "Snapshot already gone: ${snapshot_id}" >&2
|
||||
return 0
|
||||
fi
|
||||
echo "${output}" >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
array_contains() {
|
||||
local needle="$1"
|
||||
shift
|
||||
local item
|
||||
|
||||
for item in "$@"; do
|
||||
if [ "${item}" = "${needle}" ]; then
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
find_image_row() {
|
||||
local needle="$1"
|
||||
local row
|
||||
local image_id
|
||||
|
||||
for row in "${image_rows[@]}"; do
|
||||
IFS=$'\t' read -r image_id _rest <<< "${row}"
|
||||
if [ "${image_id}" = "${needle}" ]; then
|
||||
printf '%s\n' "${row}"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
in_use_ami_ids=()
|
||||
while IFS= read -r image_id; do
|
||||
if [ -n "${image_id}" ]; then
|
||||
in_use_ami_ids+=("${image_id}")
|
||||
fi
|
||||
done < <(
|
||||
aws ec2 describe-instances \
|
||||
--region "${region}" \
|
||||
--filters \
|
||||
"Name=tag:app,Values=clawdinator" \
|
||||
"Name=instance-state-name,Values=pending,running,stopping,stopped" \
|
||||
--query 'Reservations[].Instances[].ImageId' \
|
||||
--output text |
|
||||
tr '\t' '\n' |
|
||||
sed '/^None$/d;/^$/d' |
|
||||
sort -u
|
||||
)
|
||||
|
||||
images_json="$(
|
||||
aws ec2 describe-images \
|
||||
--region "${region}" \
|
||||
--owners self \
|
||||
--filters "Name=tag:clawdinator,Values=true" \
|
||||
--output json
|
||||
)"
|
||||
|
||||
image_rows=()
|
||||
while IFS= read -r row; do
|
||||
if [ -n "${row}" ]; then
|
||||
image_rows+=("${row}")
|
||||
fi
|
||||
done < <(
|
||||
printf '%s\n' "${images_json}" | jq -r '
|
||||
.Images
|
||||
| sort_by(.CreationDate)
|
||||
| reverse[]
|
||||
| [
|
||||
.ImageId,
|
||||
(.Name // ""),
|
||||
.CreationDate,
|
||||
((.RootDeviceName // "/dev/xvda") as $root
|
||||
| ([.BlockDeviceMappings[]? | select(.DeviceName == $root) | .Ebs.SnapshotId][0] // ""))
|
||||
]
|
||||
| @tsv
|
||||
'
|
||||
)
|
||||
|
||||
if [ "${#image_rows[@]}" -eq 0 ]; then
|
||||
echo "No CLAWDINATOR AMIs found."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
declare -a newest_ids=()
|
||||
declare -a keep_ids=()
|
||||
declare -a prune_rows=()
|
||||
|
||||
for image_id in "${in_use_ami_ids[@]}"; do
|
||||
keep_ids+=("${image_id}")
|
||||
done
|
||||
|
||||
recent_index=0
|
||||
for row in "${image_rows[@]}"; do
|
||||
IFS=$'\t' read -r image_id name creation_date snapshot_id <<< "${row}"
|
||||
|
||||
if [ "${recent_index}" -lt "${keep_count}" ]; then
|
||||
newest_ids+=("${image_id}")
|
||||
if ! array_contains "${image_id}" "${keep_ids[@]}"; then
|
||||
keep_ids+=("${image_id}")
|
||||
fi
|
||||
recent_index=$((recent_index + 1))
|
||||
fi
|
||||
|
||||
if ! array_contains "${image_id}" "${keep_ids[@]}"; then
|
||||
prune_rows+=("${row}")
|
||||
fi
|
||||
done
|
||||
|
||||
echo "CLAWDINATOR AMI retention"
|
||||
echo "Mode: $(printf '%s' "${apply}" | tr '[:lower:]' '[:upper:]')"
|
||||
echo "Region: ${region}"
|
||||
echo
|
||||
|
||||
echo "In-use AMIs (${#in_use_ami_ids[@]}):"
|
||||
if [ "${#in_use_ami_ids[@]}" -eq 0 ]; then
|
||||
echo " (none)"
|
||||
else
|
||||
for image_id in "${in_use_ami_ids[@]}"; do
|
||||
echo " ${image_id}"
|
||||
done
|
||||
fi
|
||||
echo
|
||||
|
||||
echo "Newest ${keep_count} AMIs by age:"
|
||||
for image_id in "${newest_ids[@]}"; do
|
||||
row="$(find_image_row "${image_id}")"
|
||||
IFS=$'\t' read -r _image_id name creation_date snapshot_id <<< "${row}"
|
||||
echo " ${image_id} ${creation_date} ${name}"
|
||||
done
|
||||
echo
|
||||
|
||||
echo "Keep-set (${#keep_ids[@]} total):"
|
||||
for row in "${image_rows[@]}"; do
|
||||
reasons=()
|
||||
IFS=$'\t' read -r image_id name creation_date snapshot_id <<< "${row}"
|
||||
if array_contains "${image_id}" "${keep_ids[@]}"; then
|
||||
if array_contains "${image_id}" "${in_use_ami_ids[@]}"; then
|
||||
reasons+=("in-use")
|
||||
fi
|
||||
if array_contains "${image_id}" "${newest_ids[@]}"; then
|
||||
reasons+=("recent")
|
||||
fi
|
||||
reason="$(
|
||||
IFS=,
|
||||
printf '%s' "${reasons[*]}"
|
||||
)"
|
||||
echo " keep ${image_id} ${creation_date} ${reason} ${name}"
|
||||
fi
|
||||
done
|
||||
echo
|
||||
|
||||
echo "Prune-set (${#prune_rows[@]} total):"
|
||||
if [ "${#prune_rows[@]}" -eq 0 ]; then
|
||||
echo " (none)"
|
||||
else
|
||||
for row in "${prune_rows[@]}"; do
|
||||
IFS=$'\t' read -r image_id name creation_date snapshot_id <<< "${row}"
|
||||
echo " prune ${image_id} ${creation_date} snapshot=${snapshot_id:-none} ${name}"
|
||||
done
|
||||
fi
|
||||
echo
|
||||
|
||||
if [ "${apply}" != "true" ]; then
|
||||
echo "Dry-run only. Re-run with APPLY=true to prune old CLAWDINATOR AMIs."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
for row in "${prune_rows[@]}"; do
|
||||
IFS=$'\t' read -r image_id name creation_date snapshot_id <<< "${row}"
|
||||
echo "Deregistering ${image_id} (${name})"
|
||||
aws_deregister_image "${image_id}"
|
||||
|
||||
if [ -n "${snapshot_id}" ]; then
|
||||
echo "Deleting snapshot ${snapshot_id}"
|
||||
aws_delete_snapshot "${snapshot_id}"
|
||||
fi
|
||||
done
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user