feat(credential): add env:// scheme for environment variable API keys

Allows api_key values like `env://OPENROUTER_API_KEY` to be resolved
from the process environment at startup, useful for container and CI
deployments where secrets are injected via env vars.

Also adds build-go target to web/Makefile, BUILD_TARGET arg to
docker/Dockerfile, and a new Dockerfile.launcher for building both
picoclaw and picoclaw-launcher into a single image.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sushi30 2026-03-25 08:03:15 +01:00 committed by github-actions[bot]
parent b6e97d04ec
commit 2449c7466f
5 changed files with 128 additions and 4 deletions

View file

@ -0,0 +1,64 @@
# ============================================================
# Stage 1: Build picoclaw binary (with WhatsApp native support)
# ============================================================
FROM golang:1.25-alpine AS picoclaw-builder
RUN apk add --no-cache git make
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
ARG BUILD_TARGET=build-whatsapp-native-docker
RUN make ${BUILD_TARGET}
# ============================================================
# Stage 2: Build picoclaw-launcher frontend
# ============================================================
FROM node:20-alpine AS frontend-builder
RUN npm install -g pnpm
WORKDIR /src/web/frontend
COPY web/frontend/package.json web/frontend/pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY web/frontend/ ./
RUN pnpm build:backend
# ============================================================
# Stage 3: Build picoclaw-launcher backend (embeds frontend)
# ============================================================
FROM golang:1.25-alpine AS launcher-builder
RUN apk add --no-cache git make
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
COPY --from=frontend-builder /src/web/backend/dist /src/web/backend/dist
RUN cd web && make build-go
# ============================================================
# Stage 4: Minimal runtime image
# ============================================================
FROM alpine:3.23
RUN apk add --no-cache ca-certificates tzdata curl
COPY --from=picoclaw-builder /src/build/picoclaw /usr/local/bin/picoclaw
COPY --from=launcher-builder /src/web/build/picoclaw-launcher /usr/local/bin/picoclaw-launcher
RUN addgroup -g 1000 picoclaw && \
adduser -D -u 1000 -G picoclaw picoclaw
USER picoclaw
RUN /usr/local/bin/picoclaw onboard
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD wget -q --spider http://localhost:18800/ || exit 1
ENTRYPOINT ["picoclaw-launcher"]
CMD ["-public", "-no-browser", "-port", "18800"]

View file

@ -49,6 +49,7 @@ The same formats apply to both `api_key` (singular) and individual elements in t
| Plaintext | `sk-abc123` | Used as-is | | Plaintext | `sk-abc123` | Used as-is |
| File reference | `file://openai.key` | Content read from the same directory as the config file | | File reference | `file://openai.key` | Content read from the same directory as the config file |
| Encrypted | `enc://<base64>` | Decrypted at startup using `PICOCLAW_KEY_PASSPHRASE` | | Encrypted | `enc://<base64>` | Decrypted at startup using `PICOCLAW_KEY_PASSPHRASE` |
| Env var | `env://OPENROUTER_API_KEY` | Value read from the named environment variable at startup |
| Empty | `""` | Passed through unchanged (used with `auth_method: oauth`) | | Empty | `""` | Passed through unchanged (used with `auth_method: oauth`) |
--- ---

View file

@ -9,6 +9,7 @@
// - Plaintext: "sk-abc123" → returned as-is // - Plaintext: "sk-abc123" → returned as-is
// - File ref: "file://filename.key" → content read from configDir/filename.key // - File ref: "file://filename.key" → content read from configDir/filename.key
// - Encrypted: "enc://<base64>" → AES-256-GCM decrypt via PICOCLAW_KEY_PASSPHRASE // - Encrypted: "enc://<base64>" → AES-256-GCM decrypt via PICOCLAW_KEY_PASSPHRASE
// - Env var: "env://VAR_NAME" → value of the named environment variable
// - Empty: "" → returned as-is (auth_method=oauth etc.) // - Empty: "" → returned as-is (auth_method=oauth etc.)
// //
// Encryption uses AES-256-GCM with HKDF-SHA256 key derivation (< 1ms, safe for embedded Linux). // Encryption uses AES-256-GCM with HKDF-SHA256 key derivation (< 1ms, safe for embedded Linux).
@ -77,6 +78,7 @@ const picoclawHome = "PICOCLAW_HOME"
const ( const (
FileScheme = "file://" FileScheme = "file://"
EncScheme = "enc://" EncScheme = "enc://"
envScheme = "env://"
hkdfInfo = "picoclaw-credential-v1" hkdfInfo = "picoclaw-credential-v1"
saltLen = 16 saltLen = 16
@ -105,9 +107,10 @@ func NewResolver(configDir string) *Resolver {
// Resolve returns the actual credential value for raw: // Resolve returns the actual credential value for raw:
// //
// - "" → "" (no error; auth_method=oauth needs no key) // - "" → "" (no error; auth_method=oauth needs no key)
// - "file://name.key" → trimmed content of configDir/name.key // - "file://name.key" → trimmed content of configDir/name.key
// - anything else → raw unchanged (plaintext credential) // - "env://VAR_NAME" → value of the named environment variable
// - anything else → raw unchanged (plaintext credential)
func (r *Resolver) Resolve(raw string) (string, error) { func (r *Resolver) Resolve(raw string) (string, error) {
if raw == "" { if raw == "" {
return "", nil return "", nil
@ -149,6 +152,21 @@ func (r *Resolver) Resolve(raw string) (string, error) {
return resolveEncrypted(raw) return resolveEncrypted(raw)
} }
if strings.HasPrefix(raw, envScheme) {
varName := strings.TrimPrefix(raw, envScheme)
if varName == "" {
return "", fmt.Errorf("credential: env:// reference has no variable name")
}
val, ok := os.LookupEnv(varName)
if !ok {
return "", fmt.Errorf("credential: env:// variable %q is not set", varName)
}
if val == "" {
return "", fmt.Errorf("credential: env:// variable %q is empty", varName)
}
return val, nil
}
// Plaintext credential — return unchanged. // Plaintext credential — return unchanged.
return raw, nil return raw, nil
} }

View file

@ -281,3 +281,40 @@ func TestEncrypt_SSHKeyOutsideAllowedDirs(t *testing.T) {
t.Fatal("expected error for SSH key outside allowed directories, got nil") t.Fatal("expected error for SSH key outside allowed directories, got nil")
} }
} }
func TestResolve_EnvVar_Success(t *testing.T) {
t.Setenv("PICOCLAW_TEST_API_KEY", "sk-from-env")
r := credential.NewResolver(t.TempDir())
got, err := r.Resolve("env://PICOCLAW_TEST_API_KEY")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "sk-from-env" {
t.Fatalf("got %q, want %q", got, "sk-from-env")
}
}
func TestResolve_EnvVar_NotSet(t *testing.T) {
r := credential.NewResolver(t.TempDir())
_, err := r.Resolve("env://PICOCLAW_TEST_UNSET_VAR_XYZ")
if err == nil {
t.Fatal("expected error for unset env var, got nil")
}
}
func TestResolve_EnvVar_Empty(t *testing.T) {
t.Setenv("PICOCLAW_TEST_EMPTY_KEY", "")
r := credential.NewResolver(t.TempDir())
_, err := r.Resolve("env://PICOCLAW_TEST_EMPTY_KEY")
if err == nil {
t.Fatal("expected error for empty env var, got nil")
}
}
func TestResolve_EnvVar_NoVarName(t *testing.T) {
r := credential.NewResolver(t.TempDir())
_, err := r.Resolve("env://")
if err == nil {
t.Fatal("expected error for env:// with no variable name, got nil")
}
}

View file

@ -1,4 +1,4 @@
.PHONY: dev dev-frontend dev-backend build build-frontend build-dev-picoclaw test lint clean .PHONY: dev dev-frontend dev-backend build build-frontend build-dev-picoclaw build-go test lint clean
# Go variables # Go variables
GO?=CGO_ENABLED=0 go GO?=CGO_ENABLED=0 go
@ -106,6 +106,10 @@ build-dev-picoclaw:
@mkdir -p "$$(dirname "$(PICOCLAW_BINARY)")" @mkdir -p "$$(dirname "$(PICOCLAW_BINARY)")"
@$(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o "$(PICOCLAW_BINARY)" ../cmd/picoclaw @$(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o "$(PICOCLAW_BINARY)" ../cmd/picoclaw
# Build only the Go binary (frontend dist must already exist)
build-go:
${WEB_GO} build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/picoclaw-launcher ./backend/
# Run all tests # Run all tests
test: test:
cd $(BACKEND_DIR) && ${WEB_GO} test ./... cd $(BACKEND_DIR) && ${WEB_GO} test ./...