From 34345b8c621cba5b8afc4832004f96d1ac78f3ab Mon Sep 17 00:00:00 2001 From: OpeOginni Date: Wed, 14 Jan 2026 18:35:55 +0100 Subject: [PATCH 1/2] feat: implement server credentials management for web and desktop - Added `CredentialsProvider` to manage server credentials in the app context. - Integrated credential storage and retrieval functions in the platform context for browser and app - Updated various components to utilize the new credentials management, including `DialogSelectServer`, `ErrorPage`, and `SessionMcpIndicator`. - Refactored `DialogSelectMcp` and `DialogSelectServer` to use new tab components for better organization. - Enhanced error handling in the `ErrorPage` to prompt for credentials when unauthorized. - Updated SDK initialization to include credentials in requests. --- packages/app/src/app.tsx | 89 ++-- .../app/src/components/dialog-select-mcp.tsx | 82 +-- .../src/components/dialog-select-server.tsx | 245 +-------- .../src/components/dialog-server-config.tsx | 78 +++ packages/app/src/components/lsp-tab.tsx | 9 + packages/app/src/components/mcp-tab.tsx | 70 +++ packages/app/src/components/plugins-tab.tsx | 11 + packages/app/src/components/server-tab.tsx | 494 ++++++++++++++++++ .../src/components/session-mcp-indicator.tsx | 3 +- .../src/components/session/session-header.tsx | 4 +- packages/app/src/components/terminal.tsx | 9 +- packages/app/src/context/credentials.tsx | 38 ++ packages/app/src/context/global-sdk.tsx | 63 ++- packages/app/src/context/global-sync.tsx | 40 +- packages/app/src/context/platform.tsx | 12 + packages/app/src/context/sdk.tsx | 50 +- packages/app/src/entry.tsx | 18 + packages/app/src/pages/error.tsx | 214 +++++++- packages/app/src/pages/home.tsx | 1 + packages/app/src/pages/layout.tsx | 4 +- packages/app/src/utils/credential-storage.ts | 106 ++++ packages/desktop/src-tauri/Cargo.lock | 2 + packages/desktop/src-tauri/Cargo.toml | 2 + packages/desktop/src-tauri/src/lib.rs | 147 +++++- packages/desktop/src/index.tsx | 103 +++- packages/opencode/src/server/server.ts | 57 +- packages/plugin/package.json | 2 +- packages/sdk/js/package.json | 2 +- 28 files changed, 1517 insertions(+), 438 deletions(-) create mode 100644 packages/app/src/components/dialog-server-config.tsx create mode 100644 packages/app/src/components/lsp-tab.tsx create mode 100644 packages/app/src/components/mcp-tab.tsx create mode 100644 packages/app/src/components/plugins-tab.tsx create mode 100644 packages/app/src/components/server-tab.tsx create mode 100644 packages/app/src/context/credentials.tsx create mode 100644 packages/app/src/utils/credential-storage.ts diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index d0678dc5369..b6da1fccdda 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -26,6 +26,7 @@ import DirectoryLayout from "@/pages/directory-layout" import { ErrorPage } from "./pages/error" import { iife } from "@opencode-ai/util/iife" import { Suspense } from "solid-js" +import { CredentialsProvider } from "./context/credentials" const Home = lazy(() => import("@/pages/home")) const Session = lazy(() => import("@/pages/session")) @@ -78,49 +79,53 @@ export function AppInterface(props: { defaultUrl?: string }) { return ( - - - ( - - - - - {props.children} - - - - - )} - > - ( - }> - - - )} - /> - - } /> - ( - - - - }> - - - - - + }> + + + + ( + + + + + {props.children} + + + + )} - /> - - - - + > + ( + }> + + + )} + /> + + } /> + ( + + + + }> + + + + + + )} + /> + + + + + + ) diff --git a/packages/app/src/components/dialog-select-mcp.tsx b/packages/app/src/components/dialog-select-mcp.tsx index c29cd827e3b..add54a359ca 100644 --- a/packages/app/src/components/dialog-select-mcp.tsx +++ b/packages/app/src/components/dialog-select-mcp.tsx @@ -1,91 +1,23 @@ -import { Component, createMemo, createSignal, Show } from "solid-js" -import { useSync } from "@/context/sync" -import { useSDK } from "@/context/sdk" +import { Component, createMemo } from "solid-js" import { Dialog } from "@opencode-ai/ui/dialog" -import { List } from "@opencode-ai/ui/list" -import { Switch } from "@opencode-ai/ui/switch" +import { McpTab } from "./mcp-tab" +import { useSync } from "@/context/sync" export const DialogSelectMcp: Component = () => { const sync = useSync() - const sdk = useSDK() - const [loading, setLoading] = createSignal(null) - const items = createMemo(() => + const mcpItems = createMemo(() => Object.entries(sync.data.mcp ?? {}) .map(([name, status]) => ({ name, status: status.status })) .sort((a, b) => a.name.localeCompare(b.name)), ) - const toggle = async (name: string) => { - if (loading()) return - setLoading(name) - const status = sync.data.mcp[name] - if (status?.status === "connected") { - await sdk.client.mcp.disconnect({ name }) - } else { - await sdk.client.mcp.connect({ name }) - } - const result = await sdk.client.mcp.status() - if (result.data) sync.set("mcp", result.data) - setLoading(null) - } - - const enabledCount = createMemo(() => items().filter((i) => i.status === "connected").length) - const totalCount = createMemo(() => items().length) + const enabledCount = createMemo(() => mcpItems().filter((i) => i.status === "connected").length) + const totalCount = createMemo(() => mcpItems().length) return ( - x?.name ?? ""} - items={items} - filterKeys={["name", "status"]} - sortBy={(a, b) => a.name.localeCompare(b.name)} - onSelect={(x) => { - if (x) toggle(x.name) - }} - > - {(i) => { - const mcpStatus = () => sync.data.mcp[i.name] - const status = () => mcpStatus()?.status - const error = () => { - const s = mcpStatus() - return s?.status === "failed" ? s.error : undefined - } - const enabled = () => status() === "connected" - return ( -
-
-
- {i.name} - - connected - - - failed - - - needs auth - - - disabled - - - ... - -
- - {error()} - -
-
e.stopPropagation()}> - toggle(i.name)} /> -
-
- ) - }} -
+
) } diff --git a/packages/app/src/components/dialog-select-server.tsx b/packages/app/src/components/dialog-select-server.tsx index 90f37212888..f58d3a979ce 100644 --- a/packages/app/src/components/dialog-select-server.tsx +++ b/packages/app/src/components/dialog-select-server.tsx @@ -1,246 +1,19 @@ -import { createResource, createEffect, createMemo, onCleanup, Show } from "solid-js" -import { createStore, reconcile } from "solid-js/store" -import { useDialog } from "@opencode-ai/ui/context/dialog" import { Dialog } from "@opencode-ai/ui/dialog" -import { List } from "@opencode-ai/ui/list" -import { TextField } from "@opencode-ai/ui/text-field" -import { Button } from "@opencode-ai/ui/button" -import { IconButton } from "@opencode-ai/ui/icon-button" -import { normalizeServerUrl, serverDisplayName, useServer } from "@/context/server" -import { usePlatform } from "@/context/platform" -import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" -import { useNavigate } from "@solidjs/router" - -type ServerStatus = { healthy: boolean; version?: string } - -async function checkHealth(url: string, fetch?: typeof globalThis.fetch): Promise { - const sdk = createOpencodeClient({ - baseUrl: url, - fetch, - signal: AbortSignal.timeout(3000), - }) - return sdk.global - .health() - .then((x) => ({ healthy: x.data?.healthy === true, version: x.data?.version })) - .catch(() => ({ healthy: false })) -} +import { createSignal, createMemo } from "solid-js" +import { ServerTab } from "./server-tab" export function DialogSelectServer() { - const navigate = useNavigate() - const dialog = useDialog() - const server = useServer() - const platform = usePlatform() - const [store, setStore] = createStore({ - url: "", - adding: false, - error: "", - status: {} as Record, - }) - const [defaultUrl, defaultUrlActions] = createResource(() => platform.getDefaultServerUrl?.()) - const isDesktop = platform.platform === "desktop" - - const items = createMemo(() => { - const current = server.url - const list = server.list - if (!current) return list - if (!list.includes(current)) return [current, ...list] - return [current, ...list.filter((x) => x !== current)] - }) - - const current = createMemo(() => items().find((x) => x === server.url) ?? items()[0]) + const [title, setTitle] = createSignal("Servers") + const [description, setDescription] = createSignal("Switch which OpenCode server this app connects to.") - const sortedItems = createMemo(() => { - const list = items() - if (!list.length) return list - const active = current() - const order = new Map(list.map((url, index) => [url, index] as const)) - const rank = (value?: ServerStatus) => { - if (value?.healthy === true) return 0 - if (value?.healthy === false) return 2 - return 1 - } - return list.slice().sort((a, b) => { - if (a === active) return -1 - if (b === active) return 1 - const diff = rank(store.status[a]) - rank(store.status[b]) - if (diff !== 0) return diff - return (order.get(a) ?? 0) - (order.get(b) ?? 0) - }) - }) - - async function refreshHealth() { - const results: Record = {} - await Promise.all( - items().map(async (url) => { - results[url] = await checkHealth(url, platform.fetch) - }), - ) - setStore("status", reconcile(results)) - } - - createEffect(() => { - items() - refreshHealth() - const interval = setInterval(refreshHealth, 10_000) - onCleanup(() => clearInterval(interval)) - }) - - function select(value: string, persist?: boolean) { - if (!persist && store.status[value]?.healthy === false) return - dialog.close() - if (persist) { - server.add(value) - navigate("/") - return - } - server.setActive(value) - navigate("/") - } - - async function handleSubmit(e: SubmitEvent) { - e.preventDefault() - const value = normalizeServerUrl(store.url) - if (!value) return - - setStore("adding", true) - setStore("error", "") - - const result = await checkHealth(value, platform.fetch) - setStore("adding", false) - - if (!result.healthy) { - setStore("error", "Could not connect to server") - return - } - - setStore("url", "") - select(value, true) - } - - async function handleRemove(url: string) { - server.remove(url) + const handleTitleChange = (newTitle: string, newDescription: string) => { + setTitle(newTitle) + setDescription(newDescription) } return ( - -
- x} - current={current()} - onSelect={(x) => { - if (x) select(x) - }} - > - {(i) => ( -
-
-
- {serverDisplayName(i)} - {store.status[i]?.version} -
- - { - e.stopPropagation() - handleRemove(i) - }} - /> - -
- )} - - -
-
-

Add a server

-
-
-
-
- { - setStore("url", v) - setStore("error", "") - }} - validationState={store.error ? "invalid" : "valid"} - error={store.error} - /> -
- -
-
-
- - -
-
-

Default server

-

- Connect to this server on app launch instead of starting a local server. Requires restart. -

-
-
- No server selected} - > - - - } - > -
- {serverDisplayName(defaultUrl()!)} -
- - -
-
-
-
+ + ) } diff --git a/packages/app/src/components/dialog-server-config.tsx b/packages/app/src/components/dialog-server-config.tsx new file mode 100644 index 00000000000..21899e97b4b --- /dev/null +++ b/packages/app/src/components/dialog-server-config.tsx @@ -0,0 +1,78 @@ +import { createMemo } from "solid-js" +import { Dialog } from "@opencode-ai/ui/dialog" +import { Tabs } from "@opencode-ai/ui/tabs" +import { useServer } from "@/context/server" +import { useSync } from "@/context/sync" +import { ServerTab } from "./server-tab" +import { McpTab } from "./mcp-tab" +import { LspTab } from "./lsp-tab" +import { PluginsTab } from "./plugins-tab" + +export function DialogServerConfig( + props: { defaultTab?: "servers" | "mcp" | "lsp" | "plugins" } = { defaultTab: "servers" }, +) { + const server = useServer() + const sync = useSync() + + const mcpItems = createMemo(() => + Object.entries(sync.data.mcp ?? {}) + .map(([name]) => name) + .sort((a, b) => a.localeCompare(b)), + ) + + return ( + + + + + {server.list.length} {server.list.length === 1 ? "Server" : "Servers"} + + + {mcpItems().length} {mcpItems().length === 1 ? "MCP" : "MCPs"} + + + LSP + + + Plugins + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/packages/app/src/components/lsp-tab.tsx b/packages/app/src/components/lsp-tab.tsx new file mode 100644 index 00000000000..bc1d8a227ef --- /dev/null +++ b/packages/app/src/components/lsp-tab.tsx @@ -0,0 +1,9 @@ +import type { Component } from "solid-js" + +export const LspTab: Component = () => { + return ( +
+ LSPs auto-detected from file types +
+ ) +} diff --git a/packages/app/src/components/mcp-tab.tsx b/packages/app/src/components/mcp-tab.tsx new file mode 100644 index 00000000000..02fd67cf484 --- /dev/null +++ b/packages/app/src/components/mcp-tab.tsx @@ -0,0 +1,70 @@ +import { createSignal, createMemo } from "solid-js" +import { List } from "@opencode-ai/ui/list" +import { Switch } from "@opencode-ai/ui/switch" +import { useSync } from "@/context/sync" +import { useSDK } from "@/context/sdk" +import type { Component } from "solid-js" + +export const McpTab: Component = () => { + const sync = useSync() + const sdk = useSDK() + const [mcpLoading, setMcpLoading] = createSignal(null) + + const mcpItems = createMemo(() => + Object.entries(sync.data.mcp ?? {}) + .map(([name, status]) => ({ name, status: status.status })) + .sort((a, b) => a.name.localeCompare(b.name)), + ) + + const toggleMcp = async (name: string) => { + if (mcpLoading()) return + setMcpLoading(name) + const status = sync.data.mcp[name] + if (status?.status === "connected") { + await sdk.client.mcp.disconnect({ name }) + } else { + await sdk.client.mcp.connect({ name }) + } + const result = await sdk.client.mcp.status() + if (result.data) sync.set("mcp", result.data) + setMcpLoading(null) + } + + return ( +
+ x.name} + onSelect={(x) => { + if (x) toggleMcp(x.name) + }} + > + {(i) => { + const mcpStatus = () => sync.data.mcp[i.name] + const status = () => mcpStatus()?.status + const enabled = () => status() === "connected" + return ( +
+
+
+ {i.name} +
+
e.stopPropagation()}> + toggleMcp(i.name)} /> +
+
+ ) + }} + +
+ ) +} diff --git a/packages/app/src/components/plugins-tab.tsx b/packages/app/src/components/plugins-tab.tsx new file mode 100644 index 00000000000..46dcde47c21 --- /dev/null +++ b/packages/app/src/components/plugins-tab.tsx @@ -0,0 +1,11 @@ +import type { Component } from "solid-js" + +export const PluginsTab: Component = () => { + return ( +
+ + Plugins configured in opencode.json + +
+ ) +} diff --git a/packages/app/src/components/server-tab.tsx b/packages/app/src/components/server-tab.tsx new file mode 100644 index 00000000000..95f9e634001 --- /dev/null +++ b/packages/app/src/components/server-tab.tsx @@ -0,0 +1,494 @@ +import { createResource, createEffect, createMemo, onCleanup, Show, createSignal } from "solid-js" +import { createStore, reconcile } from "solid-js/store" +import { List } from "@opencode-ai/ui/list" +import { TextField } from "@opencode-ai/ui/text-field" +import { Button } from "@opencode-ai/ui/button" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { Popover } from "@opencode-ai/ui/popover" +import { normalizeServerUrl, serverDisplayName, useServer } from "@/context/server" +import { usePlatform } from "@/context/platform" +import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" +import { useGlobalSDK } from "@/context/global-sdk" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import type { Component } from "solid-js" + +type ServerStatus = { healthy: boolean; version?: string; authenticated?: boolean } + +async function checkHealth(url: string, fetch?: typeof globalThis.fetch): Promise { + const sdk = createOpencodeClient({ + baseUrl: url, + fetch, + signal: AbortSignal.timeout(3000), + }) + return sdk.global + .health() + .then((x) => { + const data = x.data as { healthy: boolean; version?: string; authenticated?: boolean } + return { + healthy: data?.healthy === true, + version: data?.version, + authenticated: data?.authenticated === true, + } + }) + .catch(() => ({ healthy: false })) +} + +async function checkCredentials( + url: string, + username: string, + password: string, + fetch?: typeof globalThis.fetch, +): Promise<{ correctCredentials: boolean }> { + const sdk = createOpencodeClient({ + baseUrl: url, + fetch, + signal: AbortSignal.timeout(3000), + headers: { + Authorization: `Basic ${btoa(`${username}:${password}`)}`, + }, + }) + return sdk.config + .get() + .then((x) => { + if (x?.error) return { correctCredentials: false } + return { correctCredentials: true } + }) + .catch(() => { + return { correctCredentials: false } + }) +} + +export const ServerTab: Component<{ onTitleChange?: (title: string, description: string) => void }> = (props) => { + const server = useServer() + const platform = usePlatform() + const globalSDK = useGlobalSDK() + const dialog = useDialog() + const [store, setStore] = createStore({ + url: "", + adding: false, + error: "", + status: {} as Record, + showCredentials: false, + credentialsUrl: "", + username: "", + password: "", + credentialsError: "", + showAddServerForm: false, + addingStatus: undefined as "success" | "error" | undefined, + }) + const [defaultUrl, defaultUrlActions] = createResource(() => platform.getDefaultServerUrl?.()) + const isDesktop = platform.platform === "desktop" + + const items = createMemo(() => { + const current = server.url + const list = server.list + if (!current) return list + if (!list.includes(current)) return [current, ...list] + return [current, ...list.filter((x) => x !== current)] + }) + + const current = createMemo(() => items().find((x) => x === server.url) ?? items()[0]) + + const sortedItems = createMemo(() => { + const list = items() + if (!list.length) return list + const active = current() + const order = new Map(list.map((url, index) => [url, index] as const)) + const rank = (value?: ServerStatus) => { + if (value?.healthy === true) return 0 + if (value?.healthy === false) return 2 + return 1 + } + return list.slice().sort((a, b) => { + if (a === active) return -1 + if (b === active) return 1 + const diff = rank(store.status[a]) - rank(store.status[b]) + if (diff !== 0) return diff + return (order.get(a) ?? 0) - (order.get(b) ?? 0) + }) + }) + + async function refreshHealth() { + const results: Record = {} + await Promise.all( + items().map(async (url) => { + results[url] = await checkHealth(url, platform.fetch) + }), + ) + setStore("status", reconcile(results)) + } + + createEffect(() => { + items() + refreshHealth() + const interval = setInterval(refreshHealth, 10_000) + onCleanup(() => clearInterval(interval)) + }) + + createEffect(() => { + if (props.onTitleChange) { + const title = store.showCredentials ? "Enter credentials" : "Servers" + const description = store.showCredentials + ? `Connect to ${serverDisplayName(store.credentialsUrl)}` + : "Switch which OpenCode server this app connects to." + props.onTitleChange(title, description) + } + }) + + function select(value: string, persist?: boolean) { + if (!persist && store.status[value]?.healthy === false) return + if (persist) { + server.add(value) + return + } + server.setActive(value) + setTimeout(() => dialog.close(), 100) + } + + async function handleSaveServer(e: SubmitEvent) { + e.preventDefault() + const value = normalizeServerUrl(store.url) + if (!value) return + + setStore("adding", true) + setStore("error", "") + setStore("addingStatus", undefined) + + const result = await checkHealth(value, platform.fetch) + setStore("adding", false) + + if (!result.healthy) { + setStore("error", "Could not connect to server") + setStore("addingStatus", "error") + return + } + + setStore("addingStatus", "success") + + if (result.authenticated) { + setStore("credentialsUrl", value) + setStore("showCredentials", true) + setStore("username", "opencode") + setStore("password", "") + setStore("credentialsError", "") + return + } + + setStore("url", "") + setStore("showAddServerForm", false) + select(value, true) + setTimeout(() => dialog.close(), 100) + } + + async function handleCredentialsSubmit(e: SubmitEvent) { + e.preventDefault() + const url = store.credentialsUrl + if (!url || !store.username || !store.password) { + setStore("credentialsError", "Username and password are required") + return + } + + setStore("credentialsError", "") + setStore("adding", true) + + const result = await checkCredentials(url, store.username, store.password, platform.fetch) + setStore("adding", false) + + if (!result.correctCredentials) { + setStore("credentialsError", "Invalid username or password") + return + } + + await platform.storeServerCredentials?.(url, store.username, store.password) + await globalSDK.refetchCredentials() + + setStore("showCredentials", false) + setStore("url", "") + setStore("showAddServerForm", false) + select(url, true) + setTimeout(() => dialog.close(), 100) + } + + function handleCancelCredentials() { + setStore("showCredentials", false) + setStore("credentialsUrl", "") + setStore("username", "") + setStore("password", "") + setStore("credentialsError", "") + } + + async function handleRemove(url: string) { + server.remove(url) + await platform.removeServerCredentials?.(url) + } + + return ( + + x} + current={current()} + onSelect={(x) => { + if (x) select(x) + }} + > + {(i) => { + const [popoverOpen, setPopoverOpen] = createSignal(false) + return ( +
+
+
+ {serverDisplayName(i)} + + Authed + + {store.status[i]?.version} + + Default + +
+ +
e.stopPropagation()}> + + } + class="min-w-[150px]" + > +
+ + + + +
+ +
+ +
+ +
+ ) + }} + + + + +
+ } + > +
+
+
+
+ { + setStore("url", v) + setStore("error", "") + }} + validationState={store.error ? "invalid" : "valid"} + error={store.error} + autofocus + class="w-full" + /> +
+ +
+ + +
+ +
+ + + {/* +
+
+

Default server

+

+ Connect to this server on app launch instead of starting a local server. Requires restart. +

+
+
+ No server selected} + > + + + } + > +
+ {serverDisplayName(defaultUrl()!)} +
+ + +
+
+
*/} +
+ } + > +
+
+
+ { + setStore("username", v) + setStore("credentialsError", "") + }} + autofocus + class="w-full" + inputClass="w-full" + /> + { + setStore("password", v) + setStore("credentialsError", "") + }} + validationState={store.credentialsError ? "invalid" : "valid"} + error={store.credentialsError} + class="w-full" + inputClass="w-full" + /> +
+ + +
+
+
+
+ + ) +} diff --git a/packages/app/src/components/session-mcp-indicator.tsx b/packages/app/src/components/session-mcp-indicator.tsx index 489223b9bf5..43478d55a75 100644 --- a/packages/app/src/components/session-mcp-indicator.tsx +++ b/packages/app/src/components/session-mcp-indicator.tsx @@ -3,6 +3,7 @@ import { Button } from "@opencode-ai/ui/button" import { useDialog } from "@opencode-ai/ui/context/dialog" import { useSync } from "@/context/sync" import { DialogSelectMcp } from "@/components/dialog-select-mcp" +import { DialogServerConfig } from "./dialog-server-config" export function SessionMcpIndicator() { const sync = useSync() @@ -19,7 +20,7 @@ export function SessionMcpIndicator() { return ( 0}> - - - - {store.checking ? "Checking..." : "Check for updates"} + + +
+ - } - > - +
+ + } + > +
+ +
+

+ This server requires authentication. Please enter your credentials to continue. +

+ +
- -
+ +
+ { + setStore("username", v) + setStore("credentialsError", "") + }} + placeholder="opencode" + autofocus + required + /> + { + setStore("password", v) + setStore("credentialsError", "") + }} + placeholder="Enter password" + required + validationState={store.credentialsError ? "invalid" : "valid"} + error={store.credentialsError} + /> +
+ + +
+ +
+
+
+ +
+ + + + {store.checking ? "Checking..." : "Check for updates"} + + } + > + + + +
+
Please report this error to the OpenCode team diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index 275113566ad..621098cb413 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -59,6 +59,7 @@ export default function Home() { size="large" variant="ghost" class="mt-4 mx-auto text-14-regular text-text-weak" + // onClick={() => dialog.show(() => )} onClick={() => dialog.show(() => )} >
) + // dialog.show(() => ) + dialog.show(() => ) } function navigateToProject(directory: string | undefined) { diff --git a/packages/app/src/utils/credential-storage.ts b/packages/app/src/utils/credential-storage.ts new file mode 100644 index 00000000000..b5561812500 --- /dev/null +++ b/packages/app/src/utils/credential-storage.ts @@ -0,0 +1,106 @@ +import { hash } from "@opencode-ai/util/encode" +import { Platform } from "@/context/platform" + +// Web Crypto API helper functions +const CREDENTIALS_STORAGE_KEY = `opencode.server.credentials` +const CREDENTIALS_INDEX_KEY = `opencode.server.credentials.index` + +async function getEncryptionKey(): Promise { + // Derive a key from a domain-specific secret (could be improved with user-specific secret) + const keyMaterial = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode("opencode-credentials-v1"), + { name: "PBKDF2" }, + false, + ["deriveBits", "deriveKey"], + ) + + return crypto.subtle.deriveKey( + { + name: "PBKDF2", + salt: new TextEncoder().encode("opencode-salt-v1"), + iterations: 100000, + hash: "SHA-256", + }, + keyMaterial, + { name: "AES-GCM", length: 256 }, + false, + ["encrypt", "decrypt"], + ) +} + +async function encrypt(data: string): Promise { + const key = await getEncryptionKey() + const iv = crypto.getRandomValues(new Uint8Array(12)) + const encoded = new TextEncoder().encode(data) + + const encrypted = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, encoded) + + // Combine IV and encrypted data + const combined = new Uint8Array(iv.length + encrypted.byteLength) + combined.set(iv) + combined.set(new Uint8Array(encrypted), iv.length) + + // Convert to base64 for storage + return btoa(String.fromCharCode(...combined)) +} + +async function decrypt(encryptedData: string): Promise { + const key = await getEncryptionKey() + const combined = Uint8Array.from(atob(encryptedData), (c) => c.charCodeAt(0)) + + const iv = combined.slice(0, 12) + const encrypted = combined.slice(12) + + const decrypted = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, encrypted) + + return new TextDecoder().decode(decrypted) +} + +export async function storeServerCredentials(url: string, username: string, password: string) { + const credentials = JSON.stringify({ username, password }) + const encrypted = await encrypt(credentials) + const urlHash = await hash(url) + + // Store encrypted credentials + localStorage.setItem(`${CREDENTIALS_STORAGE_KEY}:${urlHash}`, encrypted) + console.log("Hashing URL", url) + console.log("stored credentials", `${CREDENTIALS_STORAGE_KEY}:${urlHash}`, encrypted) + + // Update index + const index = JSON.parse(localStorage.getItem(CREDENTIALS_INDEX_KEY) || "[]") as string[] + if (!index.includes(urlHash)) { + index.push(urlHash) + localStorage.setItem(CREDENTIALS_INDEX_KEY, JSON.stringify(index)) + } +} + +export async function getServerCredentials(url: string) { + const urlHash = await hash(url) + console.log("Getting credentials for", urlHash) + console.log("get credentials", `${CREDENTIALS_STORAGE_KEY}:${urlHash}`) + const encrypted = localStorage.getItem(`${CREDENTIALS_STORAGE_KEY}:${urlHash}`) + if (!encrypted) return null + + try { + const decrypted = await decrypt(encrypted) + return JSON.parse(decrypted) as { username: string; password: string } + } catch { + return null + } +} + +export async function removeServerCredentials(url: string) { + const urlHash = await hash(url) + localStorage.removeItem(`${CREDENTIALS_STORAGE_KEY}:${urlHash}`) + + // Update index + const index = JSON.parse(localStorage.getItem(CREDENTIALS_INDEX_KEY) || "[]") as string[] + const filtered = index.filter((h) => h !== urlHash) + localStorage.setItem(CREDENTIALS_INDEX_KEY, JSON.stringify(filtered)) +} + +export async function listServerCredentials() { + const index = JSON.parse(localStorage.getItem(CREDENTIALS_INDEX_KEY) || "[]") as string[] + return index +} diff --git a/packages/desktop/src-tauri/Cargo.lock b/packages/desktop/src-tauri/Cargo.lock index e577b4db78a..fda86ea7413 100644 --- a/packages/desktop/src-tauri/Cargo.lock +++ b/packages/desktop/src-tauri/Cargo.lock @@ -2794,11 +2794,13 @@ version = "0.0.0" dependencies = [ "futures", "gtk", + "hex", "listeners", "reqwest", "semver", "serde", "serde_json", + "sha2", "tauri", "tauri-build", "tauri-plugin-clipboard-manager", diff --git a/packages/desktop/src-tauri/Cargo.toml b/packages/desktop/src-tauri/Cargo.toml index 05422b09686..4f9862ce797 100644 --- a/packages/desktop/src-tauri/Cargo.toml +++ b/packages/desktop/src-tauri/Cargo.toml @@ -40,6 +40,8 @@ futures = "0.3.31" semver = "1.0.27" reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } uuid = { version = "1.19.0", features = ["v4"] } +sha2 = "0.10" +hex = "0.4" [target.'cfg(target_os = "linux")'.dependencies] gtk = "0.18.2" diff --git a/packages/desktop/src-tauri/src/lib.rs b/packages/desktop/src-tauri/src/lib.rs index 0d5b585e87b..6c178d9405b 100644 --- a/packages/desktop/src-tauri/src/lib.rs +++ b/packages/desktop/src-tauri/src/lib.rs @@ -21,9 +21,12 @@ use tauri_plugin_store::StoreExt; use tokio::sync::oneshot; use crate::window_customizer::PinchZoomDisablePlugin; +use sha2::{Sha256, Digest}; const SETTINGS_STORE: &str = "opencode.settings.dat"; const DEFAULT_SERVER_URL_KEY: &str = "defaultServerUrl"; +const CREDENTIALS_INDEX_KEY: &str = "opencode.server.credentials.index"; +const CREDENTIALS_SERVICE_PREFIX: &str = "opencode.server.credentials"; #[derive(Clone, serde::Serialize)] struct ServerReadyData { @@ -135,6 +138,144 @@ async fn set_default_server_url(app: AppHandle, url: Option) -> Result<( Ok(()) } +fn hash_url(url: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(url.as_bytes()); + hex::encode(hasher.finalize()) +} + +#[tauri::command] +async fn store_server_credentials( + app: AppHandle, + url: String, + username: String, + password: String, +) -> Result<(), String> { + let url_hash = hash_url(&url); + let credentials_key = format!("{}:{}", CREDENTIALS_SERVICE_PREFIX, url_hash); + + let store = app + .store(SETTINGS_STORE) + .map_err(|e| format!("Failed to open settings store: {}", e))?; + + // Store credentials + let credentials = serde_json::json!({ + "username": username, + "password": password + }); + store.set(&credentials_key, credentials); + + // Update index + let index_value = store.get(CREDENTIALS_INDEX_KEY); + let mut index: Vec = if let Some(v) = index_value { + if let Some(arr) = v.as_array() { + arr.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + } else { + vec![] + } + } else { + vec![] + }; + + if !index.contains(&url_hash) { + index.push(url_hash); + store.set(CREDENTIALS_INDEX_KEY, serde_json::Value::Array( + index.iter().map(|h| serde_json::Value::String(h.clone())).collect() + )); + } + + store.save().map_err(|e| format!("Failed to save credentials: {}", e))?; + + Ok(()) +} + +#[tauri::command] +async fn get_server_credentials(app: AppHandle, url: String) -> Result, String> { + let url_hash = hash_url(&url); + let credentials_key = format!("{}:{}", CREDENTIALS_SERVICE_PREFIX, url_hash); + + let store = app + .store(SETTINGS_STORE) + .map_err(|e| format!("Failed to open settings store: {}", e))?; + + let credentials_value = store.get(&credentials_key); + let credentials = match credentials_value { + Some(v) => v, + None => return Ok(None), + }; + + let username = credentials["username"] + .as_str() + .ok_or("Missing username in credentials")? + .to_string(); + let password = credentials["password"] + .as_str() + .ok_or("Missing password in credentials")? + .to_string(); + + Ok(Some((username, password))) +} + +#[tauri::command] +async fn remove_server_credentials(app: AppHandle, url: String) -> Result<(), String> { + let url_hash = hash_url(&url); + let credentials_key = format!("{}:{}", CREDENTIALS_SERVICE_PREFIX, url_hash); + + let store = app + .store(SETTINGS_STORE) + .map_err(|e| format!("Failed to open settings store: {}", e))?; + + // Remove credentials + store.delete(&credentials_key); + + // Update index + let index_value = store.get(CREDENTIALS_INDEX_KEY); + let index: Vec = if let Some(v) = index_value { + if let Some(arr) = v.as_array() { + arr.iter() + .filter_map(|v| v.as_str().map(String::from)) + .filter(|h| h != &url_hash) + .collect() + } else { + vec![] + } + } else { + vec![] + }; + + store.set(CREDENTIALS_INDEX_KEY, serde_json::Value::Array( + index.iter().map(|h| serde_json::Value::String(h.clone())).collect() + )); + + store.save().map_err(|e| format!("Failed to save settings: {}", e))?; + + Ok(()) +} + +#[tauri::command] +async fn list_server_credentials(app: AppHandle) -> Result, String> { + let store = app + .store(SETTINGS_STORE) + .map_err(|e| format!("Failed to open settings store: {}", e))?; + + let index_value = store.get(CREDENTIALS_INDEX_KEY); + match index_value { + Some(v) => { + if let Some(arr) = v.as_array() { + Ok(arr + .iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect()) + } else { + Ok(vec![]) + } + } + None => Ok(vec![]), + } +} + fn get_sidecar_port() -> u32 { option_env!("OPENCODE_PORT") .map(|s| s.to_string()) @@ -252,7 +393,11 @@ pub fn run() { install_cli, ensure_server_ready, get_default_server_url, - set_default_server_url + set_default_server_url, + store_server_credentials, + get_server_credentials, + remove_server_credentials, + list_server_credentials ]) .setup(move |app| { let app = app.handle().clone(); diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index 5d699bb90c5..b5d8813060d 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -28,7 +28,7 @@ if (import.meta.env.DEV && !(root instanceof HTMLElement)) { let update: Update | null = null -const createPlatform = (password: Accessor): Platform => ({ +const createPlatform = (password: Accessor, serverUrl: Accessor): Platform => ({ platform: "desktop", version: pkg.version, @@ -257,17 +257,35 @@ const createPlatform = (password: Accessor): Platform => ({ // @ts-expect-error fetch: (input, init) => { const pw = password() + const desktopServerUrl = serverUrl()!.replace(/\/$/, "") const addHeader = (headers: Headers, password: string) => { headers.append("Authorization", `Basic ${btoa(`opencode:${password}`)}`) } if (input instanceof Request) { - if (pw) addHeader(input.headers, pw) + const url = input.url + const normalizedUrl = url.replace(/\/$/, "") + + if (normalizedUrl.startsWith(desktopServerUrl)) { + if (pw) addHeader(input.headers, pw) + } + return tauriFetch(input) } else { const headers = new Headers(init?.headers) - if (pw) addHeader(headers, pw) + let normalizedUrl: string + + if (input instanceof URL) { + normalizedUrl = input.toString().replace(/\/$/, "") + } else { + normalizedUrl = input.replace(/\/$/, "") + } + + if (desktopServerUrl && normalizedUrl.startsWith(desktopServerUrl)) { + if (pw) addHeader(headers, pw) + } + return tauriFetch(input, { ...(init as any), headers: headers, @@ -275,6 +293,60 @@ const createPlatform = (password: Accessor): Platform => ({ } }, + // fetch: async (input, init) => { + // // Extract URL from input + // let url: string + // if (input instanceof Request) { + // url = input.url + // } else if (typeof input === "string") { + // url = input + // } else { + // // Fallback to default behavior if we can't extract URL + // return tauriFetch(input, init) + // } + + // // Normalize URL (remove trailing slash, etc.) + // const normalizedUrl = url.replace(/\/$/, "") + // const desktopUrl = serverUrl()?.replace(/\/$/, "") + + // let authHeader: string | undefined + + // // Check if this is the desktop's own server + // if (desktopUrl && normalizedUrl.startsWith(desktopUrl)) { + // // Use the password signal for desktop's own server + // const pw = password() + // if (pw) { + // authHeader = `Basic ${btoa(`opencode:${pw}`)}` + // } + // } else { + // // Fetch credentials from store for other servers + // try { + // const result = await invoke<[string, string] | null>("get_server_credentials", { url }) + // if (result) { + // authHeader = `Basic ${btoa(`${result[0]}:${result[1]}`)}` + // } + // } catch { + // // Ignore errors, proceed without auth + // } + // } + + // const addHeader = (headers: Headers, header: string) => { + // headers.append("Authorization", header) + // } + + // if (input instanceof Request) { + // if (authHeader) addHeader(input.headers, authHeader) + // return tauriFetch(input) + // } else { + // const headers = new Headers(init?.headers) + // if (authHeader) addHeader(headers, authHeader) + // return tauriFetch(input, { + // ...(init as any), + // headers: headers, + // }) + // } + // }, + getDefaultServerUrl: async () => { const result = await invoke("get_default_server_url").catch(() => null) return result @@ -283,6 +355,24 @@ const createPlatform = (password: Accessor): Platform => ({ setDefaultServerUrl: async (url: string | null) => { await invoke("set_default_server_url", { url }) }, + + storeServerCredentials: async (url: string, username: string, password: string) => { + await invoke("store_server_credentials", { url, username, password }) + }, + + getServerCredentials: async (url: string) => { + const result = await invoke<[string, string] | null>("get_server_credentials", { url }) + if (!result) return null + return { username: result[0], password: result[1] } + }, + + removeServerCredentials: async (url: string) => { + await invoke("remove_server_credentials", { url }) + }, + + listServerCredentials: async () => { + return await invoke("list_server_credentials") + }, }) createMenu() @@ -294,7 +384,11 @@ root?.addEventListener("mousewheel", (e) => { render(() => { const [serverPassword, setServerPassword] = createSignal(null) - const platform = createPlatform(() => serverPassword()) + const [serverUrl, setServerUrl] = createSignal(null) + const platform = createPlatform( + () => serverPassword(), + () => serverUrl(), + ) return ( @@ -305,6 +399,7 @@ render(() => { {(data) => { setServerPassword(data().password) + setServerUrl(data().url) window.__OPENCODE__ ??= {} window.__OPENCODE__.serverPassword = data().password ?? undefined diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 7015c818822..ea6fb9751ef 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -97,7 +97,29 @@ export namespace Server { status: 500, }) }) + .use( + cors({ + origin(input) { + if (!input) return + + if (input.startsWith("http://localhost:")) return input + if (input.startsWith("http://127.0.0.1:")) return input + if (input === "tauri://localhost" || input === "http://tauri.localhost") return input + + // *.opencode.ai (https only, adjust if needed) + if (/^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/.test(input)) { + return input + } + if (_corsWhitelist.includes(input)) { + return input + } + + return + }, + }), + ) .use((c, next) => { + if (c.req.path === "/global/health") return next() const password = Flag.OPENCODE_SERVER_PASSWORD if (!password) return next() const username = Flag.OPENCODE_SERVER_USERNAME ?? "opencode" @@ -120,27 +142,6 @@ export namespace Server { timer.stop() } }) - .use( - cors({ - origin(input) { - if (!input) return - - if (input.startsWith("http://localhost:")) return input - if (input.startsWith("http://127.0.0.1:")) return input - if (input === "tauri://localhost" || input === "http://tauri.localhost") return input - - // *.opencode.ai (https only, adjust if needed) - if (/^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/.test(input)) { - return input - } - if (_corsWhitelist.includes(input)) { - return input - } - - return - }, - }), - ) .get( "/global/health", describeRoute({ @@ -152,14 +153,24 @@ export namespace Server { description: "Health information", content: { "application/json": { - schema: resolver(z.object({ healthy: z.literal(true), version: z.string() })), + schema: resolver( + z.object({ + healthy: z.literal(true), + version: z.string(), + authenticated: z.boolean().optional(), + }), + ), }, }, }, }, }), async (c) => { - return c.json({ healthy: true, version: Installation.VERSION }) + return c.json({ + healthy: true, + version: Installation.VERSION, + authenticated: !!Flag.OPENCODE_SERVER_PASSWORD, + }) }, ) .get( diff --git a/packages/plugin/package.json b/packages/plugin/package.json index f468c3d0e13..3e9868a116a 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -25,4 +25,4 @@ "typescript": "catalog:", "@typescript/native-preview": "catalog:" } -} \ No newline at end of file +} diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 11deb33defc..7602b591552 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -30,4 +30,4 @@ "publishConfig": { "directory": "dist" } -} \ No newline at end of file +} From f9d1a7ebf6e89f33ecd2230dc4807a3bb65575c6 Mon Sep 17 00:00:00 2001 From: OpeOginni Date: Wed, 14 Jan 2026 19:13:13 +0100 Subject: [PATCH 2/2] chore: clean up unused code and comments --- packages/app/src/pages/home.tsx | 1 - packages/desktop/src/index.tsx | 54 --------------------------------- 2 files changed, 55 deletions(-) diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index 621098cb413..275113566ad 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -59,7 +59,6 @@ export default function Home() { size="large" variant="ghost" class="mt-4 mx-auto text-14-regular text-text-weak" - // onClick={() => dialog.show(() => )} onClick={() => dialog.show(() => )} >
, serverUrl: Accessor { - // // Extract URL from input - // let url: string - // if (input instanceof Request) { - // url = input.url - // } else if (typeof input === "string") { - // url = input - // } else { - // // Fallback to default behavior if we can't extract URL - // return tauriFetch(input, init) - // } - - // // Normalize URL (remove trailing slash, etc.) - // const normalizedUrl = url.replace(/\/$/, "") - // const desktopUrl = serverUrl()?.replace(/\/$/, "") - - // let authHeader: string | undefined - - // // Check if this is the desktop's own server - // if (desktopUrl && normalizedUrl.startsWith(desktopUrl)) { - // // Use the password signal for desktop's own server - // const pw = password() - // if (pw) { - // authHeader = `Basic ${btoa(`opencode:${pw}`)}` - // } - // } else { - // // Fetch credentials from store for other servers - // try { - // const result = await invoke<[string, string] | null>("get_server_credentials", { url }) - // if (result) { - // authHeader = `Basic ${btoa(`${result[0]}:${result[1]}`)}` - // } - // } catch { - // // Ignore errors, proceed without auth - // } - // } - - // const addHeader = (headers: Headers, header: string) => { - // headers.append("Authorization", header) - // } - - // if (input instanceof Request) { - // if (authHeader) addHeader(input.headers, authHeader) - // return tauriFetch(input) - // } else { - // const headers = new Headers(init?.headers) - // if (authHeader) addHeader(headers, authHeader) - // return tauriFetch(input, { - // ...(init as any), - // headers: headers, - // }) - // } - // }, - getDefaultServerUrl: async () => { const result = await invoke("get_default_server_url").catch(() => null) return result