diff --git a/packages/ui-mac/scripts/patch-server-version.ts b/packages/ui-mac/scripts/patch-server-version.ts new file mode 100644 index 000000000000..d76e3e5df59d --- /dev/null +++ b/packages/ui-mac/scripts/patch-server-version.ts @@ -0,0 +1,34 @@ +#!/usr/bin/env bun +// A4 fix — give the embedded opencode server a REAL InstallationVersion. +// +// The embedded server is bundled by upstream `packages/opencode/script/build-node.ts`, which defines +// OPENCODE_CHANNEL but NOT OPENCODE_VERSION. So the bundle's +// InstallationVersion = typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "local" +// resolves to "local". When any project with `.opencode` plugins loads, opencode's waitForDependencies +// tries to install `@opencode-ai/plugin@local` — which doesn't exist on npm — and the first request +// hangs (register A4; logged ~152×). We can't add a build `define` to the upstream build script +// (ADR-005: only-add, never edit `packages/opencode/**`), so we patch the BUILD OUTPUT (gitignored +// dist/node/node.js) instead: swap the "local" fallback for a real, npm-installable version. +// +// Purely additive alpha step (runs in prebuild after build-node). If upstream ever rewrites the +// expression, the substring won't match → we WARN and no-op (falls back to "local", loudly), so this +// can't silently rot. Bump ALPHA_OPENCODE_VERSION on upstream sync if you want it to track releases. +import path from "node:path" + +// Latest published @opencode-ai/plugin (npm) — any real version makes the plugin install resolve. +const VERSION = process.env.ALPHA_OPENCODE_VERSION ?? "1.17.13" +const FILE = path.resolve(import.meta.dir, "../../opencode/dist/node/node.js") +const FROM = 'typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "local"' +const TO = `typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "${VERSION}"` + +const text = await Bun.file(FILE).text() +if (!text.includes(FROM)) { + console.warn( + `[alpha:patch-server-version] InstallationVersion fallback not found in ${path.basename(FILE)} — ` + + `upstream reworded it? Embedded server may report "local" and A4 (@opencode-ai/plugin@local) can recur. ` + + `Update scripts/patch-server-version.ts.`, + ) + process.exit(0) +} +await Bun.write(FILE, text.replaceAll(FROM, TO)) +console.log(`[alpha:patch-server-version] embedded opencode InstallationVersion "local" → "${VERSION}" (A4)`) diff --git a/packages/ui-mac/scripts/prebuild.ts b/packages/ui-mac/scripts/prebuild.ts index 308fca983fa7..bf61a23152c7 100644 --- a/packages/ui-mac/scripts/prebuild.ts +++ b/packages/ui-mac/scripts/prebuild.ts @@ -12,3 +12,7 @@ await $`bun ./scripts/copy-metainfo.ts ${channel}` await $`cd ../ext && bun run build` await $`cd ../opencode && bun script/build-node.ts` + +// A4: patch the freshly-built embedded server so InstallationVersion isn't "local" +// (else @opencode-ai/plugin@local install fails for any .opencode-plugin project). See the script. +await $`bun ./scripts/patch-server-version.ts` diff --git a/packages/ui-mac/src/main/alpha-models.test.ts b/packages/ui-mac/src/main/alpha-models.test.ts new file mode 100644 index 000000000000..5fbec5ce4960 --- /dev/null +++ b/packages/ui-mac/src/main/alpha-models.test.ts @@ -0,0 +1,131 @@ +// Unit tests for the sidecar-injected model config assembly (alpha-models.ts). This is the load-bearing +// logic that decides which providers/models opencode sees: BYOK direct nodes (only when keyed), the +// alpha platform gateway (only when ALPHA_BASE_URL is set), and user custom providers merged into the +// hard allowlist. All driven by process.env + the user's opencode.jsonc (via OPENCODE_CONFIG_DIR). + +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import * as fs from "node:fs" +import * as os from "node:os" +import * as path from "node:path" +import { buildAlphaModelConfig, getModelCatalog } from "./alpha-models" + +// Every env var this module reads — snapshot + restore so tests don't leak into each other or the host. +const MANAGED = [ + "ALPHA_MODELS_DISABLE", + "ALPHA_BASE_URL", + "ALPHA_API_KEY", + "ALPHA_DEFAULT_MODEL", + "DEEPSEEK_API_KEY", + "ZHIPU_API_KEY", + "MINIMAX_API_KEY", + "DASHSCOPE_API_KEY", + "MOONSHOT_API_KEY", + "OPENCODE_CONFIG_DIR", +] +const saved: Record = {} +let tmp = "" + +beforeEach(() => { + for (const k of MANAGED) { + saved[k] = process.env[k] + delete process.env[k] + } + // Empty config dir → readUserProviderIds() sees no user providers (isolates the byok/gateway logic). + tmp = fs.mkdtempSync(path.join(os.tmpdir(), "alpha-models-")) + process.env.OPENCODE_CONFIG_DIR = tmp +}) +afterEach(() => { + for (const k of MANAGED) { + if (saved[k] === undefined) delete process.env[k] + else process.env[k] = saved[k] + } + try { + fs.rmSync(tmp, { recursive: true, force: true }) + } catch { + /* best effort */ + } +}) + +describe("getModelCatalog", () => { + test("exposes the catalog with the expected shape", () => { + const c = getModelCatalog() + expect(c.platformProvider?.id).toBe("alpha") + expect(Array.isArray(c.byokProviders)).toBe(true) + expect(c.byokProviders.some((p) => p.id === "deepseek")).toBe(true) + }) +}) + +describe("buildAlphaModelConfig — escape hatch + empty state", () => { + test("ALPHA_MODELS_DISABLE=1 returns undefined (opencode uses its own defaults)", () => { + process.env.ALPHA_MODELS_DISABLE = "1" + expect(buildAlphaModelConfig()).toBeUndefined() + }) + + test("no keys / no gateway / no user providers → empty allowlist, no forced default", () => { + const cfg = buildAlphaModelConfig() + expect(cfg).toBeDefined() + expect(cfg!.enabled_providers).toEqual([]) + expect(cfg!.provider).toEqual({}) + // defaultModel is null in the catalog and no env override → never force a `model` + expect(cfg!.model).toBeUndefined() + }) +}) + +describe("buildAlphaModelConfig — BYOK direct nodes (only when keyed)", () => { + test("an openai-compat provider is injected only when its keyEnv is set, with the inlined key", () => { + process.env.DEEPSEEK_API_KEY = "sk-deepseek-123" + const cfg = buildAlphaModelConfig()! + expect(cfg.enabled_providers).toContain("deepseek") + const p = cfg.provider.deepseek as any + expect(p.npm).toBe("@ai-sdk/openai-compatible") + expect(p.options.apiKey).toBe("sk-deepseek-123") + expect(Object.keys(p.models).length).toBeGreaterThan(0) + }) + + test("an anthropic-compat provider uses the anthropic sdk npm", () => { + process.env.ZHIPU_API_KEY = "sk-zhipu-xyz" + const p = buildAlphaModelConfig()!.provider.zhipuai as any + expect(p.npm).toBe("@ai-sdk/anthropic") + expect(p.options.apiKey).toBe("sk-zhipu-xyz") + }) + + test("keyless catalog providers are NOT injected", () => { + const cfg = buildAlphaModelConfig()! + expect(cfg.provider.deepseek).toBeUndefined() + expect(cfg.provider.moonshot).toBeUndefined() + }) +}) + +describe("buildAlphaModelConfig — platform gateway (代发)", () => { + test("the alpha platform provider joins the FRONT of the allowlist only when ALPHA_BASE_URL is set", () => { + process.env.ALPHA_BASE_URL = "https://gw.example/v1" + process.env.DEEPSEEK_API_KEY = "sk-deepseek-123" // a BYOK node too, to prove ordering + const cfg = buildAlphaModelConfig()! + expect(cfg.enabled_providers[0]).toBe("alpha") // unshifted to the front + const p = cfg.provider.alpha as any + expect(p.options.baseURL).toBe("https://gw.example/v1") + // key is fronted by ALPHA (代发) as an env ref, never inlined + expect(p.options.apiKey).toBe("{env:ALPHA_API_KEY}") + expect(Object.keys(p.models).length).toBeGreaterThan(0) + }) + + test("without ALPHA_BASE_URL there is no platform provider", () => { + expect(buildAlphaModelConfig()!.provider.alpha).toBeUndefined() + }) +}) + +describe("buildAlphaModelConfig — default model + user providers", () => { + test("ALPHA_DEFAULT_MODEL overrides the (null) catalog default", () => { + process.env.ALPHA_DEFAULT_MODEL = "deepseek/deepseek-chat" + expect(buildAlphaModelConfig()!.model).toBe("deepseek/deepseek-chat") + }) + + test("user custom providers in opencode.jsonc are merged into the allowlist (survive the hard reset)", () => { + fs.writeFileSync( + path.join(tmp, "opencode.jsonc"), + JSON.stringify({ provider: { myco: { npm: "@ai-sdk/openai-compatible", options: {} } } }), + ) + const cfg = buildAlphaModelConfig()! + expect(cfg.enabled_providers).toContain("myco") + }) +}) diff --git a/packages/ui-mac/src/main/alpha-provider-status.test.ts b/packages/ui-mac/src/main/alpha-provider-status.test.ts new file mode 100644 index 000000000000..4f0157f7c6dd --- /dev/null +++ b/packages/ui-mac/src/main/alpha-provider-status.test.ts @@ -0,0 +1,102 @@ +// Unit tests for the picker's BYOK key-status logic (alpha-provider-status.ts). The source-priority +// (keychain > env > opencode.jsonc inline > none) and the last-4 masking are what the model picker +// shows as 已配置/需配置 — worth locking down. The keychain (alpha-byok-keys → electron safeStorage) is +// mocked so this runs off-device; the env + opencode.jsonc paths are driven for real via a temp config. + +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test" +import * as fs from "node:fs" +import * as os from "node:os" +import * as path from "node:path" + +// Mutable so each test can set what the "keychain" holds. The mock closure reads it by reference. +let mockKeychain = new Map() +mock.module("./alpha-byok-keys", () => ({ byokKeyMap: () => mockKeychain })) + +const { getProviderKeyStatus } = await import("./alpha-provider-status") + +const MANAGED = ["DEEPSEEK_API_KEY", "ZHIPU_API_KEY", "MINIMAX_API_KEY", "DASHSCOPE_API_KEY", "MOONSHOT_API_KEY", "OPENCODE_CONFIG_DIR"] +const saved: Record = {} +let tmp = "" + +beforeEach(() => { + mockKeychain = new Map() + for (const k of MANAGED) { + saved[k] = process.env[k] + delete process.env[k] + } + tmp = fs.mkdtempSync(path.join(os.tmpdir(), "alpha-provstatus-")) + process.env.OPENCODE_CONFIG_DIR = tmp +}) +afterEach(() => { + for (const k of MANAGED) { + if (saved[k] === undefined) delete process.env[k] + else process.env[k] = saved[k] + } + try { + fs.rmSync(tmp, { recursive: true, force: true }) + } catch { + /* best effort */ + } +}) + +function writeInlineKey(providerId: string, apiKey: string) { + fs.writeFileSync(path.join(tmp, "opencode.jsonc"), JSON.stringify({ provider: { [providerId]: { options: { apiKey } } } })) +} + +describe("getProviderKeyStatus — source priority", () => { + test("nothing configured → every catalog provider is { configured:false, source:'none' }", () => { + const s = getProviderKeyStatus() + expect(s.deepseek).toEqual({ configured: false, source: "none" }) + expect(s.zhipuai.configured).toBe(false) + }) + + test("keychain key → source 'keychain' with a masked last-4 hint", () => { + mockKeychain.set("deepseek", "sk-abcdef1234") + const s = getProviderKeyStatus() + expect(s.deepseek).toEqual({ configured: true, source: "keychain", hint: "1234" }) + }) + + test("env keyEnv (no keychain) → source 'env'", () => { + process.env.DEEPSEEK_API_KEY = "sk-envkey9876" + const s = getProviderKeyStatus() + expect(s.deepseek).toEqual({ configured: true, source: "env", hint: "9876" }) + }) + + test("opencode.jsonc inline apiKey (no keychain/env) → source 'config'", () => { + writeInlineKey("deepseek", "sk-configkeyWXYZ") + const s = getProviderKeyStatus() + expect(s.deepseek.configured).toBe(true) + expect(s.deepseek.source).toBe("config") + expect(s.deepseek.hint).toBe("WXYZ") + }) + + test("keychain beats env beats config", () => { + mockKeychain.set("deepseek", "sk-fromKeychainAAAA") + process.env.DEEPSEEK_API_KEY = "sk-fromEnvBBBB" + writeInlineKey("deepseek", "sk-fromConfigCCCC") + expect(getProviderKeyStatus().deepseek.source).toBe("keychain") + }) +}) + +describe("getProviderKeyStatus — masking + off-catalog", () => { + test("keys shorter than 4 chars are masked to bullets, not leaked", () => { + mockKeychain.set("deepseek", "ab") + expect(getProviderKeyStatus().deepseek.hint).toBe("••") + }) + + test("an off-catalog provider present only in the keychain is still surfaced", () => { + mockKeychain.set("mycustom", "sk-offcatalog7890") + const s = getProviderKeyStatus() + expect(s.mycustom).toEqual({ configured: true, source: "keychain", hint: "7890" }) + }) + + test("never leaks the full key — only a <=4 char hint", () => { + const key = "sk-verylongsecretkey-END" + mockKeychain.set("deepseek", key) + const hint = getProviderKeyStatus().deepseek.hint! + expect(hint).toBe("-END") // the last 4 chars only + expect(hint.length).toBe(4) + expect(key.includes(hint)).toBe(true) + expect(hint).not.toBe(key) // never the full secret + }) +}) diff --git a/packages/ui-mac/src/shared/alpha-config.test.ts b/packages/ui-mac/src/shared/alpha-config.test.ts new file mode 100644 index 000000000000..4f42b6d7a010 --- /dev/null +++ b/packages/ui-mac/src/shared/alpha-config.test.ts @@ -0,0 +1,45 @@ +// Contract lock for the single-source-of-truth endpoint/path constants (alpha-config.ts). These feed +// ALPHA_BASE_URL (the bearer-carrying proxy target) and every alpha↔backend URL, so a regression here +// (a plain-http host, a stray trailing slash, the old api.tidelabs.click gateway, a path missing its +// leading slash) is exactly the class of bug that broke /v1 before. Guard the invariants, not the exact +// domains (those may legitimately move) — except the known-bad host, which must never come back. + +import { describe, expect, test } from "bun:test" +import { ALPHA_ENDPOINTS, ALPHA_PATHS } from "./alpha-config" + +describe("ALPHA_ENDPOINTS", () => { + const required = ["web", "platform", "account", "cloud"] as const + + test("has every required backend host", () => { + for (const k of required) expect(typeof (ALPHA_ENDPOINTS as any)[k]).toBe("string") + }) + + test.each(required)("%s is https, a valid URL, and has no trailing slash", (k) => { + const url = (ALPHA_ENDPOINTS as any)[k] as string + expect(url.startsWith("https://")).toBe(true) + expect(url.endsWith("/")).toBe(false) + expect(() => new URL(url)).not.toThrow() + }) + + test("platform gateway is NOT the old api.tidelabs.click host (which 404'd every /v1 route)", () => { + expect(ALPHA_ENDPOINTS.platform).not.toContain("tidelabs") + // it's the raw Worker that actually routes /v1 + expect(ALPHA_ENDPOINTS.platform).toContain("workers.dev") + }) +}) + +describe("ALPHA_PATHS", () => { + test("every path is a rooted URL segment (leading slash, no trailing slash)", () => { + for (const [key, p] of Object.entries(ALPHA_PATHS)) { + expect(p.startsWith("/"), `${key} must start with /`).toBe(true) + expect(p.endsWith("/"), `${key} must not end with /`).toBe(false) + } + }) + + test("the load-bearing routes are stable", () => { + expect(ALPHA_PATHS.modelProxy).toBe("/v1") + expect(ALPHA_PATHS.token).toBe("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/auth/token") + expect(ALPHA_PATHS.mcpGateway).toBe("/mcp") + expect(ALPHA_PATHS.models).toBe("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/v1/models") + }) +})