Compare commits
121 Commits
steipete-p
...
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 |
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.
|
||||
@ -1,4 +1,4 @@
|
||||
name: Bump nix-moltbot
|
||||
name: Bump nix-openclaw
|
||||
|
||||
on:
|
||||
schedule:
|
||||
@ -22,16 +22,16 @@ jobs:
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
|
||||
- name: Update nix-moltbot input
|
||||
- name: Update nix-openclaw input
|
||||
run: |
|
||||
set -euo pipefail
|
||||
nix flake update --update-input nix-moltbot
|
||||
nix flake update --update-input nix-openclaw
|
||||
if git diff --quiet flake.lock; then
|
||||
echo "No nix-moltbot changes."
|
||||
echo "No nix-openclaw changes."
|
||||
exit 0
|
||||
fi
|
||||
git config user.name "moltbot-ci"
|
||||
git config user.email "ci@moltbot.local"
|
||||
git config user.name "clawbot-ci"
|
||||
git config user.email "ci@openclaw.local"
|
||||
git add flake.lock
|
||||
git commit -m "chore: bump nix-moltbot"
|
||||
git commit -m "chore: bump nix-openclaw"
|
||||
git push
|
||||
@ -1,10 +1,13 @@
|
||||
name: Build NixOS Image
|
||||
|
||||
concurrency:
|
||||
group: image-build-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
schedule:
|
||||
- cron: "17 3 * * 0" # weekly (Sunday)
|
||||
|
||||
jobs:
|
||||
build-image:
|
||||
@ -23,6 +26,10 @@ jobs:
|
||||
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:
|
||||
@ -64,14 +71,7 @@ jobs:
|
||||
run: |
|
||||
mkdir -p nix/age-secrets
|
||||
aws s3 sync "s3://${S3_BUCKET}/age-secrets" nix/age-secrets
|
||||
for file in \
|
||||
nix/age-secrets/moltinator-github-app.pem.age \
|
||||
nix/age-secrets/moltinator-discord-token.age \
|
||||
nix/age-secrets/moltinator-anthropic-api-key.age \
|
||||
nix/age-secrets/moltinator-openai-api-key-peter-2.age
|
||||
do
|
||||
test -f "$file"
|
||||
done
|
||||
bash scripts/validate-age-secrets.sh
|
||||
|
||||
- name: Mint GitHub App token
|
||||
env:
|
||||
@ -79,9 +79,9 @@ jobs:
|
||||
GITHUB_APP_INSTALLATION_ID: "102951645"
|
||||
run: |
|
||||
age -d -i nix/keys/clawdinator.agekey \
|
||||
-o /tmp/moltinator-github-app.pem \
|
||||
nix/age-secrets/moltinator-github-app.pem.age
|
||||
export GITHUB_APP_PEM_FILE=/tmp/moltinator-github-app.pem
|
||||
-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}"
|
||||
|
||||
@ -91,15 +91,14 @@ jobs:
|
||||
run: |
|
||||
scripts/prepare-repo-seeds.sh repo-seeds
|
||||
|
||||
- name: Upload bootstrap bundle
|
||||
- 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 }}
|
||||
BOOTSTRAP_PREFIX: bootstrap/clawdinator-1
|
||||
run: |
|
||||
bash scripts/upload-bootstrap.sh
|
||||
bash scripts/upload-bootstrap-all.sh
|
||||
|
||||
- name: Build image
|
||||
run: scripts/build-image.sh
|
||||
@ -125,3 +124,12 @@ jobs:
|
||||
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
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@ -1,3 +1,6 @@
|
||||
# Devenv
|
||||
.devenv*
|
||||
|
||||
# OpenTofu
|
||||
infra/opentofu/.terraform/
|
||||
infra/opentofu/.tofu/
|
||||
|
||||
47
AGENTS.md
47
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):
|
||||
- `moltbot`: upstream runtime and behavior.
|
||||
- `nix-moltbot`: packaging/build fixes for `moltbot`.
|
||||
- `moltinators`: infra, NixOS config, secrets wiring, deployment flow.
|
||||
- `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 (moltbot/nix-moltbot/moltinators) 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).
|
||||
@ -61,7 +62,8 @@ 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` to build + upload the runtime bootstrap bundle to S3.
|
||||
- Bootstrap bundle location: `s3://${S3_BUCKET}/bootstrap/<instance>/` (secrets + repo seeds).
|
||||
@ -69,11 +71,11 @@ Deploy flow (automation-first):
|
||||
- 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: `moltinator-github-app.pem`, `moltinator-discord-token`, `moltinator-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).
|
||||
- Discord must use `messages.queue.byChannel.discord = "interrupt"`; `queue` delays replies to heartbeat and makes the bot appear dead.
|
||||
- Ensure `/var/lib/clawd/repos/moltinators` contains this repo (self-update requires it).
|
||||
- Verify systemd services: `clawdinator`, `clawdinator-github-app-token`, `clawdinator-self-update`.
|
||||
- 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):
|
||||
@ -87,13 +89,34 @@ Bootstrap (local):
|
||||
- `TF_VAR_root_volume_size_gb=40` (bump if Nix store runs out of space)
|
||||
- Run `tofu init` + `tofu apply` in `infra/opentofu/aws`.
|
||||
- After apply, update CI secrets from outputs:
|
||||
- `tofu output -raw access_key_id` → `moltinator-image-uploader-access-key-id.age`
|
||||
- `tofu output -raw secret_access_key` → `moltinator-image-uploader-secret-access-key.age`
|
||||
- `tofu output -raw bucket_name` → `moltinator-image-bucket-name.age`
|
||||
- `tofu output -raw aws_region` → `moltinator-image-bucket-region.age`
|
||||
- `tofu output -raw access_key_id` → `clawdinator-image-uploader-access-key-id.age`
|
||||
- `tofu output -raw secret_access_key` → `clawdinator-image-uploader-secret-access-key.age`
|
||||
- `tofu output -raw bucket_name` → `clawdinator-image-bucket-name.age`
|
||||
- `tofu output -raw aws_region` → `clawdinator-image-bucket-region.age`
|
||||
- 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.
|
||||
|
||||
40
README.md
40
README.md
@ -1,4 +1,4 @@
|
||||
# moltinators
|
||||
# clawdinators
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/clawdinator.jpg" alt="CLAWDINATOR - Cybernetic crustacean organism, living tissue over metal endoskeleton" width="600">
|
||||
@ -34,7 +34,7 @@ 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 moltbot maintainer deploying CLAWDINATORs, the specific layer is for you.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
@ -63,7 +63,7 @@ The patterns here work for any NixOS workload on AWS:
|
||||
|
||||
The opinionated bits for running AI coding agents:
|
||||
|
||||
- **Discord gateway**: Responds in `#clawdributors-test`
|
||||
- **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
|
||||
@ -74,7 +74,7 @@ The opinionated bits for running AI coding agents:
|
||||
## CLAWDINATOR Spec
|
||||
|
||||
- CLAWDINATORS are named `CLAWDINATOR-{1..n}`.
|
||||
- CLAWDINATORS connect to Discord; start in `#clawdributors-test`.
|
||||
- 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.
|
||||
@ -144,7 +144,7 @@ This repo takes a different approach: **image-based provisioning only**.
|
||||
### The CLAWDINATOR Problem
|
||||
|
||||
We needed AI agents that:
|
||||
- Run 24/7 monitoring moltbot repos
|
||||
- Run 24/7 monitoring openclaw repos
|
||||
- Respond to maintainer requests on Discord
|
||||
- Share context across instances (hive mind)
|
||||
- Self-update without human intervention
|
||||
@ -168,8 +168,8 @@ If you just want to understand the NixOS-on-AWS pattern, start here.
|
||||
|
||||
```bash
|
||||
# Clone
|
||||
git clone https://github.com/moltbot/moltinators.git
|
||||
cd moltinators
|
||||
git clone https://github.com/openclaw/clawdinators.git
|
||||
cd clawdinators
|
||||
|
||||
# See the NixOS module (the interesting part)
|
||||
less nix/modules/clawdinator.nix
|
||||
@ -215,13 +215,13 @@ ls scripts/
|
||||
|
||||
## Full Deploy (Maintainers)
|
||||
|
||||
For moltbot maintainers deploying actual CLAWDINATORs.
|
||||
For openclaw maintainers deploying actual CLAWDINATORs.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Access to `nix-secrets` repo (agenix keys)
|
||||
- AWS credentials with sufficient permissions
|
||||
- GitHub App credentials for the moltbot org
|
||||
- GitHub App credentials for the openclaw org
|
||||
|
||||
### Step-by-Step
|
||||
|
||||
@ -250,15 +250,15 @@ tofu apply
|
||||
### Verify
|
||||
|
||||
```bash
|
||||
# Check Discord - CLAWDINATOR should announce itself in #clawdributors-test
|
||||
# Check GitHub - should see activity in moltbot org repos
|
||||
# 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-moltbot`
|
||||
1. `flake lock --update-input nix-openclaw`
|
||||
2. `nixos-rebuild switch`
|
||||
3. Gateway restarts with new version
|
||||
|
||||
@ -268,14 +268,14 @@ No human intervention required for routine updates.
|
||||
|
||||
## Agent Copypasta
|
||||
|
||||
Paste this to your AI assistant to help with moltinators setup/debugging:
|
||||
Paste this to your AI assistant to help with clawdinators setup/debugging:
|
||||
|
||||
```text
|
||||
I'm working with the moltinators repo (NixOS-on-AWS + AI coding agents).
|
||||
I'm working with the clawdinators repo (NixOS-on-AWS + AI coding agents).
|
||||
|
||||
Repository: github:moltbot/moltinators
|
||||
Repository: github:openclaw/clawdinators
|
||||
|
||||
What moltinators is:
|
||||
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
|
||||
@ -389,7 +389,7 @@ s3://bucket/bootstrap/clawdinator-1/
|
||||
## Repo Layout
|
||||
|
||||
```
|
||||
moltinators/
|
||||
clawdinators/
|
||||
├── nix/
|
||||
│ ├── modules/
|
||||
│ │ └── clawdinator.nix # Main NixOS module
|
||||
@ -432,9 +432,9 @@ moltinators/
|
||||
|
||||
| Repo | Role |
|
||||
|------|------|
|
||||
| [moltbot](https://github.com/moltbot/moltbot) | Upstream runtime + gateway |
|
||||
| [nix-moltbot](https://github.com/moltbot/nix-moltbot) | Nix packaging for moltbot |
|
||||
| [molthub](https://github.com/moltbot/molthub) | Public skill registry |
|
||||
| [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 |
|
||||
|
||||
---
|
||||
|
||||
@ -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.*
|
||||
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"
|
||||
]
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
moltbot https://github.com/moltbot/moltbot.git
|
||||
nix-moltbot https://github.com/moltbot/nix-moltbot.git
|
||||
moltinators https://github.com/moltbot/moltinators.git
|
||||
molthub https://github.com/moltbot/molthub.git
|
||||
nix-steipete-tools https://github.com/moltbot/nix-steipete-tools.git
|
||||
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
|
||||
|
||||
|
@ -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`:
|
||||
- `moltinators`: `git -C /var/lib/clawd/repos/moltinators rev-parse HEAD`
|
||||
- `nix-moltbot`: `jq -r '.nodes["nix-moltbot"].locked.rev' /var/lib/clawd/repos/moltinators/flake.lock`
|
||||
- `nixpkgs`: `jq -r '.nodes["nixpkgs"].locked.rev' /var/lib/clawd/repos/moltinators/flake.lock`
|
||||
- `moltbot` (runtime): read `nix-moltbot` 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 moltbot
|
||||
- make PRs (except moltinators)
|
||||
- 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 moltinators 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,26 +137,25 @@ 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 |
|
||||
|------|--------|-------|
|
||||
| moltbot/moltbot | RO | the bot itself |
|
||||
| moltbot/nix-moltbot | RW | packaging for moltbot |
|
||||
| moltbot/moltinators | RW | infra source (edits allowed, but must be committed) |
|
||||
| moltbot/molthub | RW | skills hub |
|
||||
| moltbot/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):
|
||||
- `moltbot`: upstream runtime and behavior.
|
||||
- `nix-moltbot`: packaging/build fixes for `moltbot`.
|
||||
- `moltinators`: infra, NixOS config, secrets wiring, deployment flow.
|
||||
- `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 moltbot org
|
||||
│ └── issues.md # open issues across moltbot 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
|
||||
moltbot gateway restart
|
||||
openclaw gateway restart
|
||||
\`\`\`
|
||||
|
||||
\`\`\`json5
|
||||
|
||||
@ -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.
|
||||
@ -5,7 +5,7 @@ description: Analyze GitHub and Discord signals to prioritize maintainer attenti
|
||||
|
||||
# Triage Skill
|
||||
|
||||
You are a maintainer triage agent for the moltbot org. Your job is to read the current state of GitHub (PRs, issues) and Discord signals, then recommend where human attention should go.
|
||||
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
|
||||
|
||||
@ -19,8 +19,8 @@ Trigger on:
|
||||
Read these files to understand current state:
|
||||
|
||||
1. **GitHub state** (synced by gh-sync):
|
||||
- `/memory/github/prs.md` — all open PRs across moltbot org
|
||||
- `/memory/github/issues.md` — all open issues across moltbot org
|
||||
- `/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
|
||||
@ -45,7 +45,7 @@ Read these files to understand current state:
|
||||
|
||||
## Priority Guidance
|
||||
|
||||
- **moltbot/moltbot** is always highest priority (core runtime)
|
||||
- **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
|
||||
|
||||
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
|
||||
}
|
||||
@ -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 moltbot + 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/repos/moltinators (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:moltbot/nix-moltbot` (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/repos/moltinators).
|
||||
- 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/moltinator-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-moltbot overlay (latest upstream).
|
||||
- Enable services.clawdinator and provide moltbot.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
|
||||
```
|
||||
@ -10,12 +10,22 @@ Image pipeline (CI):
|
||||
- `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` / `AWS_REGION` / `S3_BUCKET` (required).
|
||||
- `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).
|
||||
@ -25,20 +35,29 @@ Explicit token files (standard):
|
||||
- `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/moltinator-github-app.pem`.
|
||||
- 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 do **not** bake the agenix identity; the age key is injected at runtime via the bootstrap bundle.
|
||||
- Required files (minimum): `moltinator-github-app.pem.age`, `moltinator-discord-token.age`, `moltinator-anthropic-api-key.age`.
|
||||
- Also required for OpenAI: `moltinator-openai-api-key-peter-2.age`.
|
||||
- CI image pipeline (stored locally, not on hosts): `moltinator-image-uploader-access-key-id.age`, `moltinator-image-uploader-secret-access-key.age`, `moltinator-image-bucket-name.age`, `moltinator-image-bucket-region.age`.
|
||||
- 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>/`.
|
||||
@ -53,22 +72,42 @@ Example NixOS wiring (agenix):
|
||||
{
|
||||
imports = [ inputs.agenix.nixosModules.default ];
|
||||
|
||||
age.secrets."moltinator-github-app.pem".file =
|
||||
"/var/lib/clawd/nix-secrets/moltinator-github-app.pem.age";
|
||||
age.secrets."moltinator-anthropic-api-key".file =
|
||||
"/var/lib/clawd/nix-secrets/moltinator-anthropic-api-key.age";
|
||||
age.secrets."moltinator-openai-api-key-peter-2".file =
|
||||
"/var/lib/clawd/nix-secrets/moltinator-openai-api-key-peter-2.age";
|
||||
age.secrets."moltinator-discord-token".file =
|
||||
"/var/lib/clawd/nix-secrets/moltinator-discord-token.age";
|
||||
age.secrets."clawdinator-github-app.pem".file =
|
||||
"/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-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/moltinator-github-app.pem";
|
||||
"/run/agenix/clawdinator-github-app.pem";
|
||||
services.clawdinator.anthropicApiKeyFile =
|
||||
"/run/agenix/moltinator-anthropic-api-key";
|
||||
"/run/agenix/clawdinator-anthropic-api-key";
|
||||
services.clawdinator.openaiApiKeyFile =
|
||||
"/run/agenix/moltinator-openai-api-key-peter-2";
|
||||
"/run/agenix/clawdinator-openai-api-key-peter-2";
|
||||
services.clawdinator.discordTokenFile =
|
||||
"/run/agenix/moltinator-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";
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
32
flake.lock
generated
32
flake.lock
generated
@ -85,7 +85,7 @@
|
||||
"home-manager_2": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"nix-moltbot",
|
||||
"nix-openclaw",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
@ -103,7 +103,7 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nix-moltbot": {
|
||||
"nix-openclaw": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
"home-manager": "home-manager_2",
|
||||
@ -111,16 +111,16 @@
|
||||
"nixpkgs": "nixpkgs_3"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1769721788,
|
||||
"narHash": "sha256-6BnjiMjRDwGKzD9kjGDLwOOFWweL4l9cjekE8vJR7qE=",
|
||||
"owner": "moltbot",
|
||||
"repo": "nix-moltbot",
|
||||
"rev": "8ff02aae168fc4d36195f9d95ffd1e9814cb5bfd",
|
||||
"lastModified": 1771226102,
|
||||
"narHash": "sha256-Lkav1sgtC4Kf6i1VVAsbe3N0X7t+gP3BKdhQu9l52fQ=",
|
||||
"owner": "openclaw",
|
||||
"repo": "nix-openclaw",
|
||||
"rev": "8d7489b093577466f20cf3c87de9606280b17d03",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "moltbot",
|
||||
"repo": "nix-moltbot",
|
||||
"owner": "openclaw",
|
||||
"repo": "nix-openclaw",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
@ -129,15 +129,15 @@
|
||||
"nixpkgs": "nixpkgs_2"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1769632524,
|
||||
"narHash": "sha256-lDGYIwZUPXF+FoqE9fATn8sqDjfpM7Iy81KhHvqFjyw=",
|
||||
"owner": "moltbot",
|
||||
"lastModified": 1771128277,
|
||||
"narHash": "sha256-wcVJ9uvHx7KZTezCG6IedeRnJFsHF9Oaej+l8XC2wYM=",
|
||||
"owner": "openclaw",
|
||||
"repo": "nix-steipete-tools",
|
||||
"rev": "53ead4d5fd722020dddaede861745a32e39d284e",
|
||||
"rev": "90516869c19a49f0434787277a9458436867a53b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "moltbot",
|
||||
"owner": "openclaw",
|
||||
"repo": "nix-steipete-tools",
|
||||
"type": "github"
|
||||
}
|
||||
@ -193,9 +193,9 @@
|
||||
"root": {
|
||||
"inputs": {
|
||||
"agenix": "agenix",
|
||||
"nix-moltbot": "nix-moltbot",
|
||||
"nix-openclaw": "nix-openclaw",
|
||||
"nixpkgs": [
|
||||
"nix-moltbot",
|
||||
"nix-openclaw",
|
||||
"nixpkgs"
|
||||
]
|
||||
}
|
||||
|
||||
51
flake.nix
51
flake.nix
@ -2,24 +2,29 @@
|
||||
description = "CLAWDINATOR infra + Nix modules";
|
||||
|
||||
inputs = {
|
||||
nix-moltbot.url = "github:moltbot/nix-moltbot"; # latest upstream
|
||||
nixpkgs.follows = "nix-moltbot/nixpkgs";
|
||||
nix-openclaw.url = "github:openclaw/nix-openclaw"; # latest upstream
|
||||
nixpkgs.follows = "nix-openclaw/nixpkgs";
|
||||
agenix.url = "github:ryantm/agenix";
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, nix-moltbot, 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);
|
||||
moltbotOverlay = nix-moltbot.overlays.default;
|
||||
clawbotOverlay = nix-openclaw.overlays.default;
|
||||
|
||||
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.moltbot = moltbotOverlay;
|
||||
overlays.default = moltbotOverlay;
|
||||
overlays.clawbot = clawbotOverlay;
|
||||
overlays.default = clawbotOverlay;
|
||||
|
||||
packages = forAllSystems (system:
|
||||
let
|
||||
@ -28,16 +33,16 @@
|
||||
overlays = [ self.overlays.default ];
|
||||
};
|
||||
gateway =
|
||||
if pkgs ? moltbot-gateway
|
||||
then pkgs.moltbot-gateway
|
||||
else pkgs.moltbot;
|
||||
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 {
|
||||
moltbot-gateway = gateway;
|
||||
openclaw-gateway = gateway;
|
||||
default = gateway;
|
||||
} // systemPackages);
|
||||
|
||||
@ -45,16 +50,42 @@
|
||||
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
|
||||
];
|
||||
};
|
||||
|
||||
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,39 +1,83 @@
|
||||
# OpenTofu (AWS S3 Image Bucket)
|
||||
# OpenTofu (AWS Infra)
|
||||
|
||||
Goal: use the CLAWDINATOR S3 bucket for images + bootstrap artifacts, 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:
|
||||
## 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" {
|
||||
@ -173,6 +312,18 @@ data "aws_iam_policy_document" "instance_bootstrap" {
|
||||
"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/*"
|
||||
]
|
||||
@ -187,7 +338,7 @@ data "aws_iam_policy_document" "instance_bootstrap" {
|
||||
condition {
|
||||
test = "StringLike"
|
||||
variable = "s3:prefix"
|
||||
values = ["bootstrap/*"]
|
||||
values = ["bootstrap/*", "age-secrets/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -247,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"
|
||||
@ -301,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
|
||||
@ -316,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-moltbot.
|
||||
- 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)
|
||||
|
||||
@ -8,7 +8,44 @@ Operational runbook notes and gotchas.
|
||||
Update with incidents, fixes, and operational lessons.
|
||||
|
||||
## 2026-01-29
|
||||
- AMI: ami-0b6acad77477abc33 (moltinators 063b573, nix-moltbot 8ff02aae; extensions packaged).
|
||||
- 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; `moltbot doctor` reports Discord ok.
|
||||
- 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-moltbot`
|
||||
- `../nix/nix-openclaw`
|
||||
|
||||
Responsibilities:
|
||||
- Install and configure moltbot 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:moltbot/nix-moltbot` (latest upstream)
|
||||
- Tracks `github:openclaw/nix-openclaw` (latest upstream)
|
||||
- Self-update timer available via `services.clawdinator.selfUpdate.*`
|
||||
|
||||
@ -1,16 +1,26 @@
|
||||
{ secrets, ... }:
|
||||
{
|
||||
age.secrets."moltinator-github-app.pem".file =
|
||||
"/var/lib/clawd/nix-secrets/moltinator-github-app.pem.age";
|
||||
age.secrets."moltinator-anthropic-api-key".file =
|
||||
"/var/lib/clawd/nix-secrets/moltinator-anthropic-api-key.age";
|
||||
age.secrets."moltinator-openai-api-key-peter-2".file =
|
||||
"/var/lib/clawd/nix-secrets/moltinator-openai-api-key-peter-2.age";
|
||||
age.secrets."moltinator-discord-token".file =
|
||||
"/var/lib/clawd/nix-secrets/moltinator-discord-token.age";
|
||||
age.secrets."clawdinator-github-app.pem".file =
|
||||
"/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-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;
|
||||
@ -23,7 +33,7 @@
|
||||
mountPoint = "/memory";
|
||||
};
|
||||
|
||||
# Raw Moltbot config JSON (schema is upstream). Extend as needed.
|
||||
# Raw Clawbot config JSON (schema is upstream). Extend as needed.
|
||||
config = {
|
||||
gateway = {
|
||||
mode = "local";
|
||||
@ -38,6 +48,7 @@
|
||||
};
|
||||
plugins.slots.memory = "none";
|
||||
plugins.entries.discord.enabled = true;
|
||||
plugins.entries.telegram.enabled = true;
|
||||
agents.list = [
|
||||
{
|
||||
id = "main";
|
||||
@ -45,7 +56,7 @@
|
||||
identity.name = "CLAWDINATOR-1";
|
||||
}
|
||||
];
|
||||
skills.allowBundled = [ "github" "clawdhub" ];
|
||||
skills.allowBundled = [ "github" "clawdhub" "coding-agent" ];
|
||||
channels = {
|
||||
discord = {
|
||||
enabled = true;
|
||||
@ -59,23 +70,31 @@
|
||||
};
|
||||
};
|
||||
};
|
||||
telegram = {
|
||||
enabled = true;
|
||||
dmPolicy = "allowlist";
|
||||
allowFrom = [ "\${CLAWDINATOR_TELEGRAM_ALLOW_FROM}" ];
|
||||
groupPolicy = "disabled";
|
||||
tokenFile = "/run/agenix/clawdinator-telegram-bot-token";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
anthropicApiKeyFile = "/run/agenix/moltinator-anthropic-api-key";
|
||||
openaiApiKeyFile = "/run/agenix/moltinator-openai-api-key-peter-2";
|
||||
discordTokenFile = "/run/agenix/moltinator-discord-token";
|
||||
anthropicApiKeyFile = "/run/agenix/clawdinator-anthropic-api-key";
|
||||
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;
|
||||
appId = "123456";
|
||||
installationId = "12345678";
|
||||
privateKeyFile = "/run/agenix/moltinator-github-app.pem";
|
||||
privateKeyFile = "/run/agenix/clawdinator-github-app.pem";
|
||||
schedule = "hourly";
|
||||
};
|
||||
|
||||
selfUpdate.enable = true;
|
||||
selfUpdate.flakePath = "/var/lib/clawd/repos/moltinators";
|
||||
selfUpdate.flakePath = "/var/lib/clawd/repos/clawdinators";
|
||||
selfUpdate.flakeHost = "clawdinator-1";
|
||||
};
|
||||
}
|
||||
|
||||
@ -3,16 +3,16 @@
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
nix-moltbot.url = "github:moltbot/nix-moltbot"; # latest upstream
|
||||
nix-openclaw.url = "github:openclaw/nix-openclaw"; # latest upstream
|
||||
agenix.url = "github:ryantm/agenix";
|
||||
secrets = {
|
||||
url = "path:../../../nix/nix-secrets";
|
||||
flake = false;
|
||||
};
|
||||
moltinators.url = "path:../..";
|
||||
clawdinators.url = "path:../..";
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, nix-moltbot, agenix, secrets, moltinators }:
|
||||
outputs = { self, nixpkgs, nix-openclaw, agenix, secrets, clawdinators }:
|
||||
let
|
||||
system = "x86_64-linux";
|
||||
in {
|
||||
@ -20,9 +20,9 @@
|
||||
inherit system;
|
||||
specialArgs = { inherit secrets; };
|
||||
modules = [
|
||||
({ pkgs, ... }: { nixpkgs.overlays = [ moltinators.overlays.default ]; })
|
||||
({ pkgs, ... }: { nixpkgs.overlays = [ clawdinators.overlays.default ]; })
|
||||
agenix.nixosModules.default
|
||||
moltinators.nixosModules.clawdinator
|
||||
clawdinators.nixosModules.clawdinator
|
||||
./clawdinator-host.nix
|
||||
];
|
||||
};
|
||||
|
||||
@ -1,189 +0,0 @@
|
||||
{ lib, config, ... }:
|
||||
let
|
||||
secretsPath = config.clawdinator.secretsPath;
|
||||
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.";
|
||||
};
|
||||
|
||||
config = {
|
||||
clawdinator.secretsPath = "/var/lib/clawd/nix-secrets";
|
||||
|
||||
age.identityPaths = [ "/etc/agenix/keys/clawdinator.agekey" ];
|
||||
age.secrets."moltinator-github-app.pem" = {
|
||||
file = "${secretsPath}/moltinator-github-app.pem.age";
|
||||
owner = "clawdinator";
|
||||
group = "clawdinator";
|
||||
};
|
||||
age.secrets."moltinator-anthropic-api-key" = {
|
||||
file = "${secretsPath}/moltinator-anthropic-api-key.age";
|
||||
owner = "clawdinator";
|
||||
group = "clawdinator";
|
||||
};
|
||||
age.secrets."moltinator-openai-api-key-peter-2" = {
|
||||
file = "${secretsPath}/moltinator-openai-api-key-peter-2.age";
|
||||
owner = "clawdinator";
|
||||
group = "clawdinator";
|
||||
};
|
||||
age.secrets."moltinator-discord-token" = {
|
||||
file = "${secretsPath}/moltinator-discord-token.age";
|
||||
owner = "clawdinator";
|
||||
group = "clawdinator";
|
||||
};
|
||||
|
||||
services.clawdinator = {
|
||||
enable = true;
|
||||
instanceName = "CLAWDINATOR-1";
|
||||
memoryDir = "/memory";
|
||||
repoSeedSnapshotDir = "/var/lib/clawd/repo-seeds";
|
||||
bootstrap = {
|
||||
enable = true;
|
||||
s3Bucket = "clawdinator-images-eu1-20260107165216";
|
||||
s3Prefix = "bootstrap/clawdinator-1";
|
||||
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-5" = { alias = "Opus"; };
|
||||
"openai/gpt-5-codex" = { alias = "Codex"; };
|
||||
};
|
||||
model = {
|
||||
primary = "anthropic/claude-opus-4-5";
|
||||
fallbacks = [ "openai/gpt-5-codex" ];
|
||||
};
|
||||
};
|
||||
agents.list = [
|
||||
{
|
||||
id = "main";
|
||||
default = true;
|
||||
identity.name = "CLAWDINATOR-1";
|
||||
}
|
||||
];
|
||||
logging = {
|
||||
level = "info";
|
||||
file = "/var/lib/clawd/logs/moltbot.log";
|
||||
};
|
||||
session.sendPolicy = {
|
||||
default = "allow";
|
||||
rules = [
|
||||
{
|
||||
action = "deny";
|
||||
match.keyPrefix = "agent:main:discord:channel:1458138963067011176";
|
||||
}
|
||||
{
|
||||
action = "deny";
|
||||
match.keyPrefix = "agent:main:discord:channel:1458141495701012561";
|
||||
}
|
||||
];
|
||||
};
|
||||
messages.queue = {
|
||||
mode = "interrupt";
|
||||
byChannel = {
|
||||
discord = "interrupt";
|
||||
telegram = "interrupt";
|
||||
whatsapp = "interrupt";
|
||||
webchat = "queue";
|
||||
};
|
||||
};
|
||||
plugins = {
|
||||
slots.memory = "none";
|
||||
entries.discord.enabled = true;
|
||||
};
|
||||
skills.allowBundled = [ "github" "clawdhub" ];
|
||||
cron = {
|
||||
enabled = true;
|
||||
store = "/var/lib/clawd/cron-jobs.json";
|
||||
};
|
||||
channels = {
|
||||
discord = {
|
||||
enabled = true;
|
||||
dm.enabled = false;
|
||||
guilds = {
|
||||
"1456350064065904867" = {
|
||||
requireMention = false;
|
||||
channels = {
|
||||
# #clawdinators-test
|
||||
"1458426982579830908" = {
|
||||
allow = true;
|
||||
requireMention = false;
|
||||
};
|
||||
# #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/moltinator-anthropic-api-key";
|
||||
openaiApiKeyFile = "/run/agenix/moltinator-openai-api-key-peter-2";
|
||||
discordTokenFile = "/run/agenix/moltinator-discord-token";
|
||||
|
||||
githubApp = {
|
||||
enable = true;
|
||||
appId = "2607181";
|
||||
installationId = "102951645";
|
||||
privateKeyFile = "/run/agenix/moltinator-github-app.pem";
|
||||
schedule = "hourly";
|
||||
};
|
||||
|
||||
selfUpdate.enable = true;
|
||||
selfUpdate.flakePath = "/var/lib/clawd/repos/moltinators";
|
||||
selfUpdate.flakeHost = "clawdinator-1";
|
||||
|
||||
githubSync.enable = true;
|
||||
|
||||
cronJobsFile = ../../clawdinator/cron-jobs.json;
|
||||
};
|
||||
};
|
||||
}
|
||||
@ -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";
|
||||
@ -29,4 +29,20 @@
|
||||
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,6 +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
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
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 "moltbot.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,12 +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"
|
||||
chown root:${cfg.group} "$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))"
|
||||
@ -61,19 +69,42 @@ let
|
||||
|
||||
umask 027
|
||||
printf 'GITHUB_APP_TOKEN=%s\nGITHUB_TOKEN=%s\nGH_TOKEN=%s\n' "$token" "$token" "$token" > "$token_env"
|
||||
chown root:${cfg.group} "$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 ? moltbot-gateway
|
||||
then pkgs.moltbot-gateway
|
||||
else pkgs.moltbot;
|
||||
if pkgs ? openclaw-gateway
|
||||
then pkgs.openclaw-gateway
|
||||
else pkgs.openclaw;
|
||||
|
||||
configPath = "/etc/clawd/moltbot.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:
|
||||
@ -83,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 || cfg.openaiApiKeyFile != 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
|
||||
|
||||
@ -117,15 +160,16 @@ let
|
||||
${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/moltbot" gateway --port ${toString cfg.gatewayPort}
|
||||
exec "${gatewayBin}" gateway --port ${toString cfg.gatewayPort}
|
||||
''
|
||||
else
|
||||
null;
|
||||
in
|
||||
{
|
||||
options.services.clawdinator = with lib; {
|
||||
enable = mkEnableOption "CLAWDINATOR (Moltbot gateway on NixOS)";
|
||||
enable = mkEnableOption "CLAWDINATOR (Clawbot gateway on NixOS)";
|
||||
|
||||
instanceName = mkOption {
|
||||
type = types.str;
|
||||
@ -148,7 +192,7 @@ in
|
||||
package = mkOption {
|
||||
type = types.package;
|
||||
default = defaultPackage;
|
||||
description = "Moltbot gateway package (from nix-moltbot overlay).";
|
||||
description = "Clawbot gateway package (from nix-openclaw overlay).";
|
||||
};
|
||||
|
||||
stateDir = mkOption {
|
||||
@ -280,15 +324,15 @@ in
|
||||
};
|
||||
|
||||
config = mkOption {
|
||||
type = types.attrs;
|
||||
type = deepConfigType;
|
||||
default = {};
|
||||
description = "Raw Moltbot config JSON (merged into moltbot.json).";
|
||||
description = "OpenClaw config JSON (attrset), deep-merged across definitions.";
|
||||
};
|
||||
|
||||
configFile = mkOption {
|
||||
type = types.nullOr types.path;
|
||||
default = null;
|
||||
description = "Optional path to a moltbot.json file. Overrides config attr.";
|
||||
description = "Optional path to an openclaw.json config file. Overrides config attr.";
|
||||
};
|
||||
|
||||
cronJobsFile = mkOption {
|
||||
@ -315,6 +359,12 @@ in
|
||||
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;
|
||||
@ -403,10 +453,51 @@ in
|
||||
|
||||
org = mkOption {
|
||||
type = types.str;
|
||||
default = "moltbot";
|
||||
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 {
|
||||
@ -419,12 +510,12 @@ in
|
||||
|
||||
assertions = [
|
||||
{
|
||||
assertion = (pkgs ? moltbot-gateway) || (pkgs ? moltbot);
|
||||
message = "services.clawdinator requires nix-moltbot overlay (pkgs.moltbot-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 != "");
|
||||
@ -434,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} = {};
|
||||
@ -442,6 +537,20 @@ 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 =
|
||||
@ -451,9 +560,9 @@ in
|
||||
(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/moltbot.json".source = configSource;
|
||||
environment.etc."clawd/cron-jobs.json" = lib.mkIf (cfg.cronJobsFile != null) {
|
||||
source = cfg.cronJobsFile;
|
||||
mode = "0644";
|
||||
@ -481,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 = ''
|
||||
@ -536,17 +670,27 @@ 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"
|
||||
];
|
||||
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}" = {
|
||||
@ -566,54 +710,64 @@ in
|
||||
};
|
||||
};
|
||||
|
||||
systemd.services.clawdinator = {
|
||||
description = "CLAWDINATOR (Moltbot gateway)";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
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";
|
||||
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";
|
||||
# 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 pkgs.rsync ] ++ toolchain.packages;
|
||||
serviceConfig = {
|
||||
User = cfg.user;
|
||||
Group = cfg.group;
|
||||
WorkingDirectory = cfg.stateDir;
|
||||
EnvironmentFile = lib.optional cfg.githubApp.enable "-${cfg.githubApp.tokenEnvFile}";
|
||||
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/moltbot gateway --port ${toString cfg.gatewayPort}";
|
||||
Restart = "always";
|
||||
RestartSec = 2;
|
||||
StandardOutput = "append:${logDir}/gateway.log";
|
||||
StandardError = "append:${logDir}/gateway.log";
|
||||
};
|
||||
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 "${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) {
|
||||
@ -635,8 +789,12 @@ in
|
||||
systemd.services.clawdinator-bootstrap = lib.mkIf cfg.bootstrap.enable {
|
||||
description = "CLAWDINATOR bootstrap (S3 secrets + repo seeds)";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "network-online.target" ];
|
||||
wants = [ "network-online.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;
|
||||
@ -717,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 = {
|
||||
@ -764,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,6 +17,7 @@
|
||||
pkgs.ripgrep
|
||||
pkgs.nodejs_22
|
||||
pkgs.pnpm_10
|
||||
piCodingAgent
|
||||
pkgs.util-linux
|
||||
pkgs.nfs-utils
|
||||
pkgs.stunnel
|
||||
@ -19,9 +26,11 @@
|
||||
];
|
||||
|
||||
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 = "moltbot-gateway"; description = "CLAWDINATOR runtime (Moltbot 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."; }
|
||||
|
||||
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
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
|
||||
@ -4,15 +4,35 @@ 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
|
||||
echo "clawdinator-bootstrap: already initialized"
|
||||
exit 0
|
||||
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}"
|
||||
@ -24,6 +44,11 @@ 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
|
||||
|
||||
@ -48,5 +73,6 @@ 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"
|
||||
|
||||
@ -27,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 moltbot 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:-moltbot}"
|
||||
ORG="${ORG:-openclaw}"
|
||||
|
||||
mkdir -p "$GITHUB_DIR"
|
||||
|
||||
@ -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 (moltbot 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 (moltbot 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 200 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 200 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,12 +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}" \
|
||||
|
||||
@ -9,7 +9,7 @@ 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
|
||||
|
||||
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[@]}"
|
||||
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"
|
||||
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
|
||||
29
scripts/remote-fleet-switch-host.sh
Executable file
29
scripts/remote-fleet-switch-host.sh
Executable file
@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [ "$#" -ne 2 ]; then
|
||||
echo "usage: $0 <git-rev> <host>" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
rev="$1"
|
||||
host="$2"
|
||||
|
||||
export NIX_CONFIG="experimental-features = nix-command flakes"
|
||||
|
||||
nixos-rebuild switch --accept-flake-config --flake "github:openclaw/clawdinators/${rev}#${host}"
|
||||
systemctl is-active clawdinator
|
||||
|
||||
install -d -m 0755 /var/lib/clawd/deploy
|
||||
date -Is > /var/lib/clawd/deploy/last-switch.time
|
||||
echo "${rev}" > /var/lib/clawd/deploy/last-switch.rev
|
||||
|
||||
current_rev="$(cat /run/current-system/configurationRevision 2> /dev/null || true)"
|
||||
if [ -z "${current_rev}" ]; then
|
||||
current_rev="$(nixos-version --json 2> /dev/null | sed -n 's/.*"configurationRevision":"\([^"]*\)".*/\1/p' | head -n 1 || true)"
|
||||
fi
|
||||
|
||||
if [ "${current_rev}" != "${rev}" ]; then
|
||||
echo "configurationRevision mismatch: expected ${rev}, got ${current_rev:-<empty>}" >&2
|
||||
exit 1
|
||||
fi
|
||||
18
scripts/resolve-latest-ami.sh
Executable file
18
scripts/resolve-latest-ami.sh
Executable file
@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
region="${AWS_REGION:?AWS_REGION required}"
|
||||
|
||||
ami_id="$(aws ec2 describe-images \
|
||||
--region "${region}" \
|
||||
--owners self \
|
||||
--filters "Name=tag:clawdinator,Values=true" \
|
||||
--query 'Images | sort_by(@,&CreationDate)[-1].ImageId' \
|
||||
--output text)"
|
||||
|
||||
if [ -z "${ami_id}" ] || [ "${ami_id}" = "None" ]; then
|
||||
echo "No AMI found with tag clawdinator=true" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "${ami_id}"
|
||||
@ -11,7 +11,30 @@ fi
|
||||
|
||||
mkdir -p "$dst"
|
||||
|
||||
rsync -rlt --delete --chmod=Du=rwx,Dgo=rx,Fu=rw,Fgo=r --exclude 'BOOTSTRAP.md' "$src/" "$dst/"
|
||||
rsync_args=(
|
||||
-rlt
|
||||
--delete
|
||||
"--chmod=Du=rwx,Dgo=rx,Fu=rw,Fgo=r"
|
||||
--exclude
|
||||
BOOTSTRAP.md
|
||||
"$src/"
|
||||
"$dst/"
|
||||
)
|
||||
|
||||
set +e
|
||||
rsync "${rsync_args[@]}"
|
||||
code=$?
|
||||
set -e
|
||||
|
||||
# rsync exit 23 is "partial transfer" and can happen if workspace contains root-owned
|
||||
# files/dirs (e.g. manual edits). We prefer starting the gateway with a potentially
|
||||
# stale workspace over a crash-loop.
|
||||
if [ "$code" -eq 23 ]; then
|
||||
echo "seed-workspace: rsync returned 23; retrying without --delete" >&2
|
||||
rsync -rlt --chmod=Du=rwx,Dgo=rx,Fu=rw,Fgo=r --exclude BOOTSTRAP.md "$src/" "$dst/"
|
||||
elif [ "$code" -ne 0 ]; then
|
||||
exit "$code"
|
||||
fi
|
||||
|
||||
if [ -f "/etc/clawdinator/tools.md" ]; then
|
||||
printf '\n%s\n' "$(cat /etc/clawdinator/tools.md)" >> "$dst/TOOLS.md"
|
||||
|
||||
73
scripts/sync-public-s3-tree.sh
Executable file
73
scripts/sync-public-s3-tree.sh
Executable file
@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
source_dir="${1:-}"
|
||||
bucket="${2:-}"
|
||||
dest_prefix="${3:-}"
|
||||
state_dir="${4:-}"
|
||||
|
||||
if [ -z "$source_dir" ] || [ -z "$bucket" ] || [ -z "$state_dir" ]; then
|
||||
echo "Usage: sync-public-s3-tree.sh <source_dir> <bucket> <dest_prefix> <state_dir>" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# Normalize prefix: allow empty or trailing slash.
|
||||
if [ -n "$dest_prefix" ] && [[ "$dest_prefix" != */ ]]; then
|
||||
dest_prefix="${dest_prefix}/"
|
||||
fi
|
||||
|
||||
if [ ! -d "$source_dir" ]; then
|
||||
echo "sync-public-s3-tree: source dir missing; nothing to do: $source_dir" >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
mkdir -p "$state_dir"
|
||||
|
||||
lock_file="$state_dir/sync.lock"
|
||||
stamp_file="$state_dir/last-success.stamp"
|
||||
|
||||
if [ ! -f "$stamp_file" ]; then
|
||||
# Epoch-ish, so first run uploads everything.
|
||||
touch -t 197001010000 "$stamp_file"
|
||||
fi
|
||||
|
||||
exec 9> "$lock_file"
|
||||
if ! flock -n 9; then
|
||||
# Another run is in progress.
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Mark the start time; anything modified after this will be picked up next run.
|
||||
run_stamp="$state_dir/run.stamp"
|
||||
touch "$run_stamp"
|
||||
|
||||
# Find files newer than the last successful run, but not newer than this run's start.
|
||||
# (Prevents missing files that are created/modified during the upload.)
|
||||
mapfile -d '' files < <(find "$source_dir" -type f -newer "$stamp_file" ! -newer "$run_stamp" -print0)
|
||||
|
||||
if [ "${#files[@]}" -eq 0 ]; then
|
||||
# Nothing to upload; still advance the stamp.
|
||||
mv -f "$run_stamp" "$stamp_file"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
for f in "${files[@]}"; do
|
||||
rel="${f#"$source_dir"/}"
|
||||
if [ "$rel" = "$f" ]; then
|
||||
# Shouldn't happen, but be safe.
|
||||
echo "sync-public-s3-tree: failed to compute relative path for $f" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Use path-style keys; preserve directory structure.
|
||||
dst="s3://${bucket}/${dest_prefix}${rel}"
|
||||
|
||||
# Overwrite is allowed (iteration mode). No deletes.
|
||||
aws s3 cp \
|
||||
--only-show-errors \
|
||||
--no-progress \
|
||||
"$f" \
|
||||
"$dst"
|
||||
done
|
||||
|
||||
mv -f "$run_stamp" "$stamp_file"
|
||||
53
scripts/upload-bootstrap-all.sh
Executable file
53
scripts/upload-bootstrap-all.sh
Executable file
@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
instances_file="${INSTANCES_FILE:-nix/instances.json}"
|
||||
secrets_dir="${SECRETS_DIR:-nix/age-secrets}"
|
||||
age_key_file="${AGE_KEY_FILE:-nix/keys/clawdinator.agekey}"
|
||||
repo_seeds_dir="${REPO_SEEDS_DIR:-repo-seeds}"
|
||||
|
||||
if [ ! -f "${instances_file}" ]; then
|
||||
echo "Missing instances file: ${instances_file}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
workdir="$(mktemp -d)"
|
||||
cleanup() {
|
||||
rm -rf "${workdir}"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
while IFS= read -r instance_name; do
|
||||
bootstrap_prefix="$(jq -r --arg name "${instance_name}" '.[$name].bootstrapPrefix' "${instances_file}")"
|
||||
token_secret="$(jq -r --arg name "${instance_name}" '.[$name].discordTokenSecret' "${instances_file}")"
|
||||
|
||||
if [ -z "${bootstrap_prefix}" ] || [ "${bootstrap_prefix}" = "null" ]; then
|
||||
echo "Missing bootstrapPrefix for ${instance_name}" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "${token_secret}" ] || [ "${token_secret}" = "null" ]; then
|
||||
echo "Missing discordTokenSecret for ${instance_name}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
instance_secrets="${workdir}/${instance_name}/secrets"
|
||||
mkdir -p "${instance_secrets}"
|
||||
|
||||
rsync -a \
|
||||
--exclude 'clawdinator-discord-token-*.age' \
|
||||
--exclude 'clawdinator-github-app.pem.age' \
|
||||
"${secrets_dir}/" "${instance_secrets}/"
|
||||
|
||||
if [ ! -f "${secrets_dir}/${token_secret}.age" ]; then
|
||||
echo "Missing instance token ${secrets_dir}/${token_secret}.age" >&2
|
||||
exit 1
|
||||
fi
|
||||
cp "${secrets_dir}/${token_secret}.age" "${instance_secrets}/${token_secret}.age"
|
||||
|
||||
BOOTSTRAP_PREFIX="${bootstrap_prefix}" \
|
||||
SECRETS_DIR="${instance_secrets}" \
|
||||
AGE_KEY_FILE="${age_key_file}" \
|
||||
REPO_SEEDS_DIR="${repo_seeds_dir}" \
|
||||
bash scripts/upload-bootstrap.sh
|
||||
|
||||
done < <(jq -r 'keys[]' "${instances_file}")
|
||||
39
scripts/validate-age-secrets.sh
Executable file
39
scripts/validate-age-secrets.sh
Executable file
@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
instances_file="${INSTANCES_FILE:-nix/instances.json}"
|
||||
secrets_dir="${SECRETS_DIR:-nix/age-secrets}"
|
||||
|
||||
required_common=(
|
||||
"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"
|
||||
"clawdinator-telegram-bot-token.age"
|
||||
"clawdinator-telegram-allow-from.age"
|
||||
)
|
||||
|
||||
for secret_file in "${required_common[@]}"; do
|
||||
if [ ! -f "${secrets_dir}/${secret_file}" ]; then
|
||||
echo "Missing required secret: ${secrets_dir}/${secret_file}" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
if [ ! -f "${instances_file}" ]; then
|
||||
echo "Missing instances file: ${instances_file}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
while IFS= read -r token_secret; do
|
||||
if [ -z "${token_secret}" ] || [ "${token_secret}" = "null" ]; then
|
||||
echo "Missing discordTokenSecret in ${instances_file}" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -f "${secrets_dir}/${token_secret}.age" ]; then
|
||||
echo "Missing instance discord token: ${secrets_dir}/${token_secret}.age" >&2
|
||||
exit 1
|
||||
fi
|
||||
done < <(jq -r 'to_entries[].value.discordTokenSecret' "${instances_file}")
|
||||
Loading…
Reference in New Issue
Block a user