fix(connect): default Basic-auth username to 'opencode' when password set (critical)
Quick Connect (the DEFAULT add-connection mode) has no username field, so every
auth-build site (username && password ? {..} : undefined) produced undefined auth
whenever a password was set but username empty -> NO Authorization header -> 401
against a password-protected server. This is the common setup
(OPENCODE_SERVER_PASSWORD=... opencode serve) and a top install->churn cause:
user sets a password, can't connect, gives up.
Fix: extract buildAuth() to a pure, testable module; when a password is present but
username is empty, default username to 'opencode' (the server's own default,
OPENCODE_SERVER_USERNAME ?? 'opencode'). Advanced mode's explicit username is
preserved. Replaced all 6 inline ternaries in connections.ts.
+3 regression tests (68 total pass), typecheck clean. Found while setting up an
on-device emulator test of the connect flow.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
22
src/lib/auth.test.ts
Normal file
22
src/lib/auth.test.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { test } from "node:test"
|
||||||
|
import assert from "node:assert/strict"
|
||||||
|
import { buildAuth } from "./auth.ts"
|
||||||
|
|
||||||
|
test("no password -> no auth (open server)", () => {
|
||||||
|
assert.equal(buildAuth("opencode", undefined), undefined)
|
||||||
|
assert.equal(buildAuth("opencode", null), undefined)
|
||||||
|
assert.equal(buildAuth("opencode", ""), undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("password but no username -> defaults username to 'opencode' (Quick Connect fix)", () => {
|
||||||
|
// Regression guard: Quick Connect has no username field. Before the fix, an empty
|
||||||
|
// username made auth undefined and the app sent NO Authorization header -> 401.
|
||||||
|
assert.deepEqual(buildAuth(undefined, "secret"), { username: "opencode", password: "secret" })
|
||||||
|
assert.deepEqual(buildAuth("", "secret"), { username: "opencode", password: "secret" })
|
||||||
|
assert.deepEqual(buildAuth(" ", "secret"), { username: "opencode", password: "secret" })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("explicit username preserved (Advanced mode), trimmed", () => {
|
||||||
|
assert.deepEqual(buildAuth("alice", "secret"), { username: "alice", password: "secret" })
|
||||||
|
assert.deepEqual(buildAuth(" alice ", "secret"), { username: "alice", password: "secret" })
|
||||||
|
})
|
||||||
22
src/lib/auth.ts
Normal file
22
src/lib/auth.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
// Pure Basic-auth credential construction for opencode connections.
|
||||||
|
// Extracted from stores/connections.ts so the username-defaulting rule is unit-testable
|
||||||
|
// without pulling in zustand/expo (which have no resolver outside Metro).
|
||||||
|
|
||||||
|
export interface BasicAuth {
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// The opencode server's default Basic-auth username is "opencode"
|
||||||
|
// (OPENCODE_SERVER_USERNAME ?? "opencode"). Quick Connect collects only a password
|
||||||
|
// (it has no username field), so when a password is set but no username, default the
|
||||||
|
// username to "opencode". Without this, an empty username made auth undefined entirely —
|
||||||
|
// no Authorization header was sent — and a password-protected server returned 401,
|
||||||
|
// i.e. the common "I set a password and now it won't connect" failure.
|
||||||
|
export function buildAuth(
|
||||||
|
username: string | null | undefined,
|
||||||
|
password: string | null | undefined,
|
||||||
|
): BasicAuth | undefined {
|
||||||
|
if (!password) return undefined
|
||||||
|
return { username: username?.trim() || "opencode", password }
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import * as Crypto from "expo-crypto"
|
|||||||
import type { ServerConnection, ConnectionType } from "../lib/types"
|
import type { ServerConnection, ConnectionType } from "../lib/types"
|
||||||
import { createClient, type Client, type Project } from "../lib/sdk"
|
import { createClient, type Client, type Project } from "../lib/sdk"
|
||||||
import { addBreadcrumb } from "../lib/sentry"
|
import { addBreadcrumb } from "../lib/sentry"
|
||||||
|
import { buildAuth } from "../lib/auth"
|
||||||
|
|
||||||
const CONNECTIONS_KEY = "opencode_connections"
|
const CONNECTIONS_KEY = "opencode_connections"
|
||||||
const PASSWORDS_PREFIX = "opencode_password_"
|
const PASSWORDS_PREFIX = "opencode_password_"
|
||||||
@@ -88,7 +89,7 @@ export const useConnections = create<ConnectionsState>((set, get) => ({
|
|||||||
let home: string | null = null
|
let home: string | null = null
|
||||||
if (active) {
|
if (active) {
|
||||||
const password = await SecureStore.getItemAsync(`${PASSWORDS_PREFIX}${active.id}`)
|
const password = await SecureStore.getItemAsync(`${PASSWORDS_PREFIX}${active.id}`)
|
||||||
const auth = active.username && password ? { username: active.username, password } : undefined
|
const auth = buildAuth(active.username, password)
|
||||||
const built = buildClient(active.url, active.directory, auth)
|
const built = buildClient(active.url, active.directory, auth)
|
||||||
client = built.client
|
client = built.client
|
||||||
base = built.base
|
base = built.base
|
||||||
@@ -147,7 +148,7 @@ export const useConnections = create<ConnectionsState>((set, get) => ({
|
|||||||
|
|
||||||
if (newConnection.active) {
|
if (newConnection.active) {
|
||||||
activeConnection = newConnection
|
activeConnection = newConnection
|
||||||
const auth = newConnection.username && password ? { username: newConnection.username, password } : undefined
|
const auth = buildAuth(newConnection.username, password)
|
||||||
const built = buildClient(newConnection.url, newConnection.directory, auth)
|
const built = buildClient(newConnection.url, newConnection.directory, auth)
|
||||||
client = built.client
|
client = built.client
|
||||||
base = built.base
|
base = built.base
|
||||||
@@ -185,7 +186,7 @@ export const useConnections = create<ConnectionsState>((set, get) => ({
|
|||||||
newActive.active = true
|
newActive.active = true
|
||||||
await SecureStore.setItemAsync(CONNECTIONS_KEY, JSON.stringify(connections))
|
await SecureStore.setItemAsync(CONNECTIONS_KEY, JSON.stringify(connections))
|
||||||
const password = await SecureStore.getItemAsync(`${PASSWORDS_PREFIX}${newActive.id}`)
|
const password = await SecureStore.getItemAsync(`${PASSWORDS_PREFIX}${newActive.id}`)
|
||||||
const auth = newActive.username && password ? { username: newActive.username, password } : undefined
|
const auth = buildAuth(newActive.username, password)
|
||||||
const built = buildClient(newActive.url, newActive.directory, auth)
|
const built = buildClient(newActive.url, newActive.directory, auth)
|
||||||
set({ connections, activeConnection: newActive, client: built.client, clientBase: built.base })
|
set({ connections, activeConnection: newActive, client: built.client, clientBase: built.base })
|
||||||
} else {
|
} else {
|
||||||
@@ -212,7 +213,7 @@ export const useConnections = create<ConnectionsState>((set, get) => ({
|
|||||||
|
|
||||||
if (active) {
|
if (active) {
|
||||||
const password = await SecureStore.getItemAsync(`${PASSWORDS_PREFIX}${active.id}`)
|
const password = await SecureStore.getItemAsync(`${PASSWORDS_PREFIX}${active.id}`)
|
||||||
const auth = active.username && password ? { username: active.username, password } : undefined
|
const auth = buildAuth(active.username, password)
|
||||||
const built = buildClient(active.url, active.directory, auth)
|
const built = buildClient(active.url, active.directory, auth)
|
||||||
client = built.client
|
client = built.client
|
||||||
base = built.base
|
base = built.base
|
||||||
@@ -246,7 +247,7 @@ export const useConnections = create<ConnectionsState>((set, get) => ({
|
|||||||
const client = createClient({
|
const client = createClient({
|
||||||
baseUrl: connection.url,
|
baseUrl: connection.url,
|
||||||
directory: connection.directory,
|
directory: connection.directory,
|
||||||
auth: connection.username && password ? { username: connection.username, password } : undefined,
|
auth: buildAuth(connection.username, password),
|
||||||
})
|
})
|
||||||
|
|
||||||
await client.global.health()
|
await client.global.health()
|
||||||
@@ -265,7 +266,7 @@ export const useConnections = create<ConnectionsState>((set, get) => ({
|
|||||||
if (get().activeConnection?.id === id) {
|
if (get().activeConnection?.id === id) {
|
||||||
const active = connections.find((c) => c.id === id)!
|
const active = connections.find((c) => c.id === id)!
|
||||||
const password = await SecureStore.getItemAsync(`${PASSWORDS_PREFIX}${id}`)
|
const password = await SecureStore.getItemAsync(`${PASSWORDS_PREFIX}${id}`)
|
||||||
const auth = active.username && password ? { username: active.username, password } : undefined
|
const auth = buildAuth(active.username, password)
|
||||||
const built = buildClient(active.url, active.directory, auth)
|
const built = buildClient(active.url, active.directory, auth)
|
||||||
try {
|
try {
|
||||||
const [project, paths] = await Promise.all([
|
const [project, paths] = await Promise.all([
|
||||||
|
|||||||
Reference in New Issue
Block a user