From 8c3011a86a940b5b078de393e6919c58643af746 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Mon, 27 Jul 2026 12:53:40 +0200 Subject: [PATCH 1/4] fix(llm): drop the two providers that shipped borrowed vendor credentials MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `openai-oauth` (ChatGPT) and `copilot-proxy` (GitHub Copilot) reached a user's subscription by presenting GitHub's and OpenAI's own OAuth client IDs (Iv1.b507a08c87ecfe98, app_EMoamEEZ73f0CkXaXp7hrann) and an `Editor-Version: vscode/1.96.2` User-Agent against endpoints reserved for first-party clients — api.github.com/copilot_internal and chatgpt.com/backend-api. That shipped in a signed installer, so the risk landed on our users' GitHub and OpenAI accounts. Both vendors expose a sanctioned surface now, so this is a migration, not an amputation: - GitHub: the Copilot SDK, where we register our own OAuth App and pass the user's `gho_` token. Their own client ID does not work against copilot_internal, which is exactly why every tool on that path borrows VS Code's. - OpenAI: `codex app-server` (JSON-RPC over stdio), which inherits the user's own `codex login` — no client ID ships at all. Both are agent runtimes rather than /chat/completions, so they land in their own PR. Removed here: the two registry entries, the four device flows, the Copilot runtime-token swap, the `chatgpt-account-id` threading through chat-service/caption-translate/chat-model, the bridge actions, their contracts, and the device-challenge screen (the provider cards themselves disappear with the registry entries). Credential blobs written by an older build still load — getCredential() matches on a usable `apiKey`, not on `kind`, so no migration is needed. provider-registry.test.ts is the guard: it fails if a provider comes back with a non-api-key auth kind or a first-party-only base URL. --- electron/ai-edition/caption-translate.ts | 8 +- electron/ai-edition/chat-service.ts | 15 +- .../agent-provider-capabilities.test.ts | 7 - .../deep-agent/agent-provider-capabilities.ts | 8 - .../ai-edition/deep-agent/chat-model.test.ts | 59 +-- electron/ai-edition/deep-agent/chat-model.ts | 68 +-- electron/ai-edition/llm-config-store.ts | 46 +- electron/ai-edition/llm-provider-auth.test.ts | 264 +--------- electron/ai-edition/llm-provider-auth.ts | 465 +----------------- electron/ai-edition/provider-registry.test.ts | 37 ++ electron/ai-edition/provider-registry.ts | 33 +- electron/ipc/nativeBridge.ts | 17 - .../services/aiEditionService.ts | 98 ---- .../ai-edition/ProviderSettings.tsx | 294 +---------- src/i18n/locales/ar/editor.json | 16 +- src/i18n/locales/en/editor.json | 16 +- src/i18n/locales/es/editor.json | 16 +- src/i18n/locales/fr/editor.json | 16 +- src/i18n/locales/it/editor.json | 16 +- src/i18n/locales/ja-JP/editor.json | 16 +- src/i18n/locales/ko-KR/editor.json | 16 +- src/i18n/locales/pt-BR/editor.json | 16 +- src/i18n/locales/ru/editor.json | 16 +- src/i18n/locales/tr/editor.json | 16 +- src/i18n/locales/vi/editor.json | 16 +- src/i18n/locales/zh-CN/editor.json | 16 +- src/i18n/locales/zh-TW/editor.json | 16 +- src/native/browserShim.ts | 8 - src/native/client.ts | 18 - src/native/contracts.ts | 36 -- 30 files changed, 131 insertions(+), 1558 deletions(-) create mode 100644 electron/ai-edition/provider-registry.test.ts diff --git a/electron/ai-edition/caption-translate.ts b/electron/ai-edition/caption-translate.ts index 02706aff4..2a4db3d3d 100644 --- a/electron/ai-edition/caption-translate.ts +++ b/electron/ai-edition/caption-translate.ts @@ -31,7 +31,6 @@ export interface CaptionTranslateOptions { apiKey: string; baseUrl?: string; reasoningEffort?: string; - accountId?: string; /** Segments per request. Keeps each call small enough to stay reliable while * still giving the model surrounding context to translate coherently. */ batchSize?: number; @@ -128,10 +127,8 @@ export async function translateCaptionSegments( const out: Record = {}; let done = 0; - // Built once, not per batch: for Copilot this call swaps the PAT for a - // runtime token over the network, and a long transcript is a lot of - // batches. ponytail: the Copilot runtime token is short-lived, so a - // translation running longer than its TTL would need a rebuild here. + // Built once, not per batch: a long transcript is a lot of batches and the + // model object is reusable across all of them. let model: Awaited>; try { model = await createOpenScreenChatModel({ @@ -140,7 +137,6 @@ export async function translateCaptionSegments( apiKey: options.apiKey, baseUrl: options.baseUrl, reasoningEffort: options.reasoningEffort, - accountId: options.accountId, }); } catch (error) { return { diff --git a/electron/ai-edition/chat-service.ts b/electron/ai-edition/chat-service.ts index d714258c6..a2f0f7ccf 100644 --- a/electron/ai-edition/chat-service.ts +++ b/electron/ai-edition/chat-service.ts @@ -253,8 +253,6 @@ export async function runChat( error: `No API key for ${def.label}. Add one in Settings → AI.`, }; } - const accountId = - credential?.entry.kind === "codex" ? (credential.entry.accountId ?? undefined) : undefined; const sessions = getProjectSessions(projectId); let session = sessions.get(sessionId); @@ -314,7 +312,6 @@ export async function runChat( model: config.model, baseUrl: config.baseUrl, reasoningEffort: config.reasoningEffort, - accountId, }); } @@ -349,7 +346,6 @@ export async function runChat( apiKey: apiKey ?? undefined, baseUrl: config.baseUrl, reasoningEffort: config.reasoningEffort, - accountId, }, history, userMessage: message, @@ -527,8 +523,6 @@ export async function compactSessionNow( if (!def) return null; const credential = llmConfig.getCredential(def.id, def.envKeys); const apiKey = credential?.value ?? ""; - const accountId = - credential?.entry.kind === "codex" ? (credential.entry.accountId ?? undefined) : undefined; const decision = shouldCompact(session.messages); if (!decision || !decision.compact) return null; @@ -541,7 +535,6 @@ export async function compactSessionNow( model: config.model, baseUrl: config.baseUrl, reasoningEffort: config.reasoningEffort, - accountId, }); if (!ok) return null; return { @@ -658,8 +651,6 @@ export async function compactSession( const def = PROVIDER_DEFINITIONS.find((d) => d.id === config.provider); const credential = def ? llmConfig.getCredential(def.id, def.envKeys) : null; const apiKey = credential?.value ?? ""; - const accountId = - credential?.entry.kind === "codex" ? (credential.entry.accountId ?? undefined) : undefined; const decision = shouldCompact(session.messages); if (!decision) return { summaryMessageId: null, summary: "" }; @@ -672,7 +663,6 @@ export async function compactSession( model: config.model, baseUrl: config.baseUrl, reasoningEffort: config.reasoningEffort, - accountId, }); return ok; } @@ -685,10 +675,8 @@ async function tryCompactSession(opts: { model: string; baseUrl?: string; reasoningEffort?: string; - accountId?: string; }): Promise<{ summaryMessageId: string | null; summary: string } | null> { - const { session, splitIndex, apiKey, provider, model, baseUrl, reasoningEffort, accountId } = - opts; + const { session, splitIndex, apiKey, provider, model, baseUrl, reasoningEffort } = opts; const oldMessages = session.messages.slice(0, splitIndex); if (oldMessages.length === 0) return null; @@ -704,7 +692,6 @@ async function tryCompactSession(opts: { apiKey, baseUrl, reasoningEffort, - accountId, }); const result = await chatModel.invoke([ new SystemMessage(COMPACTION_SYSTEM_PROMPT), diff --git a/electron/ai-edition/deep-agent/agent-provider-capabilities.test.ts b/electron/ai-edition/deep-agent/agent-provider-capabilities.test.ts index 5d5090595..0b6581ed4 100644 --- a/electron/ai-edition/deep-agent/agent-provider-capabilities.test.ts +++ b/electron/ai-edition/deep-agent/agent-provider-capabilities.test.ts @@ -10,13 +10,6 @@ import { } from "./agent-provider-capabilities"; describe("getReasoningCapability", () => { - it("gives openai-oauth the full 6-step Codex effort range", () => { - const cap = getReasoningCapability("openai-oauth", "gpt-5"); - expect(cap.supported).toBe(true); - expect(cap.strategy).toBe("custom-openai-account"); - expect(cap.efforts).toEqual(["none", "minimal", "low", "medium", "high", "xhigh"]); - }); - it("turns reasoning off for non-reasoning OpenAI models", () => { expect(getReasoningCapability("openai", "gpt-4o").supported).toBe(false); expect(getReasoningCapability("openai", "o3-mini").supported).toBe(true); diff --git a/electron/ai-edition/deep-agent/agent-provider-capabilities.ts b/electron/ai-edition/deep-agent/agent-provider-capabilities.ts index b1e419fe8..fde62ca7e 100644 --- a/electron/ai-edition/deep-agent/agent-provider-capabilities.ts +++ b/electron/ai-edition/deep-agent/agent-provider-capabilities.ts @@ -60,14 +60,6 @@ export function getReasoningCapability(provider: string, model?: string): Reason const def: ProviderDefinition | undefined = getProviderDefinition(provider); const normalizedModel = normalizeModelName(model); - if (provider === "openai-oauth") { - return { - supported: true, - efforts: AGENT_REASONING_EFFORTS, - defaultEffort: "medium", - strategy: "custom-openai-account", - }; - } if ( (provider === "openai" || provider === "openai-compatible") && isOpenAIReasoningModel(normalizedModel) diff --git a/electron/ai-edition/deep-agent/chat-model.test.ts b/electron/ai-edition/deep-agent/chat-model.test.ts index 0efcaf20e..d659c41e9 100644 --- a/electron/ai-edition/deep-agent/chat-model.test.ts +++ b/electron/ai-edition/deep-agent/chat-model.test.ts @@ -1,65 +1,18 @@ -// The Codex account header and the provider-alias normalization are both -// invisible to tsc (an optional field that nothing reads still typechecks), -// so they get a runtime check. +// Provider-alias normalization is invisible to tsc (every branch takes the same +// string type), so it gets a runtime check. +// +// The openai-oauth / copilot-proxy suites that lived here went with those +// providers in 1.8.0 — see provider-registry.ts. -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import { createOpenScreenChatModel, messageContentToText } from "./chat-model"; -vi.mock("../llm-provider-auth", () => ({ - exchangeGithubCopilotRuntimeToken: vi.fn(async () => ({ - token: "runtime-token", - expiresAt: Date.now() + 60_000, - baseUrl: "https://api.business.githubcopilot.com", - })), - GITHUB_COPILOT_USER_AGENT: "GitHubCopilotChat/0.26.7", - GITHUB_COPILOT_EDITOR_VERSION: "vscode/1.96.2", - GITHUB_COPILOT_PLUGIN_VERSION: "copilot-chat/0.26.7", -})); - /** ChatOpenAI keeps the `configuration` bag it was constructed with on * `clientConfig`; that is where the base URL and default headers land. */ function clientConfig(model: unknown): Record { return (model as { clientConfig?: Record }).clientConfig ?? {}; } -describe("createOpenScreenChatModel — openai-oauth (Codex)", () => { - it("sends chatgpt-account-id, which the gateway requires for an OAuth token", async () => { - const model = await createOpenScreenChatModel({ - provider: "openai-oauth", - model: "gpt-5", - apiKey: "oauth-access-token", - accountId: "acct_123", - }); - expect(clientConfig(model).defaultHeaders).toMatchObject({ - "chatgpt-account-id": "acct_123", - }); - }); - - it("omits the header entirely when there is no account id", async () => { - const model = await createOpenScreenChatModel({ - provider: "openai-oauth", - model: "gpt-5", - apiKey: "oauth-access-token", - }); - expect(clientConfig(model).defaultHeaders).toBeUndefined(); - }); -}); - -describe("createOpenScreenChatModel — copilot-proxy", () => { - it("uses the exchanged runtime token and the base URL it names", async () => { - const model = await createOpenScreenChatModel({ - provider: "copilot-proxy", - model: "gpt-4.1", - apiKey: "github-pat", - }); - expect(clientConfig(model).baseURL).toBe("https://api.business.githubcopilot.com"); - expect(clientConfig(model).defaultHeaders).toMatchObject({ - "Editor-Version": "vscode/1.96.2", - "Openai-Intent": "copilot-gpt-chat-completions", - }); - }); -}); - describe("createOpenScreenChatModel — provider aliases", () => { it("routes the `claude` alias to the Anthropic SDK, not the OpenAI fallback", async () => { const model = await createOpenScreenChatModel({ diff --git a/electron/ai-edition/deep-agent/chat-model.ts b/electron/ai-edition/deep-agent/chat-model.ts index 3d9ddf7a4..80e1be757 100644 --- a/electron/ai-edition/deep-agent/chat-model.ts +++ b/electron/ai-edition/deep-agent/chat-model.ts @@ -1,19 +1,15 @@ // ponytail: port of axcut's createAxcutChatModel (apps/server/src/llm/create-chat-model.ts). // Picks the right @langchain/* chat model class for the configured provider, -// honoring MiniMax/OpenAI-OAuth/GitHub Copilot as "local" providers (Anthropic- -// SDK or OpenAI-SDK shaped) and routing native Anthropic/OpenAI/Mistral calls -// through their first-party SDKs. +// honoring MiniMax as a "local" provider (Anthropic-SDK shaped) and routing +// native Anthropic/OpenAI/Mistral calls through their first-party SDKs. +// +// The openai-oauth (Codex) and copilot-proxy branches were removed in 1.8.0 +// along with their providers — see the note in provider-registry.ts. import { ChatAnthropic } from "@langchain/anthropic"; import type { BaseChatModel } from "@langchain/core/language_models/chat_models"; import { ChatMistralAI } from "@langchain/mistralai"; import { ChatOpenAI } from "@langchain/openai"; -import { - exchangeGithubCopilotRuntimeToken, - GITHUB_COPILOT_EDITOR_VERSION, - GITHUB_COPILOT_PLUGIN_VERSION, - GITHUB_COPILOT_USER_AGENT, -} from "../llm-provider-auth"; import { normalizeProviderId } from "../provider-registry"; import { buildLangChainReasoningOptions, @@ -26,7 +22,6 @@ export interface OpenScreenChatModelConfig { apiKey?: string; baseUrl?: string; reasoningEffort?: string; - accountId?: string; } // ponytail: placeholder API key for self-hosted OpenAI-compatible endpoints @@ -77,16 +72,9 @@ export async function createOpenScreenChatModel( config.reasoningEffort as never, ); - // ponytail: OpenAI-OAuth (Codex), GitHub Copilot, and MiniMax all ride - // their non-default SDK path (or a base-URL swap). Anthropic-shaped wire - // for MiniMax, ChatGPT-OAuth-shaped for Codex, runtime-token swap for - // Copilot. axcut has the same split. - if ( - config.provider === "openai-oauth" || - config.provider === "copilot-proxy" || - config.provider === "minimax" || - config.provider === "minimax-token-plan" - ) { + // ponytail: MiniMax rides a non-default SDK path — its wire format is + // Anthropic's, not OpenAI's, despite the OpenAI-looking model names. + if (config.provider === "minimax" || config.provider === "minimax-token-plan") { return createLocalProviderChatModel(config, reasoningOptions); } @@ -140,46 +128,6 @@ async function createLocalProviderChatModel( reasoningOptions: ReturnType, ): Promise { switch (config.provider) { - case "openai-oauth": - // ponytail: ChatGPT device-flow OAuth (Codex). axcut has a hand-rolled - // ChatCodexOAuth class for this; for v1 we fall back to a generic - // ChatOpenAI with the chatgpt.com/backend-api base URL. Streaming - // + tool calls work on the gateway, so this is enough for the chat. - // `chatgpt-account-id` is not optional — the gateway rejects an - // OAuth token without the account it was minted for. The rest of - // the Codex header set (originator, x-codex-window-id, the - // OpenAI-Beta Responses opt-in) belongs to the Responses dialect - // this path does not speak; add it with a real ChatCodexOAuth. - return new ChatOpenAI({ - apiKey: config.apiKey, - model: config.model, - configuration: { - baseURL: config.baseUrl || "https://chatgpt.com/backend-api", - ...(config.accountId - ? { defaultHeaders: { "chatgpt-account-id": config.accountId } } - : {}), - }, - }); - case "copilot-proxy": { - // Copilot does not accept the PAT directly: it is exchanged for a - // short-lived runtime token, which also names the base URL to use. - // The editor-identifying headers are part of the contract — the - // endpoint rejects requests without them. - const runtime = await exchangeGithubCopilotRuntimeToken(config.apiKey ?? ""); - return new ChatOpenAI({ - apiKey: runtime.token, - model: config.model, - configuration: { - baseURL: config.baseUrl || runtime.baseUrl || "https://api.individual.githubcopilot.com", - defaultHeaders: { - "User-Agent": GITHUB_COPILOT_USER_AGENT, - "Editor-Version": GITHUB_COPILOT_EDITOR_VERSION, - "Editor-Plugin-Version": GITHUB_COPILOT_PLUGIN_VERSION, - "Openai-Intent": "copilot-gpt-chat-completions", - }, - }, - }); - } case "minimax": case "minimax-token-plan": // ponytail: MiniMax is Anthropic-API-shaped. ChatAnthropic wraps diff --git a/electron/ai-edition/llm-config-store.ts b/electron/ai-edition/llm-config-store.ts index 3c4fdcbc3..4db7822b0 100644 --- a/electron/ai-edition/llm-config-store.ts +++ b/electron/ai-edition/llm-config-store.ts @@ -22,38 +22,20 @@ export interface LlmConfig { allowAgentEdits?: boolean; } -export type LlmCredentialKind = "api-key" | "codex" | "github-device" | "github-pat"; +// Only `api-key` remains: the OAuth kinds ("codex", "github-device", +// "github-pat") belonged to the ChatGPT and Copilot providers removed in 1.8.0 +// — see the note in provider-registry.ts. Blobs written by an earlier build may +// still carry them; getCredential() reads any entry with a usable `apiKey` +// rather than matching on `kind`, so old rows neither crash nor need a +// migration. They simply resolve to nothing, since no provider claims them. +export type LlmCredentialKind = "api-key"; export interface ApiKeyCredential { kind: "api-key"; apiKey: string; } -export interface CodexCredential { - kind: "codex"; - apiKey: string; // access token - refreshToken?: string; - accountId?: string; - expiresAt?: number; -} - -export interface GithubDeviceCredential { - kind: "github-device"; - apiKey: string; // github pat/secondary token - accountLogin?: string; - expiresAt?: number; -} - -export interface GithubPatCredential { - kind: "github-pat"; - apiKey: string; // user-pasted GitHub PAT -} - -export type LlmCredential = - | ApiKeyCredential - | CodexCredential - | GithubDeviceCredential - | GithubPatCredential; +export type LlmCredential = ApiKeyCredential; export type LlmCredentials = { [providerId: string]: LlmCredential | string }; @@ -121,13 +103,11 @@ export class LlmConfigStore { if (typeof stored === "string") { return { value: stored, entry: { kind: "api-key", apiKey: stored } }; } - if ( - stored.kind === "api-key" || - stored.kind === "codex" || - stored.kind === "github-device" || - stored.kind === "github-pat" - ) { - return { value: stored.apiKey, entry: stored }; + // Match on a usable secret, not on `kind`: a blob written before the + // OAuth providers were removed still carries kind: "codex" etc., and + // narrowing on the current union would make those rows unreadable. + if (typeof stored.apiKey === "string" && stored.apiKey) { + return { value: stored.apiKey, entry: { kind: "api-key", apiKey: stored.apiKey } }; } return null; } diff --git a/electron/ai-edition/llm-provider-auth.test.ts b/electron/ai-edition/llm-provider-auth.test.ts index 19b406c66..afb7c4162 100644 --- a/electron/ai-edition/llm-provider-auth.test.ts +++ b/electron/ai-edition/llm-provider-auth.test.ts @@ -1,15 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { - beginCodexDeviceAuth, - beginGithubDeviceAuth, - completeCodexDeviceAuth, - completeGithubDeviceAuth, - exchangeGithubCopilotRuntimeToken, - listGithubCopilotModels, - listOpenAiAccountModels, - probeMiniMaxModels, -} from "./llm-provider-auth"; +import { probeMiniMaxModels } from "./llm-provider-auth"; const ORIGINAL_FETCH = globalThis.fetch; @@ -35,259 +26,6 @@ afterEach(() => { globalThis.fetch = ORIGINAL_FETCH; }); -describe("beginCodexDeviceAuth", () => { - beforeEach(() => { - vi.useFakeTimers({ shouldAdvanceTime: true }); - }); - - it("POSTs to the Codex usercode endpoint and returns a normalized challenge", async () => { - mockFetchSequence([ - { - ok: true, - body: { - device_auth_id: "codex-id-abc", - user_code: "ABCD-EFGH", - interval: "7", - expires_in: 900, - }, - }, - ]); - - const challenge = await beginCodexDeviceAuth(); - - expect(globalThis.fetch).toHaveBeenCalledTimes(1); - const [url, init] = (globalThis.fetch as unknown as { mock: { calls: [unknown, unknown][] } }) - .mock.calls[0]!; - expect(url).toBe("https://auth.openai.com/api/accounts/deviceauth/usercode"); - expect(init).toMatchObject({ method: "POST" }); - - expect(challenge.userCode).toBe("ABCD-EFGH"); - expect(challenge.deviceAuthId).toBe("codex-id-abc"); - expect(challenge.verificationUri).toBe("https://auth.openai.com/codex/device"); - expect(challenge.intervalMs).toBe(7000); - expect(challenge.expiresAt).toBeGreaterThan(Date.now() + 100_000); - }); -}); - -describe("completeCodexDeviceAuth", () => { - beforeEach(() => { - vi.useFakeTimers({ shouldAdvanceTime: true }); - }); - - it("exchanges the device code for tokens via OAuth token endpoint", async () => { - mockFetchSequence([ - { - ok: true, - body: { authorization_code: "auth-code-1", code_verifier: "verifier-1" }, - }, - { - ok: true, - body: { - access_token: "eyJhbGciOiJIUzI1NiJ9.payload.signature", - refresh_token: "rt-1", - expires_in: 3600, - }, - }, - ]); - - const tokens = await completeCodexDeviceAuth({ - deviceAuthId: "codex-id", - userCode: "USER", - intervalMs: 10, - expiresAt: Date.now() + 60_000, - }); - - expect(tokens.accessToken).toMatch(/^eyJ/); - expect(tokens.refreshToken).toBe("rt-1"); - expect(tokens.expiresAt).toBeGreaterThan(Date.now()); - }); - - it("throws when the device code expires", async () => { - mockFetchSequence([{ ok: false, status: 403, body: { error: "expired_token" } }]); - - await expect( - completeCodexDeviceAuth({ - deviceAuthId: "id", - userCode: "code", - intervalMs: 1, - expiresAt: Date.now() - 1, - }), - ).rejects.toThrow(/expired/); - }); -}); - -describe("beginGithubDeviceAuth", () => { - beforeEach(() => { - vi.useFakeTimers({ shouldAdvanceTime: true }); - }); - - it("POSTs to GitHub /login/device/code with client_id and read:user scope", async () => { - mockFetchSequence([ - { - ok: true, - body: { - device_code: "gh-device-1", - user_code: "GH-USER", - verification_uri: "https://github.com/login/device", - interval: 5, - expires_in: 900, - }, - }, - ]); - - const challenge = await beginGithubDeviceAuth(); - - const [url, init] = (globalThis.fetch as unknown as { mock: { calls: [unknown, unknown][] } }) - .mock.calls[0]!; - expect(url).toBe("https://github.com/login/device/code"); - expect(init).toMatchObject({ method: "POST" }); - - const fetchBody = (init as RequestInit).body as URLSearchParams; - expect(fetchBody.get("client_id")).toBe("Iv1.b507a08c87ecfe98"); - expect(fetchBody.get("scope")).toBe("read:user"); - - expect(challenge.userCode).toBe("GH-USER"); - expect(challenge.deviceCode).toBe("gh-device-1"); - }); - - it("falls back to the GitHub login URL when the server omits verification_uri", async () => { - mockFetchSequence([ - { - ok: true, - body: { - device_code: "d-2", - user_code: "X", - interval: 5, - expires_in: 900, - }, - }, - ]); - - const challenge = await beginGithubDeviceAuth(); - expect(challenge.verificationUri).toBe("https://github.com/login/device"); - }); -}); - -describe("completeGithubDeviceAuth", () => { - beforeEach(() => { - vi.useFakeTimers({ shouldAdvanceTime: true }); - }); - - it("returns the access token on the first poll", async () => { - mockFetchSequence([{ ok: true, body: { access_token: "ghp_token_1" } }]); - - const token = await completeGithubDeviceAuth({ - deviceCode: "d-1", - userCode: "U", - intervalMs: 1, - expiresAt: Date.now() + 60_000, - }); - expect(token).toBe("ghp_token_1"); - }); - - it("throws an error for non-pending failures", async () => { - mockFetchSequence([ - { ok: true, body: { error: "access_denied", error_description: "User rejected" } }, - ]); - - await expect( - completeGithubDeviceAuth({ - deviceCode: "d-3", - userCode: "U", - intervalMs: 1, - expiresAt: Date.now() + 60_000, - }), - ).rejects.toThrow(/User rejected/); - }); -}); - -describe("exchangeGithubCopilotRuntimeToken", () => { - it("exchanges the long-lived GitHub PAT for the short-lived Copilot bearer", async () => { - mockFetchSequence([ - { - ok: true, - body: { - token: "tid=copilot;exp=2026", - expires_at: Math.floor(Date.now() / 1000) + 1800, - }, - }, - ]); - - const out = await exchangeGithubCopilotRuntimeToken("ghp_x"); - expect(out.token).toMatch(/^tid=copilot;exp=/); - expect(out.expiresAt).toBeGreaterThan(Date.now()); - expect(out.baseUrl).toBe("https://api.individual.githubcopilot.com"); - }); -}); - -describe("listOpenAiAccountModels", () => { - const jwtForAccount = (accountId: string): string => { - const header = Buffer.from(JSON.stringify({ alg: "none", typ: "JWT" })).toString("base64url"); - const payload = Buffer.from( - JSON.stringify({ - "https://api.openai.com/auth": { chatgpt_account_id: accountId }, - }), - ).toString("base64url"); - return `${header}.${payload}.signature`; - }; - - it("returns the slug list visible to the account, sorted by priority", async () => { - mockFetchSequence([ - { - ok: true, - body: { - models: [ - { slug: "gpt-5.4", visibility: "list", priority: 10 }, - { slug: "gpt-4o", visibility: "list", priority: 1 }, - { slug: "secret", visibility: "private" }, - { slug: "internal", visibility: "hidden" }, - ], - }, - }, - ]); - - const models = await listOpenAiAccountModels(jwtForAccount("acc-1")); - expect(models).toEqual(["gpt-4o", "gpt-5.4"]); - }); -}); - -describe("listGithubCopilotModels", () => { - it("exchanges the PAT, lists the Copilot catalog, and sorts alphabetically", async () => { - // 1) Copilot token exchange - // 2) GET {baseUrl}/models - mockFetchSequence([ - { - ok: true, - body: { - token: "copilot-bearer", - expires_at: Math.floor(Date.now() / 1000) + 1800, - }, - }, - { - ok: true, - body: { - data: [{ id: "gpt-4o" }, { id: "claude-3.5-sonnet" }, { id: "gpt-3.5-turbo" }], - }, - }, - ]); - - const models = await listGithubCopilotModels("ghp_x"); - expect(models).toEqual(["claude-3.5-sonnet", "gpt-3.5-turbo", "gpt-4o"]); - }); -}); - -/** - * `probeMiniMaxModels` issues a parallel POST to the OpenAI-compat sibling of - * the Anthropic base URL. The Anthropic base used to be `…/anthropic` and was - * later corrected to `…/anthropic/v1` (see provider-registry.ts and - * `76d823f fix(ai-edition): correct MiniMax base URL to /anthropic/v1`). - * The earlier URL-construction only handled the no-`/v1` shape, so the - * registry default produced the malformed `…/anthropic/v1/v1/chat/completions` - * and every probe 404'd, returning an empty list with no surfaced error. - * - * These tests pin the URL construction across both shapes and assert the - * filtering + error-surfacing behavior. - */ describe("probeMiniMaxModels", () => { /** * Per-model mock: `globalThis.fetch` is called once per candidate model in diff --git a/electron/ai-edition/llm-provider-auth.ts b/electron/ai-edition/llm-provider-auth.ts index b9d1e0952..0317ca268 100644 --- a/electron/ai-edition/llm-provider-auth.ts +++ b/electron/ai-edition/llm-provider-auth.ts @@ -1,412 +1,16 @@ -// Device-flow + GitHub Copilot runtime-token helpers for the AI provider -// feature. Ported from axcut (apps/server/src/llm/provider-runtime/openai-account.ts, -// copilot-account.ts, and the inline functions in services/llm-config-service.ts). +// Model-list discovery for the API-key providers in the registry. // -// Credentials returned by `complete*` flows land in the same -// `safeStorage` blob as API keys via `LlmConfigStore`. No plain-text auth -// files on disk. +// This file used to also carry the Codex (ChatGPT) and GitHub Copilot device +// flows. They were removed for 1.8.0: reaching a user's subscription that way +// meant shipping GitHub's and OpenAI's own OAuth client IDs and an +// `Editor-Version: vscode/…` User-Agent against endpoints reserved for +// first-party clients, inside a signed installer. Both vendors now expose a +// sanctioned surface instead — GitHub's Copilot SDK (we register our own OAuth +// App and pass the user's `gho_` token) and `codex app-server` (we drive the +// user's own `codex login`, so no client ID ships at all). Those are separate +// integrations, not a header swap, so they land in their own PR. // -// Endpoints are not proxied through OpenScreen itself — the Electron -// main process has direct internet access via `app.requestSingleInstanceLock`. - -export interface CodexDeviceChallenge { - verificationUri: string; - verificationUriComplete?: string; - userCode: string; - deviceAuthId: string; - intervalMs: number; - expiresAt: number; -} - -export interface GithubDeviceChallenge { - verificationUri: string; - userCode: string; - deviceCode: string; - intervalMs: number; - expiresAt: number; -} - -export interface CodexTokens { - accessToken: string; - refreshToken?: string; - idToken?: string; - expiresAt?: number; - accountId?: string; -} - -export interface GithubCopilotRuntimeToken { - token: string; - expiresAt: number; - baseUrl: string; -} - -const CODEX_ISSUER = "https://auth.openai.com"; -const CODEX_TOKEN_ENDPOINT = `${CODEX_ISSUER}/oauth/token`; -const CODEX_DEVICE_AUTHORIZATION_ENDPOINT = `${CODEX_ISSUER}/api/accounts/deviceauth/usercode`; -const CODEX_DEVICE_TOKEN_ENDPOINT = `${CODEX_ISSUER}/api/accounts/deviceauth/token`; -const CODEX_DEVICE_REDIRECT_URI = `${CODEX_ISSUER}/deviceauth/callback`; -const CODEX_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"; -const CODEX_DEVICE_POLLING_SAFETY_MARGIN_MS = 3_000; -export const OPENAI_ACCOUNT_BASE_URL = "https://chatgpt.com/backend-api"; -const CODEX_MODELS_PATH = "/codex/models"; -const GITHUB_DEVICE_CODE_ENDPOINT = "https://github.com/login/device/code"; -const GITHUB_DEVICE_TOKEN_ENDPOINT = "https://github.com/login/oauth/access_token"; -const GITHUB_DEVICE_CLIENT_ID = "Iv1.b507a08c87ecfe98"; -const GITHUB_DEVICE_SCOPE = "read:user"; -const GITHUB_DEVICE_POLLING_SAFETY_MARGIN_MS = 3_000; -const COPILOT_TOKEN_ENDPOINT = "https://api.github.com/copilot_internal/v2/token"; -export const GITHUB_COPILOT_USER_AGENT = "GitHubCopilotChat/0.26.7"; -export const GITHUB_COPILOT_EDITOR_VERSION = "vscode/1.96.2"; -export const GITHUB_COPILOT_PLUGIN_VERSION = "copilot-chat/0.26.7"; - -/** - * Begin the Codex (ChatGPT) device flow. Returns the user code and the - * polling parameters needed by {@link completeCodexDeviceAuth}. - */ -export async function beginCodexDeviceAuth(): Promise { - const response = await fetch(CODEX_DEVICE_AUTHORIZATION_ENDPOINT, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ client_id: CODEX_CLIENT_ID }), - }); - - if (!response.ok) { - const detail = await safeErrorDetail(response); - throw new Error( - detail - ? `OpenAI device login failed: ${detail}` - : `OpenAI device login failed: HTTP ${response.status}`, - ); - } - - const payload = (await response.json()) as { - device_auth_id?: string; - user_code?: string; - interval?: string; - expires_in?: number; - }; - - if (!payload.device_auth_id || !payload.user_code) { - throw new Error("OpenAI device login returned an incomplete challenge."); - } - - const intervalSeconds = Number.parseInt(payload.interval ?? "5", 10); - const intervalMs = Math.max(Number.isFinite(intervalSeconds) ? intervalSeconds : 5, 1) * 1000; - - return { - verificationUri: `${CODEX_ISSUER}/codex/device`, - userCode: payload.user_code, - deviceAuthId: payload.device_auth_id, - intervalMs, - expiresAt: Date.now() + (payload.expires_in ?? 600) * 1000, - }; -} - -/** - * Poll the Codex device-token endpoint until we get an authorization code, - * exchange it at the OAuth token endpoint, and return the access + refresh - * tokens. The caller persists them via `LlmConfigStore.setCredential`. - */ -export async function completeCodexDeviceAuth( - challenge: Omit, -): Promise { - const intervalMs = Math.max(1000, challenge.intervalMs); - - while (Date.now() < challenge.expiresAt) { - const response = await fetch(CODEX_DEVICE_TOKEN_ENDPOINT, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - device_auth_id: challenge.deviceAuthId, - user_code: challenge.userCode, - }), - }); - - if (response.ok) { - const deviceToken = (await response.json()) as { - authorization_code?: string; - code_verifier?: string; - }; - - if (!deviceToken.authorization_code || !deviceToken.code_verifier) { - throw new Error("OpenAI device login returned an incomplete authorization result."); - } - - const body = new URLSearchParams(); - body.set("grant_type", "authorization_code"); - body.set("code", deviceToken.authorization_code); - body.set("redirect_uri", CODEX_DEVICE_REDIRECT_URI); - body.set("client_id", CODEX_CLIENT_ID); - body.set("code_verifier", deviceToken.code_verifier); - - const tokenResponse = await fetch(CODEX_TOKEN_ENDPOINT, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: body.toString(), - }); - - if (!tokenResponse.ok) { - const detail = await safeErrorDetail(tokenResponse); - throw new Error( - detail - ? `OpenAI token exchange failed: ${detail}` - : `OpenAI token exchange failed: HTTP ${tokenResponse.status}`, - ); - } - - const tokens = (await tokenResponse.json()) as { - access_token?: string; - refresh_token?: string; - id_token?: string; - expires_in?: number; - }; - - if (!tokens.access_token) { - throw new Error("OpenAI device login returned no access_token."); - } - - const expiresAt = tokens.expires_in ? Date.now() + tokens.expires_in * 1000 : undefined; - - return { - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token, - idToken: tokens.id_token, - expiresAt, - accountId: extractChatgptAccountId(tokens.access_token, tokens.id_token), - }; - } - - if (response.status !== 403 && response.status !== 404) { - const detail = await safeErrorDetail(response); - throw new Error(detail || `OpenAI device flow failed: HTTP ${response.status}`); - } - - await delay(intervalMs + CODEX_DEVICE_POLLING_SAFETY_MARGIN_MS); - } - - throw new Error("OpenAI device code expired. Retry setup."); -} - -/** - * Begin the GitHub Device Flow. Same shape as {@link beginCodexDeviceAuth} - * but the next step is {@link completeGithubDeviceAuth}, which returns - * a Copilot-personal-access-token-style string stored as the user's - * GitHub PAT against the `copilot-proxy` provider. - */ -export async function beginGithubDeviceAuth(): Promise { - const response = await fetch(GITHUB_DEVICE_CODE_ENDPOINT, { - method: "POST", - headers: { - Accept: "application/json", - "Content-Type": "application/x-www-form-urlencoded", - }, - body: new URLSearchParams({ client_id: GITHUB_DEVICE_CLIENT_ID, scope: GITHUB_DEVICE_SCOPE }), - }); - - if (!response.ok) { - throw new Error(`GitHub device code failed: HTTP ${response.status}`); - } - - const payload = (await response.json()) as Record; - const verificationUri = - typeof payload.verification_uri === "string" - ? payload.verification_uri - : "https://github.com/login/device"; - const userCode = typeof payload.user_code === "string" ? payload.user_code : ""; - const deviceCode = typeof payload.device_code === "string" ? payload.device_code : ""; - const intervalSeconds = Number(payload.interval ?? 5); - const expiresInSeconds = Number(payload.expires_in ?? 900); - - if (!userCode || !deviceCode) { - throw new Error("GitHub device code returned an incomplete challenge."); - } - - return { - verificationUri, - userCode, - deviceCode, - intervalMs: Math.max(1000, Number.isFinite(intervalSeconds) ? intervalSeconds * 1000 : 5_000), - expiresAt: Date.now() + (Number.isFinite(expiresInSeconds) ? expiresInSeconds : 900) * 1000, - }; -} - -/** - * Poll the GitHub access-token endpoint and return the PAT. Axcut's - * `copilot-account.ts` is a thin wrapper around the same `completeGitHubDeviceAuth`, - * which is what we expose here. - */ -export async function completeGithubDeviceAuth( - challenge: Omit, -): Promise { - while (Date.now() < challenge.expiresAt) { - const response = await fetch(GITHUB_DEVICE_TOKEN_ENDPOINT, { - method: "POST", - headers: { - Accept: "application/json", - "Content-Type": "application/x-www-form-urlencoded", - }, - body: new URLSearchParams({ - client_id: GITHUB_DEVICE_CLIENT_ID, - device_code: challenge.deviceCode, - grant_type: "urn:ietf:params:oauth:grant-type:device_code", - }), - }); - - const payload = (await response.json().catch(() => ({}))) as Record; - const accessToken = typeof payload.access_token === "string" ? payload.access_token : ""; - if (accessToken) { - return accessToken; - } - - const error = typeof payload.error === "string" ? payload.error : ""; - if (error && error !== "authorization_pending" && error !== "slow_down") { - const description = - typeof payload.error_description === "string" ? payload.error_description : error; - throw new Error(description); - } - - await delay(challenge.intervalMs + GITHUB_DEVICE_POLLING_SAFETY_MARGIN_MS); - } - - throw new Error("GitHub device login expired."); -} - -/** - * Exchange a long-lived GitHub PAT (or OAuth-derived token) for the - * short-lived Copilot API bearer used to call the models endpoint. - * Caller is responsible for caching this; we do not persist the - * Copilot runtime token here. - */ -export async function exchangeGithubCopilotRuntimeToken( - githubToken: string, -): Promise { - const response = await fetch(COPILOT_TOKEN_ENDPOINT, { - method: "GET", - headers: { - Accept: "application/json", - Authorization: `Bearer ${githubToken}`, - "User-Agent": "GitHubCopilotChat/0.26.7", - }, - }); - - if (!response.ok) { - throw new Error(`Copilot token exchange failed: HTTP ${response.status}`); - } - - const payload = (await response.json()) as { - token?: string; - expires_at?: number; - expires_in?: number; - }; - if (!payload.token) { - throw new Error("Copilot token exchange returned no token."); - } - - const expiresAt = - typeof payload.expires_at === "number" - ? payload.expires_at * 1000 - : Date.now() + (typeof payload.expires_in === "number" ? payload.expires_in : 300) * 1000; - - return { - token: payload.token, - expiresAt, - baseUrl: deriveCopilotApiBaseUrl(payload.token) ?? "https://api.individual.githubcopilot.com", - }; -} - -function deriveCopilotApiBaseUrl(token: string): string | undefined { - try { - const [, payload] = token.split("."); - if (!payload) return undefined; - const json = JSON.parse(Buffer.from(payload, "base64").toString("utf8")) as { - endpoints?: { api?: string }; - }; - const api = json.endpoints?.api; - return typeof api === "string" ? api : undefined; - } catch { - return undefined; - } -} - -function delay(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -/** - * Fetch the model list for an already-authenticated ChatGPT (Codex) account. - * Returns the slug list visible to the account, sorted by ascending priority. - * On any error, returns an empty list (the caller should fall back to the - * default model in the form). - */ -export async function listOpenAiAccountModels(accessToken: string): Promise { - const headers: Record = { - Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/json", - }; - try { - const accountId = extractChatgptAccountId(accessToken); - if (accountId) headers["chatgpt-account-id"] = accountId; - } catch { - // best effort - } - - const response = await fetch( - `${OPENAI_ACCOUNT_BASE_URL}${CODEX_MODELS_PATH}?client_version=1.0.0`, - { - headers, - }, - ); - - if (!response.ok) { - throw new Error(`Codex model discovery failed: HTTP ${response.status}`); - } - - const data = (await response.json()) as { - models?: Array<{ - slug?: string; - visibility?: string; - supported_in_api?: boolean; - priority?: number; - }>; - }; - - return (data.models ?? []) - .filter((model) => typeof model.slug === "string" && model.slug!.trim().length > 0) - .filter((model) => (model.visibility ?? "list") === "list") - .sort( - (left, right) => - (left.priority ?? Number.MAX_SAFE_INTEGER) - (right.priority ?? Number.MAX_SAFE_INTEGER), - ) - .map((model) => model.slug!.trim()); -} - -/** - * Fetch the model list for an already-authenticated GitHub Copilot account. - * Exchanges the stored PAT for a short-lived Copilot API bearer, then calls - * `{baseUrl}/models`. The result is sorted alphabetically for stable rendering. - */ -export async function listGithubCopilotModels(githubToken: string): Promise { - const runtime = await exchangeGithubCopilotRuntimeToken(githubToken); - const response = await fetch(`${runtime.baseUrl}/models`, { - headers: { - Authorization: `Bearer ${runtime.token}`, - Accept: "application/json", - "User-Agent": GITHUB_COPILOT_USER_AGENT, - "Editor-Version": GITHUB_COPILOT_EDITOR_VERSION, - "Editor-Plugin-Version": GITHUB_COPILOT_PLUGIN_VERSION, - }, - }); - - if (!response.ok) { - throw new Error(`Copilot model discovery failed: HTTP ${response.status}`); - } - - const payload = (await response.json()) as { data?: Array<{ id?: string }> }; - return (payload.data ?? []) - .map((entry) => entry.id?.trim() ?? "") - .filter((id) => id.length > 0) - .sort((left, right) => left.localeCompare(right)); -} +// Credentials still live in the `safeStorage` blob via `LlmConfigStore`. /** * Generic `GET {url}` model-list fetch shared by the OpenAI-shaped @@ -484,6 +88,9 @@ const MINIMAX_DISCOVERY_CANDIDATE_MODELS = [ * error. Uses the OpenAI-compatible `/v1/chat/completions` path (a sibling of * the Anthropic-shaped `/anthropic` base actually used for chat) purely as a * cheap existence check. + * + * ponytail: these probes spend the user's own key — 9 requests at max_tokens 1, + * once per discovery click. Cache per key if it ever runs on a hot path. */ export async function probeMiniMaxModels(apiKey: string, baseUrl?: string): Promise { const resolvedBaseUrl = baseUrl || "https://api.minimax.io/anthropic"; @@ -537,47 +144,3 @@ export async function probeMiniMaxModels(apiKey: string, baseUrl?: string): Prom } return reachable; } - -async function safeErrorDetail(response: Response): Promise { - try { - const data = (await response.json()) as { - error?: string; - error_description?: string; - message?: string; - }; - return data.error_description ?? data.message ?? data.error; - } catch { - try { - return (await response.text()).trim() || undefined; - } catch { - return undefined; - } - } -} - -/** - * Pull the `chatgpt_account_id` claim from a ChatGPT JWT (access token or - * id token). Returns undefined if the claim is missing — model discovery - * then omits the `chatgpt-account-id` header. - */ -export function extractChatgptAccountId(accessToken: string, idToken?: string): string | undefined { - for (const token of [accessToken, idToken ?? ""]) { - if (!token) continue; - const segments = token.split("."); - if (segments.length !== 3) continue; - try { - const json = Buffer.from(segments[1]!, "base64").toString("utf8"); - const payload = JSON.parse(json) as Record; - const claim = payload["https://api.openai.com/auth"]; - if (claim && typeof claim === "object") { - const accountId = (claim as Record).chatgpt_account_id; - if (typeof accountId === "string" && accountId) { - return accountId; - } - } - } catch { - // fall through and try next token - } - } - return undefined; -} diff --git a/electron/ai-edition/provider-registry.test.ts b/electron/ai-edition/provider-registry.test.ts new file mode 100644 index 000000000..d4918bfc1 --- /dev/null +++ b/electron/ai-edition/provider-registry.test.ts @@ -0,0 +1,37 @@ +// Guards the 1.8.0 provider removal. `openai-oauth` and `copilot-proxy` reached +// a user's subscription by presenting GitHub's and OpenAI's own OAuth client IDs +// and an editor User-Agent against first-party-only endpoints, inside a signed +// installer. They come back on the Copilot SDK / `codex app-server` instead. +// +// This is a lint, not a unit test: it fails if someone re-adds a provider that +// points at one of those endpoints, or an auth kind the app no longer implements. + +import { describe, expect, it } from "vitest"; +import { PROVIDER_DEFINITIONS } from "./provider-registry"; + +const FIRST_PARTY_ONLY_HOSTS = [ + "chatgpt.com/backend-api", + "copilot_internal", + "githubcopilot.com", + "api.individual.githubcopilot.com", +]; + +describe("PROVIDER_DEFINITIONS", () => { + it("ships only API-key providers", () => { + const others = PROVIDER_DEFINITIONS.filter((def) => def.authKind !== "api-key"); + expect(others.map((d) => d.id)).toEqual([]); + }); + + it("points at no endpoint reserved for a vendor's own clients", () => { + const offenders = PROVIDER_DEFINITIONS.filter((def) => + FIRST_PARTY_ONLY_HOSTS.some((host) => def.baseUrl?.includes(host)), + ); + expect(offenders.map((d) => `${d.id} -> ${d.baseUrl}`)).toEqual([]); + }); + + it("no longer defines the two removed providers", () => { + const ids = PROVIDER_DEFINITIONS.map((def) => def.id); + expect(ids).not.toContain("openai-oauth"); + expect(ids).not.toContain("copilot-proxy"); + }); +}); diff --git a/electron/ai-edition/provider-registry.ts b/electron/ai-edition/provider-registry.ts index c8fb047e2..458334c26 100644 --- a/electron/ai-edition/provider-registry.ts +++ b/electron/ai-edition/provider-registry.ts @@ -10,7 +10,10 @@ export interface ProviderDefinition { id: string; label: string; defaultModel: string; - authKind: "api-key" | "oauth-device" | "pat"; + /** Only API-key providers ship today — see the removal note in + * PROVIDER_DEFINITIONS. Widen this again when the Copilot SDK / Codex + * app-server providers land. */ + authKind: "api-key"; supportsReasoningEffort: boolean; /** True when this provider always requires the user to enter a base URL * (e.g. openai-compatible). False when the default is implicit. */ @@ -86,26 +89,14 @@ export const PROVIDER_DEFINITIONS: ProviderDefinition[] = [ envKeys: ["OPENROUTER_LLM_API_KEY", "OPENROUTER_API_KEY"], setupHint: "Use OPENROUTER_API_KEY or paste an OpenRouter API key.", }, - { - id: "openai-oauth", - label: "ChatGPT (OAuth)", - defaultModel: "gpt-5.4", - authKind: "oauth-device", - supportsReasoningEffort: true, - envKeys: [], - baseUrl: "https://chatgpt.com/backend-api", - setupHint: "Connect a ChatGPT account with the device login flow.", - }, - { - id: "copilot-proxy", - label: "GitHub Copilot", - defaultModel: "gpt-4.1", - authKind: "pat", - supportsReasoningEffort: true, - envKeys: ["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"], - baseUrl: "https://api.individual.githubcopilot.com", - setupHint: "Use a GitHub Copilot token or connect with the device login flow.", - }, + // REMOVED for 1.8.0: "openai-oauth" (ChatGPT) and "copilot-proxy" (GitHub + // Copilot). Both reached a user's subscription by presenting GitHub's and + // OpenAI's own client IDs and editor User-Agents against endpoints reserved + // for first-party clients (api.github.com/copilot_internal, chatgpt.com/ + // backend-api). Both vendors now offer a sanctioned surface — GitHub's + // Copilot SDK (register our own OAuth App) and `codex app-server` (drives + // the user's own `codex login`, no client ID shipped at all) — so these come + // back on those, not on borrowed credentials. Tracked in the follow-up PR. { id: "minimax", label: "MiniMax API", diff --git a/electron/ipc/nativeBridge.ts b/electron/ipc/nativeBridge.ts index 88b90092b..d5911e1c9 100644 --- a/electron/ipc/nativeBridge.ts +++ b/electron/ipc/nativeBridge.ts @@ -493,23 +493,6 @@ export function registerNativeBridgeHandlers(context: NativeBridgeContext) { requestId, await aiEditionService.llmRemoveApiKey(request.payload.providerId), ); - case "llm.beginDeviceAuth": - return createSuccessResponse( - requestId, - await aiEditionService.llmBeginDeviceAuth( - request.payload.providerId, - request.payload.model, - ), - ); - case "llm.completeDeviceAuth": - return createSuccessResponse( - requestId, - await aiEditionService.llmCompleteDeviceAuth( - request.payload.providerId, - request.payload.challenge, - request.payload.model, - ), - ); case "llm.disconnect": return createSuccessResponse( requestId, diff --git a/electron/native-bridge/services/aiEditionService.ts b/electron/native-bridge/services/aiEditionService.ts index 1d5933970..49b2a3aab 100644 --- a/electron/native-bridge/services/aiEditionService.ts +++ b/electron/native-bridge/services/aiEditionService.ts @@ -9,8 +9,6 @@ import type { AiEditionChatRewindResult, AiEditionChatSession, AiEditionChatSessionSummary, - AiEditionDeviceChallenge, - AiEditionDeviceCompletionResult, AiEditionDocumentResult, AiEditionLlmConfig, AiEditionLlmDisconnectResult, @@ -26,15 +24,9 @@ import type { ChatEventSink } from "../../ai-edition/chat-service"; import type { DocumentService } from "../../ai-edition/document-service"; import type { LlmConfigStore, LlmCredential } from "../../ai-edition/llm-config-store"; import { - beginCodexDeviceAuth, - beginGithubDeviceAuth, - completeCodexDeviceAuth, - completeGithubDeviceAuth, listAnthropicModels, - listGithubCopilotModels, listGoogleModels, listMistralModels, - listOpenAiAccountModels, listOpenAiCompatibleModels, listOpenRouterModels, probeMiniMaxModels, @@ -220,80 +212,6 @@ export class AiEditionService { } } - async llmBeginDeviceAuth( - providerId: "openai-oauth" | "copilot-proxy", - _model?: string, - ): Promise { - if (providerId === "openai-oauth") { - return await beginCodexDeviceAuth(); - } - if (providerId === "copilot-proxy") { - return await beginGithubDeviceAuth(); - } - throw new Error(`Provider ${providerId} does not support device flow.`); - } - - async llmCompleteDeviceAuth( - providerId: "openai-oauth" | "copilot-proxy", - challenge: AiEditionDeviceChallenge, - model?: string, - ): Promise { - try { - if (providerId === "openai-oauth") { - const tokens = await completeCodexDeviceAuth({ - deviceAuthId: challenge.deviceAuthId ?? "", - userCode: challenge.userCode, - intervalMs: challenge.intervalMs, - expiresAt: challenge.expiresAt, - }); - const entry: LlmCredential = { - kind: "codex", - apiKey: tokens.accessToken, - refreshToken: tokens.refreshToken, - accountId: tokens.accountId, - expiresAt: tokens.expiresAt, - }; - await this.options.llmConfig.setCredential(providerId, entry); - } else if (providerId === "copilot-proxy") { - const token = await completeGithubDeviceAuth({ - deviceCode: challenge.deviceCode ?? "", - userCode: challenge.userCode, - intervalMs: challenge.intervalMs, - expiresAt: challenge.expiresAt, - }); - const entry: LlmCredential = { kind: "github-device", apiKey: token }; - await this.options.llmConfig.setCredential(providerId, entry); - } else { - throw new Error(`Provider ${providerId} does not support device flow.`); - } - - // Persist the selected model so the chat path knows which model to use. - const existing = this.options.llmConfig.getConfig(); - if (model && (!existing || existing.provider !== providerId)) { - await this.options.llmConfig.setConfig({ - provider: providerId, - model, - baseUrl: existing?.provider === providerId ? existing.baseUrl : undefined, - reasoningEffort: existing?.provider === providerId ? existing.reasoningEffort : undefined, - }); - } else if (!existing) { - const def = PROVIDER_DEFINITIONS.find((d) => d.id === providerId); - if (def) { - await this.options.llmConfig.setConfig({ - provider: providerId, - model: def.defaultModel, - }); - } - } - return { success: true, snapshot: await this.llmGetSnapshot() }; - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : String(error), - }; - } - } - async llmDisconnect(providerId: string): Promise { await this.options.llmConfig.removeCredential(providerId); const active = this.options.llmConfig.getConfig(); @@ -308,18 +226,6 @@ export class AiEditionService { async llmListProviderModels(providerId: string): Promise<{ models: string[]; error?: string }> { try { - if (providerId === "openai-oauth") { - const cred = this.options.llmConfig.getCredential(providerId, []); - if (!cred) return { models: [], error: "Not connected" }; - const models = await listOpenAiAccountModels(cred.value); - return { models }; - } - if (providerId === "copilot-proxy") { - const cred = this.options.llmConfig.getCredential(providerId, []); - if (!cred) return { models: [], error: "Not connected" }; - const models = await listGithubCopilotModels(cred.value); - return { models }; - } const def = PROVIDER_DEFINITIONS.find((d) => d.id === providerId); if (!def) return { models: [], error: `Unknown provider ${providerId}` }; const cred = this.options.llmConfig.getCredential(providerId, def.envKeys); @@ -478,9 +384,6 @@ export class AiEditionService { } const def = PROVIDER_DEFINITIONS.find((d) => d.id === config.provider); const credential = def ? this.options.llmConfig.getCredential(def.id, def.envKeys) : null; - const accountId = - credential?.entry.kind === "codex" ? (credential.entry.accountId ?? undefined) : undefined; - const result = await translateCaptionSegments({ segments: input.segments, targetLanguage: input.targetLanguage, @@ -490,7 +393,6 @@ export class AiEditionService { apiKey: credential?.value ?? "", baseUrl: config.baseUrl, reasoningEffort: config.reasoningEffort, - accountId, }); return { ...result, model: config.model }; } diff --git a/src/components/ai-edition/ProviderSettings.tsx b/src/components/ai-edition/ProviderSettings.tsx index 5dd4a0ded..bf0db0565 100644 --- a/src/components/ai-edition/ProviderSettings.tsx +++ b/src/components/ai-edition/ProviderSettings.tsx @@ -2,44 +2,26 @@ // // UI: 3 screens stacked in the modal, navigated by URL-less state // (mirroring axcut apps/web/src/App.tsx _p modal): -// 1. **list** — 2-col grid of provider cards (8 of them), -// each shows label, default model, an auth-kind -// pill (CONNECTED / OAUTH / TOKEN / API KEY). -// 2. **connect-form** — single form per provider: model + optional -// baseUrl + optional reasoning effort + optional -// api-key field + Connect/Save/Disconnect buttons. -// 3. **device-challenge** — opens when an oauth-device / PAT provider -// finishes its begin step: the user-code block, -// "Open login page" link, and a copy-code button. -// On completion the snapshot refreshes. +// 1. **list** — grid of provider cards, each showing label, default +// model, and a CONNECTED / API KEY pill. +// 2. **connect-form** — single form per provider: model + optional baseUrl + +// optional reasoning effort + api-key field + +// Save/Disconnect buttons. // -// Credentials are stored in the same safeStorage blob (LLMConfigStore), -// including OAuth session tokens. The renderer never sees raw keys — -// only `kind` and the user code / verification URL. +// Every provider is API-key based since 1.8.0 dropped the ChatGPT and Copilot +// OAuth providers (see provider-registry.ts); the device-challenge screen went +// with them. Credentials live in the safeStorage blob (LlmConfigStore) — the +// renderer never sees raw keys, only `kind`. // // ponytail: existing consumers (ProviderSettings used as ) // must keep working. Internal state is local-only. -import { - AlertCircle, - Check, - Copy, - ExternalLink, - Loader2, - LogIn, - Plug, - Unplug, - X, -} from "lucide-react"; +import { AlertCircle, Check, Loader2, Unplug, X } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; import { toast } from "sonner"; import { useScopedT } from "@/contexts/I18nContext"; import { nativeBridgeClient } from "@/native/client"; -import type { - AiEditionDeviceChallenge, - AiEditionLlmConfig, - AiEditionLlmSnapshot, -} from "@/native/contracts"; +import type { AiEditionLlmConfig, AiEditionLlmSnapshot } from "@/native/contracts"; import { getReasoningEffortLabel, getReasoningEffortOptions, @@ -69,7 +51,6 @@ export function ProviderSettings({ const [config, setConfig] = useState(null); const [apiKey, setApiKey] = useState(""); const [busy, setBusy] = useState(false); - const [challenge, setChallenge] = useState(null); const [error, setError] = useState(null); const refreshSnapshot = useCallback(async (): Promise => { @@ -96,7 +77,6 @@ export function ProviderSettings({ setMode("list"); setActive(null); setApiKey(""); - setChallenge(null); setError(null); } }, [open]); @@ -106,7 +86,6 @@ export function ProviderSettings({ setMode("list"); setActive(null); setApiKey(""); - setChallenge(null); setError(null); }, [busy]); @@ -125,7 +104,6 @@ export function ProviderSettings({ const openForm = (def: ProviderDefinition) => { setActive(def); setApiKey(""); - setChallenge(null); setError(null); setConfig((prev) => { const existing = prev?.provider === def.id ? prev : null; @@ -146,7 +124,6 @@ export function ProviderSettings({ setMode("list"); setActive(null); setApiKey(""); - setChallenge(null); setError(null); }; @@ -172,79 +149,6 @@ export function ProviderSettings({ } }; - const startDeviceFlow = async () => { - if (!active) return; - setBusy(true); - setError(null); - try { - const challengeResult = await nativeBridgeClient.aiEdition.llmBeginDeviceAuth( - active.id as "openai-oauth" | "copilot-proxy", - config?.model, - ); - setChallenge(challengeResult); - if (active.id === "copilot-proxy") { - // GitHub device flow accepts an optional PAT. If the user has pasted one, - // pass it through as if they were connecting with the classic token path. - if (apiKey.trim() && active.id === "copilot-proxy") { - // Skipped for now — Copilot device flow uses its own token. - } - } - // Auto-complete poll: kick off completion in the background, the UI shows - // "Completing sign-in…" and the snapshot refresh when the call returns. - void completeDeviceFlowInBackground(challengeResult); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } finally { - setBusy(false); - } - }; - - const completeDeviceFlowInBackground = async (challengeParam: AiEditionDeviceChallenge) => { - if (!active) return; - setBusy(true); - try { - const result = await nativeBridgeClient.aiEdition.llmCompleteDeviceAuth( - active.id as "openai-oauth" | "copilot-proxy", - challengeParam, - config?.model, - ); - if (result.success) { - const snap = await refreshSnapshot(); - onActiveProviderChanged?.(snap?.config?.provider ?? null); - toast.success(te("providerSettings.connected", { provider: active.label })); - setMode("list"); - setActive(null); - setChallenge(null); - } else { - setError(result.error ?? te("providerSettings.deviceFlowFailed")); - } - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } finally { - setBusy(false); - } - }; - - const startPatConnect = async () => { - if (!active || !apiKey.trim() || !config) return; - setBusy(true); - setError(null); - try { - await nativeBridgeClient.aiEdition.llmSetApiKey(active.id, apiKey.trim()); - await nativeBridgeClient.aiEdition.llmSetConfig(config); - const snap = await refreshSnapshot(); - onActiveProviderChanged?.(snap?.config?.provider ?? null); - toast.success(te("providerSettings.connected", { provider: active.label })); - setApiKey(""); - setMode("list"); - setActive(null); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } finally { - setBusy(false); - } - }; - const disconnect = async () => { if (!active) return; setBusy(true); @@ -288,15 +192,10 @@ export function ProviderSettings({ config={config} setConfig={setConfig} busy={busy} - challenge={challenge} error={error} onBack={goBackToList} - onSave={active.authKind === "api-key" ? saveApiKey : startDeviceFlow} - onPatConnect={active.authKind === "pat" ? startPatConnect : undefined} + onSave={saveApiKey} onDisconnect={disconnect} - onOpenLoginPage={(uri) => - typeof window !== "undefined" && window.open(uri, "_blank", "noopener,noreferrer") - } listProviderModels={nativeBridgeClient.aiEdition.llmListProviderModels} /> ) : null} @@ -333,16 +232,6 @@ function ProviderList({ {te("providerSettings.pillConnected")} - ) : authKindPillKind(def.authKind) === "auth" ? ( - - - {te("providerSettings.pillOAuth")} - - ) : def.authKind === "pat" ? ( - - - {te("providerSettings.pillToken")} - ) : ( @@ -358,17 +247,6 @@ function ProviderList({ ); } -function authKindPillKind(kind: ProviderDefinition["authKind"]): "auth" | "pat" | "key" { - switch (kind) { - case "oauth-device": - return "auth"; - case "pat": - return "pat"; - case "api-key": - return "key"; - } -} - function KeyIcon() { // tiny single-stroke "••• " icon as a span, avoids pulling in another lucide dep. return ( @@ -395,13 +273,10 @@ function ProviderForm({ config, setConfig, busy, - challenge, error, onBack, onSave, - onPatConnect, onDisconnect, - onOpenLoginPage, listProviderModels, }: { def: ProviderDefinition; @@ -412,19 +287,14 @@ function ProviderForm({ config: AiEditionLlmConfig | null; setConfig: (c: AiEditionLlmConfig | null) => void; busy: boolean; - challenge: AiEditionDeviceChallenge | null; error: string | null; onBack: () => void; onSave: () => void; - onPatConnect?: () => void; onDisconnect: () => void; - onOpenLoginPage: (uri: string) => void; listProviderModels: (providerId: string) => Promise<{ models: string[]; error?: string }>; }) { const te = useScopedT("editor"); - const showApiKeyField = def.authKind === "api-key" || (def.authKind === "pat" && !isConnected); const showBaseUrl = def.id === "openai-compatible" || Boolean(def.baseUrl); - const isCodexOrCopilot = def.authKind === "oauth-device"; // Every provider now exposes a live model list once connected (matching // axcut's `screen === 'models' && activeProvider?.connected` gate) — API-key // providers hit their own /models endpoint (or, for MiniMax, a probe call) @@ -603,30 +473,18 @@ function ProviderForm({ ) : null} - {showApiKeyField ? ( - - setApiKey(e.target.value)} - disabled={busy} - /> - - ) : null} + + setApiKey(e.target.value)} + disabled={busy} + /> + - {challenge ? ( - - ) : null} {error ? (

@@ -704,26 +554,6 @@ function ProviderForm({ {busy ? : } {te("providerSettings.save")} - ) : isCodexOrCopilot ? ( - - ) : def.authKind === "pat" && onPatConnect ? ( - ) : ( - -

- - ); -} - function Field({ label, hint, diff --git a/src/i18n/locales/ar/editor.json b/src/i18n/locales/ar/editor.json index a9f931f15..9fb33a0ee 100644 --- a/src/i18n/locales/ar/editor.json +++ b/src/i18n/locales/ar/editor.json @@ -382,10 +382,7 @@ "saved": "تم حفظ {{provider}}", "connected": "تم الاتصال بـ {{provider}}", "disconnected": "تم قطع الاتصال بـ {{provider}}", - "deviceFlowFailed": "فشل تدفق مصادقة الجهاز.", "pillConnected": "متصل", - "pillOAuth": "OAuth", - "pillToken": "رمز مميز", "pillApiKey": "مفتاح API", "notConnected": "غير متصل", "back": "رجوع", @@ -400,23 +397,12 @@ "reasoningEffortLabel": "مستوى الاستدلال", "apiKeyLabel": "مفتاح API", "apiKeyHintStored": "محفوظ في safeStorage. اتركه فارغًا للاحتفاظ بالإدخال الحالي.", - "patLabel": "رمز وصول شخصي من GitHub", - "patHint": "الصق رمز PAT من GitHub بنطاق `copilot`.", "projectEditsLabel": "تعديلات المشروع", "projectEditsHint": "عند الإيقاف، يجب أن يستأذن الوكيل قبل تغيير المخطط الزمني. يمكن دائمًا التراجع عن التعديلات.", "allowAgentEdits": "السماح للوكيل بتعديل المشروع", "disconnect": "قطع الاتصال", "cancel": "إلغاء", "save": "حفظ", - "saveAndUse": "حفظ واستخدام", - "reconnect": "إعادة الاتصال", - "startLogin": "بدء تسجيل الدخول", - "connect": "اتصال", - "completingSignIn": "جارٍ إتمام تسجيل الدخول…", - "browserLoginPending": "في انتظار تسجيل الدخول عبر المتصفح", - "deviceCodeInstructions": "افتح صفحة تسجيل الدخول الخاصة بـ {{provider}} وأدخل هذا الرمز. سنُكمل الاتصال تلقائيًا بمجرد منحك الإذن.", - "copyCode": "نسخ الرمز", - "copied": "تم النسخ", - "openLoginPage": "فتح صفحة تسجيل الدخول" + "saveAndUse": "حفظ واستخدام" } } diff --git a/src/i18n/locales/en/editor.json b/src/i18n/locales/en/editor.json index 4e1c39a3b..71a4a4d50 100644 --- a/src/i18n/locales/en/editor.json +++ b/src/i18n/locales/en/editor.json @@ -382,10 +382,7 @@ "saved": "{{provider}} saved", "connected": "{{provider}} connected", "disconnected": "{{provider}} disconnected", - "deviceFlowFailed": "Device flow failed.", "pillConnected": "Connected", - "pillOAuth": "OAuth", - "pillToken": "Token", "pillApiKey": "API key", "notConnected": "Not connected", "back": "Back", @@ -400,23 +397,12 @@ "reasoningEffortLabel": "Reasoning effort", "apiKeyLabel": "API key", "apiKeyHintStored": "Stored in safeStorage. Leave blank to keep the existing entry.", - "patLabel": "GitHub personal access token", - "patHint": "Paste a GitHub PAT with `copilot` scope.", "projectEditsLabel": "Project edits", "projectEditsHint": "When off, the agent must ask before changing the timeline. Edits are always undoable.", "allowAgentEdits": "Allow the agent to edit the project", "disconnect": "Disconnect", "cancel": "Cancel", "save": "Save", - "saveAndUse": "Save & use", - "reconnect": "Reconnect", - "startLogin": "Start login", - "connect": "Connect", - "completingSignIn": "Completing sign-in…", - "browserLoginPending": "Browser login pending", - "deviceCodeInstructions": "Open the {{provider}} login page and enter this code. We'll finish connecting automatically once you authorize.", - "copyCode": "Copy code", - "copied": "Copied", - "openLoginPage": "Open login page" + "saveAndUse": "Save & use" } } diff --git a/src/i18n/locales/es/editor.json b/src/i18n/locales/es/editor.json index da5379322..531e25f94 100644 --- a/src/i18n/locales/es/editor.json +++ b/src/i18n/locales/es/editor.json @@ -382,10 +382,7 @@ "saved": "{{provider}} guardado", "connected": "{{provider}} conectado", "disconnected": "{{provider}} desconectado", - "deviceFlowFailed": "Error en el flujo de dispositivo.", "pillConnected": "Conectado", - "pillOAuth": "OAuth", - "pillToken": "Token", "pillApiKey": "Clave API", "notConnected": "No conectado", "back": "Atrás", @@ -400,23 +397,12 @@ "reasoningEffortLabel": "Esfuerzo de razonamiento", "apiKeyLabel": "Clave API", "apiKeyHintStored": "Guardada en safeStorage. Déjalo en blanco para conservar la entrada existente.", - "patLabel": "Token de acceso personal de GitHub", - "patHint": "Pega un PAT de GitHub con el ámbito `copilot`.", "projectEditsLabel": "Ediciones del proyecto", "projectEditsHint": "Si está desactivado, el agente debe preguntar antes de cambiar la línea de tiempo. Las ediciones siempre se pueden deshacer.", "allowAgentEdits": "Permitir que el agente edite el proyecto", "disconnect": "Desconectar", "cancel": "Cancelar", "save": "Guardar", - "saveAndUse": "Guardar y usar", - "reconnect": "Reconectar", - "startLogin": "Iniciar sesión", - "connect": "Conectar", - "completingSignIn": "Completando el inicio de sesión…", - "browserLoginPending": "Inicio de sesión en el navegador pendiente", - "deviceCodeInstructions": "Abre la página de inicio de sesión de {{provider}} e introduce este código. Terminaremos de conectar automáticamente en cuanto autorices.", - "copyCode": "Copiar código", - "copied": "Copiado", - "openLoginPage": "Abrir página de inicio de sesión" + "saveAndUse": "Guardar y usar" } } diff --git a/src/i18n/locales/fr/editor.json b/src/i18n/locales/fr/editor.json index 1d3ea011f..917419d07 100644 --- a/src/i18n/locales/fr/editor.json +++ b/src/i18n/locales/fr/editor.json @@ -382,10 +382,7 @@ "saved": "{{provider}} enregistré", "connected": "{{provider}} connecté", "disconnected": "{{provider}} déconnecté", - "deviceFlowFailed": "Échec de l'authentification par appareil.", "pillConnected": "Connecté", - "pillOAuth": "OAuth", - "pillToken": "Jeton", "pillApiKey": "Clé API", "notConnected": "Non connecté", "back": "Retour", @@ -400,23 +397,12 @@ "reasoningEffortLabel": "Effort de raisonnement", "apiKeyLabel": "Clé API", "apiKeyHintStored": "Stockée dans safeStorage. Laissez vide pour conserver l'entrée existante.", - "patLabel": "Jeton d'accès personnel GitHub", - "patHint": "Collez un PAT GitHub avec la portée `copilot`.", "projectEditsLabel": "Modifications du projet", "projectEditsHint": "Si désactivé, l'agent doit demander avant de modifier la timeline. Les modifications sont toujours annulables.", "allowAgentEdits": "Autoriser l'agent à modifier le projet", "disconnect": "Déconnecter", "cancel": "Annuler", "save": "Enregistrer", - "saveAndUse": "Enregistrer et utiliser", - "reconnect": "Reconnecter", - "startLogin": "Démarrer la connexion", - "connect": "Connecter", - "completingSignIn": "Finalisation de la connexion…", - "browserLoginPending": "Connexion navigateur en attente", - "deviceCodeInstructions": "Ouvrez la page de connexion {{provider}} et saisissez ce code. La connexion se terminera automatiquement dès que vous aurez autorisé l'accès.", - "copyCode": "Copier le code", - "copied": "Copié", - "openLoginPage": "Ouvrir la page de connexion" + "saveAndUse": "Enregistrer et utiliser" } } diff --git a/src/i18n/locales/it/editor.json b/src/i18n/locales/it/editor.json index 079a0ef27..fe1680aed 100644 --- a/src/i18n/locales/it/editor.json +++ b/src/i18n/locales/it/editor.json @@ -382,10 +382,7 @@ "saved": "{{provider}} salvato", "connected": "{{provider}} connesso", "disconnected": "{{provider}} disconnesso", - "deviceFlowFailed": "Flusso dispositivo non riuscito.", "pillConnected": "Connesso", - "pillOAuth": "OAuth", - "pillToken": "Token", "pillApiKey": "Chiave API", "notConnected": "Non connesso", "back": "Indietro", @@ -400,23 +397,12 @@ "reasoningEffortLabel": "Sforzo di ragionamento", "apiKeyLabel": "Chiave API", "apiKeyHintStored": "Salvata in safeStorage. Lascia vuoto per mantenere la voce esistente.", - "patLabel": "Token di accesso personale GitHub", - "patHint": "Incolla un PAT GitHub con ambito `copilot`.", "projectEditsLabel": "Modifiche al progetto", "projectEditsHint": "Se disattivato, l'agente deve chiedere prima di cambiare la timeline. Le modifiche sono sempre annullabili.", "allowAgentEdits": "Consenti all'agente di modificare il progetto", "disconnect": "Disconnetti", "cancel": "Annulla", "save": "Salva", - "saveAndUse": "Salva e usa", - "reconnect": "Riconnetti", - "startLogin": "Avvia accesso", - "connect": "Connetti", - "completingSignIn": "Completamento accesso…", - "browserLoginPending": "Accesso dal browser in sospeso", - "deviceCodeInstructions": "Apri la pagina di accesso di {{provider}} e inserisci questo codice. Completeremo la connessione automaticamente non appena autorizzi.", - "copyCode": "Copia codice", - "copied": "Copiato", - "openLoginPage": "Apri pagina di accesso" + "saveAndUse": "Salva e usa" } } diff --git a/src/i18n/locales/ja-JP/editor.json b/src/i18n/locales/ja-JP/editor.json index 445e7f163..da5d61d18 100644 --- a/src/i18n/locales/ja-JP/editor.json +++ b/src/i18n/locales/ja-JP/editor.json @@ -382,10 +382,7 @@ "saved": "{{provider}} を保存しました", "connected": "{{provider}} に接続しました", "disconnected": "{{provider}} の接続を解除しました", - "deviceFlowFailed": "デバイス認証に失敗しました。", "pillConnected": "接続済み", - "pillOAuth": "OAuth", - "pillToken": "トークン", "pillApiKey": "API キー", "notConnected": "未接続", "back": "戻る", @@ -400,23 +397,12 @@ "reasoningEffortLabel": "推論の深さ", "apiKeyLabel": "API キー", "apiKeyHintStored": "safeStorage に保存されています。空欄のままにすると既存の値を保持します。", - "patLabel": "GitHub 個人用アクセストークン", - "patHint": "`copilot` スコープを持つ GitHub PAT を貼り付けてください。", "projectEditsLabel": "プロジェクトの編集", "projectEditsHint": "オフの場合、エージェントはタイムラインを変更する前に確認します。編集はいつでも元に戻せます。", "allowAgentEdits": "エージェントによるプロジェクトの編集を許可", "disconnect": "接続を解除", "cancel": "キャンセル", "save": "保存", - "saveAndUse": "保存して使用", - "reconnect": "再接続", - "startLogin": "ログインを開始", - "connect": "接続", - "completingSignIn": "サインインを完了しています…", - "browserLoginPending": "ブラウザーでのログイン待ちです", - "deviceCodeInstructions": "{{provider}} のログインページを開き、このコードを入力してください。承認が完了すると自動的に接続されます。", - "copyCode": "コードをコピー", - "copied": "コピーしました", - "openLoginPage": "ログインページを開く" + "saveAndUse": "保存して使用" } } diff --git a/src/i18n/locales/ko-KR/editor.json b/src/i18n/locales/ko-KR/editor.json index 60012d55f..97c63ffa7 100644 --- a/src/i18n/locales/ko-KR/editor.json +++ b/src/i18n/locales/ko-KR/editor.json @@ -382,10 +382,7 @@ "saved": "{{provider}} 저장됨", "connected": "{{provider}} 연결됨", "disconnected": "{{provider}} 연결 해제됨", - "deviceFlowFailed": "기기 인증에 실패했습니다.", "pillConnected": "연결됨", - "pillOAuth": "OAuth", - "pillToken": "토큰", "pillApiKey": "API 키", "notConnected": "연결되지 않음", "back": "뒤로", @@ -400,23 +397,12 @@ "reasoningEffortLabel": "추론 강도", "apiKeyLabel": "API 키", "apiKeyHintStored": "safeStorage에 저장되어 있습니다. 기존 항목을 유지하려면 비워 두세요.", - "patLabel": "GitHub 개인 액세스 토큰", - "patHint": "`copilot` 범위를 가진 GitHub PAT를 붙여넣으세요.", "projectEditsLabel": "프로젝트 편집", "projectEditsHint": "끄면 에이전트가 타임라인을 변경하기 전에 확인을 요청합니다. 편집은 항상 실행 취소할 수 있습니다.", "allowAgentEdits": "에이전트의 프로젝트 편집 허용", "disconnect": "연결 해제", "cancel": "취소", "save": "저장", - "saveAndUse": "저장 후 사용", - "reconnect": "다시 연결", - "startLogin": "로그인 시작", - "connect": "연결", - "completingSignIn": "로그인 완료 중…", - "browserLoginPending": "브라우저 로그인 대기 중", - "deviceCodeInstructions": "{{provider}} 로그인 페이지를 열고 이 코드를 입력하세요. 승인하면 자동으로 연결이 완료됩니다.", - "copyCode": "코드 복사", - "copied": "복사됨", - "openLoginPage": "로그인 페이지 열기" + "saveAndUse": "저장 후 사용" } } diff --git a/src/i18n/locales/pt-BR/editor.json b/src/i18n/locales/pt-BR/editor.json index 82534fac8..adf637eb0 100644 --- a/src/i18n/locales/pt-BR/editor.json +++ b/src/i18n/locales/pt-BR/editor.json @@ -382,10 +382,7 @@ "saved": "{{provider}} salvo", "connected": "{{provider}} conectado", "disconnected": "{{provider}} desconectado", - "deviceFlowFailed": "Falha no fluxo de dispositivo.", "pillConnected": "Conectado", - "pillOAuth": "OAuth", - "pillToken": "Token", "pillApiKey": "Chave de API", "notConnected": "Não conectado", "back": "Voltar", @@ -400,23 +397,12 @@ "reasoningEffortLabel": "Esforço de raciocínio", "apiKeyLabel": "Chave de API", "apiKeyHintStored": "Armazenada no safeStorage. Deixe em branco para manter a entrada existente.", - "patLabel": "Token de acesso pessoal do GitHub", - "patHint": "Cole um PAT do GitHub com o escopo `copilot`.", "projectEditsLabel": "Edições do projeto", "projectEditsHint": "Quando desativado, o agente precisa perguntar antes de alterar a linha do tempo. As edições sempre podem ser desfeitas.", "allowAgentEdits": "Permitir que o agente edite o projeto", "disconnect": "Desconectar", "cancel": "Cancelar", "save": "Salvar", - "saveAndUse": "Salvar e usar", - "reconnect": "Reconectar", - "startLogin": "Iniciar login", - "connect": "Conectar", - "completingSignIn": "Concluindo o login…", - "browserLoginPending": "Login no navegador pendente", - "deviceCodeInstructions": "Abra a página de login do {{provider}} e digite este código. Terminaremos de conectar automaticamente assim que você autorizar.", - "copyCode": "Copiar código", - "copied": "Copiado", - "openLoginPage": "Abrir página de login" + "saveAndUse": "Salvar e usar" } } diff --git a/src/i18n/locales/ru/editor.json b/src/i18n/locales/ru/editor.json index 6e3c4199e..18de9b298 100644 --- a/src/i18n/locales/ru/editor.json +++ b/src/i18n/locales/ru/editor.json @@ -382,10 +382,7 @@ "saved": "{{provider}} сохранён", "connected": "{{provider}} подключён", "disconnected": "{{provider}} отключён", - "deviceFlowFailed": "Не удалось выполнить вход через устройство.", "pillConnected": "Подключено", - "pillOAuth": "OAuth", - "pillToken": "Токен", "pillApiKey": "API-ключ", "notConnected": "Не подключено", "back": "Назад", @@ -400,23 +397,12 @@ "reasoningEffortLabel": "Глубина рассуждений", "apiKeyLabel": "API-ключ", "apiKeyHintStored": "Хранится в safeStorage. Оставьте пустым, чтобы сохранить текущее значение.", - "patLabel": "Персональный токен доступа GitHub", - "patHint": "Вставьте GitHub PAT с областью `copilot`.", "projectEditsLabel": "Правки проекта", "projectEditsHint": "Если выключено, агент должен спрашивать перед изменением таймлайна. Правки всегда можно отменить.", "allowAgentEdits": "Разрешить агенту редактировать проект", "disconnect": "Отключить", "cancel": "Отмена", "save": "Сохранить", - "saveAndUse": "Сохранить и использовать", - "reconnect": "Переподключить", - "startLogin": "Начать вход", - "connect": "Подключить", - "completingSignIn": "Завершаем вход…", - "browserLoginPending": "Ожидается вход через браузер", - "deviceCodeInstructions": "Откройте страницу входа {{provider}} и введите этот код. Как только вы подтвердите доступ, подключение завершится автоматически.", - "copyCode": "Скопировать код", - "copied": "Скопировано", - "openLoginPage": "Открыть страницу входа" + "saveAndUse": "Сохранить и использовать" } } diff --git a/src/i18n/locales/tr/editor.json b/src/i18n/locales/tr/editor.json index ba94a2f00..2aacd4b9b 100644 --- a/src/i18n/locales/tr/editor.json +++ b/src/i18n/locales/tr/editor.json @@ -382,10 +382,7 @@ "saved": "{{provider}} kaydedildi", "connected": "{{provider}} bağlandı", "disconnected": "{{provider}} bağlantısı kesildi", - "deviceFlowFailed": "Cihaz akışı başarısız oldu.", "pillConnected": "Bağlı", - "pillOAuth": "OAuth", - "pillToken": "Belirteç", "pillApiKey": "API anahtarı", "notConnected": "Bağlı değil", "back": "Geri", @@ -400,23 +397,12 @@ "reasoningEffortLabel": "Akıl yürütme düzeyi", "apiKeyLabel": "API anahtarı", "apiKeyHintStored": "safeStorage'da saklanıyor. Mevcut kaydı korumak için boş bırakın.", - "patLabel": "GitHub kişisel erişim belirteci", - "patHint": "`copilot` kapsamına sahip bir GitHub PAT yapıştırın.", "projectEditsLabel": "Proje düzenlemeleri", "projectEditsHint": "Kapalıyken, aracı zaman çizelgesini değiştirmeden önce izin ister. Düzenlemeler her zaman geri alınabilir.", "allowAgentEdits": "Aracının projeyi düzenlemesine izin ver", "disconnect": "Bağlantıyı kes", "cancel": "İptal", "save": "Kaydet", - "saveAndUse": "Kaydet ve kullan", - "reconnect": "Yeniden bağlan", - "startLogin": "Girişi başlat", - "connect": "Bağlan", - "completingSignIn": "Oturum açma tamamlanıyor…", - "browserLoginPending": "Tarayıcı girişi bekleniyor", - "deviceCodeInstructions": "{{provider}} giriş sayfasını açın ve bu kodu girin. İzin verdiğinizde bağlantıyı otomatik olarak tamamlayacağız.", - "copyCode": "Kodu kopyala", - "copied": "Kopyalandı", - "openLoginPage": "Giriş sayfasını aç" + "saveAndUse": "Kaydet ve kullan" } } diff --git a/src/i18n/locales/vi/editor.json b/src/i18n/locales/vi/editor.json index 1589b6258..f54dc9d15 100644 --- a/src/i18n/locales/vi/editor.json +++ b/src/i18n/locales/vi/editor.json @@ -382,10 +382,7 @@ "saved": "Đã lưu {{provider}}", "connected": "Đã kết nối {{provider}}", "disconnected": "Đã ngắt kết nối {{provider}}", - "deviceFlowFailed": "Luồng xác thực thiết bị thất bại.", "pillConnected": "Đã kết nối", - "pillOAuth": "OAuth", - "pillToken": "Token", "pillApiKey": "Khóa API", "notConnected": "Chưa kết nối", "back": "Quay lại", @@ -400,23 +397,12 @@ "reasoningEffortLabel": "Mức độ suy luận", "apiKeyLabel": "Khóa API", "apiKeyHintStored": "Được lưu trong safeStorage. Để trống để giữ mục hiện có.", - "patLabel": "Mã truy cập cá nhân GitHub", - "patHint": "Dán một GitHub PAT có phạm vi `copilot`.", "projectEditsLabel": "Chỉnh sửa dự án", "projectEditsHint": "Khi tắt, tác nhân phải hỏi trước khi thay đổi dòng thời gian. Mọi chỉnh sửa đều có thể hoàn tác.", "allowAgentEdits": "Cho phép tác nhân chỉnh sửa dự án", "disconnect": "Ngắt kết nối", "cancel": "Hủy", "save": "Lưu", - "saveAndUse": "Lưu và dùng", - "reconnect": "Kết nối lại", - "startLogin": "Bắt đầu đăng nhập", - "connect": "Kết nối", - "completingSignIn": "Đang hoàn tất đăng nhập…", - "browserLoginPending": "Đang chờ đăng nhập trên trình duyệt", - "deviceCodeInstructions": "Mở trang đăng nhập {{provider}} và nhập mã này. Chúng tôi sẽ hoàn tất kết nối tự động ngay khi bạn cấp quyền.", - "copyCode": "Sao chép mã", - "copied": "Đã sao chép", - "openLoginPage": "Mở trang đăng nhập" + "saveAndUse": "Lưu và dùng" } } diff --git a/src/i18n/locales/zh-CN/editor.json b/src/i18n/locales/zh-CN/editor.json index 82643672a..c9e3dde1b 100644 --- a/src/i18n/locales/zh-CN/editor.json +++ b/src/i18n/locales/zh-CN/editor.json @@ -382,10 +382,7 @@ "saved": "已保存 {{provider}}", "connected": "已连接 {{provider}}", "disconnected": "已断开 {{provider}}", - "deviceFlowFailed": "设备授权流程失败。", "pillConnected": "已连接", - "pillOAuth": "OAuth", - "pillToken": "令牌", "pillApiKey": "API 密钥", "notConnected": "未连接", "back": "返回", @@ -400,23 +397,12 @@ "reasoningEffortLabel": "推理强度", "apiKeyLabel": "API 密钥", "apiKeyHintStored": "已存储在 safeStorage 中。留空可保留现有条目。", - "patLabel": "GitHub 个人访问令牌", - "patHint": "粘贴具有 `copilot` 权限范围的 GitHub PAT。", "projectEditsLabel": "项目编辑", "projectEditsHint": "关闭时,智能体在更改时间轴前必须先询问。所有编辑始终可以撤销。", "allowAgentEdits": "允许智能体编辑项目", "disconnect": "断开连接", "cancel": "取消", "save": "保存", - "saveAndUse": "保存并使用", - "reconnect": "重新连接", - "startLogin": "开始登录", - "connect": "连接", - "completingSignIn": "正在完成登录…", - "browserLoginPending": "等待浏览器登录", - "deviceCodeInstructions": "打开 {{provider}} 登录页面并输入此代码。您授权后,我们会自动完成连接。", - "copyCode": "复制代码", - "copied": "已复制", - "openLoginPage": "打开登录页面" + "saveAndUse": "保存并使用" } } diff --git a/src/i18n/locales/zh-TW/editor.json b/src/i18n/locales/zh-TW/editor.json index d8e0487a2..3fece046f 100644 --- a/src/i18n/locales/zh-TW/editor.json +++ b/src/i18n/locales/zh-TW/editor.json @@ -382,10 +382,7 @@ "saved": "已儲存 {{provider}}", "connected": "已連線 {{provider}}", "disconnected": "已中斷 {{provider}}", - "deviceFlowFailed": "裝置授權流程失敗。", "pillConnected": "已連線", - "pillOAuth": "OAuth", - "pillToken": "權杖", "pillApiKey": "API 金鑰", "notConnected": "未連線", "back": "返回", @@ -400,23 +397,12 @@ "reasoningEffortLabel": "推理強度", "apiKeyLabel": "API 金鑰", "apiKeyHintStored": "已儲存在 safeStorage 中。留空可保留現有項目。", - "patLabel": "GitHub 個人存取權杖", - "patHint": "貼上具有 `copilot` 範圍的 GitHub PAT。", "projectEditsLabel": "專案編輯", "projectEditsHint": "關閉時,代理在變更時間軸前必須先詢問。所有編輯都可以復原。", "allowAgentEdits": "允許代理編輯專案", "disconnect": "中斷連線", "cancel": "取消", "save": "儲存", - "saveAndUse": "儲存並使用", - "reconnect": "重新連線", - "startLogin": "開始登入", - "connect": "連線", - "completingSignIn": "正在完成登入…", - "browserLoginPending": "等待瀏覽器登入", - "deviceCodeInstructions": "開啟 {{provider}} 登入頁面並輸入此代碼。您授權後,我們會自動完成連線。", - "copyCode": "複製代碼", - "copied": "已複製", - "openLoginPage": "開啟登入頁面" + "saveAndUse": "儲存並使用" } } diff --git a/src/native/browserShim.ts b/src/native/browserShim.ts index c86d512fe..c990df81c 100644 --- a/src/native/browserShim.ts +++ b/src/native/browserShim.ts @@ -424,14 +424,6 @@ function createShimBridgeClient() { saveLlmState(); return Promise.resolve({ success: true, snapshot: buildLlmSnapshot() }); }, - // ponytail: OAuth/PAT device flows need a real network round-trip to - // the provider — nothing meaningful to fake here. Reject with a clear - // message instead of silently returning undefined (which crashed - // callers expecting a challenge object). - llmBeginDeviceAuth: () => - Promise.reject(new Error("Device login isn't available in the browser preview.")), - llmCompleteDeviceAuth: () => - Promise.reject(new Error("Device login isn't available in the browser preview.")), llmListProviderModels: (providerId: string) => Promise.resolve({ models: [`${providerId}-demo-model-1`, `${providerId}-demo-model-2`], diff --git a/src/native/client.ts b/src/native/client.ts index a40a5100e..3f96495f6 100644 --- a/src/native/client.ts +++ b/src/native/client.ts @@ -8,8 +8,6 @@ import { type AiEditionChatRewindResult, type AiEditionChatSession, type AiEditionChatSessionSummary, - type AiEditionDeviceChallenge, - type AiEditionDeviceCompletionResult, type AiEditionDocumentResult, type AiEditionLlmConfig, type AiEditionLlmDisconnectResult, @@ -220,22 +218,6 @@ export const nativeBridgeClient = { action: "llm.removeApiKey", payload: { providerId }, }), - llmBeginDeviceAuth: (providerId: "openai-oauth" | "copilot-proxy", model?: string) => - requireNativeBridgeData({ - domain: "aiEdition", - action: "llm.beginDeviceAuth", - payload: { providerId, model }, - }), - llmCompleteDeviceAuth: ( - providerId: "openai-oauth" | "copilot-proxy", - challenge: AiEditionDeviceChallenge, - model?: string, - ) => - requireNativeBridgeData({ - domain: "aiEdition", - action: "llm.completeDeviceAuth", - payload: { providerId, challenge, model }, - }), llmDisconnect: (providerId: string) => requireNativeBridgeData({ domain: "aiEdition", diff --git a/src/native/contracts.ts b/src/native/contracts.ts index b2b66f8eb..5580984e5 100644 --- a/src/native/contracts.ts +++ b/src/native/contracts.ts @@ -206,22 +206,6 @@ export interface AiEditionLlmSnapshot { }>; } -export interface AiEditionDeviceChallenge { - verificationUri: string; - verificationUriComplete?: string; - userCode: string; - deviceCode?: string; - deviceAuthId?: string; - intervalMs: number; - expiresAt: number; -} - -export interface AiEditionDeviceCompletionResult { - success: boolean; - snapshot?: AiEditionLlmSnapshot; - error?: string; -} - export interface AiEditionLlmDisconnectResult { success: boolean; snapshot: AiEditionLlmSnapshot; @@ -529,26 +513,6 @@ export type NativeBridgeRequest = payload: { providerId: string }; requestId?: string; } - | { - domain: "aiEdition"; - action: "llm.beginDeviceAuth"; - payload: { - providerId: "openai-oauth" | "copilot-proxy"; - /** Optional model — recorded into the config when the device flow completes. */ - model?: string; - }; - requestId?: string; - } - | { - domain: "aiEdition"; - action: "llm.completeDeviceAuth"; - payload: { - providerId: "openai-oauth" | "copilot-proxy"; - challenge: AiEditionDeviceChallenge; - model?: string; - }; - requestId?: string; - } | { domain: "aiEdition"; action: "llm.disconnect"; From f1d93bf6b8810259dc6dbcb84515765570c9dda7 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Mon, 27 Jul 2026 12:57:25 +0200 Subject: [PATCH 2/4] fix(ipc): one document service, a protocol allowlist, and a loopback-only STT server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four small hardening fixes found while reviewing release/1.8.0. DocumentService serialises saves of a project through a per-INSTANCE queue — its own writeProject comment records that this race destroyed two real project files. handlers.ts built a fresh instance for every runTimelineOperation, so a timeline op and a document.save ran on two queues against one path. temp+rename still kept the file valid JSON, so the symptom was a silently lost save rather than corruption. Hoisted to one instance, alongside LlmConfigStore, whose constructor was doing two sync readFileSync plus a safeStorage decrypt on every chat message. open-external-url passed the renderer's string straight to shell.openExternal, which hands it to the OS handler — `file:`, `ms-msdt:` and UNC paths are launch primitives there. Allowlisted to http/https/mailto. The native bridge marked every INTERNAL_ERROR retryable, including permanent ones, and forwarded error.message verbatim to the renderer, where main-process errors quote absolute paths into UI strings. Now non-retryable by default, path-redacted for the renderer, and logged in full on the main side. whisper-stt-server let OPENSCREEN_WHISPER_HOST override an explicit --host, so an env var could widen a shipped, unauthenticated server's bind address. It is now a fallback only, and refuses anything but loopback. Also gives the save-race test an explicit 30s timeout: 12 rounds of real fsync+rename take ~1.7s idle but exceed vitest's 5s default when the suite runs files in parallel — it failed in the full run and passed alone. --- electron/ai-edition/document-service.test.ts | 7 +++- electron/ipc/handlers.ts | 37 +++++++++++++++----- electron/ipc/nativeBridge.ts | 21 +++++++++-- electron/native/whisper-stt/src/main.cpp | 17 +++++++-- 4 files changed, 69 insertions(+), 13 deletions(-) diff --git a/electron/ai-edition/document-service.test.ts b/electron/ai-edition/document-service.test.ts index 15adbd515..1a2915751 100644 --- a/electron/ai-edition/document-service.test.ts +++ b/electron/ai-edition/document-service.test.ts @@ -247,6 +247,11 @@ describe("DocumentService", () => { // one long/short race spliced the file ~5 times out of 10. A single attempt // passed against the very bug it was written for. 12 rounds miss it once in // ~4000 runs; the loop is the test, not decoration. + // + // Explicit timeout: 12 rounds of real create + fsync + rename take ~1.7s on + // an idle machine but blow vitest's 5s default when the full suite runs its + // files in parallel — observed failing there and passing alone. 30s is the + // "this actually hung" threshold, not the expected runtime. it("leaves valid JSON when a long and a short save race", async () => { for (let round = 0; round < 12; round++) { const doc = await service.createProject(`Race ${round}`); @@ -267,7 +272,7 @@ describe("DocumentService", () => { expect([1, 400]).toContain(parsed.annotations.length); expect(raw.trimEnd().endsWith("}")).toBe(true); } - }); + }, 30_000); it("applies racing saves in call order, last one winning", async () => { const doc = await service.createProject("Order"); diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 3fdbc681e..54c195a6f 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -2663,9 +2663,21 @@ export function registerIpcHandlers( return readCursorTelemetryFile(targetVideoPath); }); + // Protocol allowlist. `shell.openExternal` hands the string to the OS handler, + // so `file:`, `ms-msdt:`, a UNC path, or any registered custom scheme is a + // launch primitive — the renderer runs with webSecurity:false and now renders + // model-generated content, so "the renderer is trusted" is not a strong enough + // premise to skip this. http/https/mailto is everything the app actually opens. + const EXTERNAL_URL_PROTOCOLS = new Set(["http:", "https:", "mailto:"]); + ipcMain.handle("open-external-url", async (_, url: string) => { try { - await shell.openExternal(url); + const parsed = new URL(url); + if (!EXTERNAL_URL_PROTOCOLS.has(parsed.protocol)) { + console.warn(`Refused to open external URL with protocol ${parsed.protocol}`); + return { success: false, error: `Unsupported URL protocol: ${parsed.protocol}` }; + } + await shell.openExternal(parsed.toString()); return { success: true }; } catch (error) { console.error("Failed to open URL:", error); @@ -3343,6 +3355,16 @@ export function registerIpcHandlers( }, ); + // One instance each, not one per call. DocumentService serialises saves of a + // project through a per-INSTANCE queue (see its writeProject comment — this + // race destroyed two real project files), so a second instance means a second + // queue racing for the same path: temp+rename still keeps the file valid, but + // a save can land under a concurrent one and be silently lost. LlmConfigStore + // is hoisted for a duller reason: its constructor does two sync readFileSync + // plus a safeStorage decrypt, and it was running on every chat message. + const aiEditionDocuments = new DocumentService(path.join(app.getPath("userData"), "projects")); + const aiEditionLlmConfig = new LlmConfigStore(app.getPath("userData")); + registerNativeBridgeHandlers({ getPlatform: () => process.platform, getCurrentProjectPath: () => currentProjectPath, @@ -3376,15 +3398,14 @@ export function registerIpcHandlers( return null; } }, - getAiEditionDocuments: () => - new DocumentService(path.join(app.getPath("userData"), "projects")), - getAiEditionLlmConfig: () => new LlmConfigStore(app.getPath("userData")), + getAiEditionDocuments: () => aiEditionDocuments, + getAiEditionLlmConfig: () => aiEditionLlmConfig, runAiEditionChat: (projectId, sessionId, message, document, sink) => runChat( projectId, sessionId, message, - new LlmConfigStore(app.getPath("userData")), + aiEditionLlmConfig, document, sink, ), @@ -3395,18 +3416,18 @@ export function registerIpcHandlers( rewindToMessage: (projectId, sessionId, messageId) => rewindToMessage(projectId, sessionId, messageId), compactNow: (projectId, sessionId) => - compactSessionNow(projectId, sessionId, new LlmConfigStore(app.getPath("userData"))), + compactSessionNow(projectId, sessionId, aiEditionLlmConfig), runTimelineOperation: (projectId, sessionId, op, conversationMessage) => runTimelineOperation( projectId, sessionId, op, conversationMessage, - new DocumentService(path.join(app.getPath("userData"), "projects")), + aiEditionDocuments, ), getContextUsage: getSessionContextUsage, runAiEditionChatDefault: (projectId, message, sink) => - runChatDefault(projectId, message, new LlmConfigStore(app.getPath("userData")), sink), + runChatDefault(projectId, message, aiEditionLlmConfig, sink), getAiEditionChatHistoryDefault: (projectId) => getDefaultChatHistory(projectId), clearAiEditionChatHistoryDefault: (projectId) => clearDefaultChatHistory(projectId), listAiEditionChatSessions: (projectId) => listSessions(projectId), diff --git a/electron/ipc/nativeBridge.ts b/electron/ipc/nativeBridge.ts index d5911e1c9..c6e7a228f 100644 --- a/electron/ipc/nativeBridge.ts +++ b/electron/ipc/nativeBridge.ts @@ -164,6 +164,19 @@ function createErrorResponse( } satisfies NativeBridgeResponse; } +/** + * Strips absolute filesystem paths out of an error message before it crosses to + * the renderer. Main-process errors quote the path they failed on (`ENOENT: no + * such file or directory, open 'C:\Users\alice\…'`), and that string is rendered + * verbatim in toasts and the chat error rail. Keeps the basename, which is the + * part a user can act on. The full message still goes to the main-process log. + */ +function redactPaths(message: string): string { + return message + .replace(/[A-Za-z]:\\(?:[^\\/:*?"<>|\r\n]+\\)*([^\\/:*?"<>|\r\n]*)/g, "…\\$1") + .replace(/\/(?:[^/\0\s'"]+\/)+([^/\0\s'"]*)/g, "…/$1"); +} + function isBridgeRequest(value: unknown): value is NativeBridgeRequest { if (!value || typeof value !== "object") { return false; @@ -658,11 +671,15 @@ export function registerNativeBridgeHandlers(context: NativeBridgeContext) { ); } } catch (error) { + // Not retryable by default: most failures here are permanent (a missing + // file, a bad payload, an unavailable addon), and a blanket `true` tells + // the client to spin on them. The message keeps the reason but drops any + // absolute path — it crosses to the renderer and ends up in UI strings. + console.error(`native bridge ${domain}.${request.action} failed:`, error); return createErrorResponse( requestId, "INTERNAL_ERROR", - error instanceof Error ? error.message : "Unknown native bridge error.", - true, + redactPaths(error instanceof Error ? error.message : "Unknown native bridge error."), ); } }); diff --git a/electron/native/whisper-stt/src/main.cpp b/electron/native/whisper-stt/src/main.cpp index e4a70c95d..1cc067697 100644 --- a/electron/native/whisper-stt/src/main.cpp +++ b/electron/native/whisper-stt/src/main.cpp @@ -207,13 +207,14 @@ struct Word { int main(int argc, char** argv) { std::string model_path; std::string host = "127.0.0.1"; + bool host_from_flag = false; int port = 0; int threads = std::max(1u, std::thread::hardware_concurrency()); for (int i = 1; i < argc; ++i) { const std::string a = argv[i]; if (a == "--model" && i + 1 < argc) model_path = argv[++i]; - else if (a == "--host" && i + 1 < argc) host = argv[++i]; + else if (a == "--host" && i + 1 < argc) { host = argv[++i]; host_from_flag = true; } else if (a == "--port" && i + 1 < argc) port = std::atoi(argv[++i]); else if (a == "--threads" && i + 1 < argc) threads = std::atoi(argv[++i]); } @@ -226,7 +227,19 @@ int main(int argc, char** argv) { if (const char* p = std::getenv("OPENSCREEN_WHISPER_PORT")) port = std::atoi(p); } if (const char* p = std::getenv("OPENSCREEN_WHISPER_THREADS")) threads = std::atoi(p); - if (const char* p = std::getenv("OPENSCREEN_WHISPER_HOST")) host = p; + // Only a fallback, never an override: the app always passes --host 127.0.0.1, + // and an env var that can widen a shipped binary's bind address behind an + // explicit flag is a hole, not a knob. Anything but loopback is refused + // outright — this server has no authentication of any kind. + if (!host_from_flag) { + if (const char* p = std::getenv("OPENSCREEN_WHISPER_HOST")) host = p; + } + if (host != "127.0.0.1" && host != "::1" && host != "localhost") { + std::cerr << "FATAL: refusing to bind " << host + << " — whisper-stt-server is unauthenticated and loopback-only" + << std::endl; + return 2; + } if (model_path.empty()) { std::cerr << "FATAL: --model or " From 014869245bd4276fd8c2489773f3428a1ddf8f0d Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Mon, 27 Jul 2026 13:13:03 +0200 Subject: [PATCH 3/4] ci(release): run CI on release branches, and stage the STT binaries into installers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two ways this release could ship broken, and one obligation it was missing. CI never ran on this branch. ci.yml triggered on [main, feat/ai-edition] only, so PRs #167, #168 and #169 — 30k+ lines of deletion and refactor — merged into release/1.8.0 with lint, typecheck, tests, build and the PR-title check all skipped, and CodeRabbit reporting "reviews are disabled for this base branch". Added release/** to both triggers. The installers had no speech-to-text. electron/native/bin/ is gitignored and build-whisper-stt.yml publishes the binaries as artifacts of its own run; nothing carried them into build.yml. The model is downloaded at runtime, the binary is not, so resolveBinaryPath() would find nothing and transcription and captions would fail with a "build it via scripts/build-whisper-stt.sh" message shown to end users. scripts/stage-whisper-stt.sh now fetches them in all four build legs and fails the build if they are missing — a red build beats a silent one. Artifact retention 30 -> 90 days, since a release build now depends on it. Added THIRD-PARTY-NOTICES.md and shipped it (with LICENSE) into resources/. We redistribute FFmpeg's LGPL shared libraries and whisper.cpp; fetch-ffmpeg.mjs went to real lengths to keep the build LGPL rather than GPL, but nothing carried the attribution or the source offer into the installer. Also stops shipping the static ffmpeg.exe on Windows ("!win32-*/ffmpeg.exe"): the compositor addon dlopens the av*.dll set, but no runtime code spawns the exe — it is there for bench-export.mjs. It was adding a large binary and an LGPL obligation to every installer for nothing. Its licence-gate comment also still pointed at electron/media/ffmpegCapabilities.ts, deleted with the web export path. tsconfig.test.json makes the untypechecked half of the repo visible: tsconfig.json includes only src + electron and excludes **/*.test.ts, so no test file has ever been typechecked. It runs as an advisory CI job — 82 pre-existing errors, so gating on it today would just be red. --- .github/workflows/build-whisper-stt.yml | 5 +- .github/workflows/build.yml | 36 ++- .github/workflows/ci.yml | 182 ++++++----- THIRD-PARTY-NOTICES.md | 61 ++++ electron-builder.json5 | 298 ++++++++++-------- electron/ai-edition/llm-provider-auth.test.ts | 19 +- electron/ipc/handlers.ts | 17 +- scripts/fetch-ffmpeg.mjs | 15 +- scripts/stage-whisper-stt.sh | 73 +++++ .../ai-edition/ProviderSettings.tsx | 14 +- src/native/useNativePlaybackSync.ts | 1 - tsconfig.test.json | 23 ++ website/src/pages/index.tsx | 2 - 13 files changed, 467 insertions(+), 279 deletions(-) create mode 100644 THIRD-PARTY-NOTICES.md create mode 100755 scripts/stage-whisper-stt.sh create mode 100644 tsconfig.test.json diff --git a/.github/workflows/build-whisper-stt.yml b/.github/workflows/build-whisper-stt.yml index 46b528460..abc07fb5b 100644 --- a/.github/workflows/build-whisper-stt.yml +++ b/.github/workflows/build-whisper-stt.yml @@ -182,7 +182,10 @@ jobs: name: whisper-stt-${{ matrix.tag }} path: whisper-stt-${{ matrix.tag }}.tar.gz if-no-files-found: error - retention-days: 30 + # build.yml now stages these into the installers + # (scripts/stage-whisper-stt.sh), so an expired artifact fails a + # release build. 90 days instead of 30 to make that rarer. + retention-days: 90 - name: Workflow summary if: always() diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8ba60c0cf..744c1f7c5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -38,9 +38,16 @@ jobs: - name: Setup Node.js uses: ./.github/actions/setup - # ponytail: STT is handled by the bundled whisper-stt-server (whisper.cpp - # with native DTW token timestamps). No separate VAD model is fetched - # here. See technical-documentation/architecture/transcription-and-captions.md for the full architecture. + # STT is the bundled whisper-stt-server (whisper.cpp with native DTW token + # timestamps); no VAD model is fetched here. The binary is built by + # build-whisper-stt.yml and staged below — without that step the installer + # ships without speech-to-text. See + # technical-documentation/architecture/transcription-and-captions.md. + - name: Stage whisper-stt binaries + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: bash scripts/stage-whisper-stt.sh win32-x64 - name: Build Windows app run: npm run build:win -- --publish never @@ -63,9 +70,11 @@ jobs: - name: Setup Node.js uses: ./.github/actions/setup - # ponytail: STT is handled by the bundled whisper-stt-server (whisper.cpp - # with native DTW token timestamps). No separate VAD model is fetched - # here. See technical-documentation/architecture/transcription-and-captions.md for the full architecture. + - name: Stage whisper-stt binaries + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: bash scripts/stage-whisper-stt.sh win32-x64 - name: Build Windows Store package run: npm run build:win:store -- --publish never @@ -102,8 +111,12 @@ jobs: env: npm_config_build_from_source: "false" - # ponytail: STT is handled by the bundled whisper-stt-server (see the - # build-windows comment); no VAD model is fetched here. + - name: Stage whisper-stt binaries + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: bash scripts/stage-whisper-stt.sh darwin-${{ matrix.arch }} + - name: Resolve macOS signing id: signing env: @@ -261,7 +274,12 @@ jobs: - name: Install pacman build dependencies run: sudo apt-get update && sudo apt-get install -y libarchive-tools - # ponytail: no VAD fetch here either (see build-windows comment). + - name: Stage whisper-stt binaries + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: bash scripts/stage-whisper-stt.sh linux-x64 + - name: Build Linux app run: npm run build:linux -- --publish never diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 95d9d0c22..f72f826f5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,81 +1,101 @@ -name: CI - -# feat/ai-edition is a long-lived integration branch that PRs land on for -# months at a time. Without it listed here, every one of those PRs merged with -# no lint, no typecheck, no tests and no PR-title check. -on: - pull_request: - branches: [main, feat/ai-edition] - push: - branches: [main, feat/ai-edition] - -jobs: - lint: - name: Lint - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: ./.github/actions/setup - - run: npm run lint - - typecheck: - name: Type Check - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: ./.github/actions/setup - - run: npx tsc --noEmit - - docs: - name: Docs - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - # No ./.github/actions/setup: check-docs.mjs imports only node builtins, - # so npm ci would be a minute of install for nothing. Node 22 is here for - # import.meta.dirname (needs >= 20.11). - - uses: actions/setup-node@v4 - with: - node-version: 22 - - run: npm run docs:check - - test: - name: Test - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: ./.github/actions/setup - - run: npm run test - - run: npm run test:browser:install - - run: npm run test:browser - - build: - name: Build - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: ./.github/actions/setup - - run: npx vite build - - semantic-pr: - name: Validate PR title (semantic) - runs-on: ubuntu-latest - if: github.event_name == 'pull_request' - steps: - - uses: amannn/action-semantic-pull-request@v5 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - types: | - feat - fix - chore - refactor - perf - docs - test - build - ci - style - revert - requireScope: false +name: CI + +# feat/ai-edition is a long-lived integration branch that PRs land on for +# months at a time. Without it listed here, every one of those PRs merged with +# no lint, no typecheck, no tests and no PR-title check. +# +# release/** is here for the same reason and it cost more: PRs #167, #168 and +# #169 — 30k+ lines of deletion and refactor — merged into release/1.8.0 with +# every one of those jobs skipped, because a release branch matched neither +# pattern. A release branch is the LAST place to run a build unguarded. +on: + pull_request: + branches: [main, feat/ai-edition, "release/**"] + push: + branches: [main, feat/ai-edition, "release/**"] + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/setup + - run: npm run lint + + typecheck: + name: Type Check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/setup + - run: npx tsc --noEmit + + typecheck-tests: + name: Typecheck (tests, advisory) + runs-on: ubuntu-latest + # ADVISORY, NOT A GATE — 82 pre-existing errors as of this commit. The main + # Typecheck job cannot see test files at all (tsconfig.json excludes + # **/*.test.ts and includes only src + electron), and vitest transpiles + # without checking, so fixture types have drifted from the schemas they + # claim to build for years. This job makes the drift visible and stops it + # growing silently; flip `continue-on-error` off once the count is zero. + continue-on-error: true + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/setup + - run: npx tsc -p tsconfig.test.json --noEmit + + docs: + name: Docs + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + # No ./.github/actions/setup: check-docs.mjs imports only node builtins, + # so npm ci would be a minute of install for nothing. Node 22 is here for + # import.meta.dirname (needs >= 20.11). + - uses: actions/setup-node@v4 + with: + node-version: 22 + - run: npm run docs:check + + test: + name: Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/setup + - run: npm run test + - run: npm run test:browser:install + - run: npm run test:browser + + build: + name: Build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/setup + - run: npx vite build + + semantic-pr: + name: Validate PR title (semantic) + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + steps: + - uses: amannn/action-semantic-pull-request@v5 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + types: | + feat + fix + chore + refactor + perf + docs + test + build + ci + style + revert + requireScope: false diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md new file mode 100644 index 000000000..a1c2de27f --- /dev/null +++ b/THIRD-PARTY-NOTICES.md @@ -0,0 +1,61 @@ +# Third-party notices + +OpenScreen is MIT licensed (see [LICENSE](LICENSE)). The installers additionally +bundle the pre-built native components below. This file ships inside the +application resources and satisfies the attribution and source-offer obligations +that come with them. + +npm dependencies are not listed here: they are resolved from `package.json` and +distributed by their own registries, not redistributed inside our binaries. + +--- + +## FFmpeg — shared libraries (Windows only) + +- **Components**: `avcodec-*.dll`, `avformat-*.dll`, `avutil-*.dll`, + `swresample-*.dll`, `swscale-*.dll` and their siblings, under + `resources/electron/native/bin/win32-*/`. +- **Used by**: the native D3D11 compositor addon, which links against them at + load time. +- **License**: **GNU Lesser General Public License v2.1 or later** + (). FFmpeg's own + licensing page: . +- **This is an LGPL build, not a GPL one.** It is configured without + `--enable-gpl` and without `--enable-nonfree`, and links no GPL-only library + (x264, x265, xvid, vidstab, rubberband, frei0r, …). `scripts/fetch-ffmpeg.mjs` + verifies this before vendoring — it reads `ffmpeg -L`, `-buildconf` and + `-encoders` and refuses any binary that reports otherwise. +- **Upstream binaries**: BtbN/FFmpeg-Builds, release + `autobuild-2026-07-15-14-01`, the `*-lgpl-shared-8.1` assets. Pinned by + SHA-256 in `scripts/fetch-ffmpeg.mjs`; the digests there identify the exact + artifacts we ship. + +- **Corresponding source**: FFmpeg n8.1.2, commit `94138f6973`, from + . The build configuration and scripts that + produced these exact binaries are published at + . +- **Relinking**: as required by the LGPL, these are dynamic libraries. You may + replace them with your own build of the same FFmpeg version by overwriting the + DLLs in `resources/electron/native/bin/win32-*/`. + +## whisper.cpp and ggml + +- **Components**: `whisper-stt-server` and its ggml backend sidecars, under + `resources/electron/native/bin/-/`. +- **License**: MIT — and + . +- Built from source by `scripts/build-whisper-stt.sh`; the pinned upstream + revision is in `electron/native/whisper-stt/CMakeLists.txt`. +- The speech model (`ggml-*.bin`) is **not** bundled — it is downloaded into the + user's data directory on first use by `electron/stt/modelManager.ts`. + +## OpenScreen native helpers + +`wgc-capture` (Windows Graphics Capture), the ScreenCaptureKit helper (macOS) +and the D3D11 compositor addon are part of this repository and are covered by +[LICENSE](LICENSE). + +--- + +To report an omission or request source for anything bundled here, open an issue +at . diff --git a/electron-builder.json5 b/electron-builder.json5 index cbce12e3e..1a2f8d9d4 100644 --- a/electron-builder.json5 +++ b/electron-builder.json5 @@ -1,140 +1,158 @@ -// @see - https://www.electron.build/configuration/configuration -{ - "$schema": "https://raw.githubusercontent.com/electron-userland/electron-builder/master/packages/app-builder-lib/scheme.json", - "appId": "com.etiennelescot.openscreen", - "asar": true, - // .node binaries can't be dlopen'd from inside an asar — must live unpacked. - // onnxruntime-node distributes as a `.node` shared object that whisper.cpp - // helpers run from process spawn, so they all unpack via the same glob. - "asarUnpack": [ - "**/*.node" - ], - "productName": "Openscreen", - "npmRebuild": true, - // sharp ships ABI-stable (napi) prebuilt binaries with bundled libvips. Building it from source - // needs a system libvips we don't provide and breaks on CI/local ("vips-cpp.42 not found"), so we - // let electron-builder use the prebuilt instead of recompiling. - "buildDependenciesFromSource": false, - "compression": "normal", - "directories": { - "output": "release/${version}" - }, - "files": [ - "dist", - "dist-electron", - "!*.png", - "!preview*.png", - "!*.md", - "!README.md", - "!CONTRIBUTING.md", - "!LICENSE" - ], - // Asset layout contract: "wallpapers/" and "cursors/" under resourcesPath must - // align with assetBaseDir in electron/preload.ts (packaged branch). - // Native capture helpers + whisper-stt-server binaries (and their ggml - // backend sidecars) live under `electron/native/bin/-/` - // and the per-platform sections below limit them to the matching arch in - // each installer. - // - // ponytail: the STT model (ggml-small-q8_0.bin) is downloaded into the - // userData cache on first use by electron/stt/modelManager.ts, so no model - // files are bundled at build time. - "extraResources": [ - { - "from": "public/wallpapers", - "to": "wallpapers" - }, - { - "from": "public/cursors", - "to": "cursors" - } - ], - - "mac": { - "notarize": false, - "hardenedRuntime": true, - "entitlements": "macos.entitlements", - "entitlementsInherit": "macos.entitlements", - "target": [ - { - "target": "dmg", - "arch": ["x64", "arm64"] - } - ], - "icon": "icons/icons/mac/icon.icns", - "artifactName": "${productName}-Mac-${arch}-${version}-Installer.${ext}", - "extraResources": [ - { - "from": "electron/native/bin", - "to": "electron/native/bin", - "filter": ["darwin-*/*"] - } - ], - "extendInfo": { - "NSAudioCaptureUsageDescription": "OpenScreen needs audio capture permission to record system audio.", - "NSMicrophoneUsageDescription": "OpenScreen needs microphone access to record voice audio.", - "NSCameraUsageDescription": "OpenScreen needs camera access to record webcam video.", - "NSScreenCaptureUsageDescription": "OpenScreen needs screen recording permission to detect and capture windows.", - "NSCameraUseContinuityCameraDeviceType": true - } - }, - "linux": { - "target": [ - "AppImage", - "deb", - "pacman" - ], - "icon": "icons/icons/png", - "artifactName": "${productName}-Linux-${version}.${ext}", - "maintainer": "Etienne Lescot ", - "category": "AudioVideo", - "extraResources": [ - { - "from": "electron/native/bin", - "to": "electron/native/bin", - "filter": ["linux-*/*"] - } - ] - }, - "win": { - "target": [ - "nsis" - ], - "icon": "icons/icons/win/icon.ico", - "artifactName": "${productName}.Setup.${version}.${ext}", - "extraResources": [ - { - "from": "electron/native/bin", - "to": "electron/native/bin", - "filter": ["win32-*/*"] - } - ], - // Native D3D11 compositor addon (Windows-only). Built by - // `npm run build:native:compositor` (scripts/build-windows-compositor-addon.mjs) - // before packaging. Packed into asar, then auto-unpacked by the top-level - // `asarUnpack: ["**/*.node"]` rule to app.asar.unpacked/... — matches the - // `.asar` -> `.asar.unpacked` rewrite compositorViewService.ts's - // buildCandidatePaths() already applies for packaged builds. Appends to - // (doesn't replace) the top-level `files` list. - "files": [ - "electron/native/compositor-view/build" - ] - }, - "nsis": { - "oneClick": false, - "allowToChangeInstallationDirectory": true - }, - // Microsoft Store packaging identity, from Partner Center's "Product identity" page - // (Applications et jeux > OpenScreen > Product identity). - "appx": { - "identityName": "EtienneLescot.OpenScreen", - "publisher": "CN=03B0FC8C-2067-45D9-BE82-9F15CE264B4A", - "publisherDisplayName": "Etienne Lescot", - "applicationId": "Openscreen", - "displayName": "OpenScreen", - "backgroundColor": "transparent", - "capabilities": ["runFullTrust", "microphone", "webcam"], - "languages": ["en-US", "fr-FR"], - "showNameOnTiles": true - } -} +// @see - https://www.electron.build/configuration/configuration +{ + "$schema": "https://raw.githubusercontent.com/electron-userland/electron-builder/master/packages/app-builder-lib/scheme.json", + "appId": "com.etiennelescot.openscreen", + "asar": true, + // .node binaries can't be dlopen'd from inside an asar — must live unpacked. + // onnxruntime-node distributes as a `.node` shared object that whisper.cpp + // helpers run from process spawn, so they all unpack via the same glob. + "asarUnpack": [ + "**/*.node" + ], + "productName": "Openscreen", + "npmRebuild": true, + // sharp ships ABI-stable (napi) prebuilt binaries with bundled libvips. Building it from source + // needs a system libvips we don't provide and breaks on CI/local ("vips-cpp.42 not found"), so we + // let electron-builder use the prebuilt instead of recompiling. + "buildDependenciesFromSource": false, + "compression": "normal", + "directories": { + "output": "release/${version}" + }, + "files": [ + "dist", + "dist-electron", + "!*.png", + "!preview*.png", + "!*.md", + "!README.md", + "!CONTRIBUTING.md", + "!LICENSE" + ], + // Asset layout contract: "wallpapers/" and "cursors/" under resourcesPath must + // align with assetBaseDir in electron/preload.ts (packaged branch). + // Native capture helpers + whisper-stt-server binaries (and their ggml + // backend sidecars) live under `electron/native/bin/-/` + // and the per-platform sections below limit them to the matching arch in + // each installer. + // + // ponytail: the STT model (ggml-small-q8_0.bin) is downloaded into the + // userData cache on first use by electron/stt/modelManager.ts, so no model + // files are bundled at build time. + "extraResources": [ + // Attribution + LGPL source offer for the bundled ffmpeg DLLs and + // whisper.cpp binaries. Redistributing LGPL libraries obliges us to ship + // the notice with them, so it goes in resources/, not just in the repo. + { + "from": "THIRD-PARTY-NOTICES.md", + "to": "THIRD-PARTY-NOTICES.md" + }, + { + "from": "LICENSE", + "to": "LICENSE" + }, + { + "from": "public/wallpapers", + "to": "wallpapers" + }, + { + "from": "public/cursors", + "to": "cursors" + } + ], + + "mac": { + "notarize": false, + "hardenedRuntime": true, + "entitlements": "macos.entitlements", + "entitlementsInherit": "macos.entitlements", + "target": [ + { + "target": "dmg", + "arch": ["x64", "arm64"] + } + ], + "icon": "icons/icons/mac/icon.icns", + "artifactName": "${productName}-Mac-${arch}-${version}-Installer.${ext}", + "extraResources": [ + { + "from": "electron/native/bin", + "to": "electron/native/bin", + "filter": ["darwin-*/*"] + } + ], + "extendInfo": { + "NSAudioCaptureUsageDescription": "OpenScreen needs audio capture permission to record system audio.", + "NSMicrophoneUsageDescription": "OpenScreen needs microphone access to record voice audio.", + "NSCameraUsageDescription": "OpenScreen needs camera access to record webcam video.", + "NSScreenCaptureUsageDescription": "OpenScreen needs screen recording permission to detect and capture windows.", + "NSCameraUseContinuityCameraDeviceType": true + } + }, + "linux": { + "target": [ + "AppImage", + "deb", + "pacman" + ], + "icon": "icons/icons/png", + "artifactName": "${productName}-Linux-${version}.${ext}", + "maintainer": "Etienne Lescot ", + "category": "AudioVideo", + "extraResources": [ + { + "from": "electron/native/bin", + "to": "electron/native/bin", + "filter": ["linux-*/*"] + } + ] + }, + "win": { + "target": [ + "nsis" + ], + "icon": "icons/icons/win/icon.ico", + "artifactName": "${productName}.Setup.${version}.${ext}", + "extraResources": [ + { + "from": "electron/native/bin", + "to": "electron/native/bin", + // The static ffmpeg.exe is excluded on purpose. scripts/fetch-ffmpeg.mjs + // vendors two artifacts into this directory: the shared av*.dll set, + // which the D3D11 compositor addon dlopens at require() time and + // therefore MUST ship, and a standalone static ffmpeg.exe that nothing + // in the app spawns — it exists for scripts/bench-export.mjs. Shipping + // it added a large binary to every Windows installer, plus an LGPL + // redistribution obligation, for a file no shipped code opens. + "filter": ["win32-*/*", "!win32-*/ffmpeg.exe"] + } + ], + // Native D3D11 compositor addon (Windows-only). Built by + // `npm run build:native:compositor` (scripts/build-windows-compositor-addon.mjs) + // before packaging. Packed into asar, then auto-unpacked by the top-level + // `asarUnpack: ["**/*.node"]` rule to app.asar.unpacked/... — matches the + // `.asar` -> `.asar.unpacked` rewrite compositorViewService.ts's + // buildCandidatePaths() already applies for packaged builds. Appends to + // (doesn't replace) the top-level `files` list. + "files": [ + "electron/native/compositor-view/build" + ] + }, + "nsis": { + "oneClick": false, + "allowToChangeInstallationDirectory": true + }, + // Microsoft Store packaging identity, from Partner Center's "Product identity" page + // (Applications et jeux > OpenScreen > Product identity). + "appx": { + "identityName": "EtienneLescot.OpenScreen", + "publisher": "CN=03B0FC8C-2067-45D9-BE82-9F15CE264B4A", + "publisherDisplayName": "Etienne Lescot", + "applicationId": "Openscreen", + "displayName": "OpenScreen", + "backgroundColor": "transparent", + "capabilities": ["runFullTrust", "microphone", "webcam"], + "languages": ["en-US", "fr-FR"], + "showNameOnTiles": true + } +} diff --git a/electron/ai-edition/llm-provider-auth.test.ts b/electron/ai-edition/llm-provider-auth.test.ts index afb7c4162..b6621bb23 100644 --- a/electron/ai-edition/llm-provider-auth.test.ts +++ b/electron/ai-edition/llm-provider-auth.test.ts @@ -1,26 +1,9 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { probeMiniMaxModels } from "./llm-provider-auth"; const ORIGINAL_FETCH = globalThis.fetch; -function mockFetchSequence( - responses: Array<{ ok: boolean; status?: number; body: unknown; delayMs?: number }>, -) { - const queue = [...responses]; - vi.spyOn(globalThis, "fetch").mockImplementation(async () => { - const next = queue.shift(); - if (!next) throw new Error("mock fetch: no response queued"); - const init = next.delayMs ? await new Promise((r) => setTimeout(r, next.delayMs)) : null; - void init; - const status = next.status ?? (next.ok ? 200 : 500); - return new Response(JSON.stringify(next.body), { - status, - headers: { "Content-Type": "application/json" }, - }); - }); -} - afterEach(() => { vi.restoreAllMocks(); globalThis.fetch = ORIGINAL_FETCH; diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 54c195a6f..d2facad8f 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -3401,14 +3401,7 @@ export function registerIpcHandlers( getAiEditionDocuments: () => aiEditionDocuments, getAiEditionLlmConfig: () => aiEditionLlmConfig, runAiEditionChat: (projectId, sessionId, message, document, sink) => - runChat( - projectId, - sessionId, - message, - aiEditionLlmConfig, - document, - sink, - ), + runChat(projectId, sessionId, message, aiEditionLlmConfig, document, sink), undoAiEditionToolBatch: (_projectId, _sessionId) => ({ success: false, error: "Per-tool-batch undo retired in favor of per-message rewind.", @@ -3418,13 +3411,7 @@ export function registerIpcHandlers( compactNow: (projectId, sessionId) => compactSessionNow(projectId, sessionId, aiEditionLlmConfig), runTimelineOperation: (projectId, sessionId, op, conversationMessage) => - runTimelineOperation( - projectId, - sessionId, - op, - conversationMessage, - aiEditionDocuments, - ), + runTimelineOperation(projectId, sessionId, op, conversationMessage, aiEditionDocuments), getContextUsage: getSessionContextUsage, runAiEditionChatDefault: (projectId, message, sink) => runChatDefault(projectId, message, aiEditionLlmConfig, sink), diff --git a/scripts/fetch-ffmpeg.mjs b/scripts/fetch-ffmpeg.mjs index 0584d03bf..1f438242d 100644 --- a/scripts/fetch-ffmpeg.mjs +++ b/scripts/fetch-ffmpeg.mjs @@ -29,6 +29,8 @@ // tag, asset name and digest are one unit. // Source approved by Etienne (2026-07-16). BtbN is linked from ffmpeg.org's // download page and published on winget as BtbN.FFmpeg.LGPL. +// Attribution + source offer for what we redistribute: THIRD-PARTY-NOTICES.md, +// which electron-builder ships into resources/. // // LICENSING — the other thing this script exists to protect: // ffmpeg is LGPL *by default*. It becomes GPL only when built with @@ -38,6 +40,12 @@ // we got before vendoring. Never swap in a "gpl" asset for the extra encoders: // there is nothing in them we need — the hardware encoders are all LGPL. // +// WHAT ACTUALLY SHIPS: only the shared av*.dll set, which the compositor addon +// dlopens. electron-builder excludes the static ffmpeg.exe from the installer +// ("!win32-*/ffmpeg.exe") — it is a bench tool, and shipping a large binary no +// runtime code opens is cost with no benefit. It still lands in this directory +// so scripts/bench-export.mjs can use a local checkout. +// // NOTE: the plan this was vendored for is REFUTED. Feeding native ffmpeg from // the renderer measured 2.1x SLOWER than the WebCodecs path it was to replace — // the wall is the compositor, not the encoder. See @@ -133,9 +141,10 @@ function run(cmd, args, opts = {}) { } /** - * Refuses to vendor anything that would relicense the app. Mirrors isLgplBuild() - * in electron/media/ffmpegCapabilities.ts on purpose: that one guards at - * runtime, this one at build time. If they ever disagree, one of them drifted. + * Refuses to vendor anything that would relicense the app. This is now the only + * licence gate — the runtime twin it used to mirror + * (electron/media/ffmpegCapabilities.ts) went with the web export pipeline, and + * nothing in the app spawns ffmpeg any more. */ function assertLgpl(exePath) { const problems = []; diff --git a/scripts/stage-whisper-stt.sh b/scripts/stage-whisper-stt.sh new file mode 100755 index 000000000..226024111 --- /dev/null +++ b/scripts/stage-whisper-stt.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +# Stages the whisper-stt-server binary (plus its ggml backend sidecars) into +# electron/native/bin// so electron-builder's extraResources picks it up. +# +# WHY THIS EXISTS. electron/native/bin/ is gitignored and build-whisper-stt.yml +# publishes the binaries as GitHub *artifacts* of its own workflow run. Nothing +# carried them into build.yml, so every installer built before this script +# shipped with no STT binary at all: resolveBinaryPath() found nothing, +# transcription and captions failed with "whisper-stt-server binary not found; +# build it via scripts/build-whisper-stt.sh" — a developer message, shown to +# end users. The model is fetched at runtime by modelManager.ts; the binary is +# not, and never was. +# +# Fails loudly on purpose. A release without STT is worse than a red build, and +# the failure mode this replaces was completely silent. +# +# Usage: bash scripts/stage-whisper-stt.sh +# tag: darwin-arm64 | darwin-x64 | linux-x64 | win32-x64 +# Requires: gh (preinstalled on GitHub runners), GH_TOKEN in the environment. + +set -euo pipefail + +TAG="${1:?usage: stage-whisper-stt.sh }" +ARTIFACT="whisper-stt-${TAG}" +DEST="electron/native/bin/${TAG}" +REPO="${GITHUB_REPOSITORY:-getopenscreen/openscreen}" + +# A locally built binary wins: `npm run build:whisper-binaries` puts one here, +# and a developer testing a change should not have it silently replaced by CI's. +if compgen -G "${DEST}/whisper-stt-server*" > /dev/null; then + echo "whisper-stt-server already present in ${DEST} — leaving it alone." + exit 0 +fi + +TMP="$(mktemp -d)" +trap 'rm -rf "${TMP}"' EXIT + +echo "Fetching ${ARTIFACT} from the latest successful build-whisper-stt run..." +# No run id: gh resolves the most recent run that published this artifact. +# Artifacts expire (retention-days in build-whisper-stt.yml), so a stale branch +# can legitimately find nothing — say so in terms someone can act on. +if ! gh run download --repo "${REPO}" --name "${ARTIFACT}" --dir "${TMP}" 2>"${TMP}/err"; then + cat "${TMP}/err" >&2 + cat >&2 <&2; exit 1; } + +tar -xzf "${ARCHIVE}" -C "${TMP}" +mkdir -p "${DEST}" +cp -v "${TMP}/${ARTIFACT}"/* "${DEST}/" + +# Verify what we actually staged rather than trusting the copy: this is the +# check whose absence is the whole reason for this script. +BIN="$(find "${DEST}" -maxdepth 1 -name 'whisper-stt-server*' -print -quit)" +[ -n "${BIN}" ] || { echo "FATAL: no whisper-stt-server binary in ${DEST}" >&2; exit 1; } +[ "${TAG#win32}" = "${TAG}" ] && chmod +x "${BIN}" + +echo "Staged $(basename "${BIN}") + $(( $(ls -1 "${DEST}" | wc -l) - 1 )) sidecar(s) -> ${DEST}" diff --git a/src/components/ai-edition/ProviderSettings.tsx b/src/components/ai-edition/ProviderSettings.tsx index bf0db0565..77000be35 100644 --- a/src/components/ai-edition/ProviderSettings.tsx +++ b/src/components/ai-edition/ProviderSettings.tsx @@ -295,18 +295,14 @@ function ProviderForm({ }) { const te = useScopedT("editor"); const showBaseUrl = def.id === "openai-compatible" || Boolean(def.baseUrl); - // Every provider now exposes a live model list once connected (matching - // axcut's `screen === 'models' && activeProvider?.connected` gate) — API-key - // providers hit their own /models endpoint (or, for MiniMax, a probe call) - // same as OAuth/PAT providers do. - const supportsDynamicModels = true; - + // Every provider exposes a live model list once connected: each hits its own + // /models endpoint, or a probe call for MiniMax. const [modelOptions, setModelOptions] = useState([]); const [modelsLoading, setModelsLoading] = useState(false); const [modelsError, setModelsError] = useState(null); useEffect(() => { - if (!supportsDynamicModels || !isConnected) { + if (!isConnected) { setModelOptions([]); setModelsError(null); return; @@ -331,7 +327,7 @@ function ProviderForm({ return () => { cancelled = true; }; - }, [def.id, isConnected, supportsDynamicModels, listProviderModels]); + }, [def.id, isConnected, listProviderModels]); const modelSelectable = modelOptions.length > 0; @@ -370,7 +366,7 @@ function ProviderForm({ ? te("providerSettings.modelHintLive") : modelsError ? te("providerSettings.modelHintError", { error: modelsError }) - : supportsDynamicModels && isConnected + : isConnected ? te("providerSettings.modelHintLoading") : undefined } diff --git a/src/native/useNativePlaybackSync.ts b/src/native/useNativePlaybackSync.ts index f7ea7dd29..64e877b63 100644 --- a/src/native/useNativePlaybackSync.ts +++ b/src/native/useNativePlaybackSync.ts @@ -62,7 +62,6 @@ export function useNativePlaybackSync( const lastSyncedWallTimeRef = useRef(0); const lastActiveClipIdRef = useRef(null); - // biome-ignore lint/correctness/useExhaustiveDependencies: ref values useEffect(() => { if (!active || sourceTimeSec === null || !activeClipId) { return; diff --git a/tsconfig.test.json b/tsconfig.test.json new file mode 100644 index 000000000..654a9f6fa --- /dev/null +++ b/tsconfig.test.json @@ -0,0 +1,23 @@ +// Typechecks what tsconfig.json deliberately leaves out. +// +// The main config is `include: ["src", "electron"]` with +// `exclude: ["**/*.test.ts", "**/*.test.tsx"]`, so no test file and nothing +// under tests/ ever reaches `tsc --noEmit` — and vitest transpiles without +// checking. A schema refactor on release/1.8.0 rewrote fixtures by hand and +// left a `preview.playbackRate` that was never in the schema; the typecheck +// gate it leaned on could not see the files it had edited. +// +// Kept separate from tsconfig.json rather than folded in: test files use +// vitest globals and `any`-ish mock shapes that would have to be loosened for +// the whole app if they shared one config. Same strictness, wider include. +{ + "extends": "./tsconfig.json", + "compilerOptions": { + // Vitest's `describe`/`it`/`expect` are ambient in test files only. + "types": ["vitest/globals", "node"], + // Mocks legitimately shadow parameters they never read. + "noUnusedParameters": false + }, + "include": ["src", "electron", "tests", "scripts"], + "exclude": [] +} diff --git a/website/src/pages/index.tsx b/website/src/pages/index.tsx index 5cc38b0b3..1b02713b7 100644 --- a/website/src/pages/index.tsx +++ b/website/src/pages/index.tsx @@ -182,7 +182,6 @@ export default function Home() {
{MINI_WAVEFORM_A.map((h, i) => ( - // biome-ignore lint/suspicious/noArrayIndexKey: static decorative bars ))}
@@ -190,7 +189,6 @@ export default function Home() {
{MINI_WAVEFORM_B.map((h, i) => ( - // biome-ignore lint/suspicious/noArrayIndexKey: static decorative bars ))}
From 95d164b00b1fb06c20b59e327b012c5d1fb5cf69 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Mon, 27 Jul 2026 13:27:39 +0200 Subject: [PATCH 4/4] ci: make the test typecheck a ratchet instead of a permanent red X MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit continue-on-error left the job reporting "fail" on every PR forever. The run concluded success and merges were never blocked, but a check nobody can fix is a check everyone learns to ignore — which is worse than not having one. It now fails only when the count GROWS past a baseline, so the backlog stays visible in the job summary without crying wolf, and a PR that adds a new fixture-vs-schema drift is caught the day it lands. Baseline 80, measured on this branch; lower it when you fix some, delete the logic at zero. --- .github/workflows/ci.yml | 40 +++++++++++++++++++++++++++++++--------- 1 file changed, 31 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f72f826f5..adbb039db 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,19 +32,41 @@ jobs: - run: npx tsc --noEmit typecheck-tests: - name: Typecheck (tests, advisory) + name: Typecheck (tests) runs-on: ubuntu-latest - # ADVISORY, NOT A GATE — 82 pre-existing errors as of this commit. The main - # Typecheck job cannot see test files at all (tsconfig.json excludes - # **/*.test.ts and includes only src + electron), and vitest transpiles - # without checking, so fixture types have drifted from the schemas they - # claim to build for years. This job makes the drift visible and stops it - # growing silently; flip `continue-on-error` off once the count is zero. - continue-on-error: true + # A RATCHET, not a gate. tsconfig.json includes only src + electron and + # excludes **/*.test.ts, so no test file has ever been typechecked, and + # vitest transpiles without checking — fixture types have drifted from the + # schemas they claim to build for years. Failing on the whole backlog would + # put a red X on every PR that nobody can fix, and a check everyone ignores + # is worse than no check. So: fail only if the count GROWS. Lower BASELINE + # whenever you fix some; delete this job's baseline logic at zero. steps: - uses: actions/checkout@v4 - uses: ./.github/actions/setup - - run: npx tsc -p tsconfig.test.json --noEmit + - name: Typecheck tests against a baseline + shell: bash + env: + BASELINE: 80 + run: | + set -uo pipefail + COUNT=$(npx tsc -p tsconfig.test.json --noEmit 2>&1 | grep -c 'error TS' || true) + { + echo "## Test-file typecheck" + echo "" + echo "| | |" + echo "|---|---|" + echo "| Errors now | ${COUNT} |" + echo "| Baseline | ${BASELINE} |" + } >> "$GITHUB_STEP_SUMMARY" + if [ "${COUNT}" -gt "${BASELINE}" ]; then + echo "::error::Test-file type errors went ${BASELINE} -> ${COUNT}. Fix the new ones, or raise BASELINE in ci.yml with a reason." + npx tsc -p tsconfig.test.json --noEmit 2>&1 | grep 'error TS' || true + exit 1 + fi + if [ "${COUNT}" -lt "${BASELINE}" ]; then + echo "::notice::Test-file type errors down to ${COUNT} (baseline ${BASELINE}). Lower BASELINE in ci.yml to lock the win in." + fi docs: name: Docs