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 |
|
| 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`) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -107,6 +109,7 @@ func NewResolver(configDir string) *Resolver {
|
||||||
//
|
//
|
||||||
// - "" → "" (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
|
||||||
|
// - "env://VAR_NAME" → value of the named environment variable
|
||||||
// - anything else → raw unchanged (plaintext credential)
|
// - 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 == "" {
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 ./...
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue