From 24d26365e6403fd8add79fec45ed63f2fb20381e Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Fri, 3 Jul 2026 13:37:12 -0400 Subject: [PATCH] refactor(cli): manage environment variables with effect config --- packages/cli/src/commands/handlers/default.ts | 9 ++++++--- packages/cli/src/commands/handlers/serve.ts | 11 ++++++++--- packages/cli/src/env.ts | 18 ++++++++++++++++++ 3 files changed, 32 insertions(+), 6 deletions(-) create mode 100644 packages/cli/src/env.ts diff --git a/packages/cli/src/commands/handlers/default.ts b/packages/cli/src/commands/handlers/default.ts index addafa634fc..5521af8fe65 100644 --- a/packages/cli/src/commands/handlers/default.ts +++ b/packages/cli/src/commands/handlers/default.ts @@ -1,8 +1,9 @@ import { NodeFileSystem } from "@effect/platform-node" import { Commands } from "../commands" import { Runtime } from "../../framework/runtime" -import { Effect, Option } from "effect" +import { Effect, Option, Redacted } from "effect" import { Service } from "@opencode-ai/client/effect" +import { Env } from "../../env" import { ServiceConfig } from "../../services/service-config" import { Standalone } from "../../services/standalone" import { Updater } from "../../services/updater" @@ -18,10 +19,12 @@ export default Runtime.handler(Commands, (input) => return yield* Effect.fail(new Error("--server and --standalone cannot be combined")) const transport = yield* Effect.gen(function* () { if (server !== undefined) { - const password = process.env["OPENCODE_PASSWORD"] + const password = Option.getOrUndefined(yield* Env.password) const explicit = { url: server, - headers: password ? { authorization: "Basic " + btoa("opencode:" + password) } : undefined, + headers: password + ? { authorization: "Basic " + btoa("opencode:" + Redacted.value(password)) } + : undefined, } satisfies Service.Transport // Fail loudly before entering the TUI: an explicit server that is // unreachable or rejects auth should not present as reconnect churn. diff --git a/packages/cli/src/commands/handlers/serve.ts b/packages/cli/src/commands/handlers/serve.ts index d6b6938ee32..2b00b297a34 100644 --- a/packages/cli/src/commands/handlers/serve.ts +++ b/packages/cli/src/commands/handlers/serve.ts @@ -4,7 +4,7 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { PermissionSaved } from "@opencode-ai/core/permission/saved" import { Global } from "@opencode-ai/core/global" -import { Context, FileSystem, Layer, Option, Schedule, Schema } from "effect" +import { Context, FileSystem, Layer, Option, Redacted, Schedule, Schema } from "effect" import * as Effect from "effect/Effect" import { HttpRouter, HttpServer } from "effect/unstable/http" import { createServer } from "node:http" @@ -15,6 +15,7 @@ import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" import { Commands } from "../commands" import { Runtime } from "../../framework/runtime" import { Service } from "@opencode-ai/client/effect" +import { Env } from "../../env" import { ServiceConfig } from "../../services/service-config" import { Updater } from "../../services/updater" import { randomBytes, randomUUID } from "crypto" @@ -26,12 +27,16 @@ export default Runtime.handler( if (input.service) yield* Effect.sync(() => process.chdir(Global.Path.home)) return yield* Effect.scoped( Effect.gen(function* () { - const standalonePassword = process.env.OPENCODE_SERVER_PASSWORD + const standalonePassword = Option.getOrUndefined(yield* Env.serverPassword) + // Keep the lease credential out of the environment inherited by any + // process this server spawns. if (input.stdio) delete process.env.OPENCODE_SERVER_PASSWORD const config = input.service ? yield* ServiceConfig.read() : {} const password = input.service ? yield* ServiceConfig.password() - : standalonePassword || randomBytes(32).toString("base64url") + : standalonePassword + ? Redacted.value(standalonePassword) + : randomBytes(32).toString("base64url") if (!password) return yield* Effect.fail(new Error("Missing server password")) const hostname = Option.getOrUndefined(input.hostname) ?? config.hostname ?? "127.0.0.1" const port = Option.isSome(input.port) diff --git a/packages/cli/src/env.ts b/packages/cli/src/env.ts new file mode 100644 index 00000000000..914abac644b --- /dev/null +++ b/packages/cli/src/env.ts @@ -0,0 +1,18 @@ +import { Config } from "effect" + +// Every environment variable the CLI reads, in one place. Consumers yield +// these instead of touching process.env so the full surface stays visible, +// typed, and redacted where secret. + +// Client-side password for an explicit --server target. The legacy name is +// still honored; it also remains the variable a standalone child inherits. +export const password = Config.redacted("OPENCODE_PASSWORD").pipe( + Config.orElse(() => Config.redacted("OPENCODE_SERVER_PASSWORD")), + Config.option, +) + +// Server-side lease password: set by the standalone spawner for its child, +// or preset for a manually managed `opencode serve`. +export const serverPassword = Config.redacted("OPENCODE_SERVER_PASSWORD").pipe(Config.option) + +export * as Env from "./env"