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:
parent
b6e97d04ec
commit
2449c7466f
5 changed files with 128 additions and 4 deletions
64
docker/Dockerfile.launcher
Normal file
64
docker/Dockerfile.launcher
Normal 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"]
|
||||
|
|
@ -49,6 +49,7 @@ The same formats apply to both `api_key` (singular) and individual elements in t
|
|||
| Plaintext | `sk-abc123` | Used as-is |
|
||||
| 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` |
|
||||
| Env var | `env://OPENROUTER_API_KEY` | Value read from the named environment variable at startup |
|
||||
| Empty | `""` | Passed through unchanged (used with `auth_method: oauth`) |
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
// - Plaintext: "sk-abc123" → returned as-is
|
||||
// - File ref: "file://filename.key" → content read from configDir/filename.key
|
||||
// - 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.)
|
||||
//
|
||||
// Encryption uses AES-256-GCM with HKDF-SHA256 key derivation (< 1ms, safe for embedded Linux).
|
||||
|
|
@ -77,6 +78,7 @@ const picoclawHome = "PICOCLAW_HOME"
|
|||
const (
|
||||
FileScheme = "file://"
|
||||
EncScheme = "enc://"
|
||||
envScheme = "env://"
|
||||
|
||||
hkdfInfo = "picoclaw-credential-v1"
|
||||
saltLen = 16
|
||||
|
|
@ -107,6 +109,7 @@ func NewResolver(configDir string) *Resolver {
|
|||
//
|
||||
// - "" → "" (no error; auth_method=oauth needs no key)
|
||||
// - "file://name.key" → trimmed content of configDir/name.key
|
||||
// - "env://VAR_NAME" → value of the named environment variable
|
||||
// - anything else → raw unchanged (plaintext credential)
|
||||
func (r *Resolver) Resolve(raw string) (string, error) {
|
||||
if raw == "" {
|
||||
|
|
@ -149,6 +152,21 @@ func (r *Resolver) Resolve(raw string) (string, error) {
|
|||
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.
|
||||
return raw, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -281,3 +281,40 @@ func TestEncrypt_SSHKeyOutsideAllowedDirs(t *testing.T) {
|
|||
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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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?=CGO_ENABLED=0 go
|
||||
|
|
@ -106,6 +106,10 @@ build-dev-picoclaw:
|
|||
@mkdir -p "$$(dirname "$(PICOCLAW_BINARY)")"
|
||||
@$(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
|
||||
test:
|
||||
cd $(BACKEND_DIR) && ${WEB_GO} test ./...
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue