Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
|-------|--------|
Expand Down
69 changes: 64 additions & 5 deletions cmd/claw/compose_up.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand All @@ -37,6 +38,7 @@ var (
dockerBuildTaggedImage = dockerBuildTaggedImageDefault
findClawdapusRepoRoot = findRepoRoot
runInfraDockerCommand = runInfraDockerCommandDefault
runComposeDockerCommand = runComposeDockerCommandDefault
)

var composeUpCmd = &cobra.Command{
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand All @@ -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 {
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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()
Expand Down
32 changes: 32 additions & 0 deletions cmd/claw/compose_up_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand Down
122 changes: 122 additions & 0 deletions docs/reviews/persona-runtime-status-2026-03-08.md
Original file line number Diff line number Diff line change
@@ -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 <ref>` in a Clawfile is still emitted as `LABEL claw.persona.default=<ref>`.
- `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/<service>/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
6 changes: 4 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -22,17 +24,17 @@ 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
go.opentelemetry.io/otel v1.21.0 // indirect
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
)
11 changes: 7 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down Expand Up @@ -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=
Expand Down Expand Up @@ -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=
Loading