Skip to content

Commit 361be4c

Browse files
authored
Merge pull request #89 from mostlydev/feat/cllama-model-policy-enforcement
feat: compile and enforce declared model policy
2 parents c7d30da + 5628e74 commit 361be4c

16 files changed

+1093
-40
lines changed

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,7 @@ The spike tests are the heavy end-to-end path. They build images, run Docker, an
177177
- Multi-arch cllama image: `docker buildx build --platform linux/amd64,linux/arm64 -t ghcr.io/mostlydev/cllama:latest --push cllama/` using the `multiarch-builder` buildx builder.
178178
- User-defined `healthcheck:` in `claw-pod.yml` takes precedence over driver defaults. The override happens in `compose_emit.go` — check `serviceOut["healthcheck"]` before applying `result.Healthcheck`.
179179
- `Service.Compose` in the pod parser preserves all non-`x-claw` compose keys as a deep-copied `map[string]interface{}`. This is how user healthchecks, depends_on, command, etc. flow through.
180-
- Releases: use `gh release create` with semver tags. cllama has its own tag namespace (e.g. `v0.1.0`) published from the submodule repo. ghcr.io packages default to private; must be set public via GitHub UI after first push.
180+
- Releases: use `gh release create` with semver tags. cllama has its own tag namespace (e.g. `v0.1.0`) published from the submodule repo. ghcr.io packages default to private; must be set public via GitHub UI after first push. Pre-built `claw` binaries are published via `goreleaser` (`.goreleaser.yml` is in-tree) — do not suggest adding goreleaser, it already exists. `install.sh` downloads the latest release with checksum verification; `claw update` re-runs it.
181181
- `claw-api` image is not published to ghcr.io. The `ensureImage` fallback tries a git URL build which fails because the Docker builder cannot access the private cllama submodule. Build it locally from the repo root: `docker build -t ghcr.io/mostlydev/claw-api:latest -f dockerfiles/claw-api/Dockerfile .`
182182
- `claw-wall` image is built from `dockerfiles/claw-wall/Dockerfile` with `.` context and published to `ghcr.io/mostlydev/claw-wall:latest`. The `ensureInfraImages` fallback applies: local image → `docker pull` → local Dockerfile build. Multi-arch build: `docker buildx build --platform linux/amd64,linux/arm64 -t ghcr.io/mostlydev/claw-wall:latest --push -f dockerfiles/claw-wall/Dockerfile .`
183183
- `hermes-base` image is built from `dockerfiles/hermes-base/` and published to `ghcr.io/mostlydev/hermes-base:<tag>` (e.g. `v2026.3.17`). It installs `hermes-agent[messaging,cron]` from the pinned upstream tag, then runs `patch-hermes-runtime.py` to apply compatibility fixes. The patch disables the `members` and `voice_states` Discord intents, makes slash-command sync non-blocking (best-effort with timeout), and — critically — sets `allowed_mentions=discord.AllowedMentions(replied_user=False)` on all `channel.send()` calls that carry a reply reference. Without this last fix, Hermes's reply feature auto-pings the original author, which in multi-agent pods creates mention loops even when `DISCORD_REQUIRE_MENTION=true`. Build: `docker buildx build --platform linux/amd64,linux/arm64 -t ghcr.io/mostlydev/hermes-base:v2026.3.17 --push dockerfiles/hermes-base/`.

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,10 +114,10 @@ go build -o bin/claw ./cmd/claw
114114
Clawdapus moves fast — update frequently.
115115

116116
```bash
117-
curl -sSL https://raw.githubusercontent.com/mostlydev/clawdapus/master/install.sh | sh
117+
claw update
118118
```
119119

120-
`claw` checks for updates once a day and prints a notice at the end of any command when a newer release is available.
120+
`claw` checks for updates once an hour and prints a notice when a newer release is available.
121121

122122
### Install AI Skill
123123

cmd/claw/compose_up.go

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -482,14 +482,14 @@ func runComposeUp(podFile string) error {
482482
ClawdapusMD: md,
483483
Feeds: feeds,
484484
ServiceAuth: ordinalAuth,
485-
Metadata: map[string]interface{}{
485+
Metadata: cllama.InjectCompiledModelPolicy(map[string]any{
486486
"service": name,
487487
"ordinal": i,
488488
"pod": p.Name,
489489
"type": rc.ClawType,
490490
"token": tokens[ordinalName],
491491
"timezone": agentTimezone,
492-
},
492+
}, rc.Models),
493493
})
494494
}
495495
continue
@@ -507,13 +507,13 @@ func runComposeUp(podFile string) error {
507507
ClawdapusMD: md,
508508
Feeds: feeds,
509509
ServiceAuth: svcAuth,
510-
Metadata: map[string]interface{}{
510+
Metadata: cllama.InjectCompiledModelPolicy(map[string]any{
511511
"service": name,
512512
"pod": p.Name,
513513
"type": rc.ClawType,
514514
"token": tokens[name],
515515
"timezone": agentTimezone,
516-
},
516+
}, rc.Models),
517517
})
518518
}
519519
if err := cllama.GenerateContextDir(runtimeDir, contextInputs); err != nil {
@@ -2062,6 +2062,8 @@ var seedKeyDefs = []seedKeyDef{
20622062
{"OPENAI_API_KEY", "openai", "seed:OPENAI_API_KEY", "primary"},
20632063
{"OPENAI_API_KEY_1", "openai", "seed:OPENAI_API_KEY_1", "backup-1"},
20642064
{"OPENAI_API_KEY_2", "openai", "seed:OPENAI_API_KEY_2", "backup-2"},
2065+
{"XAI_API_KEY", "xai", "seed:XAI_API_KEY", "primary"},
2066+
{"XAI_API_KEY_1", "xai", "seed:XAI_API_KEY_1", "backup-1"},
20652067
{"ANTHROPIC_API_KEY", "anthropic", "seed:ANTHROPIC_API_KEY", "primary"},
20662068
{"ANTHROPIC_API_KEY_1", "anthropic", "seed:ANTHROPIC_API_KEY_1", "backup-1"},
20672069
{"OPENROUTER_API_KEY", "openrouter", "seed:OPENROUTER_API_KEY", "primary"},
@@ -2098,6 +2100,7 @@ type v2KeyEntry struct {
20982100

20992101
var defaultBaseURLs = map[string]string{
21002102
"openai": "https://api.openai.com/v1",
2103+
"xai": "https://api.x.ai/v1",
21012104
"anthropic": "https://api.anthropic.com/v1",
21022105
"openrouter": "https://openrouter.ai/api/v1",
21032106
}
@@ -2147,6 +2150,7 @@ func mergeProviderSeeds(authDir string, p *pod.Pod) error {
21472150
// Also collect base URLs from cllama-env.
21482151
baseURLEnvMap := map[string]string{
21492152
"OPENAI_BASE_URL": "openai",
2153+
"XAI_BASE_URL": "xai",
21502154
"ANTHROPIC_BASE_URL": "anthropic",
21512155
"OPENROUTER_BASE_URL": "openrouter",
21522156
}

cmd/claw/compose_up_test.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1779,6 +1779,59 @@ func TestMergeProviderSeedsWritesV2File(t *testing.T) {
17791779
}
17801780
}
17811781

1782+
func TestMergeProviderSeedsWritesXAIProvider(t *testing.T) {
1783+
dir := t.TempDir()
1784+
p := &pod.Pod{
1785+
Services: map[string]*pod.Service{
1786+
"trader": {
1787+
Claw: &pod.ClawBlock{
1788+
CllamaEnv: map[string]string{
1789+
"XAI_API_KEY": "xai-primary",
1790+
},
1791+
},
1792+
},
1793+
},
1794+
}
1795+
if err := mergeProviderSeeds(dir, p); err != nil {
1796+
t.Fatalf("mergeProviderSeeds: %v", err)
1797+
}
1798+
1799+
data, err := os.ReadFile(filepath.Join(dir, "providers.json"))
1800+
if err != nil {
1801+
t.Fatalf("read providers.json: %v", err)
1802+
}
1803+
1804+
var probe struct {
1805+
Providers map[string]struct {
1806+
BaseURL string `json:"base_url"`
1807+
Keys []struct {
1808+
ID string `json:"id"`
1809+
Secret string `json:"secret"`
1810+
} `json:"keys"`
1811+
} `json:"providers"`
1812+
}
1813+
if err := json.Unmarshal(data, &probe); err != nil {
1814+
t.Fatalf("parse providers.json: %v", err)
1815+
}
1816+
1817+
xai, ok := probe.Providers["xai"]
1818+
if !ok {
1819+
t.Fatal("xai missing from output")
1820+
}
1821+
if xai.BaseURL != "https://api.x.ai/v1" {
1822+
t.Fatalf("expected xai base URL, got %q", xai.BaseURL)
1823+
}
1824+
if len(xai.Keys) != 1 {
1825+
t.Fatalf("expected 1 xai key, got %d", len(xai.Keys))
1826+
}
1827+
if xai.Keys[0].ID != "seed:XAI_API_KEY" {
1828+
t.Fatalf("expected xai key id seed:XAI_API_KEY, got %q", xai.Keys[0].ID)
1829+
}
1830+
if xai.Keys[0].Secret != "xai-primary" {
1831+
t.Fatalf("expected xai secret preserved, got %q", xai.Keys[0].Secret)
1832+
}
1833+
}
1834+
17821835
func TestMergeProviderSeedsPreservesExistingRuntimeKeys(t *testing.T) {
17831836
dir := t.TempDir()
17841837

cmd/claw/spike_rollcall_test.go

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -54,11 +54,15 @@ func TestSpikeRollCall(t *testing.T) {
5454
if env["DISCORD_BOT_ID"] == "" {
5555
t.Skip("DISCORD_BOT_ID not set — skipping")
5656
}
57-
if env["OPENROUTER_API_KEY"] == "" && env["ANTHROPIC_API_KEY"] == "" {
57+
if env["OPENROUTER_API_KEY"] == "" && env["ANTHROPIC_API_KEY"] == "" && env["XAI_API_KEY"] == "" {
5858
t.Skip("No LLM API key set — skipping")
5959
}
60+
xaiKey := strings.TrimSpace(env["XAI_API_KEY"])
6061
anthropicKey := strings.TrimSpace(env["ANTHROPIC_API_KEY"])
6162
openrouterKey := strings.TrimSpace(env["OPENROUTER_API_KEY"])
63+
if _, ok := env["XAI_API_KEY"]; !ok {
64+
env["XAI_API_KEY"] = ""
65+
}
6266
if _, ok := env["ANTHROPIC_API_KEY"]; !ok {
6367
env["ANTHROPIC_API_KEY"] = ""
6468
}
@@ -78,7 +82,7 @@ func TestSpikeRollCall(t *testing.T) {
7882
botToken := env["DISCORD_BOT_TOKEN"]
7983
botID := env["DISCORD_BOT_ID"]
8084
webhookURL := env["DISCORD_WEBHOOK_URL"]
81-
proxyRequest := chooseRollcallProxyRequest(t, anthropicKey, openrouterKey)
85+
proxyRequest := chooseRollcallProxyRequest(t, xaiKey, anthropicKey, openrouterKey)
8286
if webhookURL == "" {
8387
t.Fatal("DISCORD_WEBHOOK_URL not set in rollcall/.env")
8488
}
@@ -88,10 +92,10 @@ func TestSpikeRollCall(t *testing.T) {
8892
// and the script may change. Real runtimes are expensive to build and
8993
// are skipped when they already exist locally.
9094
baseImages := []struct {
91-
tag string
92-
dockerfile string
93-
contextDir string // empty = use rollcall dir
94-
alwaysRebuild bool // true for stubs that embed discord-responder.sh
95+
tag string
96+
dockerfile string
97+
contextDir string // empty = use rollcall dir
98+
alwaysRebuild bool // true for stubs that embed discord-responder.sh
9599
}{
96100
{"openclaw:latest", "Dockerfile.openclaw-base", "", true},
97101
{"nullclaw:latest", "Dockerfile.nullclaw-base", "", true},
@@ -112,7 +116,7 @@ func TestSpikeRollCall(t *testing.T) {
112116
spikeBuildImage(t, ctxDir, b.tag, b.dockerfile)
113117
}
114118
}
115-
spikeEnsureCllamaPassthroughImage(t)
119+
spikeEnsureCllamaPassthroughImage(t, repoRoot)
116120

117121
// Build agent images (Clawfile on top of base)
118122
agentImages := []struct {
@@ -274,12 +278,15 @@ type rollcallProxyRequest struct {
274278
CllamaEnv map[string]string
275279
}
276280

277-
func chooseRollcallProxyRequest(t *testing.T, anthropicKey, openrouterKey string) rollcallProxyRequest {
281+
func chooseRollcallProxyRequest(t *testing.T, xaiKey, anthropicKey, openrouterKey string) rollcallProxyRequest {
278282
t.Helper()
279283

280284
cfg := rollcallProxyRequest{
281285
CllamaEnv: make(map[string]string),
282286
}
287+
if xaiKey != "" {
288+
cfg.CllamaEnv["XAI_API_KEY"] = xaiKey
289+
}
283290
if anthropicKey != "" {
284291
cfg.CllamaEnv["ANTHROPIC_API_KEY"] = anthropicKey
285292
}
@@ -288,6 +295,9 @@ func chooseRollcallProxyRequest(t *testing.T, anthropicKey, openrouterKey string
288295
}
289296

290297
switch {
298+
case xaiKey != "":
299+
cfg.APIFormat = "openai"
300+
cfg.Model = "xai/grok-4-1-fast-reasoning"
291301
case anthropicKey != "":
292302
cfg.APIFormat = "anthropic"
293303
cfg.Model = "claude-sonnet-4"

cmd/claw/spike_test.go

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ func TestSpikeComposeUp(t *testing.T) {
107107
spikeBuildImage(t, dir, "trading-desk-microclaw:latest", "Clawfile.microclaw")
108108
spikeBuildImage(t, dir, "trading-desk-hermes:latest", "Clawfile.hermes")
109109
spikeBuildImage(t, dir, "trading-api:latest", "Dockerfile.trading-api")
110-
spikeEnsureCllamaPassthroughImage(t)
110+
spikeEnsureCllamaPassthroughImage(t, repoRoot)
111111

112112
// Write a pre-expanded spike pod YAML so Go YAML parser sees real IDs.
113113
rawPod := spikeReadFile(t, filepath.Join(dir, "claw-pod.yml"))
@@ -833,16 +833,26 @@ func spikeBuildImage(t *testing.T, contextDir, tag, dockerfile string) {
833833
}
834834

835835
// spikeEnsureCllamaPassthroughImage guarantees a local image exists for
836-
// ghcr.io/mostlydev/cllama:latest. It tries, in order:
837-
// 1. Skip if the image already exists locally.
838-
// 2. Build from the GitHub cllama repo.
839-
// 3. Fall back to a stub image (healthcheck-only, no real proxy).
840-
func spikeEnsureCllamaPassthroughImage(t *testing.T) {
836+
// ghcr.io/mostlydev/cllama:latest. For spike coverage we prefer the local
837+
// submodule under test over any cached image, then fall back to GitHub, then
838+
// finally to a stub image if no real build is possible.
839+
func spikeEnsureCllamaPassthroughImage(t *testing.T, repoRoot string) {
841840
t.Helper()
842841
const tag = "ghcr.io/mostlydev/cllama:latest"
843-
if spikeImageExists(tag) {
844-
t.Logf("cllama image already exists")
845-
return
842+
843+
localContext := filepath.Join(repoRoot, "cllama")
844+
localDockerfile := filepath.Join(localContext, "Dockerfile")
845+
if repoRoot != "" {
846+
if _, err := os.Stat(localDockerfile); err == nil {
847+
t.Logf("building local cllama image from %s", localContext)
848+
cmd := exec.Command("docker", "build", "-t", tag, localContext)
849+
out, err := cmd.CombinedOutput()
850+
if err == nil {
851+
t.Logf("built local cllama image from working tree")
852+
return
853+
}
854+
t.Logf("local cllama build failed, falling back to GitHub: %v\n%s", err, out)
855+
}
846856
}
847857

848858
// Try building from the GitHub repo.

cmd/claw/update.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package main
2+
3+
import (
4+
"os"
5+
"os/exec"
6+
7+
"github.com/spf13/cobra"
8+
)
9+
10+
var updateCmd = &cobra.Command{
11+
Use: "update",
12+
Short: "Update claw to the latest release",
13+
RunE: func(cmd *cobra.Command, args []string) error {
14+
sh, err := exec.LookPath("sh")
15+
if err != nil {
16+
return err
17+
}
18+
curl := exec.Command("curl", "-sSL",
19+
"https://raw.githubusercontent.com/mostlydev/clawdapus/master/install.sh")
20+
install := exec.Command(sh)
21+
install.Stdin, err = curl.StdoutPipe()
22+
if err != nil {
23+
return err
24+
}
25+
install.Stdout = os.Stdout
26+
install.Stderr = os.Stderr
27+
if err := curl.Start(); err != nil {
28+
return err
29+
}
30+
if err := install.Start(); err != nil {
31+
return err
32+
}
33+
if err := curl.Wait(); err != nil {
34+
return err
35+
}
36+
return install.Wait()
37+
},
38+
}
39+
40+
func init() {
41+
rootCmd.AddCommand(updateCmd)
42+
}

cmd/claw/update_check.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import (
1212

1313
const (
1414
updateCheckURL = "https://api.github.com/repos/mostlydev/clawdapus/releases/latest"
15-
updateCheckInterval = 24 * time.Hour
15+
updateCheckInterval = time.Hour
1616
updateCheckFile = ".claw-update-check"
1717
)
1818

@@ -91,7 +91,7 @@ func writeCache(c *updateCheckCache) {
9191
}
9292

9393
// maybeNotifyUpdate prints an update notice if a newer release is available.
94-
// Checks at most once per day; never blocks on network errors.
94+
// Checks at most once per hour; never blocks on network errors.
9595
func maybeNotifyUpdate() {
9696
if version == "dev" {
9797
return
@@ -117,5 +117,5 @@ func maybeNotifyUpdate() {
117117
}
118118

119119
func printUpdateNotice(latest string) {
120-
fmt.Fprintf(os.Stderr, "\n Update available: v%s → v%s\n Run: curl -sSL https://raw.githubusercontent.com/mostlydev/clawdapus/master/install.sh | sh\n\n", version, latest)
120+
fmt.Fprintf(os.Stderr, "\n Update available: v%s → v%s (run: claw update)\n\n", version, latest)
121121
}

0 commit comments

Comments
 (0)