diff --git a/.changeset/remove-local-credential-import.md b/.changeset/remove-local-credential-import.md new file mode 100644 index 0000000..4e28d83 --- /dev/null +++ b/.changeset/remove-local-credential-import.md @@ -0,0 +1,5 @@ +--- +"opencode-bridge-copilot-chat": minor +--- + +Remove all local OpenCode credential and session importing. OpenCode Console accounts must now use an explicit device-code sign-in initiated from VS Code, and credentials remain in VS Code Secret Storage. diff --git a/README.md b/README.md index 946b900..8b7d278 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,6 @@ This native VS Code `LanguageModelChatProvider` registers Zen, Go, and Console a - Separate OpenCode Zen, Go, and Console model groups - API-key and Console device-code authentication in VS Code Secret Storage -- Read-only, one-way import of an existing local Console session - Credential-scoped live discovery, with six-hour persisted models.dev enrichment for Zen and Go - Streaming text, reasoning, image inputs, and agent-mode tool calls - Model-specific Thinking Effort and Qwen thinking-budget controls diff --git a/docs/security.md b/docs/security.md index 08e484c..9fc2bc1 100644 --- a/docs/security.md +++ b/docs/security.md @@ -2,7 +2,7 @@ Zen and Go API keys added through **Manage Language Models** are marked as secret provider configuration, so VS Code stores every entry securely. The extension places only a short SHA-256-derived reference in model metadata and local usage state. The older command-managed Zen and Go defaults remain in Secret Storage for migration and development workflows. -Console access and refresh tokens are stored per named profile in VS Code Secret Storage, with separate refresh locks and organization selection. When no VS Code-managed default Console session exists, the extension can read the active account from `~/.local/share/opencode/opencode.db` and import it once. The database is always opened read-only; device sign-in, token refresh, organization selection, and sign-out never modify it. +Console access and refresh tokens are obtained only through an explicit device-code sign-in initiated in VS Code and stored per named profile in VS Code Secret Storage, with separate refresh locks and organization selection. The extension does not read credentials or sessions from OpenCode or other applications on the local machine. The output channel records only status and metadata. It does not record API keys, access tokens, refresh tokens, prompts, response text, or account data. diff --git a/docs/setup.md b/docs/setup.md index 12c61ee..6080f51 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -5,10 +5,9 @@ Open **Manage Language Models** and use **Add Models** for each OpenCode entry y - Zen and Go accept an API key directly in the native provider form. VS Code stores the field as a secret. Add another named entry to use another account or key. - Console first requires **OpenCode: Add Console Account**. Choose a profile ID, complete device authorization and organization selection, then enter the same profile ID when adding the native Console provider entry. Leaving the profile field empty preserves the legacy `default` Console account. - **OpenCode: Select Console Profile for Usage and Management** chooses the profile targeted by those commands. VS Code restores it after restart; it does not change the account attached to a native model entry. -- **OpenCode Console: Import Local Session** performs a read-only, one-way import from the active account in local `opencode.db` when that file and schema are available. Zen, Go, and Console remain separate providers, and each provider can now have multiple named entries. `opencode.defaultMode` only chooses the provider targeted by generic legacy connection, refresh, and test commands; it does not hide other entries. Console model discovery is profile- and organization-scoped and is read from `/api/config` with `x-org-id`; models marked `disabled` in that response are excluded. -After device sign-in or local import, VS Code Secret Storage is authoritative. Token refreshes, organization changes, and sign-out do not read from or write to `opencode.db`. Automatic import is attempted only when VS Code has never managed or signed out a Console session; after sign-out, use the explicit import command to import again. If the organization config is unavailable, no Console models are shown. +After device sign-in, VS Code Secret Storage is authoritative. Token refreshes, organization changes, and sign-out operate only on the session managed by the extension. The extension does not read credentials or sessions from local OpenCode data. If the organization config is unavailable, no Console models are shown. Reasoning-capable models show a **Thinking** or **Thinking Effort** submenu in the Copilot Chat model picker. The extension derives the choices from OpenCode's live `reasoning_options` metadata when available and maps the selected value to the request format required by that model family. Qwen models can also expose a separate thinking-token budget control. diff --git a/package.json b/package.json index 31a8391..2758bcf 100644 --- a/package.json +++ b/package.json @@ -63,10 +63,6 @@ "command": "opencodeCopilot.manageConsole", "title": "OpenCode Console: Manage Connection" }, - { - "command": "opencodeCopilot.importConsoleSession", - "title": "OpenCode Console: Import Local Session" - }, { "command": "opencodeCopilot.addConsoleAccount", "title": "OpenCode: Add Console Account" diff --git a/src/auth/auth.test.ts b/src/auth/auth.test.ts index 65d5cd7..27d70af 100644 --- a/src/auth/auth.test.ts +++ b/src/auth/auth.test.ts @@ -37,16 +37,13 @@ test("completes device flow and selects an organization", async () => { return new Response(JSON.stringify([{ id: "org-2", name: "Beta" }, { id: "org-1", name: "Alpha" }])); }; const now = () => 1000; - let localReads = 0; - const localImporter = { async readActiveSession() { localReads += 1; return undefined; } }; - const auth = new OpenCodeAuth(new Secrets() as never, fetcher, now, async () => undefined, localImporter as never); + const auth = new OpenCodeAuth(new Secrets() as never, fetcher, now, async () => undefined); const device: DeviceCode = { deviceCode: "device", userCode: "ABCD", verificationUrl: "https://example.test", expiresAt: 100_000, intervalMs: 1, server: "https://example.test" }; const session = await auth.completeDeviceSignIn(device); assert.equal(session.orgId, "org-1"); assert.equal(session.orgName, "Alpha"); await auth.selectOrganization({ id: "org-2", name: "Beta" }); assert.equal((await auth.getConsoleSession())?.orgId, "org-2"); - assert.equal(localReads, 0); }); test("stores named Console sessions separately", async () => { @@ -199,127 +196,14 @@ test("does not persist an organization change that finishes after sign-out", asy assert.equal(await auth.getConsoleSession("work"), undefined); }); -test("does not persist a local import that finishes after sign-out", async () => { - const secrets = new Secrets(); - let release!: () => void; - let started!: () => void; - const wait = new Promise((resolve) => { release = resolve; }); - const importStarted = new Promise((resolve) => { started = resolve; }); - const localImporter = { async readActiveSession() { - started(); - await wait; - return { - mode: "console" as const, server: "https://example.test", accessToken: "access", refreshToken: "refresh", - expiresAt: Date.now() + 3600_000, accountId: "work", email: "work@example.com", orgs: [], - }; - } }; - const auth = new OpenCodeAuth( - secrets as never, - async () => Response.json([]), - Date.now, - undefined, - localImporter as never, - ); - const importing = auth.importLocalConsoleSession(true, "work"); - await importStarted; - await auth.signOut("console", "work"); - release(); - await assert.rejects(importing, /was superseded/); - assert.equal(await auth.getConsoleSession("work"), undefined); -}); - -test("signing out of Console clears only the VS Code-managed session", async () => { +test("signing out of Console clears the VS Code-managed session", async () => { const secrets = new Secrets(); await secrets.store("opencode.consoleSession.v1", JSON.stringify({ mode: "console", server: "https://example.test", accessToken: "access", refreshToken: "refresh", expiresAt: Date.now() + 3600_000, accountId: "account", email: "user@example.com", orgs: [], orgId: "org", })); - let localReads = 0; - const localImporter = { async readActiveSession() { localReads += 1; return undefined; } }; - const auth = new OpenCodeAuth(secrets as never, fetch, Date.now, undefined, localImporter as never); + const auth = new OpenCodeAuth(secrets as never); assert.ok(await auth.getConsoleSession()); await auth.signOut("console"); assert.equal(await auth.getConsoleSession(), undefined); - assert.equal(localReads, 0); -}); - -test("keeps the VS Code organization authoritative after local import", async () => { - const secrets = new Secrets(); - let localReads = 0; - const localImporter = { - async readActiveSession() { - localReads += 1; - return { - mode: "console" as const, - server: "https://example.test", - accessToken: "local-access", - refreshToken: "local-refresh", - expiresAt: 1000, - accountId: "account", - email: "user@example.com", - orgs: [], - orgId: "org-new", - }; - }, - }; - await secrets.store("opencode.consoleSession.v1", JSON.stringify({ - mode: "console", server: "https://example.test", accessToken: "access", refreshToken: "refresh", expiresAt: 1000, - accountId: "account", email: "user@example.com", orgs: [{ id: "org-old", name: "Old org" }, { id: "org-new", name: "New org" }], orgId: "org-old", orgName: "Old org", - })); - const auth = new OpenCodeAuth(secrets as never, fetch, Date.now, undefined, localImporter as never); - assert.equal((await auth.getConsoleSession())?.orgId, "org-old"); - assert.equal((await auth.getConsoleSession())?.accessToken, "access"); - assert.equal(localReads, 0); -}); - -test("imports and hydrates a local Console session once into Secret Storage", async () => { - const secrets = new Secrets(); - let localReads = 0; - const localImporter = { - async readActiveSession() { - localReads += 1; - return { - mode: "console" as const, - server: "https://example.test", - accessToken: "access", - refreshToken: "refresh", - expiresAt: Date.now() + 3600_000, - accountId: "account", - email: "user@example.com", - orgs: [], - orgId: "org-2", - }; - }, - }; - const fetcher: typeof fetch = async () => new Response(JSON.stringify([ - { id: "org-2", name: "Beta" }, - { id: "org-1", name: "Alpha" }, - ])); - const auth = new OpenCodeAuth(secrets as never, fetcher, Date.now, undefined, localImporter as never); - const session = await auth.importLocalConsoleSession(); - assert.deepEqual(session?.orgs, [{ id: "org-1", name: "Alpha" }, { id: "org-2", name: "Beta" }]); - assert.equal(session?.orgName, "Beta"); - assert.equal((await auth.getConsoleSession())?.orgName, "Beta"); - assert.equal(await auth.importLocalConsoleSession(), undefined); - assert.equal(localReads, 1); -}); - -test("does not automatically re-import after a VS Code sign-out", async () => { - const secrets = new Secrets(); - let localReads = 0; - const localImporter = { async readActiveSession() { - localReads += 1; - return { - mode: "console" as const, server: "https://example.test", accessToken: "access", refreshToken: "refresh", - expiresAt: Date.now() + 3600_000, accountId: "account", email: "user@example.com", orgs: [], - }; - } }; - const fetcher: typeof fetch = async () => new Response(JSON.stringify([])); - const auth = new OpenCodeAuth(secrets as never, fetcher, Date.now, undefined, localImporter as never); - assert.ok(await auth.importLocalConsoleSession()); - await auth.signOut("console"); - assert.equal(await auth.importLocalConsoleSession(), undefined); - assert.equal(localReads, 1); - assert.ok(await auth.importLocalConsoleSession(true)); - assert.equal(localReads, 2); }); diff --git a/src/auth/auth.ts b/src/auth/auth.ts index 48b1160..4683813 100644 --- a/src/auth/auth.ts +++ b/src/auth/auth.ts @@ -1,11 +1,8 @@ -import { homedir } from "node:os"; -import { join } from "node:path"; import type * as vscode from "vscode"; import { DEFAULT_CONSOLE_SERVER, OPENCODE_CLIENT_ID, type OpenCodeMode } from "../transport/protocol"; const API_KEYS_KEY = "opencode.apiKeys.v1"; const CONSOLE_SESSION_KEY = "opencode.consoleSession.v1"; -const CONSOLE_IMPORT_STATE_KEY = "opencode.consoleImportState.v1"; const CONSOLE_PROFILES_KEY = "opencode.consoleProfiles.v1"; export const DEFAULT_CONSOLE_PROFILE = "default"; @@ -21,10 +18,6 @@ function consoleSessionKey(profile: string): string { return profile === DEFAULT_CONSOLE_PROFILE ? CONSOLE_SESSION_KEY : `${CONSOLE_SESSION_KEY}.${profile}`; } -function consoleImportStateKey(profile: string): string { - return profile === DEFAULT_CONSOLE_PROFILE ? CONSOLE_IMPORT_STATE_KEY : `${CONSOLE_IMPORT_STATE_KEY}.${profile}`; -} - export interface ApiKeys { zen?: string; go?: string; @@ -79,7 +72,6 @@ export class OpenCodeAuth { private readonly fetcher: Fetcher = fetch, private readonly now: () => number = Date.now, private readonly sleep: Sleeper = delay, - private readonly localImporter: LocalOpenCodeSessionImporter = new LocalOpenCodeSessionImporter(), ) {} async getApiKeys(): Promise { @@ -250,42 +242,10 @@ export class OpenCodeAuth { this.invalidateProfile(normalized); await this.mutateSession(normalized, async () => { await this.secrets.delete(consoleSessionKey(normalized)); - await this.secrets.store(consoleImportStateKey(normalized), "signed-out"); await this.mutateConsoleProfileIndex((profiles) => profiles.delete(normalized)); }); } - async importLocalConsoleSession( - force = false, - profile = DEFAULT_CONSOLE_PROFILE, - ): Promise { - const normalized = normalizeConsoleProfile(profile); - const startingGeneration = this.profileGeneration(normalized); - if (await this.getConsoleSession(normalized)) return undefined; - if (!force && await this.secrets.get(consoleImportStateKey(normalized))) return undefined; - if (this.profileGeneration(normalized) !== startingGeneration) { - throw new Error(`OpenCode Console import for profile “${normalized}” was superseded`); - } - const generation = this.beginSessionReplacement(normalized); - const imported = await this.localImporter.readActiveSession(); - if (!imported) return undefined; - const current = imported.expiresAt > this.now() + 5 * 60_000 - ? imported - : await this.refreshConsoleSession(imported, normalized, false); - const orgs = normalizeOrganizations(await this.getJson(current.server, "/api/orgs", current.accessToken) as unknown[]); - const org = current.orgId - ? orgs.find((item) => item.id === current.orgId) - : orgs[0]; - const hydrated: ConsoleSession = { - ...current, - orgs, - ...(org ? { orgId: org.id, orgName: org.name } : {}), - }; - await this.saveConsoleSession(hydrated, normalized, generation); - await this.secrets.store(consoleImportStateKey(normalized), "imported"); - return hydrated; - } - async listConsoleProfiles(): Promise { let candidates: string[] = []; try { @@ -365,7 +325,6 @@ export class OpenCodeAuth { private async storeConsoleSession(session: ConsoleSession, profile: string): Promise { await this.secrets.store(consoleSessionKey(profile), JSON.stringify(session)); - await this.secrets.store(consoleImportStateKey(profile), "managed"); await this.mutateConsoleProfileIndex((profiles) => { profiles.add(profile); }); } @@ -424,50 +383,6 @@ function consoleSessionIdentity(session: ConsoleSession): string { return `${session.accessToken}\u0000${session.refreshToken}\u0000${session.expiresAt}`; } -export class LocalOpenCodeSessionImporter { - private readonly path = join(process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share"), "opencode", "opencode.db"); - - async readActiveSession(): Promise { - try { - const row = await this.withDatabase((db) => db.prepare("SELECT a.id, a.email, a.url, a.access_token, a.refresh_token, a.token_expiry, s.active_org_id FROM account a JOIN account_state s ON s.active_account_id = a.id WHERE s.id = 1").get() as Record | undefined); - if (!row) return undefined; - const accessToken = string(row.access_token); - const refreshToken = string(row.refresh_token); - const accountId = string(row.id); - const email = string(row.email); - const server = string(row.url); - if (!accessToken || !refreshToken || !accountId || !email || !server) return undefined; - return { - mode: "console", - server, - accessToken, - refreshToken, - expiresAt: typeof row.token_expiry === "number" ? row.token_expiry : 0, - accountId, - email, - orgs: [], - ...(string(row.active_org_id) ? { orgId: string(row.active_org_id) } : {}), - }; - } catch { - return undefined; - } - } - - private async open(): Promise { - const sqlite = await import("node:sqlite"); - return new sqlite.DatabaseSync(this.path, { readOnly: true, timeout: 5000 }); - } - - private async withDatabase(operation: (database: any) => T): Promise { - const database = await this.open(); - try { - return operation(database); - } finally { - database.close(); - } - } -} - function parseSession(raw: string): ConsoleSession | undefined { try { const value = JSON.parse(raw) as Partial; diff --git a/src/commands/commands.ts b/src/commands/commands.ts index 2f94c90..5e37c22 100644 --- a/src/commands/commands.ts +++ b/src/commands/commands.ts @@ -21,7 +21,6 @@ export function registerCommands( vscode.commands.registerCommand("opencodeCopilot.manageZen", () => manage(auth, providers, output, "zen")), vscode.commands.registerCommand("opencodeCopilot.manageGo", () => manage(auth, providers, output, "go")), vscode.commands.registerCommand("opencodeCopilot.manageConsole", () => manage(auth, providers, output, "console")), - vscode.commands.registerCommand("opencodeCopilot.importConsoleSession", () => importConsoleSession(auth, providers.console, output)), vscode.commands.registerCommand("opencodeCopilot.addConsoleAccount", () => addConsoleAccount(auth, providers.console, output)), vscode.commands.registerCommand("opencodeCopilot.selectConsoleProfile", () => selectConsoleProfile(auth, providers.console)), vscode.commands.registerCommand("opencodeCopilot.setInlineSuggestionsModel", () => setInlineSuggestionsModel()), @@ -55,7 +54,6 @@ async function manage(auth: OpenCodeAuth, providers: OpenCodeProviders, output: { label: "$(device-mobile) Sign in with OpenCode Console device code", action: "console" }, { label: "$(add) Add named Console account", action: "addConsole" }, { label: "$(account) Select Console profile for usage and management", action: "profile" }, - { label: "$(database) Import Console session from local OpenCode", action: "importConsole" }, { label: "$(output) Show OpenCode logs", action: "logs" }, ]; const picked = await vscode.window.showQuickPick(choices, { title: `OpenCode — ${signedIn ? `${label(mode)} connected` : "not connected"}${mode === "console" ? ` [${profile}]` : ""}` }); @@ -72,7 +70,6 @@ async function manage(auth: OpenCodeAuth, providers: OpenCodeProviders, output: else if (picked.action === "switch") await chooseModeAndSignIn(auth, providers, output); else if (picked.action === "zen" || picked.action === "go") await signInWithApiKey(auth, providers[picked.action], picked.action); else if (picked.action === "console") await signInWithConsole(auth, providers.console, output, profile); - else if (picked.action === "importConsole") await importConsoleSession(auth, providers.console, output); } async function chooseModeAndSignIn(auth: OpenCodeAuth, providers: OpenCodeProviders, output: vscode.OutputChannel): Promise { @@ -80,31 +77,12 @@ async function chooseModeAndSignIn(auth: OpenCodeAuth, providers: OpenCodeProvid { label: "OpenCode Zen API key", mode: "zen" as const }, { label: "OpenCode Go API key", mode: "go" as const }, { label: "OpenCode Console device code", mode: "console" as const }, - { label: "Import Console session from local OpenCode", mode: "importConsole" as const }, ], { title: "Choose an OpenCode credential mode" }); if (!picked) return; if (picked.mode === "console") await signInWithConsole(auth, providers.console, output); - else if (picked.mode === "importConsole") await importConsoleSession(auth, providers.console, output); else await signInWithApiKey(auth, providers[picked.mode], picked.mode); } -async function importConsoleSession(auth: OpenCodeAuth, provider: OpenCodeProvider, output: vscode.OutputChannel): Promise { - try { - const session = await auth.importLocalConsoleSession(true); - if (!session) { - vscode.window.showInformationMessage("No importable OpenCode Console session was found in the local opencode.db."); - return; - } - await setMode("console"); - provider.setActiveConsoleProfile(DEFAULT_CONSOLE_PROFILE); - const models = await provider.refreshModels(); - vscode.window.showInformationMessage(`Imported OpenCode Console into VS Code${session.orgName ? ` for ${session.orgName}` : ""}. Found ${models.length} allowed models.`); - } catch (error) { - output.appendLine(`[console] local import failed: ${messageOf(error)}`); - vscode.window.showErrorMessage(`Unable to import the local OpenCode Console session: ${messageOf(error)}`); - } -} - async function signInWithApiKey(auth: OpenCodeAuth, provider: OpenCodeProvider, mode: "zen" | "go"): Promise { const key = await vscode.window.showInputBox({ title: `OpenCode ${label(mode)} API key`, diff --git a/src/extension.ts b/src/extension.ts index 6dc55b7..6d4f418 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -86,11 +86,6 @@ export function activate(context: vscode.ExtensionContext): void { context.subscriptions.push(new vscode.Disposable(() => clearInterval(releaseTimer))); void checkForRelease(context, version, userAgent, output); output.appendLine(`[activate] OpenCode for Copilot Chat ${version} on VS Code ${vscode.version}`); - void auth.importLocalConsoleSession().then((session) => { - if (!session) return; - output.appendLine("[auth] imported a local OpenCode Console session into VS Code Secret Storage"); - providers.console.fireDidChange(); - }).catch((error) => output.appendLine(`[auth] local OpenCode database import failed: ${messageOf(error)}`)); } async function checkForRelease(