Compare commits

...

121 Commits

Author SHA1 Message Date
joshp123
280744ce0c infra: slim clawdinators aws footprint
What:
- bound CLAWDINATOR image artifact retention with S3 lifecycle, AMI pruning, and import provenance tags
- reduce the AWS fleet to Babelfish-only and make GitHub credentials opt-in per host
- disable the AMI build, nix-openclaw bump, and release workflows by moving them out of .github/workflows/
- update operator docs for the new explicit build and deploy model

Why:
- stop unbounded S3 and snapshot growth from image builds
- remove unattended resurrection paths and shut down the unused t3.large instances
- keep the remaining Babelfish host running without GitHub App credentials or sync timers

Tests:
- `nix shell nixpkgs#shellcheck nixpkgs#shfmt -c bash scripts/lint-shell.sh` (pass)
- `nix build .#nixosConfigurations.clawdinator-babelfish.config.system.build.toplevel .#nixosConfigurations.clawdinator-1.config.system.build.toplevel .#nixosConfigurations.clawdinator-2.config.system.build.toplevel` (pass)
- `AWS_PROFILE=homelab-admin TF_VAR_aws_region=eu-central-1 TF_VAR_ami_id=ami-0a9abe17feeee0079 TF_VAR_ssh_public_key="$(cat ~/.ssh/id_ed25519.pub)" nix shell nixpkgs#opentofu -c sh -lc 'tofu fmt -check && tofu validate'` (pass)
- live AWS apply: destroyed `clawdinator-1` and `clawdinator-2`, replaced Babelfish, and verified only `Fleet Deploy` remains active in GitHub Actions
2026-04-03 15:38:57 +02:00
joshp123
4a40ae24e2 🤖 config: restrict main clawdinator discord scope to clawdinators-test
What:
- remove #clawdributors-test and #clawdributors channel IDs from `nix/hosts/clawdinator-common.nix`
- keep only channel `1458426982579830908` (#clawdinators-test) in the main Discord allowlist
- simplify now-unused sendPolicy deny rules tied to removed channels
- align docs/memory/workspace references to #clawdinators-test only

Why:
- enforce single-channel listening surface for main clawdinator instances
- eliminate stale channel references that could cause operator confusion
- keep runtime config and docs aligned

Tests:
- nix shell nixpkgs#shellcheck nixpkgs#shfmt -c bash scripts/lint-shell.sh (pass)
- nix eval --raw .#nixosConfigurations.clawdinator-1.config.system.build.toplevel.drvPath --accept-flake-config >/dev/null (pass)
- nix eval --raw .#nixosConfigurations.clawdinator-2.config.system.build.toplevel.drvPath --accept-flake-config >/dev/null (pass)
2026-02-23 17:20:38 +01:00
joshp123
33755bec7a 🤖 fix: remove inline remote deploy logic from fleet switch
What:
- move host-side nixos switch + revision verification into scripts/remote-fleet-switch-host.sh
- update scripts/fleet-switch-nixos.sh to fetch and execute the committed remote script at the target git rev
- keep canary host loop behavior unchanged while eliminating inline remote bash payload logic

Why:
- prevent local shell interpolation bugs in deploy assertions
- align deploy flow with repo rule: put logic in script files and call them
- make host-side deploy verification easier to audit and reason about

Tests:
- nix shell nixpkgs#shellcheck nixpkgs#shfmt -c sh -c "find scripts -type f -name *.sh -print0 | xargs -0 shellcheck -S warning && find scripts -type f -name *.sh -print0 | xargs -0 shfmt -i 2 -ci -sr -d"
2026-02-16 08:59:22 -08:00
joshp123
5446b35ffe 🤖 chore: bump nix-openclaw to openai reasoning replay fix
What:
- update flake.lock nix-openclaw input from 2a9a3be to 8d7489b
- pull in openclaw pin bump that includes PR #17792 reasoning replay follower-id fix

Why:
- propagate the merged openclaw replay fix through nix-openclaw into clawdinators
- keep deployment source-of-truth aligned across the repo chain

Tests:
- nix flake lock --update-input nix-openclaw
- nix eval .#nixosConfigurations.clawdinator-1.config.system.configurationRevision --raw
- nix build .#openclaw-gateway (fails on darwin: attribute not provided for current system)
2026-02-15 23:15:45 -08:00
joshp123
9d1ee1023e Disable control API in fleet deploy workflow
CI user cannot create IAM roles/users required for the control API. Keep TF_VAR_control_api_enabled=false so fleet AMI redeploys are reliable.
2026-02-15 18:29:39 -08:00
joshp123
0f7e6570eb Fix CI S3 permissions for pr-intent bucket reads
Terraform refresh calls GetAccelerateConfiguration (and other non-GetBucket* APIs). Grant s3:Get*/s3:Put* on the pr-intent bucket ARN so fleet deploy tofu apply can refresh bucket config.
2026-02-15 18:08:05 -08:00
joshp123
ce846a36dc Switch CI IAM policy to managed policy
Inline IAM user policies hit the 2048 byte size limit. Replace aws_iam_user_policy with an aws_iam_policy + aws_iam_user_policy_attachment for the CI user.
2026-02-15 17:55:28 -08:00
joshp123
233d0d6da8 Fix pr-intent bucket name default
Default pr_intent_bucket_name to openclaw-pr-intent to match the live bucket and avoid accidental bucket replacement.
2026-02-15 17:53:45 -08:00
joshp123
7dbedacdff Fix CI tofu permissions for pr-intent public bucket
Grant CI user bucket-management read/write actions (GetBucket*/PutBucket*) on the public PR-intent bucket so fleet deploy can run tofu apply without AccessDenied.
2026-02-15 17:52:21 -08:00
joshp123
833264bbe3 Make seed-workspace resilient to permission drift
Retry rsync without --delete on exit 23 so the gateway does not crash-loop if workspace contains root-owned files.
2026-02-15 17:15:12 -08:00
joshp123
6cd6b7fada Fix jq precedence in fleet-status
Wrap defaulting expression in parentheses so jq parses correctly.
2026-02-15 17:13:09 -08:00
joshp123
028880fef3 Inline shell lint into release/image workflows
Drop dedicated shell-lint workflow; run scripts/lint-shell.sh as an early step in release.yml and image-build.yml.
2026-02-15 15:56:37 -08:00
joshp123
52f5168cd2 Add shellcheck + shfmt linting for scripts
Add CI workflow to run shellcheck + shfmt, plus a scripts/lint-shell.sh helper.

Also apply shfmt formatting and fix initial shellcheck warnings.
2026-02-15 15:51:40 -08:00
joshp123
c44d54319e Stamp deploy time and enrich version output
After nixos-rebuild switch, write /var/lib/clawd/deploy/last-switch.{time,rev}.

clawdinator-version now optionally fetches OpenClaw commit date via GitHub API when gh is authenticated.
2026-02-15 15:47:39 -08:00
joshp123
55788b92ff Teach workspace how to report running versions
Switch startup checklist to use clawdinator-version and drop the deprecated self-update unit check.
2026-02-15 15:46:14 -08:00
joshp123
eb3c79c5f5 Add version introspection tool + build info
Expose pinned component revs via /etc/clawdinator/build-info.json and ship a clawdinator-version helper script (logic lives in scripts/, not inline in Nix).

This supports fleet consistency checks and maintainer introspection.
2026-02-15 15:45:00 -08:00
joshp123
c3fd19af9f Disable host-local flake self-update
Rely on CI release pipeline for pinned, consistent versions across the fleet.
2026-02-15 15:33:13 -08:00
joshp123
3d64364853 Enable amazon-ssm-agent on hosts
Needed for SSM-based fleet deploy workflow.
2026-02-15 15:32:09 -08:00
joshp123
e126e33d54 Stamp deployed revision and verify after switch
Set system.configurationRevision from flake rev and have fleet switch verify it matches the deployed git SHA.
2026-02-15 15:31:39 -08:00
joshp123
e549dca9fd Fix SSM send-command quoting
Pass commands via JSON to avoid AWS CLI argument parsing issues.
2026-02-15 15:30:01 -08:00
joshp123
9245311395 Add fast release pipeline (bootstrap + SSM nixos-rebuild)
- Add release.yml: eval -> upload bootstrap -> deploy via SSM (canary order)
- Make image-build manual/weekly (base AMI lane)
- Add SSM permissions to CI IAM policy (requires tofu apply)
- Add scripts for SSM-based nixos-rebuild and docs for the two-lane model
2026-02-15 15:22:27 -08:00
joshp123
d7df4f0e13 Fix openclaw-gateway unit override merge
Remove conflicting systemd unit description override; keep only after/wants deps.
2026-02-15 15:03:56 -08:00
joshp123
fda12f98cb Use nix-openclaw NixOS module for gateway service
- Import nix-openclaw nixosModules.openclaw-gateway
- Replace custom systemd gateway service with upstream module
- Let upstream module own /etc/clawd/openclaw.json generation

This reduces duplication between clawdinators and nix-openclaw and aligns config merge semantics.
2026-02-15 14:56:00 -08:00
joshp123
c0794f84e2 Deep-merge OpenClaw config to avoid per-host clobber
Replace configFragments with a deep-merged config option type so host overrides (e.g. disabling telegram) don't drop sibling keys like channels.discord.

clawdinator-2: disable telegram; keep discord.
2026-02-15 14:24:54 -08:00
joshp123
e5e959f90a CI concurrency + deep-merge config fragments; fix clawdinator-2 channels
- Cancel in-progress image builds on new pushes (concurrency)
- Add services.clawdinator.configFragments for deep-merge tweaks
- Use configFragments in clawdinator-2 to disable telegram without clobbering discord

No host changes; intended to ship via next AMI build.
2026-02-15 13:33:38 -08:00
joshp123
5e1977a078 Un-deprecate landpr; hide distill skills from slash; seed ClawKeeper repo
- Revert accidental /landpr deprecation language and restore model invocation
- Make distill-pr-intent skills non user-invocable (still available to the model)
- Add openclaw/ClawKeeper to repo seeds list for AMI snapshots
2026-02-15 13:20:52 -08:00
joshp123
c3a4b7dbf1 Deprecate landpr from model prompt
Keep /landpr slash command available but prevent autonomous model invocation; steer default PR workflows to PR intent distillation.
2026-02-15 13:13:58 -08:00
joshp123
4bd99e8821 Tighten PR canned-response workflow guardrails
Make explicit-approval requirement unambiguous in workspace prompt.
2026-02-15 13:13:04 -08:00
joshp123
ac61f9551d Add PR intent distillation skills + prompt wiring
- Bundle distill-pr-intent + orchestrator as workspace skills
- Update CLAWDINATOR workspace AGENTS.md to reference dataset paths and skills
2026-02-15 13:11:11 -08:00
joshp123
5f99924bd1 Fix public S3 publisher service PATH
Include bash in systemd unit PATH so sync script runs on NixOS.
2026-02-15 12:45:24 -08:00
joshp123
ffb27ab614 Public PR intent S3 bucket + publisher timer
- Provision public S3 bucket (anonymous list/get) for PR intent artifacts
- Grant instance role PutObject and add NixOS systemd timer to publish /memory/pr-intent
- Default agent thinking level to high for GPT-5.2/Codex
- Make OpenTofu instance management explicit (manage_instances) to prevent accidental fleet destroy

Tests: not run (infra/Nix changes)
2026-02-15 12:44:11 -08:00
Josh Palmer
63fa64a0b1 tools: bump pi-coding-agent to 0.52.6
Why: keep fleet pi in sync with upstream fixes and model registry updates.

Notes:
- package-lock pins internal @mariozechner deps to 0.52.6 for determinism.

Tests:
- nix build pi-coding-agent derivation (darwin)
2026-02-06 09:59:21 -08:00
Josh Palmer
e1d3009c30 tools: bump pi-coding-agent to 0.52.0
Update the pinned pi CLI used on hosts so it matches local (0.52.x) and includes the openai-codex provider/model registry.
2026-02-05 18:45:02 -08:00
Josh Palmer
deebc3431f clawdinator: use claude-opus-4-6
Update default Anthropic fallback model to claude-opus-4-6 (available per Anthropic models API).
2026-02-05 17:50:11 -08:00
Josh Palmer
3bac990eca clawdinator: bump Opus model pin
Use the latest claude-opus-4-5 snapshot model ID in the default agent fallback list.
2026-02-05 16:47:48 -08:00
Josh Palmer
52198c23cb flake.lock: bump nix-openclaw
Pick up nix-openclaw updates (openclaw→pi pin bump).
2026-02-05 13:11:19 -08:00
Josh Palmer
aeeb41632e mem: name babelfish tech-中国 channel
Tests: not run (doc update)
2026-02-05 07:59:15 -08:00
Josh Palmer
1a64384a48 babelfish: allow second discord channel
Adds channel 1468983176620675132 to the babelfish allowlist.

Tests: not run (config/docs change)
2026-02-05 07:12:21 -08:00
Josh Palmer
7233368238 clawdinator-2: disable telegram
Prevent c2 from polling Telegram; c1 retains the token.

Tests: not run (config change)
2026-02-04 16:29:35 -08:00
Josh Palmer
dbda75c1df ops: log babelfish thread-starter redeploy
- record new babelfish instance after includeThreadStarter rollout

Tests: not run (doc update)
2026-02-04 14:30:49 -08:00
Josh Palmer
4884b6b65f clawdinator-babelfish: disable thread starter context
- bump nix-openclaw input for includeThreadStarter support
- disable thread starter injection for babelfish forum channel

Tests: not run (config change)
2026-02-04 14:20:19 -08:00
Josh Palmer
76e10eb42c ops: log babelfish forum-trim redeploy
Record new babelfish instance after context-trimming prompt.
2026-02-04 13:06:51 -08:00
Josh Palmer
debd806389 nix: trim babelfish context
Ignore thread starter metadata and disable envelope timestamps for forum threads.
2026-02-04 12:57:38 -08:00
Josh Palmer
509ee48696 ops: log babelfish sandbox redeploy
Record new babelfish instance after docker sandbox fix.
2026-02-04 12:48:54 -08:00
Josh Palmer
65cc44486f nix: disable babelfish sandbox
Avoid docker sandbox spawn so babelfish can reply.
2026-02-04 12:39:59 -08:00
Josh Palmer
d846155ad0 ops: log babelfish redeploy
Record new babelfish instance after config fix.
2026-02-04 12:31:51 -08:00
Josh Palmer
9bdf5a611a nix: fix babelfish config schema
- move tools to top-level, drop unsupported systemPrompt

- set groupChat historyLimit to 1
2026-02-04 12:25:33 -08:00
Josh Palmer
7a3ae4423a docs: capture discord channel ids
- add guild + babelfish channel ids
2026-02-04 12:20:26 -08:00
Josh Palmer
59bef8196b ops: record babelfish deploy
- add AMI + instance/IP for clawdinator-babelfish
2026-02-04 12:19:51 -08:00
Josh Palmer
446bad9107 nix: enable github app for babelfish
- satisfy clawdinator token assertion while keeping tools disabled
2026-02-04 12:08:41 -08:00
Josh Palmer
edfb499d52 nix: add babelfish host
- add clawdinator-babelfish instance + t3.small sizing

- define translation-only openclaw config
2026-02-04 11:47:01 -08:00
Josh Palmer
a01294cae7 ops: record clawdinator-2 AMI/IP
- add AMI ami-0ae43cb24200e1665 + instance i-00fe5c0c6372baaf3
- note amazon-init completion + transient hostname state

Tests: n/a
2026-02-04 07:28:31 -08:00
Josh Palmer
94fe55e819 infra: force oneshot rerun
- restart bootstrap/repo-seed to honor new prefix

- wait for new ExecMainStartTimestamp
2026-02-04 06:31:17 -08:00
Josh Palmer
6b0d5c7fe2 infra: ensure oneshot actually ran
- gate inactive success on ExecMainStartTimestampMonotonic

- avoid pre-run success shortcut
2026-02-04 06:03:37 -08:00
Josh Palmer
c37a235393 infra: handle oneshot service success
- treat inactive+Result=success as completed

- avoid timeouts for oneshot repo-seed/bootstrap
2026-02-04 00:12:06 -08:00
Josh Palmer
2f521d51f6 infra: fix userdata template vars
- avoid HCL template interpolation in wait_for_service

- keep wait loop compatible with set -u
2026-02-03 20:40:07 -08:00
Josh Palmer
4feadcceec infra: wait for bootstrap with poll
- replace systemctl --wait with polling loop
2026-02-03 20:19:44 -08:00
Josh Palmer
5e0ad83959 infra: wait for bootstrap before rebuild
- block user-data until bootstrap + repo seed complete
2026-02-03 19:50:58 -08:00
Josh Palmer
0445635ae6 infra: rebootstrap on prefix change
- record bootstrap prefix in secrets dir

- reset secrets when prefix differs
2026-02-03 18:27:30 -08:00
Josh Palmer
0456fa91ec infra: avoid amazon-init bootstrap deadlock
- drop amazon-init ordering for clawdinator-bootstrap

- wait on fetch-ec2-metadata when available
2026-02-03 17:43:09 -08:00
Josh Palmer
634f7fc0ce docs: enforce AMI rebuild
- note manual host fixes require rebuild + redeploy
2026-02-03 17:02:15 -08:00
Josh Palmer
1384ee7b47 infra: restore ec2 user-data fetch
- add fetch-ec2-metadata service for AMI bootstrap

- set git safe.directory for nixos-rebuild

- note clawdinator-2 recovery in ops
2026-02-03 16:36:21 -08:00
Josh Palmer
b54453c593 ops: record clawdinator-2 redeploy
- update instance id + ip after fleet deploy
2026-02-03 15:31:33 -08:00
Josh Palmer
7e346f3086 infra: allow CI instance replace
- grant ec2 run/terminate + pass instance role
2026-02-03 15:24:55 -08:00
Josh Palmer
92ab45da18 infra: widen CI bucket read
- allow s3:Get* on bucket
2026-02-03 15:22:02 -08:00
Josh Palmer
1c2508b781 infra: allow CI bucket/lambda reads
- add s3 accelerate + lambda get/list permissions
2026-02-03 15:19:06 -08:00
Josh Palmer
72db2fd5de infra: allow CI infra reads
- add bucket + infra describe/read perms for fleet deploy
2026-02-03 15:16:44 -08:00
Josh Palmer
20139a56d2 infra: allow CI lock table access
- grant dynamodb permissions for tofu state lock
2026-02-03 15:13:23 -08:00
Josh Palmer
16482a9eb3 config: disable mention regex fallback
- require explicit Discord mentions
2026-02-03 14:59:19 -08:00
Josh Palmer
6c7ddee942 config: require mentions per guild
- set guild-level requireMention=true
2026-02-03 14:32:04 -08:00
Josh Palmer
bf65890259 docs: update ops memory
- note 2026-02-03 fleet AMI + IPs
2026-02-03 14:24:07 -08:00
Josh Palmer
f127c39d90 config: require mentions in clawdinators-test
- set requireMention=true for #clawdinators-test
2026-02-03 14:06:51 -08:00
Josh Palmer
ba96cfbebf infra: allow control invoker describe
- grant ec2:DescribeInstances for fleet status
2026-02-03 13:58:11 -08:00
Josh Palmer
bda89e4c97 infra: allow instance self-bootstrap
- grant instance role read age-secrets
- allow PutObject to bootstrap prefix
2026-02-03 13:44:10 -08:00
Josh Palmer
e869c7b5a7 fix: move fleet status local
- drop AWS SDK from control api
- fetch status via AWS CLI in fleet control
- update control plane docs
2026-02-03 12:46:41 +01:00
Josh Palmer
4fd6ab11e4 feat: control api invoke creds
- add lambda invoke IAM user + outputs
- update fleet control to invoke lambda directly
- wire new control access-key secrets
- update docs + secrets guidance
2026-02-03 11:10:39 +01:00
Josh Palmer
c8d54bfc24 fix: allow public lambda url invoke
- drop function_url_auth_type condition for control api
2026-02-03 10:52:42 +01:00
Josh Palmer
8e5f256e96 fix: control api auth header
- use X-Clawdinator-Token header for lambda url
- update fleet control script + docs
- adjust control api archive path
2026-02-03 10:43:27 +01:00
Josh Palmer
f1daf782f6 infra: fix tofu init + archive provider
- read instances.json from repo root
- add archive provider to versions.tf
- update .terraform.lock.hcl
2026-02-03 01:37:09 +01:00
Josh Palmer
56ffff4010 chore: fix workflow token secret name
- use CLAWDINATOR_WORKFLOW_TOKEN instead of GITHUB_WORKFLOW_TOKEN
- update infra README
2026-02-03 01:32:30 +01:00
Josh Palmer
05d43b1926 infra: add fleet control api + multi-instance
- add control API Lambda + fleet deploy workflow
- introduce instances registry + common host config
- add fleet control skill + scripts
- update bootstrap bundles + secrets docs
- wire OpenTofu for multi-instance + user-data
2026-02-03 01:20:23 +01:00
Josh Palmer
c373a14bb4 🤖 infra: lock telegram allowlist + close gateway ingress
What:
- add telegram allowFrom secret wiring in the Nix module + host configs
- enforce Telegram DM allowlist and disable group traffic
- drop public gateway ingress (SG + firewall)
- document telegram secret handling and update ops memory

Why:
- restrict Telegram access to a single maintainer
- remove unnecessary public exposure for the gateway port
- keep secret wiring explicit in docs

Tests:
- nix flake check
2026-02-03 00:07:33 +01:00
Josh Palmer
8f2cf7a58d Switch pi defaults to OpenAI API provider
- set pi default provider to openai for API key auth
- stop writing openai-codex into auth.json

Tests: manual
- pi -p --provider openai --model gpt-5.2-codex "ping"
- pi -p --provider anthropic --model claude-opus-4-5 "ping"
2026-02-02 17:22:25 +01:00
Josh Palmer
fbd6dc2118 Seed pi auth.json from agenix keys
- add pi-auth script to build auth.json at runtime
- wire clawdinator-pi-auth service after agenix

Tests: not run (config/script only)
2026-02-02 17:08:44 +01:00
Josh Palmer
8a1deeed09 Add pi coding agent tool + settings
- package pi-coding-agent with a vendored lockfile
- add pi to toolchain and expose settings file via tmpfiles
- seed pi defaults to match local model choices

Tests: nix build --no-link --impure --expr 'let pkgs = import <nixpkgs> {}; in pkgs.callPackage ./nix/tools/pi-coding-agent.nix {}'
2026-02-02 16:47:40 +01:00
Josh Palmer
77b2cef22f Enable coding-agent bundled skill
- allow coding-agent in bundled skill allowlist for prod + example config
- unblocks coding-agent skill visibility on hosts

Tests: not run (config only)
2026-02-02 16:24:45 +01:00
Josh Palmer
4b7eb80f2c Update canned-response paths in workspace AGENTS
- point canned-response references at repo absolute paths
- avoid missing-file lookups from workspace

Tests: not run (docs only)
2026-02-02 15:31:42 +01:00
Josh Palmer
eb491737a1 docs: add intel note to stale closure 2026-02-01 22:36:43 +01:00
Josh Palmer
61c64d3ddb docs: clarify PR closure intel note
- move intel aggregation note earlier in each closure variant
- keep tone consistent across variants
2026-02-01 22:35:42 +01:00
Josh Palmer
929b2cde70 config: default to gpt-5.2-codex
- set gpt-5.2-codex as primary model
- keep opus as fallback
- update model alias list
2026-02-01 18:07:17 +01:00
Josh Palmer
956bd1493e docs: record new AMI + instance 2026-02-01 17:57:55 +01:00
Josh Palmer
5060960a89 fix: create swapfile at /swapfile 2026-02-01 17:50:01 +01:00
Josh Palmer
2b97a7afce infra: enlarge host + add swap + set git identity
- set default instance type to t3.large
- add 8GiB swapfile on host
- set global git name/email for clawdinator user
2026-02-01 17:36:11 +01:00
Josh Palmer
be9f5fada8 refine: landpr flow to single approval gate
- default to rebase unless squash requested
- avoid dumping checklist; treat as playbook
- add single approval gate before rebase/force-push/merge
2026-02-01 17:15:33 +01:00
Josh Palmer
9a4d467f05 feat: add /landpr skill + checklist
- add landpr workspace skill to expose /landpr command
- add scripts/landpr.md checklist for OpenClaw PR landings
2026-02-01 17:10:24 +01:00
Josh Palmer
3f04ee8675 config: use gpt-5.2-codex as openai fallback 2026-02-01 17:03:10 +01:00
Josh Palmer
6a8b189421 config: allow commands from anyone in #clawdinators-test 2026-02-01 13:34:47 +01:00
Josh Palmer
2f6b950eb8 fix: ensure github token env path writable for clawdinator
- enforce /run/clawd ownership via tmpfiles z rule
- precreate + chown github-app.env
2026-02-01 12:30:43 +01:00
Josh Palmer
fc793d67a9 config: open #clawdinators-test to all users 2026-02-01 12:19:47 +01:00
Josh Palmer
2320639342 fix: allow gh token refresh without sudo
- run github-app-token service as clawdinator user
- add clawdinator-gh-refresh command + tools note
- move canned-response guardrails to workspace AGENTS
2026-02-01 12:04:16 +01:00
Josh Palmer
ec8b7dabc6 docs: soften canned responses with alternate contribution paths
- Add ClawHub/CLI/fork suggestions to PR closure variants
- Add alternate contribution paragraph to needs-changes + stale templates
2026-02-01 01:35:01 +01:00
Josh Palmer
a2978e20a3 docs: forbid closing maintainer-assigned PRs 2026-02-01 01:25:12 +01:00
Josh Palmer
4dfed7f610 docs: lock canned PR responses to approved context
- Require canned responses as the base
- Ban riffing/policy statements without approval
- Allow only short factual PR-specific context with approval
2026-02-01 01:16:45 +01:00
Josh Palmer
e06cecf11f docs: note ClawHub domain for skills hub 2026-02-01 00:40:22 +01:00
Josh Palmer
3975a6485c fix: gh config perms for clawdinator user
- chown -R /var/lib/clawd/gh after gh auth login
- ensure config.yml readable by clawdinator
- prevents gh auth/config permission errors
2026-02-01 00:29:54 +01:00
Josh Palmer
7690daf793 docs: add full SDLC deploy steps to AGENTS.md
Documented verified local→AMI→host flow including:
- homelab-admin AWS creds via agenix
- CI run monitoring and AMI_ID extraction
- tofu redeploy via devenv
- post-deploy checks + GH auth status
- reminder about snapshot-seeded repos/workspace
2026-02-01 00:19:31 +01:00
Josh Palmer
c0022322d6 fix: use correct gateway binary name
Use  (openclaw if present, else moltbot) in
clawdinator-gateway wrapper and ExecStart fallback.
Prevents exit 127 when openclaw binary is renamed.
2026-01-31 23:52:01 +01:00
Josh Palmer
1c422360b1 chore: remove stray .DS_Store from canned responses 2026-01-31 23:47:50 +01:00
Josh Palmer
790a10c0f4 feat: add more canned response gifs + templates
- Add approval/needs-changes/stale PR templates
- Add four new gif assets to canned-responses/gifs
- Reference gifs explicitly in templates and README
2026-01-31 23:47:36 +01:00
Josh Palmer
b7efe5017b fix: keep gh auth fresh without env tokens
- Write GH CLI auth file (/var/lib/clawd/gh/hosts.yml) on token refresh
- Set GH_CONFIG_DIR for the gateway service
- Stop injecting GH_TOKEN into clawdinator service env
- Document GH CLI auth file in docs/SECRETS.md
2026-01-31 23:36:20 +01:00
Josh Palmer
8470c3c5c2 fix: prevent GitHub token expiry mid-session
- Change token refresh from hourly to every 45 min (tokens expire at 1h)
- Restart clawdinator service after token refresh so the process
  picks up the new GH_TOKEN from the env file
- Root cause: bot reads GH_TOKEN at startup, never re-reads
2026-01-31 23:23:04 +01:00
Josh Palmer
c8831041fb feat: add Josh to guild users allowlist for slash commands
Discord user ID 1032349993484439622 added to guild users list
so /new and other slash commands work.
2026-01-31 17:42:22 +01:00
Josh Palmer
4f4c848813 feat: add maintainer allowlist (22 org members)
- Add maintainers.md with full openclaw org member list
- NEVER auto-close PRs from org members
- Update canned-responses README + AGENTS.md workflow
- Source: gh api /orgs/openclaw/members
2026-01-31 17:29:02 +01:00
Josh Palmer
d449308fcf fix: update canned responses channel + READ THE TOPIC warning
- Replace #clawdributors with #pr-thunderdome-dangerzone
- Add READ THE TOPIC or risk immediate termination warning
- Fix sentence flow after sed replacements
- Update README guidelines with channel name
2026-01-31 17:09:08 +01:00
Josh Palmer
1ad5189beb feat: add canned PR responses + gif + AGENTS.md workflow
Add canned-responses directory with:
- 5 rotating PR closure variants (Arnie-themed, SOUL.md compliant)
- T2 thumbs-up gif for PR closures
- README with voice guidelines and rotation rules

Update workspace AGENTS.md:
- Add GitHub PR canned response workflow section
- Update 'comment on github' policy to require explicit user approval
- Add host discovery notes (terraform state -> SSH)
2026-01-31 16:59:08 +01:00
Josh Palmer
52d9b34693 🔄 rebrand: update to openclaw packages + config
Update to nix-openclaw 9d8bafc4 with renamed packages.

- Packages: moltbot-gateway → openclaw-gateway, moltbot → openclaw
- Binary: bin/moltbot → bin/openclaw
- Config: moltbot.json → openclaw.json
- Log: moltbot.log → openclaw.log
- Zero moltbot references remaining in repo.

Tests: not run (CI will validate flake eval + AMI build)
2026-01-30 14:35:26 +01:00
Josh Palmer
2e6ee3772b 🔄 flake: update lock for nix-openclaw
Point flake.lock at openclaw/nix-openclaw (same rev, new URL).
2026-01-30 14:31:58 +01:00
Josh Palmer
c2c3bf4f46 🔄 rebrand: moltbot → openclaw, clawdinators
Rename org references from moltbot to openclaw across all config, docs,
CI workflows, flake inputs, secret names, and repo seeds.

Mapping:
- org: moltbot → openclaw
- repos: moltinators → clawdinators, nix-moltbot → nix-openclaw, molthub → clawhub
- secrets: moltinator-* → clawdinator-*
- flake input: nix-moltbot → nix-openclaw
- GH repos renamed: openclaw/nix-openclaw, openclaw/clawdinators

Upstream package/binary names (moltbot, moltbot-gateway, moltbot.json)
kept as-is — those come from nix-openclaw and haven't been renamed yet.

Tests: not run (rename-only change; CI will validate flake eval)
2026-01-30 14:30:46 +01:00
Josh Palmer
b9b3ad6ffe 🤖 ops: sync openclaw org
Set githubSync org default to openclaw and override on clawdinator-1.
Make gh-sync headers reflect configured org.

Tests: not run (config change)
2026-01-30 12:15:37 +01:00
Josh Palmer
faa8917f2d 🤖 docs: forbid token leaks
Add explicit secret redaction guidance to workspace AGENTS.md.

Tests: not run (docs change)
2026-01-30 12:05:56 +01:00
Josh Palmer
682523d829 🤖 nix: give clawdinator a shell
Set the system user shell to bash so exec/memory wrappers can run.

Tests: not run (requires CI image build)
2026-01-30 09:49:03 +01:00
86 changed files with 9864 additions and 520 deletions

7
.github/workflows-disabled/README.md vendored Normal file
View 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.

View File

@ -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

View File

@ -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

View 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
View 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
View File

@ -1,3 +1,6 @@
# Devenv
.devenv*
# OpenTofu
infra/opentofu/.terraform/
infra/opentofu/.tofu/

View File

@ -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 dont survive restarts — write it to a file.

View File

@ -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 |
---

View File

@ -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)

View 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 |

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

View 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

View 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.*

View 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 opensource 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 opensource 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 opensource 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 opensource 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 opensource 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.*

View 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 opensource tool, or maintain your own fork of openclaw.
Stay br00tal.
*🤖 This is an automated message from [CLAWDINATOR](https://openclaw.ai), the OpenClaw maintainer bot.*

View 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 opensource 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.*

View 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"
]
}

View File

@ -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

1 moltbot openclaw https://github.com/moltbot/moltbot.git https://github.com/openclaw/openclaw.git
2 nix-moltbot nix-openclaw https://github.com/moltbot/nix-moltbot.git https://github.com/openclaw/nix-openclaw.git
3 moltinators clawdinators https://github.com/moltbot/moltinators.git https://github.com/openclaw/clawdinators.git
4 molthub clawhub https://github.com/moltbot/molthub.git https://github.com/openclaw/clawhub.git
5 nix-steipete-tools nix-steipete-tools https://github.com/moltbot/nix-steipete-tools.git https://github.com/openclaw/nix-steipete-tools.git
6 clawkeeper https://github.com/openclaw/ClawKeeper.git

View File

@ -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 (AE).
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

View File

@ -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`.

View 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 23 briefly.
If nothing coherent: say `Intent unclear: ...`.

View 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.

View 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 readonly prep, then summarize the plan and ask for explicit approval **once** before any rebase/forcepush/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.

View File

@ -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
View 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
View 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
}

View File

@ -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
View File

@ -0,0 +1,191 @@
# Control Plane
Goal: manage CLAWDINATOR host lifecycle (create/recreate/replace) from **CLAWDINATOR chat** (Telegram/Discord) using an outofband control API. CLAWDINATOR agents can edit IaC, but **deploys run OOB** with no AWS creds inside agents.
## Goals
- **Planesafe control** from CLAWDINATOR chat (chatonly).
- 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.
## NonGoals
- 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 outofband 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 selfdeploy) 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 selfdeploy).
- 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.
## SelfRecycle (OutofBand)
- 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 (PerInstance)
- Upload per instance:
- `bootstrap/clawdinator-1`
- `bootstrap/clawdinator-2`
- Each bundle contains **only that instances** Discord token.
## EC2 User-Data (Instance Boot)
- OpenTofu renders a per-instance userdata 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 (Chatonly)
### 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` (clawdinator1 + clawdinator2).
2) Add `nix/hosts/clawdinator-2.nix` and wire host configs to read registry values.
3) Update OpenTofu:
- multiinstance `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
View 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.

View File

@ -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.

View 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
```

View File

@ -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
View File

@ -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"
]
}

View File

@ -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
];
};

View File

@ -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"

View File

@ -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`.

View File

@ -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
}

View File

@ -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
}

View 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
}

View 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}

View File

@ -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"
}

View File

@ -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"

View File

@ -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.

View File

@ -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)

View File

@ -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.

View File

@ -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.*`

View File

@ -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";
};
}

View File

@ -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
];
};

View File

@ -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;
};
};
}

View File

@ -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" ];
}

View File

@ -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
};
}

View 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;
}

View 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.
'';
};
};
};
};
};
};
};
}

View 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
View File

@ -0,0 +1,8 @@
{
"clawdinator-babelfish": {
"host": "clawdinator-babelfish",
"instanceType": "t3.small",
"bootstrapPrefix": "bootstrap/clawdinator-babelfish",
"discordTokenSecret": "clawdinator-discord-token-babelfish"
}
}

View File

@ -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;
};
};
};
}

View File

@ -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."; }

View 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

File diff suppressed because it is too large Load Diff

View 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
View 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

View File

@ -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"

View File

@ -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
View 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

View 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
View 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
View 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
View 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
View 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

View File

@ -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

View File

@ -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}" \

View File

@ -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
View 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
View 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
View 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"

View 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

View 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
View 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}"

View File

@ -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
View 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
View 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
View 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}")