diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index f48be50e..e17e6ad2 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -18,11 +18,8 @@ body: options: - openclaw - hermes - - nanoclaw - nanobot - picoclaw - - nullclaw - - microclaw - generic / none - not driver-related validations: diff --git a/.gitignore b/.gitignore index c2b1d8e0..09d3ce4f 100644 --- a/.gitignore +++ b/.gitignore @@ -48,7 +48,7 @@ cmd/clawdash/node_modules/ testdata/* !testdata/openclaw-stub/ !testdata/openclaw-stub-nonroot/ -!testdata/microclaw-stub/ +!testdata/nanobot-stub/ !testdata/tool-stub/ *-notes.md .claude/settings.local.json diff --git a/AGENTS.md b/AGENTS.md index 668a2594..ae60f2b5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -142,12 +142,12 @@ Driver directories currently in-tree: - `internal/driver/openclaw` - `internal/driver/hermes` - `internal/driver/nanobot` -- `internal/driver/nanoclaw` - `internal/driver/picoclaw` -- `internal/driver/microclaw` -- `internal/driver/nullclaw` - `internal/driver/shared` +NanoClaw, MicroClaw, and NullClaw were retired in ADR-026; their `CLAW_TYPE`s fail +closed with a migration message. + Do not assume older docs mentioning only a subset are current. ## Runtime Model That Exists Today @@ -162,13 +162,13 @@ Do not assume older docs mentioning only a subset are current. ## Repo-Specific Gotchas -- Bug fixes in one driver often apply to all 7. When fixing driver behavior (permissions, config defaults, env vars), check all drivers in `internal/driver/*/driver.go` and `config.go` — not just the one mentioned in the issue. +- Bug fixes in one driver often apply to all 4. When fixing driver behavior (permissions, config defaults, env vars), check all drivers in `internal/driver/*/driver.go` and `config.go` — not just the one mentioned in the issue. - Runtime directories created by `Materialize()` use `0o777` (not `0o700`) so container users with different uids can write. Do not regress this. - All drivers set `mention_only` (or equivalent like `requireMention`, `DISCORD_REQUIRE_MENTION`) for Discord channels. Without this, multi-agent pods enter feedback loops. - All drivers explicitly set `HOME` in the container env map to match their config mount path. Container base images may run as root or a different user than expected. - `cllama/` is a git submodule pointing to a public SSH repo. Fresh `git clone` leaves it empty. Infra images (cllama, clawdash) are published to ghcr.io as public packages to avoid this for end users. `cllama/` has its own `.git` — changes require two commits: one inside `cllama/` (for feeds/proxy code), then `git add cllama && git commit` in the repo root to update the pointer. Shell working directory can silently drift to `cllama/` between commands — use absolute paths for git operations or verify with `pwd` first. - `internal/feeds/` and other cllama internals live at `cllama/internal/`, not at the repo root. -- Infra image remediation is explicit now: `claw pull` owns pinned infra freshness *and* built-in local runner alias freshness (`openclaw:latest`, `nanobot:latest`, `nanoclaw-orchestrator:latest`, etc.) refreshed via `docker build --pull --no-cache`; `claw build` owns pod `build:` services and consumes an already-refreshed runner alias; `claw up` points at one or the other when something is missing. `claw up --fix` is the opt-in auto-remediation path. Use `claw pull --no-runners` for the fast pinned-infra-only path. See ADR-024. +- Infra image remediation is explicit now: `claw pull` owns pinned infra freshness *and* built-in local runner alias freshness (`openclaw:latest`, `nanobot:latest`, `picoclaw:latest`, etc.) refreshed via `docker build --pull --no-cache`; `claw build` owns pod `build:` services and consumes an already-refreshed runner alias; `claw up` points at one or the other when something is missing. `claw up --fix` is the opt-in auto-remediation path. Use `claw pull --no-runners` for the fast pinned-infra-only path. See ADR-024. - Runner base provenance: `claw build` rewrites `FROM :latest` to `FROM :v` in `Dockerfile.generated` and stamps three labels (`claw.runner.built-against`, `claw.runner.image-id`, `claw.runner.recipe-sha`). `claw up` reads `claw.runner.image-id` and prints a soft drift hint when the local alias has moved on; it does not auto-rebuild. Service images built with a manual `docker build` of a runner base must also create a versioned sibling tag, otherwise `claw build` will fail-closed with a `claw pull` remediation hint. - Managed services require `claw up -d` because post-apply verification is fail-closed. - Multi-proxy cllama is represented in the data model but runtime currently fails fast if more than one proxy type is declared. diff --git a/README.md b/README.md index d781c46f..8a69cf4d 100644 --- a/README.md +++ b/README.md @@ -265,7 +265,7 @@ The Clawfile extends the Dockerfile with directives that the `claw build` prepro | Directive | Purpose | |---|---| -| `CLAW_TYPE` | Selects the runtime driver (openclaw, hermes, nanobot, picoclaw, nanoclaw, microclaw, nullclaw) | +| `CLAW_TYPE` | Selects the runtime driver (openclaw, hermes, nanobot, picoclaw) | | `AGENT` | Names the behavioral contract file | | `PERSONA` | Imports a persona workspace — local path or OCI artifact ref | | `MODEL` | Binds named model slots to providers | @@ -285,18 +285,21 @@ The Clawfile extends the Dockerfile with directives that the `claw build` prepro Pick a driver based on what you need. All drivers support `MODEL`, `AGENT`, `CLLAMA`, and `CONFIGURE`. -| | `openclaw` | `hermes` | `nanoclaw` | `nanobot` | `picoclaw` | `nullclaw` | `microclaw` | -|---|:---:|:---:|:---:|:---:|:---:|:---:|:---:| -| **Runtime** | [OpenClaw](https://openclaw.ai) | [Hermes](https://github.com/NousResearch/hermes-agent) | NanoClaw / Claude Code-compatible orchestrator | [Nanobot](https://github.com/HKUDS/nanobot) | [PicoClaw](https://github.com/sipeed/picoclaw) | [NullClaw](https://github.com/nullclaw/nullclaw) | [MicroClaw](https://github.com/microclaw/microclaw) | -| `claw init` scaffold | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| HANDLE: Discord | ✅ | ✅ | — | ✅ | ✅ | ✅ | ✅ | -| HANDLE: Telegram | ✅ | ✅ | — | ✅ | ✅ | ✅ | ✅ | -| HANDLE: Slack | ✅ | ✅ | — | ✅ | ✅ | ✅ | ✅ | -| HANDLE: long-tail ¹ | — | — | — | — | ✅ | — | — | -| INVOKE (cron) | ✅ | ✅ | — | ✅ | ✅ | ✅ | — | -| Structured health | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| Read-only rootfs | ✅ | ✅ | no | ✅ | ✅ | ✅ | no | -| Non-root container | — | — | — | — | ✅ | — | — | +| | `openclaw` | `hermes` | `nanobot` | `picoclaw` | +|---|:---:|:---:|:---:|:---:| +| **Runtime** | [OpenClaw](https://openclaw.ai) | [Hermes](https://github.com/NousResearch/hermes-agent) | [Nanobot](https://github.com/HKUDS/nanobot) | [PicoClaw](https://github.com/sipeed/picoclaw) | +| `claw init` scaffold | ✅ | ✅ | ✅ | ✅ | +| HANDLE: Discord | ✅ | ✅ | ✅ | ✅ | +| HANDLE: Telegram | ✅ | ✅ | ✅ | ✅ | +| HANDLE: Slack | ✅ | ✅ | ✅ | ✅ | +| HANDLE: long-tail ¹ | — | — | — | ✅ | +| INVOKE (cron) | ✅ | ✅ | ✅ | ✅ | +| Structured health | ✅ | ✅ | ✅ | ✅ | +| Read-only rootfs | ✅ | ✅ | ✅ | ✅ | +| Non-root container | — | — | — | ✅ | + +Retired drivers (`nanoclaw`, `microclaw`, `nullclaw`) fail `claw up` with a migration +message; see [ADR-026](docs/decisions/026-runner-adoption-and-retirement.md). ¹ PicoClaw long-tail: WhatsApp, Feishu, LINE, QQ, DingTalk, OneBot, WeCom, WeCom App, Pico, MaixCam. `claw init` scaffolds `generic` (alpine:3.20, no driver enforcement) for custom runtimes. @@ -319,7 +322,7 @@ The OpenClaw driver now maps the supported `channel://discord` routing controls --- -## Nullclaw `CONFIGURE` Examples +## Driver `CONFIGURE` Examples Use these when you want high-level `HANDLE` defaults, but need runtime-specific policy details. @@ -327,21 +330,18 @@ Use these when you want high-level `HANDLE` defaults, but need runtime-specific # Base identity on a platform: HANDLE discord -# "Can talk on" -> pin to one guild/server -CONFIGURE nullclaw config set channels.discord.accounts.main.guild_id "123456789012345678" +# Enable/adjust a channel beyond HANDLE defaults +CONFIGURE picoclaw config set channels.discord.enabled true -# "Can talk to" -> require mention in group chats -CONFIGURE nullclaw config set channels.discord.accounts.main.require_mention true +# Override the gateway port +CONFIGURE picoclaw config set gateway.port 19000 -# Telegram allowlist for DMs -CONFIGURE nullclaw config set channels.telegram.accounts.main.allow_from ["111111111","222222222"] - -# Slack transport mode selection -CONFIGURE nullclaw config set channels.slack.accounts.main.mode "socket" +# Pin a fallback model name +CONFIGURE picoclaw config set agents.defaults.model_name "fallback" ``` Notes: -- `CONFIGURE` is driver-side DSL here (`nullclaw config set `), applied to generated `config.json`. +- `CONFIGURE` is driver-side DSL (` config set `), applied to the generated config. - Values are parsed as JSON when possible: booleans/numbers/arrays/objects should be unquoted; strings should be quoted. - `CONFIGURE` runs after defaults, so it overrides what `HANDLE` generated. @@ -353,7 +353,7 @@ Notes: block-beta columns 1 contract["Behavioral Contract\nread-only bind mount\nAGENTS.md — purpose, on the host\nSurvives full container compromise"] - runner["Runner\nOpenClaw · NanoClaw · Claude Code · custom"] + runner["Runner\nOpenClaw · Hermes · Nanobot · PicoClaw"] persona["Persona\nMemory · history · style · knowledge"] proxy["cllama — governance proxy\nIntercepts prompts outbound + responses inbound\nRunner never knows it's there"] diff --git a/TESTING.md b/TESTING.md index 465f3ed4..22e04893 100644 --- a/TESTING.md +++ b/TESTING.md @@ -35,7 +35,7 @@ Docker, and a real Discord server. They are not CI tests. The two primary spike paths are: -- `TestSpikeRollCall`: the broad driver-parity validation path. Boots all 7 driver +- `TestSpikeRollCall`: the broad driver-parity validation path. Boots all 4 retained driver types plus `cllama` passthrough and `clawdash`, sends a Discord roll call, and verifies runtime-specific responses. - `TestSpikeComposeUp`: the deeper trading-desk validation path. Focuses on artifact @@ -54,9 +54,6 @@ cross-driver support. It uses [`examples/rollcall/`](./examples/rollcall/) and exercises: - `openclaw` -- `nullclaw` -- `microclaw` -- `nanoclaw` - `nanobot` - `picoclaw` - `hermes` @@ -65,7 +62,7 @@ exercises: ### What it validates -- Base images build for all 7 driver families +- Base images build for all 4 retained driver families - Agent images build from their `Clawfile`s - `claw up` succeeds on the rollcall pod - All agent containers converge to healthy/running state diff --git a/cmd/claw-api/agent_context_test.go b/cmd/claw-api/agent_context_test.go index ea5a7c3f..a14d9298 100644 --- a/cmd/claw-api/agent_context_test.go +++ b/cmd/claw-api/agent_context_test.go @@ -21,7 +21,7 @@ func TestAgentsListFiltersByServiceScopeAndLiveContext(t *testing.T) { }) writeAgentContextFixture(t, contextRoot, "analyst-0", map[string]string{ "service": "analyst", - "type": "nanoclaw", + "type": "nanobot", }) var sawAuth string diff --git a/cmd/claw-api/handler.go b/cmd/claw-api/handler.go index 38b70b92..f1ca7f5d 100644 --- a/cmd/claw-api/handler.go +++ b/cmd/claw-api/handler.go @@ -27,10 +27,7 @@ import ( manifestpkg "github.com/mostlydev/clawdapus/internal/clawdash" "github.com/mostlydev/clawdapus/internal/driver" _ "github.com/mostlydev/clawdapus/internal/driver/hermes" - _ "github.com/mostlydev/clawdapus/internal/driver/microclaw" _ "github.com/mostlydev/clawdapus/internal/driver/nanobot" - _ "github.com/mostlydev/clawdapus/internal/driver/nanoclaw" - _ "github.com/mostlydev/clawdapus/internal/driver/nullclaw" _ "github.com/mostlydev/clawdapus/internal/driver/openclaw" _ "github.com/mostlydev/clawdapus/internal/driver/picoclaw" schedulepkg "github.com/mostlydev/clawdapus/internal/schedule" diff --git a/cmd/claw/agent_test.go b/cmd/claw/agent_test.go index 04d86c56..1f8a7067 100644 --- a/cmd/claw/agent_test.go +++ b/cmd/claw/agent_test.go @@ -220,9 +220,6 @@ func TestAgentAddTypeDefaults(t *testing.T) { }{ {name: "generic", agentName: "genericone", clawType: "generic", baseImage: "alpine:3.20"}, {name: "hermes", agentName: "hermesone", clawType: "hermes", baseImage: hermes.BaseImageTag}, - {name: "nanoclaw", agentName: "nanoclawone", clawType: "nanoclaw", baseImage: "nanoclaw-orchestrator:latest"}, - {name: "microclaw", agentName: "microclawone", clawType: "microclaw", baseImage: "microclaw:latest"}, - {name: "nullclaw", agentName: "nullclawone", clawType: "nullclaw", baseImage: "nullclaw:latest"}, {name: "nanobot", agentName: "nanobotone", clawType: "nanobot", baseImage: "nanobot:latest"}, {name: "picoclaw", agentName: "picoclawone", clawType: "picoclaw", baseImage: "picoclaw:latest"}, } @@ -266,7 +263,7 @@ func TestAgentAddTypeFlagUsageListsAllScaffoldTypes(t *testing.T) { } usage := flag.Usage - for _, typ := range []string{"openclaw", "hermes", "nanoclaw", "microclaw", "nullclaw", "nanobot", "picoclaw", "generic"} { + for _, typ := range []string{"openclaw", "hermes", "nanobot", "picoclaw", "generic"} { if !strings.Contains(usage, typ) { t.Fatalf("expected agent add --type usage to include %q, got: %s", typ, usage) } diff --git a/cmd/claw/compose_health.go b/cmd/claw/compose_health.go index cd047e8e..b116afde 100644 --- a/cmd/claw/compose_health.go +++ b/cmd/claw/compose_health.go @@ -14,10 +14,7 @@ import ( "github.com/mostlydev/clawdapus/internal/driver" _ "github.com/mostlydev/clawdapus/internal/driver/hermes" - _ "github.com/mostlydev/clawdapus/internal/driver/microclaw" _ "github.com/mostlydev/clawdapus/internal/driver/nanobot" - _ "github.com/mostlydev/clawdapus/internal/driver/nanoclaw" - _ "github.com/mostlydev/clawdapus/internal/driver/nullclaw" _ "github.com/mostlydev/clawdapus/internal/driver/openclaw" _ "github.com/mostlydev/clawdapus/internal/driver/picoclaw" ) diff --git a/cmd/claw/compose_test.go b/cmd/claw/compose_test.go index 43d2ee62..2ff39cbc 100644 --- a/cmd/claw/compose_test.go +++ b/cmd/claw/compose_test.go @@ -219,7 +219,7 @@ func TestComposePassthroughAllowsStaleGeneratedOnlyForBuild(t *testing.T) { } func TestBuiltinDriversRegistered(t *testing.T) { - for _, clawType := range []string{"openclaw", "hermes", "nanoclaw", "microclaw", "nullclaw", "nanobot", "picoclaw"} { + for _, clawType := range []string{"openclaw", "hermes", "nanobot", "picoclaw"} { if _, err := driver.Lookup(clawType); err != nil { t.Fatalf("expected driver %q to be registered in CLI package: %v", clawType, err) } diff --git a/cmd/claw/init_test.go b/cmd/claw/init_test.go index 9b1f1742..7e0ffd18 100644 --- a/cmd/claw/init_test.go +++ b/cmd/claw/init_test.go @@ -285,9 +285,6 @@ func TestInitScaffoldTypeDefaults(t *testing.T) { }{ {name: "generic", clawType: "generic", baseImage: "alpine:3.20"}, {name: "hermes", clawType: "hermes", baseImage: hermes.BaseImageTag}, - {name: "nanoclaw", clawType: "nanoclaw", baseImage: "nanoclaw-orchestrator:latest"}, - {name: "microclaw", clawType: "microclaw", baseImage: "microclaw:latest"}, - {name: "nullclaw", clawType: "nullclaw", baseImage: "nullclaw:latest"}, {name: "nanobot", clawType: "nanobot", baseImage: "nanobot:latest"}, {name: "picoclaw", clawType: "picoclaw", baseImage: "picoclaw:latest"}, } @@ -362,7 +359,7 @@ func TestInitTypeFlagUsageListsAllScaffoldTypes(t *testing.T) { } usage := flag.Usage - for _, typ := range []string{"openclaw", "hermes", "nanoclaw", "microclaw", "nullclaw", "nanobot", "picoclaw", "generic"} { + for _, typ := range []string{"openclaw", "hermes", "nanobot", "picoclaw", "generic"} { if !strings.Contains(usage, typ) { t.Fatalf("expected init --type usage to include %q, got: %s", typ, usage) } diff --git a/cmd/claw/scaffold_helpers.go b/cmd/claw/scaffold_helpers.go index 6f12bce3..abdd30bb 100644 --- a/cmd/claw/scaffold_helpers.go +++ b/cmd/claw/scaffold_helpers.go @@ -11,6 +11,7 @@ import ( "strings" "unicode" + "github.com/mostlydev/clawdapus/internal/driver" "github.com/mostlydev/clawdapus/internal/driver/hermes" ) @@ -24,7 +25,7 @@ const ( var validNamePattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_-]*$`) -var scaffoldClawTypes = []string{"openclaw", "hermes", "nanoclaw", "microclaw", "nullclaw", "nanobot", "picoclaw", "generic"} +var scaffoldClawTypes = []string{"openclaw", "hermes", "nanobot", "picoclaw", "generic"} func shouldPromptInteractively() bool { info, err := os.Stdin.Stat() @@ -157,9 +158,12 @@ func parseClawType(value string) (string, error) { switch v { case "": return "", fmt.Errorf("claw type is required") - case "openclaw", "hermes", "nanoclaw", "microclaw", "nullclaw", "nanobot", "picoclaw", "generic": + case "openclaw", "hermes", "nanobot", "picoclaw", "generic": return v, nil default: + if err := driver.RetirementError(v); err != nil { + return "", err + } return "", fmt.Errorf("invalid claw type %q (allowed: %s)", value, strings.Join(scaffoldClawTypes, ", ")) } } @@ -170,12 +174,6 @@ func defaultBaseImageForClawType(clawType string) string { return "openclaw:latest" case "hermes": return hermes.BaseImageTag - case "nanoclaw": - return "nanoclaw-orchestrator:latest" - case "microclaw": - return "microclaw:latest" - case "nullclaw": - return "nullclaw:latest" case "nanobot": return "nanobot:latest" case "picoclaw": diff --git a/cmd/claw/scaffold_helpers_test.go b/cmd/claw/scaffold_helpers_test.go index 430ad4ba..fb7c307d 100644 --- a/cmd/claw/scaffold_helpers_test.go +++ b/cmd/claw/scaffold_helpers_test.go @@ -11,9 +11,6 @@ func TestParseClawTypeAcceptsSupportedValues(t *testing.T) { tests := []string{ "openclaw", "hermes", - "nanoclaw", - "microclaw", - "nullclaw", "nanobot", "picoclaw", "generic", @@ -45,6 +42,22 @@ func TestParseClawTypeRejectsUnknownValue(t *testing.T) { } } +func TestParseClawTypeRejectsRetiredValuesWithMigrationGuidance(t *testing.T) { + for _, clawType := range []string{"nanoclaw", "microclaw", "nullclaw"} { + t.Run(clawType, func(t *testing.T) { + _, err := parseClawType(clawType) + if err == nil { + t.Fatalf("expected %s to be rejected", clawType) + } + for _, want := range []string{clawType, "retired", "ADR-026", `CLAW_TYPE "hermes"`} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("retirement error %q does not contain %q", err, want) + } + } + }) + } +} + func TestDefaultBaseImageForClawType(t *testing.T) { tests := []struct { name string @@ -53,9 +66,6 @@ func TestDefaultBaseImageForClawType(t *testing.T) { }{ {name: "openclaw", clawType: "openclaw", want: "openclaw:latest"}, {name: "hermes", clawType: "hermes", want: hermes.BaseImageTag}, - {name: "nanoclaw", clawType: "nanoclaw", want: "nanoclaw-orchestrator:latest"}, - {name: "microclaw", clawType: "microclaw", want: "microclaw:latest"}, - {name: "nullclaw", clawType: "nullclaw", want: "nullclaw:latest"}, {name: "nanobot", clawType: "nanobot", want: "nanobot:latest"}, {name: "picoclaw", clawType: "picoclaw", want: "picoclaw:latest"}, {name: "generic", clawType: "generic", want: "alpine:3.20"}, diff --git a/cmd/claw/schedule_manifest.go b/cmd/claw/schedule_manifest.go index e96817d7..cc7da5ac 100644 --- a/cmd/claw/schedule_manifest.go +++ b/cmd/claw/schedule_manifest.go @@ -107,7 +107,7 @@ func buildScheduleManifest(p *pod.Pod, resolved map[string]*driver.ResolvedClaw) func supportsExternalScheduler(clawType string) bool { switch strings.TrimSpace(clawType) { - case "openclaw", "hermes", "nanobot", "picoclaw", "nullclaw": + case "openclaw", "hermes", "nanobot", "picoclaw": return true default: return false @@ -150,12 +150,6 @@ func resolveWakeAdapter(clawType, target string, inv driver.Invocation) (schedul Target: target, Command: []string{"picoclaw", "agent", "-m", inv.Message}, }, nil - case "nullclaw": - return schedule.Wake{ - Adapter: "nullclaw-exec", - Target: target, - Command: []string{"nullclaw", "agent", "-m", inv.Message}, - }, nil default: return schedule.Wake{}, fmt.Errorf("unsupported wake adapter for driver %q", clawType) } @@ -166,7 +160,7 @@ func scheduleWakeWarning(serviceName, clawType string, inv driver.Invocation) st return "" } switch strings.TrimSpace(clawType) { - case "picoclaw", "nullclaw": + case "picoclaw": return fmt.Sprintf("service %q: driver %q external scheduler wake does not support invoke.to=%q; firing direct agent message without delivery routing", serviceName, clawType, inv.To) default: return "" diff --git a/cmd/claw/schedule_manifest_test.go b/cmd/claw/schedule_manifest_test.go index e1f8330e..ee1ec9a0 100644 --- a/cmd/claw/schedule_manifest_test.go +++ b/cmd/claw/schedule_manifest_test.go @@ -107,7 +107,7 @@ func TestBuildScheduleManifestUsesServiceTimezoneWithoutCalendar(t *testing.T) { manifest, err := buildScheduleManifest(&pod.Pod{Name: "ops"}, map[string]*driver.ResolvedClaw{ "bot": { ServiceName: "bot", - ClawType: "nullclaw", + ClawType: "picoclaw", Timezone: "America/New_York", Invocations: []driver.Invocation{ { @@ -134,7 +134,7 @@ func TestBuildScheduleManifestFallsBackToUTCWithoutServiceTimezone(t *testing.T) manifest, err := buildScheduleManifest(&pod.Pod{Name: "ops"}, map[string]*driver.ResolvedClaw{ "bot": { ServiceName: "bot", - ClawType: "nullclaw", + ClawType: "picoclaw", Invocations: []driver.Invocation{ { ID: "podjob01", diff --git a/cmd/claw/skill_data/SKILL.md b/cmd/claw/skill_data/SKILL.md index 4ededeab..a52262f4 100644 --- a/cmd/claw/skill_data/SKILL.md +++ b/cmd/claw/skill_data/SKILL.md @@ -349,11 +349,11 @@ The policy is tunable from the pod YAML via `x-claw.tool-policy` (service level) ## Communication Tools Contract -All 7 runtimes enforce private thinking + deliberate delivery — agent reasoning never reaches Discord automatically. +All four drivers compile mention-only channel consumption so an unaddressed bot message does not create a feedback loop. - **Hermes**: `HERMES_TOOL_ONLY_MODE=1` injected when Discord handles are present; runtime patches prefer `send_message`, suppress duplicate final text after a successful `send_message`, and fall back to final-text delivery rather than silently dropping replies - **OpenClaw**: enforced natively -- **NullClaw, MicroClaw, NanoClaw, NanoBot, PicoClaw**: `discord-responder.sh` passes a `send_message` tool to the LLM; only posts to Discord when the tool is called +- **NanoBot and PicoClaw**: generated channel configuration enables each runner's mention-only equivalent CLAWDAPUS.md includes a `## Communication Tools` section with private-thinking policy whenever handles are configured. @@ -430,7 +430,6 @@ When the aggregate cap drops a feed the model sees an explicit `--- FEED: | `compose.generated.yml` | Final compose with all enforcement | Next to claw-pod.yml | | `CLAWDAPUS.md` | Per-agent infrastructure map | Mounted into container | | `AGENTS.effective.md` | Merged contract + CLAWDAPUS.md (OpenClaw and Hermes cllama context) | Mounted into container or cllama context | -| `CLAUDE.md` | Combined contract + CLAWDAPUS.md (NanoClaw) | Mounted into container | | `openclaw.json` | Generated runner config (OpenClaw) | Bind-mounted directory | | `config.yaml` / `.env` | Generated runner config (Hermes) | Bind-mounted directory | | `jobs.json` | Cron schedule for INVOKE tasks | Runner state directory | @@ -444,11 +443,10 @@ When the aggregate cap drops a feed the model sees an explicit `--- FEED: |--------|-----------|--------|--------------|-------| | OpenClaw | `openclaw` | OpenClaw | JSON5 Go-native patching -> `openclaw.json` | Primary driver. Read-only container. Docker exec health probe. | | Hermes | `hermes` | Hermes (Python) | `config.yaml` + `.env` | Discord/Telegram/Slack. `HERMES_TOOL_ONLY_MODE`. Requires at least one handle. | -| NanoBot | `nanobot` | Nanobot (Node.js) | `config.json` | Cron via `jobs.json`. Merged AGENTS.md. | -| NanoClaw | `nanoclaw` | Claude Agent SDK | Combined `CLAUDE.md` | Requires `PRIVILEGE docker-socket true`. Mounts Docker socket. | +| NanoBot | `nanobot` | Nanobot (Python) | `config.json` | Cron via `jobs.json`. Merged AGENTS.md. | | PicoClaw | `picoclaw` | PicoClaw | `config.json` | HTTP `/health` + `/ready` probe. Read-only container. | -| MicroClaw | `microclaw` | MicroClaw (YAML) | `microclaw.config.yaml` | Built-in web UI on port 10961. No INVOKE support. | -| NullClaw | `nullclaw` | NullClaw (HTTP) | `config.json` | Cron via `PostApply` exec (not pre-written). Read-only container. | + +Retired (ADR-026): `nanoclaw`, `microclaw`, `nullclaw` — these `CLAW_TYPE`s fail `claw up` with a migration message. All drivers set `CLAW_MANAGED=true`, explicit `HOME`, and `DISCORD_REQUIRE_MENTION` (or equivalent) to prevent feedback loops. diff --git a/cmd/claw/spike_channel_digest_test.go b/cmd/claw/spike_channel_digest_test.go index 43d3a624..d4765e9b 100644 --- a/cmd/claw/spike_channel_digest_test.go +++ b/cmd/claw/spike_channel_digest_test.go @@ -67,11 +67,11 @@ func TestSpikeChannelDigestGeneratedPod(t *testing.T) { spikeBuildImage(t, repoRoot, channelMemoryImage, "examples/channel-memory/Dockerfile") rollcallDir := filepath.Join(repoRoot, "examples", "rollcall") - spikeBuildImage(t, rollcallDir, "nullclaw:latest", "Dockerfile.nullclaw-base") + spikeBuildImage(t, rollcallDir, "nanobot:latest", "Dockerfile.nanobot-base") spikeWriteFile(t, filepath.Join(workDir, "AGENTS.md"), "# Digest Spike Agent\n\nUse runtime channel context.") - spikeWriteFile(t, filepath.Join(workDir, "Clawfile"), `FROM nullclaw:latest + spikeWriteFile(t, filepath.Join(workDir, "Clawfile"), `FROM nanobot:latest -CLAW_TYPE nullclaw +CLAW_TYPE nanobot AGENT AGENTS.md MODEL primary openai/gpt-4o HANDLE discord diff --git a/cmd/claw/spike_mixed_managed_test.go b/cmd/claw/spike_mixed_managed_test.go index 7a177803..dd5cece4 100644 --- a/cmd/claw/spike_mixed_managed_test.go +++ b/cmd/claw/spike_mixed_managed_test.go @@ -27,15 +27,15 @@ func TestSpikeMixedManagedTypesCoexist(t *testing.T) { repoRoot := filepath.Join(filepath.Dir(thisFile), "..", "..") openFixture := filepath.Join(repoRoot, "testdata", "openclaw-stub") - microFixture := filepath.Join(repoRoot, "testdata", "microclaw-stub") + nanoFixture := filepath.Join(repoRoot, "testdata", "nanobot-stub") openTag := fmt.Sprintf("claw-spike-openclaw:%d", time.Now().UnixNano()) - microTag := fmt.Sprintf("claw-spike-microclaw:%d", time.Now().UnixNano()) + nanoTag := fmt.Sprintf("claw-spike-nanobot:%d", time.Now().UnixNano()) spikeBuildImage(t, openFixture, openTag, "Clawfile") - spikeBuildImage(t, microFixture, microTag, "Clawfile") + spikeBuildImage(t, nanoFixture, nanoTag, "Clawfile") t.Cleanup(func() { - _, _ = exec.Command("docker", "image", "rm", "-f", openTag, microTag).CombinedOutput() + _, _ = exec.Command("docker", "image", "rm", "-f", openTag, nanoTag).CombinedOutput() }) spikeEnsureRepoInfraImages(t, repoRoot, infraComponentClawdash) @@ -48,8 +48,8 @@ func TestSpikeMixedManagedTypesCoexist(t *testing.T) { if err := os.WriteFile(filepath.Join(agentsDir, "OPEN.md"), []byte("# Open Agent\n\nYou are open."), 0o644); err != nil { t.Fatalf("write OPEN.md: %v", err) } - if err := os.WriteFile(filepath.Join(agentsDir, "MICRO.md"), []byte("# Micro Agent\n\nYou are micro."), 0o644); err != nil { - t.Fatalf("write MICRO.md: %v", err) + if err := os.WriteFile(filepath.Join(agentsDir, "NANO.md"), []byte("# Nano Agent\n\nYou are nano."), 0o644); err != nil { + t.Fatalf("write NANO.md: %v", err) } podPath := filepath.Join(workDir, "claw-pod.yml") @@ -62,13 +62,13 @@ services: x-claw: agent: ./agents/OPEN.md - micro: + nano: image: %s x-claw: - agent: ./agents/MICRO.md + agent: ./agents/NANO.md environment: ANTHROPIC_API_KEY: sk-spike-anthropic -`, openTag, microTag) +`, openTag, nanoTag) if err := os.WriteFile(podPath, []byte(podYAML), 0o644); err != nil { t.Fatalf("write pod file: %v", err) } @@ -92,28 +92,27 @@ services: if _, err := os.Stat(openConfig); err != nil { t.Fatalf("openclaw config not generated: %v", err) } - microConfig := filepath.Join(workDir, ".claw-runtime", "micro", "config", "microclaw.config.yaml") - if _, err := os.Stat(microConfig); err != nil { - t.Fatalf("microclaw config not generated: %v", err) + nanoConfig := filepath.Join(workDir, ".claw-runtime", "nano", "nanobot-home", "config.json") + if _, err := os.Stat(nanoConfig); err != nil { + t.Fatalf("nanobot config not generated: %v", err) } - microSeed := filepath.Join(workDir, ".claw-runtime", "micro", "data", "runtime", "groups", "AGENTS.md") - if _, err := os.Stat(microSeed); err != nil { - t.Fatalf("microclaw seeded AGENTS.md not generated: %v", err) + nanoSeed := filepath.Join(workDir, ".claw-runtime", "nano", "nanobot-home", "workspace", "AGENTS.md") + if _, err := os.Stat(nanoSeed); err != nil { + t.Fatalf("nanobot seeded AGENTS.md not generated: %v", err) } - microBytes, err := os.ReadFile(microConfig) + nanoBytes, err := os.ReadFile(nanoConfig) if err != nil { - t.Fatalf("read microclaw config: %v", err) + t.Fatalf("read nanobot config: %v", err) } - var micro map[string]interface{} - if err := yaml.Unmarshal(microBytes, µ); err != nil { - t.Fatalf("parse microclaw config yaml: %v", err) + var nano map[string]interface{} + if err := yaml.Unmarshal(nanoBytes, &nano); err != nil { + t.Fatalf("parse nanobot config json: %v", err) } - if got := micro["llm_provider"]; got != "anthropic" { - t.Fatalf("expected microclaw llm_provider=anthropic, got %v", got) - } - if got := micro["model"]; got != "claude-sonnet-4" { - t.Fatalf("expected microclaw model=claude-sonnet-4, got %v", got) + agents, _ := nano["agents"].(map[string]interface{}) + defaults, _ := agents["defaults"].(map[string]interface{}) + if got := defaults["model"]; got != "anthropic/claude-sonnet-4" { + t.Fatalf("expected nanobot agents.defaults.model=anthropic/claude-sonnet-4, got %v", got) } composeBytes, err := os.ReadFile(composePath) @@ -142,18 +141,21 @@ services: t.Fatalf("unexpected open OPENCLAW_CONFIG_PATH: %q", openSvc.Environment["OPENCLAW_CONFIG_PATH"]) } - microSvc, ok := compose.Services["micro"] + nanoSvc, ok := compose.Services["nano"] if !ok { - t.Fatalf("compose missing micro service") + t.Fatalf("compose missing nano service") + } + if !nanoSvc.ReadOnly { + t.Fatalf("expected nano service to remain read_only") } - if microSvc.ReadOnly { - t.Fatalf("expected micro service to be writable") + if nanoSvc.Environment["CLAW_MANAGED"] != "true" { + t.Fatalf("unexpected nano CLAW_MANAGED: %q", nanoSvc.Environment["CLAW_MANAGED"]) } - if microSvc.Environment["MICROCLAW_CONFIG"] != "/app/config/microclaw.config.yaml" { - t.Fatalf("unexpected micro MICROCLAW_CONFIG: %q", microSvc.Environment["MICROCLAW_CONFIG"]) + if nanoSvc.Environment["HOME"] != "/root" { + t.Fatalf("unexpected nano HOME: %q", nanoSvc.Environment["HOME"]) } - for _, service := range []string{"open", "micro"} { + for _, service := range []string{"open", "nano"} { out, err := exec.Command("docker", "compose", "-f", composePath, "ps", "-q", service).Output() if err != nil { t.Fatalf("docker compose ps %s: %v", service, err) diff --git a/cmd/claw/spike_policy_test.go b/cmd/claw/spike_policy_test.go index ceca08ae..565921b7 100644 --- a/cmd/claw/spike_policy_test.go +++ b/cmd/claw/spike_policy_test.go @@ -54,7 +54,7 @@ func TestSpikePolicyEvaluatorGeneratedPod(t *testing.T) { agentImage := fmt.Sprintf("policy-spike-agent:%d", time.Now().UnixNano()) spikeEnsurePulledImage(t, pythonImage) rollcallDir := filepath.Join(repoRoot, "examples", "rollcall") - spikeBuildImage(t, rollcallDir, "nullclaw:latest", "Dockerfile.nullclaw-base") + spikeBuildImage(t, rollcallDir, "nanobot:latest", "Dockerfile.nanobot-base") spikeEnsureRepoInfraImages(t, repoRoot, infraComponentClawdash) spikeEnsureCllamaPassthroughImage(t, repoRoot) t.Cleanup(func() { @@ -62,9 +62,9 @@ func TestSpikePolicyEvaluatorGeneratedPod(t *testing.T) { }) spikeWriteFile(t, filepath.Join(workDir, "AGENTS.md"), "# Policy Spike Agent\n\nUse the configured model.") - spikeWriteFile(t, filepath.Join(workDir, "Clawfile"), `FROM nullclaw:latest + spikeWriteFile(t, filepath.Join(workDir, "Clawfile"), `FROM nanobot:latest -CLAW_TYPE nullclaw +CLAW_TYPE nanobot AGENT AGENTS.md MODEL primary openai/gpt-4o `) diff --git a/cmd/claw/spike_rollcall_test.go b/cmd/claw/spike_rollcall_test.go index bdc1a8e2..a91b00de 100644 --- a/cmd/claw/spike_rollcall_test.go +++ b/cmd/claw/spike_rollcall_test.go @@ -107,9 +107,6 @@ func TestSpikeRollCall(t *testing.T) { alwaysRebuild bool // true for stubs that embed discord-responder.sh }{ {"openclaw:latest", "Dockerfile.openclaw-base", "", true}, - {"nullclaw:latest", "Dockerfile.nullclaw-base", "", true}, - {"microclaw:latest", "Dockerfile.microclaw-base", "", true}, - {"nanoclaw-orchestrator:latest", "Dockerfile.nanoclaw-base", "", true}, {"nanobot:latest", "Dockerfile.nanobot-base", "", true}, {"picoclaw:latest", "Dockerfile.picoclaw-base", "", true}, // Hermes is a real runtime — build from the canonical dockerfiles dir so @@ -136,9 +133,6 @@ func TestSpikeRollCall(t *testing.T) { dockerfile string }{ {"rollcall-openclaw:latest", "agents/oc-roll/Clawfile"}, - {"rollcall-nullclaw:latest", "agents/nc-roll/Clawfile"}, - {"rollcall-microclaw:latest", "agents/mc-roll/Clawfile"}, - {"rollcall-nanoclaw:latest", "agents/nano-roll/Clawfile"}, {"rollcall-nanobot:latest", "agents/nb-roll/Clawfile"}, {"rollcall-picoclaw:latest", "agents/pc-roll/Clawfile"}, {"rollcall-hermes:latest", "agents/hm-roll/Clawfile"}, @@ -186,52 +180,28 @@ func TestSpikeRollCall(t *testing.T) { requireKeys: []string{"GEMINI_API_KEY"}, }, { - // Stubs send the bare provider/model ref directly via curl, so we - // must use a model name that Anthropic actually recognises today + // nb-roll carries the anthropic-messages ingress surface for the + // default run. The retired nullclaw/nanoclaw stubs used to cover + // it (ADR-026). Stubs send the + // bare provider/model ref directly via curl, so we must use a + // model name that Anthropic actually recognises today // (claude-sonnet-4 alone is no longer a valid alias upstream). - name: "nc-roll", - runtime: "nullclaw", - proxyFormat: "anthropic", - proxyModel: "anthropic/claude-sonnet-4-6", - expectedSurface: "anthropic-messages", - requireKeys: []string{"ANTHROPIC_API_KEY"}, - }, - { - name: "mc-roll", - runtime: "microclaw", - proxyFormat: "openai", - proxyModel: "openrouter/anthropic/claude-sonnet-4", - expectedSurface: "openai-chat-completions", - requireKeys: []string{"OPENROUTER_API_KEY"}, - }, - { - name: "nano-roll", - runtime: "nanoclaw", + name: "nb-roll", + runtime: "nanobot", proxyFormat: "anthropic", proxyModel: "anthropic/claude-sonnet-4-6", expectedSurface: "anthropic-messages", requireKeys: []string{"ANTHROPIC_API_KEY"}, }, { - name: "nb-roll", - runtime: "nanobot", - proxyFormat: "openai", - proxyModel: "openrouter/anthropic/claude-sonnet-4", - expectedSurface: "openai-chat-completions", - requireKeys: []string{"OPENROUTER_API_KEY"}, - }, - { - // pc-roll is currently broken upstream — picoclaw's gateway binary - // rejects a port=0 config pre-check that the clawdapus picoclaw - // driver does not populate. Reproduces on master, tracked in #137. - // Gated behind CLAW_SPIKE_ENABLE_PICOCLAW so it skips by default - // instead of failing the suite while #137 is open. + // #137 fixed the upstream gateway-port regression; keep PicoClaw in + // the default matrix so rollcall covers every retained driver. name: "pc-roll", runtime: "picoclaw", proxyFormat: "anthropic", proxyModel: "anthropic/claude-sonnet-4-6", expectedSurface: "anthropic-messages", - requireKeys: []string{"ANTHROPIC_API_KEY", "CLAW_SPIKE_ENABLE_PICOCLAW"}, + requireKeys: []string{"ANTHROPIC_API_KEY"}, }, { name: "hm-roll", @@ -633,12 +603,7 @@ func rollcallResolveContainerID(t *testing.T, composePath, serviceName string) s } func rollcallExpectedRuntimeKeywords(runtime string) []string { - switch strings.ToLower(runtime) { - case "nanoclaw": - return []string{"nanoclaw", "claude agent"} - default: - return []string{strings.ToLower(runtime)} - } + return []string{strings.ToLower(runtime)} } func rollcallWaitForRuntimeResponse(t *testing.T, token, channelID, afterMessageID string, wantKeywords []string, timeout time.Duration) string { diff --git a/cmd/claw/spike_test.go b/cmd/claw/spike_test.go index 999af0fa..4d30cd36 100644 --- a/cmd/claw/spike_test.go +++ b/cmd/claw/spike_test.go @@ -64,9 +64,7 @@ func TestSpikeComposeUp(t *testing.T) { } for _, key := range []string{ "MOMENTUM_TRADER_BOT_TOKEN", - "SYSTEMS_MONITOR_BOT_TOKEN", "VALUE_TRADER_BOT_TOKEN", - "MICRO_BOT_TOKEN", "HERMES_BOT_TOKEN", } { if env[key] == "" { @@ -76,9 +74,7 @@ func TestSpikeComposeUp(t *testing.T) { requiredIDs := []string{ "DESK_MANAGER_DISCORD_ID", "MOMENTUM_TRADER_DISCORD_ID", - "SYSTEMS_MONITOR_DISCORD_ID", "VALUE_TRADER_DISCORD_ID", - "MICRO_DISCORD_ID", "HERMES_DISCORD_ID", "DISCORD_GUILD_ID", "DISCORD_TRADING_FLOOR_CHANNEL", @@ -100,14 +96,8 @@ func TestSpikeComposeUp(t *testing.T) { if !spikeImageExists("openclaw:latest") { spikeBuildImage(t, dir, "openclaw:latest", "Dockerfile.openclaw-base") } - if !spikeImageExists("nanoclaw-orchestrator:latest") { - rollcallDir := filepath.Join(repoRoot, "examples", "rollcall") - spikeBuildImage(t, rollcallDir, "nanoclaw-orchestrator:latest", "Dockerfile.nanoclaw-base") - } spikeBuildImage(t, dir, "trading-desk:latest", "Clawfile") - spikeBuildImage(t, dir, "trading-desk-nanoclaw:latest", "Clawfile.nanoclaw") - spikeBuildImage(t, dir, "trading-desk-nullclaw:latest", "Clawfile.nullclaw") - spikeBuildImage(t, dir, "trading-desk-microclaw:latest", "Clawfile.microclaw") + spikeBuildImage(t, dir, "trading-desk-nanobot:latest", "Clawfile.nanobot") spikeBuildImage(t, dir, "trading-desk-hermes:latest", "Clawfile.hermes") spikeBuildImage(t, dir, "trading-api:latest", "Dockerfile.trading-api") spikeEnsureRepoInfraImages(t, repoRoot, infraComponentClawAPI, infraComponentClawdash, infraComponentClawWall) @@ -158,7 +148,7 @@ func TestSpikeComposeUp(t *testing.T) { if err != nil { t.Fatalf("parse expanded spike pod: %v", err) } - for _, svcName := range []string{"desk-manager", "momentum-trader", "systems-monitor", "value-trader", "micro", "hermes"} { + for _, svcName := range []string{"desk-manager", "momentum-trader", "value-trader", "hermes"} { svc := parsedPod.Services[svcName] if svc == nil || svc.Claw == nil { t.Fatalf("parsed pod: missing claw service %q", svcName) @@ -200,7 +190,7 @@ func TestSpikeComposeUp(t *testing.T) { // teardown runs the compose down and dumps logs. teardown := func() { - for _, svc := range []string{"desk-manager", "momentum-trader", "systems-monitor", "value-trader", "micro", "hermes", "trading-api"} { + for _, svc := range []string{"desk-manager", "momentum-trader", "value-trader", "hermes", "trading-api"} { name := fmt.Sprintf("trading-desk-%s-1", svc) out, _ := exec.Command("docker", "logs", "--tail", "100", name).CombinedOutput() t.Logf("=== %s logs ===\n%s", name, string(out)) @@ -385,7 +375,7 @@ func TestSpikeComposeUp(t *testing.T) { // ── Verify cllama context artifacts ───────────────────────────────────── - for _, agent := range []string{"desk-manager", "momentum-trader", "systems-monitor", "value-trader", "micro", "hermes"} { + for _, agent := range []string{"desk-manager", "momentum-trader", "value-trader", "hermes"} { agentDir := filepath.Join(runtimeDir, "context", agent) for _, rel := range []string{"AGENTS.md", "CLAWDAPUS.md", "metadata.json"} { if _, err := os.Stat(filepath.Join(agentDir, rel)); err != nil { @@ -457,161 +447,48 @@ func TestSpikeComposeUp(t *testing.T) { healthOut, _ := exec.Command("docker", "exec", containerName, "openclaw", "health", "--json").Output() t.Logf("openclaw health --json: %s", strings.TrimSpace(string(healthOut))) - // ── Verify Systems-Monitor (nanoclaw orchestrator) container artifacts ─── - - systemsMonitorContainer := spikeContainerName("systems-monitor") - spikeWaitRunning(t, systemsMonitorContainer, 45*time.Second) - - // Combined CLAUDE.md at /workspace/groups/main/CLAUDE.md (agent contract + CLAWDAPUS.md) - systemsMonitorClaude, errA := exec.Command("docker", "exec", systemsMonitorContainer, "cat", "/workspace/groups/main/CLAUDE.md").Output() - if errA != nil { - t.Errorf("systems-monitor: docker exec cat /workspace/groups/main/CLAUDE.md: %v", errA) - } else { - claudeStr := string(systemsMonitorClaude) - if !strings.Contains(claudeStr, "Systems-Monitor") { - t.Errorf("systems-monitor: CLAUDE.md doesn't mention Systems-Monitor: %q", claudeStr[:min(200, len(claudeStr))]) - } - if !strings.Contains(claudeStr, "trading-desk") { - t.Errorf("systems-monitor: CLAUDE.md doesn't reference pod name 'trading-desk'") - } - t.Logf("systems-monitor CLAUDE.md: %d bytes", len(systemsMonitorClaude)) - } - - // Docker socket must be mounted - systemsMonitorSock, errS := exec.Command("docker", "exec", systemsMonitorContainer, "ls", "-la", "/var/run/docker.sock").Output() - if errS != nil { - t.Errorf("systems-monitor: Docker socket not mounted at /var/run/docker.sock: %v", errS) - } else { - t.Logf("systems-monitor docker.sock: %s", strings.TrimSpace(string(systemsMonitorSock))) - } - - // Skills must use directory layout at orchestrator path - systemsMonitorSkills, errSk := exec.Command("docker", "exec", systemsMonitorContainer, "find", "/workspace/container/skills", "-name", "SKILL.md").Output() - if errSk != nil { - t.Errorf("systems-monitor: failed to list skills at /workspace/container/skills: %v", errSk) - } else if strings.TrimSpace(string(systemsMonitorSkills)) == "" { - t.Error("systems-monitor: no SKILL.md files found in /workspace/container/skills/") - } else { - t.Logf("systems-monitor skills (directory layout):\n%s", strings.TrimSpace(string(systemsMonitorSkills))) - } + // ── Verify Value-Trader (nanobot) artifacts ────────────────────────────── - // .env file with cllama bearer token (orchestrator's readEnvFile passes to agent-runners) - systemsMonitorEnvFile, errEF := exec.Command("docker", "exec", systemsMonitorContainer, "cat", "/workspace/.env").Output() - if errEF != nil { - t.Errorf("systems-monitor: .env not mounted at /workspace/.env: %v", errEF) - } else if !strings.Contains(string(systemsMonitorEnvFile), "ANTHROPIC_API_KEY=") { - t.Errorf("systems-monitor: .env should contain ANTHROPIC_API_KEY, got %q", string(systemsMonitorEnvFile)) - } else { - t.Logf("systems-monitor .env: %d bytes", len(systemsMonitorEnvFile)) - } - - // Verify compose.generated.yml has nanoclaw-specific markers - if !strings.Contains(composeSrc, "/var/run/docker.sock") { - t.Error("compose.generated.yml: expected Docker socket mount for nanoclaw service") - } - if !strings.Contains(composeSrc, "ANTHROPIC_BASE_URL") { - t.Error("compose.generated.yml: expected ANTHROPIC_BASE_URL for nanoclaw cllama wiring") - } - if !strings.Contains(composeSrc, "/app/config/microclaw.config.yaml") { - t.Error("compose.generated.yml: expected microclaw config mount") - } - if !strings.Contains(composeSrc, "/root/.hermes") { - t.Error("compose.generated.yml: expected hermes home mount") - } - if !strings.Contains(composeSrc, "HERMES_HOME") { - t.Error("compose.generated.yml: expected HERMES_HOME env for hermes service") - } - - // ANTHROPIC_BASE_URL env var points to cllama proxy - systemsMonitorEnvOut, errE := exec.Command("docker", "exec", systemsMonitorContainer, "printenv", "ANTHROPIC_BASE_URL").Output() - if errE != nil { - t.Errorf("systems-monitor: ANTHROPIC_BASE_URL not set: %v", errE) - } else { - systemsMonitorBaseURL := strings.TrimSpace(string(systemsMonitorEnvOut)) - if !strings.Contains(systemsMonitorBaseURL, "cllama") { - t.Errorf("systems-monitor: ANTHROPIC_BASE_URL should point to cllama proxy, got %q", systemsMonitorBaseURL) - } else { - t.Logf("systems-monitor ANTHROPIC_BASE_URL: %s", systemsMonitorBaseURL) - } - } - - // CLAW_NETWORK env var is set for agent-runner pod connectivity - systemsMonitorNetwork, errN := exec.Command("docker", "exec", systemsMonitorContainer, "printenv", "CLAW_NETWORK").Output() - if errN != nil { - t.Errorf("systems-monitor: CLAW_NETWORK not set: %v", errN) - } else { - t.Logf("systems-monitor CLAW_NETWORK: %s", strings.TrimSpace(string(systemsMonitorNetwork))) - } - - // ── Verify Value-Trader (nullclaw) artifacts ───────────────────────────── - - valueTraderConfigPath := filepath.Join(runtimeDir, "value-trader", "nullclaw-home", "config.json") + valueTraderConfigPath := filepath.Join(runtimeDir, "value-trader", "nanobot-home", "config.json") valueTraderConfigData := spikeReadFile(t, valueTraderConfigPath) var valueTraderCfg map[string]interface{} if err := json.Unmarshal([]byte(valueTraderConfigData), &valueTraderCfg); err != nil { - t.Fatalf("parse value-trader nullclaw config.json: %v", err) + t.Fatalf("parse value-trader nanobot config.json: %v", err) } - valueTraderChannels, ok := valueTraderCfg["channels"].(map[string]interface{}) + valueTraderAgents, ok := valueTraderCfg["agents"].(map[string]interface{}) if !ok { - t.Fatalf("value-trader config.json: missing channels object") + t.Fatalf("value-trader config.json: missing agents object") } - valueTraderDiscord, ok := valueTraderChannels["discord"].(map[string]interface{}) + valueTraderDefaults, ok := valueTraderAgents["defaults"].(map[string]interface{}) if !ok { - t.Fatalf("value-trader config.json: missing channels.discord object") + t.Fatalf("value-trader config.json: missing agents.defaults object") } - valueTraderAccounts, ok := valueTraderDiscord["accounts"].(map[string]interface{}) - if !ok { - t.Fatalf("value-trader config.json: missing channels.discord.accounts object") - } - valueTraderMainAccount, ok := valueTraderAccounts["main"].(map[string]interface{}) - if !ok { - t.Fatalf("value-trader config.json: missing channels.discord.accounts.main object") + if got, _ := valueTraderDefaults["model"].(string); got != "anthropic/claude-sonnet-4" { + t.Errorf("value-trader config.json: expected agents.defaults.model=anthropic/claude-sonnet-4, got %v", valueTraderDefaults["model"]) } - if tok, _ := valueTraderMainAccount["token"].(string); tok == "" { - t.Errorf("value-trader config.json: expected channels.discord.accounts.main.token to be set") - } - if gid, _ := valueTraderMainAccount["guild_id"].(string); gid != env["DISCORD_GUILD_ID"] { - t.Errorf("value-trader config.json: expected guild_id=%q, got %q", env["DISCORD_GUILD_ID"], gid) - } - if models, ok := valueTraderCfg["models"].(map[string]interface{}); ok { - if providers, ok := models["providers"].(map[string]interface{}); ok { - if anthropic, ok := providers["anthropic"].(map[string]interface{}); ok { - if got := anthropic["base_url"]; got != "http://cllama-passthrough:8080/v1" { - t.Errorf("value-trader config.json: expected anthropic.base_url=http://cllama-passthrough:8080/v1, got %v", got) - } + if providers, ok := valueTraderCfg["providers"].(map[string]interface{}); ok { + if anthropic, ok := providers["anthropic"].(map[string]interface{}); ok { + if got, _ := anthropic["base_url"].(string); !strings.Contains(got, "cllama") { + t.Errorf("value-trader config.json: expected providers.anthropic.base_url to point at cllama, got %v", anthropic["base_url"]) } + } else { + t.Errorf("value-trader config.json: missing providers.anthropic for cllama wiring") } + } else { + t.Errorf("value-trader config.json: missing providers object") } - valueTraderContainer := spikeContainerName("value-trader") - spikeWaitHealthy(t, valueTraderContainer, 60*time.Second) - if out, err := exec.Command("docker", "exec", valueTraderContainer, "cat", "/root/.nullclaw/config.json").CombinedOutput(); err != nil { - t.Errorf("value-trader: expected /root/.nullclaw/config.json in container: %v (%s)", err, strings.TrimSpace(string(out))) - } - - // ── Verify Micro (microclaw) artifacts ─────────────────────────────────── - - microConfigPath := filepath.Join(runtimeDir, "micro", "config", "microclaw.config.yaml") - microConfigData := spikeReadFile(t, microConfigPath) - var microCfg map[string]interface{} - if err := yaml.Unmarshal([]byte(microConfigData), µCfg); err != nil { - t.Fatalf("parse microclaw.config.yaml: %v", err) - } - if got := microCfg["llm_provider"]; got != "anthropic" { - t.Errorf("microclaw.config.yaml: expected llm_provider=anthropic, got %v", got) - } - if got := microCfg["model"]; got != "claude-sonnet-4" { - t.Errorf("microclaw.config.yaml: expected model=claude-sonnet-4, got %v", got) - } - if got := microCfg["llm_base_url"]; got != "http://cllama:8080/v1" { - t.Errorf("microclaw.config.yaml: expected llm_base_url=http://cllama:8080/v1, got %v", got) + valueTraderSeedPath := filepath.Join(runtimeDir, "value-trader", "nanobot-home", "workspace", "AGENTS.md") + valueTraderSeed := spikeReadFile(t, valueTraderSeedPath) + if !strings.Contains(valueTraderSeed, "Value-Trader") { + t.Errorf("value-trader seeded AGENTS.md doesn't mention Value-Trader") } - microContainer := spikeContainerName("micro") - spikeWaitHealthy(t, microContainer, 60*time.Second) - if out, err := exec.Command("docker", "exec", microContainer, "test", "-f", "/app/config/microclaw.config.yaml").CombinedOutput(); err != nil { - t.Errorf("micro: expected /app/config/microclaw.config.yaml in container: %v (%s)", err, strings.TrimSpace(string(out))) + valueTraderContainer := spikeContainerName("value-trader") + spikeWaitHealthy(t, valueTraderContainer, 60*time.Second) + if out, err := exec.Command("docker", "exec", valueTraderContainer, "cat", "/root/.nanobot/config.json").CombinedOutput(); err != nil { + t.Errorf("value-trader: expected /root/.nanobot/config.json in container: %v (%s)", err, strings.TrimSpace(string(out))) } // ── Verify Hermes artifacts ────────────────────────────────────────────── @@ -685,7 +562,6 @@ func TestSpikeComposeUp(t *testing.T) { spikeVerifyDiscordGreeting(t, env["DESK_MANAGER_BOT_TOKEN"], channelID, "desk-manager online", 10*time.Second) spikeVerifyDiscordGreeting(t, env["MOMENTUM_TRADER_BOT_TOKEN"], channelID, "momentum-trader online", 10*time.Second) spikeVerifyDiscordGreeting(t, env["DESK_MANAGER_BOT_TOKEN"], channelID, "value-trader online", 15*time.Second) - spikeVerifyDiscordGreeting(t, env["DESK_MANAGER_BOT_TOKEN"], channelID, "microclaw online", 15*time.Second) spikeVerifyDiscordGreeting(t, env["DESK_MANAGER_BOT_TOKEN"], channelID, "hermes online", 15*time.Second) // trading-api posts its own startup message to Discord via webhook — this @@ -696,8 +572,8 @@ func TestSpikeComposeUp(t *testing.T) { // The startup message must contain Discord mentions for openclaw agents. // CLAW_HANDLE_* vars are broadcast to all pod services by claw, so trading-api // picks up the agent IDs and includes <@ID> mentions in its webhook message. - // Note: Systems-Monitor (nanoclaw) has no greeting mechanism — the mock_server.py only - // formats mentions for agents it knows about (desk-manager, momentum-trader). + // Note: the mock_server.py only formats mentions for agents it knows about + // (desk-manager, momentum-trader). if deskManagerID := env["DESK_MANAGER_DISCORD_ID"]; deskManagerID != "" { spikeVerifyDiscordGreeting(t, env["DESK_MANAGER_BOT_TOKEN"], channelID, "<@"+deskManagerID+">", 5*time.Second) } @@ -871,9 +747,6 @@ func spikeTagRunnerBaseImage(t *testing.T, tag string) { func spikeIsRunnerBaseTag(tag string) bool { switch tag { case "openclaw:latest", - "nullclaw:latest", - "microclaw:latest", - "nanoclaw-orchestrator:latest", "nanobot:latest", "picoclaw:latest": return true diff --git a/cmd/claw/trading_desk_contracts_test.go b/cmd/claw/trading_desk_contracts_test.go index b7720395..9bfa8148 100644 --- a/cmd/claw/trading_desk_contracts_test.go +++ b/cmd/claw/trading_desk_contracts_test.go @@ -10,7 +10,6 @@ import ( func TestTradingDeskContractsDoNotContainInstructionPlaceholders(t *testing.T) { agentDir := filepath.Join("..", "..", "examples", "trading-desk", "agents") files := []string{ - "SYSTEMS-MONITOR.md", "SOCIAL-TRADER.md", "NEWS-ROUTER.md", "MACRO-TRADER.md", diff --git a/dockerfiles/nanoclaw-orchestrator/Dockerfile b/dockerfiles/nanoclaw-orchestrator/Dockerfile deleted file mode 100644 index b51b71dc..00000000 --- a/dockerfiles/nanoclaw-orchestrator/Dockerfile +++ /dev/null @@ -1,32 +0,0 @@ -# NanoClaw Orchestrator — built from stock nanoclaw + Clawdapus governance patch -# -# Clones upstream nanoclaw, applies a small patch to forward ANTHROPIC_BASE_URL -# and CLAW_NETWORK to ephemeral agent-runner containers, then builds. -# No fork required. -ARG NANOCLAW_REF=main -ARG NANOCLAW_REPO=https://github.com/qwibitai/nanoclaw.git - -FROM node:22-slim AS builder -ARG NANOCLAW_REF -ARG NANOCLAW_REPO -RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates && rm -rf /var/lib/apt/lists/* -WORKDIR /src -RUN git clone --depth 1 --branch ${NANOCLAW_REF} ${NANOCLAW_REPO} . -COPY governance.patch /tmp/governance.patch -RUN git apply /tmp/governance.patch -RUN npm ci -RUN npx tsc - -FROM node:22-slim -COPY --from=docker:27-cli /usr/local/bin/docker /usr/local/bin/docker -RUN apt-get update && apt-get install -y --no-install-recommends python3 make g++ && rm -rf /var/lib/apt/lists/* -WORKDIR /app -COPY --from=builder /src/dist/ ./dist/ -COPY --from=builder /src/node_modules/ ./node_modules/ -COPY --from=builder /src/package.json ./ -RUN npm rebuild better-sqlite3 -COPY --from=builder /src/container/ ./container/ -RUN mkdir -p /workspace/groups/main /workspace/data /workspace/store -RUN rm -rf /var/lib/apt/lists/* && apt-get purge -y python3 make g++ && apt-get autoremove -y || true -WORKDIR /workspace -ENTRYPOINT ["node", "/app/dist/index.js"] diff --git a/docs/PROJECT_STATE.md b/docs/PROJECT_STATE.md index 934f9095..ed4398eb 100644 --- a/docs/PROJECT_STATE.md +++ b/docs/PROJECT_STATE.md @@ -7,7 +7,7 @@ of them — do not duplicate their content, point to them. Maintenance: when a capability lands or a release cuts, update the relevant row here in the same change. Trust order when sources disagree: current code → tests → examples → ADRs → plans. -Last reconciled: 2026-05-31 (against `master` @ v0.21.0, issue #280). +Last reconciled: 2026-08-03 (issue #353 runner-retirement audit). Status legend: **Shipped** (implemented and in use) · **Partial** (some shipped; gap noted) · **Planned** (designed, not implemented) · **External** (depends on an out-of-tree component, @@ -42,6 +42,7 @@ e.g. an org's Master Claw policy). | 022 | Infra Image Lifecycle & Four-Verb Surface | Shipped | `claw pull/build/up/down`; `internal/infraimages/release_manifest.go` | | 023 | Explicit cllama Ingress Surface Matrix | Shipped | `internal/cllama/ingress.go` compiles OpenAI Chat Completions at `/v1/chat/completions` and Anthropic Messages at `/v1/messages`. Anthropic-surface providers: `anthropic`, `synthetic`, `minimax-portal`, `kimi-coding`, `cloudflare-ai-gateway`, `xiaomi`; other providers default to OpenAI chat. Runtime dispatch treats `/v1/messages` as Anthropic and other POST paths as OpenAI. | | 024 | Runner Base Refresh from Upstream | Shipped | `claw pull` runner refresh; `built-against`/`image-id`/`recipe-sha` labels | +| 026 | Runner Adoption and Retirement | Shipped | four retained drivers; retired types fail closed with Hermes migration guidance | *(ADR-005 does not exist.)* @@ -65,7 +66,7 @@ e.g. an org's Master Claw policy). | claw-api + Master Claw (ADR-012/015) | Partial | manifesto, what-is, architecture | Budget caps enforced by cllama; policy decisions external; model-restrict enforcement partial | | Social topology / HANDLE (ADR-003/016) | Shipped | social-topology | No | | Persona materialization | Shipped | anatomy | No | -| 7 runner drivers | Shipped | drivers | No | +| 4 runner drivers | Shipped | drivers | No | | Context feeds (ADR-013) | Shipped | surfaces-and-skills, cllama, social-topology | No | | `TRACK` / recipe / bake | **Planned** | what-is roadmap, architecture | Correctly future, label it clearly | | Drift scoring | **External/Planned** | what-is, cllama, cli, README, manifesto, architecture | No built-in score; docs mark it external/planned | diff --git a/docs/decisions/026-runner-adoption-and-retirement.md b/docs/decisions/026-runner-adoption-and-retirement.md new file mode 100644 index 00000000..21c223d9 --- /dev/null +++ b/docs/decisions/026-runner-adoption-and-retirement.md @@ -0,0 +1,206 @@ +# ADR-026: Runner Adoption Measurement and Driver Retirement Policy + +**Date:** 2026-08-03 +**Status:** Accepted +**Depends on:** ADR-024 (Runner Base Refresh From Upstream) +**Tracks:** #353 (audit adoption and retire stagnant runner integrations) +**Evidence snapshot:** 2026-08-03T01:58Z — see `scripts/runner-adoption-snapshot/` and `docs/evidence/2026-08-03-runner-adoption.json` + +## Context + +Clawdapus carries seven runner drivers: OpenClaw, Hermes, Nanobot, NanoClaw, PicoClaw, MicroClaw, +and NullClaw. Each one is a standing cost — compatibility with an upstream we do not control, image +refresh, documentation, fixtures, a conformance shape in `examples/rollcall/`, and a share of every +driver-generic bug fix. `AGENTS.md` records the multiplier plainly: "Bug fixes in one driver often +apply to all 7." + +The request that opened #353 was to retire runners "not seeing an increase in adoption over time." +This ADR exists because **that rule cannot be applied as stated**, and discovering why changed the +decision. + +## The measurement problem + +Three candidate evidence sources were tried. Two do not survive scrutiny. + +**Stargazer timestamps — unavailable.** #353 specified these as the adoption time series. The +`/repos/{owner}/{repo}/stargazers` endpoint with `Accept: application/vnd.github.star+json` returns +404 for every external upstream from our environment. This is not rate limiting (4846/5000 core +requests remaining when tested) and not a token scope problem — the identical call succeeds against +`mostlydev/clawdapus` and returns real `starred_at` values, and plain `repos/{owner}/{repo}` succeeds +for every upstream. Only the subresource is blocked. Recorded here as a permanent limitation so a +future collaborator does not waste the same afternoon. + +**Per-release downloads normalized by release age — confounded, rejected.** Downloads concentrate in +the days after publication and taper. Age-normalization therefore flatters whichever release is +newest, and in our data every project with asset-bearing releases shows its most recent release with +the highest downloads/day, *including the ones visibly winding down*. It measures recency, not growth. +Release data is retained in the evidence artifact for absolute distribution scale and cadence only. + +**Fork creation timestamps bucketed into 30-day windows — adopted as the primary metric.** Unlike +stars, total downloads, and fork *counts*, a bucketed fork rate is not cumulative: it can fall. It is +a weak proxy for users, but it is the only non-cumulative adoption series obtainable for every +runner, and it is reproducible from a public endpoint. + +Secondary sources corroborate where available: PyPI download history (`nanobot-ai`), Docker Hub +`pull_count` (PicoClaw), and cumulative GitHub release-asset downloads. ghcr.io publishes no public +pull counts, so container pulls are not comparable across the cohort. + +One identification note, since it cost time: PyPI publishes no project URLs for `nanobot-ai`, so +Nanobot's upstream is not discoverable from the package page. It was confirmed as `HKUDS/nanobot` by +matching that repository's `pyproject.toml` — `name = "nanobot-ai"`, `version = "0.3.0"` — against +the published package. The collector records the mapping so the next audit does not have to redo it. + +**Commit velocity is explicitly not an adoption metric.** It measures maintainer output. It is +recorded because upstream viability is a separate concern, and it must never decide adoption. The +evidence below contains a case that proves the point. + +## Evidence + +New forks per 30-day window, newest first, snapshot 2026-08-03T01:58Z: + +| runner | 0-30d | 30-60d | 60-90d | 90-120d | newest/oldest | recent60/prior60 | commits QoQ | latest release | +|---|---|---|---|---|---|---|---|---| +| Hermes | 6036 | 7587 | 10442 | 16307 | 0.37 | 0.51 | **+86%** | 2026-07-30 | +| OpenClaw | 1807 | 2182 | 3756 | 6980 | 0.26 | 0.37 | **+6%** | 2026-08-02 | +| Nanobot | 366 | 314 | 480 | 813 | 0.45 | 0.53 | -33% | 2026-07-25 | +| PicoClaw | 315 | 102 | 154 | 306 | **1.03** | **0.91** | **-76%** | 2026-07-02 (`nightly`) | +| NanoClaw | 167 | 222 | 413 | 2346 | **0.07** | 0.14 | -32% | 2026-08-01 | +| NullClaw | 32 | 22 | 44 | 44 | 0.73 | 0.61 | -89% | **2026-05-29** | +| MicroClaw | **0** | 3 | 10 | 8 | **0.00** | 0.17 | -91% | 2026-08-01 | + +Supporting figures: stars — OpenClaw 384960, Hermes 224342, Nanobot 46522, NanoClaw 30411, +PicoClaw 29799, NullClaw 7987, MicroClaw 730. Forks — OpenClaw 80906, Hermes 43387, Nanobot 8231, +NanoClaw 12871, PicoClaw 4381, NullClaw 929, MicroClaw 132. Cumulative GitHub release-asset +downloads — OpenClaw 1825617, Hermes 25561, PicoClaw 227049, NullClaw 13229, MicroClaw 12161, +and none for Nanobot or NanoClaw. PicoClaw also has 210807 Docker Hub pulls. `nanobot-ai` PyPI +monthly downloads — Mar 79326, Apr 53246, May 33854, Jun 33446, Jul 68201. + +The table above is the committed collector's output. Those figures were also collected independently +by a second agent using separately written tooling, which agreed exactly on the low-volume boundary +(MicroClaw 0 and NullClaw 32) and within live drift on the larger projects. Two +collectors reaching the same answer is a materially stronger basis for deleting a driver than either +run alone. It establishes that the fork counts are not artifacts of one implementation; it does not +turn those proxy counts into Clawdapus usage telemetry. + +### Two findings that determine the policy + +**A derivative is not a useful retirement boundary.** Six runners have declining fork rates, +including OpenClaw (0.26) and Hermes (0.37), which are unambiguous keeps on every other axis. +PicoClaw is the sole flat-to-rising exception. A literal rule of “retire what is not increasing” +would therefore delete six of seven drivers, including the two the product is built around. Recent +absolute activity and corroborating scale matter more than the sign of the derivative. + +**PicoClaw has the best adoption retention in the cohort and the worst maintenance trend.** Its +fork rate is the only one flat-to-rising (1.03 newest/oldest, 0.90 on 60-day windows) while its +commit volume fell 75%. The two metrics return opposite verdicts, and the adoption metric is the one +that answers the question actually being asked. Any policy that had used commit velocity as the +deciding signal would have deleted the healthiest-retaining runner in the set. + +## Decision + +The operator's instruction was to stop carrying the ambiguous runners: *"Just remove all the +ambiguous ones. I don't need to maintain them."* The basis for this ADR is therefore **maintenance +cost**, with the adoption evidence used to decide which runners are defensibly worth that cost. It is +worth being explicit that this is a broader basis than adoption alone, rather than pretending a pure +adoption rule produced this set. + +The evidence is a review boundary, not an automatic three-clause deletion formula. Applying a +conjunction after seeing the rows would be post-hoc: NanoClaw's lack of release assets is missing +corroboration, not evidence of missing users, while NullClaw has measurable adoption despite its +maintenance signal. The decision is therefore recorded at its real level of judgment: + +1. **Keep a runner when the public case for carrying it is clear.** Absolute recent fork activity is + the common floor; package, image, or release distribution is corroboration where available. +2. **Treat missing corroboration and maintenance uncertainty as ambiguity, not as negative adoption + evidence.** Those signals can justify declining the integration's maintenance cost, but must not + be rewritten as “no users.” +3. **Record each retirement independently.** A future audit re-runs the evidence; it does not + mechanically delete a runner because one proxy crossed a tuned threshold. + +### Verdicts + +| runner | public evidence | uncertainty | decision | +|---|---|---|---| +| OpenClaw | 1807 forks/30d; 1.8m release-asset downloads | none material | **retain** | +| Hermes | 6036 forks/30d; 25.5k release-asset downloads | none material | **retain** | +| Nanobot | 366 forks/30d; PyPI 68201/month in July | download series is choppy | **retain** | +| PicoClaw | 315 forks/30d; 210807 Docker pulls; best retention | commits down 76% | **retain** | +| NanoClaw | 167 forks/30d; 30411 stars | no independent distribution signal; 0.07 retention | **retire** | +| NullClaw | 32 forks/30d; 13.2k release-asset downloads | no release in 64 days; commits down 89% | **retire** | +| MicroClaw | 0 forks/30d; 730 stars | minimal public adoption despite active releases | **retire** | + +**MicroClaw has minimal public adoption.** Zero new forks in the trailing 30 days, 0.00 retention, 730 +stars, and 12161 cumulative release-asset downloads — orders of magnitude below PicoClaw. It is +*actively maintained*, four releases in the ten days before the snapshot, which is precisely the +point: maintenance activity does not establish adoption. The evidence supports “minimal public +adoption,” not “no users.” + +**NullClaw's maintenance signal is weak.** No release since 2026-05-29 and commit volume down 89%. Its +adoption is real (7987 stars, 13229 cumulative release-asset downloads, 32 new forks), so this is a +maintenance-cost decision under uncertainty, not a claim that nobody uses it or that the upstream +has stopped entirely. + +**NanoClaw remains ambiguous.** No independent public distribution metric was found, so its 30411 +stars cannot be checked against package downloads, image pulls, or release-asset downloads. That +absence is ambiguity, not negative evidence. NanoClaw also carries the steepest adoption decay in +the cohort by a wide margin — 0.07 newest/oldest, 0.14 across 60-day windows, against a next-worst of +0.26. Without a corroborating signal, the operator declined the cost of maintaining the integration. + +**PicoClaw is retained, and this is the load-bearing case.** On maintenance it looks like the worst +runner in the set: commits down 76%, and its most recent +tag is a `nightly`. On adoption it is the *best* — the only runner whose fork rate is flat-to-rising +(1.03 newest/oldest, 0.91 across 60-day windows), plus 210807 Docker Hub pulls and 227049 cumulative +release-asset downloads. Two metrics, opposite verdicts. Any rule that let maintenance activity +decide would have deleted the healthiest-retaining runner in the cohort while keeping weaker ones. + +**Removed `CLAW_TYPE` values fail closed for one release** with a validation error naming the retired +runner, ADR-026, and Hermes as the supported migration target (`internal/driver/registry.go`). A +`claw up` that fails with a clear message is compile-time behavior consistent with the compiler +contract; silently changing behavior is not. + +**Revisit policy:** re-run the collector and re-evaluate at each minor release, or whenever a +retained runner's upstream goes 60+ days without a release. + +## Consequences + +**The cost side is not free, and it is not the deleted lines.** Driver LOC: openclaw 3523, hermes +2784, picoclaw 1252, nullclaw 1125, nanobot 974, microclaw 847, nanoclaw 597. Retiring NanoClaw, +MicroClaw, and NullClaw reclaims ~2,600 lines of driver code plus their examples, fixtures, and +docs. What it actually costs is **three conformance shapes**. `examples/rollcall/` was a seven-driver +pod and `AGENTS.md` requires it remain a full spike test; `TestSpikeRollCall` is the primary proof +that cllama proxy enforcement holds across *heterogeneous* runner shapes — distinct config formats, +`HOME` handling, mention-only semantics, cron paths. Every shape removed is a shape that can no +longer catch a driver-generic regression; the pre-retirement guide recorded that bug fixes commonly +applied across all seven. The rollcall fixture must be **revised to keep full conformance coverage across all +four retained drivers**, not merely have three services deleted from it. The offsetting gain is the +operator's stated one: every driver-generic fix now has four targets instead of seven, and none of +the remaining four is a runner we cannot corroborate real use for. + +**Historical references are not support surface, and must not be edited.** A retired runner's name +survives in three distinct kinds of place, and they get different treatment: + +- **Active support surface — update.** `internal/driver/`, the driver registry, build recipes and + runner aliases, `examples/`, `README.md`, `site/guide/`, the embedded skill text under + `cmd/claw/skill_data/`, and generated-artifact test expectations. These describe what Clawdapus + supports today; leaving a retired runner in them is a false claim. +- **Historical record — leave alone.** `docs/plans/`, superseded ADRs, and existing `site/changelog.md` + entries. These are dated accounts of what was true when written. `AGENTS.md` already forbids + rewriting historical changelog entries; the same reasoning covers plans and prior ADRs. A plan + from 2026-03 that describes bringing MicroClaw to driver parity remains a true statement about + 2026-03. Editing it to erase the runner falsifies the record and destroys the context a future + reader needs to understand why the driver existed. +- **Migration guidance — add.** The validation error for a retired `CLAW_TYPE`, the new changelog + entry, and this ADR. These are the only places that should describe the removal itself. + +The practical test: if a reader would be misled about what `claw up` will do *today*, update it. If +they would only be reading about the past, leave it. + +**The central limitation, stated plainly:** we have no telemetry on which drivers *Clawdapus users* +actually run. Every metric in this ADR is upstream popularity. It answers "would anyone out there +miss this runner", not "do our users depend on this driver". The entire decision rests on that +substitution, and if driver-level usage telemetry ever exists it supersedes this evidence model. + +**Reproducibility.** `scripts/runner-adoption-snapshot/` re-collects every surviving metric and +writes a dated JSON artifact under `docs/evidence/`. The collector captures fork windows, commit +windows, release cadence and asset downloads, Docker Hub pulls where public, and PyPI history where +available. A future audit can refresh the evidence without inheriting today's judgment as code. diff --git a/docs/evidence/2026-08-03-runner-adoption.json b/docs/evidence/2026-08-03-runner-adoption.json new file mode 100644 index 00000000..4af520f8 --- /dev/null +++ b/docs/evidence/2026-08-03-runner-adoption.json @@ -0,0 +1,185 @@ +{ + "captured_at": "2026-08-03T01:58:23.798226Z", + "window_days": 30, + "window_count": 4, + "limitations": [ + "GitHub /stargazers with the star+json media type returns 404 for external repositories from this environment; it is not rate limiting and not a token scope issue. Stargazer time series are unavailable.", + "Per-release downloads normalized by release age is confounded by post-publication download decay and must not be read as a growth trend.", + "ghcr.io publishes no public pull counts, so image pulls are only comparable for docker.io-hosted runners.", + "No metric here measures Clawdapus-side usage. All of it is upstream popularity." + ], + "runners": [ + { + "driver": "openclaw", + "repo": "openclaw/openclaw", + "stars": 384960, + "forks": 80906, + "pushed_at": "2026-08-03T01:58:22Z", + "latest_release": "2026-08-02T08:11:31Z", + "fork_windows": [ + 1807, + 2182, + 3756, + 6980 + ], + "commit_windows_90d": [ + 34524, + 32475 + ], + "release_count": 229, + "release_asset_downloads": 1825617 + }, + { + "driver": "hermes", + "repo": "NousResearch/hermes-agent", + "stars": 224342, + "forks": 43387, + "pushed_at": "2026-08-03T01:53:26Z", + "latest_release": "2026-07-30T23:45:37Z", + "fork_windows": [ + 6036, + 7587, + 10442, + 16307 + ], + "commit_windows_90d": [ + 13124, + 7074 + ], + "release_count": 23, + "release_asset_downloads": 25561 + }, + { + "driver": "nanobot", + "repo": "HKUDS/nanobot", + "pypi": "nanobot-ai", + "stars": 46522, + "forks": 8231, + "pushed_at": "2026-08-03T01:51:35Z", + "latest_release": "2026-07-25T08:08:47Z", + "fork_windows": [ + 366, + 314, + 480, + 813 + ], + "commit_windows_90d": [ + 1479, + 2192 + ], + "release_count": 19, + "pypi_monthly": [ + { + "period": "2026-02", + "count": 49581 + }, + { + "period": "2026-03", + "count": 79326 + }, + { + "period": "2026-04", + "count": 53246 + }, + { + "period": "2026-05", + "count": 33854 + }, + { + "period": "2026-06", + "count": 33446 + }, + { + "period": "2026-07", + "count": 68201 + }, + { + "period": "2026-08", + "count": 2135 + } + ] + }, + { + "driver": "nanoclaw", + "repo": "nanocoai/nanoclaw", + "note": "canonical repo redirected from qwibitai/nanoclaw", + "stars": 30411, + "forks": 12871, + "pushed_at": "2026-08-02T14:13:24Z", + "latest_release": "2026-08-01T20:46:31Z", + "fork_windows": [ + 167, + 222, + 413, + 2346 + ], + "commit_windows_90d": [ + 888, + 1310 + ], + "release_count": 5 + }, + { + "driver": "picoclaw", + "repo": "sipeed/picoclaw", + "docker_hub": "sipeed/picoclaw", + "stars": 29799, + "forks": 4381, + "pushed_at": "2026-07-30T18:32:36Z", + "latest_release": "2026-07-02T01:26:53Z", + "fork_windows": [ + 315, + 102, + 154, + 306 + ], + "commit_windows_90d": [ + 506, + 2077 + ], + "release_count": 16, + "release_asset_downloads": 227049, + "docker_hub_pulls": 210807 + }, + { + "driver": "microclaw", + "repo": "microclaw/microclaw", + "stars": 730, + "forks": 132, + "pushed_at": "2026-08-02T02:09:14Z", + "latest_release": "2026-08-01T21:26:19Z", + "fork_windows": [ + 0, + 3, + 10, + 8 + ], + "commit_windows_90d": [ + 91, + 996 + ], + "release_count": 161, + "release_asset_downloads": 12161 + }, + { + "driver": "nullclaw", + "repo": "nullclaw/nullclaw", + "stars": 7987, + "forks": 929, + "pushed_at": "2026-07-19T04:20:06Z", + "latest_release": "2026-05-29T13:41:10Z", + "fork_windows": [ + 32, + 22, + 44, + 44 + ], + "commit_windows_90d": [ + 260, + 2475 + ], + "release_count": 30, + "release_asset_downloads": 13229 + } + ] +} diff --git a/examples/rollcall/Dockerfile.microclaw-base b/examples/rollcall/Dockerfile.microclaw-base deleted file mode 100644 index 53eabb2f..00000000 --- a/examples/rollcall/Dockerfile.microclaw-base +++ /dev/null @@ -1,31 +0,0 @@ -# Base image for microclaw agents (test stub). -# Usage: docker build -t microclaw:latest -f Dockerfile.microclaw-base . -# -# MicroClaw is a fictional test runtime. This stub provides a long-running -# process named "microclaw" so the driver healthcheck (pgrep) passes. - -FROM alpine:3.20 - -RUN apk add --no-cache bash curl jq procps tini - -# Stub binary: long-running process with "microclaw" in its name -COPY <<'STUB' /usr/local/bin/microclaw -#!/bin/sh -echo "microclaw spike stub running (pid $$)" >&2 -while true; do sleep 3600; done -STUB -RUN chmod +x /usr/local/bin/microclaw - -COPY discord-responder.sh /usr/local/bin/discord-responder -RUN chmod +x /usr/local/bin/discord-responder - -# Entrypoint wrapper: start Discord responder in background, then run main process -COPY <<'WRAPPER' /usr/local/bin/microclaw-entrypoint -#!/bin/sh -/usr/local/bin/discord-responder & -exec microclaw -WRAPPER -RUN chmod +x /usr/local/bin/microclaw-entrypoint - -ENTRYPOINT ["/sbin/tini", "--"] -CMD ["microclaw-entrypoint"] diff --git a/examples/rollcall/Dockerfile.nanoclaw-base b/examples/rollcall/Dockerfile.nanoclaw-base deleted file mode 100644 index 11f474b8..00000000 --- a/examples/rollcall/Dockerfile.nanoclaw-base +++ /dev/null @@ -1,38 +0,0 @@ -# Base image for nanoclaw (Claude Agent SDK) orchestrator. -# Usage: docker build -t nanoclaw-orchestrator:latest -f Dockerfile.nanoclaw-base . -# -# NanoClaw spawns ephemeral agent containers via the Docker socket. -# It requires the Claude Code SDK and Docker CLI. - -FROM node:22-slim - -RUN apt-get update && apt-get install -y --no-install-recommends \ - ca-certificates curl git tini python3 make g++ procps jq \ - && rm -rf /var/lib/apt/lists/* - -# Install Docker CLI (for spawning agent containers) -RUN curl -fsSL https://get.docker.com | sh - -# Install Claude Code SDK -RUN npm install -g @anthropic-ai/claude-code - -RUN mkdir -p /workspace/groups/main /workspace/container/skills - -WORKDIR /workspace - -HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ - CMD pgrep -f "node" > /dev/null || exit 1 - -COPY discord-responder.sh /usr/local/bin/discord-responder -RUN chmod +x /usr/local/bin/discord-responder - -# Entrypoint wrapper: start Discord responder in background, then run main process -COPY <<'WRAPPER' /usr/local/bin/nanoclaw-entrypoint -#!/bin/sh -/usr/local/bin/discord-responder & -exec node -e "console.log('nanoclaw orchestrator ready'); setInterval(() => {}, 60000)" -WRAPPER -RUN chmod +x /usr/local/bin/nanoclaw-entrypoint - -ENTRYPOINT ["/usr/bin/tini", "--"] -CMD ["nanoclaw-entrypoint"] diff --git a/examples/rollcall/Dockerfile.nullclaw-base b/examples/rollcall/Dockerfile.nullclaw-base deleted file mode 100644 index b0b058ce..00000000 --- a/examples/rollcall/Dockerfile.nullclaw-base +++ /dev/null @@ -1,71 +0,0 @@ -# Base image for nullclaw agents (test stub). -# Usage: docker build -t nullclaw:latest -f Dockerfile.nullclaw-base . -# -# NullClaw is a fictional test runtime. This stub provides a minimal HTTP -# health endpoint on :3000 and cron subcommands for driver integration testing. - -FROM alpine:3.20 - -RUN apk add --no-cache curl jq tini python3 - -# Stub binary: health endpoint + cron commands + agent invocation -COPY <<'STUB' /usr/local/bin/nullclaw -#!/bin/sh -set -eu -cmd="${1:-run}" -case "$cmd" in - run) - mkdir -p /tmp/nullclaw-spike - printf "ok\n" >/tmp/nullclaw-spike/health - exec python3 -m http.server 3000 --directory /tmp/nullclaw-spike - ;; - cron) - sub="${2:-}" - case "$sub" in - list) - jobs_file=/tmp/nullclaw-spike/cron.jobs - count=0 - if [ -f "$jobs_file" ]; then count="$(wc -l <"$jobs_file" | tr -d ' ')"; fi - echo "info(cron): Scheduled jobs (${count}):" - if [ -f "$jobs_file" ]; then - i=0 - while IFS="$(printf '\t')" read -r expr command; do - [ -n "${expr:-}" ] || continue - i=$((i + 1)) - echo "info(cron): - job-${i} | ${expr} | next=0 | status=n/a cmd: ${command}" - done <"$jobs_file" - fi - ;; - add) - [ "$#" -ge 4 ] || { echo "error: usage: nullclaw cron add '' ''" >&2; exit 2; } - mkdir -p /tmp/nullclaw-spike - printf "%s\t%s\n" "$3" "$4" >>/tmp/nullclaw-spike/cron.jobs - echo "info(cron): added schedule '$3'" - ;; - *) echo "error: unknown nullclaw cron subcommand '$sub'" >&2; exit 2 ;; - esac - ;; - agent) - [ "${2:-}" = "-m" ] && { echo "nullclaw agent: ${3:-}"; exit 0; } - echo "error: usage: nullclaw agent -m " >&2; exit 2 - ;; - *) echo "error: unknown nullclaw subcommand '$cmd'" >&2; exit 2 ;; -esac -STUB -RUN chmod +x /usr/local/bin/nullclaw - -COPY discord-responder.sh /usr/local/bin/discord-responder -RUN chmod +x /usr/local/bin/discord-responder - -# Entrypoint wrapper: start Discord responder in background, then run main process -COPY <<'WRAPPER' /usr/local/bin/nullclaw-entrypoint -#!/bin/sh -/usr/local/bin/discord-responder & -exec nullclaw run -WRAPPER -RUN chmod +x /usr/local/bin/nullclaw-entrypoint - -EXPOSE 3000 - -ENTRYPOINT ["/sbin/tini", "--"] -CMD ["nullclaw-entrypoint"] diff --git a/examples/rollcall/README.md b/examples/rollcall/README.md index ed34e681..5264a241 100644 --- a/examples/rollcall/README.md +++ b/examples/rollcall/README.md @@ -2,7 +2,7 @@ End-to-end driver parity fixture for Clawdapus. -This fixture reuses one Discord bot identity across seven runtime families, so +This fixture reuses one Discord bot identity across four runtime families, so the spike now materializes one runtime at a time rather than pretending to be a concurrent social-topology test. Each subtest wires one runtime through `cllama` passthrough, exposes `clawdash`, posts a Discord mention, and verifies @@ -11,9 +11,6 @@ that the runtime replies identifying itself. ## What It Covers - `openclaw` -- `nullclaw` -- `microclaw` -- `nanoclaw` - `nanobot` - `picoclaw` - `hermes` @@ -25,7 +22,7 @@ conformance only. It is not a valid concurrent topology example. ## Files -- `claw-pod.yml`: spike template containing the seven runtime service definitions +- `claw-pod.yml`: spike template containing the four runtime service definitions - `agents/*/Clawfile`: one Clawfile per runtime - `agents/*/AGENTS.md`: minimal runtime-specific self-identification contract - `Dockerfile.*-base`: local base images used by the spike test @@ -76,15 +73,12 @@ go test -tags spike -v -run TestSpikeCapabilityWaveLive ./cmd/claw/... The test should: 1. Build the base images for each runtime family if needed. -2. Build the seven rollcall agent images. +2. Build the four rollcall agent images. 3. Materialize and run one single-service pod per runtime. 4. Wait for each runtime container to become healthy or running. 5. Post a Discord mention through the webhook for that runtime. -6. Observe seven AI-generated replies across the full test run mentioning: +6. Observe four AI-generated replies across the full test run mentioning: - `openclaw` - - `nullclaw` - - `microclaw` - - `nanoclaw` (or `Claude Agent SDK`) - `nanobot` - `picoclaw` - `hermes` diff --git a/examples/rollcall/agents/mc-roll/AGENTS.md b/examples/rollcall/agents/mc-roll/AGENTS.md deleted file mode 100644 index a56d4ae9..00000000 --- a/examples/rollcall/agents/mc-roll/AGENTS.md +++ /dev/null @@ -1,16 +0,0 @@ -# mc-roll - -You are mc-roll, an agent running on the **MicroClaw** runtime. - -## CRITICAL: Tool-only mode - -Plain text responses are private thinking. They are NEVER sent to Discord. - -The ONLY way to communicate is by calling the `send_message` tool. When a message arrives, call `send_message` with one sentence stating your name and runtime. - -Example: -``` -send_message(message="I am mc-roll, running on the MicroClaw runtime.") -``` - -After the tool call completes, respond with only: `Done.` diff --git a/examples/rollcall/agents/mc-roll/Clawfile b/examples/rollcall/agents/mc-roll/Clawfile deleted file mode 100644 index 8e2ca29c..00000000 --- a/examples/rollcall/agents/mc-roll/Clawfile +++ /dev/null @@ -1,6 +0,0 @@ -FROM microclaw:latest - -CLAW_TYPE microclaw -AGENT AGENTS.md -MODEL primary anthropic/claude-sonnet-4 -HANDLE discord diff --git a/examples/rollcall/agents/nano-roll/AGENTS.md b/examples/rollcall/agents/nano-roll/AGENTS.md deleted file mode 100644 index 83c43530..00000000 --- a/examples/rollcall/agents/nano-roll/AGENTS.md +++ /dev/null @@ -1,16 +0,0 @@ -# nano-roll - -You are nano-roll, an agent running on the **NanoClaw** (Claude Agent SDK) runtime. - -## CRITICAL: Tool-only mode - -Plain text responses are private thinking. They are NEVER sent to Discord. - -The ONLY way to communicate is by calling the `send_message` tool. When a message arrives, call `send_message` with one sentence stating your name and runtime. - -Example: -``` -send_message(message="I am nano-roll, running on the NanoClaw (Claude Agent SDK) runtime.") -``` - -After the tool call completes, respond with only: `Done.` diff --git a/examples/rollcall/agents/nano-roll/Clawfile b/examples/rollcall/agents/nano-roll/Clawfile deleted file mode 100644 index d01b1e30..00000000 --- a/examples/rollcall/agents/nano-roll/Clawfile +++ /dev/null @@ -1,7 +0,0 @@ -FROM nanoclaw-orchestrator:latest - -CLAW_TYPE nanoclaw -AGENT AGENTS.md -MODEL primary anthropic/claude-sonnet-4-6 -PRIVILEGE docker-socket true -HANDLE discord diff --git a/examples/rollcall/agents/nb-roll/Clawfile b/examples/rollcall/agents/nb-roll/Clawfile index 84eee497..a8c91f4e 100644 --- a/examples/rollcall/agents/nb-roll/Clawfile +++ b/examples/rollcall/agents/nb-roll/Clawfile @@ -2,5 +2,5 @@ FROM nanobot:latest CLAW_TYPE nanobot AGENT AGENTS.md -MODEL primary openrouter/anthropic/claude-sonnet-4 +MODEL primary anthropic/claude-sonnet-4 HANDLE discord diff --git a/examples/rollcall/agents/nc-roll/AGENTS.md b/examples/rollcall/agents/nc-roll/AGENTS.md deleted file mode 100644 index a7e7e114..00000000 --- a/examples/rollcall/agents/nc-roll/AGENTS.md +++ /dev/null @@ -1,16 +0,0 @@ -# nc-roll - -You are nc-roll, an agent running on the **NullClaw** runtime. - -## CRITICAL: Tool-only mode - -Plain text responses are private thinking. They are NEVER sent to Discord. - -The ONLY way to communicate is by calling the `send_message` tool. When a message arrives, call `send_message` with one sentence stating your name and runtime. - -Example: -``` -send_message(message="I am nc-roll, running on the NullClaw runtime.") -``` - -After the tool call completes, respond with only: `Done.` diff --git a/examples/rollcall/agents/nc-roll/Clawfile b/examples/rollcall/agents/nc-roll/Clawfile deleted file mode 100644 index 26548a28..00000000 --- a/examples/rollcall/agents/nc-roll/Clawfile +++ /dev/null @@ -1,6 +0,0 @@ -FROM nullclaw:latest - -CLAW_TYPE nullclaw -AGENT AGENTS.md -MODEL primary anthropic/claude-sonnet-4 -HANDLE discord diff --git a/examples/rollcall/claw-pod.yml b/examples/rollcall/claw-pod.yml index 17aa5b88..e8570c7d 100644 --- a/examples/rollcall/claw-pod.yml +++ b/examples/rollcall/claw-pod.yml @@ -43,84 +43,6 @@ services: ROLLCALL_CHANNEL_ID: "${ROLLCALL_CHANNEL_ID}" CLAW_RUNTIME: "openclaw" - nc-roll: - image: rollcall-nullclaw:latest - build: - context: . - dockerfile: agents/nc-roll/Clawfile - x-claw: - agent: ./agents/nc-roll/AGENTS.md - cllama: passthrough - cllama-env: - ANTHROPIC_API_KEY: "${ANTHROPIC_API_KEY}" - handles: - discord: - id: "${DISCORD_BOT_ID}" - username: "nc-roll" - guilds: - - id: "${DISCORD_GUILD_ID}" - name: "Roll Call" - channels: - - id: "${ROLLCALL_CHANNEL_ID}" - name: roll-call - environment: - DISCORD_BOT_TOKEN: "${DISCORD_BOT_TOKEN}" - DISCORD_BOT_ID: "${DISCORD_BOT_ID}" - ROLLCALL_CHANNEL_ID: "${ROLLCALL_CHANNEL_ID}" - CLAW_RUNTIME: "nullclaw" - - mc-roll: - image: rollcall-microclaw:latest - build: - context: . - dockerfile: agents/mc-roll/Clawfile - x-claw: - agent: ./agents/mc-roll/AGENTS.md - cllama: passthrough - cllama-env: - ANTHROPIC_API_KEY: "${ANTHROPIC_API_KEY}" - handles: - discord: - id: "${DISCORD_BOT_ID}" - username: "mc-roll" - guilds: - - id: "${DISCORD_GUILD_ID}" - name: "Roll Call" - channels: - - id: "${ROLLCALL_CHANNEL_ID}" - name: roll-call - environment: - DISCORD_BOT_TOKEN: "${DISCORD_BOT_TOKEN}" - DISCORD_BOT_ID: "${DISCORD_BOT_ID}" - ROLLCALL_CHANNEL_ID: "${ROLLCALL_CHANNEL_ID}" - CLAW_RUNTIME: "microclaw" - - nano-roll: - image: rollcall-nanoclaw:latest - build: - context: . - dockerfile: agents/nano-roll/Clawfile - x-claw: - agent: ./agents/nano-roll/AGENTS.md - cllama: passthrough - cllama-env: - ANTHROPIC_API_KEY: "${ANTHROPIC_API_KEY}" - handles: - discord: - id: "${DISCORD_BOT_ID}" - username: "nano-roll" - guilds: - - id: "${DISCORD_GUILD_ID}" - name: "Roll Call" - channels: - - id: "${ROLLCALL_CHANNEL_ID}" - name: roll-call - environment: - DISCORD_BOT_TOKEN: "${DISCORD_BOT_TOKEN}" - DISCORD_BOT_ID: "${DISCORD_BOT_ID}" - ROLLCALL_CHANNEL_ID: "${ROLLCALL_CHANNEL_ID}" - CLAW_RUNTIME: "nanoclaw" - nb-roll: image: rollcall-nanobot:latest build: @@ -129,8 +51,10 @@ services: x-claw: agent: ./agents/nb-roll/AGENTS.md cllama: passthrough + # nb-roll carries the anthropic-messages ingress surface now that the + # nullclaw/nanoclaw stubs are retired (ADR-026). cllama-env: - OPENROUTER_API_KEY: "${OPENROUTER_API_KEY}" + ANTHROPIC_API_KEY: "${ANTHROPIC_API_KEY}" handles: discord: id: "${DISCORD_BOT_ID}" diff --git a/examples/trading-desk/.env.example b/examples/trading-desk/.env.example index 4ed2f2a3..ddaf5e5e 100644 --- a/examples/trading-desk/.env.example +++ b/examples/trading-desk/.env.example @@ -4,14 +4,10 @@ export DESK_MANAGER_DISCORD_ID=desk_manager_discord_id export MOMENTUM_TRADER_BOT_TOKEN=momentum_trader_bot_token export MOMENTUM_TRADER_DISCORD_ID=momentum_trader_discord_id -export SYSTEMS_MONITOR_BOT_TOKEN=systems_monitor_bot_token -export SYSTEMS_MONITOR_DISCORD_ID=systems_monitor_discord_id export VALUE_TRADER_BOT_TOKEN=value_trader_bot_token export VALUE_TRADER_DISCORD_ID=value_trader_discord_id -export MICRO_BOT_TOKEN=micro_bot_token -export MICRO_DISCORD_ID=micro_discord_id export HERMES_BOT_TOKEN=hermes_bot_token export HERMES_DISCORD_ID=hermes_discord_id diff --git a/examples/trading-desk/Clawfile.microclaw b/examples/trading-desk/Clawfile.microclaw deleted file mode 100644 index 66744d58..00000000 --- a/examples/trading-desk/Clawfile.microclaw +++ /dev/null @@ -1,16 +0,0 @@ -FROM alpine:3.20 - -CLAW_TYPE microclaw -AGENT AGENTS.md - -MODEL primary anthropic/claude-sonnet-4 - -HANDLE discord - -RUN apk add --no-cache bash curl jq procps tini -COPY microclaw /usr/local/bin/microclaw -RUN chmod +x /usr/local/bin/microclaw -COPY microclaw-entrypoint.sh /app/entrypoint.sh -RUN chmod +x /app/entrypoint.sh - -ENTRYPOINT ["/sbin/tini", "--", "/app/entrypoint.sh"] diff --git a/examples/trading-desk/Clawfile.nanobot b/examples/trading-desk/Clawfile.nanobot new file mode 100644 index 00000000..8364f3a9 --- /dev/null +++ b/examples/trading-desk/Clawfile.nanobot @@ -0,0 +1,16 @@ +FROM alpine:3.20 + +CLAW_TYPE nanobot +AGENT AGENTS.md + +MODEL primary anthropic/claude-sonnet-4 + +HANDLE discord + +RUN apk add --no-cache bash curl jq tini procps +COPY nanobot /usr/local/bin/nanobot +RUN chmod +x /usr/local/bin/nanobot +COPY nanobot-entrypoint.sh /app/entrypoint.sh +RUN chmod +x /app/entrypoint.sh + +ENTRYPOINT ["/sbin/tini", "--", "/app/entrypoint.sh"] diff --git a/examples/trading-desk/Clawfile.nanoclaw b/examples/trading-desk/Clawfile.nanoclaw deleted file mode 100644 index f2152971..00000000 --- a/examples/trading-desk/Clawfile.nanoclaw +++ /dev/null @@ -1,11 +0,0 @@ -FROM nanoclaw-orchestrator:latest - -CLAW_TYPE nanoclaw - -MODEL primary anthropic/claude-sonnet-4-6 - -HANDLE discord - -PRIVILEGE docker-socket true - -TRACK apt npm diff --git a/examples/trading-desk/Clawfile.nullclaw b/examples/trading-desk/Clawfile.nullclaw deleted file mode 100644 index 07aeb9de..00000000 --- a/examples/trading-desk/Clawfile.nullclaw +++ /dev/null @@ -1,16 +0,0 @@ -FROM alpine:3.20 - -CLAW_TYPE nullclaw -AGENT AGENTS.md - -MODEL primary anthropic/claude-sonnet-4 - -HANDLE discord - -RUN apk add --no-cache curl jq tini python3 -COPY nullclaw /usr/local/bin/nullclaw -RUN chmod +x /usr/local/bin/nullclaw -COPY nullclaw-entrypoint.sh /app/entrypoint.sh -RUN chmod +x /app/entrypoint.sh - -ENTRYPOINT ["/sbin/tini", "--", "/app/entrypoint.sh"] diff --git a/examples/trading-desk/README.md b/examples/trading-desk/README.md index 7c578c28..c2931c61 100644 --- a/examples/trading-desk/README.md +++ b/examples/trading-desk/README.md @@ -1,6 +1,6 @@ # Trading Desk Example -A reference pod showing mixed managed agents (`openclaw`, `nanoclaw`, `nullclaw`, `microclaw`, `hermes`) running as a governed fleet — each with Discord presence and shared infrastructure context. +A reference pod showing mixed managed agents (`openclaw`, `nanobot`, `hermes`) running as a governed fleet — each with Discord presence and shared infrastructure context. This spike now enables cllama passthrough routing for trader agents (`cllama`). ## What's in the box @@ -8,9 +8,7 @@ This spike now enables cllama passthrough routing for trader agents (`cllama`). | File | Purpose | |------|---------| | `Clawfile` | OpenClaw trader image (desk-manager, momentum-trader) | -| `Clawfile.nanoclaw` | NanoClaw image (systems-monitor) | -| `Clawfile.nullclaw` | NullClaw image with Discord startup announcement (value-trader) | -| `Clawfile.microclaw` | MicroClaw spike image with Discord startup announcement | +| `Clawfile.nanobot` | Nanobot spike image with Discord startup announcement (value-trader) | | `Clawfile.hermes` | Hermes spike image with Discord startup announcement | | `claw-pod.yml` | Pod definition — per-agent handles, surfaces, invoke schedules | | `Dockerfile.openclaw-base` | Builds the local `openclaw:latest` base image | @@ -32,16 +30,12 @@ Copy `.env.example` and fill in real values: ``` DESK_MANAGER_BOT_TOKEN=Bot ... MOMENTUM_TRADER_BOT_TOKEN=Bot ... -SYSTEMS_MONITOR_BOT_TOKEN=Bot ... VALUE_TRADER_BOT_TOKEN=Bot ... -MICRO_BOT_TOKEN=Bot ... HERMES_BOT_TOKEN=Bot ... DESK_MANAGER_DISCORD_ID=... MOMENTUM_TRADER_DISCORD_ID=... -SYSTEMS_MONITOR_DISCORD_ID=... VALUE_TRADER_DISCORD_ID=... -MICRO_DISCORD_ID=... HERMES_DISCORD_ID=... DISCORD_GUILD_ID=... DISCORD_TRADING_FLOOR_CHANNEL=... @@ -84,7 +78,7 @@ claw logs desk-manager **Requirements:** - Docker running -- Env-owned Discord IDs for desk-manager, momentum-trader, systems-monitor, value-trader, micro, and hermes, plus shared guild/channel IDs +- Env-owned Discord IDs for desk-manager, momentum-trader, value-trader, and hermes, plus shared guild/channel IDs - Real bot tokens in `examples/trading-desk/.env` (`DESK_MANAGER_BOT_TOKEN` at minimum; others can reuse it for local spike runs) - Provider key for proxy env (`OPENROUTER_API_KEY`; `ANTHROPIC_API_KEY` optional fallback) - Internet access from Docker containers (no internal-only Docker Desktop network mode) diff --git a/examples/trading-desk/agents/MICRO.md b/examples/trading-desk/agents/MICRO.md deleted file mode 100644 index 1aeabb72..00000000 --- a/examples/trading-desk/agents/MICRO.md +++ /dev/null @@ -1,8 +0,0 @@ -# MICRO — MicroClaw Operations Agent - -You are Micro, a low-latency operations agent for the trading desk. - -Core responsibilities: -- Monitor incident signals and post short operational updates in Discord. -- Escalate critical outages to the trading floor with concrete status. -- Keep updates concise, actionable, and timestamped. diff --git a/examples/trading-desk/agents/SYSTEMS-MONITOR.md b/examples/trading-desk/agents/SYSTEMS-MONITOR.md deleted file mode 100644 index cb9eedfa..00000000 --- a/examples/trading-desk/agents/SYSTEMS-MONITOR.md +++ /dev/null @@ -1,41 +0,0 @@ -# Systems-Monitor — Agent Contract - -Systems monitor. Keeps the infrastructure healthy. Does not trade. - -## Startup - -Read `/claw/CLAWDAPUS.md`. Your surfaces are `trading-api` (REST health endpoint) -and `clawd-shared` (volume, monitor disk). Peer agent liveness is observed via -Discord — unresponsive agents won't post on schedule. Report to #infra. - -## Role - -Watch infrastructure: API health, container reachability, error patterns, disk -pressure on shared volumes. Don't interrupt traders with routine status. -Cross-post to #trading-floor only for critical systemic events. - -## Instructions - -On every heartbeat, check `GET /health` on `trading-api`, verify the response is -fresh, and note any non-`ok` status, repeated errors, or rising latency. Inspect -the shared volume for obvious disk pressure, missing research files, or write -failures that would block traders from updating state. - -Treat the following as actionable alerts: -- `trading-api` unavailable or returning errors for two consecutive checks -- shared volume close to full or becoming read-only -- repeated container restarts, missing Discord presence, or obvious dead-agent behavior -- any condition that can block trade proposal, compliance review, or execution - -Post routine operational status only in `#infra`. Use a terse format: -`[STATUS] component | state | impact | next check`. -Escalate to `#trading-floor` only when the problem affects trading decisions, -execution, or the whole desk. Include concrete operator action when you have one. - -Escalate to the human operator immediately for sustained API outage, data loss -risk, corrupted shared state, or any condition that leaves the desk trading blind. -Do not suggest trades or comment on market direction. - -## Communication - -Primary channel: #infra. Only use #trading-floor for critical systemic alerts. diff --git a/examples/trading-desk/claw-pod.yml b/examples/trading-desk/claw-pod.yml index 58ff2f9d..91457e3c 100644 --- a/examples/trading-desk/claw-pod.yml +++ b/examples/trading-desk/claw-pod.yml @@ -30,15 +30,15 @@ x-trading-desk: - ./policy/approval-workflow.md # ───────────────────────────────────────────────────────────────────────────── -# Trading Desk Pod (desk-manager + momentum-trader + systems-monitor + value-trader + micro + hermes) +# Trading Desk Pod (desk-manager + momentum-trader + value-trader + hermes) # -# Mixed-driver pod: openclaw + nanoclaw + nullclaw + microclaw + hermes agents +# Mixed-driver pod: openclaw + nanobot + hermes agents # coordinating via Discord and a shared research volume. # # Required environment variables (.env or Docker secrets): # POSTGRES_PASSWORD, SECRET_KEY_BASE -# DESK_MANAGER_BOT_TOKEN, MOMENTUM_TRADER_BOT_TOKEN, SYSTEMS_MONITOR_BOT_TOKEN, VALUE_TRADER_BOT_TOKEN, MICRO_BOT_TOKEN, HERMES_BOT_TOKEN -# DESK_MANAGER_DISCORD_ID, MOMENTUM_TRADER_DISCORD_ID, SYSTEMS_MONITOR_DISCORD_ID, VALUE_TRADER_DISCORD_ID, MICRO_DISCORD_ID, HERMES_DISCORD_ID +# DESK_MANAGER_BOT_TOKEN, MOMENTUM_TRADER_BOT_TOKEN, VALUE_TRADER_BOT_TOKEN, HERMES_BOT_TOKEN +# DESK_MANAGER_DISCORD_ID, MOMENTUM_TRADER_DISCORD_ID, VALUE_TRADER_DISCORD_ID, HERMES_DISCORD_ID # DISCORD_GUILD_ID, DISCORD_TRADING_FLOOR_CHANNEL # OPENROUTER_API_KEY, ANTHROPIC_API_KEY (proxy-side provider keys for cllama) # Note: claw resolves ${...} in x-claw metadata from the pod-local .env before @@ -163,31 +163,13 @@ services: CLAW_GREETING_CHANNEL: "${DISCORD_TRADING_FLOOR_CHANNEL}" CLAW_GREETING_MESSAGE: "momentum-trader online." - # ── Systems-Monitor — Systems Monitor (NanoClaw) ────────────────────────── - - systems-monitor: - image: trading-desk-nanoclaw:latest - build: - context: . - dockerfile: Clawfile.nanoclaw - x-claw: - <<: *cllama_agent - agent: ./agents/SYSTEMS-MONITOR.md - handles: - discord: - id: "${SYSTEMS_MONITOR_DISCORD_ID}" - username: "systems-monitor" - surfaces: *shared_trading_surfaces - environment: - DISCORD_BOT_TOKEN: "${SYSTEMS_MONITOR_BOT_TOKEN}" - - # ── Value-Trader — Counterparty Sanity Check (NullClaw) ─────────────────── + # ── Value-Trader — Counterparty Sanity Check (Nanobot) ──────────────────── value-trader: - image: trading-desk-nullclaw:latest + image: trading-desk-nanobot:latest build: context: . - dockerfile: Clawfile.nullclaw + dockerfile: Clawfile.nanobot x-claw: <<: *cllama_agent agent: ./agents/VALUE-TRADER.md @@ -202,26 +184,6 @@ services: CLAW_GREETING_CHANNEL: "${DISCORD_TRADING_FLOOR_CHANNEL}" CLAW_GREETING_MESSAGE: "value-trader online." - # ── Micro — Operations Monitor (MicroClaw) ─────────────────────────────── - - micro: - image: trading-desk-microclaw:latest - build: - context: . - dockerfile: Clawfile.microclaw - x-claw: - <<: *cllama_agent - agent: ./agents/MICRO.md - handles: - discord: - id: "${MICRO_DISCORD_ID}" - username: "micro" - surfaces: *shared_trading_surfaces - environment: - DISCORD_BOT_TOKEN: "${MICRO_BOT_TOKEN}" - CLAW_GREETING_CHANNEL: "${DISCORD_TRADING_FLOOR_CHANNEL}" - CLAW_GREETING_MESSAGE: "microclaw online." - # ── Hermes — Desk Communications (Hermes) ───────────────────────────────── hermes: diff --git a/examples/trading-desk/microclaw b/examples/trading-desk/microclaw deleted file mode 100644 index 2d5c47ad..00000000 --- a/examples/trading-desk/microclaw +++ /dev/null @@ -1,8 +0,0 @@ -#!/bin/sh -# Minimal long-running process for trading spike image. -# Command name includes "microclaw" so driver healthcheck `pgrep -f microclaw` passes. - -echo "microclaw spike stub running (pid $$)" >&2 -while true; do - sleep 3600 -done diff --git a/examples/trading-desk/microclaw-entrypoint.sh b/examples/trading-desk/microclaw-entrypoint.sh deleted file mode 100644 index 4b66f3eb..00000000 --- a/examples/trading-desk/microclaw-entrypoint.sh +++ /dev/null @@ -1,27 +0,0 @@ -#!/bin/sh -set -e - -send_greeting() { - if [ -z "${DISCORD_BOT_TOKEN:-}" ] || [ -z "${CLAW_GREETING_CHANNEL:-}" ] || [ -z "${CLAW_GREETING_MESSAGE:-}" ]; then - return 0 - fi - - payload="$(jq -n --arg content "${CLAW_GREETING_MESSAGE}" '{content: $content}')" - i=0 - while [ "$i" -lt 5 ]; do - if curl -fsS -X POST "https://discord.com/api/v10/channels/${CLAW_GREETING_CHANNEL}/messages" \ - -H "Authorization: Bot ${DISCORD_BOT_TOKEN}" \ - -H "Content-Type: application/json" \ - -d "$payload" >/dev/null; then - echo "[microclaw-entrypoint] greeting sent" - return 0 - fi - i=$((i + 1)) - sleep 3 - done - - echo "[microclaw-entrypoint] warning: failed to send greeting after retries" -} - -send_greeting -exec microclaw run diff --git a/examples/trading-desk/nanobot b/examples/trading-desk/nanobot new file mode 100755 index 00000000..d7076378 --- /dev/null +++ b/examples/trading-desk/nanobot @@ -0,0 +1,41 @@ +#!/bin/sh +set -eu + +cmd="${1:-gateway}" + +case "$cmd" in + gateway) + # Stub nanobot gateway: stay alive so the driver healthcheck + # (pgrep -f 'nanobot gateway') passes. + echo "[nanobot-stub] gateway running (pid $$)" >&2 + while true; do + sleep 3600 + done + ;; + cron) + sub="${2:-}" + case "$sub" in + list) + jobs_file=/tmp/nanobot-spike/cron.jobs + count=0 + if [ -f "$jobs_file" ]; then + count="$(wc -l <"$jobs_file" | tr -d ' ')" + fi + echo "info(cron): Scheduled jobs (${count}):" + ;; + add) + mkdir -p /tmp/nanobot-spike + printf "%s\t%s\n" "${3:-}" "${4:-}" >>/tmp/nanobot-spike/cron.jobs + echo "info(cron): added schedule '${3:-}'" + ;; + *) + echo "error: unknown nanobot cron subcommand '$sub'" >&2 + exit 2 + ;; + esac + ;; + *) + echo "error: unknown nanobot subcommand '$cmd'" >&2 + exit 2 + ;; +esac diff --git a/examples/trading-desk/nullclaw-entrypoint.sh b/examples/trading-desk/nanobot-entrypoint.sh old mode 100644 new mode 100755 similarity index 81% rename from examples/trading-desk/nullclaw-entrypoint.sh rename to examples/trading-desk/nanobot-entrypoint.sh index 84102fec..e37887b3 --- a/examples/trading-desk/nullclaw-entrypoint.sh +++ b/examples/trading-desk/nanobot-entrypoint.sh @@ -13,15 +13,15 @@ send_greeting() { -H "Authorization: Bot ${DISCORD_BOT_TOKEN}" \ -H "Content-Type: application/json" \ -d "$payload" >/dev/null; then - echo "[nullclaw-entrypoint] greeting sent" + echo "[nanobot-entrypoint] greeting sent" return 0 fi i=$((i + 1)) sleep 3 done - echo "[nullclaw-entrypoint] warning: failed to send greeting after retries" + echo "[nanobot-entrypoint] warning: failed to send greeting after retries" } send_greeting -exec nullclaw run +exec nanobot gateway diff --git a/examples/trading-desk/nullclaw b/examples/trading-desk/nullclaw deleted file mode 100644 index d6a07460..00000000 --- a/examples/trading-desk/nullclaw +++ /dev/null @@ -1,60 +0,0 @@ -#!/bin/sh -set -eu - -cmd="${1:-run}" - -case "$cmd" in - run) - # Minimal health endpoint expected by the nullclaw driver. - mkdir -p /tmp/nullclaw-spike - printf "ok\n" >/tmp/nullclaw-spike/health - exec python3 -m http.server 3000 --directory /tmp/nullclaw-spike - ;; - cron) - sub="${2:-}" - case "$sub" in - list) - jobs_file=/tmp/nullclaw-spike/cron.jobs - count=0 - if [ -f "$jobs_file" ]; then - count="$(wc -l <"$jobs_file" | tr -d ' ')" - fi - echo "info(cron): Scheduled jobs (${count}):" - if [ -f "$jobs_file" ]; then - i=0 - while IFS="$(printf '\t')" read -r expr command; do - [ -n "${expr:-}" ] || continue - i=$((i + 1)) - echo "info(cron): - job-${i} | ${expr} | next=0 | status=n/a cmd: ${command}" - done <"$jobs_file" - fi - ;; - add) - if [ "$#" -lt 4 ]; then - echo "error: usage: nullclaw cron add '' ''" >&2 - exit 2 - fi - mkdir -p /tmp/nullclaw-spike - printf "%s\t%s\n" "$3" "$4" >>/tmp/nullclaw-spike/cron.jobs - echo "info(cron): added schedule '$3'" - ;; - *) - echo "error: unknown nullclaw cron subcommand '$sub'" >&2 - exit 2 - ;; - esac - ;; - agent) - # Spike stub accepts agent invocations so PostApply cron wiring can call it. - if [ "${2:-}" = "-m" ]; then - echo "nullclaw agent: ${3:-}" - exit 0 - fi - echo "error: usage: nullclaw agent -m " >&2 - exit 2 - ;; - *) - echo "error: unknown nullclaw subcommand '$cmd'" >&2 - exit 2 - ;; -esac diff --git a/internal/build/build.go b/internal/build/build.go index 7d3c9330..0f5387d0 100644 --- a/internal/build/build.go +++ b/internal/build/build.go @@ -14,10 +14,7 @@ import ( "github.com/mostlydev/clawdapus/internal/clawfile" "github.com/mostlydev/clawdapus/internal/driver" _ "github.com/mostlydev/clawdapus/internal/driver/hermes" - _ "github.com/mostlydev/clawdapus/internal/driver/microclaw" _ "github.com/mostlydev/clawdapus/internal/driver/nanobot" - _ "github.com/mostlydev/clawdapus/internal/driver/nanoclaw" - _ "github.com/mostlydev/clawdapus/internal/driver/nullclaw" _ "github.com/mostlydev/clawdapus/internal/driver/openclaw" // register built-in drivers for build-time validation _ "github.com/mostlydev/clawdapus/internal/driver/picoclaw" ) diff --git a/internal/build/build_test.go b/internal/build/build_test.go index 6c1f89dc..0d1a5ec7 100644 --- a/internal/build/build_test.go +++ b/internal/build/build_test.go @@ -5,12 +5,27 @@ import ( "os" "path/filepath" "reflect" + "sort" "strings" "testing" + "github.com/mostlydev/clawdapus/internal/driver" "github.com/mostlydev/clawdapus/internal/driver/hermes" ) +func TestBuiltInDriverSetMatchesSupportedContract(t *testing.T) { + registered := driver.Registered() + got := make([]string, 0, len(registered)) + for name := range registered { + got = append(got, name) + } + sort.Strings(got) + want := []string{"hermes", "nanobot", "openclaw", "picoclaw"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("registered built-in drivers = %v, want %v", got, want) + } +} + func TestGenerateWritesDockerfile(t *testing.T) { dir := t.TempDir() clawfilePath := filepath.Join(dir, "Clawfile") @@ -75,31 +90,26 @@ AGENT CONTRACT.md } } -func TestGenerateAcceptsMicroclawType(t *testing.T) { - dir := t.TempDir() - clawfilePath := filepath.Join(dir, "Clawfile") - - input := `FROM alpine:latest - -CLAW_TYPE microclaw -AGENT AGENTS.md -MODEL primary anthropic/claude-sonnet-4 -` - if err := os.WriteFile(clawfilePath, []byte(input), 0o644); err != nil { - t.Fatal(err) - } - - generatedPath, err := Generate(clawfilePath) - if err != nil { - t.Fatalf("expected microclaw CLAW_TYPE to be accepted, got error: %v", err) - } - - content, err := os.ReadFile(generatedPath) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(string(content), `LABEL claw.type="microclaw"`) { - t.Fatal("missing claw.type=microclaw label in generated output") +func TestGenerateRejectsRetiredClawTypesWithMigrationGuidance(t *testing.T) { + for _, clawType := range []string{"nanoclaw", "microclaw", "nullclaw"} { + t.Run(clawType, func(t *testing.T) { + dir := t.TempDir() + clawfilePath := filepath.Join(dir, "Clawfile") + input := "FROM alpine:latest\nCLAW_TYPE " + clawType + "\nAGENT AGENTS.md\n" + if err := os.WriteFile(clawfilePath, []byte(input), 0o644); err != nil { + t.Fatal(err) + } + + _, err := Generate(clawfilePath) + if err == nil { + t.Fatalf("expected Generate to reject retired CLAW_TYPE %q", clawType) + } + for _, want := range []string{clawType, "retired", "ADR-026", `CLAW_TYPE "hermes"`} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("retirement error %q does not contain %q", err, want) + } + } + }) } } diff --git a/internal/driver/microclaw/baseimage.go b/internal/driver/microclaw/baseimage.go deleted file mode 100644 index 0cbb5374..00000000 --- a/internal/driver/microclaw/baseimage.go +++ /dev/null @@ -1,23 +0,0 @@ -package microclaw - -const baseImageTag = "microclaw:latest" - -const baseImageDockerfile = `FROM ghcr.io/microclaw/microclaw:latest - -USER root - -RUN apt-get update && apt-get install -y --no-install-recommends procps \ - && rm -rf /var/lib/apt/lists/* - -RUN mkdir -p /app/config /claw-data - -USER microclaw -` - -func (d *Driver) BaseImage() (string, string) { - return baseImageTag, baseImageDockerfile -} - -func (d *Driver) RunnerAlias() string { - return "microclaw" -} diff --git a/internal/driver/microclaw/baseimage_test.go b/internal/driver/microclaw/baseimage_test.go deleted file mode 100644 index 959c9ae1..00000000 --- a/internal/driver/microclaw/baseimage_test.go +++ /dev/null @@ -1,29 +0,0 @@ -package microclaw - -import ( - "strings" - "testing" - - "github.com/mostlydev/clawdapus/internal/driver" -) - -func TestBaseImageProvider(t *testing.T) { - d := &Driver{} - - var _ driver.BaseImageProvider = d - - tag, dockerfile := d.BaseImage() - - if tag != "microclaw:latest" { - t.Fatalf("expected tag microclaw:latest, got %q", tag) - } - if !strings.HasPrefix(dockerfile, "FROM ghcr.io/microclaw/microclaw:latest") { - t.Fatal("Dockerfile should start from the official microclaw image") - } - if !strings.Contains(dockerfile, "apt-get install -y --no-install-recommends procps") { - t.Fatal("Dockerfile should install procps for the pgrep-based healthcheck") - } - if !strings.Contains(dockerfile, "mkdir -p /app/config /claw-data") { - t.Fatal("Dockerfile should create the config mount parent path") - } -} diff --git a/internal/driver/microclaw/driver.go b/internal/driver/microclaw/driver.go deleted file mode 100644 index f5d50853..00000000 --- a/internal/driver/microclaw/driver.go +++ /dev/null @@ -1,487 +0,0 @@ -package microclaw - -import ( - "context" - "fmt" - "os" - "path/filepath" - "sort" - "strconv" - "strings" - "time" - - "github.com/docker/docker/client" - "github.com/mostlydev/clawdapus/internal/cllama" - "github.com/mostlydev/clawdapus/internal/driver" - "github.com/mostlydev/clawdapus/internal/driver/shared" - "gopkg.in/yaml.v3" -) - -// Driver implements Clawdapus runtime materialization for MicroClaw. -type Driver struct{} - -func init() { - driver.Register("microclaw", &Driver{}) -} - -func (d *Driver) Validate(rc *driver.ResolvedClaw) error { - if rc.AgentHostPath == "" { - return fmt.Errorf("microclaw driver: no agent host path specified (no contract, no start)") - } - if _, err := os.Stat(rc.AgentHostPath); err != nil { - return fmt.Errorf("microclaw driver: agent file %q not found: %w", rc.AgentHostPath, err) - } - - modelRef, err := shared.PrimaryModelRef(rc.Models) - if err != nil { - return fmt.Errorf("microclaw driver: %w", err) - } - provider, _, ok := shared.SplitModelRef(modelRef) - if !ok { - return fmt.Errorf("microclaw driver: invalid MODEL primary %q (expected provider/model)", modelRef) - } - - for _, cmd := range rc.Configures { - if _, _, err := parseConfigSetCommand(cmd); err != nil { - return fmt.Errorf("microclaw driver: unsupported CONFIGURE command %q: %w", cmd, err) - } - } - - for platform := range rc.Handles { - switch platform { - case "discord": - token, err := shared.ResolveEnvTokenFromMapWithRuntimeEnv(rc.Environment, "DISCORD_BOT_TOKEN", rc.RuntimeEnv) - if err != nil { - return fmt.Errorf("microclaw driver: DISCORD_BOT_TOKEN: %w", err) - } - if token == "" { - return fmt.Errorf("microclaw driver: HANDLE discord requires DISCORD_BOT_TOKEN in service environment") - } - case "telegram": - token, err := shared.ResolveEnvTokenFromMapWithRuntimeEnv(rc.Environment, "TELEGRAM_BOT_TOKEN", rc.RuntimeEnv) - if err != nil { - return fmt.Errorf("microclaw driver: TELEGRAM_BOT_TOKEN: %w", err) - } - if token == "" { - return fmt.Errorf("microclaw driver: HANDLE telegram requires TELEGRAM_BOT_TOKEN in service environment") - } - case "slack": - botToken, err := shared.ResolveEnvTokenFromMapWithRuntimeEnv(rc.Environment, "SLACK_BOT_TOKEN", rc.RuntimeEnv) - if err != nil { - return fmt.Errorf("microclaw driver: SLACK_BOT_TOKEN: %w", err) - } - appToken, err := shared.ResolveEnvTokenFromMapWithRuntimeEnv(rc.Environment, "SLACK_APP_TOKEN", rc.RuntimeEnv) - if err != nil { - return fmt.Errorf("microclaw driver: SLACK_APP_TOKEN: %w", err) - } - if botToken == "" || appToken == "" { - return fmt.Errorf("microclaw driver: HANDLE slack requires SLACK_BOT_TOKEN and SLACK_APP_TOKEN in service environment") - } - default: - fmt.Printf("[claw] warning: microclaw driver has no HANDLE mapping for platform %q; skipping channel enablement\n", platform) - } - } - - if len(rc.Cllama) == 0 { - llmProvider := shared.NormalizeProvider(provider) - if !shared.ProviderAllowsEmptyAPIKey(llmProvider) { - key, err := shared.ResolveProviderAPIKeyWithRuntimeEnv(llmProvider, rc.Environment, rc.RuntimeEnv) - if err != nil { - return fmt.Errorf("microclaw driver: provider %q API key: %w", llmProvider, err) - } - if key == "" { - expected := strings.Join(shared.ExpectedProviderKeys(llmProvider), ", ") - return fmt.Errorf("microclaw driver: no API key found for provider %q (checked: %s)", llmProvider, expected) - } - } - } - - if len(rc.Invocations) > 0 { - fmt.Printf("[claw] warning: microclaw driver: INVOKE scheduling not supported; ignoring %d invocations\n", len(rc.Invocations)) - } - - return nil -} - -func (d *Driver) Materialize(rc *driver.ResolvedClaw, opts driver.MaterializeOpts) (*driver.MaterializeResult, error) { - podName := opts.PodName - if podName == "" { - podName = rc.ServiceName - } - memoryDir, err := shared.PreparePortableMemory(shared.ResolveStateDir(opts.RuntimeDir, opts.StateDir), opts.RuntimeDir) - if err != nil { - return nil, fmt.Errorf("microclaw driver: prepare portable memory: %w", err) - } - - cfg, err := generateConfig(rc) - if err != nil { - return nil, err - } - - for _, cmd := range rc.Configures { - path, value, err := parseConfigSetCommand(cmd) - if err != nil { - return nil, fmt.Errorf("microclaw driver: apply CONFIGURE %q: %w", cmd, err) - } - if err := shared.SetPath(cfg, path, value); err != nil { - return nil, fmt.Errorf("microclaw driver: apply CONFIGURE %q: %w", cmd, err) - } - } - - cfgBytes, err := yaml.Marshal(cfg) - if err != nil { - return nil, fmt.Errorf("microclaw driver: marshal config yaml: %w", err) - } - - configDir := filepath.Join(opts.RuntimeDir, "config") - if err := os.MkdirAll(configDir, 0o777); err != nil { - return nil, fmt.Errorf("microclaw driver: create config dir: %w", err) - } - configPath := filepath.Join(configDir, "microclaw.config.yaml") - if err := os.WriteFile(configPath, cfgBytes, 0o644); err != nil { - return nil, fmt.Errorf("microclaw driver: write config: %w", err) - } - - dataDir := filepath.Join(opts.RuntimeDir, "data") - groupsDir := filepath.Join(dataDir, "runtime", "groups") - if err := os.MkdirAll(groupsDir, 0o777); err != nil { - return nil, fmt.Errorf("microclaw driver: create runtime groups dir: %w", err) - } - if err := os.MkdirAll(filepath.Join(dataDir, "skills"), 0o777); err != nil { - return nil, fmt.Errorf("microclaw driver: create skills dir: %w", err) - } - if err := os.MkdirAll(filepath.Join(dataDir, "working_dir"), 0o777); err != nil { - return nil, fmt.Errorf("microclaw driver: create working_dir: %w", err) - } - - agentContent, err := os.ReadFile(rc.AgentHostPath) - if err != nil { - return nil, fmt.Errorf("microclaw driver: read agent contract: %w", err) - } - clawdapusMd := shared.GenerateClawdapusMD(rc, podName) - seededAgents := strings.TrimSpace(string(agentContent)) + "\n\n---\n\n" + strings.TrimSpace(clawdapusMd) + "\n" - seedPath := filepath.Join(groupsDir, "AGENTS.md") - if err := os.WriteFile(seedPath, []byte(seededAgents), 0o644); err != nil { - return nil, fmt.Errorf("microclaw driver: write seeded AGENTS.md: %w", err) - } - - mounts := []driver.Mount{ - { - HostPath: configPath, - ContainerPath: "/app/config/microclaw.config.yaml", - ReadOnly: true, - }, - { - HostPath: dataDir, - ContainerPath: "/claw-data", - ReadOnly: false, - }, - { - HostPath: memoryDir, - ContainerPath: shared.PortableMemoryDir, - ReadOnly: false, - }, - } - if rc.PersonaHostPath != "" { - mounts = append(mounts, driver.Mount{ - HostPath: rc.PersonaHostPath, - ContainerPath: "/claw-data/persona", - ReadOnly: false, - }) - } - - env := map[string]string{ - "CLAW_MANAGED": "true", - shared.PortableMemoryEnv: shared.PortableMemoryDir, - "MICROCLAW_CONFIG": "/app/config/microclaw.config.yaml", - } - if rc.PersonaHostPath != "" { - env["CLAW_PERSONA_DIR"] = "/claw-data/persona" - } - - return &driver.MaterializeResult{ - Mounts: mounts, - Tmpfs: []string{"/tmp"}, - ReadOnly: false, - Restart: "on-failure", - User: "microclaw", - SkillDir: "/claw-data/skills", - SkillLayout: "directory", - Healthcheck: &driver.Healthcheck{ - Test: []string{"CMD-SHELL", "pgrep -f 'microclaw' > /dev/null"}, - Interval: "30s", - Timeout: "10s", - Retries: 3, - }, - Environment: env, - }, nil -} - -func (d *Driver) PostApply(rc *driver.ResolvedClaw, opts driver.PostApplyOpts) error { - if opts.ContainerID == "" { - return fmt.Errorf("microclaw driver: post-apply check failed: no container ID") - } - cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) - if err != nil { - return fmt.Errorf("microclaw driver: post-apply docker client: %w", err) - } - defer cli.Close() - - info, err := cli.ContainerInspect(context.Background(), opts.ContainerID) - if err != nil { - return fmt.Errorf("microclaw driver: post-apply inspect: %w", err) - } - if info.State == nil || !info.State.Running { - status := "unknown" - if info.State != nil { - status = info.State.Status - } - return fmt.Errorf("microclaw driver: container is not running (status: %s)", status) - } - return nil -} - -func (d *Driver) HealthProbe(ref driver.ContainerRef) (*driver.Health, error) { - if ref.ContainerID == "" { - return &driver.Health{OK: false, Detail: "no container ID"}, nil - } - cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) - if err != nil { - return nil, fmt.Errorf("microclaw driver: health docker client: %w", err) - } - defer cli.Close() - - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) - defer cancel() - - info, err := cli.ContainerInspect(ctx, ref.ContainerID) - if err != nil { - return &driver.Health{OK: false, Detail: fmt.Sprintf("inspect: %v", err)}, nil - } - if info.State == nil || !info.State.Running { - status := "unknown" - if info.State != nil { - status = info.State.Status - } - return &driver.Health{OK: false, Detail: fmt.Sprintf("not running (%s)", status)}, nil - } - return &driver.Health{OK: true, Detail: "container running"}, nil -} - -func generateConfig(rc *driver.ResolvedClaw) (map[string]interface{}, error) { - modelRef, err := shared.PrimaryModelRef(rc.Models) - if err != nil { - return nil, fmt.Errorf("microclaw driver: %w", err) - } - provider, modelID, ok := shared.SplitModelRef(modelRef) - if !ok { - return nil, fmt.Errorf("microclaw driver: invalid MODEL primary %q (expected provider/model)", modelRef) - } - - cfg := map[string]interface{}{ - "data_dir": "/claw-data", - "skills_dir": "/claw-data/skills", - "working_dir": "/claw-data/working_dir", - "working_dir_isolation": "chat", - "timezone": resolvedTimezone(rc.Timezone), - "web_enabled": true, - "web_host": "127.0.0.1", - "web_port": 10961, - } - - channels := map[string]interface{}{ - "web": map[string]interface{}{"enabled": true}, - } - - if len(rc.Cllama) > 0 { - if strings.TrimSpace(rc.CllamaToken) == "" { - return nil, fmt.Errorf("microclaw driver: CLLAMA is enabled but token is empty") - } - firstProxy := cllama.ProxyBaseURL(rc.Cllama[0]) - cfg["llm_base_url"] = firstProxy - cfg["api_key"] = rc.CllamaToken - - if shared.NormalizeProvider(provider) == "anthropic" { - cfg["llm_provider"] = "anthropic" - cfg["model"] = modelID - } else { - cfg["llm_provider"] = "openai" - cfg["model"] = provider + "/" + modelID - } - } else { - llmProvider := shared.NormalizeProvider(provider) - cfg["llm_provider"] = llmProvider - cfg["model"] = modelID - apiKey, err := shared.ResolveProviderAPIKeyWithRuntimeEnv(llmProvider, rc.Environment, rc.RuntimeEnv) - if err != nil { - return nil, fmt.Errorf("config generation: provider %q api_key: %w", llmProvider, err) - } - if apiKey != "" { - cfg["api_key"] = apiKey - } else { - cfg["api_key"] = "" - } - } - - platforms := make([]string, 0, len(rc.Handles)) - for platform := range rc.Handles { - platforms = append(platforms, platform) - } - sort.Strings(platforms) - - for _, platform := range platforms { - h := rc.Handles[platform] - switch platform { - case "discord": - discord := map[string]interface{}{"enabled": true, "mention_only": true} - token, err := shared.ResolveEnvTokenFromMapWithRuntimeEnv(rc.Environment, "DISCORD_BOT_TOKEN", rc.RuntimeEnv) - if err != nil { - return nil, fmt.Errorf("config generation: HANDLE discord: DISCORD_BOT_TOKEN: %w", err) - } - if token != "" { - discord["bot_token"] = token - } - if h != nil && strings.TrimSpace(h.Username) != "" { - discord["bot_username"] = h.Username - } - if allowed := discordAllowedChannels(h); len(allowed) > 0 { - discord["allowed_channels"] = allowed - } - channels["discord"] = discord - case "telegram": - telegram := map[string]interface{}{"enabled": true} - token, err := shared.ResolveEnvTokenFromMapWithRuntimeEnv(rc.Environment, "TELEGRAM_BOT_TOKEN", rc.RuntimeEnv) - if err != nil { - return nil, fmt.Errorf("config generation: HANDLE telegram: TELEGRAM_BOT_TOKEN: %w", err) - } - if token != "" { - telegram["bot_token"] = token - } - if h != nil && strings.TrimSpace(h.Username) != "" { - telegram["bot_username"] = h.Username - } - if allowed := telegramAllowedGroups(h); len(allowed) > 0 { - telegram["allowed_groups"] = allowed - } - channels["telegram"] = telegram - case "slack": - slack := map[string]interface{}{"enabled": true} - bot, err := shared.ResolveEnvTokenFromMapWithRuntimeEnv(rc.Environment, "SLACK_BOT_TOKEN", rc.RuntimeEnv) - if err != nil { - return nil, fmt.Errorf("config generation: HANDLE slack: SLACK_BOT_TOKEN: %w", err) - } - if bot != "" { - slack["bot_token"] = bot - } - app, err := shared.ResolveEnvTokenFromMapWithRuntimeEnv(rc.Environment, "SLACK_APP_TOKEN", rc.RuntimeEnv) - if err != nil { - return nil, fmt.Errorf("config generation: HANDLE slack: SLACK_APP_TOKEN: %w", err) - } - if app != "" { - slack["app_token"] = app - } - if allowed := slackAllowedChannels(h); len(allowed) > 0 { - slack["allowed_channels"] = allowed - } - channels["slack"] = slack - } - } - - cfg["channels"] = channels - return cfg, nil -} - -func resolvedTimezone(raw string) string { - timezone := strings.TrimSpace(raw) - if timezone == "" { - return "UTC" - } - return timezone -} - -func discordAllowedChannels(h *driver.HandleInfo) []uint64 { - if h == nil { - return nil - } - seen := map[uint64]struct{}{} - out := make([]uint64, 0) - for _, g := range h.Guilds { - for _, ch := range g.Channels { - id, err := strconv.ParseUint(strings.TrimSpace(ch.ID), 10, 64) - if err != nil || id == 0 { - continue - } - if _, ok := seen[id]; ok { - continue - } - seen[id] = struct{}{} - out = append(out, id) - } - } - sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) - return out -} - -func telegramAllowedGroups(h *driver.HandleInfo) []int64 { - if h == nil { - return nil - } - seen := map[int64]struct{}{} - out := make([]int64, 0) - for _, g := range h.Guilds { - id, err := strconv.ParseInt(strings.TrimSpace(g.ID), 10, 64) - if err != nil || id == 0 { - continue - } - if _, ok := seen[id]; ok { - continue - } - seen[id] = struct{}{} - out = append(out, id) - } - sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) - return out -} - -func slackAllowedChannels(h *driver.HandleInfo) []string { - if h == nil { - return nil - } - seen := map[string]struct{}{} - out := make([]string, 0) - for _, g := range h.Guilds { - for _, ch := range g.Channels { - id := strings.TrimSpace(ch.ID) - if id == "" { - continue - } - if _, ok := seen[id]; ok { - continue - } - seen[id] = struct{}{} - out = append(out, id) - } - } - sort.Strings(out) - return out -} - -func parseConfigSetCommand(cmd string) (string, interface{}, error) { - parts := strings.Fields(cmd) - if len(parts) < 5 || parts[0] != "microclaw" || parts[1] != "config" || parts[2] != "set" { - return "", nil, fmt.Errorf("expected 'microclaw config set '") - } - path := parts[3] - value := strings.TrimSpace(strings.Join(parts[4:], " ")) - if value == "" { - return "", nil, fmt.Errorf("empty config value") - } - - var typed interface{} - if err := yaml.Unmarshal([]byte(value), &typed); err == nil { - if typed != nil { - return path, typed, nil - } - } - return path, value, nil -} diff --git a/internal/driver/microclaw/driver_test.go b/internal/driver/microclaw/driver_test.go deleted file mode 100644 index 4c6e7f70..00000000 --- a/internal/driver/microclaw/driver_test.go +++ /dev/null @@ -1,308 +0,0 @@ -package microclaw - -import ( - "os" - "path/filepath" - "strings" - "testing" - - "github.com/mostlydev/clawdapus/internal/driver" - "github.com/mostlydev/clawdapus/internal/driver/shared" - "gopkg.in/yaml.v3" -) - -func TestDriverRegistered(t *testing.T) { - d, err := driver.Lookup("microclaw") - if err != nil { - t.Fatalf("microclaw driver not registered: %v", err) - } - if d == nil { - t.Fatal("microclaw driver is nil") - } -} - -func TestValidateRequiresAgentPath(t *testing.T) { - d := &Driver{} - rc := &driver.ResolvedClaw{ServiceName: "mc", Models: map[string]string{"primary": "anthropic/claude-sonnet-4"}} - err := d.Validate(rc) - if err == nil { - t.Fatal("expected error for missing agent host path") - } - if !strings.Contains(err.Error(), "no agent host path") { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestValidateRequiresPrimaryModel(t *testing.T) { - rc, _ := newTestRC(t) - rc.Models = map[string]string{} - - d := &Driver{} - err := d.Validate(rc) - if err == nil { - t.Fatal("expected error for missing primary model") - } - if !strings.Contains(err.Error(), "MODEL primary") { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestValidateDiscordHandleRequiresToken(t *testing.T) { - rc, _ := newTestRC(t) - rc.Handles = map[string]*driver.HandleInfo{"discord": {ID: "123"}} - rc.Environment = map[string]string{} - - d := &Driver{} - err := d.Validate(rc) - if err == nil { - t.Fatal("expected error for missing DISCORD_BOT_TOKEN") - } - if !strings.Contains(err.Error(), "DISCORD_BOT_TOKEN") { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestValidateAcceptsBasicConfig(t *testing.T) { - rc, _ := newTestRC(t) - rc.Environment["ANTHROPIC_API_KEY"] = "sk-ant" - - d := &Driver{} - if err := d.Validate(rc); err != nil { - t.Fatalf("unexpected validation error: %v", err) - } -} - -func TestMaterializeWritesConfigAndSeededMemory(t *testing.T) { - rc, tmp := newTestRC(t) - rc.Models = map[string]string{"primary": "openrouter/anthropic/claude-sonnet-4"} - rc.Environment["OPENROUTER_API_KEY"] = "or-key" - rc.Timezone = "America/New_York" - - runtimeDir := filepath.Join(tmp, "runtime") - if err := os.MkdirAll(runtimeDir, 0o700); err != nil { - t.Fatal(err) - } - - d := &Driver{} - result, err := d.Materialize(rc, driver.MaterializeOpts{RuntimeDir: runtimeDir, PodName: "mixed-pod"}) - if err != nil { - t.Fatalf("Materialize failed: %v", err) - } - - if result.ReadOnly { - t.Fatal("microclaw should run with writable filesystem") - } - if result.SkillDir != "/claw-data/skills" { - t.Fatalf("unexpected skill dir: %q", result.SkillDir) - } - if result.SkillLayout != "directory" { - t.Fatalf("unexpected skill layout: %q", result.SkillLayout) - } - if result.User != "microclaw" { - t.Fatalf("unexpected user: %q", result.User) - } - if result.Environment["MICROCLAW_CONFIG"] != "/app/config/microclaw.config.yaml" { - t.Fatalf("expected MICROCLAW_CONFIG env, got %q", result.Environment["MICROCLAW_CONFIG"]) - } - if result.Environment[shared.PortableMemoryEnv] != shared.PortableMemoryDir { - t.Fatalf("expected %s=%s, got %q", shared.PortableMemoryEnv, shared.PortableMemoryDir, result.Environment[shared.PortableMemoryEnv]) - } - - foundMemoryMount := false - for _, mount := range result.Mounts { - if mount.ContainerPath == shared.PortableMemoryDir { - foundMemoryMount = true - if mount.ReadOnly { - t.Fatal("portable memory mount should be writable") - } - } - } - if !foundMemoryMount { - t.Fatal("expected portable memory mount") - } - - cfgPath := filepath.Join(runtimeDir, "config", "microclaw.config.yaml") - cfgBytes, err := os.ReadFile(cfgPath) - if err != nil { - t.Fatalf("read generated config: %v", err) - } - var cfg map[string]interface{} - if err := yaml.Unmarshal(cfgBytes, &cfg); err != nil { - t.Fatalf("parse generated config yaml: %v", err) - } - if got := cfg["llm_provider"]; got != "openrouter" { - t.Fatalf("expected llm_provider=openrouter, got %v", got) - } - if got := cfg["model"]; got != "anthropic/claude-sonnet-4" { - t.Fatalf("expected model=anthropic/claude-sonnet-4, got %v", got) - } - if got := cfg["api_key"]; got != "or-key" { - t.Fatalf("expected api_key=or-key, got %v", got) - } - if got := cfg["data_dir"]; got != "/claw-data" { - t.Fatalf("expected data_dir=/claw-data, got %v", got) - } - if got := cfg["timezone"]; got != "America/New_York" { - t.Fatalf("expected timezone=America/New_York, got %v", got) - } - - channels, _ := cfg["channels"].(map[string]interface{}) - web, _ := channels["web"].(map[string]interface{}) - if enabled, _ := web["enabled"].(bool); !enabled { - t.Fatal("expected channels.web.enabled=true") - } - - seedPath := filepath.Join(runtimeDir, "data", "runtime", "groups", "AGENTS.md") - seeded, err := os.ReadFile(seedPath) - if err != nil { - t.Fatalf("read seeded AGENTS.md: %v", err) - } - seedStr := string(seeded) - if !strings.Contains(seedStr, "You are micro") { - t.Fatalf("seeded AGENTS.md missing agent contract content") - } - if !strings.Contains(seedStr, "mixed-pod") { - t.Fatalf("seeded AGENTS.md missing pod context") - } -} - -func TestMaterializeCllamaOpenAIModelRewrite(t *testing.T) { - rc, tmp := newTestRC(t) - rc.Models = map[string]string{"primary": "openrouter/moonshotai/kimi-k2.5"} - rc.Cllama = []string{"passthrough"} - rc.CllamaToken = "agent:token" - - runtimeDir := filepath.Join(tmp, "runtime") - if err := os.MkdirAll(runtimeDir, 0o700); err != nil { - t.Fatal(err) - } - - d := &Driver{} - if _, err := d.Materialize(rc, driver.MaterializeOpts{RuntimeDir: runtimeDir}); err != nil { - t.Fatalf("Materialize failed: %v", err) - } - - cfgPath := filepath.Join(runtimeDir, "config", "microclaw.config.yaml") - cfgBytes, err := os.ReadFile(cfgPath) - if err != nil { - t.Fatalf("read generated config: %v", err) - } - var cfg map[string]interface{} - if err := yaml.Unmarshal(cfgBytes, &cfg); err != nil { - t.Fatalf("parse generated config yaml: %v", err) - } - - if got := cfg["llm_provider"]; got != "openai" { - t.Fatalf("expected cllama llm_provider=openai, got %v", got) - } - if got := cfg["model"]; got != "openrouter/moonshotai/kimi-k2.5" { - t.Fatalf("expected cllama model provider-prefixed, got %v", got) - } - if got := cfg["llm_base_url"]; got != "http://cllama:8080/v1" { - t.Fatalf("expected cllama llm_base_url rewrite, got %v", got) - } - if got := cfg["api_key"]; got != "agent:token" { - t.Fatalf("expected cllama token api_key, got %v", got) - } -} - -func TestMaterializeCllamaAnthropicModelRewrite(t *testing.T) { - rc, tmp := newTestRC(t) - rc.Models = map[string]string{"primary": "anthropic/claude-sonnet-4"} - rc.Cllama = []string{"passthrough"} - rc.CllamaToken = "agent:token" - - runtimeDir := filepath.Join(tmp, "runtime") - if err := os.MkdirAll(runtimeDir, 0o700); err != nil { - t.Fatal(err) - } - - d := &Driver{} - if _, err := d.Materialize(rc, driver.MaterializeOpts{RuntimeDir: runtimeDir}); err != nil { - t.Fatalf("Materialize failed: %v", err) - } - - cfgPath := filepath.Join(runtimeDir, "config", "microclaw.config.yaml") - cfgBytes, err := os.ReadFile(cfgPath) - if err != nil { - t.Fatalf("read generated config: %v", err) - } - var cfg map[string]interface{} - if err := yaml.Unmarshal(cfgBytes, &cfg); err != nil { - t.Fatalf("parse generated config yaml: %v", err) - } - - if got := cfg["llm_provider"]; got != "anthropic" { - t.Fatalf("expected anthropic provider for anthropic model under cllama, got %v", got) - } - if got := cfg["model"]; got != "claude-sonnet-4" { - t.Fatalf("expected de-prefixed anthropic model, got %v", got) - } -} - -func TestMaterializeDiscordHandleChannelConfig(t *testing.T) { - rc, tmp := newTestRC(t) - rc.Environment["OPENROUTER_API_KEY"] = "or-key" - rc.Environment["DISCORD_BOT_TOKEN"] = "discord-token" - rc.Models = map[string]string{"primary": "openrouter/anthropic/claude-sonnet-4"} - rc.Handles = map[string]*driver.HandleInfo{ - "discord": { - ID: "111", - Username: "micro-bot", - Guilds: []driver.GuildInfo{{ - ID: "999", - Channels: []driver.ChannelInfo{{ID: "123456789012345678", Name: "trading-floor"}}, - }}, - }, - } - - runtimeDir := filepath.Join(tmp, "runtime") - if err := os.MkdirAll(runtimeDir, 0o700); err != nil { - t.Fatal(err) - } - - d := &Driver{} - if _, err := d.Materialize(rc, driver.MaterializeOpts{RuntimeDir: runtimeDir}); err != nil { - t.Fatalf("Materialize failed: %v", err) - } - - cfgPath := filepath.Join(runtimeDir, "config", "microclaw.config.yaml") - cfgBytes, err := os.ReadFile(cfgPath) - if err != nil { - t.Fatalf("read generated config: %v", err) - } - var cfg map[string]interface{} - if err := yaml.Unmarshal(cfgBytes, &cfg); err != nil { - t.Fatalf("parse generated config yaml: %v", err) - } - - channels, _ := cfg["channels"].(map[string]interface{}) - discord, _ := channels["discord"].(map[string]interface{}) - if enabled, _ := discord["enabled"].(bool); !enabled { - t.Fatal("expected channels.discord.enabled=true") - } - if got := discord["bot_token"]; got != "discord-token" { - t.Fatalf("expected discord bot token in config, got %v", got) - } - if got := discord["bot_username"]; got != "micro-bot" { - t.Fatalf("expected discord bot_username from HANDLE username, got %v", got) - } -} - -func newTestRC(t *testing.T) (*driver.ResolvedClaw, string) { - t.Helper() - tmp := t.TempDir() - agentPath := filepath.Join(tmp, "AGENTS.md") - if err := os.WriteFile(agentPath, []byte("# Agent\n\nYou are micro."), 0o644); err != nil { - t.Fatal(err) - } - - rc := &driver.ResolvedClaw{ - ServiceName: "micro", - ClawType: "microclaw", - AgentHostPath: agentPath, - Models: map[string]string{"primary": "anthropic/claude-sonnet-4"}, - Environment: map[string]string{}, - } - return rc, tmp -} diff --git a/internal/driver/nanoclaw/baseimage.go b/internal/driver/nanoclaw/baseimage.go deleted file mode 100644 index c77fff22..00000000 --- a/internal/driver/nanoclaw/baseimage.go +++ /dev/null @@ -1,44 +0,0 @@ -package nanoclaw - -const baseImageTag = "nanoclaw-orchestrator:latest" - -const baseImageDockerfile = `FROM node:22-bookworm-slim AS builder - -RUN apt-get update && apt-get install -y --no-install-recommends \ - ca-certificates git python3 make g++ \ - && rm -rf /var/lib/apt/lists/* - -WORKDIR /src - -RUN git clone --depth 1 https://github.com/qwibitai/nanoclaw.git /src \ - && rm -rf /src/.git - -RUN npm ci -RUN npm run build - -FROM node:22-bookworm-slim - -RUN apt-get update && apt-get install -y --no-install-recommends \ - ca-certificates git procps tini \ - && rm -rf /var/lib/apt/lists/* - -COPY --from=docker:27-cli /usr/local/bin/docker /usr/local/bin/docker - -RUN npm install -g @anthropic-ai/claude-code - -WORKDIR /workspace - -COPY --from=builder /src /workspace - -RUN mkdir -p /workspace/groups/main /workspace/container/skills - -ENTRYPOINT ["/usr/bin/tini", "--", "node", "/workspace/dist/index.js"] -` - -func (d *Driver) BaseImage() (string, string) { - return baseImageTag, baseImageDockerfile -} - -func (d *Driver) RunnerAlias() string { - return "nanoclaw-orchestrator" -} diff --git a/internal/driver/nanoclaw/baseimage_test.go b/internal/driver/nanoclaw/baseimage_test.go deleted file mode 100644 index 5b23a7da..00000000 --- a/internal/driver/nanoclaw/baseimage_test.go +++ /dev/null @@ -1,35 +0,0 @@ -package nanoclaw - -import ( - "strings" - "testing" - - "github.com/mostlydev/clawdapus/internal/driver" -) - -func TestBaseImageProvider(t *testing.T) { - d := &Driver{} - - var _ driver.BaseImageProvider = d - - tag, dockerfile := d.BaseImage() - - if tag != "nanoclaw-orchestrator:latest" { - t.Fatalf("expected tag nanoclaw-orchestrator:latest, got %q", tag) - } - if !strings.HasPrefix(dockerfile, "FROM node:22-bookworm-slim AS builder") { - t.Fatal("Dockerfile should use a builder stage") - } - if !strings.Contains(dockerfile, "https://github.com/qwibitai/nanoclaw.git") { - t.Fatal("Dockerfile should clone the nanoclaw upstream repository") - } - if !strings.Contains(dockerfile, "COPY --from=docker:27-cli /usr/local/bin/docker /usr/local/bin/docker") { - t.Fatal("Dockerfile should copy in the Docker CLI") - } - if !strings.Contains(dockerfile, "npm install -g @anthropic-ai/claude-code") { - t.Fatal("Dockerfile should install the Claude Code CLI") - } - if !strings.Contains(dockerfile, `ENTRYPOINT ["/usr/bin/tini", "--", "node", "/workspace/dist/index.js"]`) { - t.Fatal("Dockerfile should start the nanoclaw orchestrator") - } -} diff --git a/internal/driver/nanoclaw/driver.go b/internal/driver/nanoclaw/driver.go deleted file mode 100644 index 1de3485c..00000000 --- a/internal/driver/nanoclaw/driver.go +++ /dev/null @@ -1,169 +0,0 @@ -package nanoclaw - -import ( - "context" - "fmt" - "os" - "path/filepath" - "time" - - "github.com/docker/docker/client" - "github.com/mostlydev/clawdapus/internal/cllama" - "github.com/mostlydev/clawdapus/internal/driver" - "github.com/mostlydev/clawdapus/internal/driver/shared" -) - -// Driver implements the Clawdapus driver interface for NanoClaw — -// a lightweight agent runtime using the Claude Agent SDK. -type Driver struct{} - -func init() { - driver.Register("nanoclaw", &Driver{}) -} - -func (d *Driver) Validate(rc *driver.ResolvedClaw) error { - if rc.AgentHostPath == "" { - return fmt.Errorf("nanoclaw driver: no agent host path specified (no contract, no start)") - } - if _, err := os.Stat(rc.AgentHostPath); err != nil { - return fmt.Errorf("nanoclaw driver: agent file %q not found: %w", rc.AgentHostPath, err) - } - if rc.Privileges == nil || rc.Privileges["docker-socket"] != "true" { - return fmt.Errorf("nanoclaw driver: requires PRIVILEGE docker-socket (nanoclaw spawns agent containers via Docker)") - } - if len(rc.Invocations) > 0 { - fmt.Printf("[claw] warning: nanoclaw driver: INVOKE scheduling not supported; ignoring %d invocations\n", len(rc.Invocations)) - } - return nil -} - -func (d *Driver) Materialize(rc *driver.ResolvedClaw, opts driver.MaterializeOpts) (*driver.MaterializeResult, error) { - podName := opts.PodName - if podName == "" { - podName = rc.ServiceName - } - memoryDir, err := shared.PreparePortableMemory(shared.ResolveStateDir(opts.RuntimeDir, opts.StateDir), opts.RuntimeDir) - if err != nil { - return nil, fmt.Errorf("nanoclaw driver: prepare portable memory: %w", err) - } - - // Combine agent contract + CLAWDAPUS.md into single CLAUDE.md. - // Flows: orchestrator groups/main/ → agent-runner /workspace/group/CLAUDE.md → SDK auto-loads. - agentContent, err := os.ReadFile(rc.AgentHostPath) - if err != nil { - return nil, fmt.Errorf("nanoclaw driver: read agent contract: %w", err) - } - clawdapusMd := shared.GenerateClawdapusMD(rc, podName) - combined := string(agentContent) + "\n\n---\n\n" + clawdapusMd - combinedPath := filepath.Join(opts.RuntimeDir, "CLAUDE.md") - if err := os.WriteFile(combinedPath, []byte(combined), 0644); err != nil { - return nil, fmt.Errorf("nanoclaw driver: write combined CLAUDE.md: %w", err) - } - - mounts := []driver.Mount{ - {HostPath: combinedPath, ContainerPath: "/workspace/groups/main/CLAUDE.md", ReadOnly: true}, - {HostPath: "/var/run/docker.sock", ContainerPath: "/var/run/docker.sock", ReadOnly: false}, - {HostPath: memoryDir, ContainerPath: shared.PortableMemoryDir, ReadOnly: false}, - } - if rc.PersonaHostPath != "" { - mounts = append(mounts, driver.Mount{ - HostPath: rc.PersonaHostPath, - ContainerPath: "/workspace/container/persona", - ReadOnly: false, - }) - } - - env := map[string]string{ - "CLAW_MANAGED": "true", - shared.PortableMemoryEnv: shared.PortableMemoryDir, - } - if rc.PersonaHostPath != "" { - env["CLAW_PERSONA_DIR"] = "/workspace/container/persona" - } - - if len(rc.Cllama) > 0 { - firstProxy := cllama.ProxyBaseURL(rc.Cllama[0]) - env["ANTHROPIC_BASE_URL"] = firstProxy - // Compose network name: {project}_{network} - env["CLAW_NETWORK"] = fmt.Sprintf("%s_claw-internal", podName) - - if rc.CllamaToken != "" { - // .env file for orchestrator's readEnvFile() — passes to agent-runners via stdin - envContent := fmt.Sprintf("ANTHROPIC_API_KEY=%s\n", rc.CllamaToken) - envPath := filepath.Join(opts.RuntimeDir, ".env") - if err := os.WriteFile(envPath, []byte(envContent), 0600); err != nil { - return nil, fmt.Errorf("nanoclaw driver: write .env: %w", err) - } - mounts = append(mounts, driver.Mount{ - HostPath: envPath, - ContainerPath: "/workspace/.env", - ReadOnly: true, - }) - } - } - - return &driver.MaterializeResult{ - Mounts: mounts, - Tmpfs: []string{"/tmp"}, - ReadOnly: false, - Restart: "on-failure", - User: "0:0", - SkillDir: "/workspace/container/skills", - SkillLayout: "directory", - Healthcheck: &driver.Healthcheck{ - Test: []string{"CMD-SHELL", "pgrep -f 'node.*index' > /dev/null"}, - Interval: "30s", - Timeout: "10s", - Retries: 3, - }, - Environment: env, - }, nil -} - -func (d *Driver) PostApply(rc *driver.ResolvedClaw, opts driver.PostApplyOpts) error { - if opts.ContainerID == "" { - return fmt.Errorf("nanoclaw driver: post-apply check failed: no container ID") - } - cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) - if err != nil { - return fmt.Errorf("nanoclaw driver: post-apply docker client: %w", err) - } - defer cli.Close() - info, err := cli.ContainerInspect(context.Background(), opts.ContainerID) - if err != nil { - return fmt.Errorf("nanoclaw driver: post-apply inspect: %w", err) - } - if !info.State.Running { - cid := opts.ContainerID - if len(cid) > 12 { - cid = cid[:12] - } - return fmt.Errorf("nanoclaw driver: container %s not running (status: %s)", cid, info.State.Status) - } - return nil -} - -func (d *Driver) HealthProbe(ref driver.ContainerRef) (*driver.Health, error) { - if ref.ContainerID == "" { - return &driver.Health{OK: false, Detail: "no container ID"}, nil - } - cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) - if err != nil { - return nil, fmt.Errorf("nanoclaw driver: health docker client: %w", err) - } - defer cli.Close() - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) - defer cancel() - info, err := cli.ContainerInspect(ctx, ref.ContainerID) - if err != nil { - return &driver.Health{OK: false, Detail: fmt.Sprintf("inspect: %v", err)}, nil - } - if info.State == nil || !info.State.Running { - status := "unknown" - if info.State != nil { - status = info.State.Status - } - return &driver.Health{OK: false, Detail: fmt.Sprintf("not running (%s)", status)}, nil - } - return &driver.Health{OK: true, Detail: "container running"}, nil -} diff --git a/internal/driver/nanoclaw/driver_test.go b/internal/driver/nanoclaw/driver_test.go deleted file mode 100644 index 4f670c2b..00000000 --- a/internal/driver/nanoclaw/driver_test.go +++ /dev/null @@ -1,349 +0,0 @@ -package nanoclaw - -import ( - "os" - "path/filepath" - "strings" - "testing" - - "github.com/mostlydev/clawdapus/internal/driver" - "github.com/mostlydev/clawdapus/internal/driver/shared" -) - -func TestDriverRegistered(t *testing.T) { - d, err := driver.Lookup("nanoclaw") - if err != nil { - t.Fatalf("nanoclaw driver not registered: %v", err) - } - if d == nil { - t.Fatal("nanoclaw driver is nil") - } -} - -func TestValidateRequiresAgentPath(t *testing.T) { - d := &Driver{} - rc := &driver.ResolvedClaw{ - ServiceName: "test", - Privileges: map[string]string{"docker-socket": "true"}, - } - err := d.Validate(rc) - if err == nil { - t.Fatal("expected error for missing agent host path") - } - if !strings.Contains(err.Error(), "no agent host path") { - t.Errorf("unexpected error: %v", err) - } -} - -func TestValidateRequiresDockerSocket(t *testing.T) { - tmp := t.TempDir() - agentPath := filepath.Join(tmp, "AGENTS.md") - if err := os.WriteFile(agentPath, []byte("test"), 0644); err != nil { - t.Fatal(err) - } - - d := &Driver{} - rc := &driver.ResolvedClaw{ - ServiceName: "test", - AgentHostPath: agentPath, - Privileges: map[string]string{}, - } - err := d.Validate(rc) - if err == nil { - t.Fatal("expected error for missing docker-socket privilege") - } - if !strings.Contains(err.Error(), "PRIVILEGE docker-socket") { - t.Errorf("unexpected error: %v", err) - } -} - -func TestValidateAcceptsValidConfig(t *testing.T) { - tmp := t.TempDir() - agentPath := filepath.Join(tmp, "AGENTS.md") - if err := os.WriteFile(agentPath, []byte("test"), 0644); err != nil { - t.Fatal(err) - } - - d := &Driver{} - rc := &driver.ResolvedClaw{ - ServiceName: "test", - AgentHostPath: agentPath, - Privileges: map[string]string{"docker-socket": "true"}, - } - if err := d.Validate(rc); err != nil { - t.Fatalf("unexpected validation error: %v", err) - } -} - -func TestValidateWarnsOnInvocations(t *testing.T) { - tmp := t.TempDir() - agentPath := filepath.Join(tmp, "AGENTS.md") - if err := os.WriteFile(agentPath, []byte("test"), 0644); err != nil { - t.Fatal(err) - } - - d := &Driver{} - rc := &driver.ResolvedClaw{ - ServiceName: "test", - AgentHostPath: agentPath, - Privileges: map[string]string{"docker-socket": "true"}, - Invocations: []driver.Invocation{{Schedule: "0 * * * *", Message: "test"}}, - } - // Should not error, just warn - if err := d.Validate(rc); err != nil { - t.Fatalf("invocations should produce warning, not error: %v", err) - } -} - -// --- Materialize tests --- - -func newTestRC(t *testing.T) (*driver.ResolvedClaw, string) { - t.Helper() - tmp := t.TempDir() - agentPath := filepath.Join(tmp, "AGENTS.md") - if err := os.WriteFile(agentPath, []byte("# Test Agent\n\nYou are Allen."), 0644); err != nil { - t.Fatal(err) - } - rc := &driver.ResolvedClaw{ - ServiceName: "nano-bot", - ClawType: "nanoclaw", - AgentHostPath: agentPath, - Privileges: map[string]string{"docker-socket": "true"}, - } - return rc, tmp -} - -func TestMaterializeBasic(t *testing.T) { - rc, tmp := newTestRC(t) - runtimeDir := filepath.Join(tmp, "runtime") - if err := os.MkdirAll(runtimeDir, 0700); err != nil { - t.Fatal(err) - } - - d := &Driver{} - result, err := d.Materialize(rc, driver.MaterializeOpts{RuntimeDir: runtimeDir, PodName: "test-pod"}) - if err != nil { - t.Fatalf("Materialize failed: %v", err) - } - - // ReadOnly must be false — NanoClaw needs writable workspace - if result.ReadOnly { - t.Error("expected ReadOnly=false for nanoclaw") - } - - // SkillDir targets orchestrator's container/skills path - if result.SkillDir != "/workspace/container/skills" { - t.Errorf("expected orchestrator skill dir /workspace/container/skills, got %q", result.SkillDir) - } - if result.SkillLayout != "directory" { - t.Errorf("expected directory skill layout, got %q", result.SkillLayout) - } - if result.User != "0:0" { - t.Errorf("expected User=0:0, got %q", result.User) - } - - // Check mounts - hasDockerSocket := false - hasCombinedClaude := false - hasOldAgent := false - hasOldClawdapus := false - for _, m := range result.Mounts { - if m.ContainerPath == "/var/run/docker.sock" { - hasDockerSocket = true - if m.ReadOnly { - t.Error("Docker socket mount should be read-write") - } - } - if m.ContainerPath == "/workspace/groups/main/CLAUDE.md" { - hasCombinedClaude = true - if !m.ReadOnly { - t.Error("combined CLAUDE.md mount should be read-only") - } - } - // Old paths must NOT appear - if m.ContainerPath == "/workspace/AGENTS.md" { - hasOldAgent = true - } - if m.ContainerPath == "/workspace/CLAWDAPUS.md" { - hasOldClawdapus = true - } - } - if !hasDockerSocket { - t.Error("expected Docker socket mount") - } - if !hasCombinedClaude { - t.Error("expected combined CLAUDE.md mount at /workspace/groups/main/CLAUDE.md") - } - if hasOldAgent { - t.Error("should NOT have old /workspace/AGENTS.md mount") - } - if hasOldClawdapus { - t.Error("should NOT have old /workspace/CLAWDAPUS.md mount") - } - - // Verify combined CLAUDE.md contains both agent content and CLAWDAPUS.md - combinedPath := filepath.Join(runtimeDir, "CLAUDE.md") - content, err := os.ReadFile(combinedPath) - if err != nil { - t.Fatalf("CLAUDE.md not written: %v", err) - } - if !strings.Contains(string(content), "Test Agent") { - t.Error("combined CLAUDE.md should contain agent contract content") - } - if !strings.Contains(string(content), "test-pod") { - t.Error("combined CLAUDE.md should contain pod name from CLAWDAPUS.md") - } - if !strings.Contains(string(content), "nano-bot") { - t.Error("combined CLAUDE.md should contain service name from CLAWDAPUS.md") - } - - // Environment - if result.Environment["CLAW_MANAGED"] != "true" { - t.Error("expected CLAW_MANAGED=true in environment") - } - if result.Environment[shared.PortableMemoryEnv] != shared.PortableMemoryDir { - t.Errorf("expected %s=%s, got %q", shared.PortableMemoryEnv, shared.PortableMemoryDir, result.Environment[shared.PortableMemoryEnv]) - } - - hasPortableMemory := false - for _, m := range result.Mounts { - if m.ContainerPath == shared.PortableMemoryDir { - hasPortableMemory = true - if m.ReadOnly { - t.Error("portable memory mount should be writable") - } - } - } - if !hasPortableMemory { - t.Error("expected portable memory mount") - } -} - -func TestMaterializeWithCllama(t *testing.T) { - rc, tmp := newTestRC(t) - rc.Cllama = []string{"passthrough"} - rc.CllamaToken = "nano-bot:abc123" - runtimeDir := filepath.Join(tmp, "runtime") - if err := os.MkdirAll(runtimeDir, 0700); err != nil { - t.Fatal(err) - } - - d := &Driver{} - result, err := d.Materialize(rc, driver.MaterializeOpts{RuntimeDir: runtimeDir, PodName: "test-pod"}) - if err != nil { - t.Fatalf("Materialize failed: %v", err) - } - - // ANTHROPIC_BASE_URL as env var (orchestrator forwards to agent-runners) - if result.Environment["ANTHROPIC_BASE_URL"] != "http://cllama:8080/v1" { - t.Errorf("expected ANTHROPIC_BASE_URL rewritten to proxy, got %q", result.Environment["ANTHROPIC_BASE_URL"]) - } - - // ANTHROPIC_API_KEY must NOT be in env — goes to .env file instead - if _, ok := result.Environment["ANTHROPIC_API_KEY"]; ok { - t.Error("ANTHROPIC_API_KEY should NOT be in env vars — must go to .env file") - } - - // CLAW_NETWORK for agent-runner pod connectivity - if result.Environment["CLAW_NETWORK"] != "test-pod_claw-internal" { - t.Errorf("expected CLAW_NETWORK=test-pod_claw-internal, got %q", result.Environment["CLAW_NETWORK"]) - } - - // .env file mount with ANTHROPIC_API_KEY - hasEnvMount := false - for _, m := range result.Mounts { - if m.ContainerPath == "/workspace/.env" { - hasEnvMount = true - if !m.ReadOnly { - t.Error(".env mount should be read-only") - } - } - } - if !hasEnvMount { - t.Error("expected .env mount at /workspace/.env for cllama token") - } - - // Verify .env file content - envPath := filepath.Join(runtimeDir, ".env") - envContent, err := os.ReadFile(envPath) - if err != nil { - t.Fatalf(".env not written: %v", err) - } - envInfo, err := os.Stat(envPath) - if err != nil { - t.Fatalf(".env stat failed: %v", err) - } - if perm := envInfo.Mode().Perm(); perm != 0o600 { - t.Errorf("expected .env permissions 0600, got %o", perm) - } - if !strings.Contains(string(envContent), "ANTHROPIC_API_KEY=nano-bot:abc123") { - t.Errorf(".env should contain ANTHROPIC_API_KEY token, got %q", string(envContent)) - } -} - -func TestMaterializeMountsPersonaWorkspace(t *testing.T) { - rc, tmp := newTestRC(t) - personaDir := filepath.Join(tmp, "persona") - if err := os.MkdirAll(personaDir, 0o755); err != nil { - t.Fatal(err) - } - rc.Persona = "ghcr.io/mostlydev/personas/allen:latest" - rc.PersonaHostPath = personaDir - runtimeDir := filepath.Join(tmp, "runtime") - if err := os.MkdirAll(runtimeDir, 0o700); err != nil { - t.Fatal(err) - } - - d := &Driver{} - result, err := d.Materialize(rc, driver.MaterializeOpts{RuntimeDir: runtimeDir, PodName: "test-pod"}) - if err != nil { - t.Fatalf("Materialize failed: %v", err) - } - - found := false - for _, mount := range result.Mounts { - if mount.ContainerPath == "/workspace/container/persona" { - found = true - if mount.ReadOnly { - t.Fatal("persona mount should be writable") - } - } - } - if !found { - t.Fatal("expected persona mount for nanoclaw") - } - if result.Environment["CLAW_PERSONA_DIR"] != "/workspace/container/persona" { - t.Fatalf("unexpected persona env: %q", result.Environment["CLAW_PERSONA_DIR"]) - } -} - -func TestMaterializeWithoutCllama(t *testing.T) { - rc, tmp := newTestRC(t) - runtimeDir := filepath.Join(tmp, "runtime") - if err := os.MkdirAll(runtimeDir, 0700); err != nil { - t.Fatal(err) - } - - d := &Driver{} - result, err := d.Materialize(rc, driver.MaterializeOpts{RuntimeDir: runtimeDir, PodName: "test-pod"}) - if err != nil { - t.Fatalf("Materialize failed: %v", err) - } - - if _, ok := result.Environment["ANTHROPIC_BASE_URL"]; ok { - t.Error("expected no ANTHROPIC_BASE_URL when cllama not enabled") - } - if _, ok := result.Environment["ANTHROPIC_API_KEY"]; ok { - t.Error("expected no ANTHROPIC_API_KEY when cllama not enabled") - } - if _, ok := result.Environment["CLAW_NETWORK"]; ok { - t.Error("expected no CLAW_NETWORK when cllama not enabled") - } - - // No .env mount - for _, m := range result.Mounts { - if m.ContainerPath == "/workspace/.env" { - t.Error("expected no .env mount when cllama not enabled") - } - } -} diff --git a/internal/driver/nullclaw/baseimage.go b/internal/driver/nullclaw/baseimage.go deleted file mode 100644 index 09ae72aa..00000000 --- a/internal/driver/nullclaw/baseimage.go +++ /dev/null @@ -1,16 +0,0 @@ -package nullclaw - -const baseImageTag = "nullclaw:latest" - -const baseImageDockerfile = `FROM ghcr.io/nullclaw/nullclaw:latest - -LABEL org.opencontainers.image.source="https://github.com/nullclaw/nullclaw" -` - -func (d *Driver) BaseImage() (string, string) { - return baseImageTag, baseImageDockerfile -} - -func (d *Driver) RunnerAlias() string { - return "nullclaw" -} diff --git a/internal/driver/nullclaw/baseimage_test.go b/internal/driver/nullclaw/baseimage_test.go deleted file mode 100644 index e62a99dd..00000000 --- a/internal/driver/nullclaw/baseimage_test.go +++ /dev/null @@ -1,26 +0,0 @@ -package nullclaw - -import ( - "strings" - "testing" - - "github.com/mostlydev/clawdapus/internal/driver" -) - -func TestBaseImageProvider(t *testing.T) { - d := &Driver{} - - var _ driver.BaseImageProvider = d - - tag, dockerfile := d.BaseImage() - - if tag != "nullclaw:latest" { - t.Fatalf("expected tag nullclaw:latest, got %q", tag) - } - if !strings.HasPrefix(dockerfile, "FROM ghcr.io/nullclaw/nullclaw:latest") { - t.Fatal("Dockerfile should alias the official nullclaw image") - } - if !strings.Contains(dockerfile, "https://github.com/nullclaw/nullclaw") { - t.Fatal("Dockerfile should point at the nullclaw upstream source") - } -} diff --git a/internal/driver/nullclaw/config.go b/internal/driver/nullclaw/config.go deleted file mode 100644 index f26c51ba..00000000 --- a/internal/driver/nullclaw/config.go +++ /dev/null @@ -1,162 +0,0 @@ -package nullclaw - -import ( - "encoding/json" - "fmt" - "strings" - - "github.com/mostlydev/clawdapus/internal/driver" - "github.com/mostlydev/clawdapus/internal/driver/shared" -) - -// GenerateConfig builds a nullclaw JSON config from resolved Claw directives. -// Output is deterministic because map keys are sorted by encoding/json. -func GenerateConfig(rc *driver.ResolvedClaw) ([]byte, error) { - config := make(map[string]interface{}) - - // Conservative gateway defaults: keep local bind + pairing requirement. - if err := shared.SetPath(config, "gateway.port", 3000); err != nil { - return nil, fmt.Errorf("config generation: %w", err) - } - if err := shared.SetPath(config, "gateway.host", "127.0.0.1"); err != nil { - return nil, fmt.Errorf("config generation: %w", err) - } - if err := shared.SetPath(config, "gateway.require_pairing", true); err != nil { - return nil, fmt.Errorf("config generation: %w", err) - } - - // Safety defaults. - if err := shared.SetPath(config, "autonomy.level", "supervised"); err != nil { - return nil, fmt.Errorf("config generation: %w", err) - } - if err := shared.SetPath(config, "autonomy.workspace_only", true); err != nil { - return nil, fmt.Errorf("config generation: %w", err) - } - - for slot, model := range rc.Models { - if slot == "fallback" { - if err := shared.SetPath(config, "reliability.fallback_providers", []string{model}); err != nil { - return nil, fmt.Errorf("config generation: %w", err) - } - continue - } - if err := shared.SetPath(config, "agents.defaults.model."+slot, model); err != nil { - return nil, fmt.Errorf("config generation: %w", err) - } - } - - if len(rc.Cllama) > 0 { - if strings.TrimSpace(rc.CllamaToken) == "" { - return nil, fmt.Errorf("config generation: CLLAMA is enabled but token is empty") - } - firstProxy := fmt.Sprintf("http://cllama-%s:8080/v1", rc.Cllama[0]) - for _, provider := range shared.CollectProviders(rc.Models) { - base := "models.providers." + provider - if err := shared.SetPath(config, base+".base_url", firstProxy); err != nil { - return nil, fmt.Errorf("config generation: cllama provider %q base_url: %w", provider, err) - } - if err := shared.SetPath(config, base+".api_key", rc.CllamaToken); err != nil { - return nil, fmt.Errorf("config generation: cllama provider %q api_key: %w", provider, err) - } - } - } - - // Signal tool-based response delivery when channel handles are present. - // Agents post via explicit send_message rather than auto-routed text. - if len(rc.Handles) > 0 { - if err := shared.SetPath(config, "gateway.response_mode", "tool"); err != nil { - return nil, fmt.Errorf("config generation: gateway.response_mode: %w", err) - } - } - - // HANDLE defaults first. CONFIGURE runs last and overrides these values. - for platform, h := range rc.Handles { - switch strings.ToLower(platform) { - case "discord": - if err := shared.SetPath(config, "channels.discord.accounts.main.mention_only", true); err != nil { - return nil, fmt.Errorf("config generation: HANDLE discord: %w", err) - } - token, err := shared.ResolveEnvTokenFromMapWithRuntimeEnv(rc.Environment, "DISCORD_BOT_TOKEN", rc.RuntimeEnv) - if err != nil { - return nil, fmt.Errorf("config generation: HANDLE discord: DISCORD_BOT_TOKEN: %w", err) - } - if token != "" { - if err := shared.SetPath(config, "channels.discord.accounts.main.token", token); err != nil { - return nil, fmt.Errorf("config generation: HANDLE discord: %w", err) - } - } - if h != nil { - for _, g := range h.Guilds { - gid := strings.TrimSpace(g.ID) - if gid == "" { - continue - } - if err := shared.SetPath(config, "channels.discord.accounts.main.guild_id", gid); err != nil { - return nil, fmt.Errorf("config generation: HANDLE discord: %w", err) - } - break - } - } - case "telegram": - token, err := shared.ResolveEnvTokenFromMapWithRuntimeEnv(rc.Environment, "TELEGRAM_BOT_TOKEN", rc.RuntimeEnv) - if err != nil { - return nil, fmt.Errorf("config generation: HANDLE telegram: TELEGRAM_BOT_TOKEN: %w", err) - } - if token != "" { - if err := shared.SetPath(config, "channels.telegram.accounts.main.bot_token", token); err != nil { - return nil, fmt.Errorf("config generation: HANDLE telegram: %w", err) - } - } - case "slack": - token, err := shared.ResolveEnvTokenFromMapWithRuntimeEnv(rc.Environment, "SLACK_BOT_TOKEN", rc.RuntimeEnv) - if err != nil { - return nil, fmt.Errorf("config generation: HANDLE slack: SLACK_BOT_TOKEN: %w", err) - } - if token != "" { - if err := shared.SetPath(config, "channels.slack.accounts.main.bot_token", token); err != nil { - return nil, fmt.Errorf("config generation: HANDLE slack: %w", err) - } - } - appToken, err := shared.ResolveEnvTokenFromMapWithRuntimeEnv(rc.Environment, "SLACK_APP_TOKEN", rc.RuntimeEnv) - if err != nil { - return nil, fmt.Errorf("config generation: HANDLE slack: SLACK_APP_TOKEN: %w", err) - } - if appToken != "" { - if err := shared.SetPath(config, "channels.slack.accounts.main.app_token", appToken); err != nil { - return nil, fmt.Errorf("config generation: HANDLE slack: %w", err) - } - if err := shared.SetPath(config, "channels.slack.accounts.main.mode", "socket"); err != nil { - return nil, fmt.Errorf("config generation: HANDLE slack: %w", err) - } - } - signingSecret, err := shared.ResolveEnvTokenFromMapWithRuntimeEnv(rc.Environment, "SLACK_SIGNING_SECRET", rc.RuntimeEnv) - if err != nil { - return nil, fmt.Errorf("config generation: HANDLE slack: SLACK_SIGNING_SECRET: %w", err) - } - if signingSecret != "" { - if err := shared.SetPath(config, "channels.slack.accounts.main.signing_secret", signingSecret); err != nil { - return nil, fmt.Errorf("config generation: HANDLE slack: %w", err) - } - if appToken == "" { - if err := shared.SetPath(config, "channels.slack.accounts.main.mode", "http"); err != nil { - return nil, fmt.Errorf("config generation: HANDLE slack: %w", err) - } - } - } - default: - fmt.Printf("[claw] warning: nullclaw driver has no config mapping for HANDLE platform %q; skipping channel enablement\n", platform) - } - } - - for _, cmd := range rc.Configures { - path, value, err := shared.ParseConfigSetCommand(cmd, "nullclaw") - if err != nil { - return nil, fmt.Errorf("config generation: %w", err) - } - if err := shared.SetPath(config, path, value); err != nil { - return nil, fmt.Errorf("config generation: %w", err) - } - } - - return json.MarshalIndent(config, "", " ") -} diff --git a/internal/driver/nullclaw/config_test.go b/internal/driver/nullclaw/config_test.go deleted file mode 100644 index 76266dd6..00000000 --- a/internal/driver/nullclaw/config_test.go +++ /dev/null @@ -1,250 +0,0 @@ -package nullclaw - -import ( - "encoding/json" - "strings" - "testing" - - "github.com/mostlydev/clawdapus/internal/driver" -) - -func getPath(data []byte, path string) (interface{}, bool) { - var m map[string]interface{} - if err := json.Unmarshal(data, &m); err != nil { - return nil, false - } - keys := strings.Split(path, ".") - var current interface{} = m - for _, key := range keys { - cm, ok := current.(map[string]interface{}) - if !ok { - return nil, false - } - current, ok = cm[key] - if !ok { - return nil, false - } - } - return current, true -} - -func TestGenerateConfigSetsGateway(t *testing.T) { - rc := &driver.ResolvedClaw{} - data, err := GenerateConfig(rc) - if err != nil { - t.Fatal(err) - } - - if v, _ := getPath(data, "gateway.port"); v != float64(3000) { - t.Fatalf("expected gateway.port=3000, got %v", v) - } - if v, _ := getPath(data, "gateway.host"); v != "127.0.0.1" { - t.Fatalf("expected gateway.host=127.0.0.1, got %v", v) - } - if v, _ := getPath(data, "gateway.require_pairing"); v != true { - t.Fatalf("expected gateway.require_pairing=true, got %v", v) - } -} - -func TestGenerateConfigSetsModelPrimary(t *testing.T) { - rc := &driver.ResolvedClaw{ - Models: map[string]string{ - "primary": "openrouter/anthropic/claude-sonnet-4", - }, - } - data, err := GenerateConfig(rc) - if err != nil { - t.Fatal(err) - } - if v, _ := getPath(data, "agents.defaults.model.primary"); v != "openrouter/anthropic/claude-sonnet-4" { - t.Fatalf("unexpected model.primary: %v", v) - } -} - -func TestGenerateConfigModelFallback(t *testing.T) { - rc := &driver.ResolvedClaw{ - Models: map[string]string{ - "primary": "anthropic/claude-sonnet-4", - "fallback": "openrouter/meta-llama/llama-3.3-70b-instruct", - }, - } - data, err := GenerateConfig(rc) - if err != nil { - t.Fatal(err) - } - v, ok := getPath(data, "reliability.fallback_providers") - if !ok { - t.Fatal("expected reliability.fallback_providers") - } - arr, ok := v.([]interface{}) - if !ok || len(arr) != 1 || arr[0] != "openrouter/meta-llama/llama-3.3-70b-instruct" { - t.Fatalf("unexpected fallback providers: %#v", v) - } -} - -func TestGenerateConfigDiscordHandle(t *testing.T) { - rc := &driver.ResolvedClaw{ - Handles: map[string]*driver.HandleInfo{ - "discord": { - Guilds: []driver.GuildInfo{{ID: "123456"}}, - }, - }, - Environment: map[string]string{ - "DISCORD_BOT_TOKEN": "discord-token", - }, - } - data, err := GenerateConfig(rc) - if err != nil { - t.Fatal(err) - } - if v, _ := getPath(data, "channels.discord.accounts.main.token"); v != "discord-token" { - t.Fatalf("unexpected discord token: %v", v) - } - if v, _ := getPath(data, "channels.discord.accounts.main.guild_id"); v != "123456" { - t.Fatalf("unexpected discord guild_id: %v", v) - } -} - -func TestGenerateConfigTelegramHandle(t *testing.T) { - rc := &driver.ResolvedClaw{ - Handles: map[string]*driver.HandleInfo{ - "telegram": {}, - }, - Environment: map[string]string{ - "TELEGRAM_BOT_TOKEN": "tg-token", - }, - } - data, err := GenerateConfig(rc) - if err != nil { - t.Fatal(err) - } - if v, _ := getPath(data, "channels.telegram.accounts.main.bot_token"); v != "tg-token" { - t.Fatalf("unexpected telegram bot token: %v", v) - } -} - -func TestGenerateConfigSlackHandle(t *testing.T) { - rc := &driver.ResolvedClaw{ - Handles: map[string]*driver.HandleInfo{ - "slack": {}, - }, - Environment: map[string]string{ - "SLACK_BOT_TOKEN": "xoxb-123", - "SLACK_APP_TOKEN": "xapp-456", - "SLACK_SIGNING_SECRET": "secret-1", - }, - } - data, err := GenerateConfig(rc) - if err != nil { - t.Fatal(err) - } - if v, _ := getPath(data, "channels.slack.accounts.main.bot_token"); v != "xoxb-123" { - t.Fatalf("unexpected slack bot token: %v", v) - } - if v, _ := getPath(data, "channels.slack.accounts.main.app_token"); v != "xapp-456" { - t.Fatalf("unexpected slack app token: %v", v) - } - if v, _ := getPath(data, "channels.slack.accounts.main.signing_secret"); v != "secret-1" { - t.Fatalf("unexpected slack signing secret: %v", v) - } - if v, _ := getPath(data, "channels.slack.accounts.main.mode"); v != "socket" { - t.Fatalf("unexpected slack mode: %v", v) - } -} - -func TestGenerateConfigCllamaRewrite(t *testing.T) { - rc := &driver.ResolvedClaw{ - Models: map[string]string{ - "primary": "openrouter/anthropic/claude-sonnet-4", - "fallback": "anthropic/claude-3-5-haiku", - }, - Cllama: []string{"passthrough"}, - CllamaToken: "agent-token", - } - data, err := GenerateConfig(rc) - if err != nil { - t.Fatal(err) - } - - for _, provider := range []string{"openrouter", "anthropic"} { - basePath := "models.providers." + provider - if v, _ := getPath(data, basePath+".base_url"); v != "http://cllama-passthrough:8080/v1" { - t.Fatalf("unexpected %s.base_url: %v", basePath, v) - } - if v, _ := getPath(data, basePath+".api_key"); v != "agent-token" { - t.Fatalf("unexpected %s.api_key: %v", basePath, v) - } - } -} - -func TestGenerateConfigConfigure(t *testing.T) { - rc := &driver.ResolvedClaw{ - Configures: []string{ - "nullclaw config set gateway.port 8081", - "nullclaw config set gateway.host \"0.0.0.0\"", - }, - } - data, err := GenerateConfig(rc) - if err != nil { - t.Fatal(err) - } - if v, _ := getPath(data, "gateway.port"); v != float64(8081) { - t.Fatalf("expected gateway.port override, got %v", v) - } - if v, _ := getPath(data, "gateway.host"); v != "0.0.0.0" { - t.Fatalf("expected gateway.host override, got %v", v) - } -} - -func TestGenerateConfigConfigureOverridesHandle(t *testing.T) { - rc := &driver.ResolvedClaw{ - Handles: map[string]*driver.HandleInfo{ - "discord": {}, - }, - Environment: map[string]string{ - "DISCORD_BOT_TOKEN": "default-token", - }, - Configures: []string{ - "nullclaw config set channels.discord.accounts.main.token \"override-token\"", - }, - } - data, err := GenerateConfig(rc) - if err != nil { - t.Fatal(err) - } - if v, _ := getPath(data, "channels.discord.accounts.main.token"); v != "override-token" { - t.Fatalf("expected CONFIGURE override token, got %v", v) - } -} - -func TestGenerateConfigDeterministic(t *testing.T) { - rc := &driver.ResolvedClaw{ - Models: map[string]string{ - "primary": "openrouter/anthropic/claude-sonnet-4", - "fallback": "anthropic/claude-3-5-haiku", - }, - Handles: map[string]*driver.HandleInfo{ - "discord": {}, - "slack": {}, - }, - Environment: map[string]string{ - "DISCORD_BOT_TOKEN": "discord-token", - "SLACK_BOT_TOKEN": "xoxb-123", - }, - Configures: []string{ - "nullclaw config set gateway.port 3001", - }, - } - - first, err := GenerateConfig(rc) - if err != nil { - t.Fatal(err) - } - second, err := GenerateConfig(rc) - if err != nil { - t.Fatal(err) - } - if string(first) != string(second) { - t.Fatal("expected deterministic output") - } -} diff --git a/internal/driver/nullclaw/driver.go b/internal/driver/nullclaw/driver.go deleted file mode 100644 index ab552ba5..00000000 --- a/internal/driver/nullclaw/driver.go +++ /dev/null @@ -1,339 +0,0 @@ -package nullclaw - -import ( - "context" - "fmt" - "os" - "path/filepath" - "strings" - "time" - - "github.com/docker/docker/client" - "github.com/mostlydev/clawdapus/internal/driver" - "github.com/mostlydev/clawdapus/internal/driver/shared" -) - -type Driver struct{} - -func init() { - driver.Register("nullclaw", &Driver{}) -} - -func (d *Driver) Validate(rc *driver.ResolvedClaw) error { - if rc.AgentHostPath == "" { - return fmt.Errorf("nullclaw driver: no agent host path specified (no contract, no start)") - } - if _, err := os.Stat(rc.AgentHostPath); err != nil { - return fmt.Errorf("nullclaw driver: agent file %q not found: %w", rc.AgentHostPath, err) - } - - for _, cmd := range rc.Configures { - if _, _, err := shared.ParseConfigSetCommand(cmd, "nullclaw"); err != nil { - return fmt.Errorf("nullclaw driver: unsupported CONFIGURE command %q: %w", cmd, err) - } - } - - for platform := range rc.Handles { - switch strings.ToLower(platform) { - case "discord": - token, err := shared.ResolveEnvTokenFromMapWithRuntimeEnv(rc.Environment, "DISCORD_BOT_TOKEN", rc.RuntimeEnv) - if err != nil { - return fmt.Errorf("nullclaw driver: DISCORD_BOT_TOKEN: %w", err) - } - if token == "" { - return fmt.Errorf("nullclaw driver: HANDLE discord requires DISCORD_BOT_TOKEN in service environment") - } - case "telegram": - token, err := shared.ResolveEnvTokenFromMapWithRuntimeEnv(rc.Environment, "TELEGRAM_BOT_TOKEN", rc.RuntimeEnv) - if err != nil { - return fmt.Errorf("nullclaw driver: TELEGRAM_BOT_TOKEN: %w", err) - } - if token == "" { - return fmt.Errorf("nullclaw driver: HANDLE telegram requires TELEGRAM_BOT_TOKEN in service environment") - } - case "slack": - token, err := shared.ResolveEnvTokenFromMapWithRuntimeEnv(rc.Environment, "SLACK_BOT_TOKEN", rc.RuntimeEnv) - if err != nil { - return fmt.Errorf("nullclaw driver: SLACK_BOT_TOKEN: %w", err) - } - if token == "" { - return fmt.Errorf("nullclaw driver: HANDLE slack requires SLACK_BOT_TOKEN in service environment") - } - default: - fmt.Printf("[claw] warning: nullclaw driver has no HANDLE validation for platform %q; skipping\n", platform) - } - } - - return nil -} - -func (d *Driver) Materialize(rc *driver.ResolvedClaw, opts driver.MaterializeOpts) (*driver.MaterializeResult, error) { - configData, err := GenerateConfig(rc) - if err != nil { - return nil, fmt.Errorf("nullclaw driver: config generation failed: %w", err) - } - memoryDir, err := shared.PreparePortableMemory(shared.ResolveStateDir(opts.RuntimeDir, opts.StateDir), opts.RuntimeDir) - if err != nil { - return nil, fmt.Errorf("nullclaw driver: prepare portable memory: %w", err) - } - - homeDir := filepath.Join(opts.RuntimeDir, "nullclaw-home") - if err := os.MkdirAll(homeDir, 0o777); err != nil { - return nil, fmt.Errorf("nullclaw driver: create nullclaw home dir: %w", err) - } - configPath := filepath.Join(homeDir, "config.json") - if err := os.WriteFile(configPath, configData, 0o644); err != nil { - return nil, fmt.Errorf("nullclaw driver: write config.json: %w", err) - } - - podName := opts.PodName - if podName == "" { - podName = rc.ServiceName - } - clawdapusPath := filepath.Join(opts.RuntimeDir, "CLAWDAPUS.md") - clawdapusMD := shared.GenerateClawdapusMD(rc, podName) - if err := os.WriteFile(clawdapusPath, []byte(clawdapusMD), 0o644); err != nil { - return nil, fmt.Errorf("nullclaw driver: write CLAWDAPUS.md: %w", err) - } - - mounts := []driver.Mount{ - { - HostPath: homeDir, - ContainerPath: "/root/.nullclaw", - ReadOnly: false, - }, - { - // Upstream image sets HOME=/nullclaw-data; mount both for compatibility. - HostPath: homeDir, - ContainerPath: "/nullclaw-data/.nullclaw", - ReadOnly: false, - }, - { - HostPath: rc.AgentHostPath, - ContainerPath: "/claw/AGENTS.md", - ReadOnly: true, - }, - { - HostPath: clawdapusPath, - ContainerPath: "/claw/CLAWDAPUS.md", - ReadOnly: true, - }, - { - HostPath: memoryDir, - ContainerPath: shared.PortableMemoryDir, - ReadOnly: false, - }, - } - if rc.PersonaHostPath != "" { - mounts = append(mounts, driver.Mount{ - HostPath: rc.PersonaHostPath, - ContainerPath: "/claw/persona", - ReadOnly: false, - }) - } - - env := map[string]string{ - "CLAW_MANAGED": "true", - shared.PortableMemoryEnv: shared.PortableMemoryDir, - "HOME": "/nullclaw-data", - } - if rc.PersonaHostPath != "" { - env["CLAW_PERSONA_DIR"] = "/claw/persona" - } - - return &driver.MaterializeResult{ - Mounts: mounts, - Tmpfs: []string{"/tmp"}, - ReadOnly: true, - Restart: "on-failure", - SkillDir: "/claw/skills", - SkillLayout: "", - Healthcheck: &driver.Healthcheck{ - Test: []string{"CMD-SHELL", "curl -fsS http://localhost:3000/health >/dev/null || exit 1"}, - Interval: "30s", - Timeout: "10s", - Retries: 3, - }, - Environment: env, - }, nil -} - -func (d *Driver) PostApply(rc *driver.ResolvedClaw, opts driver.PostApplyOpts) error { - if opts.ContainerID == "" { - return fmt.Errorf("nullclaw driver: post-apply check failed: no container ID") - } - - cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) - if err != nil { - return fmt.Errorf("nullclaw driver: post-apply failed to create docker client: %w", err) - } - defer cli.Close() - - inspectCtx, cancelInspect := context.WithTimeout(context.Background(), 15*time.Second) - defer cancelInspect() - info, err := cli.ContainerInspect(inspectCtx, opts.ContainerID) - if err != nil { - return fmt.Errorf("nullclaw driver: post-apply container inspect failed: %w", err) - } - if info.State == nil || !info.State.Running { - status := "unknown" - if info.State != nil && info.State.Status != "" { - status = info.State.Status - } - return fmt.Errorf("nullclaw driver: post-apply check failed: container is not running (status: %s)", status) - } - - nativeInvocations := make([]driver.Invocation, 0, len(rc.Invocations)) - for _, inv := range rc.Invocations { - if inv.Origin == driver.OriginPod { - continue - } - nativeInvocations = append(nativeInvocations, inv) - } - if len(nativeInvocations) == 0 { - return nil - } - - existing, err := listExistingCronJobs(cli, opts.ContainerID) - if err != nil { - return fmt.Errorf("nullclaw driver: post-apply failed to list cron jobs: %w", err) - } - - for _, inv := range nativeInvocations { - if strings.TrimSpace(inv.Name) != "" { - fmt.Printf("[claw] warning: nullclaw driver: INVOKE name %q is not supported by nullclaw cron CLI; ignoring\n", inv.Name) - } - if strings.TrimSpace(inv.To) != "" { - fmt.Printf("[claw] warning: nullclaw driver: INVOKE to=%q is not supported by nullclaw cron CLI; ignoring\n", inv.To) - } - - command, err := buildInvocationCommand(inv.Message) - if err != nil { - return fmt.Errorf("nullclaw driver: post-apply invalid INVOKE message: %w", err) - } - key := cronEntryKey(inv.Schedule, command) - if _, exists := existing[key]; exists { - fmt.Printf("[claw] nullclaw: cron already exists (schedule: %s)\n", inv.Schedule) - continue - } - - args := buildCronAddArgs(inv.Schedule, command) - execCtx, cancelExec := context.WithTimeout(context.Background(), 20*time.Second) - stdout, stderr, exitCode, execErr := shared.ExecInContainer(execCtx, cli, opts.ContainerID, args) - cancelExec() - if execErr != nil { - return fmt.Errorf("nullclaw driver: post-apply failed to add cron job (schedule: %s): %w", inv.Schedule, execErr) - } - if exitCode != 0 { - detail := strings.TrimSpace(stderr) - if detail == "" { - detail = strings.TrimSpace(stdout) - } - if detail == "" { - detail = "no output" - } - return fmt.Errorf("nullclaw driver: post-apply cron add failed (schedule: %s, exit: %d): %s", inv.Schedule, exitCode, detail) - } - - existing[key] = struct{}{} - fmt.Printf("[claw] nullclaw: registered cron job (schedule: %s)\n", inv.Schedule) - } - - return nil -} - -func (d *Driver) HealthProbe(ref driver.ContainerRef) (*driver.Health, error) { - if ref.ContainerID == "" { - return &driver.Health{OK: false, Detail: "no container ID"}, nil - } - - cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) - if err != nil { - return nil, fmt.Errorf("nullclaw driver: health probe failed to create docker client: %w", err) - } - defer cli.Close() - - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) - defer cancel() - info, err := cli.ContainerInspect(ctx, ref.ContainerID) - if err != nil { - return &driver.Health{OK: false, Detail: fmt.Sprintf("container inspect failed: %v", err)}, nil - } - if info.State == nil || !info.State.Running { - status := "unknown" - if info.State != nil && info.State.Status != "" { - status = info.State.Status - } - return &driver.Health{OK: false, Detail: fmt.Sprintf("container is not running (status: %s)", status)}, nil - } - return &driver.Health{OK: true, Detail: "container running"}, nil -} - -func buildCronAddArgs(expression, command string) []string { - return []string{"nullclaw", "cron", "add", expression, command} -} - -func buildInvocationCommand(message string) (string, error) { - trimmed := strings.TrimSpace(message) - if trimmed == "" { - return "", fmt.Errorf("empty invocation message") - } - return "nullclaw agent -m " + shellQuote(trimmed), nil -} - -func shellQuote(s string) string { - if s == "" { - return "''" - } - return "'" + strings.ReplaceAll(s, "'", `'"'"'`) + "'" -} - -func cronEntryKey(expression, command string) string { - return strings.TrimSpace(expression) + "\x1f" + strings.TrimSpace(command) -} - -func parseCronListOutput(text string) map[string]struct{} { - out := make(map[string]struct{}) - lines := strings.Split(text, "\n") - for _, line := range lines { - trimmed := strings.TrimSpace(line) - if !strings.Contains(trimmed, " | ") || !strings.Contains(trimmed, "cmd:") { - continue - } - parts := strings.Split(trimmed, "|") - if len(parts) < 2 { - continue - } - expr := strings.TrimSpace(parts[1]) - cmdIdx := strings.LastIndex(trimmed, "cmd:") - if cmdIdx < 0 { - continue - } - cmd := strings.TrimSpace(trimmed[cmdIdx+len("cmd:"):]) - if expr == "" || cmd == "" { - continue - } - out[cronEntryKey(expr, cmd)] = struct{}{} - } - return out -} - -func listExistingCronJobs(cli *client.Client, containerID string) (map[string]struct{}, error) { - ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) - defer cancel() - stdout, stderr, exitCode, err := shared.ExecInContainer(ctx, cli, containerID, []string{"nullclaw", "cron", "list"}) - if err != nil { - return nil, err - } - if exitCode != 0 { - detail := strings.TrimSpace(stderr) - if detail == "" { - detail = strings.TrimSpace(stdout) - } - if detail == "" { - detail = "no output" - } - return nil, fmt.Errorf("cron list failed (exit: %d): %s", exitCode, detail) - } - return parseCronListOutput(stdout + "\n" + stderr), nil -} diff --git a/internal/driver/nullclaw/driver_test.go b/internal/driver/nullclaw/driver_test.go deleted file mode 100644 index 9f8992b1..00000000 --- a/internal/driver/nullclaw/driver_test.go +++ /dev/null @@ -1,332 +0,0 @@ -package nullclaw - -import ( - "os" - "path/filepath" - "strings" - "testing" - - "github.com/mostlydev/clawdapus/internal/driver" - "github.com/mostlydev/clawdapus/internal/driver/shared" -) - -func TestDriverRegistered(t *testing.T) { - d, err := driver.Lookup("nullclaw") - if err != nil { - t.Fatalf("nullclaw driver not registered: %v", err) - } - if d == nil { - t.Fatal("nullclaw driver is nil") - } -} - -func TestValidateRequiresAgentPath(t *testing.T) { - d := &Driver{} - rc := &driver.ResolvedClaw{ServiceName: "null"} - err := d.Validate(rc) - if err == nil { - t.Fatal("expected error for missing agent host path") - } - if !strings.Contains(err.Error(), "no agent host path") { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestValidateRequiresAgentFileExists(t *testing.T) { - d := &Driver{} - rc := &driver.ResolvedClaw{ - ServiceName: "null", - AgentHostPath: "/path/that/does/not/exist/AGENTS.md", - } - if err := d.Validate(rc); err == nil { - t.Fatal("expected error for missing agent file") - } -} - -func TestValidateAcceptsBasicConfig(t *testing.T) { - rc, _ := newTestRC(t) - d := &Driver{} - if err := d.Validate(rc); err != nil { - t.Fatalf("unexpected validation error: %v", err) - } -} - -func TestValidateDiscordHandleRequiresToken(t *testing.T) { - rc, _ := newTestRC(t) - rc.Handles = map[string]*driver.HandleInfo{ - "discord": {ID: "1"}, - } - rc.Environment = map[string]string{} - - d := &Driver{} - err := d.Validate(rc) - if err == nil { - t.Fatal("expected error for missing DISCORD_BOT_TOKEN") - } - if !strings.Contains(err.Error(), "DISCORD_BOT_TOKEN") { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestValidateTelegramHandleRequiresToken(t *testing.T) { - rc, _ := newTestRC(t) - rc.Handles = map[string]*driver.HandleInfo{ - "telegram": {ID: "1"}, - } - rc.Environment = map[string]string{} - - d := &Driver{} - err := d.Validate(rc) - if err == nil { - t.Fatal("expected error for missing TELEGRAM_BOT_TOKEN") - } - if !strings.Contains(err.Error(), "TELEGRAM_BOT_TOKEN") { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestValidateSlackHandleRequiresToken(t *testing.T) { - rc, _ := newTestRC(t) - rc.Handles = map[string]*driver.HandleInfo{ - "slack": {ID: "1"}, - } - rc.Environment = map[string]string{} - - d := &Driver{} - err := d.Validate(rc) - if err == nil { - t.Fatal("expected error for missing SLACK_BOT_TOKEN") - } - if !strings.Contains(err.Error(), "SLACK_BOT_TOKEN") { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestValidateRejectsInvalidConfigureCommand(t *testing.T) { - rc, _ := newTestRC(t) - rc.Configures = []string{"some random command"} - - d := &Driver{} - err := d.Validate(rc) - if err == nil { - t.Fatal("expected error for invalid CONFIGURE command") - } - if !strings.Contains(err.Error(), "CONFIGURE") { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestMaterializeWritesConfig(t *testing.T) { - rc, tmp := newTestRC(t) - - d := &Driver{} - _, err := d.Materialize(rc, driver.MaterializeOpts{RuntimeDir: tmp, PodName: "pod-a"}) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - configPath := filepath.Join(tmp, "nullclaw-home", "config.json") - if _, err := os.Stat(configPath); err != nil { - t.Fatalf("expected config at %s: %v", configPath, err) - } -} - -func TestMaterializeMounts(t *testing.T) { - rc, tmp := newTestRC(t) - - d := &Driver{} - result, err := d.Materialize(rc, driver.MaterializeOpts{RuntimeDir: tmp, PodName: "pod-a"}) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - var homeMount *driver.Mount - var imageHomeMount *driver.Mount - var agentMount *driver.Mount - var clawdapusMount *driver.Mount - var memoryMount *driver.Mount - for i := range result.Mounts { - m := &result.Mounts[i] - switch m.ContainerPath { - case "/root/.nullclaw": - homeMount = m - case "/nullclaw-data/.nullclaw": - imageHomeMount = m - case "/claw/AGENTS.md": - agentMount = m - case "/claw/CLAWDAPUS.md": - clawdapusMount = m - case shared.PortableMemoryDir: - memoryMount = m - } - } - - if homeMount == nil || homeMount.ReadOnly { - t.Fatal("expected writable /root/.nullclaw mount") - } - if imageHomeMount == nil || imageHomeMount.ReadOnly { - t.Fatal("expected writable /nullclaw-data/.nullclaw mount") - } - if agentMount == nil || !agentMount.ReadOnly { - t.Fatal("expected readonly /claw/AGENTS.md mount") - } - if clawdapusMount == nil || !clawdapusMount.ReadOnly { - t.Fatal("expected readonly /claw/CLAWDAPUS.md mount") - } - if memoryMount == nil || memoryMount.ReadOnly { - t.Fatal("expected writable portable memory mount") - } -} - -func TestMaterializeEnvironment(t *testing.T) { - rc, tmp := newTestRC(t) - - d := &Driver{} - result, err := d.Materialize(rc, driver.MaterializeOpts{RuntimeDir: tmp}) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if result.Environment["CLAW_MANAGED"] != "true" { - t.Fatalf("expected CLAW_MANAGED=true, got %q", result.Environment["CLAW_MANAGED"]) - } - if result.Environment[shared.PortableMemoryEnv] != shared.PortableMemoryDir { - t.Fatalf("expected %s=%s, got %q", shared.PortableMemoryEnv, shared.PortableMemoryDir, result.Environment[shared.PortableMemoryEnv]) - } -} - -func TestMaterializeHealthcheck(t *testing.T) { - rc, tmp := newTestRC(t) - - d := &Driver{} - result, err := d.Materialize(rc, driver.MaterializeOpts{RuntimeDir: tmp}) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if result.Healthcheck == nil || len(result.Healthcheck.Test) == 0 { - t.Fatal("expected healthcheck config") - } - joined := strings.Join(result.Healthcheck.Test, " ") - if !strings.Contains(joined, "/health") { - t.Fatalf("expected /health in healthcheck command, got %q", joined) - } -} - -func TestMaterializeSkillDir(t *testing.T) { - rc, tmp := newTestRC(t) - - d := &Driver{} - result, err := d.Materialize(rc, driver.MaterializeOpts{RuntimeDir: tmp}) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if result.SkillDir != "/claw/skills" { - t.Fatalf("unexpected skill dir: %q", result.SkillDir) - } - if result.SkillLayout != "" { - t.Fatalf("unexpected skill layout: %q", result.SkillLayout) - } -} - -func TestMaterializeCllamaConfig(t *testing.T) { - rc, tmp := newTestRC(t) - rc.Models = map[string]string{ - "primary": "anthropic/claude-sonnet-4", - } - rc.Cllama = []string{"passthrough"} - rc.CllamaToken = "token-a" - - d := &Driver{} - if _, err := d.Materialize(rc, driver.MaterializeOpts{RuntimeDir: tmp}); err != nil { - t.Fatalf("unexpected error: %v", err) - } - - configPath := filepath.Join(tmp, "nullclaw-home", "config.json") - data, err := os.ReadFile(configPath) - if err != nil { - t.Fatalf("read config: %v", err) - } - if !strings.Contains(string(data), "http://cllama-passthrough:8080/v1") { - t.Fatalf("expected cllama base_url in config: %s", string(data)) - } - if !strings.Contains(string(data), "\"api_key\": \"token-a\"") { - t.Fatalf("expected cllama api_key in config: %s", string(data)) - } -} - -func TestPostApplyRequiresContainerID(t *testing.T) { - rc, _ := newTestRC(t) - d := &Driver{} - err := d.PostApply(rc, driver.PostApplyOpts{}) - if err == nil { - t.Fatal("expected error for missing container ID") - } -} - -func TestBuildInvocationCommand(t *testing.T) { - cmd, err := buildInvocationCommand("hello 'world'") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - expected := "nullclaw agent -m 'hello '\"'\"'world'\"'\"''" - if cmd != expected { - t.Fatalf("unexpected command:\nwant: %s\ngot: %s", expected, cmd) - } -} - -func TestBuildInvocationCommandEmptyMessage(t *testing.T) { - _, err := buildInvocationCommand(" ") - if err == nil { - t.Fatal("expected error for empty message") - } -} - -func TestBuildCronAddArgs(t *testing.T) { - args := buildCronAddArgs("*/5 * * * *", "nullclaw agent -m 'hello'") - want := []string{"nullclaw", "cron", "add", "*/5 * * * *", "nullclaw agent -m 'hello'"} - if len(args) != len(want) { - t.Fatalf("unexpected args len: %#v", args) - } - for i := range want { - if args[i] != want[i] { - t.Fatalf("unexpected arg[%d]: want %q got %q", i, want[i], args[i]) - } - } -} - -func TestParseCronListOutput(t *testing.T) { - text := ` -info(cron): Scheduled jobs (2): -info(cron): - job-1 | */5 * * * * | next=1740700000 | status=n/a cmd: nullclaw agent -m 'hello' -info(cron): - job-2 | 0 9 * * 1-5 | next=1740701000 | status=n/a cmd: nullclaw agent -m 'market open' -` - parsed := parseCronListOutput(text) - if len(parsed) != 2 { - t.Fatalf("expected 2 parsed cron jobs, got %d", len(parsed)) - } - if _, ok := parsed[cronEntryKey("*/5 * * * *", "nullclaw agent -m 'hello'")]; !ok { - t.Fatalf("missing expected key for first cron job: %#v", parsed) - } - if _, ok := parsed[cronEntryKey("0 9 * * 1-5", "nullclaw agent -m 'market open'")]; !ok { - t.Fatalf("missing expected key for second cron job: %#v", parsed) - } -} - -func newTestRC(t *testing.T) (*driver.ResolvedClaw, string) { - t.Helper() - tmp := t.TempDir() - agentPath := filepath.Join(tmp, "AGENTS.md") - if err := os.WriteFile(agentPath, []byte("# Agent\n\nYou are nullclaw."), 0o644); err != nil { - t.Fatal(err) - } - - rc := &driver.ResolvedClaw{ - ServiceName: "null", - ClawType: "nullclaw", - AgentHostPath: agentPath, - Models: map[string]string{ - "primary": "anthropic/claude-sonnet-4", - }, - Environment: map[string]string{}, - } - return rc, tmp -} diff --git a/internal/driver/registry.go b/internal/driver/registry.go index 39b82254..ccbd3423 100644 --- a/internal/driver/registry.go +++ b/internal/driver/registry.go @@ -16,11 +16,34 @@ func Register(name string, d Driver) { drivers[name] = d } +const retiredRunnerMigrationTarget = "hermes" + +// retiredRunners records runner types deliberately removed by ADR-026. Keep +// this compatibility error for one release so old Clawfiles fail with an +// actionable migration instead of looking like typos. +var retiredRunners = map[string]struct{}{ + "nanoclaw": {}, + "microclaw": {}, + "nullclaw": {}, +} + +// RetirementError returns the canonical migration error for a retired runner. +// Other unknown names return nil and keep the generic unknown-driver path. +func RetirementError(name string) error { + if _, retired := retiredRunners[name]; !retired { + return nil + } + return fmt.Errorf("CLAW_TYPE %q was retired by ADR-026; migrate this Clawfile to CLAW_TYPE %q", name, retiredRunnerMigrationTarget) +} + func Lookup(name string) (Driver, error) { mu.RLock() defer mu.RUnlock() d, ok := drivers[name] if !ok { + if err := RetirementError(name); err != nil { + return nil, err + } return nil, fmt.Errorf("unknown CLAW_TYPE %q: no registered driver", name) } return d, nil diff --git a/internal/driver/retired_test.go b/internal/driver/retired_test.go new file mode 100644 index 00000000..6210a12a --- /dev/null +++ b/internal/driver/retired_test.go @@ -0,0 +1,52 @@ +package driver + +import ( + "strings" + "testing" +) + +// Retired runners must fail closed with guidance, not fall through to the +// generic "no registered driver" message. See ADR-026. +func TestLookupRejectsRetiredRunnersWithMigrationGuidance(t *testing.T) { + for _, name := range []string{"nanoclaw", "microclaw", "nullclaw"} { + t.Run(name, func(t *testing.T) { + d, err := Lookup(name) + if err == nil { + t.Fatalf("expected %s to be rejected, got driver %T", name, d) + } + msg := err.Error() + if !strings.Contains(msg, name) { + t.Errorf("error should name the retired runner, got %q", msg) + } + if !strings.Contains(msg, "retired") { + t.Errorf("error should say the runner was retired, got %q", msg) + } + if !strings.Contains(msg, "ADR-026") { + t.Errorf("error should point at the decision record, got %q", msg) + } + if !strings.Contains(msg, `CLAW_TYPE "hermes"`) { + t.Errorf("error should name Hermes as the migration target, got %q", msg) + } + }) + } +} + +func TestRetiredRunnersAreNotRegistered(t *testing.T) { + for _, name := range []string{"nanoclaw", "microclaw", "nullclaw"} { + if _, ok := Registered()[name]; ok { + t.Errorf("retired runner %s is still registered", name) + } + } +} + +// An unrelated unknown type keeps the generic message; only the runners we +// deliberately dropped get migration guidance. +func TestLookupKeepsGenericErrorForUnknownRunner(t *testing.T) { + _, err := Lookup("not-a-runner") + if err == nil { + t.Fatal("expected unknown runner to be rejected") + } + if strings.Contains(err.Error(), "retired") { + t.Errorf("unknown runner should not be reported as retired, got %q", err) + } +} diff --git a/internal/driver/shared/history.go b/internal/driver/shared/history.go index bd95941a..13af6359 100644 --- a/internal/driver/shared/history.go +++ b/internal/driver/shared/history.go @@ -53,6 +53,8 @@ func legacyPortableHistoryDirs(root string) []portableHistoryDirMapping { {srcPath: filepath.Join(root, "nanobot-home", "sessions"), dstRel: filepath.Join("nanobot", "sessions")}, {srcPath: filepath.Join(root, "picoclaw-home", "workspace", "sessions"), dstRel: filepath.Join("picoclaw", "sessions")}, {srcPath: filepath.Join(root, "picoclaw-home", "sessions"), dstRel: filepath.Join("picoclaw", "sessions")}, + // Retired runner paths remain importable so an operator can migrate + // without losing the portable history those drivers already wrote. {srcPath: filepath.Join(root, "data", "working_dir", "sessions"), dstRel: filepath.Join("microclaw", "sessions")}, {srcPath: filepath.Join(root, "data", "working_dir", "history"), dstRel: filepath.Join("microclaw", "history")}, {srcPath: filepath.Join(root, "nullclaw-home", "sessions"), dstRel: filepath.Join("nullclaw", "sessions")}, diff --git a/internal/pod/parser_handles_test.go b/internal/pod/parser_handles_test.go index 4023f627..ab6a5462 100644 --- a/internal/pod/parser_handles_test.go +++ b/internal/pod/parser_handles_test.go @@ -186,10 +186,10 @@ services: handles: discord: "999000111" - nc-roll: - image: nullclaw:latest + nb-roll: + image: nanobot:latest x-claw: - agent: ./NC.md + agent: ./NB.md handles: discord: "999000111" ` diff --git a/scripts/runner-adoption-snapshot/main.go b/scripts/runner-adoption-snapshot/main.go new file mode 100644 index 00000000..c95ca2a8 --- /dev/null +++ b/scripts/runner-adoption-snapshot/main.go @@ -0,0 +1,578 @@ +// Command runner-adoption-snapshot collects the adoption evidence behind +// ADR-026 and writes a dated JSON artifact under docs/evidence/. +// +// The primary metric is new forks bucketed into fixed-width windows. Unlike +// stars, total downloads, and fork counts, a bucketed fork rate is not +// cumulative, so it can fall — which is what makes it usable as an adoption +// time series. See ADR-026 for why the two obvious alternatives were rejected: +// stargazer timestamps are unavailable (the endpoint 404s for external repos) +// and per-release downloads-per-day is confounded by post-publication decay. +// +// Classification judgments deliberately live in ADR-026, not here, so a future +// audit can refresh the data without inheriting today's judgment. +// +// Usage: +// +// go run ./scripts/runner-adoption-snapshot -out docs/evidence +// +// Requires an authenticated `gh` CLI on PATH. Fork-window boundaries are found +// with binary search over newest-first pages and cached across windows, so even +// large repositories take tens of requests rather than hundreds. +package main + +import ( + "encoding/json" + "flag" + "fmt" + "io" + "net/http" + "net/url" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "sync" + "time" +) + +// runners maps each in-tree driver to its canonical upstream repository. +var runners = []runner{ + {Driver: "openclaw", Repo: "openclaw/openclaw"}, + {Driver: "hermes", Repo: "NousResearch/hermes-agent"}, + // PyPI publishes no project URLs for nanobot-ai; the repository below was + // confirmed by matching its pyproject.toml name and version to the package. + {Driver: "nanobot", Repo: "HKUDS/nanobot", PyPI: "nanobot-ai"}, + {Driver: "nanoclaw", Repo: "nanocoai/nanoclaw", Note: "canonical repo redirected from qwibitai/nanoclaw"}, + {Driver: "picoclaw", Repo: "sipeed/picoclaw", DockerHub: "sipeed/picoclaw"}, + {Driver: "microclaw", Repo: "microclaw/microclaw"}, + {Driver: "nullclaw", Repo: "nullclaw/nullclaw"}, +} + +type runner struct { + Driver string `json:"driver"` + Repo string `json:"repo,omitempty"` + PyPI string `json:"pypi,omitempty"` + DockerHub string `json:"docker_hub,omitempty"` + Note string `json:"note,omitempty"` +} + +type snapshot struct { + CapturedAt time.Time `json:"captured_at"` + WindowDays int `json:"window_days"` + WindowCount int `json:"window_count"` + Limitations []string `json:"limitations"` + Runners []runnerResult `json:"runners"` +} + +type runnerResult struct { + runner + Stars int `json:"stars,omitempty"` + Forks int `json:"forks,omitempty"` + Archived bool `json:"archived,omitempty"` + PushedAt string `json:"pushed_at,omitempty"` + LatestRelease string `json:"latest_release,omitempty"` + ForkWindows []int `json:"fork_windows,omitempty"` + CommitWindows []int `json:"commit_windows_90d,omitempty"` + ReleaseCount int `json:"release_count,omitempty"` + AssetDownloads int `json:"release_asset_downloads,omitempty"` + DockerHubPulls int `json:"docker_hub_pulls,omitempty"` + PyPIMonthly []bucket `json:"pypi_monthly,omitempty"` + Errors []string `json:"errors,omitempty"` +} + +type bucket struct { + Period string `json:"period"` + Count int `json:"count"` +} + +func main() { + out := flag.String("out", "docs/evidence", "directory for the dated JSON artifact") + windowDays := flag.Int("window-days", 30, "width of each fork-count window in days") + windows := flag.Int("windows", 4, "number of consecutive windows to collect, newest first") + flag.Parse() + + if err := run(*out, *windowDays, *windows); err != nil { + fmt.Fprintln(os.Stderr, "runner-adoption-snapshot:", err) + os.Exit(1) + } +} + +func run(outDir string, windowDays, windowCount int) error { + now := time.Now().UTC() + snap := snapshot{ + CapturedAt: now, + WindowDays: windowDays, + WindowCount: windowCount, + Limitations: []string{ + "GitHub /stargazers with the star+json media type returns 404 for external repositories from this environment; it is not rate limiting and not a token scope issue. Stargazer time series are unavailable.", + "Per-release downloads normalized by release age is confounded by post-publication download decay and must not be read as a growth trend.", + "ghcr.io publishes no public pull counts, so image pulls are only comparable for docker.io-hosted runners.", + "No metric here measures Clawdapus-side usage. All of it is upstream popularity.", + }, + } + + results := make([]runnerResult, len(runners)) + var wg sync.WaitGroup + for index, r := range runners { + wg.Add(1) + go func() { + defer wg.Done() + result := runnerResult{runner: r} + if r.Repo != "" { + if err := collectRepo(&result, now, windowDays, windowCount); err != nil { + result.Errors = append(result.Errors, err.Error()) + } + } + if r.PyPI != "" { + monthly, err := collectPyPI(r.PyPI) + if err != nil { + result.Errors = append(result.Errors, err.Error()) + } + result.PyPIMonthly = monthly + } + if r.DockerHub != "" { + pulls, err := collectDockerHubPulls(r.DockerHub) + if err != nil { + result.Errors = append(result.Errors, err.Error()) + } else { + result.DockerHubPulls = pulls + } + } + results[index] = result + }() + } + wg.Wait() + snap.Runners = results + + if err := os.MkdirAll(outDir, 0o755); err != nil { + return fmt.Errorf("create %s: %w", outDir, err) + } + path := filepath.Join(outDir, now.Format("2006-01-02")+"-runner-adoption.json") + body, err := json.MarshalIndent(snap, "", " ") + if err != nil { + return err + } + if err := os.WriteFile(path, append(body, '\n'), 0o644); err != nil { + return fmt.Errorf("write %s: %w", path, err) + } + + printTable(snap) + fmt.Fprintf(os.Stderr, "\nwrote %s\n", path) + return nil +} + +func collectRepo(result *runnerResult, now time.Time, windowDays, windowCount int) error { + meta, err := ghJSON("repos/" + result.Repo) + if err != nil { + return fmt.Errorf("repo metadata: %w", err) + } + result.Stars = intField(meta, "stargazers_count") + result.Forks = intField(meta, "forks_count") + result.Archived = boolField(meta, "archived") + result.PushedAt = stringField(meta, "pushed_at") + + latest, releaseCount, downloads, err := collectReleases(result.Repo) + if err != nil { + result.Errors = append(result.Errors, "releases: "+err.Error()) + } else { + result.LatestRelease = latest + result.ReleaseCount = releaseCount + result.AssetDownloads = downloads + } + + counts, err := forkWindows(result.Repo, result.Forks, now, windowDays, windowCount) + if err != nil { + return fmt.Errorf("fork windows: %w", err) + } + result.ForkWindows = counts + + current, err := commitCount(result.Repo, now.AddDate(0, 0, -90), now) + if err != nil { + result.Errors = append(result.Errors, "current commit window: "+err.Error()) + } else { + previous, err := commitCount(result.Repo, now.AddDate(0, 0, -180), now.AddDate(0, 0, -90)) + if err != nil { + result.Errors = append(result.Errors, "previous commit window: "+err.Error()) + } else { + result.CommitWindows = []int{current, previous} + } + } + return nil +} + +type forkPageLoader func(page int) ([]map[string]any, error) + +func forkWindows(repo string, totalForks int, now time.Time, windowDays, windowCount int) ([]int, error) { + return forkWindowsWithLoader(totalForks, now, windowDays, windowCount, func(page int) ([]map[string]any, error) { + path := fmt.Sprintf("repos/%s/forks?sort=newest&per_page=100&page=%d", repo, page) + return ghArray(path) + }) +} + +// forkWindowsWithLoader finds each time boundary with binary search over the +// newest-first fork pages. Complete pages between boundaries do not need to be +// downloaded; only boundary pages are inspected and all fetched pages are +// cached across windows. +func forkWindowsWithLoader(totalForks int, now time.Time, windowDays, windowCount int, load forkPageLoader) ([]int, error) { + if windowDays <= 0 || windowCount <= 0 || totalForks < 0 { + return nil, fmt.Errorf("invalid fork-window configuration") + } + counts := make([]int, windowCount) + if totalForks == 0 { + return counts, nil + } + + pages := (totalForks + 99) / 100 + cache := make(map[int][]map[string]any) + page := func(number int) ([]map[string]any, error) { + if items, ok := cache[number]; ok { + return items, nil + } + items, err := load(number) + if err != nil { + return nil, err + } + cache[number] = items + return items, nil + } + + countAfter := func(cutoff time.Time) (int, error) { + lo, hi := 1, pages + boundary := pages + 1 + for lo <= hi { + mid := lo + (hi-lo)/2 + items, err := page(mid) + if err != nil { + return 0, err + } + if len(items) == 0 { + boundary = mid + hi = mid - 1 + continue + } + last, err := time.Parse(time.RFC3339, stringField(items[len(items)-1], "created_at")) + if err != nil { + return 0, fmt.Errorf("page %d last fork timestamp: %w", mid, err) + } + if last.After(cutoff) { + lo = mid + 1 + } else { + boundary = mid + hi = mid - 1 + } + } + + if boundary == pages+1 { + lastPage, err := page(pages) + if err != nil { + return 0, err + } + return (pages-1)*100 + len(lastPage), nil + } + + items, err := page(boundary) + if err != nil { + return 0, err + } + prefix := 0 + for _, item := range items { + created, err := time.Parse(time.RFC3339, stringField(item, "created_at")) + if err != nil { + return 0, fmt.Errorf("page %d fork timestamp: %w", boundary, err) + } + if !created.After(cutoff) { + break + } + prefix++ + } + return (boundary-1)*100 + prefix, nil + } + + previous, err := countAfter(now) + if err != nil { + return nil, err + } + for index := 0; index < windowCount; index++ { + cutoff := now.AddDate(0, 0, -windowDays*(index+1)) + cumulative, err := countAfter(cutoff) + if err != nil { + return nil, err + } + counts[index] = cumulative - previous + previous = cumulative + } + return counts, nil +} + +func collectReleases(repo string) (string, int, int, error) { + latest := "" + count := 0 + downloads := 0 + for page := 1; ; page++ { + releases, err := ghArray(fmt.Sprintf("repos/%s/releases?per_page=100&page=%d", repo, page)) + if err != nil { + return "", 0, 0, err + } + if page == 1 && len(releases) > 0 { + latest = stringField(releases[0], "published_at") + } + for _, release := range releases { + count++ + assets, _ := release["assets"].([]any) + for _, raw := range assets { + asset, _ := raw.(map[string]any) + downloads += intField(asset, "download_count") + } + } + if len(releases) < 100 { + return latest, count, downloads, nil + } + } +} + +func commitCount(repo string, since, until time.Time) (int, error) { + query := url.Values{} + query.Set("since", since.UTC().Format(time.RFC3339)) + query.Set("until", until.UTC().Format(time.RFC3339)) + query.Set("per_page", "1") + body, headers, err := ghResponse("repos/" + repo + "/commits?" + query.Encode()) + if err != nil { + return 0, err + } + var commits []map[string]any + if err := json.Unmarshal(body, &commits); err != nil { + return 0, err + } + if len(commits) == 0 { + return 0, nil + } + if last := lastPage(headers.Get("Link")); last > 0 { + return last, nil + } + return len(commits), nil +} + +func lastPage(link string) int { + for _, part := range strings.Split(link, ",") { + if !strings.Contains(part, `rel="last"`) { + continue + } + start := strings.Index(part, "<") + end := strings.Index(part, ">") + if start < 0 || end <= start { + return 0 + } + u, err := url.Parse(part[start+1 : end]) + if err != nil { + return 0 + } + var page int + _, _ = fmt.Sscanf(u.Query().Get("page"), "%d", &page) + return page + } + return 0 +} + +func collectDockerHubPulls(repo string) (int, error) { + body, err := httpGet("https://hub.docker.com/v2/repositories/" + repo + "/") + if err != nil { + return 0, fmt.Errorf("docker hub %s: %w", repo, err) + } + var payload struct { + PullCount int `json:"pull_count"` + } + if err := json.Unmarshal(body, &payload); err != nil { + return 0, fmt.Errorf("docker hub %s: %w", repo, err) + } + return payload.PullCount, nil +} + +// collectPyPI returns monthly download totals excluding mirrors. This is the +// only genuine, non-cumulative adoption time series available for any runner, +// and it exists only for runners distributed as a Python package. +func collectPyPI(pkg string) ([]bucket, error) { + body, err := httpGet("https://pypistats.org/api/packages/" + pkg + "/overall") + if err != nil { + return nil, fmt.Errorf("pypistats %s: %w", pkg, err) + } + var payload struct { + Data []struct { + Category string `json:"category"` + Date string `json:"date"` + Downloads int `json:"downloads"` + } `json:"data"` + } + if err := json.Unmarshal(body, &payload); err != nil { + return nil, fmt.Errorf("pypistats %s: %w", pkg, err) + } + totals := map[string]int{} + for _, row := range payload.Data { + if row.Category != "without_mirrors" || len(row.Date) < 7 { + continue + } + totals[row.Date[:7]] += row.Downloads + } + months := make([]string, 0, len(totals)) + for month := range totals { + months = append(months, month) + } + sort.Strings(months) + out := make([]bucket, 0, len(months)) + for _, month := range months { + out = append(out, bucket{Period: month, Count: totals[month]}) + } + return out, nil +} + +func httpGet(url string) ([]byte, error) { + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Get(url) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("status %s", resp.Status) + } + return io.ReadAll(resp.Body) +} + +// windowIndex places a fork creation time into a window counted back from now, +// where window 0 is the most recent. Times in the future, or older than the +// oldest window, are not counted. +func windowIndex(now, created time.Time, windowDays, windowCount int) (int, bool) { + if windowDays <= 0 || windowCount <= 0 || created.After(now) { + return 0, false + } + index := int(now.Sub(created).Hours() / 24 / float64(windowDays)) + if index < 0 || index >= windowCount { + return 0, false + } + return index, true +} + +func printTable(snap snapshot) { + fmt.Printf("Runner adoption snapshot %s (%d x %dd windows, newest first)\n\n", + snap.CapturedAt.Format("2006-01-02"), snap.WindowCount, snap.WindowDays) + fmt.Printf("%-11s %10s %8s %s\n", "runner", "stars", "ret", "fork windows") + sorted := append([]runnerResult(nil), snap.Runners...) + sort.Slice(sorted, func(i, j int) bool { return sorted[i].Stars > sorted[j].Stars }) + for _, r := range sorted { + retention := "-" + if n := len(r.ForkWindows); n > 1 && r.ForkWindows[n-1] > 0 { + retention = fmt.Sprintf("%.2f", float64(r.ForkWindows[0])/float64(r.ForkWindows[n-1])) + } + cells := make([]string, 0, len(r.ForkWindows)) + for _, c := range r.ForkWindows { + cells = append(cells, fmt.Sprint(c)) + } + fmt.Printf("%-11s %10d %8s %s\n", r.Driver, r.Stars, retention, strings.Join(cells, " / ")) + for _, e := range r.Errors { + fmt.Printf("%-11s %s\n", "", "error: "+e) + } + } +} + +func ghJSON(path string) (map[string]any, error) { + body, err := gh(path) + if err != nil { + return nil, err + } + var out map[string]any + if err := json.Unmarshal(body, &out); err != nil { + return nil, err + } + return out, nil +} + +func ghArray(path string) ([]map[string]any, error) { + body, err := gh(path) + if err != nil { + return nil, err + } + var out []map[string]any + if err := json.Unmarshal(body, &out); err != nil { + return nil, err + } + return out, nil +} + +// gh calls the GitHub REST API directly, borrowing the `gh` CLI's credentials +// once rather than spawning a subprocess per request. Even with boundary search, +// process startup would otherwise dominate the collector's runtime. +func gh(path string) ([]byte, error) { + body, _, err := ghResponse(path) + return body, err +} + +func ghResponse(path string) ([]byte, http.Header, error) { + token, err := githubToken() + if err != nil { + return nil, nil, err + } + req, err := http.NewRequest(http.MethodGet, "https://api.github.com/"+strings.TrimPrefix(path, "/"), nil) + if err != nil { + return nil, nil, err + } + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + resp, err := githubClient.Do(req) + if err != nil { + return nil, nil, fmt.Errorf("GET %s: %w", path, err) + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, nil, fmt.Errorf("GET %s: %w", path, err) + } + if resp.StatusCode != http.StatusOK { + return nil, resp.Header, fmt.Errorf("GET %s: status %s: %s", path, resp.Status, strings.TrimSpace(string(body))) + } + return body, resp.Header, nil +} + +var ( + githubClient = &http.Client{Timeout: 30 * time.Second} + githubTokenOnce sync.Once + githubTokenVal string + githubTokenErr error +) + +func githubToken() (string, error) { + githubTokenOnce.Do(func() { + if env := strings.TrimSpace(os.Getenv("GITHUB_TOKEN")); env != "" { + githubTokenVal = env + return + } + out, err := exec.Command("gh", "auth", "token").Output() + if err != nil { + githubTokenErr = fmt.Errorf("no GITHUB_TOKEN set and `gh auth token` failed: %w", err) + return + } + githubTokenVal = strings.TrimSpace(string(out)) + }) + return githubTokenVal, githubTokenErr +} + +func stringField(m map[string]any, key string) string { + if v, ok := m[key].(string); ok { + return v + } + return "" +} + +func intField(m map[string]any, key string) int { + if v, ok := m[key].(float64); ok { + return int(v) + } + return 0 +} + +func boolField(m map[string]any, key string) bool { + v, _ := m[key].(bool) + return v +} diff --git a/scripts/runner-adoption-snapshot/main_test.go b/scripts/runner-adoption-snapshot/main_test.go new file mode 100644 index 00000000..e0a218a5 --- /dev/null +++ b/scripts/runner-adoption-snapshot/main_test.go @@ -0,0 +1,125 @@ +package main + +import ( + "fmt" + "reflect" + "testing" + "time" +) + +func TestWindowIndexBucketsByAge(t *testing.T) { + now := time.Date(2026, time.August, 3, 0, 0, 0, 0, time.UTC) + + cases := []struct { + name string + created time.Time + want int + ok bool + }{ + {"just now lands in the newest window", now, 0, true}, + {"one day old is still the newest window", now.AddDate(0, 0, -1), 0, true}, + {"boundary belongs to the older window", now.AddDate(0, 0, -30), 1, true}, + {"one day inside the second window", now.AddDate(0, 0, -31), 1, true}, + {"last day of the oldest window counts", now.AddDate(0, 0, -119), 3, true}, + {"past the oldest window is dropped", now.AddDate(0, 0, -120), 0, false}, + {"future timestamps are dropped", now.AddDate(0, 0, 1), 0, false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, ok := windowIndex(now, tc.created, 30, 4) + if ok != tc.ok { + t.Fatalf("ok = %v, want %v", ok, tc.ok) + } + if ok && got != tc.want { + t.Fatalf("index = %d, want %d", got, tc.want) + } + }) + } +} + +func TestWindowIndexRejectsNonPositiveConfiguration(t *testing.T) { + now := time.Date(2026, time.August, 3, 0, 0, 0, 0, time.UTC) + if _, ok := windowIndex(now, now, 0, 4); ok { + t.Fatal("expected zero window width to be rejected") + } + if _, ok := windowIndex(now, now, 30, 0); ok { + t.Fatal("expected zero window count to be rejected") + } +} + +func TestForkWindowsBinarySearchesBoundariesAndCachesPages(t *testing.T) { + now := time.Date(2026, time.August, 3, 12, 0, 0, 0, time.UTC) + items := make([]map[string]any, 1000) + for index := range items { + // One future timestamp and one timestamp exactly at now exercise the + // upper bound. Six-hour spacing gives exactly 120 forks per 30 days. + created := now.Add(6*time.Hour - time.Duration(index)*6*time.Hour) + items[index] = map[string]any{"created_at": created.Format(time.RFC3339)} + } + + loads := 0 + got, err := forkWindowsWithLoader(len(items), now, 30, 4, func(page int) ([]map[string]any, error) { + loads++ + start := (page - 1) * 100 + if start >= len(items) { + return nil, nil + } + end := start + 100 + if end > len(items) { + end = len(items) + } + return items[start:end], nil + }) + if err != nil { + t.Fatal(err) + } + if want := []int{120, 120, 120, 120}; !reflect.DeepEqual(got, want) { + t.Fatalf("fork windows = %v, want %v", got, want) + } + if loads > 10 { + t.Fatalf("loaded %d pages; boundary search should cache and stay logarithmic", loads) + } +} + +func TestForkWindowsCountsPartialLastPageWhenAllForksAreRecent(t *testing.T) { + now := time.Date(2026, time.August, 3, 12, 0, 0, 0, time.UTC) + items := make([]map[string]any, 205) + for index := range items { + items[index] = map[string]any{"created_at": now.Add(-time.Duration(index) * time.Hour).Format(time.RFC3339)} + } + + got, err := forkWindowsWithLoader(len(items), now, 30, 1, func(page int) ([]map[string]any, error) { + start := (page - 1) * 100 + if start >= len(items) { + return nil, nil + } + end := start + 100 + if end > len(items) { + end = len(items) + } + return items[start:end], nil + }) + if err != nil { + t.Fatal(err) + } + if want := []int{205}; !reflect.DeepEqual(got, want) { + t.Fatalf("fork windows = %v, want %v", got, want) + } +} + +func TestForkWindowsRejectsMalformedBoundaryTimestamp(t *testing.T) { + _, err := forkWindowsWithLoader(1, time.Now().UTC(), 30, 1, func(int) ([]map[string]any, error) { + return []map[string]any{{"created_at": "not-a-time"}}, nil + }) + if err == nil { + t.Fatal("expected malformed timestamp to fail the snapshot") + } +} + +func TestLastPageParsesGitHubLinkHeader(t *testing.T) { + link := fmt.Sprintf(`; rel="last", ; rel="next"`, 34897) + if got := lastPage(link); got != 34897 { + t.Fatalf("lastPage = %d, want 34897", got) + } +} diff --git a/site/changelog.md b/site/changelog.md index 4573ffdf..6bb6b805 100644 --- a/site/changelog.md +++ b/site/changelog.md @@ -29,6 +29,7 @@ outline: deep ## Unreleased +- **NanoClaw, MicroClaw, and NullClaw drivers retired** ([ADR-026](https://github.com/mostlydev/clawdapus/blob/master/docs/decisions/026-runner-adoption-and-retirement.md), [#353](https://github.com/mostlydev/clawdapus/issues/353)) -- a reproducible upstream-adoption audit (`scripts/runner-adoption-snapshot`, dated evidence under `docs/evidence/`) exposed three ambiguous maintenance cases, and the maintainer chose to stop carrying them. Retired `CLAW_TYPE`s now fail `claw up` with a migration error pointing to Hermes instead of a generic unknown-driver message. The retained set is `openclaw`, `hermes`, `nanobot`, and `picoclaw`; the rollcall conformance pod and trading-desk example were revised to keep full coverage across all four. - **Slow scheduled wakes no longer block unrelated targets** -- claw-api dispatches due targets concurrently while serializing wakes per runner, coalesces overlapping slots without regressing next-fire state, rejects duplicate manual fires with a conflict, and drains active scheduler dispatches cleanly on shutdown. Coalesced slots are now recorded in schedule state (`suppressed_slots`, `last_suppressed_at`) and surfaced on the clawdash schedule card, so a schedule whose wake outruns its own cadence no longer reads as perfectly healthy. Closes [#347](https://github.com/mostlydev/clawdapus/issues/347). - **Manual schedule fires honor runner wake budgets** -- `claw api schedule fire` now gives the in-container request 2 minutes 5 seconds and its outer compose transport 2 minutes 10 seconds, enough to return the final result of the longest supported runner wake. Other schedule operations retain their short defaults, and an explicit `--exec-timeout` still overrides the outer transport. Closes [#348](https://github.com/mostlydev/clawdapus/issues/348). - **Hermes scheduled wakes get a runner-sized timeout** -- claw-api now gives `hermes-exec` the same two-minute wake budget as other runner-native cron paths, preventing a successfully triggered Hermes job from being recorded as `exec timed out` solely because the runner took longer than the generic 30-second transport budget. Closes [#345](https://github.com/mostlydev/clawdapus/issues/345). diff --git a/site/guide/anatomy.md b/site/guide/anatomy.md index 03c3b7e6..4eee42e1 100644 --- a/site/guide/anatomy.md +++ b/site/guide/anatomy.md @@ -6,7 +6,7 @@ A running Claw splits cognition into two independent layers: **internal executio block-beta columns 1 contract["Behavioral Contract\nread-only bind mount\nAGENTS.md — purpose, on the host\nSurvives full container compromise"] - runner["Runner\nOpenClaw · Hermes · NanoClaw · custom"] + runner["Runner\nOpenClaw · Hermes · Nanobot · PicoClaw"] persona["Persona\nStyle · knowledge · workspace state"] proxy["cllama — governance proxy\nRoutes model calls, injects context, mediates tools\nRunner never sees provider keys"] @@ -26,11 +26,8 @@ Supported runners today: |--------|---------| | `openclaw` | [OpenClaw](https://openclaw.ai) | | `hermes` | [Hermes](https://github.com/NousResearch/hermes-agent) | -| `nanoclaw` | NanoClaw / Claude Code-compatible orchestrator | | `nanobot` | [Nanobot](https://github.com/HKUDS/nanobot) | | `picoclaw` | [PicoClaw](https://github.com/sipeed/picoclaw) | -| `nullclaw` | [NullClaw](https://github.com/nullclaw/nullclaw) | -| `microclaw` | [MicroClaw](https://github.com/microclaw/microclaw) | Pick a runner based on what you need. Swap it without touching the persona or the contract. A 400-line Python script with brokerage API access needs the exact same purpose contract and governance proxy as a massive agent OS. diff --git a/site/guide/architecture.md b/site/guide/architecture.md index 59c3ab7f..69500489 100644 --- a/site/guide/architecture.md +++ b/site/guide/architecture.md @@ -123,7 +123,7 @@ operator policy, not built-in behavior — see [Roadmap](#what-ships-today-vs-ro | Capability | State | |------------|-------| | Four-verb operator surface; `claw up` compiler | Shipped | -| 7 runner drivers (OpenClaw, Hermes, NanoClaw, Nanobot, PicoClaw, MicroClaw, NullClaw) | Shipped → [Drivers](/guide/drivers) | +| 4 runner drivers (OpenClaw, Hermes, Nanobot, PicoClaw) | Shipped → [Drivers](/guide/drivers) | | cllama: credential starvation, compiled tool mediation, normalized telemetry, `claw audit` | Shipped | | Memory plane: session history, portable memory, ambient recall | Shipped | | Context feeds, channel-awareness + channel-memory retrieval | Shipped | diff --git a/site/guide/clawfile.md b/site/guide/clawfile.md index 8a2fe4da..f7fca0ed 100644 --- a/site/guide/clawfile.md +++ b/site/guide/clawfile.md @@ -43,7 +43,7 @@ This declares an OpenClaw agent that: | Directive | Purpose | |-----------|---------| -| `CLAW_TYPE` | Selects the runtime driver (`openclaw`, `hermes`, `nanobot`, `picoclaw`, `nanoclaw`, `microclaw`, `nullclaw`) | +| `CLAW_TYPE` | Selects the runtime driver (`openclaw`, `hermes`, `nanobot`, `picoclaw`) | | `AGENT` | Names the behavioral contract file to be bind-mounted read-only | | `PERSONA` | Imports a persona workspace -- local path or OCI artifact ref | | `MODEL` | Binds named model slots (e.g., `primary`, `fallback`) to providers | @@ -160,14 +160,14 @@ For example, `CLAW_TYPE openclaw` becomes a label on the image. `MODEL primary o The `CLAW_TYPE` directive selects which runtime driver handles the agent. All drivers support `MODEL`, `AGENT`, `CLLAMA`, and `CONFIGURE`. Platform support varies: -| Capability | `openclaw` | `hermes` | `nanoclaw` | `nanobot` | `picoclaw` | `nullclaw` | `microclaw` | -|---|:---:|:---:|:---:|:---:|:---:|:---:|:---:| -| HANDLE: Discord | yes | yes | -- | yes | yes | yes | yes | -| HANDLE: Telegram | yes | yes | -- | yes | yes | yes | yes | -| HANDLE: Slack | yes | yes | -- | yes | yes | yes | yes | -| INVOKE (cron) | yes | yes | -- | yes | yes | yes | -- | -| Structured health | yes | yes | yes | yes | yes | yes | yes | -| Read-only rootfs | yes | yes | no | yes | yes | yes | no | +| Capability | `openclaw` | `hermes` | `nanobot` | `picoclaw` | +|---|:---:|:---:|:---:|:---:| +| HANDLE: Discord | yes | yes | yes | yes | +| HANDLE: Telegram | yes | yes | yes | yes | +| HANDLE: Slack | yes | yes | yes | yes | +| INVOKE (cron) | yes | yes | yes | yes | +| Structured health | yes | yes | yes | yes | +| Read-only rootfs | yes | yes | yes | yes | ## MODEL Slots @@ -192,11 +192,11 @@ Clawfile `MODEL` labels are the base slot map, but `claw-pod.yml` can retarget s ```dockerfile HANDLE discord -# Pin to one guild -CONFIGURE nullclaw config set channels.discord.accounts.main.guild_id "123456789012345678" +# Enable a channel beyond HANDLE defaults +CONFIGURE picoclaw config set channels.discord.enabled true -# Require mention in group chats -CONFIGURE nullclaw config set channels.discord.accounts.main.require_mention true +# Override the gateway port +CONFIGURE picoclaw config set gateway.port 19000 ``` ::: tip Defaults First, Then Override @@ -234,11 +234,9 @@ PRIVILEGE worker root PRIVILEGE runtime claw-user ``` -The syntax is `PRIVILEGE `. The compiler emits labels such as `claw.privilege.runtime=claw-user`; enforcement depends on the selected driver. NanoClaw currently requires an explicit Docker-socket grant because it spawns nested agent containers: +The syntax is `PRIVILEGE `. The compiler emits labels such as `claw.privilege.runtime=claw-user`; enforcement depends on the selected driver. A driver that spawns nested agent containers would require an explicit Docker-socket grant: ```dockerfile -CLAW_TYPE nanoclaw -AGENT AGENTS.md PRIVILEGE docker-socket true ``` diff --git a/site/guide/cli.md b/site/guide/cli.md index 759d28ae..d79a7c1f 100644 --- a/site/guide/cli.md +++ b/site/guide/cli.md @@ -327,7 +327,7 @@ When run interactively (no flags), prompts for project name, agent name, claw ty | `--source ` | Source runtime override when `--from` autodetection is ambiguous. | | `--project ` | Project name (used for `x-claw.pod` and image prefix). | | `--agent ` | Primary agent name. | -| `--type ` | Claw type (openclaw, hermes, nanoclaw, nanobot, picoclaw, nullclaw, microclaw, generic). | +| `--type ` | Claw type (openclaw, hermes, nanobot, picoclaw, generic). | | `--model ` | Primary model. | | `--cllama ` | Enable cllama proxy. | | `--platform ` | Platform handle (discord, slack, telegram, none). | diff --git a/site/guide/drivers.md b/site/guide/drivers.md index 78306221..8ed47678 100644 --- a/site/guide/drivers.md +++ b/site/guide/drivers.md @@ -6,18 +6,24 @@ New to Hermes? Start with the dedicated [Hermes quickstart](/guide/hermes). ## Feature Matrix -| | `openclaw` | `hermes` | `nanoclaw` | `nanobot` | `picoclaw` | `nullclaw` | `microclaw` | -|---|:---:|:---:|:---:|:---:|:---:|:---:|:---:| -| **Runtime** | [OpenClaw](https://openclaw.ai) | [Hermes](https://github.com/NousResearch/hermes-agent) | NanoClaw / Claude Code-compatible orchestrator | [Nanobot](https://github.com/HKUDS/nanobot) | [PicoClaw](https://github.com/sipeed/picoclaw) | [NullClaw](https://github.com/nullclaw/nullclaw) | [MicroClaw](https://github.com/microclaw/microclaw) | -| `claw init` scaffold | yes | yes | yes | yes | yes | yes | yes | -| HANDLE: Discord | yes | yes | -- | yes | yes | yes | yes | -| HANDLE: Telegram | yes | yes | -- | yes | yes | yes | yes | -| HANDLE: Slack | yes | yes | -- | yes | yes | yes | yes | -| HANDLE: long-tail | -- | -- | -- | -- | yes | -- | -- | -| INVOKE (cron) | yes | yes | -- | yes | yes | yes | -- | -| Structured health | yes | yes | yes | yes | yes | yes | yes | -| Read-only rootfs | yes | yes | no | yes | yes | yes | no | -| Non-root container | -- | -- | -- | -- | yes | -- | -- | +| | `openclaw` | `hermes` | `nanobot` | `picoclaw` | +|---|:---:|:---:|:---:|:---:| +| **Runtime** | [OpenClaw](https://openclaw.ai) | [Hermes](https://github.com/NousResearch/hermes-agent) | [Nanobot](https://github.com/HKUDS/nanobot) | [PicoClaw](https://github.com/sipeed/picoclaw) | +| `claw init` scaffold | yes | yes | yes | yes | +| HANDLE: Discord | yes | yes | yes | yes | +| HANDLE: Telegram | yes | yes | yes | yes | +| HANDLE: Slack | yes | yes | yes | yes | +| HANDLE: long-tail | -- | -- | -- | yes | +| INVOKE (cron) | yes | yes | yes | yes | +| Structured health | yes | yes | yes | yes | +| Read-only rootfs | yes | yes | yes | yes | +| Non-root container | -- | -- | -- | yes | + +::: info Retired drivers +`nanoclaw`, `microclaw`, and `nullclaw` were retired in +[ADR-026](https://github.com/mostlydev/clawdapus/blob/master/docs/decisions/026-runner-adoption-and-retirement.md). +Their `CLAW_TYPE`s fail `claw up` with a migration message pointing to Hermes. +::: **PicoClaw long-tail platforms:** WhatsApp, Feishu, LINE, QQ, DingTalk, OneBot, WeCom, WeCom App, Pico, MaixCam. @@ -93,10 +99,6 @@ as channel prose; set `HERMES_CHAT_STATUS_DELIVERY=on` or configure that runtime telemetry. Unset `HERMES_CHAT_STATUS_DELIVERY` preserves upstream Hermes behavior; the quiet default is applied by the Clawdapus driver. -### nanoclaw - -NanoClaw / Claude Code-compatible orchestrator driver. It does not currently support HANDLE or INVOKE, and it requires `PRIVILEGE docker-socket true` because the runtime spawns agent containers through Docker. It has a structured health probe and uses a writable root filesystem. - ### nanobot Nanobot driver with generated config and Discord/Telegram/Slack handle wiring. Supports INVOKE scheduling and read-only rootfs. @@ -105,19 +107,11 @@ Nanobot driver with generated config and Discord/Telegram/Slack handle wiring. S PicoClaw is the most platform-diverse driver, supporting the long-tail of chat platforms beyond Discord/Telegram/Slack. Supports model-list config, non-root containers, and structured health probes. -### nullclaw - -NullClaw supports CONFIGURE for fine-grained runtime config mutations. Use `CONFIGURE nullclaw config set ` to pin guild IDs, set mention requirements, configure Telegram allowlists, or select Slack transport modes. - -### microclaw - -Minimal driver supporting Discord, Telegram, and Slack handles. Does not support INVOKE scheduling. It has a structured health probe and uses a writable root filesystem. - ## Choosing a Driver - **Need Discord routing controls?** `openclaw` has the richest Discord config support. -- **Need Telegram or Slack?** `openclaw`, `hermes`, `nanobot`, `picoclaw`, `nullclaw`, or `microclaw`. +- **Need Telegram or Slack?** `openclaw`, `hermes`, `nanobot`, or `picoclaw`. - **Need WhatsApp, LINE, or other platforms?** `picoclaw` is the only option. - **Need non-root containers?** `picoclaw`. -- **Need fine-grained runtime config?** `nullclaw` with `CONFIGURE`. +- **Need fine-grained runtime config?** every driver supports `CONFIGURE config set `. - **Just need a governed container?** `generic` type gives you an alpine base with no driver enforcement. diff --git a/site/guide/social-topology.md b/site/guide/social-topology.md index f1663680..58951d65 100644 --- a/site/guide/social-topology.md +++ b/site/guide/social-topology.md @@ -122,12 +122,12 @@ services: An agent can have handles on multiple platforms simultaneously. Platform support varies by driver: -| Platform | `openclaw` | `hermes` | `nanoclaw` | `nanobot` | `picoclaw` | `nullclaw` | `microclaw` | -|----------|:---:|:---:|:---:|:---:|:---:|:---:|:---:| -| Discord | yes | yes | -- | yes | yes | yes | yes | -| Telegram | yes | yes | -- | yes | yes | yes | yes | -| Slack | yes | yes | -- | yes | yes | yes | yes | -| Long-tail chat | -- | -- | -- | -- | yes | -- | -- | +| Platform | `openclaw` | `hermes` | `nanobot` | `picoclaw` | +|----------|:---:|:---:|:---:|:---:| +| Discord | yes | yes | yes | yes | +| Telegram | yes | yes | yes | yes | +| Slack | yes | yes | yes | yes | +| Long-tail chat | -- | -- | -- | yes | PicoClaw additionally supports WhatsApp, Feishu, LINE, QQ, DingTalk, OneBot, WeCom, and other long-tail platforms. diff --git a/site/guide/what-is-clawdapus.md b/site/guide/what-is-clawdapus.md index 0a82fde8..6a724896 100644 --- a/site/guide/what-is-clawdapus.md +++ b/site/guide/what-is-clawdapus.md @@ -28,7 +28,7 @@ Any valid Dockerfile is a valid Clawfile. Any valid `docker-compose.yml` is a va ## What It Is NOT -Clawdapus is **not an agent framework**. It does not define how agents reason, plan, or execute code. It supports seven different runner types today -- OpenClaw, Hermes, NanoClaw, Nanobot, PicoClaw, NullClaw, MicroClaw -- and treats them all the same way. +Clawdapus is **not an agent framework**. It does not define how agents reason, plan, or execute code. It supports four runner types today -- OpenClaw, Hermes, Nanobot, and PicoClaw -- and treats them all the same way. Clawdapus is **not a bot-building tool**. It helps you deploy, govern, monitor, and evolve bots that already exist. You bring the agent; Clawdapus brings the infrastructure that makes it safe to run in production. diff --git a/site/index.md b/site/index.md index f1a4cdac..d93bd8ed 100644 --- a/site/index.md +++ b/site/index.md @@ -28,7 +28,7 @@ features: details: Clawfile extends Dockerfile. claw-pod.yml extends docker-compose.yml. Eject anytime — you still have working OCI images and compose files. - icon: "\U0001F3AD" title: 7 Runner Drivers - details: "OpenClaw, Hermes, NanoClaw, Nanobot, PicoClaw, NullClaw, MicroClaw. Pick your runtime. Same governance layer wraps them all." + details: "OpenClaw, Hermes, Nanobot, PicoClaw. Pick your runtime. Same governance layer wraps them all." - icon: "\U0001F4E1" title: Social Topology details: "HANDLE declares platform identity. Every agent's Discord/Telegram/Slack IDs are broadcast pod-wide. Services can @mention bots without hardcoding." diff --git a/skills/clawdapus/SKILL.md b/skills/clawdapus/SKILL.md index 4ededeab..a52262f4 100644 --- a/skills/clawdapus/SKILL.md +++ b/skills/clawdapus/SKILL.md @@ -349,11 +349,11 @@ The policy is tunable from the pod YAML via `x-claw.tool-policy` (service level) ## Communication Tools Contract -All 7 runtimes enforce private thinking + deliberate delivery — agent reasoning never reaches Discord automatically. +All four drivers compile mention-only channel consumption so an unaddressed bot message does not create a feedback loop. - **Hermes**: `HERMES_TOOL_ONLY_MODE=1` injected when Discord handles are present; runtime patches prefer `send_message`, suppress duplicate final text after a successful `send_message`, and fall back to final-text delivery rather than silently dropping replies - **OpenClaw**: enforced natively -- **NullClaw, MicroClaw, NanoClaw, NanoBot, PicoClaw**: `discord-responder.sh` passes a `send_message` tool to the LLM; only posts to Discord when the tool is called +- **NanoBot and PicoClaw**: generated channel configuration enables each runner's mention-only equivalent CLAWDAPUS.md includes a `## Communication Tools` section with private-thinking policy whenever handles are configured. @@ -430,7 +430,6 @@ When the aggregate cap drops a feed the model sees an explicit `--- FEED: | `compose.generated.yml` | Final compose with all enforcement | Next to claw-pod.yml | | `CLAWDAPUS.md` | Per-agent infrastructure map | Mounted into container | | `AGENTS.effective.md` | Merged contract + CLAWDAPUS.md (OpenClaw and Hermes cllama context) | Mounted into container or cllama context | -| `CLAUDE.md` | Combined contract + CLAWDAPUS.md (NanoClaw) | Mounted into container | | `openclaw.json` | Generated runner config (OpenClaw) | Bind-mounted directory | | `config.yaml` / `.env` | Generated runner config (Hermes) | Bind-mounted directory | | `jobs.json` | Cron schedule for INVOKE tasks | Runner state directory | @@ -444,11 +443,10 @@ When the aggregate cap drops a feed the model sees an explicit `--- FEED: |--------|-----------|--------|--------------|-------| | OpenClaw | `openclaw` | OpenClaw | JSON5 Go-native patching -> `openclaw.json` | Primary driver. Read-only container. Docker exec health probe. | | Hermes | `hermes` | Hermes (Python) | `config.yaml` + `.env` | Discord/Telegram/Slack. `HERMES_TOOL_ONLY_MODE`. Requires at least one handle. | -| NanoBot | `nanobot` | Nanobot (Node.js) | `config.json` | Cron via `jobs.json`. Merged AGENTS.md. | -| NanoClaw | `nanoclaw` | Claude Agent SDK | Combined `CLAUDE.md` | Requires `PRIVILEGE docker-socket true`. Mounts Docker socket. | +| NanoBot | `nanobot` | Nanobot (Python) | `config.json` | Cron via `jobs.json`. Merged AGENTS.md. | | PicoClaw | `picoclaw` | PicoClaw | `config.json` | HTTP `/health` + `/ready` probe. Read-only container. | -| MicroClaw | `microclaw` | MicroClaw (YAML) | `microclaw.config.yaml` | Built-in web UI on port 10961. No INVOKE support. | -| NullClaw | `nullclaw` | NullClaw (HTTP) | `config.json` | Cron via `PostApply` exec (not pre-written). Read-only container. | + +Retired (ADR-026): `nanoclaw`, `microclaw`, `nullclaw` — these `CLAW_TYPE`s fail `claw up` with a migration message. All drivers set `CLAW_MANAGED=true`, explicit `HOME`, and `DISCORD_REQUIRE_MENTION` (or equivalent) to prevent feedback loops. diff --git a/testdata/microclaw-stub/AGENTS.md b/testdata/microclaw-stub/AGENTS.md deleted file mode 100644 index 98912729..00000000 --- a/testdata/microclaw-stub/AGENTS.md +++ /dev/null @@ -1,3 +0,0 @@ -# MicroClaw Stub Contract - -You are a microclaw stub agent used for clawdapus integration testing. diff --git a/testdata/microclaw-stub/entrypoint.sh b/testdata/microclaw-stub/entrypoint.sh deleted file mode 100644 index 695dbc49..00000000 --- a/testdata/microclaw-stub/entrypoint.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/bin/sh -set -e - -exec microclaw run diff --git a/testdata/microclaw-stub/microclaw b/testdata/microclaw-stub/microclaw deleted file mode 100644 index 49026963..00000000 --- a/testdata/microclaw-stub/microclaw +++ /dev/null @@ -1,8 +0,0 @@ -#!/bin/sh -# Stub microclaw process for integration/spike tests. -# Keep this script process alive so `pgrep -f microclaw` healthcheck passes. - -echo "microclaw-stub: running (pid $$)" >&2 -while true; do - sleep 3600 -done diff --git a/testdata/nanobot-stub/AGENTS.md b/testdata/nanobot-stub/AGENTS.md new file mode 100644 index 00000000..2440a7e0 --- /dev/null +++ b/testdata/nanobot-stub/AGENTS.md @@ -0,0 +1,3 @@ +# Nanobot Stub Contract + +You are a nanobot stub agent used for Clawdapus integration testing. diff --git a/testdata/microclaw-stub/Clawfile b/testdata/nanobot-stub/Clawfile similarity index 71% rename from testdata/microclaw-stub/Clawfile rename to testdata/nanobot-stub/Clawfile index e98c0e7e..04e93f39 100644 --- a/testdata/microclaw-stub/Clawfile +++ b/testdata/nanobot-stub/Clawfile @@ -1,13 +1,13 @@ FROM alpine:3.20 -CLAW_TYPE microclaw +CLAW_TYPE nanobot AGENT AGENTS.md MODEL primary anthropic/claude-sonnet-4 RUN apk add --no-cache bash tini procps -COPY microclaw /usr/local/bin/microclaw -RUN chmod +x /usr/local/bin/microclaw +COPY nanobot /usr/local/bin/nanobot +RUN chmod +x /usr/local/bin/nanobot COPY entrypoint.sh /app/entrypoint.sh RUN chmod +x /app/entrypoint.sh ENTRYPOINT ["/sbin/tini", "--", "/app/entrypoint.sh"] diff --git a/testdata/nanobot-stub/entrypoint.sh b/testdata/nanobot-stub/entrypoint.sh new file mode 100755 index 00000000..8179b9dd --- /dev/null +++ b/testdata/nanobot-stub/entrypoint.sh @@ -0,0 +1,4 @@ +#!/bin/sh +set -e + +exec nanobot gateway diff --git a/testdata/nanobot-stub/nanobot b/testdata/nanobot-stub/nanobot new file mode 100755 index 00000000..982d72d9 --- /dev/null +++ b/testdata/nanobot-stub/nanobot @@ -0,0 +1,8 @@ +#!/bin/sh +# Stub nanobot process for integration/spike tests. +# Keep this script alive so `pgrep -f 'nanobot gateway'` healthcheck passes. + +echo "nanobot-stub: running $* (pid $$)" >&2 +while true; do + sleep 3600 +done