Compare commits

...

3 Commits

Author SHA1 Message Date
Dax Raad 9f2bd92fea fix(cli): allow no-auth services
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-08-17 14:02:28 +00:00
Dax Raad 4c3039677a fix(cli): scope no-auth to manual servers
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-08-17 13:48:35 +00:00
Dax Raad 4efaa4c6b9 feat(cli): add no-auth serve option
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-08-17 13:46:19 +00:00
6 changed files with 44 additions and 12 deletions
+4
View File
@@ -272,6 +272,10 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
params: {
hostname: Flag.string("hostname").pipe(Flag.optional),
port: Flag.integer("port").pipe(Flag.optional),
noAuth: Flag.boolean("no-auth").pipe(
Flag.withDescription("Disable server authentication"),
Flag.withDefault(false),
),
service: Flag.boolean("service").pipe(Flag.withDefault(false)),
stdio: Flag.boolean("stdio").pipe(Flag.withDefault(false)),
},
@@ -11,6 +11,7 @@ export default Runtime.handler(
mode: input.service ? "service" : input.stdio ? "stdio" : "default",
hostname: Option.getOrUndefined(input.hostname),
port: Option.getOrUndefined(input.port),
noAuth: input.noAuth,
})
}),
)
+4
View File
@@ -12,4 +12,8 @@ export const password = Config.redacted("OPENCODE_PASSWORD").pipe(
Config.withDefault(undefined),
)
// Whether servers require authentication. Prefer a positive environment
// variable so deployments can opt out with an explicit `false`.
export const auth = Config.boolean("OPENCODE_AUTH").pipe(Config.withDefault(true))
export * as Env from "./env"
+9 -7
View File
@@ -21,6 +21,7 @@ export type Options = {
readonly mode: Mode
readonly hostname?: string
readonly port?: number
readonly noAuth?: boolean
}
// The process effect lives until server shutdown; tracing it would parent every request to one process-lifetime trace.
@@ -56,18 +57,19 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
if (incumbent !== undefined) return
const { start } = yield* Effect.promise(() => import("@opencode-ai/server/process"))
const environmentPassword = yield* Env.password
const auth = !options.noAuth && (yield* Env.auth)
// Keep the lease credential out of the environment inherited by tools.
if (options.mode === "stdio") {
delete process.env.OPENCODE_PASSWORD
delete process.env.OPENCODE_SERVER_PASSWORD
}
const password =
options.mode === "service"
const password = !auth
? undefined
: options.mode === "service"
? config.password || randomBytes(32).toString("base64url")
: environmentPassword
? Redacted.value(environmentPassword)
: randomBytes(32).toString("base64url")
if (!password) return yield* Effect.fail(new Error("Missing server password"))
const instanceID = randomUUID()
const transform = yield* WebUi.handler()
const server = yield* start(
@@ -120,7 +122,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
instanceID,
onListen: (address, shutdown) =>
Effect.gen(function* () {
if (!config.password) yield* ServiceConfig.password(password)
if (password && !config.password) yield* ServiceConfig.password(password)
return yield* register(address, password, instanceID, serviceOptions.file, shutdown)
}),
},
@@ -146,7 +148,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
if (server === undefined) return
const url = HttpServer.formatAddress(server.address)
console.log(options.mode === "stdio" ? JSON.stringify({ url }) : `server listening on ${url}`)
if (foreground && !environmentPassword) console.log(`server password ${password}`)
if (foreground && password && !environmentPassword) console.log(`server password ${password}`)
const updater = yield* Updater.Service
yield* updater.check().pipe(Effect.schedule(Schedule.spaced("10 minutes")), Effect.forkScoped)
return yield* options.mode === "service"
@@ -164,7 +166,7 @@ const decodeInfo = Schema.decodeUnknownEffect(infoJson)
const register = Effect.fnUntraced(function* (
address: HttpServer.Address,
password: string,
password: string | undefined,
id: string,
file: string,
shutdown: Effect.Effect<void>,
@@ -177,7 +179,7 @@ const register = Effect.fnUntraced(function* (
version: OPENCODE_VERSION,
url: HttpServer.formatAddress(address),
pid: process.pid,
password,
...(password ? { password } : {}),
}
const encoded = yield* encodeInfo(info)
const current = fs.readFileString(file).pipe(
+9 -5
View File
@@ -47,7 +47,6 @@ export const start = Effect.fn("ServerProcess.start")(function* <E, R>(
transform?: Transform,
) {
const password = options.password
if (!password) return yield* Effect.fail(new Error("Missing server password"))
const hostname = options.hostname ?? "127.0.0.1"
const port = Option.fromNullishOr(options.port)
const shutdown = yield* Deferred.make<void>()
@@ -160,13 +159,13 @@ function addressInUse(error: unknown) {
}
function dispatch(
password: string,
password: string | undefined,
status: Status.Interface,
application: Ref.Ref<Option.Option<App>>,
shutdown: Deferred.Deferred<void>,
version: string,
): App {
const auth = ServerAuth.Config.of({ password: Option.some(password), username: "opencode" })
const auth = ServerAuth.Config.of({ password: Option.fromNullishOr(password), username: "opencode" })
return Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest
const url = new URL(request.url, "http://localhost")
@@ -177,13 +176,18 @@ function dispatch(
? "stop"
: undefined
if (lifecycle !== undefined) {
if (!(yield* authorizedRequest(request, auth))) return unauthorized()
if (ServerAuth.required(auth) && !(yield* authorizedRequest(request, auth))) return unauthorized()
return yield* control(request, lifecycle, status, () => Deferred.doneUnsafe(shutdown, Effect.void), version)
}
const state = yield* status.current
const app = yield* Ref.get(application)
const ready = state.type === "ready" && Option.isSome(app)
if ((!ready || !hasPtyConnectTicketURL(url)) && !(yield* authorizedRequest(request, auth))) return unauthorized()
if (
ServerAuth.required(auth) &&
(!ready || !hasPtyConnectTicketURL(url)) &&
!(yield* authorizedRequest(request, auth))
)
return unauthorized()
if (ready) return yield* app.value
return unavailable(state)
})
+17
View File
@@ -60,3 +60,20 @@ it.live("allows browser preflight requests without credentials", () =>
expect(yield* Effect.promise(() => missing.text())).toBe("fallback")
}),
)
it.live("allows requests without credentials when authentication is disabled", () =>
Effect.gen(function* () {
const server = yield* ServerProcess.start<never, never>({
hostname: "127.0.0.1",
port: 0,
app: { version: "test-version" },
database: { path: ":memory:" },
})
const response = yield* Effect.promise(() =>
fetch(new URL("/api/health", HttpServer.formatAddress(server.address))),
)
expect(response.status).toBe(200)
expect(yield* Effect.promise(() => response.json())).toMatchObject({ version: "test-version" })
}),
)