diff --git a/README.md b/README.md index c33384e..e370885 100644 --- a/README.md +++ b/README.md @@ -399,7 +399,7 @@ Bots install things. That's how real work gets done. Tracked mutation is evoluti ## Status -**v0.1.0 released** — [download](https://github.com/mostlydev/clawdapus/releases/tag/v0.1.0) +**v0.2.0 released** — [download](https://github.com/mostlydev/clawdapus/releases/tag/v0.2.0) | Phase | Status | |-------|--------| diff --git a/cmd/claw/compose_up.go b/cmd/claw/compose_up.go index e339b48..43d23c3 100644 --- a/cmd/claw/compose_up.go +++ b/cmd/claw/compose_up.go @@ -19,6 +19,7 @@ import ( "github.com/mostlydev/clawdapus/internal/driver" "github.com/mostlydev/clawdapus/internal/driver/shared" "github.com/mostlydev/clawdapus/internal/inspect" + "github.com/mostlydev/clawdapus/internal/persona" "github.com/mostlydev/clawdapus/internal/pod" "github.com/mostlydev/clawdapus/internal/runtime" ) @@ -37,6 +38,7 @@ var ( dockerBuildTaggedImage = dockerBuildTaggedImageDefault findClawdapusRepoRoot = findRepoRoot runInfraDockerCommand = runInfraDockerCommandDefault + runComposeDockerCommand = runComposeDockerCommandDefault ) var composeUpCmd = &cobra.Command{ @@ -163,6 +165,11 @@ func runComposeUp(podFile string) error { if len(resolvedIncludes) > 0 { agentHostPath = filepath.Join(svcRuntimeDir, "AGENTS.generated.md") } + personaRef := firstNonEmpty(svc.Claw.Persona, info.Persona) + resolvedPersona, err := persona.Materialize(podDir, svcRuntimeDir, personaRef) + if err != nil { + return fmt.Errorf("service %q: materialize persona: %w", name, err) + } // Merge skills: image-level (from labels) + pod-level (from x-claw) imageSkills, err := runtime.ResolveSkills(podDir, info.Skills) @@ -227,7 +234,7 @@ func runComposeUp(podFile string) error { ClawType: info.ClawType, Agent: agentFile, AgentHostPath: agentHostPath, - Persona: firstNonEmpty(svc.Claw.Persona, info.Persona), + Persona: personaRef, Models: info.Models, Handles: svc.Claw.Handles, PeerHandles: peerHandles, @@ -240,6 +247,9 @@ func runComposeUp(podFile string) error { Skills: skills, Cllama: resolveCllama(info.Cllama, svc.Claw.Cllama), } + if resolvedPersona != nil { + rc.PersonaHostPath = resolvedPersona.HostPath + } // Merge image-level invocations (from Clawfile INVOKE labels via inspect) for _, imgInv := range info.Invocations { @@ -519,13 +529,18 @@ func runComposeUp(podFile string) error { composeArgs = append(composeArgs, "-d") } - dockerCmd := exec.Command("docker", composeArgs...) - dockerCmd.Stdout = os.Stdout - dockerCmd.Stderr = os.Stderr - if err := dockerCmd.Run(); err != nil { + if err := runComposeDockerCommand(composeArgs...); err != nil { return fmt.Errorf("docker compose up failed: %w", err) } + runtimeConsumers := runtimeConsumerServices(resolvedClaws, proxies, p.Clawdash) + if composeUpDetach && len(runtimeConsumers) > 0 { + recreateArgs := append([]string{"compose", "-f", generatedPath, "up", "-d", "--force-recreate"}, runtimeConsumers...) + if err := runComposeDockerCommand(recreateArgs...); err != nil { + return fmt.Errorf("docker compose force-recreate failed: %w", err) + } + } + // PostApply: verify every generated service container. for name, d := range drivers { rc := resolvedClaws[name] @@ -554,6 +569,43 @@ func resetRuntimeDir(path string) error { return os.MkdirAll(path, 0o700) } +func runtimeConsumerServices(resolvedClaws map[string]*driver.ResolvedClaw, proxies []pod.CllamaProxyConfig, dash *pod.ClawdashConfig) []string { + seen := make(map[string]struct{}) + names := make([]string, 0, len(resolvedClaws)+len(proxies)+1) + + for name, rc := range resolvedClaws { + count := 1 + if rc != nil && rc.Count > 0 { + count = rc.Count + } + for _, generated := range expandedServiceNames(name, count) { + if _, ok := seen[generated]; ok { + continue + } + seen[generated] = struct{}{} + names = append(names, generated) + } + } + + for _, proxy := range proxies { + serviceName := cllama.ProxyServiceName(proxy.ProxyType) + if _, ok := seen[serviceName]; ok { + continue + } + seen[serviceName] = struct{}{} + names = append(names, serviceName) + } + + if dash != nil { + if _, ok := seen["clawdash"]; !ok { + names = append(names, "clawdash") + } + } + + sort.Strings(names) + return names +} + func resolveRuntimePlaceholders(podDir string, p *pod.Pod) error { env, err := loadRuntimeEnv(podDir) if err != nil { @@ -1640,6 +1692,13 @@ func runInfraDockerCommandDefault(args ...string) error { return cmd.Run() } +func runComposeDockerCommandDefault(args ...string) error { + cmd := exec.Command("docker", args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + // findRepoRoot walks up from cwd looking for go.mod with the clawdapus module. func findRepoRoot() (string, bool) { dir, err := os.Getwd() diff --git a/cmd/claw/compose_up_test.go b/cmd/claw/compose_up_test.go index ecff6e1..80b0c2b 100644 --- a/cmd/claw/compose_up_test.go +++ b/cmd/claw/compose_up_test.go @@ -293,6 +293,38 @@ func TestResetRuntimeDirClearsStaleContents(t *testing.T) { } } +func TestRuntimeConsumerServicesIncludesManagedServicesAndInfra(t *testing.T) { + services := runtimeConsumerServices( + map[string]*driver.ResolvedClaw{ + "assistant": {Count: 1}, + "worker": {Count: 2}, + }, + []pod.CllamaProxyConfig{{ProxyType: "passthrough"}}, + &pod.ClawdashConfig{}, + ) + + want := []string{"assistant", "clawdash", "cllama", "worker-0", "worker-1"} + if !slices.Equal(services, want) { + t.Fatalf("unexpected runtime consumer services: got %v want %v", services, want) + } +} + +func TestRuntimeConsumerServicesDeduplicatesAndSorts(t *testing.T) { + services := runtimeConsumerServices( + map[string]*driver.ResolvedClaw{ + "zeta": {Count: 1}, + "alpha": nil, + }, + []pod.CllamaProxyConfig{{ProxyType: "passthrough"}, {ProxyType: "passthrough"}}, + nil, + ) + + want := []string{"alpha", "cllama", "zeta"} + if !slices.Equal(services, want) { + t.Fatalf("unexpected runtime consumer services: got %v want %v", services, want) + } +} + func TestMergedPortsDeduplication(t *testing.T) { expose := []string{"80", "443"} ports := []string{"443", "8080"} diff --git a/docs/reviews/persona-runtime-status-2026-03-08.md b/docs/reviews/persona-runtime-status-2026-03-08.md new file mode 100644 index 0000000..6aded88 --- /dev/null +++ b/docs/reviews/persona-runtime-status-2026-03-08.md @@ -0,0 +1,122 @@ +# PERSONA Runtime Status + +Date: 2026-03-08 + +## What PERSONA Actually Does Today + +`PERSONA` is now a runtime materialization feature, but it is narrower than the manifesto language implies. + +Current behavior: + +- `PERSONA ` in a Clawfile is still emitted as `LABEL claw.persona.default=`. +- `x-claw.persona` in a pod overrides the image-level default. +- During `claw up`, Clawdapus resolves the effective persona ref and materializes it into the service runtime directory at `.claw-runtime//persona/`. +- Local refs are supported: + - relative directory paths like `./personas/allen` + - absolute paths + - `file://` paths +- Local persona directories are copied into the runtime directory with path-traversal checks and symlink rejection. +- Non-local refs are treated as OCI artifact references and pulled via `oras-go` into the runtime persona directory using Docker credentials when available. +- Drivers mount the resulting persona directory into the runner as a writable workspace and expose its path with `CLAW_PERSONA_DIR` when a persona is actually present. +- Generated `CLAWDAPUS.md` now includes the persona ref and the persona mount location. + +What PERSONA does not do today: + +- It does not merge persona content into `AGENTS.md`. +- It does not inject persona files into the prompt automatically. +- It does not define or enforce any specific file layout inside the persona directory. +- It does not restore memory/history into runner-native state stores. +- It does not provide snapshotting, syncing back to a registry, or promotion workflows. +- It does not currently have end-to-end tests against a live OCI registry pull path; current automated coverage is local-path based. + +## Compared With The Manifesto + +The manifesto describes persona as: + +- a portable identity layer +- a package containing memory, history, style, and knowledge +- independently swappable from both the runner and the contract +- downloadable from a registry + +The implementation now satisfies part of that: + +- independent from the contract: yes +- independently swappable at deploy time: yes +- registry-backed in the runtime model: yes, via OCI pull support +- actual identity semantics: no, not by itself + +In practice, the code implements persona as a mounted writable directory, not as a fully realized identity system. + +## Compared With The Architecture Plan + +The architecture plan said: + +- build time stores a default persona ref as image metadata +- runtime resolves the ref +- runtime fetches the artifact via `oras` +- runtime bind-mounts it into the container + +That is now materially true, with one extension: + +- local directory refs are also supported for development and testing, in addition to OCI refs + +The gap versus the plan is not fetch/mount anymore. The gap is higher-level lifecycle and runner integration. + +## Usefulness + +`PERSONA` is useful now, but mostly as infrastructure plumbing rather than as a finished product feature. + +Useful today: + +- separating mutable identity/workspace content from the immutable behavioral contract +- swapping persona content without rebuilding the image +- sharing a reusable directory of memory/style/reference artifacts across runners +- giving runners and tools a stable filesystem path (`CLAW_PERSONA_DIR`) for persona-scoped state + +Not especially useful yet: + +- if the runner does not know to read or write that directory +- if operators expect persona alone to change model behavior without any runner-side consumption +- if they expect persistence or registry round-trips beyond initial materialization + +Bottom line: + +`PERSONA` is now a real runtime mount mechanism. It is useful as a deployment primitive. It is not yet a complete “identity system.” + +## Validation Performed + +Code paths reviewed: + +- `cmd/claw/compose_up.go` +- `internal/persona/materialize.go` +- `internal/driver/openclaw/driver.go` +- `internal/driver/nanoclaw/driver.go` +- `internal/driver/shared/clawdapus_md.go` + +Automated tests run: + +- `go test ./internal/persona ./internal/driver/openclaw ./internal/driver/nanoclaw ./internal/driver/shared ./cmd/claw` +- `go test ./...` + +Important covered cases: + +- local persona directories are copied into the runtime directory +- escaping local paths are rejected +- openclaw mounts persona at `/claw/persona` +- nanoclaw mounts persona at `/workspace/container/persona` +- `CLAWDAPUS.md` advertises persona only when mounted + +## Recommendation + +Docs should describe `PERSONA` as: + +- a deploy-time materialized persona workspace +- mounted writable into the runner +- independently swappable from contract and image + +Docs should not currently claim: + +- automatic memory restoration +- automatic prompt injection +- complete portable identity semantics +- finished registry lifecycle tooling diff --git a/go.mod b/go.mod index 91dcf43..7df67dc 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,8 @@ require ( github.com/docker/docker v26.1.4+incompatible github.com/moby/buildkit v0.13.2 github.com/spf13/cobra v1.8.1 + gopkg.in/yaml.v3 v3.0.1 + oras.land/oras-go/v2 v2.6.0 ) require ( @@ -22,7 +24,7 @@ require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect - github.com/opencontainers/image-spec v1.1.0-rc5 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/spf13/pflag v1.0.5 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 // indirect @@ -30,9 +32,9 @@ require ( go.opentelemetry.io/otel/metric v1.21.0 // indirect go.opentelemetry.io/otel/trace v1.21.0 // indirect golang.org/x/mod v0.13.0 // indirect + golang.org/x/sync v0.14.0 // indirect golang.org/x/sys v0.18.0 // indirect golang.org/x/tools v0.14.0 // indirect google.golang.org/protobuf v1.31.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect gotest.tools/v3 v3.5.2 // indirect ) diff --git a/go.sum b/go.sum index 8071c7d..643ab21 100644 --- a/go.sum +++ b/go.sum @@ -50,8 +50,8 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= -github.com/opencontainers/image-spec v1.1.0-rc5 h1:Ygwkfw9bpDvs+c9E34SdgGOj41dX/cbdlwvlWt0pnFI= -github.com/opencontainers/image-spec v1.1.0-rc5/go.mod h1:X4pATf0uXsnn3g5aiGIsVnJBR4mxhKzfwmvK/B2NTm8= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -99,8 +99,8 @@ golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE= -golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= +golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -134,8 +134,11 @@ google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp0 google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= +oras.land/oras-go/v2 v2.6.0 h1:X4ELRsiGkrbeox69+9tzTu492FMUu7zJQW6eJU+I2oc= +oras.land/oras-go/v2 v2.6.0/go.mod h1:magiQDfG6H1O9APp+rOsvCPcW1GD2MM7vgnKY0Y+u1o= diff --git a/internal/driver/microclaw/driver.go b/internal/driver/microclaw/driver.go index 0f379ad..56a05b3 100644 --- a/internal/driver/microclaw/driver.go +++ b/internal/driver/microclaw/driver.go @@ -143,19 +143,36 @@ func (d *Driver) Materialize(rc *driver.ResolvedClaw, opts driver.MaterializeOpt return nil, fmt.Errorf("microclaw driver: write seeded AGENTS.md: %w", err) } - return &driver.MaterializeResult{ - Mounts: []driver.Mount{ - { - HostPath: configPath, - ContainerPath: "/app/config/microclaw.config.yaml", - ReadOnly: true, - }, - { - HostPath: dataDir, - ContainerPath: "/claw-data", - ReadOnly: false, - }, + mounts := []driver.Mount{ + { + HostPath: configPath, + ContainerPath: "/app/config/microclaw.config.yaml", + ReadOnly: true, + }, + { + HostPath: dataDir, + ContainerPath: "/claw-data", + 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", + "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", @@ -167,10 +184,7 @@ func (d *Driver) Materialize(rc *driver.ResolvedClaw, opts driver.MaterializeOpt Timeout: "10s", Retries: 3, }, - Environment: map[string]string{ - "CLAW_MANAGED": "true", - "MICROCLAW_CONFIG": "/app/config/microclaw.config.yaml", - }, + Environment: env, }, nil } diff --git a/internal/driver/nanobot/driver.go b/internal/driver/nanobot/driver.go index ac87562..f3c313a 100644 --- a/internal/driver/nanobot/driver.go +++ b/internal/driver/nanobot/driver.go @@ -124,14 +124,30 @@ func (d *Driver) Materialize(rc *driver.ResolvedClaw, opts driver.MaterializeOpt } } - return &driver.MaterializeResult{ - Mounts: []driver.Mount{ - { - HostPath: homeDir, - ContainerPath: "/root/.nanobot", - ReadOnly: false, - }, + mounts := []driver.Mount{ + { + HostPath: homeDir, + ContainerPath: "/root/.nanobot", + ReadOnly: false, }, + } + if rc.PersonaHostPath != "" { + mounts = append(mounts, driver.Mount{ + HostPath: rc.PersonaHostPath, + ContainerPath: "/root/.nanobot/workspace/persona", + ReadOnly: false, + }) + } + + env := map[string]string{ + "CLAW_MANAGED": "true", + } + if rc.PersonaHostPath != "" { + env["CLAW_PERSONA_DIR"] = "/root/.nanobot/workspace/persona" + } + + return &driver.MaterializeResult{ + Mounts: mounts, Tmpfs: []string{"/tmp"}, ReadOnly: true, Restart: "on-failure", @@ -143,9 +159,7 @@ func (d *Driver) Materialize(rc *driver.ResolvedClaw, opts driver.MaterializeOpt Timeout: "10s", Retries: 3, }, - Environment: map[string]string{ - "CLAW_MANAGED": "true", - }, + Environment: env, }, nil } diff --git a/internal/driver/nanoclaw/driver.go b/internal/driver/nanoclaw/driver.go index f473b30..0092636 100644 --- a/internal/driver/nanoclaw/driver.go +++ b/internal/driver/nanoclaw/driver.go @@ -60,8 +60,20 @@ func (d *Driver) Materialize(rc *driver.ResolvedClaw, opts driver.MaterializeOpt {HostPath: combinedPath, ContainerPath: "/workspace/groups/main/CLAUDE.md", ReadOnly: true}, {HostPath: "/var/run/docker.sock", ContainerPath: "/var/run/docker.sock", 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"} + env := map[string]string{ + "CLAW_MANAGED": "true", + } + if rc.PersonaHostPath != "" { + env["CLAW_PERSONA_DIR"] = "/workspace/container/persona" + } if len(rc.Cllama) > 0 { firstProxy := cllama.ProxyBaseURL(rc.Cllama[0]) diff --git a/internal/driver/nanoclaw/driver_test.go b/internal/driver/nanoclaw/driver_test.go index f22c1ab..1508855 100644 --- a/internal/driver/nanoclaw/driver_test.go +++ b/internal/driver/nanoclaw/driver_test.go @@ -261,6 +261,42 @@ func TestMaterializeWithCllama(t *testing.T) { } } +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") diff --git a/internal/driver/nullclaw/driver.go b/internal/driver/nullclaw/driver.go index 460bf95..284c9cd 100644 --- a/internal/driver/nullclaw/driver.go +++ b/internal/driver/nullclaw/driver.go @@ -83,30 +83,46 @@ func (d *Driver) Materialize(rc *driver.ResolvedClaw, opts driver.MaterializeOpt return nil, fmt.Errorf("nullclaw driver: write CLAWDAPUS.md: %w", err) } - return &driver.MaterializeResult{ - 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, - }, + 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, + }, + } + if rc.PersonaHostPath != "" { + mounts = append(mounts, driver.Mount{ + HostPath: rc.PersonaHostPath, + ContainerPath: "/claw/persona", + ReadOnly: false, + }) + } + + env := map[string]string{ + "CLAW_MANAGED": "true", + } + if rc.PersonaHostPath != "" { + env["CLAW_PERSONA_DIR"] = "/claw/persona" + } + + return &driver.MaterializeResult{ + Mounts: mounts, Tmpfs: []string{"/tmp"}, ReadOnly: true, Restart: "on-failure", @@ -118,9 +134,7 @@ func (d *Driver) Materialize(rc *driver.ResolvedClaw, opts driver.MaterializeOpt Timeout: "10s", Retries: 3, }, - Environment: map[string]string{ - "CLAW_MANAGED": "true", - }, + Environment: env, }, nil } diff --git a/internal/driver/openclaw/channel_config_test.go b/internal/driver/openclaw/channel_config_test.go index 06640ae..2b1ff52 100644 --- a/internal/driver/openclaw/channel_config_test.go +++ b/internal/driver/openclaw/channel_config_test.go @@ -2,6 +2,7 @@ package openclaw import ( "encoding/json" + "strings" "testing" "github.com/mostlydev/clawdapus/internal/driver" @@ -117,21 +118,12 @@ func TestGenerateConfigChannelSurfaceGuildPolicy(t *testing.T) { }, }, } - data, err := GenerateConfig(rc) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - var config map[string]interface{} - if err := json.Unmarshal(data, &config); err != nil { - t.Fatalf("invalid JSON: %v", err) - } - discord := config["channels"].(map[string]interface{})["discord"].(map[string]interface{}) - guild := discord["guilds"].(map[string]interface{})["GUILD1"].(map[string]interface{}) - if guild["policy"] != "allowlist" { - t.Errorf("expected guild policy=allowlist, got %v", guild["policy"]) + _, err := GenerateConfig(rc) + if err == nil { + t.Fatal("expected guild policy to fail early until OpenClaw supports it") } - if guild["requireMention"] != true { - t.Errorf("expected guild requireMention=true, got %v", guild["requireMention"]) + if got := err.Error(); !strings.Contains(got, "guild policy is not supported") { + t.Fatalf("unexpected error: %v", err) } } diff --git a/internal/driver/openclaw/config.go b/internal/driver/openclaw/config.go index 64ea1af..51f0cbc 100644 --- a/internal/driver/openclaw/config.go +++ b/internal/driver/openclaw/config.go @@ -343,9 +343,7 @@ func applyDiscordChannelSurface(config map[string]interface{}, cc *driver.Channe for guildID, guildCfg := range cc.Guilds { base := fmt.Sprintf("channels.discord.guilds.%s", guildID) if guildCfg.Policy != "" { - if err := setPath(config, base+".policy", guildCfg.Policy); err != nil { - return err - } + return fmt.Errorf("guild policy is not supported by the current OpenClaw runtime for guild %q; remove channel://discord guild policy until runtime support lands", guildID) } if guildCfg.RequireMention { if err := setPath(config, base+".requireMention", true); err != nil { diff --git a/internal/driver/openclaw/driver.go b/internal/driver/openclaw/driver.go index 4f1676c..94cf26a 100644 --- a/internal/driver/openclaw/driver.go +++ b/internal/driver/openclaw/driver.go @@ -67,6 +67,13 @@ func (d *Driver) Materialize(rc *driver.ResolvedClaw, opts driver.MaterializeOpt ReadOnly: true, }, } + if rc.PersonaHostPath != "" { + mounts = append(mounts, driver.Mount{ + HostPath: rc.PersonaHostPath, + ContainerPath: "/claw/persona", + ReadOnly: false, + }) + } // Generate jobs.json if there are scheduled invocations. // Mounted read-write: openclaw updates job state (nextRunAtMs, lastRunAtMs, etc.) @@ -110,6 +117,15 @@ func (d *Driver) Materialize(rc *driver.ResolvedClaw, opts driver.MaterializeOpt ReadOnly: true, }) + env := map[string]string{ + "CLAW_MANAGED": "true", + "OPENCLAW_CONFIG_PATH": "/app/config/openclaw.json", + "OPENCLAW_STATE_DIR": "/app/state", + } + if rc.PersonaHostPath != "" { + env["CLAW_PERSONA_DIR"] = "/claw/persona" + } + return &driver.MaterializeResult{ Mounts: mounts, Tmpfs: []string{ @@ -133,11 +149,7 @@ func (d *Driver) Materialize(rc *driver.ResolvedClaw, opts driver.MaterializeOpt Timeout: "10s", Retries: 3, }, - Environment: map[string]string{ - "CLAW_MANAGED": "true", - "OPENCLAW_CONFIG_PATH": "/app/config/openclaw.json", - "OPENCLAW_STATE_DIR": "/app/state", - }, + Environment: env, }, nil } diff --git a/internal/driver/openclaw/driver_test.go b/internal/driver/openclaw/driver_test.go index 022693a..f2d3686 100644 --- a/internal/driver/openclaw/driver_test.go +++ b/internal/driver/openclaw/driver_test.go @@ -150,3 +150,66 @@ func TestMaterializeJobsDirMountedNotFile(t *testing.T) { t.Error("jobs cron dir must be read-write so openclaw can update job state") } } + +func TestMaterializeNoPersonaOmitsEnvVar(t *testing.T) { + dir := t.TempDir() + agentFile := filepath.Join(dir, "AGENTS.md") + os.WriteFile(agentFile, []byte("# Contract"), 0o644) + + d := &Driver{} + rc := &driver.ResolvedClaw{ + ClawType: "openclaw", + Agent: "AGENTS.md", + AgentHostPath: agentFile, + Models: make(map[string]string), + } + result, err := d.Materialize(rc, driver.MaterializeOpts{RuntimeDir: dir}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if v, ok := result.Environment["CLAW_PERSONA_DIR"]; ok { + t.Fatalf("CLAW_PERSONA_DIR should not be set without persona, got %q", v) + } +} + +func TestMaterializeMountsPersonaWorkspace(t *testing.T) { + dir := t.TempDir() + agentFile := filepath.Join(dir, "AGENTS.md") + personaDir := filepath.Join(dir, "persona-src") + if err := os.WriteFile(agentFile, []byte("# Contract"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(personaDir, 0o755); err != nil { + t.Fatal(err) + } + + d := &Driver{} + rc := &driver.ResolvedClaw{ + ClawType: "openclaw", + Agent: "AGENTS.md", + AgentHostPath: agentFile, + Persona: "ghcr.io/mostlydev/personas/allen:latest", + PersonaHostPath: personaDir, + Models: make(map[string]string), + } + result, err := d.Materialize(rc, driver.MaterializeOpts{RuntimeDir: dir}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + found := false + for _, mount := range result.Mounts { + if mount.ContainerPath == "/claw/persona" { + found = true + if mount.ReadOnly { + t.Fatal("persona mount should be writable") + } + } + } + if !found { + t.Fatal("expected /claw/persona mount") + } + if result.Environment["CLAW_PERSONA_DIR"] != "/claw/persona" { + t.Fatalf("expected CLAW_PERSONA_DIR to be set, got %q", result.Environment["CLAW_PERSONA_DIR"]) + } +} diff --git a/internal/driver/picoclaw/driver.go b/internal/driver/picoclaw/driver.go index dc330d9..e56aee8 100644 --- a/internal/driver/picoclaw/driver.go +++ b/internal/driver/picoclaw/driver.go @@ -135,14 +135,32 @@ func (d *Driver) Materialize(rc *driver.ResolvedClaw, opts driver.MaterializeOpt } } - return &driver.MaterializeResult{ - Mounts: []driver.Mount{ - { - HostPath: homeDir, - ContainerPath: picoclawHomeDir, - ReadOnly: false, - }, + mounts := []driver.Mount{ + { + HostPath: homeDir, + ContainerPath: picoclawHomeDir, + ReadOnly: false, }, + } + if rc.PersonaHostPath != "" { + mounts = append(mounts, driver.Mount{ + HostPath: rc.PersonaHostPath, + ContainerPath: picoclawWorkspaceDir + "/persona", + ReadOnly: false, + }) + } + + env := map[string]string{ + "CLAW_MANAGED": "true", + "PICOCLAW_HOME": picoclawHomeDir, + "PICOCLAW_CONFIG": picoclawHomeDir + "/config.json", + } + if rc.PersonaHostPath != "" { + env["CLAW_PERSONA_DIR"] = picoclawWorkspaceDir + "/persona" + } + + return &driver.MaterializeResult{ + Mounts: mounts, Tmpfs: []string{"/tmp"}, ReadOnly: true, Restart: "on-failure", @@ -154,11 +172,7 @@ func (d *Driver) Materialize(rc *driver.ResolvedClaw, opts driver.MaterializeOpt Timeout: "10s", Retries: 3, }, - Environment: map[string]string{ - "CLAW_MANAGED": "true", - "PICOCLAW_HOME": picoclawHomeDir, - "PICOCLAW_CONFIG": picoclawHomeDir + "/config.json", - }, + Environment: env, }, nil } diff --git a/internal/driver/shared/clawdapus_md.go b/internal/driver/shared/clawdapus_md.go index f0e9c0e..dcff8a2 100644 --- a/internal/driver/shared/clawdapus_md.go +++ b/internal/driver/shared/clawdapus_md.go @@ -23,8 +23,21 @@ func GenerateClawdapusMD(rc *driver.ResolvedClaw, podName string) string { b.WriteString(fmt.Sprintf("- **Pod:** %s\n", podName)) b.WriteString(fmt.Sprintf("- **Service:** %s\n", rc.ServiceName)) b.WriteString(fmt.Sprintf("- **Type:** %s\n", rc.ClawType)) + if rc.Persona != "" { + b.WriteString(fmt.Sprintf("- **Persona Ref:** %s\n", rc.Persona)) + } b.WriteString("\n") + if rc.PersonaHostPath != "" { + b.WriteString("## Persona\n\n") + b.WriteString("Your mutable persona workspace is mounted separately from the behavioral contract.\n\n") + b.WriteString("- **Mount:** `persona/`\n") + if rc.Persona != "" { + b.WriteString(fmt.Sprintf("- **Source:** `%s`\n", rc.Persona)) + } + b.WriteString("\n") + } + // Surfaces b.WriteString("## Surfaces\n\n") if len(rc.Surfaces) == 0 { diff --git a/internal/driver/shared/clawdapus_md_test.go b/internal/driver/shared/clawdapus_md_test.go index e79132e..a2abee6 100644 --- a/internal/driver/shared/clawdapus_md_test.go +++ b/internal/driver/shared/clawdapus_md_test.go @@ -109,6 +109,25 @@ func TestGenerateClawdapusMDHandlesSection(t *testing.T) { } } +func TestGenerateClawdapusMDPersonaSection(t *testing.T) { + rc := &driver.ResolvedClaw{ + ServiceName: "bot", + ClawType: "openclaw", + Persona: "ghcr.io/mostlydev/personas/allen:latest", + PersonaHostPath: "/tmp/runtime/persona", + } + md := GenerateClawdapusMD(rc, "test-pod") + if !strings.Contains(md, "Persona Ref") { + t.Fatal("expected persona ref in identity section") + } + if !strings.Contains(md, "## Persona") { + t.Fatal("expected persona section") + } + if !strings.Contains(md, "`persona/`") { + t.Fatal("expected persona mount path") + } +} + func TestGenerateClawdapusMDIncludesContextComposition(t *testing.T) { rc := &driver.ResolvedClaw{ ServiceName: "bot", diff --git a/internal/driver/types.go b/internal/driver/types.go index 0e272d7..168086d 100644 --- a/internal/driver/types.go +++ b/internal/driver/types.go @@ -25,25 +25,26 @@ type Invocation struct { // ResolvedClaw combines image-level claw labels with pod-level x-claw overrides. type ResolvedClaw struct { - ServiceName string - ImageRef string - ClawType string - Agent string // filename from image labels (e.g., "AGENTS.md") - AgentHostPath string // resolved host path for bind mount - Persona string // runtime persona ref from x-claw or image metadata - Models map[string]string // slot -> provider/model - Handles map[string]*HandleInfo // platform -> contact card (from x-claw handles block) - PeerHandles map[string]map[string]*HandleInfo // service name -> platform -> HandleInfo for sibling services - Includes []ResolvedInclude // composed contract fragments from x-claw.include - Surfaces []ResolvedSurface - Skills []ResolvedSkill - Privileges map[string]string - Configures []string // openclaw config set commands from labels - Invocations []Invocation // scheduled agent tasks from image labels + pod x-claw.invoke - Count int // from pod x-claw (default 1) - Environment map[string]string // from pod environment block - Cllama []string // ordered cllama proxy types (e.g., ["passthrough"]) - CllamaToken string // per-agent bearer token injected when cllama is active + ServiceName string + ImageRef string + ClawType string + Agent string // filename from image labels (e.g., "AGENTS.md") + AgentHostPath string // resolved host path for bind mount + Persona string // runtime persona ref from x-claw or image metadata + PersonaHostPath string // resolved host path for persona workspace mount + Models map[string]string // slot -> provider/model + Handles map[string]*HandleInfo // platform -> contact card (from x-claw handles block) + PeerHandles map[string]map[string]*HandleInfo // service name -> platform -> HandleInfo for sibling services + Includes []ResolvedInclude // composed contract fragments from x-claw.include + Surfaces []ResolvedSurface + Skills []ResolvedSkill + Privileges map[string]string + Configures []string // openclaw config set commands from labels + Invocations []Invocation // scheduled agent tasks from image labels + pod x-claw.invoke + Count int // from pod x-claw (default 1) + Environment map[string]string // from pod environment block + Cllama []string // ordered cllama proxy types (e.g., ["passthrough"]) + CllamaToken string // per-agent bearer token injected when cllama is active } // HandleInfo is the full contact card for an agent on a platform. diff --git a/internal/persona/materialize.go b/internal/persona/materialize.go new file mode 100644 index 0000000..bb2df2d --- /dev/null +++ b/internal/persona/materialize.go @@ -0,0 +1,183 @@ +package persona + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "oras.land/oras-go/v2" + "oras.land/oras-go/v2/content/file" + "oras.land/oras-go/v2/registry" + "oras.land/oras-go/v2/registry/remote" + "oras.land/oras-go/v2/registry/remote/auth" + "oras.land/oras-go/v2/registry/remote/credentials" +) + +type ResolvedPersona struct { + Ref string + HostPath string +} + +func Materialize(baseDir, runtimeDir, ref string) (*ResolvedPersona, error) { + ref = strings.TrimSpace(ref) + if ref == "" { + return nil, nil + } + + targetDir := filepath.Join(runtimeDir, "persona") + if err := os.RemoveAll(targetDir); err != nil { + return nil, fmt.Errorf("reset persona dir: %w", err) + } + if err := os.MkdirAll(targetDir, 0o700); err != nil { + return nil, fmt.Errorf("create persona dir: %w", err) + } + + if isLocalRef(ref) { + src, err := resolveLocalPersonaPath(baseDir, ref) + if err != nil { + return nil, err + } + if err := copyDir(src, targetDir); err != nil { + return nil, err + } + return &ResolvedPersona{Ref: ref, HostPath: targetDir}, nil + } + + if err := pullRemotePersona(context.Background(), targetDir, ref); err != nil { + return nil, err + } + return &ResolvedPersona{Ref: ref, HostPath: targetDir}, nil +} + +func isLocalRef(ref string) bool { + return strings.HasPrefix(ref, ".") || strings.HasPrefix(ref, "/") || strings.HasPrefix(ref, "file://") +} + +func resolveLocalPersonaPath(baseDir, ref string) (string, error) { + path := strings.TrimPrefix(ref, "file://") + absBase, err := filepath.Abs(baseDir) + if err != nil { + return "", fmt.Errorf("resolve base dir %q: %w", baseDir, err) + } + realBase, err := filepath.EvalSymlinks(absBase) + if err != nil { + return "", fmt.Errorf("resolve real base dir %q: %w", baseDir, err) + } + + hostPath, err := filepath.Abs(filepath.Join(baseDir, path)) + if err != nil { + return "", fmt.Errorf("resolve persona path %q: %w", ref, err) + } + if !strings.HasPrefix(hostPath, absBase+string(filepath.Separator)) && hostPath != absBase { + return "", fmt.Errorf("persona path %q escapes base directory %q", ref, baseDir) + } + + info, err := os.Stat(hostPath) + if err != nil { + return "", fmt.Errorf("persona path %q not found: %w", hostPath, err) + } + if !info.IsDir() { + return "", fmt.Errorf("persona path %q is not a directory", ref) + } + + realHostPath, err := filepath.EvalSymlinks(hostPath) + if err != nil { + return "", fmt.Errorf("resolve real persona path %q: %w", ref, err) + } + if !strings.HasPrefix(realHostPath, realBase+string(filepath.Separator)) && realHostPath != realBase { + return "", fmt.Errorf("persona path %q escapes base directory %q", ref, baseDir) + } + return realHostPath, nil +} + +func copyDir(srcDir, dstDir string) error { + entries, err := os.ReadDir(srcDir) + if err != nil { + return fmt.Errorf("read persona dir %q: %w", srcDir, err) + } + for _, entry := range entries { + srcPath := filepath.Join(srcDir, entry.Name()) + dstPath := filepath.Join(dstDir, entry.Name()) + + info, err := entry.Info() + if err != nil { + return fmt.Errorf("stat persona entry %q: %w", srcPath, err) + } + if info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("persona path %q contains unsupported symlink %q", srcDir, srcPath) + } + if entry.IsDir() { + if err := os.MkdirAll(dstPath, info.Mode().Perm()); err != nil { + return fmt.Errorf("create persona directory %q: %w", dstPath, err) + } + if err := copyDir(srcPath, dstPath); err != nil { + return err + } + continue + } + if !info.Mode().IsRegular() { + return fmt.Errorf("persona path %q contains unsupported file type at %q", srcDir, srcPath) + } + if err := copyFile(srcPath, dstPath, info.Mode().Perm()); err != nil { + return err + } + } + return nil +} + +func copyFile(srcPath, dstPath string, mode os.FileMode) error { + src, err := os.Open(srcPath) + if err != nil { + return fmt.Errorf("open persona file %q: %w", srcPath, err) + } + defer src.Close() + + dst, err := os.OpenFile(dstPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode) + if err != nil { + return fmt.Errorf("create persona file %q: %w", dstPath, err) + } + + if _, err := io.Copy(dst, src); err != nil { + dst.Close() + return fmt.Errorf("copy persona file %q: %w", srcPath, err) + } + if err := dst.Close(); err != nil { + return fmt.Errorf("close persona file %q: %w", dstPath, err) + } + return nil +} + +func pullRemotePersona(ctx context.Context, targetDir, ref string) error { + parsed, err := registry.ParseReference(ref) + if err != nil { + return fmt.Errorf("parse persona reference %q: %w", ref, err) + } + + repo, err := remote.NewRepository(parsed.Registry + "/" + parsed.Repository) + if err != nil { + return fmt.Errorf("create persona repository %q: %w", ref, err) + } + + if store, err := credentials.NewStoreFromDocker(credentials.StoreOptions{}); err == nil { + repo.Client = &auth.Client{ + Credential: credentials.Credential(store), + Cache: auth.NewCache(), + } + } + + fs, err := file.New(targetDir) + if err != nil { + return fmt.Errorf("create persona file store: %w", err) + } + defer fs.Close() + fs.IgnoreNoName = true + fs.PreservePermissions = true + + if _, err := oras.Copy(ctx, repo, parsed.ReferenceOrDefault(), fs, "latest", oras.DefaultCopyOptions); err != nil { + return fmt.Errorf("pull persona %q: %w", ref, err) + } + return nil +} diff --git a/internal/persona/materialize_test.go b/internal/persona/materialize_test.go new file mode 100644 index 0000000..f8c684c --- /dev/null +++ b/internal/persona/materialize_test.go @@ -0,0 +1,65 @@ +package persona + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestMaterializeLocalPersonaCopiesDirectory(t *testing.T) { + baseDir := t.TempDir() + srcDir := filepath.Join(baseDir, "personas", "allen") + if err := os.MkdirAll(filepath.Join(srcDir, "history"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(srcDir, "style.md"), []byte("dry humor\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(srcDir, "history", "memory.txt"), []byte("first trade\n"), 0o644); err != nil { + t.Fatal(err) + } + + runtimeDir := filepath.Join(baseDir, ".claw-runtime", "bot") + if err := os.MkdirAll(runtimeDir, 0o755); err != nil { + t.Fatal(err) + } + + resolved, err := Materialize(baseDir, runtimeDir, "./personas/allen") + if err != nil { + t.Fatalf("Materialize: %v", err) + } + if resolved == nil { + t.Fatal("expected resolved persona") + } + + stylePath := filepath.Join(resolved.HostPath, "style.md") + got, err := os.ReadFile(stylePath) + if err != nil { + t.Fatalf("read materialized style file: %v", err) + } + if string(got) != "dry humor\n" { + t.Fatalf("unexpected style content: %q", string(got)) + } + + memoryPath := filepath.Join(resolved.HostPath, "history", "memory.txt") + if _, err := os.Stat(memoryPath); err != nil { + t.Fatalf("expected nested persona file to be copied: %v", err) + } +} + +func TestMaterializeRejectsEscapingLocalPersonaPath(t *testing.T) { + baseDir := t.TempDir() + runtimeDir := filepath.Join(baseDir, ".claw-runtime", "bot") + if err := os.MkdirAll(runtimeDir, 0o755); err != nil { + t.Fatal(err) + } + + _, err := Materialize(baseDir, runtimeDir, "../elsewhere") + if err == nil { + t.Fatal("expected escape error") + } + if !strings.Contains(err.Error(), "escapes base directory") { + t.Fatalf("unexpected error: %v", err) + } +}