diff --git a/docker/Dockerfile.launcher b/docker/Dockerfile.launcher new file mode 100644 index 000000000..67858ad1d --- /dev/null +++ b/docker/Dockerfile.launcher @@ -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"] diff --git a/docs/credential_encryption.md b/docs/credential_encryption.md index 54c2ee5f9..04b59c000 100644 --- a/docs/credential_encryption.md +++ b/docs/credential_encryption.md @@ -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://` | 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`) | --- diff --git a/pkg/credential/credential.go b/pkg/credential/credential.go index 8ecd6783b..5a1466223 100644 --- a/pkg/credential/credential.go +++ b/pkg/credential/credential.go @@ -9,6 +9,7 @@ // - Plaintext: "sk-abc123" → returned as-is // - File ref: "file://filename.key" → content read from configDir/filename.key // - Encrypted: "enc://" → 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 @@ -105,9 +107,10 @@ func NewResolver(configDir string) *Resolver { // Resolve returns the actual credential value for raw: // -// - "" → "" (no error; auth_method=oauth needs no key) -// - "file://name.key" → trimmed content of configDir/name.key -// - anything else → raw unchanged (plaintext credential) +// - "" → "" (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 == "" { return "", nil @@ -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 } diff --git a/pkg/credential/credential_test.go b/pkg/credential/credential_test.go index 138af3134..4d4de6852 100644 --- a/pkg/credential/credential_test.go +++ b/pkg/credential/credential_test.go @@ -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") + } +} diff --git a/web/Makefile b/web/Makefile index 891c170c2..c45402ba5 100644 --- a/web/Makefile +++ b/web/Makefile @@ -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 ./...