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
10 changes: 10 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,16 @@ __pycache__/
# Node
node_modules/

# E2E runtime artifacts
/tests/e2e/.bin/
/tests/e2e/logs/
/tests/e2e/results/
/tests/e2e/*.jsonl
/tests/e2e/*.log
/tests/e2e/*.out
/tests/e2e/*.pid
/tests/e2e/*.tap

# Local agent guidance (private)
/AGENTS.md
/CLAUDE.md
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ Phoenix is built for the people actually running agents today — self-hosted, h
- **Reference-first workflows** — `phoenix://` URIs replace raw values in configs and scripts
- **Exec credential stripping** — `phoenix exec` injects secrets and strips broker credentials from the child process
- **Works with existing setups** — `phoenix import` pulls from `.env` files in one command; 1Password users can migrate secrets or broker reads at runtime without moving anything
- **Agent-native integrations** — built-in MCP server for Claude Code / Claude Desktop, Python/Go/TypeScript SDKs, OpenClaw exec backend, direct HTTP API
- **Agent-native integrations** — built-in MCP server for Claude Code / Claude Desktop, Python/Go/TypeScript SDKs, OpenClaw SecretRef exec provider and plugin-tool path, direct HTTP API
- **Emergency offline access** — break-glass secret retrieval directly from disk when the server is down

Two binaries (`phoenix` client + `phoenix-server`), single codebase, no external runtime dependencies.
Expand Down
41 changes: 41 additions & 0 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,46 @@
# Release Notes

## v0.15.2 (2026-07-12)

### OpenClaw Exec Provider Merge-Review Fixes

- Session mint with `PHOENIX_ROLE` no longer fails when the home directory
cannot be determined and no `PHOENIX_SEAL_KEY` is set; the CLI now mints an
unsealed session, matching the missing-key-file behavior.
- The exec provider short-circuits an empty `ids` list to an empty response
without calling the resolve API.
- The exec provider rejects trailing non-whitespace data after the stdin JSON
request object.
- Audit metadata sanitization now strips Unicode format/bidi control
characters (category Cf, e.g. U+202E) from `X-OpenClaw-*` header values.
- Added tests for the sealed-envelope decrypt branch, non-200 server
responses in the exec-provider path, missing-HOME session mint, and
format-control sanitization.
- Documented the three exec-provider auth modes (bootstrap token + role,
mTLS + role, pre-minted session token) and the invalid combinations,
added `HOME` to the recommended `passEnv` example, documented the
audit-only `X-OpenClaw-*` metadata headers, and noted that signed resolve
is not supported in the exec-provider path. `phoenix openclaw-exec-provider`
is now documented as the canonical command form.

## v0.15.1 (2026-06-17)

### OpenClaw Audit Metadata

- Captures sanitized allowlisted `X-OpenClaw-*` metadata headers on server audit entries as audit hints only.
- Omits raw `X-OpenClaw-Session-Key` from audit records.
- Added tests proving spoofed OpenClaw headers do not affect Phoenix authorization, attestation policy identity, or sealed-response policy decisions.

## v0.15.0 (2026-04-27)

### OpenClaw SecretRef Exec Provider

- Added `phoenix openclaw-exec-provider` for OpenClaw's built-in exec SecretRef provider protocol.
- Added `phoenix secret-provider openclaw` and `phoenix resolve --stdin-json` aliases for compatibility with OpenClaw exec-provider configuration.
- The provider reads OpenClaw's stdin JSON request, resolves requested ids through Phoenix using the existing auth/session/mTLS/sealed-response paths, and writes OpenClaw-compatible `values`/`errors` JSON to stdout.
- Added tests for request parsing, stdout response shape, id normalization, and partial per-id failures.
- Updated OpenClaw integration docs to distinguish bootstrap/config SecretRefs from runtime `openclaw-phoenix` plugin tools, and to stop claiming plain `phoenix resolve <ref>` is the exec-provider protocol.

## v0.13.5 (2026-04-07)

### Step-Up Authorization Hygiene
Expand Down
196 changes: 194 additions & 2 deletions cmd/phoenix/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
// phoenix agent create <name> --token <token> --acl <path:actions,...> [--force]
// phoenix agent list
// phoenix resolve <ref> [ref...]
// phoenix resolve --stdin-json
// phoenix openclaw-exec-provider
// phoenix exec --env KEY=phoenix://ns/secret [--output-env <path>] [--timeout <dur>] [--mask-env] -- <command> [args...]
// phoenix verify <file> [--dry-run]
// phoenix status
Expand Down Expand Up @@ -162,6 +164,20 @@ func main() {
}
case "resolve":
err = cmdResolve(args)
case "openclaw-exec-provider":
err = cmdOpenClawExecProvider(args)
case "secret-provider":
if len(args) < 1 {
fmt.Fprintln(os.Stderr, "usage: phoenix secret-provider <openclaw>")
os.Exit(1)
}
switch args[0] {
case "openclaw":
err = cmdOpenClawExecProvider(args[1:])
default:
fmt.Fprintf(os.Stderr, "unknown secret-provider subcommand: %s\n", args[0])
os.Exit(1)
}
case "exec":
err = cmdExec(args)
case "verify":
Expand Down Expand Up @@ -322,9 +338,13 @@ Usage:
phoenix import --from 1password --vault <v> --prefix <p> [--item <name>] [--dry-run] [--skip-existing]
phoenix audit [-n N] [-a agent] [-s time] Query audit log
phoenix agent create <name> -t <token> --acl <path:actions;path:actions> [--force]
ACL path globs: ns/* = one level, ns/** = recursive
phoenix agent list List agents
phoenix agent delete <name> Delete an agent
phoenix resolve [--signed] <ref> [ref...] Resolve phoenix:// references to values
phoenix resolve --stdin-json OpenClaw SecretRef exec provider (stdin/stdout JSON)
phoenix openclaw-exec-provider OpenClaw SecretRef exec provider (stdin/stdout JSON)
phoenix secret-provider openclaw Alias for openclaw-exec-provider
phoenix exec --env K=phoenix://n/s -- cmd Run command with resolved secrets as env
phoenix exec --output-env <file> --env ... Write resolved env to file (no exec)
phoenix exec --timeout 5s --env ... Fail if resolution exceeds duration
Expand Down Expand Up @@ -1018,7 +1038,178 @@ func cmdAgentDelete(args []string) error {
return nil
}

type openClawExecProviderRequest struct {
ProtocolVersion int `json:"protocolVersion"`
Provider string `json:"provider"`
IDs []string `json:"ids"`
}

type openClawExecProviderError struct {
Message string `json:"message"`
}

type openClawExecProviderResponse struct {
ProtocolVersion int `json:"protocolVersion"`
Values map[string]string `json:"values"`
Errors map[string]openClawExecProviderError `json:"errors,omitempty"`
}

func parseOpenClawExecProviderRequest(r io.Reader) (*openClawExecProviderRequest, error) {
var req openClawExecProviderRequest
dec := json.NewDecoder(r)
if err := dec.Decode(&req); err != nil {
return nil, fmt.Errorf("decoding OpenClaw exec provider request: %w", err)
}
if _, err := dec.Token(); err != io.EOF {
return nil, fmt.Errorf("OpenClaw exec provider request contains trailing data")
}
if req.ProtocolVersion != 1 {
return nil, fmt.Errorf("OpenClaw exec provider protocolVersion must be 1")
}
if req.Provider == "" {
return nil, fmt.Errorf("OpenClaw exec provider request missing provider")
}
if req.Provider != "phoenix" {
return nil, fmt.Errorf("OpenClaw exec provider request provider must be phoenix")
}
if req.IDs == nil {
return nil, fmt.Errorf("OpenClaw exec provider request missing ids")
}
for _, id := range req.IDs {
if strings.TrimSpace(id) == "" {
return nil, fmt.Errorf("OpenClaw exec provider request contains empty id")
}
}
return &req, nil
}

func openClawIDToPhoenixRef(id string) string {
trimmed := strings.TrimSpace(id)
if strings.HasPrefix(trimmed, "phoenix://") {
return trimmed
}
return "phoenix://" + strings.TrimPrefix(trimmed, "/")
}

func cmdOpenClawExecProvider(args []string) error {
if len(args) != 0 {
return fmt.Errorf("usage: phoenix openclaw-exec-provider")
}
if err := requireAuth(); err != nil {
return err
}

req, err := parseOpenClawExecProviderRequest(os.Stdin)
if err != nil {
return err
}

if len(req.IDs) == 0 {
enc := json.NewEncoder(os.Stdout)
return enc.Encode(openClawExecProviderResponse{
ProtocolVersion: 1,
Values: make(map[string]string),
})
}

refs := make([]string, 0, len(req.IDs))
seenRefs := make(map[string]bool, len(req.IDs))
for _, id := range req.IDs {
ref := openClawIDToPhoenixRef(id)
if !seenRefs[ref] {
refs = append(refs, ref)
seenRefs[ref] = true
}
}

sealPrivKey, err := loadSealKeyForRequest()
if err != nil {
return fmt.Errorf("loading seal key: %w", err)
}

body, _ := json.Marshal(map[string]interface{}{"refs": refs})
resp, err := apiRequestWithHeaders("POST", "/v1/resolve", strings.NewReader(string(body)), sealHeaders(sealPrivKey))
if err != nil {
return fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return handleError(resp)
}

values := make(map[string]string)
var errs map[string]string
if sealPrivKey != nil {
var result struct {
SealedValues map[string]interface{} `json:"sealed_values"`
Errors map[string]string `json:"errors"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return fmt.Errorf("decoding response: %w", err)
}
errs = result.Errors
for ref, raw := range result.SealedValues {
if envMap, ok := raw.(map[string]interface{}); ok {
if envRef, _ := envMap["ref"].(string); envRef != ref {
return fmt.Errorf("sealed envelope ref mismatch: map key %q, envelope %q", ref, envRef)
}
}
val, err := decryptSealedValue(raw, sealPrivKey)
if err != nil {
return fmt.Errorf("decrypting %s: %w", ref, err)
}
values[ref] = val
}
} else {
var result struct {
Values map[string]string `json:"values"`
Errors map[string]string `json:"errors"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return fmt.Errorf("decoding response: %w", err)
}
values = result.Values
errs = result.Errors
}

out := openClawExecProviderResponse{
ProtocolVersion: 1,
Values: make(map[string]string),
}
for _, id := range req.IDs {
ref := openClawIDToPhoenixRef(id)
if errMsg, ok := errs[ref]; ok {
if out.Errors == nil {
out.Errors = make(map[string]openClawExecProviderError)
}
out.Errors[id] = openClawExecProviderError{Message: errMsg}
continue
}
val, ok := values[ref]
if !ok {
if out.Errors == nil {
out.Errors = make(map[string]openClawExecProviderError)
}
out.Errors[id] = openClawExecProviderError{Message: "no value returned by Phoenix"}
continue
}
out.Values[id] = val
}

enc := json.NewEncoder(os.Stdout)
return enc.Encode(out)
}

func cmdResolve(args []string) error {
if len(args) == 1 && args[0] == "--stdin-json" {
return cmdOpenClawExecProvider(nil)
}
for _, arg := range args {
if arg == "--stdin-json" {
return fmt.Errorf("usage: phoenix resolve --stdin-json")
}
}

if err := requireAuth(); err != nil {
return err
}
Expand Down Expand Up @@ -2183,10 +2374,11 @@ func ensureSealKey(role string) (*[32]byte, string, error) {
return priv, crypto.EncodeSealKey(pub), nil
}

// Check per-role key file
// Check per-role key file. If the home directory cannot be determined,
// treat it the same as no key file: mint without seal binding.
home, err := os.UserHomeDir()
if err != nil {
return nil, "", fmt.Errorf("cannot determine home directory: %w", err)
return nil, "", nil
}
keyDir := filepath.Join(home, ".phoenix")
keyPath := filepath.Join(keyDir, "session-seal-"+role+".key")
Expand Down
Loading