Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/remove-local-credential-import.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 0 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
3 changes: 1 addition & 2 deletions docs/setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
4 changes: 0 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
122 changes: 3 additions & 119 deletions src/auth/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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<void>((resolve) => { release = resolve; });
const importStarted = new Promise<void>((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);
});
85 changes: 0 additions & 85 deletions src/auth/auth.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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;
Expand Down Expand Up @@ -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<ApiKeys> {
Expand Down Expand Up @@ -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<ConsoleSession | undefined> {
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<string[]> {
let candidates: string[] = [];
try {
Expand Down Expand Up @@ -365,7 +325,6 @@ export class OpenCodeAuth {

private async storeConsoleSession(session: ConsoleSession, profile: string): Promise<void> {
await this.secrets.store(consoleSessionKey(profile), JSON.stringify(session));
await this.secrets.store(consoleImportStateKey(profile), "managed");
await this.mutateConsoleProfileIndex((profiles) => { profiles.add(profile); });
}

Expand Down Expand Up @@ -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<ConsoleSession | undefined> {
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<string, unknown> | 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<any> {
const sqlite = await import("node:sqlite");
return new sqlite.DatabaseSync(this.path, { readOnly: true, timeout: 5000 });
}

private async withDatabase<T>(operation: (database: any) => T): Promise<T> {
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<ConsoleSession>;
Expand Down
Loading