From 9532a996aae2d590d41ea8a603a2104b3a892808 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Thu, 20 Aug 2026 02:31:07 +0200 Subject: [PATCH 1/9] feat(onboarding): add credential_scanner module with TDD tests (#449) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the core scanning logic for auto-import credentials: - Scans 5 sources in priority order (opencode account/auth, env, RC, Claude) - Deduplicates by provider (first source wins) - Normalizes provider IDs (opencode-go → opencode, etc.) - Shell RC parsing via strict regex (no eval/subshell) - webviewSafeResults strips keys for host→webview messages - writeBatchConfig writes all providers in one pass 25 tests covering priority, normalization, security, error handling. --- packages/extension/src/credential_scanner.ts | 316 +++++++++++ .../extension/test/credential_scanner.test.ts | 504 ++++++++++++++++++ 2 files changed, 820 insertions(+) create mode 100644 packages/extension/src/credential_scanner.ts create mode 100644 packages/extension/test/credential_scanner.test.ts diff --git a/packages/extension/src/credential_scanner.ts b/packages/extension/src/credential_scanner.ts new file mode 100644 index 00000000..1f40fe45 --- /dev/null +++ b/packages/extension/src/credential_scanner.ts @@ -0,0 +1,316 @@ +// Credential Scanner — Auto-Import Credentials (#449) +// +// Scans flat-file credential sources in priority order, deduplicates by provider, +// and returns detected credentials. Keys NEVER leave the extension host process. +// +// Source priority (first hit per provider wins): +// 1. ~/.local/share/opencode/account.json (v2) +// 2. ~/.local/share/opencode/auth.json (v1) +// 3. process.env +// 4. Shell RC files (~/.zshrc, ~/.bashrc, ~/.zprofile, ~/.bash_profile) +// 5. ~/.claude/.credentials.json (type: "api" only) + +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as os from "node:os"; + +import { PROVIDER_MODELS, writeOnboardingConfig } from "./onboarding_panel"; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +export interface DetectedCredential { + provider: string; + key: string; + source: string; +} + +export interface ScanOptions { + accountJsonPath: string; + authJsonPath: string; + env: Record; + rcPaths: string[]; + claudeCredPath: string; +} + +export interface ScanResult { + credentials: DetectedCredential[]; +} + +/** Webview-safe representation — NO key material. */ +export interface SafeCredential { + provider: string; + source: string; + model: string; +} + +// ─── Env var → provider mapping ────────────────────────────────────────────── + +const ENV_TO_PROVIDER: Record = { + ANTHROPIC_API_KEY: "anthropic", + OPENAI_API_KEY: "openai", + GOOGLE_API_KEY: "google", + OPENROUTER_API_KEY: "openrouter", + OPENCODE_API_KEY: "opencode", +}; + +/** Env vars to scan in process.env and shell RC files. */ +const SCANNABLE_ENV_VARS = Object.keys(ENV_TO_PROVIDER); + +// ─── Provider ID normalization ─────────────────────────────────────────────── + +const PROVIDER_ALIASES: Record = { + "opencode-go": "opencode", +}; + +function normalizeProviderId(raw: string): string { + return PROVIDER_ALIASES[raw] ?? raw; +} + +// ─── Provider → env var (for config writing) ───────────────────────────────── + +const PROVIDER_ENV_VAR: Record = { + anthropic: "ANTHROPIC_API_KEY", + openai: "OPENAI_API_KEY", + google: "GOOGLE_API_KEY", + opencode: "OPENCODE_API_KEY", + openrouter: "OPENROUTER_API_KEY", +}; + +// ─── Scanner ───────────────────────────────────────────────────────────────── + +/** Default scan options using standard paths. */ +export function defaultScanOptions(): ScanOptions { + const home = os.homedir(); + const dataDir = path.join(home, ".local", "share", "opencode"); + return { + accountJsonPath: path.join(dataDir, "account.json"), + authJsonPath: path.join(dataDir, "auth.json"), + env: process.env as Record, + rcPaths: [ + path.join(home, ".zshrc"), + path.join(home, ".bashrc"), + path.join(home, ".zprofile"), + path.join(home, ".bash_profile"), + ], + claudeCredPath: path.join(home, ".claude", ".credentials.json"), + }; +} + +/** + * Scan for existing API credentials across all configured sources. + * Sources are checked in priority order; first hit per provider wins. + * Unreadable or malformed sources are skipped silently. + */ +export async function scanCredentials(options: ScanOptions): Promise { + const seen = new Set(); + const credentials: DetectedCredential[] = []; + + function add(provider: string, key: string, source: string): void { + const normalized = normalizeProviderId(provider); + if (seen.has(normalized)) return; + if (!key || key.trim() === "") return; + seen.add(normalized); + credentials.push({ provider: normalized, key: key.trim(), source }); + } + + // 1. opencode account.json (v2) + scanAccountJson(options.accountJsonPath, add); + + // 2. opencode auth.json (v1) + scanAuthJson(options.authJsonPath, add); + + // 3. Environment variables + scanEnv(options.env, add); + + // 4. Shell RC files + for (const rcPath of options.rcPaths) { + scanRcFile(rcPath, add); + } + + // 5. Claude Code .credentials.json + scanClaudeCredentials(options.claudeCredPath, add); + + return { credentials }; +} + +// ─── Source scanners ───────────────────────────────────────────────────────── + +type AddFn = (provider: string, key: string, source: string) => void; + +function scanAccountJson(filePath: string, add: AddFn): void { + try { + const raw = fs.readFileSync(filePath, "utf8"); + const data = JSON.parse(raw); + if (typeof data !== "object" || data === null) return; + + for (const [serviceId, entry] of Object.entries(data)) { + if (typeof entry === "object" && entry !== null && "token" in entry) { + const token = (entry as { token: unknown }).token; + if (typeof token === "string") { + add(serviceId, token, "opencode (account)"); + } + } + } + } catch { + // Skip unreadable/malformed files silently + } +} + +function scanAuthJson(filePath: string, add: AddFn): void { + try { + const raw = fs.readFileSync(filePath, "utf8"); + const data = JSON.parse(raw); + if (typeof data !== "object" || data === null) return; + + const providers = (data as { provider?: unknown }).provider; + if (typeof providers !== "object" || providers === null) return; + + for (const [providerId, entry] of Object.entries(providers)) { + if (typeof entry === "object" && entry !== null && "key" in entry) { + const key = (entry as { key: unknown }).key; + if (typeof key === "string") { + add(providerId, key, "opencode (auth)"); + } + } + } + } catch { + // Skip unreadable/malformed files silently + } +} + +function scanEnv(env: Record, add: AddFn): void { + for (const varName of SCANNABLE_ENV_VARS) { + const value = env[varName]; + if (typeof value === "string" && value.trim() !== "") { + add(ENV_TO_PROVIDER[varName], value, "environment"); + } + } +} + +/** + * Parse shell RC files using strict regex — NO eval, NO child_process, NO subshell. + * Matches: export VAR_NAME="value", export VAR_NAME='value', export VAR_NAME=value + * Skips commented lines and lines with subshell expansion $(...) or backticks. + */ +function scanRcFile(filePath: string, add: AddFn): void { + try { + const content = fs.readFileSync(filePath, "utf8"); + const basename = path.basename(filePath); + + for (const line of content.split("\n")) { + const trimmed = line.trim(); + // Skip comments + if (trimmed.startsWith("#")) continue; + + // Strict regex: export VAR=value (with optional quotes) + const match = trimmed.match(/^export\s+([\w]+)=["']?([^"'\s]*)["']?/); + if (!match) continue; + + const [, varName, value] = match; + if (!SCANNABLE_ENV_VARS.includes(varName)) continue; + if (!value || value.trim() === "") continue; + + // Skip lines with subshell expansion (security: never execute) + if (value.includes("$(") || value.includes("`")) continue; + + add(ENV_TO_PROVIDER[varName], value, basename); + } + } catch { + // Skip unreadable files silently + } +} + +function scanClaudeCredentials(filePath: string, add: AddFn): void { + try { + const raw = fs.readFileSync(filePath, "utf8"); + const data = JSON.parse(raw); + if (!Array.isArray(data)) return; + + for (const entry of data) { + if (typeof entry !== "object" || entry === null) continue; + // Only import type: "api" entries — NEVER OAuth tokens + if (entry.type !== "api") continue; + const provider = entry.provider; + const key = entry.key; + if (typeof provider === "string" && typeof key === "string") { + add(provider, key, "Claude Code"); + } + } + } catch { + // Skip unreadable/malformed files silently + } +} + +// ─── Webview-safe output (AC8) ─────────────────────────────────────────────── + +/** + * Convert detected credentials to a webview-safe format. + * Keys are STRIPPED — only provider names, sources, and default model IDs are included. + */ +export function webviewSafeResults(credentials: DetectedCredential[]): SafeCredential[] { + return credentials.map((c) => { + const models = PROVIDER_MODELS[c.provider]; + const defaultModel = models?.[0]?.id ?? `${c.provider}/unknown`; + return { + provider: c.provider, + source: c.source, + model: defaultModel, + }; + }); +} + +// ─── Batch config writing (AC7) ────────────────────────────────────────────── + +/** + * Write all detected providers to opencode.json in one pass. + * The `activeProvider` becomes the active `model` (using its first model entry). + * Uses the same schema as writeOnboardingConfig: provider..options.apiKey, env as string[]. + */ +export function writeBatchConfig( + credentials: DetectedCredential[], + activeProvider: string, + configPath?: string, +): void { + const targetPath = configPath ?? path.join(os.homedir(), ".config", "opencode", "opencode.json"); + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + + // Read existing config to merge + let existing: Record = {}; + try { + if (fs.existsSync(targetPath)) { + existing = JSON.parse(fs.readFileSync(targetPath, "utf8")); + } + } catch { + // Start fresh if parsing fails + } + + // Build provider entries + const providerEntry: Record = { + ...(existing.provider as Record ?? {}), + }; + + for (const cred of credentials) { + const entry: Record = {}; + if (cred.key) { + entry.options = { apiKey: cred.key }; + } + const envVar = PROVIDER_ENV_VAR[cred.provider]; + if (envVar) { + entry.env = [envVar]; + } + providerEntry[cred.provider] = entry; + } + + // Determine active model + const activeModels = PROVIDER_MODELS[activeProvider]; + const activeModel = activeModels?.[0]?.id ?? `${activeProvider}/unknown`; + + const result = { + ...existing, + $schema: "https://opencode.ai/config.json", + provider: providerEntry, + model: activeModel, + }; + + fs.writeFileSync(targetPath, JSON.stringify(result, null, 2) + "\n"); +} diff --git a/packages/extension/test/credential_scanner.test.ts b/packages/extension/test/credential_scanner.test.ts new file mode 100644 index 00000000..1d9fa576 --- /dev/null +++ b/packages/extension/test/credential_scanner.test.ts @@ -0,0 +1,504 @@ +// Credential Scanner tests — Auto-Import Credentials (#449) +// +// Tests the credential scanning module: source priority, deduplication, +// normalization, shell RC parsing safety, and the security invariant +// (keys never appear in webview-safe output). + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as os from "node:os"; + +import { + scanCredentials, + type DetectedCredential, + type ScanOptions, + type ScanResult, + webviewSafeResults, +} from "../src/credential_scanner"; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function makeTmpDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "cred-scan-")); +} + +function writeJson(dir: string, filename: string, data: unknown): string { + const p = path.join(dir, filename); + fs.mkdirSync(path.dirname(p), { recursive: true }); + fs.writeFileSync(p, JSON.stringify(data)); + return p; +} + +function writeText(dir: string, filename: string, content: string): string { + const p = path.join(dir, filename); + fs.mkdirSync(path.dirname(p), { recursive: true }); + fs.writeFileSync(p, content); + return p; +} + +// ─── Source priority and deduplication ─────────────────────────────────────── + +describe("scanCredentials — source priority (AC11)", () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = makeTmpDir(); + }); + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("returns credentials from opencode account.json (highest priority)", async () => { + const accountPath = writeJson(tmpDir, "account.json", { + anthropic: { serviceID: "anthropic", token: "sk-ant-from-account" }, + }); + const result = await scanCredentials({ + accountJsonPath: accountPath, + authJsonPath: "/nonexistent", + env: {}, + rcPaths: [], + claudeCredPath: "/nonexistent", + }); + expect(result.credentials.length).toBeGreaterThan(0); + const ant = result.credentials.find((c) => c.provider === "anthropic"); + expect(ant).toBeDefined(); + expect(ant!.key).toBe("sk-ant-from-account"); + expect(ant!.source).toBe("opencode (account)"); + }); + + it("returns credentials from opencode auth.json (priority 2)", async () => { + const authPath = writeJson(tmpDir, "auth.json", { + provider: { anthropic: { key: "sk-ant-from-auth" } }, + }); + const result = await scanCredentials({ + accountJsonPath: "/nonexistent", + authJsonPath: authPath, + env: {}, + rcPaths: [], + claudeCredPath: "/nonexistent", + }); + const ant = result.credentials.find((c) => c.provider === "anthropic"); + expect(ant).toBeDefined(); + expect(ant!.key).toBe("sk-ant-from-auth"); + expect(ant!.source).toBe("opencode (auth)"); + }); + + it("returns credentials from environment variables (priority 3)", async () => { + const result = await scanCredentials({ + accountJsonPath: "/nonexistent", + authJsonPath: "/nonexistent", + env: { ANTHROPIC_API_KEY: "sk-ant-from-env", OPENAI_API_KEY: "sk-openai-env" }, + rcPaths: [], + claudeCredPath: "/nonexistent", + }); + expect(result.credentials.length).toBe(2); + const ant = result.credentials.find((c) => c.provider === "anthropic"); + expect(ant!.key).toBe("sk-ant-from-env"); + expect(ant!.source).toBe("environment"); + }); + + it("returns credentials from shell RC files (priority 4)", async () => { + const rcPath = writeText(tmpDir, ".zshrc", 'export ANTHROPIC_API_KEY="sk-ant-from-rc"\n'); + const result = await scanCredentials({ + accountJsonPath: "/nonexistent", + authJsonPath: "/nonexistent", + env: {}, + rcPaths: [rcPath], + claudeCredPath: "/nonexistent", + }); + const ant = result.credentials.find((c) => c.provider === "anthropic"); + expect(ant).toBeDefined(); + expect(ant!.key).toBe("sk-ant-from-rc"); + expect(ant!.source).toContain(".zshrc"); + }); + + it("returns credentials from Claude .credentials.json (priority 5, type:api only)", async () => { + const claudePath = writeJson(tmpDir, ".credentials.json", [ + { type: "api", provider: "anthropic", key: "sk-ant-from-claude" }, + { type: "oauth", provider: "anthropic", token: "oauth-should-skip" }, + ]); + const result = await scanCredentials({ + accountJsonPath: "/nonexistent", + authJsonPath: "/nonexistent", + env: {}, + rcPaths: [], + claudeCredPath: claudePath, + }); + const ant = result.credentials.find((c) => c.provider === "anthropic"); + expect(ant).toBeDefined(); + expect(ant!.key).toBe("sk-ant-from-claude"); + expect(ant!.source).toBe("Claude Code"); + }); + + it("deduplicates: first source wins per provider (AC11)", async () => { + // account.json has anthropic, env also has anthropic — account wins + const accountPath = writeJson(tmpDir, "account.json", { + anthropic: { serviceID: "anthropic", token: "sk-ant-ACCOUNT-WINS" }, + }); + const result = await scanCredentials({ + accountJsonPath: accountPath, + authJsonPath: "/nonexistent", + env: { ANTHROPIC_API_KEY: "sk-ant-ENV-LOSES" }, + rcPaths: [], + claudeCredPath: "/nonexistent", + }); + const ants = result.credentials.filter((c) => c.provider === "anthropic"); + expect(ants).toHaveLength(1); + expect(ants[0].key).toBe("sk-ant-ACCOUNT-WINS"); + }); + + it("returns multiple providers from different sources", async () => { + const accountPath = writeJson(tmpDir, "account.json", { + anthropic: { serviceID: "anthropic", token: "sk-ant" }, + }); + const result = await scanCredentials({ + accountJsonPath: accountPath, + authJsonPath: "/nonexistent", + env: { OPENAI_API_KEY: "sk-openai" }, + rcPaths: [], + claudeCredPath: "/nonexistent", + }); + expect(result.credentials.length).toBe(2); + expect(result.credentials.map((c) => c.provider).sort()).toEqual(["anthropic", "openai"]); + }); +}); + +// ─── Provider ID normalization ────────────────────────────────────────────── + +describe("scanCredentials — provider normalization", () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = makeTmpDir(); + }); + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("normalizes 'opencode-go' to 'opencode'", async () => { + const accountPath = writeJson(tmpDir, "account.json", { + "opencode-go": { serviceID: "opencode-go", token: "oc-key" }, + }); + const result = await scanCredentials({ + accountJsonPath: accountPath, + authJsonPath: "/nonexistent", + env: {}, + rcPaths: [], + claudeCredPath: "/nonexistent", + }); + const oc = result.credentials.find((c) => c.provider === "opencode"); + expect(oc).toBeDefined(); + expect(oc!.key).toBe("oc-key"); + }); + + it("normalizes 'amazon-bedrock' from account.json", async () => { + const accountPath = writeJson(tmpDir, "account.json", { + "amazon-bedrock": { serviceID: "amazon-bedrock", token: "aws-key" }, + }); + const result = await scanCredentials({ + accountJsonPath: accountPath, + authJsonPath: "/nonexistent", + env: {}, + rcPaths: [], + claudeCredPath: "/nonexistent", + }); + const aws = result.credentials.find((c) => c.provider === "amazon-bedrock"); + expect(aws).toBeDefined(); + }); + + it("maps OPENROUTER_API_KEY env var to 'openrouter' provider", async () => { + const result = await scanCredentials({ + accountJsonPath: "/nonexistent", + authJsonPath: "/nonexistent", + env: { OPENROUTER_API_KEY: "or-key" }, + rcPaths: [], + claudeCredPath: "/nonexistent", + }); + const or = result.credentials.find((c) => c.provider === "openrouter"); + expect(or).toBeDefined(); + expect(or!.key).toBe("or-key"); + }); + + it("maps GOOGLE_API_KEY env var to 'google' provider", async () => { + const result = await scanCredentials({ + accountJsonPath: "/nonexistent", + authJsonPath: "/nonexistent", + env: { GOOGLE_API_KEY: "AIza-key" }, + rcPaths: [], + claudeCredPath: "/nonexistent", + }); + const g = result.credentials.find((c) => c.provider === "google"); + expect(g).toBeDefined(); + }); +}); + +// ─── Shell RC parsing safety ───────────────────────────────────────────────── + +describe("scanCredentials — shell RC parsing (security)", () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = makeTmpDir(); + }); + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("parses single-quoted export lines", async () => { + const rcPath = writeText(tmpDir, ".bashrc", "export OPENAI_API_KEY='sk-single-quoted'\n"); + const result = await scanCredentials({ + accountJsonPath: "/nonexistent", + authJsonPath: "/nonexistent", + env: {}, + rcPaths: [rcPath], + claudeCredPath: "/nonexistent", + }); + const oi = result.credentials.find((c) => c.provider === "openai"); + expect(oi!.key).toBe("sk-single-quoted"); + }); + + it("parses double-quoted export lines", async () => { + const rcPath = writeText(tmpDir, ".zshrc", 'export OPENAI_API_KEY="sk-double-quoted"\n'); + const result = await scanCredentials({ + accountJsonPath: "/nonexistent", + authJsonPath: "/nonexistent", + env: {}, + rcPaths: [rcPath], + claudeCredPath: "/nonexistent", + }); + const oi = result.credentials.find((c) => c.provider === "openai"); + expect(oi!.key).toBe("sk-double-quoted"); + }); + + it("parses unquoted export lines", async () => { + const rcPath = writeText(tmpDir, ".zshrc", "export OPENAI_API_KEY=sk-unquoted\n"); + const result = await scanCredentials({ + accountJsonPath: "/nonexistent", + authJsonPath: "/nonexistent", + env: {}, + rcPaths: [rcPath], + claudeCredPath: "/nonexistent", + }); + const oi = result.credentials.find((c) => c.provider === "openai"); + expect(oi!.key).toBe("sk-unquoted"); + }); + + it("ignores commented-out lines", async () => { + const rcPath = writeText( + tmpDir, + ".zshrc", + '# export OPENAI_API_KEY="sk-commented"\nexport ANTHROPIC_API_KEY=sk-real\n', + ); + const result = await scanCredentials({ + accountJsonPath: "/nonexistent", + authJsonPath: "/nonexistent", + env: {}, + rcPaths: [rcPath], + claudeCredPath: "/nonexistent", + }); + expect(result.credentials.find((c) => c.provider === "openai")).toBeUndefined(); + expect(result.credentials.find((c) => c.provider === "anthropic")).toBeDefined(); + }); + + it("does not execute shell commands or subshells", async () => { + const rcPath = writeText( + tmpDir, + ".zshrc", + 'export OPENAI_API_KEY=$(echo "injected")\nexport ANTHROPIC_API_KEY=sk-safe\n', + ); + const result = await scanCredentials({ + accountJsonPath: "/nonexistent", + authJsonPath: "/nonexistent", + env: {}, + rcPaths: [rcPath], + claudeCredPath: "/nonexistent", + }); + // The $(echo) line should be skipped or taken literally — never executed + const oi = result.credentials.find((c) => c.provider === "openai"); + // If parsed, value would be literal `$(echo "injected")` or skipped entirely + if (oi) { + // If it didn't skip, the literal includes $( which is fine (not executed) + expect(oi.key).not.toBe("injected"); + } + // The safe key should always be found + expect(result.credentials.find((c) => c.provider === "anthropic")!.key).toBe("sk-safe"); + }); + + it("handles empty values gracefully (skips)", async () => { + const rcPath = writeText(tmpDir, ".zshrc", "export OPENAI_API_KEY=\nexport ANTHROPIC_API_KEY=''\n"); + const result = await scanCredentials({ + accountJsonPath: "/nonexistent", + authJsonPath: "/nonexistent", + env: {}, + rcPaths: [rcPath], + claudeCredPath: "/nonexistent", + }); + // Empty values should not create credentials + expect(result.credentials).toHaveLength(0); + }); +}); + +// ─── Error handling (AC10) ─────────────────────────────────────────────────── + +describe("scanCredentials — error handling (AC10)", () => { + it("skips unreadable files silently", async () => { + const result = await scanCredentials({ + accountJsonPath: "/nonexistent/account.json", + authJsonPath: "/nonexistent/auth.json", + env: { ANTHROPIC_API_KEY: "sk-works" }, + rcPaths: ["/nonexistent/.zshrc"], + claudeCredPath: "/nonexistent/.credentials.json", + }); + // Should still return the env var credential + expect(result.credentials).toHaveLength(1); + expect(result.credentials[0].provider).toBe("anthropic"); + }); + + it("skips malformed JSON files silently", async () => { + const tmpDir = makeTmpDir(); + const badJson = writeText(tmpDir, "account.json", "not valid json {{{"); + const result = await scanCredentials({ + accountJsonPath: badJson, + authJsonPath: "/nonexistent", + env: { OPENAI_API_KEY: "sk-still-works" }, + rcPaths: [], + claudeCredPath: "/nonexistent", + }); + expect(result.credentials).toHaveLength(1); + expect(result.credentials[0].provider).toBe("openai"); + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("returns empty credentials array when all sources fail", async () => { + const result = await scanCredentials({ + accountJsonPath: "/nonexistent", + authJsonPath: "/nonexistent", + env: {}, + rcPaths: ["/nonexistent"], + claudeCredPath: "/nonexistent", + }); + expect(result.credentials).toEqual([]); + }); +}); + +// ─── Security: webview-safe output (AC8) ───────────────────────────────────── + +describe("webviewSafeResults — no key material leaks (AC8)", () => { + it("strips keys from credentials for webview consumption", () => { + const credentials: DetectedCredential[] = [ + { provider: "anthropic", key: "sk-ant-SENSITIVE", source: "environment" }, + { provider: "openai", key: "sk-openai-SENSITIVE", source: "opencode (account)" }, + ]; + const safe = webviewSafeResults(credentials); + + // Must NOT contain any key values + const serialized = JSON.stringify(safe); + expect(serialized).not.toContain("sk-ant-SENSITIVE"); + expect(serialized).not.toContain("sk-openai-SENSITIVE"); + + // Must contain provider names and sources + expect(safe).toHaveLength(2); + expect(safe[0].provider).toBe("anthropic"); + expect(safe[0].source).toBe("environment"); + expect(safe[1].provider).toBe("openai"); + expect(safe[1].source).toBe("opencode (account)"); + }); + + it("includes the default model for each detected provider", () => { + const credentials: DetectedCredential[] = [ + { provider: "anthropic", key: "sk-x", source: "env" }, + ]; + const safe = webviewSafeResults(credentials); + expect(safe[0].model).toBeTruthy(); + // Should be the first model from PROVIDER_MODELS for anthropic + expect(safe[0].model).toContain("anthropic/"); + }); + + it("no field matching /key|secret|token|credential/i contains a string > 8 chars", () => { + const credentials: DetectedCredential[] = [ + { provider: "anthropic", key: "sk-ant-very-long-secret-key-12345", source: "environment" }, + ]; + const safe = webviewSafeResults(credentials); + const serialized = JSON.stringify(safe); + const parsed = JSON.parse(serialized); + + // Walk all string values in the result + const checkObj = (obj: unknown): void => { + if (typeof obj === "string") return; + if (Array.isArray(obj)) { + obj.forEach(checkObj); + return; + } + if (obj && typeof obj === "object") { + for (const [k, v] of Object.entries(obj)) { + if (/key|secret|token|credential/i.test(k) && typeof v === "string" && v.length > 8) { + throw new Error(`Field "${k}" has sensitive-looking value: ${v.slice(0, 10)}...`); + } + checkObj(v); + } + } + }; + expect(() => checkObj(parsed)).not.toThrow(); + }); +}); + +// ─── Batch config writing (AC7) ────────────────────────────────────────────── + +describe("scanCredentials — batch config writing integration (AC7)", () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = makeTmpDir(); + }); + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("writes multiple providers to opencode.json with correct schema", async () => { + // We import writeOnboardingConfig from onboarding_panel to verify integration + const { writeOnboardingConfig } = await import("../src/onboarding_panel"); + const { writeBatchConfig } = await import("../src/credential_scanner"); + + const credentials: DetectedCredential[] = [ + { provider: "anthropic", key: "sk-ant-batch", source: "env" }, + { provider: "openai", key: "sk-openai-batch", source: "env" }, + ]; + + const configPath = path.join(tmpDir, "opencode.json"); + writeBatchConfig(credentials, "anthropic", configPath); + + const written = JSON.parse(fs.readFileSync(configPath, "utf8")); + + // Both providers written + expect(written.provider.anthropic).toBeDefined(); + expect(written.provider.openai).toBeDefined(); + + // Correct schema: options.apiKey (nested), not top-level + expect(written.provider.anthropic.options.apiKey).toBe("sk-ant-batch"); + expect(written.provider.openai.options.apiKey).toBe("sk-openai-batch"); + + // env is string[] + expect(written.provider.anthropic.env).toEqual(["ANTHROPIC_API_KEY"]); + expect(written.provider.openai.env).toEqual(["OPENAI_API_KEY"]); + + // Active model is the default for the selected provider + expect(written.model).toContain("anthropic/"); + }); + + it("uses the selected provider's first model as the active model", async () => { + const { writeBatchConfig } = await import("../src/credential_scanner"); + + const credentials: DetectedCredential[] = [ + { provider: "anthropic", key: "sk-ant", source: "env" }, + { provider: "openai", key: "sk-oi", source: "env" }, + ]; + + const configPath = path.join(tmpDir, "opencode.json"); + writeBatchConfig(credentials, "openai", configPath); + + const written = JSON.parse(fs.readFileSync(configPath, "utf8")); + // Active model should be openai's first model + expect(written.model).toContain("openai/"); + }); +}); From 85bdaa8cbfe20ea5421a01a5e6bf5b7b2ea8adda Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Thu, 20 Aug 2026 02:35:38 +0200 Subject: [PATCH 2/9] feat(onboarding): integrate credential scanner into panel + webview UI (#449) Panel integration: - Handle 'scan-credentials' message: triggers scan, posts scan-status/results - Handle 'confirm-import' message: writes batch config, disposes panel - Hold credentials in host memory only; drop on dispose/back (AC13, AC14) - Connection tests fire in parallel via Promise.allSettled (AC12) - scanAborted flag prevents stale posts after panel close Webview UI: - 'Import existing credentials' link below the manual form (AC1) - Pulsing orange dot + 'Searching...' on scan start (AC2) - Green dot + 'Found N providers!' on success, inline message on empty (AC3, AC9) - Preview card with provider rows, source labels, live test status (AC4) - Radio selection for default provider (AC5) - 'Confirm & Save' enables when at least one test passes (AC12) - 'Back' link returns to manual form (AC13) 4 new panel tests covering scan-status, security (no key in payload), dispose mid-scan, and confirm-import flow. --- packages/extension/src/onboarding_panel.ts | 213 +++++- packages/extension/src/onboarding_webview.ts | 722 ++++++++++++++++-- .../extension/test/onboarding_panel.test.ts | 305 +++++++- 3 files changed, 1141 insertions(+), 99 deletions(-) diff --git a/packages/extension/src/onboarding_panel.ts b/packages/extension/src/onboarding_panel.ts index 5beda0ef..07d1275b 100644 --- a/packages/extension/src/onboarding_panel.ts +++ b/packages/extension/src/onboarding_panel.ts @@ -12,6 +12,14 @@ import * as path from "node:path"; import * as os from "node:os"; import * as vscode from "vscode"; +import { + scanCredentials, + defaultScanOptions, + webviewSafeResults, + writeBatchConfig, + type DetectedCredential, +} from "./credential_scanner"; + // ─── Provider → Model data (data-driven, not hard-coded conditionals) ──────── export interface ModelEntry { @@ -20,26 +28,55 @@ export interface ModelEntry { } /** Data-driven provider→model map. Each provider key is the opencode provider id; - * models are the provider/model-id pairs opencode expects in `config.model`. */ + * models are the provider/model-id pairs opencode expects in `config.model`. + * "github-copilot" uses OAuth (no key); "custom" uses a base URL + model text. */ export const PROVIDER_MODELS: Record = { + "github-copilot": [ + { id: "github-copilot/claude-sonnet-4-5", name: "Claude Sonnet 4.5" }, + { id: "github-copilot/gpt-4o", name: "GPT-4o" }, + { id: "github-copilot/o3-mini", name: "o3-mini" }, + ], + opencode: [ + { id: "anthropic/claude-sonnet-4-5", name: "Claude Sonnet 4.5" }, + { id: "anthropic/claude-opus-4-6", name: "Claude Opus 4.6" }, + { id: "openai/gpt-4.1", name: "GPT-4.1" }, + ], anthropic: [ - { id: "anthropic/claude-sonnet-4-20250514", name: "Claude Sonnet 4" }, - { id: "anthropic/claude-opus-4-20250514", name: "Claude Opus 4" }, - { id: "anthropic/claude-haiku-3-5-20241022", name: "Claude 3.5 Haiku" }, + { id: "anthropic/claude-sonnet-4-5", name: "Claude Sonnet 4.5" }, + { id: "anthropic/claude-opus-4-6", name: "Claude Opus 4.6" }, + { id: "anthropic/claude-haiku-4-5", name: "Claude Haiku 4.5" }, ], openai: [ - { id: "openai/gpt-4o", name: "GPT-4o" }, - { id: "openai/gpt-4o-mini", name: "GPT-4o Mini" }, + { id: "openai/gpt-4.1", name: "GPT-4.1" }, + { id: "openai/gpt-4.1-mini", name: "GPT-4.1 Mini" }, { id: "openai/o3-mini", name: "o3-mini" }, ], google: [ { id: "google/gemini-2.5-pro", name: "Gemini 2.5 Pro" }, { id: "google/gemini-2.5-flash", name: "Gemini 2.5 Flash" }, ], - "amazon-bedrock": [ - { id: "amazon-bedrock/us.anthropic.claude-sonnet-4-20250514-v1:0", name: "Claude Sonnet 4 (Bedrock)" }, - { id: "amazon-bedrock/us.anthropic.claude-opus-4-20250514-v1:0", name: "Claude Opus 4 (Bedrock)" }, + openrouter: [ + { id: "openrouter/anthropic/claude-sonnet-4-5", name: "Claude Sonnet 4.5" }, + { id: "openrouter/openai/gpt-4.1", name: "GPT-4.1" }, + { id: "openrouter/google/gemini-2.5-pro", name: "Gemini 2.5 Pro" }, ], + vercel: [ + { id: "vercel/anthropic/claude-sonnet-4-5", name: "Claude Sonnet 4.5" }, + { id: "vercel/openai/gpt-4.1", name: "GPT-4.1" }, + ], + custom: [], +}; + +/** Human-readable display names for the provider dropdown. */ +export const PROVIDER_DISPLAY_NAMES: Record = { + "github-copilot": "GitHub Copilot (Free)", + opencode: "OpenCode", + anthropic: "Anthropic", + openai: "OpenAI", + google: "Google", + openrouter: "OpenRouter", + vercel: "Vercel", + custom: "Custom (OpenAI-compatible)", }; // ─── Config types and writing ──────────────────────────────────────────────── @@ -76,13 +113,20 @@ export function writeOnboardingConfig( // Provider-specific key env var name const envVarName = providerKeyEnvVar(config.provider); - // Build the provider entry + // Build the provider entry per opencode schema: + // provider..options.apiKey (NOT provider..apiKey) + // provider..env = string[] (NOT a bare string) + const providerConfig: Record = {}; + if (config.apiKey) { + providerConfig.options = { apiKey: config.apiKey }; + } + if (envVarName) { + providerConfig.env = [envVarName]; + } + const providerEntry: Record = { ...(existing.provider as Record ?? {}), - [config.provider]: { - apiKey: config.apiKey, - ...(envVarName ? { env: envVarName } : {}), - }, + [config.provider]: providerConfig, }; const result = { @@ -101,7 +145,8 @@ function providerKeyEnvVar(provider: string): string | undefined { anthropic: "ANTHROPIC_API_KEY", openai: "OPENAI_API_KEY", google: "GOOGLE_API_KEY", - "amazon-bedrock": "AWS_ACCESS_KEY_ID", + opencode: "OPENCODE_API_KEY", + openrouter: "OPENROUTER_API_KEY", }; return map[provider]; } @@ -118,7 +163,9 @@ const PROVIDER_TEST_ENDPOINTS: Record = { anthropic: "https://api.anthropic.com/v1/messages", openai: "https://api.openai.com/v1/chat/completions", google: "https://generativelanguage.googleapis.com/v1beta/models", - "amazon-bedrock": "https://bedrock-runtime.us-east-1.amazonaws.com", + opencode: "https://opencode.ai/api/v1/chat/completions", + openrouter: "https://openrouter.ai/api/v1/chat/completions", + vercel: "https://api.vercel.ai/v1/chat/completions", }; /** Test the connection by making exactly one minimal LLM API call. @@ -206,6 +253,7 @@ function buildTestRequest( type OnCompleteListener = () => void; const completionListeners: OnCompleteListener[] = []; +const cancelListeners: OnCompleteListener[] = []; /** Register a listener for when onboarding completes successfully. * Downstream wiring (Slice 2) observes this to auto-open chat. */ @@ -217,6 +265,16 @@ export function onOnboardingComplete(listener: OnCompleteListener): vscode.Dispo }); } +/** Register a listener for when onboarding is cancelled (X button). + * Downstream opens chat normally (skip onboarding). */ +export function onOnboardingCancelled(listener: OnCompleteListener): vscode.Disposable { + cancelListeners.push(listener); + return new vscode.Disposable(() => { + const idx = cancelListeners.indexOf(listener); + if (idx >= 0) cancelListeners.splice(idx, 1); + }); +} + function fireOnboardingComplete(): void { for (const listener of completionListeners) { try { @@ -227,6 +285,16 @@ function fireOnboardingComplete(): void { } } +function fireOnboardingCancelled(): void { + for (const listener of cancelListeners) { + try { + listener(); + } catch { + // Don't let a listener failure crash the flow + } + } +} + // ─── WebviewPanel host ─────────────────────────────────────────────────────── let currentPanel: vscode.WebviewPanel | undefined; @@ -262,15 +330,10 @@ export function registerOnboardingPanel(ctx: vscode.ExtensionContext): void { ); currentPanel = panel; - panel.onDidDispose( - () => { - currentPanel = undefined; - }, - null, - ctx.subscriptions, - ); - // Handle messages from the webview + let heldCredentials: DetectedCredential[] = []; + let scanAborted = false; + panel.webview.onDidReceiveMessage( async (msg: { type: string; payload?: unknown }) => { if (msg.type === "test-connection") { @@ -282,12 +345,95 @@ export function registerOnboardingPanel(ctx: vscode.ExtensionContext): void { writeOnboardingConfig(payload); panel.dispose(); fireOnboardingComplete(); + } else if (msg.type === "cancel") { + // User cancelled onboarding — close panel, re-open chat + panel.dispose(); + fireOnboardingCancelled(); + // Also directly open chat as fallback (in case no listener is wired) + void vscode.commands.executeCommand("amicode.openChat"); + } else if (msg.type === "scan-credentials") { + // Auto-import: scan for existing credentials + scanAborted = false; + heldCredentials = []; + panel.webview.postMessage({ + type: "scan-status", + payload: { state: "searching" }, + }); + + try { + const scanResult = await scanCredentials(defaultScanOptions()); + if (scanAborted) return; // Panel was closed mid-scan + heldCredentials = scanResult.credentials; + + if (heldCredentials.length === 0) { + panel.webview.postMessage({ + type: "scan-status", + payload: { state: "empty" }, + }); + } else { + panel.webview.postMessage({ + type: "scan-status", + payload: { state: "found", count: heldCredentials.length }, + }); + // Send webview-safe results (no key material) + panel.webview.postMessage({ + type: "scan-results", + payload: { providers: webviewSafeResults(heldCredentials) }, + }); + + // Run connection tests in parallel (AC12) + const testPromises = heldCredentials.map(async (cred) => { + const models = PROVIDER_MODELS[cred.provider]; + const model = models?.[0]?.id ?? `${cred.provider}/unknown`; + const result = await testConnection({ + provider: cred.provider, + model, + apiKey: cred.key, + }); + if (!scanAborted) { + panel.webview.postMessage({ + type: "test-status-update", + payload: { provider: cred.provider, ok: result.ok, error: result.error }, + }); + } + }); + // Fire all tests in parallel, don't await sequentially + void Promise.allSettled(testPromises); + } + } catch { + if (!scanAborted) { + panel.webview.postMessage({ + type: "scan-status", + payload: { state: "failed", error: "Scan failed unexpectedly" }, + }); + } + } + } else if (msg.type === "confirm-import") { + // User confirmed the import — write batch config + const payload = msg.payload as { activeProvider: string }; + if (heldCredentials.length > 0) { + writeBatchConfig(heldCredentials, payload.activeProvider); + } + heldCredentials = []; + panel.dispose(); + fireOnboardingComplete(); } }, null, ctx.subscriptions, ); + // On panel close, abort scan and drop credentials (AC13, AC14) + panel.onDidDispose( + () => { + scanAborted = true; + heldCredentials = []; + currentPanel = undefined; + }, + null, + ctx.subscriptions, + ); + // Render the webview HTML const uri = (...p: string[]) => panel.webview.asWebviewUri(vscode.Uri.joinPath(ctx.extensionUri, ...p)); @@ -310,14 +456,27 @@ function buildWebviewHtml( +
- + `; } diff --git a/packages/extension/src/onboarding_webview.ts b/packages/extension/src/onboarding_webview.ts index b3c29ec4..cb9ed3e0 100644 --- a/packages/extension/src/onboarding_webview.ts +++ b/packages/extension/src/onboarding_webview.ts @@ -5,6 +5,7 @@ // // Contract: // window.__PROVIDERS__: Record +// window.__PROVIDER_NAMES__: Record // host → webview: { type: "test-result", payload: { ok: boolean, error?: string } } // webview → host: { type: "test-connection", payload: OnboardingConfig } // webview → host: { type: "config-success", payload: OnboardingConfig } @@ -13,11 +14,13 @@ declare function acquireVsCodeApi(): { postMessage(msg: unknown): void }; declare global { interface Window { __PROVIDERS__: Record; + __PROVIDER_NAMES__: Record; } } const vscodeApi = acquireVsCodeApi(); const providers = window.__PROVIDERS__; +const providerNames = window.__PROVIDER_NAMES__ ?? {}; // ─── Animation ─────────────────────────────────────────────────────────────── @@ -33,37 +36,330 @@ function playWelcomeAnimation(): void { } animationPlayed = true; - // Brand animation: logo fade-in → hold → dissolve (~2.5s total) + // Brand animation: Amico drops in whole, bounces onto his feet, then idles + // — breathing, springing, blinking, and winking at you on a loop. + // + // Geometry is the DETAILED mark (amicode media/amico.svg, mirrored as + // MarkDetailed in the fork's logo.tsx) — correct here because the brand rule + // is "small -> reduced bracket, large -> detailed". The rotate(-180) + // transforms in the source SVG are Illustrator no-ops and are dropped so each + // glyph can be grouped and animated. viewBox is cropped to the glyph's own + // bounds plus room for the tilt and bob. + // + // Colour follows media/brand.css, NOT the app design system — VS Code webviews + // are their own stack. The mark takes the INK role (--color-accent-ink): lemon + // on dark, neutral theme foreground on light. Yellow is never a foreground on + // a light ground, and every mark the extension ships already resolves this way. + // + // Every animated property sits on its own nested group so nothing fights over + // the transform property: breathe wraps jump wraps enter, and each eye owns + // only its own lid. The lean sits on its own group too, synced to the winks. animationEl.innerHTML = ` -