From 832af091a478dbff3741ade5f99133bebbf0d893 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Mon, 10 Aug 2026 16:51:20 -0400 Subject: [PATCH 01/62] Add canonical onboarding provider resolution --- renderer/lib/onboarding-provider.ts | 134 ++++++++++++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 renderer/lib/onboarding-provider.ts diff --git a/renderer/lib/onboarding-provider.ts b/renderer/lib/onboarding-provider.ts new file mode 100644 index 00000000..67e1b283 --- /dev/null +++ b/renderer/lib/onboarding-provider.ts @@ -0,0 +1,134 @@ +import type { Provider } from "./types"; + +export type OnboardingProviderChoice = + | "openai-key" + | "openai-signin" + | "anthropic" + | "lmstudio" + | "ollama" + | "tailscale"; + +export type OnboardingProviderDraft = Omit; + +export interface OnboardingProviderFields { + apiKey: string; + baseUrl: string; +} + +export interface OnboardingDiscoveryResult { + models: string[]; + recommendedModel?: string; +} + +const LOCAL_PROVIDER_IDS: Partial> = { + lmstudio: "custom:lmstudio", + ollama: "custom:ollama", +}; + +function providerIntent(provider: Provider): OnboardingProviderDraft { + const { + hasKey: _hasKey, + legacyIds: _legacyIds, + authMethods: _authMethods, + ...intentAndCache + } = provider; + return intentAndCache; +} + +/** + * Build the connection onboarding will test and save. Reserved local identities + * reuse the live provider intent exactly; onboarding may refresh their model + * cache, but must never reset an edited endpoint, protocol, auth, or label. + */ +export function makeOnboardingProvider( + choice: OnboardingProviderChoice, + baseUrl: string, + currentProviders: readonly Provider[] = [], +): OnboardingProviderDraft | null { + if (choice === "openai-signin") return null; + if (choice === "openai-key") { + return { + id: "custom:onboarding-openai", + kind: "openai", + label: "OpenAI", + baseUrl: baseUrl || "https://api.openai.com/v1", + models: ["gpt-4.1", "gpt-4.1-mini"], + defaultModel: "gpt-4.1-mini", + needsKey: true, + deployment: "hosted", + }; + } + if (choice === "anthropic") { + return { + id: "custom:onboarding-anthropic", + kind: "anthropic", + label: "Anthropic", + baseUrl: baseUrl || "https://api.anthropic.com/v1", + models: ["claude-sonnet-4-5", "claude-haiku-4-5"], + defaultModel: "claude-sonnet-4-5", + needsKey: true, + deployment: "hosted", + }; + } + + const localProviderId = LOCAL_PROVIDER_IDS[choice]; + if (localProviderId) { + const existing = currentProviders.find( + (provider) => provider.id === localProviderId && provider.isBuiltin !== true, + ); + if (existing) return providerIntent(existing); + if (choice === "lmstudio") { + return { + id: localProviderId, + kind: "openai", + label: "LM Studio (local)", + baseUrl: "http://127.0.0.1:1234/v1", + models: [], + needsKey: false, + deployment: "local", + }; + } + return { + id: localProviderId, + kind: "openai", + label: "Ollama (local)", + baseUrl: "http://127.0.0.1:11434/v1", + models: [], + needsKey: false, + deployment: "local", + }; + } + + return { + id: "custom:onboarding-tailscale", + kind: "openai", + label: "Tailscale model", + baseUrl, + models: [], + needsKey: false, + deployment: "local", + }; +} + +/** Preserve a visible draft only while its own choice stays selected. */ +export function fieldsAfterProviderChoiceChange( + currentChoice: OnboardingProviderChoice | null, + nextChoice: OnboardingProviderChoice | null, + fields: OnboardingProviderFields, +): OnboardingProviderFields { + return currentChoice === nextChoice ? fields : { apiKey: "", baseUrl: "" }; +} + +/** Keep an existing usable default; otherwise prefer the runtime's transient recommendation. */ +export function discoveredDefaultModel( + provider: OnboardingProviderDraft, + discovery: OnboardingDiscoveryResult, +): string | undefined { + if (provider.defaultModel && discovery.models.includes(provider.defaultModel)) { + return provider.defaultModel; + } + if (discovery.recommendedModel && discovery.models.includes(discovery.recommendedModel)) { + return discovery.recommendedModel; + } + return discovery.models[0]; +} From 2f0aae9c9854b316856fa7b9febc26bd263a546f Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Mon, 10 Aug 2026 16:51:20 -0400 Subject: [PATCH 02/62] Recognize canonical local provider aliases --- main/services/custom-provider-id.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/main/services/custom-provider-id.ts b/main/services/custom-provider-id.ts index 937db9d1..52dc42fa 100644 --- a/main/services/custom-provider-id.ts +++ b/main/services/custom-provider-id.ts @@ -17,13 +17,21 @@ export function customProviderId(providerId: string): string { /** * Aiden template identities, deliberately separate from Pi's provider IDs. - * The legacy spellings remain here for the brief migration window so existing - * local connections retain their specialized discovery behavior. + * Legacy spellings and collision-safe numeric siblings remain recognized so + * migrated local connections retain specialized discovery and loading probes. */ export function isLmStudioProviderId(providerId: string): boolean { - return providerId === "lmstudio" || providerId === `${CUSTOM_PROVIDER_ID_PREFIX}lmstudio`; + return ( + providerId === "lmstudio" || + providerId === `${CUSTOM_PROVIDER_ID_PREFIX}lmstudio` || + /^custom:lmstudio-(?:[2-9]|[1-9]\d+)$/u.test(providerId) + ); } export function isOllamaProviderId(providerId: string): boolean { - return providerId === "ollama" || providerId === `${CUSTOM_PROVIDER_ID_PREFIX}ollama`; + return ( + providerId === "ollama" || + providerId === `${CUSTOM_PROVIDER_ID_PREFIX}ollama` || + /^custom:ollama-(?:[2-9]|[1-9]\d+)$/u.test(providerId) + ); } From 1be27c7affa83f44f8f6d8fb2abaaf50025d3049 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Mon, 10 Aug 2026 16:51:20 -0400 Subject: [PATCH 03/62] Migrate released local provider identities --- .../provider-config-migration-core.ts | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/main/services/provider-config-migration-core.ts b/main/services/provider-config-migration-core.ts index b72e9abd..abb9ee43 100644 --- a/main/services/provider-config-migration-core.ts +++ b/main/services/provider-config-migration-core.ts @@ -21,6 +21,12 @@ const LEGACY_PI_PROVIDER_IDS = new Set([ "moonshot", ]); +/** IDs shipped briefly by onboarding before local presets adopted their reserved identities. */ +const RELEASED_ONBOARDING_LOCAL_PROVIDER_IDS = new Map([ + ["custom:onboarding-lmstudio", "custom:lmstudio"], + ["custom:onboarding-ollama", "custom:ollama"], +]); + const LEGACY_PI_BASE_URLS: Readonly> = { openai: "https://api.openai.com/v1", anthropic: "https://api.anthropic.com/v1", @@ -189,6 +195,24 @@ export function migratePiProviderConfig(config: ProviderConfigMigrationShape): b ]); config.providers = config.providers.flatMap((provider) => { + const releasedLocalTarget = RELEASED_ONBOARDING_LOCAL_PROVIDER_IDS.get(provider.id); + if (releasedLocalTarget) { + const sourceId = provider.id; + // The canonical ID normally wins. If it is already an active provider or + // reserved anywhere in the alias graph, retain both connections under a + // collision-safe sibling instead of overwriting either endpoint intent. + const targetId = uniqueCustomId(releasedLocalTarget.slice("custom:".length), usedIds); + setAlias(aliases, sourceId, targetId); + return [ + { + ...provider, + id: targetId, + isPreset: false, + isBuiltin: false, + }, + ]; + } + const isLegacyPiProvider = LEGACY_PI_PROVIDER_IDS.has(provider.id); if (isLegacyPiProvider && isUntouchedPiPreset(provider)) return []; From e09c3a34735bbdf82bb7953b54592e9f5ec1e91a Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Mon, 10 Aug 2026 16:51:20 -0400 Subject: [PATCH 04/62] Cover local provider identity migration --- .../provider-config-migration-core.test.ts | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/main/services/provider-config-migration-core.test.ts b/main/services/provider-config-migration-core.test.ts index e5967af1..7de6c463 100644 --- a/main/services/provider-config-migration-core.test.ts +++ b/main/services/provider-config-migration-core.test.ts @@ -7,6 +7,7 @@ import { } from "./provider-config-migration-core.js"; import type { StoredProvider } from "./types.js"; import { MAX_CONFIG_ID_LENGTH } from "./types.js"; +import { isLmStudioProviderId, isOllamaProviderId } from "./custom-provider-id.js"; function provider( id: string, @@ -74,6 +75,78 @@ test("removes only untouched cloud presets and namespaces legacy local connectio assert.equal(migratePiProviderConfig(config), false); }); +test("moves released onboarding local IDs onto their canonical aliases", () => { + const config: ProviderConfigMigrationShape = { + providers: [ + provider("custom:onboarding-lmstudio", "http://localhost:1234/v1", { + label: "LM Studio (local)", + models: ["loaded-model"], + defaultModel: "loaded-model", + needsKey: false, + deployment: "local", + }), + provider("custom:onboarding-ollama", "http://localhost:11434/v1", { + label: "Ollama (local)", + models: ["qwen3:latest"], + defaultModel: "qwen3:latest", + needsKey: false, + deployment: "local", + }), + ], + settings: { lastProviderId: "custom:onboarding-lmstudio" }, + }; + + assert.equal(migratePiProviderConfig(config), true); + assert.deepEqual( + config.providers.map(({ id }) => id), + ["custom:lmstudio", "custom:ollama"], + ); + assert.deepEqual(config.providerIdAliases, { + "custom:onboarding-lmstudio": "custom:lmstudio", + "custom:onboarding-ollama": "custom:ollama", + }); + assert.equal(config.settings.lastProviderId, "custom:lmstudio"); + assert.deepEqual(config.providers[0]?.models, ["loaded-model"]); + assert.equal(migratePiProviderConfig(config), false); +}); + +test("keeps both local connections when a released onboarding ID collides with canonical intent", () => { + const released = provider("custom:onboarding-lmstudio", "http://old-mac.example.test:1234/v1", { + label: "Remote LM Studio", + models: ["remote-model"], + defaultModel: "remote-model", + needsKey: false, + deployment: "local", + }); + const canonical = provider("custom:lmstudio", "http://127.0.0.1:1234/v1", { + label: "This Mac", + models: ["local-model"], + defaultModel: "local-model", + needsKey: false, + deployment: "local", + }); + const config: ProviderConfigMigrationShape = { + providers: [released, canonical], + settings: { lastProviderId: released.id }, + }; + + assert.equal(migratePiProviderConfig(config), true); + assert.deepEqual( + config.providers.map(({ id }) => id), + ["custom:lmstudio-2", "custom:lmstudio"], + ); + assert.equal(config.providers[0]?.baseUrl, released.baseUrl); + assert.equal(config.providers[1]?.baseUrl, canonical.baseUrl); + assert.deepEqual(config.providerIdAliases, { + "custom:onboarding-lmstudio": "custom:lmstudio-2", + }); + assert.equal(config.settings.lastProviderId, "custom:lmstudio-2"); + assert.equal(isLmStudioProviderId("custom:lmstudio-2"), true); + assert.equal(isOllamaProviderId("custom:ollama-2"), true); + assert.equal(isLmStudioProviderId("custom:lmstudio-work"), false); + assert.equal(migratePiProviderConfig(config), false); +}); + test("keeps every edited preset custom and never lets a future Pi ID claim it", () => { const config: ProviderConfigMigrationShape = { providers: [ From feb674eebf019e16766419d97f366e635301af89 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Mon, 10 Aug 2026 16:51:20 -0400 Subject: [PATCH 05/62] Verify provider migration persistence --- main/services/config-store-core.test.ts | 29 +++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/main/services/config-store-core.test.ts b/main/services/config-store-core.test.ts index 19554244..2884c5ae 100644 --- a/main/services/config-store-core.test.ts +++ b/main/services/config-store-core.test.ts @@ -417,6 +417,35 @@ test("an untouched legacy preset is still retired when seeding follows the split ); }); +test("released onboarding local identity migration re-homes cache and remembered provider", async (t) => { + const releasedId = "custom:onboarding-lmstudio"; + const h = await harness(t, { + providers: [ + { + ...provider, + id: releasedId, + defaultModel: "qwen3-8b", + }, + ], + settings: { lastProviderId: releasedId }, + seeded: true, + }); + + const [listed] = await h.store.listProviders(); + + assert.equal(listed.id, "custom:lmstudio"); + assert.deepEqual(listed.legacyIds, [releasedId]); + assert.deepEqual(listed.models, provider.models); + assert.deepEqual(listed.modelMetadata, provider.modelMetadata); + assert.equal((await h.store.getSettings()).lastProviderId, "custom:lmstudio"); + const cache = await readJson<{ byProvider: Record }>(h.cacheFile); + assert.equal(cache.byProvider[releasedId], undefined); + assert.deepEqual(cache.byProvider["custom:lmstudio"], { + models: provider.models, + modelMetadata: provider.modelMetadata, + }); +}); + test("an edited legacy preset is retained under a reserved ID with its cache re-homed", async (t) => { const h = await harness(t, { providers: [{ ...untouchedPreset, baseUrl: "https://proxy.internal/v1" }], From 0426ca5a695e43d6f910852f1a36b91d8ebf10cb Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Mon, 10 Aug 2026 16:51:20 -0400 Subject: [PATCH 06/62] Harden local model discovery --- main/services/models.ts | 151 ++++++++++++++++++++++++++-------------- 1 file changed, 99 insertions(+), 52 deletions(-) diff --git a/main/services/models.ts b/main/services/models.ts index 09fbb781..e5330ed6 100644 --- a/main/services/models.ts +++ b/main/services/models.ts @@ -56,6 +56,8 @@ interface OllamaShowResponse { export interface DiscoveredModels { models: string[]; modelMetadata: Record; + /** Ephemeral runtime hint; never persisted as model metadata. */ + recommendedModel?: string; } export interface ConnectionTestResult extends DiscoveredModels { @@ -125,17 +127,25 @@ function providerEndpoint(provider: StoredProvider, pathname: string): string { async function fetchJson( url: string, headers: Record, - init: { method?: "GET" | "POST"; body?: string; redirect?: RequestRedirect } = {}, + init: { + method?: "GET" | "POST"; + body?: string; + redirect?: RequestRedirect; + signal?: AbortSignal; + timeoutMs?: number; + } = {}, ): Promise { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), MODEL_DISCOVERY_TIMEOUT_MS); + const controller = init.signal ? null : new AbortController(); + const signal = init.signal ?? controller!.signal; + const timeoutMs = init.timeoutMs ?? MODEL_DISCOVERY_TIMEOUT_MS; + const timeout = controller ? setTimeout(() => controller.abort(), timeoutMs) : null; try { const response = await fetch(url, { method: init.method ?? "GET", body: init.body, headers: init.body ? { ...headers, "content-type": "application/json" } : headers, redirect: init.redirect, - signal: controller.signal, + signal, }); if (!response.ok) { const body = await response.text().catch(() => ""); @@ -146,12 +156,12 @@ async function fetchJson( } return response.json() as Promise; } catch (error) { - if (controller.signal.aborted) { - throw new Error(`Connection timed out after ${MODEL_DISCOVERY_TIMEOUT_MS / 1000} seconds.`); + if (signal.aborted) { + throw new Error(`Connection timed out after ${timeoutMs / 1000} seconds.`); } throw error; } finally { - clearTimeout(timeout); + if (timeout) clearTimeout(timeout); } } @@ -222,6 +232,7 @@ function capabilityFlags(value: unknown): { toolCall?: boolean; reasoning?: boolean; embedding?: boolean; + completion?: boolean; } { if (Array.isArray(value)) { const capabilities = new Set( @@ -234,6 +245,7 @@ function capabilityFlags(value: unknown): { toolCall: capabilities.has("tools") || capabilities.has("tool_use"), reasoning: capabilities.has("reasoning") || capabilities.has("thinking"), embedding: capabilities.has("embedding") || capabilities.has("embeddings"), + completion: capabilities.has("completion"), }; } const capabilities = object(value); @@ -307,6 +319,7 @@ function parseLmStudioResponse(value: unknown): DiscoveredModels | null { .map((value) => object(value)) .filter((value): value is Record => Boolean(value)); const metadataEntries: Array<[string, ProviderModelMetadata]> = []; + let recommendedModel: string | undefined; for (const entry of entries) { const key = typeof entry.key === "string" ? entry.key : undefined; if (!key) continue; @@ -314,6 +327,14 @@ function parseLmStudioResponse(value: unknown): DiscoveredModels | null { const type = entry.type === "embedding" ? "embedding" : entry.type === "llm" ? "llm" : undefined; const quantization = object(entry.quantization); + const loadedInstances = entry.loaded_instances; + if ( + !recommendedModel && + type !== "embedding" && + ((Array.isArray(loadedInstances) && loadedInstances.length > 0) || entry.state === "loaded") + ) { + recommendedModel = key; + } metadataEntries.push([ key, { @@ -338,7 +359,11 @@ function parseLmStudioResponse(value: unknown): DiscoveredModels | null { const models = Object.keys(modelMetadata) .filter((id) => modelMetadata[id]?.type !== "embedding") .sort(); - return { models, modelMetadata }; + return { + models, + modelMetadata, + ...(recommendedModel && models.includes(recommendedModel) ? { recommendedModel } : {}), + }; } function ollamaContextLength(modelInfo: Record | undefined): number | undefined { @@ -371,53 +396,75 @@ async function mapWithConcurrency( return output; } -async function discoverOllama( +export async function discoverOllamaModels( provider: StoredProvider, headers: Record, + timeoutMs = MODEL_DISCOVERY_TIMEOUT_MS, ): Promise { - const tagsValue = await fetchJson(providerEndpoint(provider, "/api/tags"), headers); - const tagsResponse = object(tagsValue); - if (!tagsResponse || !Array.isArray(tagsResponse.models)) return null; - const tags = tagsResponse.models - .map((value) => object(value) as OllamaTag | null) - .filter((value): value is OllamaTag => Boolean(value?.model ?? value?.name)); - - const rows = await mapWithConcurrency(tags, 4, async (tag) => { - const id = tag.model ?? tag.name!; - let detail: OllamaShowResponse = {}; - try { - detail = (await fetchJson(providerEndpoint(provider, "/api/show"), headers, { - method: "POST", - body: JSON.stringify({ model: id, verbose: false }), - })) as OllamaShowResponse; - } catch { - // One damaged model entry must not hide the rest of an otherwise healthy local catalog. - } - const capabilities = capabilityFlags(detail.capabilities); - const type = capabilities.embedding ? "embedding" : "llm"; - const details = detail.details ?? tag.details; - return { - id, - metadata: { - source: "ollama", - name: tag.name ?? tag.model, - type, - vision: capabilities.vision, - toolCall: capabilities.toolCall, - reasoning: capabilities.reasoning, - contextLength: ollamaContextLength(detail.model_info), - parameterCount: details?.parameter_size, - format: details?.quantization_level ?? details?.format, - } satisfies ProviderModelMetadata, - }; - }); + const boundedTimeoutMs = + Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : MODEL_DISCOVERY_TIMEOUT_MS; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), boundedTimeoutMs); + try { + const tagsValue = await fetchJson(providerEndpoint(provider, "/api/tags"), headers, { + signal: controller.signal, + timeoutMs: boundedTimeoutMs, + }); + const tagsResponse = object(tagsValue); + if (!tagsResponse || !Array.isArray(tagsResponse.models)) return null; + const tags = tagsResponse.models + .map((value) => object(value) as OllamaTag | null) + .filter((value): value is OllamaTag => Boolean(value?.model ?? value?.name)); + + const rows = await mapWithConcurrency(tags, 4, async (tag) => { + const id = tag.model ?? tag.name!; + let detail: OllamaShowResponse = {}; + if (!controller.signal.aborted) { + try { + const value = await fetchJson(providerEndpoint(provider, "/api/show"), headers, { + method: "POST", + body: JSON.stringify({ model: id, verbose: false }), + signal: controller.signal, + timeoutMs: boundedTimeoutMs, + }); + detail = (object(value) ?? {}) as OllamaShowResponse; + } catch { + // A damaged or deadline-aborted detail entry must not hide the safe + // tag metadata from the rest of an otherwise healthy local catalog. + } + } + const capabilities = capabilityFlags(detail.capabilities); + const type = capabilities.embedding + ? "embedding" + : capabilities.completion + ? "llm" + : undefined; + const details = detail.details ?? tag.details; + return { + id, + metadata: { + source: "ollama", + name: tag.name ?? tag.model, + type, + vision: capabilities.vision, + toolCall: capabilities.toolCall, + reasoning: capabilities.reasoning, + contextLength: ollamaContextLength(detail.model_info), + parameterCount: details?.parameter_size, + format: details?.quantization_level ?? details?.format, + } satisfies ProviderModelMetadata, + }; + }); - const modelMetadata = Object.fromEntries(rows.map((row) => [row.id, row.metadata])); - const models = rows - .filter((row) => row.metadata.type !== "embedding") - .map((row) => row.id) - .sort(); - return { models, modelMetadata }; + const modelMetadata = Object.fromEntries(rows.map((row) => [row.id, row.metadata])); + const models = rows + .filter((row) => row.metadata.type === "llm") + .map((row) => row.id) + .sort(); + return { models, modelMetadata }; + } finally { + clearTimeout(timeout); + } } function canFallBackFromNative(error: unknown): boolean { @@ -442,7 +489,7 @@ export async function discoverModels( } if (isOllamaProviderId(provider.id)) { try { - const native = await discoverOllama(provider, headers); + const native = await discoverOllamaModels(provider, headers); if (native) return native; } catch (error) { if (!canFallBackFromNative(error)) throw error; From bca7dfc09b1a736a186973d7fda71d2238298f39 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Mon, 10 Aug 2026 16:51:20 -0400 Subject: [PATCH 07/62] Cover local model discovery edge cases --- main/services/models.test.ts | 137 ++++++++++++++++++++++++++++++++++- 1 file changed, 136 insertions(+), 1 deletion(-) diff --git a/main/services/models.test.ts b/main/services/models.test.ts index 02e75e99..72a70803 100644 --- a/main/services/models.test.ts +++ b/main/services/models.test.ts @@ -11,7 +11,7 @@ import { resolveProviderRuntimeLimits, resolveRuntimeLimits, } from "./models-catalog-core.js"; -import { normalizeProviderBaseUrl, testConnection } from "./models.js"; +import { discoverOllamaModels, normalizeProviderBaseUrl, testConnection } from "./models.js"; import { canonicalGoogleProvider } from "./google-provider.js"; const lmStudioProvider = { @@ -110,6 +110,7 @@ test("LM Studio custom connections use native metadata and exclude embeddings", params_string: "4B-A2B", max_context_length: 131_072, format: "mlx", + loaded_instances: [{ id: "gemma-loaded" }], capabilities: { vision: true, trained_for_tool_use: true, @@ -131,6 +132,7 @@ test("LM Studio custom connections use native metadata and exclude embeddings", const result = await testConnection({ ...lmStudioProvider, id: "custom:lmstudio" }, null); assert.deepEqual(result.models, ["google/gemma-4-e2b"]); assert.equal(result.modelCount, 1); + assert.equal(result.recommendedModel, "google/gemma-4-e2b"); assert.deepEqual(result.modelMetadata["google/gemma-4-e2b"], { source: "lmstudio", name: "Gemma 4 E2B", @@ -227,6 +229,139 @@ test("Ollama custom connections enrich chat models with show metadata and filter assert.equal(result.modelMetadata["nomic-embed:latest"]?.type, "embedding"); }); +test("Ollama detail enrichment shares one deadline and keeps safe partial tag metadata", async (t) => { + const originalFetch = globalThis.fetch; + t.after(() => { + globalThis.fetch = originalFetch; + }); + const signals = new Set(); + const tags = Array.from({ length: 20 }, (_, index) => ({ + model: `model-${index}`, + details: { parameter_size: `${index + 1}B`, quantization_level: "Q4_K_M" }, + })); + globalThis.fetch = (async (input, init) => { + signals.add(init?.signal); + if (String(input).endsWith("/api/tags")) { + return new Response(JSON.stringify({ models: tags }), { status: 200 }); + } + const body = JSON.parse(String(init?.body)) as { model: string }; + if (body.model === "model-0") { + return new Response( + JSON.stringify({ + capabilities: ["completion", "vision"], + model_info: { "llama.context_length": 16_384 }, + }), + { status: 200 }, + ); + } + const signal = init?.signal; + return await new Promise((_resolve, reject) => { + const abort = () => reject(new DOMException("Aborted", "AbortError")); + if (signal?.aborted) abort(); + else signal?.addEventListener("abort", abort, { once: true }); + }); + }) as typeof fetch; + + const startedAt = Date.now(); + const result = await discoverOllamaModels( + { + ...lmStudioProvider, + id: "custom:ollama", + label: "Ollama (local)", + baseUrl: "http://127.0.0.1:11434/v1", + }, + {}, + 80, + ); + const elapsedMs = Date.now() - startedAt; + + assert.ok(result); + assert.ok( + elapsedMs < 300, + `one 80ms deadline should not multiply by model count (${elapsedMs}ms)`, + ); + assert.equal(signals.size, 1, "tags and every detail request share one AbortSignal"); + assert.deepEqual(result.models, ["model-0"]); + assert.deepEqual(result.modelMetadata["model-0"], { + source: "ollama", + name: "model-0", + type: "llm", + vision: true, + toolCall: false, + reasoning: false, + contextLength: 16_384, + parameterCount: "1B", + format: "Q4_K_M", + }); + assert.deepEqual(result.modelMetadata["model-19"], { + source: "ollama", + name: "model-19", + type: undefined, + vision: undefined, + toolCall: undefined, + reasoning: undefined, + contextLength: undefined, + parameterCount: "20B", + format: "Q4_K_M", + }); +}); + +test("Ollama does not expose an embedding model as chat-capable when show times out", async (t) => { + const originalFetch = globalThis.fetch; + t.after(() => { + globalThis.fetch = originalFetch; + }); + globalThis.fetch = (async (input, init) => { + if (String(input).endsWith("/api/tags")) { + return new Response( + JSON.stringify({ + models: [ + { + model: "nomic-embed-text:latest", + details: { parameter_size: "137M", quantization_level: "F16" }, + }, + ], + }), + { status: 200 }, + ); + } + const signal = init?.signal; + return await new Promise((_resolve, reject) => { + const abort = () => reject(new DOMException("Aborted", "AbortError")); + if (signal?.aborted) abort(); + else signal?.addEventListener("abort", abort, { once: true }); + }); + }) as typeof fetch; + + const startedAt = Date.now(); + const result = await discoverOllamaModels( + { + ...lmStudioProvider, + id: "custom:ollama", + label: "Ollama (local)", + baseUrl: "http://127.0.0.1:11434/v1", + }, + {}, + 80, + ); + const elapsedMs = Date.now() - startedAt; + + assert.ok(result); + assert.ok(elapsedMs < 300, `one 80ms deadline should bound the detail request (${elapsedMs}ms)`); + assert.deepEqual(result.models, []); + assert.deepEqual(result.modelMetadata["nomic-embed-text:latest"], { + source: "ollama", + name: "nomic-embed-text:latest", + type: undefined, + vision: undefined, + toolCall: undefined, + reasoning: undefined, + contextLength: undefined, + parameterCount: "137M", + format: "F16", + }); +}); + test("keyless Anthropic discovery omits x-api-key while retaining its protocol version", async (t) => { const originalFetch = globalThis.fetch; t.after(() => { From 0c158277952a11b9aaf24a2321b4748252349afc Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Mon, 10 Aug 2026 16:51:20 -0400 Subject: [PATCH 08/62] Discover local models during onboarding --- renderer/components/onboarding-flow.tsx | 157 ++++++++++++------------ 1 file changed, 81 insertions(+), 76 deletions(-) diff --git a/renderer/components/onboarding-flow.tsx b/renderer/components/onboarding-flow.tsx index 0d81acf4..6ee07771 100644 --- a/renderer/components/onboarding-flow.tsx +++ b/renderer/components/onboarding-flow.tsx @@ -38,19 +38,18 @@ import { BuiltinProviderEditor } from "./settings/builtin-provider-editor"; import { Button, Input, Text, toast } from "./ui"; import { providersApi, profileApi } from "../lib/ipc"; import { markOnboardingComplete, shouldShowOnboarding } from "../lib/onboarding-state"; +import { + discoveredDefaultModel, + fieldsAfterProviderChoiceChange, + makeOnboardingProvider, + type OnboardingProviderChoice, +} from "../lib/onboarding-provider"; import { getOnboardingMoreProviders } from "../lib/pi-provider-display"; import { queryKeys, useProviders } from "../lib/queries"; +import { persistModelSelection } from "../lib/use-model-selection"; import type { Provider } from "../lib/types"; type Step = "profile" | "provider" | "tour"; -type ProviderChoice = - | "openai-key" - | "openai-signin" - | "anthropic" - | "lmstudio" - | "ollama" - | "tailscale"; - const steps: Step[] = ["profile", "provider", "tour"]; const stepLabels: Readonly> = { profile: "Your profile", @@ -88,7 +87,7 @@ const FEATURE_ILLUSTRATIONS = { } as const; const providerChoices: Array<{ - id: ProviderChoice; + id: OnboardingProviderChoice; title: string; description: string; iconProviderId?: string; @@ -401,65 +400,6 @@ function OnboardingDialogShell({ children }: React.PropsWithChildren) { ); } -function makeProvider(choice: ProviderChoice, baseUrl: string): Omit | null { - if (choice === "openai-signin") return null; - if (choice === "openai-key") { - return { - id: "custom:onboarding-openai", - kind: "openai", - label: "OpenAI", - baseUrl: baseUrl || "https://api.openai.com/v1", - models: ["gpt-4.1", "gpt-4.1-mini"], - defaultModel: "gpt-4.1-mini", - needsKey: true, - deployment: "hosted", - }; - } - if (choice === "anthropic") { - return { - id: "custom:onboarding-anthropic", - kind: "anthropic", - label: "Anthropic", - baseUrl: baseUrl || "https://api.anthropic.com/v1", - models: ["claude-sonnet-4-5", "claude-haiku-4-5"], - defaultModel: "claude-sonnet-4-5", - needsKey: true, - deployment: "hosted", - }; - } - if (choice === "lmstudio") { - return { - id: "custom:onboarding-lmstudio", - kind: "openai", - label: "LM Studio (local)", - baseUrl: baseUrl || "http://127.0.0.1:1234/v1", - models: [], - needsKey: false, - deployment: "local", - }; - } - if (choice === "ollama") { - return { - id: "custom:onboarding-ollama", - kind: "openai", - label: "Ollama (local)", - baseUrl: baseUrl || "http://127.0.0.1:11434/v1", - models: [], - needsKey: false, - deployment: "local", - }; - } - return { - id: "custom:onboarding-tailscale", - kind: "openai", - label: "Tailscale model", - baseUrl, - models: [], - needsKey: false, - deployment: "local", - }; -} - function builtinProviderSetupLabel(provider: Provider): string { if (provider.hasKey) return "Ready on this Mac"; const methods = (provider.authMethods ?? []) @@ -479,13 +419,15 @@ export function OnboardingFlow() { const [open, setOpen] = React.useState(() => shouldShowOnboarding()); const [index, setIndex] = React.useState(0); const [name, setName] = React.useState(""); - const [choice, setChoice] = React.useState("openai-signin"); + const [choice, setChoice] = React.useState("openai-signin"); const [builtinChoiceId, setBuiltinChoiceId] = React.useState(null); const [showMoreProviders, setShowMoreProviders] = React.useState(false); const [settingUpProvider, setSettingUpProvider] = React.useState(null); const [apiKey, setApiKey] = React.useState(""); const [baseUrl, setBaseUrl] = React.useState(""); const [saving, setSaving] = React.useState(false); + const [discovering, setDiscovering] = React.useState(false); + const [providerError, setProviderError] = React.useState(null); const savingRef = React.useRef(false); const scrollContainerRef = React.useRef(null); @@ -505,6 +447,14 @@ export function OnboardingFlow() { const canContinue = step === "profile" ? name.trim().length > 0 : step === "provider" ? hasProviderChoice : true; + const selectProviderChoice = (nextChoice: OnboardingProviderChoice | null) => { + const nextFields = fieldsAfterProviderChoiceChange(choice, nextChoice, { apiKey, baseUrl }); + setApiKey(nextFields.apiKey); + setBaseUrl(nextFields.baseUrl); + setChoice(nextChoice); + setProviderError(null); + }; + const next = async () => { if (!canContinue || savingRef.current) return; if (step === "profile") { @@ -550,7 +500,6 @@ export function OnboardingFlow() { else setSettingUpProvider(chatGptProvider); return; } - const provider = makeProvider(choice, baseUrl.trim()); if (choice === "tailscale" && !baseUrl.trim()) { toast.error("Enter the Tailscale model server URL before continuing."); return; @@ -561,21 +510,65 @@ export function OnboardingFlow() { } savingRef.current = true; setSaving(true); + setProviderError(null); try { - if (provider) { + const isLocalRuntime = choice === "lmstudio" || choice === "ollama"; + // Resolve reserved local identities from a fresh main-process snapshot. + // The query cache may still be loading or stale when the user clicks Next. + const currentProviders = isLocalRuntime + ? await providersApi.list() + : (providers.data ?? []); + if (isLocalRuntime) { + queryClient.setQueryData(queryKeys.providers, currentProviders); + } + let providerToSave = makeOnboardingProvider(choice, baseUrl.trim(), currentProviders); + if (providerToSave && isLocalRuntime) { + let discovery: Awaited>; + try { + setDiscovering(true); + discovery = await providersApi.test(providerToSave); + } catch (error) { + const message = `Couldn't reach ${selected.title}: ${error instanceof Error ? error.message : String(error)}`; + setProviderError(message); + toast.error(message); + return; + } finally { + setDiscovering(false); + } + const defaultModel = discoveredDefaultModel(providerToSave, discovery); + if (!defaultModel) { + const message = + "Endpoint reached, but no chat models were found. Load one in the server, then try again."; + setProviderError(message); + toast.info(message); + return; + } + providerToSave = { + ...providerToSave, + models: discovery.models, + modelMetadata: discovery.modelMetadata, + defaultModel, + }; + } + if (providerToSave) { const saved = await providersApi.save( - provider, + providerToSave, selected.requiresKey ? apiKey.trim() : undefined, ); queryClient.setQueryData(queryKeys.providers, (current) => { const without = (current ?? []).filter((item) => item.id !== saved.id); return [...without, saved]; }); + if (isLocalRuntime) { + persistModelSelection(saved.id, saved.defaultModel ?? providerToSave.defaultModel!); + } toast.success(`${saved.label} added.`); } setIndex(2); } catch (error) { - toast.error(error instanceof Error ? error.message : "Couldn't add that provider."); + const message = error instanceof Error ? error.message : "Couldn't add that provider."; + setProviderError(message); + toast.error(message); } finally { savingRef.current = false; setSaving(false); @@ -722,7 +715,7 @@ export function OnboardingFlow() { aria-pressed={choice === item.id} className={`flex min-h-[68px] items-start gap-2.5 rounded-control border px-3 py-2.5 text-left transition-[background-color,border-color] duration-150 focus-visible:outline focus-visible:outline-2 focus-visible:outline-focus-ring ${choice === item.id ? "border-accent bg-accent/10" : "border-field bg-well hover:bg-control"}`} onClick={() => { - setChoice(item.id); + selectProviderChoice(item.id); setBuiltinChoiceId(null); }} > @@ -826,7 +819,7 @@ export function OnboardingFlow() { aria-pressed={isSelected} className={`flex min-h-14 items-center gap-2.5 rounded-control border px-2.5 py-2 text-left transition-[background-color,border-color] duration-150 focus-visible:outline focus-visible:outline-2 focus-visible:outline-focus-ring disabled:cursor-not-allowed disabled:opacity-50 ${isSelected ? "border-accent bg-accent/10" : "border-transparent bg-transparent hover:border-field hover:bg-control"}`} onClick={() => { - setChoice(null); + selectProviderChoice(null); setBuiltinChoiceId(provider.id); }} > @@ -894,6 +887,11 @@ export function OnboardingFlow() { ) : null} + {providerError ? ( + + {providerError} + + ) : null} ) : null} @@ -1004,7 +1002,14 @@ export function OnboardingFlow() { Back From 26cf8cd57fc36e9c45fa882f4247fb83f9497f61 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Mon, 10 Aug 2026 16:51:20 -0400 Subject: [PATCH 09/62] Cover local provider onboarding --- renderer/components/onboarding-flow.test.tsx | 124 ++++++++++++++++++- 1 file changed, 122 insertions(+), 2 deletions(-) diff --git a/renderer/components/onboarding-flow.test.tsx b/renderer/components/onboarding-flow.test.tsx index 59d82d01..53f9d051 100644 --- a/renderer/components/onboarding-flow.test.tsx +++ b/renderer/components/onboarding-flow.test.tsx @@ -1,6 +1,13 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; +import { + discoveredDefaultModel, + fieldsAfterProviderChoiceChange, + makeOnboardingProvider, + type OnboardingProviderChoice, +} from "../lib/onboarding-provider.js"; +import type { Provider } from "../lib/types.js"; const source = readFileSync(new URL("./onboarding-flow.tsx", import.meta.url), "utf8"); const agentsInstructions = readFileSync(new URL("../../AGENTS.md", import.meta.url), "utf8"); @@ -30,11 +37,11 @@ const featureAssetPaths = [ ] as const; const providerPresentation = source.slice( source.indexOf("const providerChoices"), - source.indexOf("function makeProvider"), + source.indexOf("function builtinProviderSetupLabel"), ); const featurePresentation = source.slice( source.indexOf("const featureBentos"), - source.indexOf("function makeProvider"), + source.indexOf("function builtinProviderSetupLabel"), ); test("onboarding uses the Aiden mark and the existing provider icon system", () => { @@ -46,6 +53,119 @@ test("onboarding uses the Aiden mark and the existing provider icon system", () assert.match(source, /aria-pressed=\{choice === item\.id\}/u); }); +test("local onboarding reuses canonical intent and never applies another choice's hidden URL", () => { + const existing: Provider = { + id: "custom:lmstudio", + kind: "anthropic", + label: "Studio over Tailnet", + baseUrl: "https://studio.example.ts.net/custom-api", + models: ["kept-model"], + modelMetadata: { "kept-model": { source: "provider", reasoning: true } }, + defaultModel: "kept-model", + needsKey: true, + deployment: "hosted", + isPreset: false, + isBuiltin: false, + hasKey: true, + legacyIds: ["old-studio"], + }; + + assert.deepEqual(makeOnboardingProvider("lmstudio", "https://hidden.example/v1", [existing]), { + id: existing.id, + kind: existing.kind, + label: existing.label, + baseUrl: existing.baseUrl, + models: existing.models, + modelMetadata: existing.modelMetadata, + defaultModel: existing.defaultModel, + needsKey: existing.needsKey, + deployment: existing.deployment, + isPreset: existing.isPreset, + isBuiltin: existing.isBuiltin, + }); + assert.equal( + makeOnboardingProvider("ollama", "https://hidden.example/v1")?.baseUrl, + "http://127.0.0.1:11434/v1", + ); + assert.equal(makeOnboardingProvider("lmstudio", "")?.id, "custom:lmstudio"); + assert.equal(makeOnboardingProvider("ollama", "")?.id, "custom:ollama"); +}); + +test("switching provider choices clears API-key and URL drafts before they become hidden", () => { + const populated = { apiKey: "secret", baseUrl: "https://gateway.example/v1" }; + const choices: OnboardingProviderChoice[] = [ + "openai-key", + "openai-signin", + "anthropic", + "lmstudio", + "ollama", + "tailscale", + ]; + for (const current of choices) { + for (const next of choices) { + assert.deepEqual( + fieldsAfterProviderChoiceChange(current, next, populated), + current === next ? populated : { apiKey: "", baseUrl: "" }, + `${current} -> ${next}`, + ); + } + } + assert.deepEqual(fieldsAfterProviderChoiceChange("anthropic", null, populated), { + apiKey: "", + baseUrl: "", + }); +}); + +test("local discovery preserves a still-usable default before transient recommendations", () => { + const provider = makeOnboardingProvider("lmstudio", ""); + assert.ok(provider); + provider.defaultModel = "already-selected"; + assert.equal( + discoveredDefaultModel(provider, { + models: ["recommended", "already-selected"], + recommendedModel: "recommended", + }), + "already-selected", + ); + provider.defaultModel = "gone"; + assert.equal( + discoveredDefaultModel(provider, { + models: ["recommended", "fallback"], + recommendedModel: "recommended", + }), + "recommended", + ); +}); + +test("local onboarding discovers and selects a usable default model before continuing", () => { + const providerStep = source.slice( + source.indexOf('if (step === "provider")'), + source.indexOf("markOnboardingComplete()"), + ); + const freshList = providerStep.indexOf("await providersApi.list()"); + const providerBuild = providerStep.indexOf("makeOnboardingProvider(choice"); + const discovery = providerStep.indexOf("await providersApi.test(providerToSave)"); + const save = providerStep.indexOf("await providersApi.save("); + const cache = providerStep.indexOf("queryClient.setQueryData"); + const selection = providerStep.indexOf("persistModelSelection(saved.id"); + + assert.ok(freshList >= 0 && freshList < providerBuild, "resolve live intent before building"); + assert.ok(providerBuild >= 0 && providerBuild < discovery, "reuse intent before discovery"); + assert.ok(discovery >= 0 && discovery < save, "discover before saving a local provider"); + assert.match(providerStep, /models: discovery\.models/u); + assert.match(providerStep, /modelMetadata: discovery\.modelMetadata/u); + assert.match(providerStep, /discoveredDefaultModel\(providerToSave, discovery\)/u); + assert.match(providerStep, /defaultModel,/u); + assert.match(providerStep, /if \(!defaultModel\)[\s\S]*?no chat models were found/u); + assert.ok(cache >= 0 && cache < selection, "publish the provider before selecting its model"); + assert.match( + providerStep, + /persistModelSelection\(saved\.id, saved\.defaultModel \?\? providerToSave\.defaultModel!\)/u, + ); + assert.match(source, /\{discovering[\s\S]*?Discovering models…/u); + assert.match(source, /providerError[\s\S]*?role="alert"/u); +}); + test("onboarding keeps navigation fixed while its content scrolls", () => { assert.match( source, From 9357f84b66acbc9b3d1163c4f1460ec7b7754a74 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Mon, 10 Aug 2026 16:51:20 -0400 Subject: [PATCH 10/62] Add provider editor focus targeting --- .../settings/provider-editor-focus.ts | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 renderer/components/settings/provider-editor-focus.ts diff --git a/renderer/components/settings/provider-editor-focus.ts b/renderer/components/settings/provider-editor-focus.ts new file mode 100644 index 00000000..b5dd23f1 --- /dev/null +++ b/renderer/components/settings/provider-editor-focus.ts @@ -0,0 +1,23 @@ +export interface ProviderEditorFocusElement { + readonly isConnected: boolean; + focus(): void; +} + +/** + * One-shot return-focus target for a custom-provider editor. Capturing every + * open path and consuming on close prevents an earlier Configure button from + * stealing focus after a later Add-provider flow. + */ +export class ProviderEditorFocusTarget { + #target: ProviderEditorFocusElement | null = null; + + capture(target: ProviderEditorFocusElement | null): void { + this.#target = target; + } + + take(): ProviderEditorFocusElement | null { + const target = this.#target; + this.#target = null; + return target?.isConnected ? target : null; + } +} From 6739d8a7a79040c05955a55f0809d3a0f8e2a5f5 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Mon, 10 Aug 2026 16:51:20 -0400 Subject: [PATCH 11/62] Restore provider editor focus --- .../components/settings/provider-editor.tsx | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/renderer/components/settings/provider-editor.tsx b/renderer/components/settings/provider-editor.tsx index d3c94024..e7fc0cce 100644 --- a/renderer/components/settings/provider-editor.tsx +++ b/renderer/components/settings/provider-editor.tsx @@ -49,9 +49,16 @@ interface ProviderEditorProps { open: boolean; onOpenChange: (open: boolean) => void; onSaved: () => void; + returnFocus?: () => HTMLElement | null; } -export function ProviderEditor({ provider, open, onOpenChange, onSaved }: ProviderEditorProps) { +export function ProviderEditor({ + provider, + open, + onOpenChange, + onSaved, + returnFocus, +}: ProviderEditorProps) { const [label, setLabel] = React.useState(provider.label); const [baseUrl, setBaseUrl] = React.useState(provider.baseUrl); const [kind, setKind] = React.useState(provider.kind); @@ -113,10 +120,17 @@ export function ProviderEditor({ provider, open, onOpenChange, onSaved }: Provid const applyDiscoveredModels = ( list: string[], metadata: Record, + recommendedModel?: string, ) => { setModels(list); setModelMetadata(metadata); - setDefaultModel((current) => (list.includes(current) ? current : (list[0] ?? ""))); + setDefaultModel((current) => + list.includes(current) + ? current + : recommendedModel && list.includes(recommendedModel) + ? recommendedModel + : (list[0] ?? ""), + ); setModelsStale(false); }; @@ -132,7 +146,7 @@ export function ProviderEditor({ provider, open, onOpenChange, onSaved }: Provid setTesting(true); try { const result = await providersApi.test(buildDraft(), keyDraft.trim() || undefined); - applyDiscoveredModels(result.models, result.modelMetadata); + applyDiscoveredModels(result.models, result.modelMetadata, result.recommendedModel); if (result.models.length > 0) { setConnectionNotice({ message: `${result.modelCount} model${result.modelCount === 1 ? "" : "s"} found. Save to use them.`, @@ -190,6 +204,7 @@ export function ProviderEditor({ provider, open, onOpenChange, onSaved }: Provid confirmLabel="Save" confirmDisabled={saving || testing} onConfirm={handleSave} + returnFocus={returnFocus} >
From 6aed86ffce3c56e0a256e1f1f7915bba458cb5f1 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Mon, 10 Aug 2026 16:51:20 -0400 Subject: [PATCH 12/62] Bind provider editor focus triggers --- .../components/settings/providers-settings.tsx | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/renderer/components/settings/providers-settings.tsx b/renderer/components/settings/providers-settings.tsx index 597227e9..910c89c5 100644 --- a/renderer/components/settings/providers-settings.tsx +++ b/renderer/components/settings/providers-settings.tsx @@ -25,6 +25,7 @@ import { import { ChevronDown, Plus, RefreshCw, Trash2 } from "lucide-react"; import { ProviderIcon } from "../provider-icon"; import { ProviderEditor } from "./provider-editor"; +import { ProviderEditorFocusTarget } from "./provider-editor-focus"; import { BuiltinProviderEditor } from "./builtin-provider-editor"; import { CodexProviderSettings } from "./codex-provider-settings"; import { providersApi, settingsApi, titleProvidersApi } from "../../lib/ipc"; @@ -112,6 +113,8 @@ export function ProvidersSettings() { const settings = useSettings(); const foundationModels = useFoundationModelsConnection(); const [editing, setEditing] = React.useState(null); + const editingFocusTarget = React.useRef(new ProviderEditorFocusTarget()); + const addProviderTriggerRef = React.useRef(null); const [settingUp, setSettingUp] = React.useState(null); const [removing, setRemoving] = React.useState(null); const [savingTitleProvider, setSavingTitleProvider] = React.useState(false); @@ -170,6 +173,7 @@ export function ProvidersSettings() { }; const addCustom = (template: "lmstudio" | "ollama" | "custom" | "tailnet") => { + editingFocusTarget.current.capture(addProviderTriggerRef.current); const id = template === "lmstudio" || template === "ollama" ? `custom:${template}` @@ -235,7 +239,7 @@ export function ProvidersSettings() { - ))} From 0436756f06c47d4e53afa93654b8a431426f5485 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Mon, 10 Aug 2026 16:51:20 -0400 Subject: [PATCH 18/62] Allow fixed attachment preload channels --- renderer/preload-channels.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/renderer/preload-channels.ts b/renderer/preload-channels.ts index 99137c4c..a72dd04d 100644 --- a/renderer/preload-channels.ts +++ b/renderer/preload-channels.ts @@ -37,6 +37,8 @@ export const INVOKE_PREFIXES = [ export const NATIVE_INVOKE_CHANNELS = { accessibilityRequest: "aiden:accessibility:request", accessibilityStatus: "aiden:accessibility:status", + attachmentDroppedRead: "aiden:attachments:dropped-read", + attachmentClipboardRead: "aiden:attachments:clipboard-read", dialogOpen: "aiden:dialog:open", themeGet: "aiden:theme:get", themeSet: "aiden:theme:set", From 75422e7606d6b8bed9f424cbcdd904c9eda9460f Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Mon, 10 Aug 2026 16:51:21 -0400 Subject: [PATCH 19/62] Add fixed-purpose attachment preload bridge --- renderer/preload-attachments.ts | 134 ++++++++++++++++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 renderer/preload-attachments.ts diff --git a/renderer/preload-attachments.ts b/renderer/preload-attachments.ts new file mode 100644 index 00000000..f08c3f24 --- /dev/null +++ b/renderer/preload-attachments.ts @@ -0,0 +1,134 @@ +// Fixed-purpose attachment bridge. Unlike the generic renderer IPC surface, +// none of these methods accepts a renderer-authored filesystem path. + +import type { Attachment } from "./lib/types.js"; +import { NATIVE_INVOKE_CHANNELS } from "./preload-channels.js"; + +export const PRELOAD_MAX_ATTACHMENT_PATHS = 20; +export const PRELOAD_MAX_CLIPBOARD_IMAGES = 20; +export const PRELOAD_MAX_IMAGE_BYTES = 8 * 1024 * 1024; +export const PRELOAD_MAX_ATTACHMENT_BYTES = 16 * 1024 * 1024; + +const CLIPBOARD_IMAGE_MIME_TYPES = new Set([ + "image/png", + "image/jpeg", + "image/gif", + "image/webp", + "image/bmp", + "image/heic", + "image/heif", +]); + +export interface PreloadClipboardImage { + mimeType: string; + bytes: Uint8Array; +} + +interface AttachmentBridgeDependencies { + invoke(channel: string, ...args: unknown[]): Promise; + getPathForFile(file: File): string; +} + +export interface AttachmentPreloadBridge { + readDroppedFiles( + files: readonly File[], + remainingSlots: number, + includeImages: boolean, + remainingInlineBytes: number, + ): Promise; + readClipboardImages( + images: readonly PreloadClipboardImage[], + remainingSlots: number, + remainingInlineBytes: number, + ): Promise; +} + +function invalidClipboardPayload(): Error { + return new Error("Invalid clipboard image payload."); +} + +function validateRemainingLimits(remainingSlots: number, remainingInlineBytes: number): void { + if ( + !Number.isSafeInteger(remainingSlots) || + remainingSlots < 1 || + remainingSlots > PRELOAD_MAX_ATTACHMENT_PATHS || + !Number.isSafeInteger(remainingInlineBytes) || + remainingInlineBytes < 1 || + remainingInlineBytes > PRELOAD_MAX_ATTACHMENT_BYTES + ) { + throw new Error("Invalid attachment limits."); + } +} + +/** Build the context-isolated bridge; dependency injection keeps its trust contract testable. */ +export function createAttachmentPreloadBridge( + dependencies: AttachmentBridgeDependencies, +): AttachmentPreloadBridge { + return { + readDroppedFiles: async (files, remainingSlots, includeImages, remainingInlineBytes) => { + validateRemainingLimits(remainingSlots, remainingInlineBytes); + if ( + !Array.isArray(files) || + files.length > PRELOAD_MAX_ATTACHMENT_PATHS || + typeof includeImages !== "boolean" + ) { + throw new Error("Invalid dropped file selection."); + } + const paths: string[] = []; + for (const file of files) { + try { + const resolved = dependencies.getPathForFile(file); + if (resolved) paths.push(resolved); + } catch { + // Synthetic renderer File objects and arbitrary non-File values have + // no trusted OS path and are intentionally ignored. + } + } + const uniquePaths = [...new Set(paths)].slice(0, remainingSlots); + if (uniquePaths.length === 0) return []; + return dependencies.invoke( + NATIVE_INVOKE_CHANNELS.attachmentDroppedRead, + uniquePaths, + remainingSlots, + includeImages, + remainingInlineBytes, + ); + }, + + readClipboardImages: async (images, remainingSlots, remainingInlineBytes) => { + validateRemainingLimits(remainingSlots, remainingInlineBytes); + if ( + !Array.isArray(images) || + images.length === 0 || + images.length > PRELOAD_MAX_CLIPBOARD_IMAGES || + images.length > remainingSlots + ) { + throw invalidClipboardPayload(); + } + let totalBytes = 0; + const parsed = images.map((entry): PreloadClipboardImage => { + if ( + !entry || + typeof entry !== "object" || + !CLIPBOARD_IMAGE_MIME_TYPES.has(entry.mimeType) || + !(entry.bytes instanceof Uint8Array) || + entry.bytes.byteLength === 0 || + entry.bytes.byteLength > PRELOAD_MAX_IMAGE_BYTES + ) { + throw invalidClipboardPayload(); + } + totalBytes += entry.bytes.byteLength; + if (totalBytes > remainingInlineBytes) { + throw new Error("Clipboard images exceed the aggregate byte limit."); + } + return { mimeType: entry.mimeType, bytes: entry.bytes }; + }); + return dependencies.invoke( + NATIVE_INVOKE_CHANNELS.attachmentClipboardRead, + parsed, + remainingSlots, + remainingInlineBytes, + ); + }, + }; +} From b1fe35a17b30819b1ddb7752afaaa343a2fcca88 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Mon, 10 Aug 2026 16:51:21 -0400 Subject: [PATCH 20/62] Expose trusted attachment ingestion methods --- renderer/preload.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/renderer/preload.ts b/renderer/preload.ts index 8fdb9726..e40bea06 100644 --- a/renderer/preload.ts +++ b/renderer/preload.ts @@ -1,10 +1,11 @@ -import { contextBridge, ipcRenderer } from "electron"; +import { contextBridge, ipcRenderer, webUtils } from "electron"; import type { OpenDialogOptions, OpenDialogReturnValue } from "electron"; import { INVOKE_PREFIXES, NATIVE_INVOKE_CHANNELS, NOTIFICATION_CHANNELS, } from "./preload-channels.js"; +import { createAttachmentPreloadBridge } from "./preload-attachments.js"; // Re-exported so the contract test can assert coverage without importing this // Electron-bound module. @@ -49,6 +50,11 @@ const aidenAPI = { options, ) as Promise, }, + attachments: createAttachmentPreloadBridge({ + invoke: (channel: string, ...args: unknown[]) => + ipcRenderer.invoke(channel, ...args) as Promise, + getPathForFile: (file: File) => webUtils.getPathForFile(file), + }), nativeTheme: { getInfo: (): Promise => ipcRenderer.invoke(NATIVE_INVOKE_CHANNELS.themeGet) as Promise, From 4585073a2074d04351a6435a1a06d43ced9c36d8 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Mon, 10 Aug 2026 16:51:21 -0400 Subject: [PATCH 21/62] Add renderer attachment bridge contracts --- renderer/lib/ipc.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/renderer/lib/ipc.ts b/renderer/lib/ipc.ts index 8725764e..1d044bed 100644 --- a/renderer/lib/ipc.ts +++ b/renderer/lib/ipc.ts @@ -171,6 +171,7 @@ export const providersApi = { modelCount: number; models: string[]; modelMetadata: Record; + recommendedModel?: string; }>("providers:test", provider, keyOverride), listModels: (provider: Omit, keyOverride?: string) => invoke("providers:listModels", provider, keyOverride), @@ -402,6 +403,28 @@ export const attachmentsApi = { includeImages, remainingInlineBytes, ), + readDroppedFiles: ( + files: readonly File[], + remainingSlots: number, + includeImages: boolean, + remainingInlineBytes: number, + ) => + window.aidenAPI.attachments.readDroppedFiles( + files, + remainingSlots, + includeImages, + remainingInlineBytes, + ), + readClipboardImages: ( + images: Array<{ mimeType: string; bytes: Uint8Array }>, + remainingSlots: number, + remainingInlineBytes: number, + ) => + window.aidenAPI.attachments.readClipboardImages( + images, + remainingSlots, + remainingInlineBytes, + ), }; export const modelsApi = { From 5b56b1294bc4db3e185aeeeab0c9deb9fbfe15e7 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Mon, 10 Aug 2026 16:51:21 -0400 Subject: [PATCH 22/62] Enforce authoritative attachment contracts --- main/services/attachment-contract.ts | 68 +++++++++++++++++++++++----- 1 file changed, 56 insertions(+), 12 deletions(-) diff --git a/main/services/attachment-contract.ts b/main/services/attachment-contract.ts index bf1ab395..d81533c7 100644 --- a/main/services/attachment-contract.ts +++ b/main/services/attachment-contract.ts @@ -1,5 +1,10 @@ import type { Attachment } from "./types.js"; -import { MAX_IMAGE_BYTES, MAX_TEXT_CHARS } from "./attachments.js"; +import { + imageBytesMatchMime, + isCanonicalRasterImageMimeType, + MAX_IMAGE_BYTES, + MAX_TEXT_CHARS, +} from "./attachments.js"; import { MAX_ATTACHMENT_INLINE_BYTES, MAX_ATTACHMENTS_PER_MESSAGE, @@ -11,6 +16,11 @@ const MAX_MIME_TYPE_CHARS = 128; const MAX_LEGACY_TEXT_CHARS = MAX_TEXT_CHARS + "\n… [truncated]".length; const MAX_IMAGE_BASE64_CHARS = Math.ceil(MAX_IMAGE_BYTES / 3) * 4; const MAX_LEGACY_ATTACHMENT_INLINE_BYTES = MAX_ATTACHMENTS_PER_MESSAGE * MAX_IMAGE_BYTES; +const MAX_IMAGE_SIGNATURE_BYTES = 4096; +const IMAGE_ATTACHMENT_KEYS = new Set(["id", "name", "mimeType", "kind", "size", "data"]); +const TEXT_ATTACHMENT_KEYS = new Set(["id", "name", "mimeType", "kind", "size", "text"]); + +type AttachmentParseMode = "append" | "stored"; function base64DecodedBytes(value: string): number | undefined { if (value.length === 0 || value.length % 4 !== 0) return undefined; @@ -55,11 +65,33 @@ function boundedString( return value; } -function parseAttachment(value: unknown, index: number): Attachment { +function hasExactKeys(value: Record, expected: ReadonlySet): boolean { + let count = 0; + for (const key in value) { + if (!Object.prototype.hasOwnProperty.call(value, key)) continue; + count += 1; + if (count > expected.size || !expected.has(key)) return false; + } + return count === expected.size; +} + +function decodedBase64Prefix(value: string): Buffer { + const encodedChars = Math.min(value.length, Math.ceil(MAX_IMAGE_SIGNATURE_BYTES / 3) * 4); + return Buffer.from(value.slice(0, encodedChars), "base64"); +} + +function parseAttachment(value: unknown, index: number, mode: AttachmentParseMode): Attachment { if (!value || typeof value !== "object" || Array.isArray(value)) { throw new Error(`Invalid attachment at index ${index}.`); } const attachment = value as Record; + if ( + mode === "append" && + ((attachment.kind === "text" && !hasExactKeys(attachment, TEXT_ATTACHMENT_KEYS)) || + (attachment.kind === "image" && !hasExactKeys(attachment, IMAGE_ATTACHMENT_KEYS))) + ) { + throw new Error(`Invalid attachment fields at index ${index}.`); + } const id = boundedString(attachment.id, "id", MAX_ATTACHMENT_ID_CHARS); const name = boundedString(attachment.name, "name", MAX_ATTACHMENT_NAME_CHARS); const mimeType = boundedString(attachment.mimeType, "mimeType", MAX_MIME_TYPE_CHARS); @@ -73,12 +105,15 @@ function parseAttachment(value: unknown, index: number): Attachment { return { id, name, mimeType, kind: "text", size, text }; } if (attachment.kind === "image") { + const data = attachment.data; const decodedBytes = - typeof attachment.data === "string" && attachment.data.length <= MAX_IMAGE_BASE64_CHARS - ? base64DecodedBytes(attachment.data) + typeof data === "string" && data.length <= MAX_IMAGE_BASE64_CHARS + ? base64DecodedBytes(data) : undefined; if ( - !mimeType.startsWith("image/") || + (mode === "append" + ? !isCanonicalRasterImageMimeType(mimeType) + : !mimeType.startsWith("image/")) || decodedBytes === undefined ) { throw new Error("Invalid image attachment data."); @@ -86,7 +121,14 @@ function parseAttachment(value: unknown, index: number): Attachment { if (decodedBytes > MAX_IMAGE_BYTES || decodedBytes !== size) { throw new Error("Invalid image attachment size."); } - return { id, name, mimeType, kind: "image", size, data: attachment.data as string }; + if ( + mode === "append" && + isCanonicalRasterImageMimeType(mimeType) && + !imageBytesMatchMime(decodedBase64Prefix(data as string), mimeType) + ) { + throw new Error("Image attachment bytes do not match the declared image type."); + } + return { id, name, mimeType, kind: "image", size, data: data as string }; } throw new Error("Invalid attachment kind."); } @@ -94,6 +136,7 @@ function parseAttachment(value: unknown, index: number): Attachment { function parseAttachmentsWithLimit( value: unknown, aggregateLimit: number, + mode: AttachmentParseMode, ): Attachment[] | undefined { if (value === undefined) return undefined; if (!Array.isArray(value) || value.length > MAX_ATTACHMENTS_PER_MESSAGE) { @@ -112,7 +155,7 @@ function parseAttachmentsWithLimit( const parsed: Attachment[] = []; let inlineBytes = 0; for (let index = 0; index < value.length; index += 1) { - const attachment = parseAttachment(value[index], index); + const attachment = parseAttachment(value[index], index, mode); inlineBytes += attachment.kind === "image" ? attachment.size @@ -126,15 +169,16 @@ function parseAttachmentsWithLimit( } export function parseAttachments(value: unknown): Attachment[] | undefined { - return parseAttachmentsWithLimit(value, MAX_ATTACHMENT_INLINE_BYTES); + return parseAttachmentsWithLimit(value, MAX_ATTACHMENT_INLINE_BYTES, "append"); } export function safeStoredAttachments(value: unknown): Attachment[] | undefined { try { - // Histories created before aggregate admission shipped may contain up to - // twenty individually valid 8 MiB images. Preserve those bytes on reads - // and unrelated rewrites; only new renderer appends use the stricter cap. - return parseAttachmentsWithLimit(value, MAX_LEGACY_ATTACHMENT_INLINE_BYTES); + // Histories created before aggregate and raster admission shipped may use + // the former image envelope or contain up to twenty individually valid + // 8 MiB images. Preserve those bytes on reads and unrelated rewrites; only + // new renderer appends use the exact raster contract and stricter cap. + return parseAttachmentsWithLimit(value, MAX_LEGACY_ATTACHMENT_INLINE_BYTES, "stored"); } catch { return undefined; } From 4ee823b6289419c13b6be832fff19077acd0ca0d Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Mon, 10 Aug 2026 16:51:21 -0400 Subject: [PATCH 23/62] Cover strict raster attachment parsing --- main/services/attachment-contract.test.ts | 92 +++++++++++++++++++++-- 1 file changed, 84 insertions(+), 8 deletions(-) diff --git a/main/services/attachment-contract.test.ts b/main/services/attachment-contract.test.ts index 902992bd..7613587b 100644 --- a/main/services/attachment-contract.test.ts +++ b/main/services/attachment-contract.test.ts @@ -4,6 +4,19 @@ import { MAX_ATTACHMENT_INLINE_BYTES } from "../../renderer/shared/attachment-co import { parseAttachments, safeStoredAttachments } from "./attachment-contract.js"; import { MAX_IMAGE_BYTES } from "./attachments.js"; +const ONE_PIXEL_PNG = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4z8DwHwAFgAI/ScL2aQAAAABJRU5ErkJggg==", + "base64", +); +const ONE_PIXEL_GIF = Buffer.from("R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==", "base64"); + +function paddedPngData(size: number): string { + assert.ok(size >= ONE_PIXEL_PNG.byteLength); + const bytes = Buffer.alloc(size); + ONE_PIXEL_PNG.copy(bytes); + return bytes.toString("base64"); +} + function image(data: string, id = "image") { return { id, @@ -16,7 +29,7 @@ function image(data: string, id = "image") { } test("attachment parsing accepts the exact image cap without recursive Base64 matching", () => { - const data = Buffer.alloc(MAX_IMAGE_BYTES).toString("base64"); + const data = paddedPngData(MAX_IMAGE_BYTES); const parsed = parseAttachments([image(data)]); assert.equal(parsed?.[0].size, MAX_IMAGE_BYTES); }); @@ -28,26 +41,89 @@ test("attachment parsing rejects malformed and non-canonical Base64 padding", () }); test("attachment parsing enforces one aggregate inline-data budget per message", () => { - const half = Buffer.alloc(MAX_ATTACHMENT_INLINE_BYTES / 2).toString("base64"); + const half = paddedPngData(MAX_ATTACHMENT_INLINE_BYTES / 2); assert.equal(parseAttachments([image(half, "one"), image(half, "two")])?.length, 2); assert.throws( - () => parseAttachments([image(half, "one"), image(half, "two"), image("AA==", "extra")]), + () => + parseAttachments([ + image(half, "one"), + image(half, "two"), + image(ONE_PIXEL_PNG.toString("base64"), "extra"), + ]), /aggregate inline-data limit/, ); - const oneMiB = Buffer.alloc(1024 * 1024).toString("base64"); + const oneMiB = paddedPngData(1024 * 1024); assert.throws( () => - parseAttachments( - Array.from({ length: 20 }, (_, index) => image(oneMiB, `image-${index}`)), - ), + parseAttachments(Array.from({ length: 20 }, (_, index) => image(oneMiB, `image-${index}`))), /aggregate inline-data limit/, ); }); test("legacy-valid large attachment history survives sanitization for unrelated rewrites", () => { - const data = Buffer.alloc(MAX_IMAGE_BYTES).toString("base64"); + const data = paddedPngData(MAX_IMAGE_BYTES); const legacy = [image(data, "one"), image(data, "two"), image(data, "three")]; assert.throws(() => parseAttachments(legacy), /aggregate inline-data limit/); assert.equal(safeStoredAttachments(legacy)?.length, 3); }); + +test("new append parsing requires exact kind-specific fields", () => { + const raster = image(ONE_PIXEL_PNG.toString("base64")); + assert.throws( + () => parseAttachments([{ ...raster, text: "unexpected" }]), + /Invalid attachment fields/u, + ); + assert.throws( + () => + parseAttachments([ + { + id: "text", + name: "note.txt", + mimeType: "text/plain", + kind: "text", + size: 4, + text: "note", + data: "unexpected", + }, + ]), + /Invalid attachment fields/u, + ); +}); + +test("new append parsing rejects SVG and raster MIME-byte mismatches", () => { + const svgData = Buffer.from('').toString("base64"); + assert.throws( + () => + parseAttachments([ + { + id: "svg", + name: "payload.svg", + mimeType: "image/svg+xml", + kind: "image", + size: Buffer.byteLength(svgData, "base64"), + data: svgData, + }, + ]), + /Invalid image attachment data/u, + ); + assert.throws( + () => parseAttachments([image(ONE_PIXEL_GIF.toString("base64"), "mismatch")]), + /do not match the declared image type/u, + ); +}); + +test("stored attachment sanitization keeps the former image envelope compatibility separate", () => { + const svgData = Buffer.from('').toString("base64"); + const legacy = { + id: "legacy-svg", + name: "legacy.svg", + mimeType: "image/svg+xml", + kind: "image", + size: Buffer.byteLength(svgData, "base64"), + data: svgData, + formerOptionalMetadata: true, + }; + assert.throws(() => parseAttachments([legacy]), /Invalid attachment fields/u); + assert.equal(safeStoredAttachments([legacy])?.[0]?.mimeType, "image/svg+xml"); +}); From ace71fa66c95f125c211e754a3d415ee33fc0ea4 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Mon, 10 Aug 2026 16:51:21 -0400 Subject: [PATCH 24/62] Harden attachment ingestion and admission --- main/services/attachments.ts | 348 ++++++++++++++++++++++++++++++++++- 1 file changed, 347 insertions(+), 1 deletion(-) diff --git a/main/services/attachments.ts b/main/services/attachments.ts index b7239dac..40b2832d 100644 --- a/main/services/attachments.ts +++ b/main/services/attachments.ts @@ -15,8 +15,233 @@ export const MAX_IMAGE_BYTES = 8 * 1024 * 1024; // 8 MB export const MAX_TEXT_CHARS = 100_000; export const MAX_TEXT_READ_BYTES = MAX_TEXT_CHARS * 4; export const MAX_ATTACHMENT_BATCH_BYTES = MAX_ATTACHMENT_INLINE_BYTES; +export const MAX_CLIPBOARD_IMAGES = MAX_ATTACHMENTS_PER_MESSAGE; +const ATTACHMENT_REPRESENTATION_OVERHEAD_BYTES = 1024; +export const MAX_ATTACHMENT_INGESTION_REPRESENTATION_BYTES = + Math.ceil(MAX_ATTACHMENT_BATCH_BYTES / 3) * 4 + + MAX_ATTACHMENTS_PER_MESSAGE * ATTACHMENT_REPRESENTATION_OVERHEAD_BYTES; const TEXT_TRUNCATION_SUFFIX = "\n… [truncated]"; +export const CANONICAL_RASTER_IMAGE_MIME_TYPES = [ + "image/png", + "image/jpeg", + "image/gif", + "image/webp", + "image/bmp", + "image/heic", + "image/heif", +] as const; + +export type CanonicalRasterImageMimeType = (typeof CANONICAL_RASTER_IMAGE_MIME_TYPES)[number]; + +const CANONICAL_RASTER_IMAGE_MIME_TYPE_SET = new Set(CANONICAL_RASTER_IMAGE_MIME_TYPES); + +export function isCanonicalRasterImageMimeType( + value: unknown, +): value is CanonicalRasterImageMimeType { + return typeof value === "string" && CANONICAL_RASTER_IMAGE_MIME_TYPE_SET.has(value); +} + +function ascii(bytes: Uint8Array, offset: number, length: number): string { + if (offset < 0 || length < 0 || offset + length > bytes.byteLength) return ""; + return String.fromCharCode(...bytes.subarray(offset, offset + length)); +} + +function isoBaseMediaBrands(bytes: Uint8Array): Set { + if (bytes.byteLength < 12 || ascii(bytes, 4, 4) !== "ftyp") return new Set(); + const brands = new Set([ascii(bytes, 8, 4)]); + const declaredBoxBytes = + bytes[0]! * 0x1000000 + bytes[1]! * 0x10000 + bytes[2]! * 0x100 + bytes[3]!; + const availableBoxBytes = Math.min( + bytes.byteLength, + declaredBoxBytes >= 16 ? declaredBoxBytes : bytes.byteLength, + ); + for (let offset = 16; offset + 4 <= availableBoxBytes; offset += 4) { + brands.add(ascii(bytes, offset, 4)); + } + return brands; +} + +/** Match the canonical raster MIME to the bytes that main will retain and later generate with. */ +export function imageBytesMatchMime( + bytes: Uint8Array, + mimeType: CanonicalRasterImageMimeType, +): boolean { + switch (mimeType) { + case "image/png": + return ( + bytes.byteLength >= 8 && + bytes[0] === 0x89 && + bytes[1] === 0x50 && + bytes[2] === 0x4e && + bytes[3] === 0x47 && + bytes[4] === 0x0d && + bytes[5] === 0x0a && + bytes[6] === 0x1a && + bytes[7] === 0x0a + ); + case "image/jpeg": + return bytes.byteLength >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff; + case "image/gif": + return ascii(bytes, 0, 6) === "GIF87a" || ascii(bytes, 0, 6) === "GIF89a"; + case "image/webp": + return ascii(bytes, 0, 4) === "RIFF" && ascii(bytes, 8, 4) === "WEBP"; + case "image/bmp": + return bytes.byteLength >= 2 && bytes[0] === 0x42 && bytes[1] === 0x4d; + case "image/heic": { + const brands = isoBaseMediaBrands(bytes); + return ["heic", "heix", "hevc", "hevx", "heim", "heis"].some((brand) => brands.has(brand)); + } + case "image/heif": { + const brands = isoBaseMediaBrands(bytes); + return ["mif1", "msf1", "heif"].some((brand) => brands.has(brand)); + } + } +} + +export function attachmentIngestionRepresentationBytes( + inlineBytes: number, + attachmentCount: number, +): number { + if ( + !Number.isSafeInteger(inlineBytes) || + inlineBytes < 1 || + inlineBytes > MAX_ATTACHMENT_BATCH_BYTES || + !Number.isSafeInteger(attachmentCount) || + attachmentCount < 1 || + attachmentCount > MAX_ATTACHMENTS_PER_MESSAGE + ) { + throw new Error("Invalid attachment ingestion reservation."); + } + return ( + Math.ceil(inlineBytes / 3) * 4 + attachmentCount * ATTACHMENT_REPRESENTATION_OVERHEAD_BYTES + ); +} + +export interface AttachmentIngestionAdmissionOptions { + maxActivePerDocument?: number; + maxGlobalActive?: number; + maxGlobalAttachments?: number; + maxGlobalRepresentationBytes?: number; +} + +export interface AttachmentIngestionLease { + isActive(): boolean; + cancel(): void; + release(): void; +} + +interface AttachmentIngestionRecord { + documentId: string; + attachmentCount: number; + representationBytes: number; + cancelled: boolean; +} + +function positiveSafeInteger(value: number, name: string): number { + if (!Number.isSafeInteger(value) || value < 1) { + throw new Error(`Invalid attachment admission ${name}.`); + } + return value; +} + +function hasExactObjectKeys( + value: Record, + expected: ReadonlySet, +): boolean { + let count = 0; + for (const key in value) { + if (!Object.prototype.hasOwnProperty.call(value, key)) continue; + count += 1; + if (count > expected.size || !expected.has(key)) return false; + } + return count === expected.size; +} + +/** Process-owned accounting for renderer-triggered picker, drop, and clipboard ingestion. */ +export class AttachmentIngestionAdmission { + private readonly records = new Set(); + private readonly activeByDocument = new Map(); + private readonly maxActivePerDocument: number; + private readonly maxGlobalActive: number; + private readonly maxGlobalAttachments: number; + private readonly maxGlobalRepresentationBytes: number; + private activeAttachments = 0; + private activeRepresentationBytes = 0; + + constructor(options: AttachmentIngestionAdmissionOptions = {}) { + this.maxActivePerDocument = positiveSafeInteger( + options.maxActivePerDocument ?? 1, + "per-document limit", + ); + this.maxGlobalActive = positiveSafeInteger(options.maxGlobalActive ?? 2, "global limit"); + this.maxGlobalAttachments = positiveSafeInteger( + options.maxGlobalAttachments ?? MAX_ATTACHMENTS_PER_MESSAGE * 2, + "attachment limit", + ); + this.maxGlobalRepresentationBytes = positiveSafeInteger( + options.maxGlobalRepresentationBytes ?? MAX_ATTACHMENT_INGESTION_REPRESENTATION_BYTES * 2, + "representation limit", + ); + } + + acquire( + documentId: string, + attachmentCount: number, + representationBytes: number, + ): AttachmentIngestionLease { + if ( + typeof documentId !== "string" || + documentId.length === 0 || + !Number.isSafeInteger(attachmentCount) || + attachmentCount < 1 || + attachmentCount > MAX_ATTACHMENTS_PER_MESSAGE || + !Number.isSafeInteger(representationBytes) || + representationBytes < 1 || + representationBytes > MAX_ATTACHMENT_INGESTION_REPRESENTATION_BYTES + ) { + throw new Error("Invalid attachment ingestion reservation."); + } + if ((this.activeByDocument.get(documentId) ?? 0) >= this.maxActivePerDocument) { + throw new Error("Another attachment request is already running for this window."); + } + if ( + this.records.size >= this.maxGlobalActive || + attachmentCount > this.maxGlobalAttachments - this.activeAttachments || + representationBytes > this.maxGlobalRepresentationBytes - this.activeRepresentationBytes + ) { + throw new Error("Too many attachment requests are in progress. Try again in a moment."); + } + + const record: AttachmentIngestionRecord = { + documentId, + attachmentCount, + representationBytes, + cancelled: false, + }; + this.records.add(record); + this.activeByDocument.set(documentId, (this.activeByDocument.get(documentId) ?? 0) + 1); + this.activeAttachments += attachmentCount; + this.activeRepresentationBytes += representationBytes; + + const release = (): void => { + if (!this.records.delete(record)) return; + const remainingForDocument = (this.activeByDocument.get(documentId) ?? 1) - 1; + if (remainingForDocument === 0) this.activeByDocument.delete(documentId); + else this.activeByDocument.set(documentId, remainingForDocument); + this.activeAttachments -= attachmentCount; + this.activeRepresentationBytes -= representationBytes; + }; + return { + isActive: () => this.records.has(record) && !record.cancelled, + cancel: () => { + if (this.records.has(record)) record.cancelled = true; + }, + release, + }; + } +} + interface PathIdentityEntry { path: string; dev: number; @@ -43,7 +268,7 @@ const FIXED_SYSTEM_ALIASES = new Map([ ["/var", "/private/var"], ]); -const IMAGE_MIME: Record = { +const IMAGE_MIME: Record = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", @@ -54,6 +279,16 @@ const IMAGE_MIME: Record = { heif: "image/heif", }; +const CLIPBOARD_IMAGE_NAME: Record = { + "image/png": "Pasted image.png", + "image/jpeg": "Pasted image.jpg", + "image/gif": "Pasted image.gif", + "image/webp": "Pasted image.webp", + "image/bmp": "Pasted image.bmp", + "image/heic": "Pasted image.heic", + "image/heif": "Pasted image.heif", +}; + export function isImageAttachmentPath(filePath: string): boolean { return Boolean(IMAGE_MIME[path.extname(filePath).slice(1).toLowerCase()]); } @@ -192,6 +427,7 @@ async function assertPickedPathIdentity( async function readOne( filePath: string, remainingBatchBytes: number, + isActive: () => boolean, afterLexicalCapture?: (filePath: string) => void | Promise, beforeOpen?: (filePath: string) => void | Promise, beforeConsistencyCheck?: (filePath: string) => void | Promise, @@ -205,6 +441,7 @@ async function readOne( } catch { throw new Error(`${name || "The selected file"} couldn't be read safely.`); } + if (!isActive()) throw new Error("The renderer document is no longer active."); let handle: fs.FileHandle; try { handle = await fs.open( @@ -218,6 +455,7 @@ async function readOne( const stat = await handle.stat(); if (!stat.isFile()) throw new Error(`${name || "The selected file"} isn't a regular file.`); await assertPickedPathIdentity(identity, stat); + if (!isActive()) throw new Error("The renderer document is no longer active."); const imageMime = IMAGE_MIME[ext]; if (imageMime) { @@ -234,9 +472,14 @@ async function readOne( if (buf.length !== stat.size) { throw new Error(`${name} changed while it was being attached. Please select it again.`); } + if (!isActive()) throw new Error("The renderer document is no longer active."); await beforeConsistencyCheck?.(filePath); await assertFileUnchanged(handle, stat, name); await assertPickedPathIdentity(identity, stat); + if (!isActive()) throw new Error("The renderer document is no longer active."); + if (!imageBytesMatchMime(buf, imageMime)) { + throw new Error(`${name} doesn't match its image file type.`); + } return { attachment: { id: newId(), @@ -260,9 +503,11 @@ async function readOne( if (buf.length !== Math.min(stat.size, textReadLimit)) { throw new Error(`${name} changed while it was being attached. Please select it again.`); } + if (!isActive()) throw new Error("The renderer document is no longer active."); await beforeConsistencyCheck?.(filePath); await assertFileUnchanged(handle, stat, name); await assertPickedPathIdentity(identity, stat); + if (!isActive()) throw new Error("The renderer document is no longer active."); if (buf.includes(0)) { throw new Error(`${name} isn't a supported text or image file.`); } @@ -333,6 +578,7 @@ export async function readPickedAttachments( const result = await readOne( filePath, maxBatchBytes - bytesRead, + isActive, options.afterLexicalCapture, options.beforeOpen, options.beforeConsistencyCheck, @@ -343,3 +589,103 @@ export async function readPickedAttachments( if (!isActive()) throw new Error("The renderer document is no longer active."); return attachments; } + +interface ValidatedClipboardImage { + mimeType: CanonicalRasterImageMimeType; + bytes: Uint8Array; +} + +const CLIPBOARD_IMAGE_KEYS = new Set(["mimeType", "bytes"]); + +export interface ValidatedClipboardAttachmentPayload { + images: readonly ValidatedClipboardImage[]; + inlineBytes: number; + representationBytes: number; +} + +/** Validate renderer-cloned clipboard metadata before reserving conversion capacity. */ +export function validateClipboardAttachmentPayload( + value: unknown, + remainingSlots: unknown, + remainingInlineBytes: unknown, +): ValidatedClipboardAttachmentPayload { + if ( + !Number.isSafeInteger(remainingSlots) || + (remainingSlots as number) < 1 || + (remainingSlots as number) > MAX_CLIPBOARD_IMAGES || + !Number.isSafeInteger(remainingInlineBytes) || + (remainingInlineBytes as number) < 1 || + (remainingInlineBytes as number) > MAX_ATTACHMENT_BATCH_BYTES || + !Array.isArray(value) || + value.length === 0 || + value.length > (remainingSlots as number) + ) { + throw new Error("Invalid clipboard image payload."); + } + + const images: ValidatedClipboardImage[] = []; + let totalBytes = 0; + for (const candidate of value) { + if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) { + throw new Error("Invalid clipboard image payload."); + } + const record = candidate as Record; + if ( + !hasExactObjectKeys(record, CLIPBOARD_IMAGE_KEYS) || + !isCanonicalRasterImageMimeType(record.mimeType) || + !(record.bytes instanceof Uint8Array) || + record.bytes.byteLength === 0 || + record.bytes.byteLength > MAX_IMAGE_BYTES + ) { + throw new Error("Invalid clipboard image payload."); + } + totalBytes += record.bytes.byteLength; + if (totalBytes > (remainingInlineBytes as number)) { + throw new Error("Clipboard images exceed the remaining attachment data limit."); + } + images.push({ mimeType: record.mimeType, bytes: record.bytes }); + } + return { + images, + inlineBytes: totalBytes, + representationBytes: attachmentIngestionRepresentationBytes(totalBytes, images.length), + }; +} + +/** Convert admitted in-memory clipboard images without granting filesystem authority. */ +export function materializeClipboardAttachments( + payload: ValidatedClipboardAttachmentPayload, + isActive: () => boolean = () => true, +): Attachment[] { + const attachments: Attachment[] = []; + for (const image of payload.images) { + if (!isActive()) throw new Error("The renderer document is no longer active."); + const bytes = Buffer.from(image.bytes); + if (bytes.byteLength === 0 || !imageBytesMatchMime(bytes, image.mimeType)) { + throw new Error("Clipboard image bytes do not match the declared image type."); + } + attachments.push({ + id: newId(), + name: CLIPBOARD_IMAGE_NAME[image.mimeType], + mimeType: image.mimeType, + kind: "image", + size: bytes.byteLength, + data: bytes.toString("base64"), + }); + } + if (!isActive()) throw new Error("The renderer document is no longer active."); + return attachments; +} + +/** Validate and convert bounded clipboard images for non-IPC callers and focused tests. */ +export function readClipboardAttachments( + value: unknown, + remainingSlots: unknown, + remainingInlineBytes: unknown, + isActive: () => boolean = () => true, +): Attachment[] { + return materializeClipboardAttachments( + validateClipboardAttachmentPayload(value, remainingSlots, remainingInlineBytes), + isActive, + ); +} From 300ea899b713c1fb8922f84e77903c5f78ca0c1f Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Mon, 10 Aug 2026 16:51:21 -0400 Subject: [PATCH 25/62] Cover bounded attachment ingestion --- main/services/attachments.test.ts | 191 ++++++++++++++++++++++++++++-- 1 file changed, 180 insertions(+), 11 deletions(-) diff --git a/main/services/attachments.test.ts b/main/services/attachments.test.ts index b492690d..a902645d 100644 --- a/main/services/attachments.test.ts +++ b/main/services/attachments.test.ts @@ -4,19 +4,38 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import { + attachmentIngestionRepresentationBytes, + AttachmentIngestionAdmission, + imageBytesMatchMime, + MAX_ATTACHMENT_BATCH_BYTES, + MAX_ATTACHMENT_INGESTION_REPRESENTATION_BYTES, + MAX_CLIPBOARD_IMAGES, + MAX_IMAGE_BYTES, MAX_TEXT_CHARS, MAX_TEXT_READ_BYTES, + readClipboardAttachments, readPickedAttachments, } from "./attachments.js"; import { MAX_ATTACHMENTS_PER_MESSAGE } from "../../renderer/shared/attachment-contract.js"; const temporaryDirectories: string[] = []; +const ONE_PIXEL_PNG_BASE64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4z8DwHwAFgAI/ScL2aQAAAABJRU5ErkJggg=="; +const ONE_PIXEL_PNG = Buffer.from(ONE_PIXEL_PNG_BASE64, "base64"); +const ONE_PIXEL_GIF = Buffer.from("R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==", "base64"); + +function paddedPngBytes(size: number): Buffer { + assert.ok(size >= ONE_PIXEL_PNG.byteLength); + const bytes = Buffer.alloc(size); + ONE_PIXEL_PNG.copy(bytes); + return bytes; +} afterEach(async () => { await Promise.all( - temporaryDirectories.splice(0).map((directory) => - fs.rm(directory, { recursive: true, force: true }), - ), + temporaryDirectories + .splice(0) + .map((directory) => fs.rm(directory, { recursive: true, force: true })), ); }); @@ -55,10 +74,7 @@ test("a bounded text prefix drops only an incomplete trailing UTF-8 code point", test("picked binary-looking text is rejected without returning bytes", async () => { const filePath = await temporaryFile("secret.bin", Buffer.from([97, 0, 98])); - await assert.rejects( - readPickedAttachments([filePath]), - /isn't a supported text or image file/, - ); + await assert.rejects(readPickedAttachments([filePath]), /isn't a supported text or image file/); }); test("empty images are rejected before they can create an unsendable composer chip", async () => { @@ -89,16 +105,18 @@ test("picked attachment reads enforce count and owner lifetime", async () => { }); test("picked attachment reads enforce one aggregate byte budget sequentially", async () => { - const first = await temporaryFile("first.png", Buffer.from([1, 2, 3])); - const second = await temporaryFile("second.png", Buffer.from([4, 5, 6])); + const first = await temporaryFile("first.png", ONE_PIXEL_PNG); + const second = await temporaryFile("second.png", ONE_PIXEL_PNG); await assert.rejects( - readPickedAttachments([first, second], { maxBatchBytes: 5 }), + readPickedAttachments([first, second], { + maxBatchBytes: ONE_PIXEL_PNG.byteLength * 2 - 1, + }), /batch limit/, ); }); test("picked attachment reads reject a file that grows after the bounded read", async () => { - const filePath = await temporaryFile("growing.png", Buffer.from([1, 2, 3])); + const filePath = await temporaryFile("growing.png", ONE_PIXEL_PNG); await assert.rejects( readPickedAttachments([filePath], { beforeConsistencyCheck: async (selectedPath) => { @@ -109,6 +127,11 @@ test("picked attachment reads reject a file that grows after the bounded read", ); }); +test("picked images must match the raster type selected by their extension", async () => { + const filePath = await temporaryFile("mislabeled.png", ONE_PIXEL_GIF); + await assert.rejects(readPickedAttachments([filePath]), /doesn't match its image file type/u); +}); + test("picked attachment reads reject an ancestor redirected before open", async () => { const directory = await fs.mkdtemp(path.join(os.tmpdir(), "aiden-attachment-ancestor-")); temporaryDirectories.push(directory); @@ -184,3 +207,149 @@ test("picked attachment reads reject a picker path already redirected through an await assert.rejects(readPickedAttachments([selectedPath]), /couldn't be read safely/u); }); + +test("clipboard images are bounded, raster-only, and detached from renderer bytes", () => { + const source = new Uint8Array(ONE_PIXEL_PNG); + const [attachment] = readClipboardAttachments( + [{ mimeType: "image/png", bytes: source }], + 1, + MAX_ATTACHMENT_BATCH_BYTES, + ); + assert.deepEqual( + { + name: attachment.name, + mimeType: attachment.mimeType, + kind: attachment.kind, + size: attachment.size, + data: attachment.data, + }, + { + name: "Pasted image.png", + mimeType: "image/png", + kind: "image", + size: ONE_PIXEL_PNG.byteLength, + data: ONE_PIXEL_PNG_BASE64, + }, + ); + source.fill(9); + assert.equal(attachment.data, ONE_PIXEL_PNG_BASE64); + + assert.throws( + () => + readClipboardAttachments( + [{ mimeType: "image/svg+xml", bytes: new Uint8Array([1]) }], + 1, + MAX_ATTACHMENT_BATCH_BYTES, + ), + /Invalid clipboard/u, + ); + assert.throws( + () => + readClipboardAttachments( + Array.from({ length: MAX_CLIPBOARD_IMAGES + 1 }, () => ({ + mimeType: "image/png", + bytes: new Uint8Array(ONE_PIXEL_PNG), + })), + MAX_CLIPBOARD_IMAGES, + MAX_ATTACHMENT_BATCH_BYTES, + ), + /Invalid clipboard/u, + ); + assert.throws( + () => + readClipboardAttachments( + [{ mimeType: "image/png", bytes: new Uint8Array(paddedPngBytes(MAX_IMAGE_BYTES)) }], + 1, + MAX_IMAGE_BYTES - 1, + ), + /remaining attachment data limit/u, + ); + assert.throws( + () => + readClipboardAttachments( + [{ mimeType: "image/png", bytes: new Uint8Array(ONE_PIXEL_GIF) }], + 1, + MAX_ATTACHMENT_BATCH_BYTES, + ), + /do not match the declared image type/u, + ); +}); + +test("canonical raster signatures match only their declared MIME", () => { + const signatures = [ + [ONE_PIXEL_PNG, "image/png"], + [Buffer.from([0xff, 0xd8, 0xff, 0xe0]), "image/jpeg"], + [ONE_PIXEL_GIF, "image/gif"], + [Buffer.from("RIFF\x00\x00\x00\x00WEBP", "binary"), "image/webp"], + [Buffer.from("BM", "ascii"), "image/bmp"], + [Buffer.from("\x00\x00\x00\x18ftypheic\x00\x00\x00\x00heic", "binary"), "image/heic"], + [Buffer.from("\x00\x00\x00\x18ftypmif1\x00\x00\x00\x00mif1", "binary"), "image/heif"], + ] as const; + for (const [bytes, mimeType] of signatures) { + assert.equal(imageBytesMatchMime(bytes, mimeType), true, mimeType); + } + assert.equal(imageBytesMatchMime(ONE_PIXEL_GIF, "image/png"), false); + assert.equal(imageBytesMatchMime(ONE_PIXEL_PNG, "image/jpeg"), false); +}); + +test("attachment admission rejects concurrent work from one document until final release", () => { + const admission = new AttachmentIngestionAdmission(); + const representationBytes = attachmentIngestionRepresentationBytes(1024, 1); + const first = admission.acquire("document-a", 1, representationBytes); + + assert.throws( + () => admission.acquire("document-a", 1, representationBytes), + /already running for this window/u, + ); + first.cancel(); + assert.equal(first.isActive(), false); + assert.throws( + () => admission.acquire("document-a", 1, representationBytes), + /already running for this window/u, + "owner cancellation must not free accounting before the operation's finally block", + ); + + first.release(); + first.release(); + const replacement = admission.acquire("document-a", 1, representationBytes); + assert.equal(replacement.isActive(), true); + replacement.release(); +}); + +test("attachment admission enforces global concurrent count and representation budgets", () => { + const representationBytes = attachmentIngestionRepresentationBytes(1024, 1); + const countAdmission = new AttachmentIngestionAdmission({ + maxGlobalActive: 4, + maxGlobalAttachments: 2, + }); + const first = countAdmission.acquire("document-a", 1, representationBytes); + const second = countAdmission.acquire("document-b", 1, representationBytes); + assert.throws( + () => countAdmission.acquire("document-c", 1, representationBytes), + /Too many attachment requests/u, + ); + first.release(); + const replacement = countAdmission.acquire("document-c", 1, representationBytes); + replacement.release(); + second.release(); + + const byteAdmission = new AttachmentIngestionAdmission({ + maxGlobalActive: 4, + maxGlobalAttachments: 4, + maxGlobalRepresentationBytes: representationBytes * 2 - 1, + }); + const retained = byteAdmission.acquire("document-a", 1, representationBytes); + assert.throws( + () => byteAdmission.acquire("document-b", 1, representationBytes), + /Too many attachment requests/u, + ); + retained.release(); + const afterRelease = byteAdmission.acquire("document-b", 1, representationBytes); + afterRelease.release(); + + assert.throws( + () => + countAdmission.acquire("document-d", 1, MAX_ATTACHMENT_INGESTION_REPRESENTATION_BYTES + 1), + /Invalid attachment ingestion reservation/u, + ); +}); From 14eda8ec8a069e7db39e5c963f2b71b4edc0bc95 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Mon, 10 Aug 2026 16:51:21 -0400 Subject: [PATCH 26/62] Register trusted attachment ingestion handlers --- main/handlers/attachments.ts | 156 +++++++++++++++++++++++++++++------ 1 file changed, 133 insertions(+), 23 deletions(-) diff --git a/main/handlers/attachments.ts b/main/handlers/attachments.ts index f9c3d78f..521f6a80 100644 --- a/main/handlers/attachments.ts +++ b/main/handlers/attachments.ts @@ -1,13 +1,53 @@ // Reading user-attached files + bundled model capability lookups. import { BrowserWindow, dialog, ipcMain } from "../platform.js"; -import { isImageAttachmentPath, readPickedAttachments } from "../services/attachments.js"; +import { + attachmentIngestionRepresentationBytes, + AttachmentIngestionAdmission, + isImageAttachmentPath, + materializeClipboardAttachments, + readPickedAttachments, + validateClipboardAttachmentPayload, +} from "../services/attachments.js"; import { providerModelInfo } from "../services/provider-model-info.js"; import { MAX_ATTACHMENT_INLINE_BYTES, MAX_ATTACHMENTS_PER_MESSAGE, } from "../../renderer/shared/attachment-contract.js"; -import { rendererDocumentOwner } from "../services/renderer-document-owner.js"; +import { + rendererDocumentOwner, + type RendererDocumentOwner, +} from "../services/renderer-document-owner.js"; + +const attachmentIngestionAdmission = new AttachmentIngestionAdmission(); + +async function runOwnedAttachmentIngestion( + owner: RendererDocumentOwner, + attachmentCount: number, + representationBytes: number, + operation: (isActive: () => boolean) => T | Promise, +): Promise { + const lease = attachmentIngestionAdmission.acquire( + owner.documentId, + attachmentCount, + representationBytes, + ); + const isActive = (): boolean => lease.isActive() && !owner.isDestroyed(); + let removeOwnerInvalidation = (): void => undefined; + try { + removeOwnerInvalidation = owner.onInvalidated(lease.cancel); + if (!isActive()) throw new Error("The renderer document is no longer active."); + const result = await operation(isActive); + // Keep accounting through one main-loop turn so simultaneous invoke bursts + // cannot each materialize an unaccounted Base64 result before delivery. + await new Promise((resolve) => setImmediate(resolve)); + if (!isActive()) throw new Error("The renderer document is no longer active."); + return result; + } finally { + removeOwnerInvalidation(); + lease.release(); + } +} export function registerAttachmentHandlers(): void { let pickerActive = false; @@ -42,33 +82,103 @@ export function registerAttachmentHandlers(): void { pickerActive = true; try { - const result = await dialog.showOpenDialog(parent, { - properties: ["openFile", "multiSelections"], - }); - if (owner.isDestroyed()) throw new Error("The renderer document is no longer active."); - if (result.canceled || result.filePaths.length === 0) { - return { attachments: [], skipped: 0 }; - } - const selectedPaths: string[] = []; - for (const filePath of result.filePaths.slice(0, 200)) { - if (!includeImages && isImageAttachmentPath(filePath)) continue; - selectedPaths.push(filePath); - if (selectedPaths.length === remainingSlots) break; - } - const attachments = await readPickedAttachments(selectedPaths, { - isActive: () => !owner.isDestroyed(), - maxBatchBytes: remainingInlineBytes, - }); - return { - attachments, - skipped: Math.max(0, result.filePaths.length - selectedPaths.length), - }; + return await runOwnedAttachmentIngestion( + owner, + remainingSlots, + attachmentIngestionRepresentationBytes(remainingInlineBytes, remainingSlots), + async (isActive) => { + const result = await dialog.showOpenDialog(parent, { + properties: ["openFile", "multiSelections"], + }); + if (!isActive()) throw new Error("The renderer document is no longer active."); + if (result.canceled || result.filePaths.length === 0) { + return { attachments: [], skipped: 0 }; + } + const selectedPaths: string[] = []; + for (const filePath of result.filePaths.slice(0, 200)) { + if (!includeImages && isImageAttachmentPath(filePath)) continue; + selectedPaths.push(filePath); + if (selectedPaths.length === remainingSlots) break; + } + const attachments = await readPickedAttachments(selectedPaths, { + isActive, + maxBatchBytes: remainingInlineBytes, + }); + return { + attachments, + skipped: Math.max(0, result.filePaths.length - selectedPaths.length), + }; + }, + ); } finally { pickerActive = false; } }, ); + ipcMain.handle( + "aiden:attachments:dropped-read", + async ( + event, + value: unknown, + remainingSlots: unknown, + includeImages: unknown, + remainingInlineBytes: unknown, + ) => { + if ( + !Array.isArray(value) || + value.length === 0 || + value.length > MAX_ATTACHMENTS_PER_MESSAGE || + !Number.isSafeInteger(remainingSlots) || + (remainingSlots as number) < 1 || + (remainingSlots as number) > MAX_ATTACHMENTS_PER_MESSAGE || + typeof includeImages !== "boolean" || + !Number.isSafeInteger(remainingInlineBytes) || + (remainingInlineBytes as number) < 1 || + (remainingInlineBytes as number) > MAX_ATTACHMENT_INLINE_BYTES + ) { + throw new Error("Invalid dropped attachment request."); + } + const paths = [...new Set(value)].filter( + (entry): entry is string => typeof entry === "string" && entry.length > 0, + ); + if (paths.length !== value.length) throw new Error("Invalid dropped attachment request."); + const owner = rendererDocumentOwner(event, () => new Error("Untrusted attachment drop.")); + const selected = paths + .filter((filePath) => includeImages || !isImageAttachmentPath(filePath)) + .slice(0, remainingSlots as number); + if (selected.length === 0) return []; + return runOwnedAttachmentIngestion( + owner, + selected.length, + attachmentIngestionRepresentationBytes(remainingInlineBytes as number, selected.length), + (isActive) => + readPickedAttachments(selected, { + isActive, + maxBatchBytes: remainingInlineBytes as number, + }), + ); + }, + ); + + ipcMain.handle( + "aiden:attachments:clipboard-read", + async (event, value: unknown, remainingSlots: unknown, remainingInlineBytes: unknown) => { + const owner = rendererDocumentOwner(event, () => new Error("Untrusted clipboard image.")); + const payload = validateClipboardAttachmentPayload( + value, + remainingSlots, + remainingInlineBytes, + ); + return runOwnedAttachmentIngestion( + owner, + payload.images.length, + payload.representationBytes, + (isActive) => materializeClipboardAttachments(payload, isActive), + ); + }, + ); + ipcMain.handle("models:info", async (_event, providerId: unknown, modelIds: unknown) => { const pid = typeof providerId === "string" ? providerId : ""; const ids = Array.isArray(modelIds) From 74dcbc142e2cbd1b46069faad3e67699873cd202 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Mon, 10 Aug 2026 16:51:21 -0400 Subject: [PATCH 27/62] Cover main-owned attachment picker contract --- main/handlers/attachments.contract.test.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/main/handlers/attachments.contract.test.ts b/main/handlers/attachments.contract.test.ts index 615eb1b4..43cb0487 100644 --- a/main/handlers/attachments.contract.test.ts +++ b/main/handlers/attachments.contract.test.ts @@ -19,3 +19,19 @@ test("attachment IPC combines native selection and bounded reading in main", () assert.doesNotMatch(rendererIpcSource, /attachments:read/); assert.doesNotMatch(rendererIpcSource, /function pickFiles/); }); + +test("every attachment ingestion channel uses process-owned owner-bound admission", () => { + assert.equal( + handlerSource.match(/\brunOwnedAttachmentIngestion\(/gu)?.length, + 3, + "picker, drop, and clipboard admission calls must remain present", + ); + assert.match(handlerSource, /async function runOwnedAttachmentIngestion\(/u); + assert.match(handlerSource, /owner\.onInvalidated\(lease\.cancel\)/u); + assert.match( + handlerSource, + /finally \{\s+removeOwnerInvalidation\(\);\s+lease\.release\(\);\s+\}/u, + ); + assert.match(handlerSource, /validateClipboardAttachmentPayload\(/u); + assert.match(handlerSource, /materializeClipboardAttachments\(payload, isActive\)/u); +}); From 9e944ae74d9b5ad8ae5c26f51df75074724c9f82 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Mon, 10 Aug 2026 16:51:21 -0400 Subject: [PATCH 28/62] Reject malformed appended attachments --- main/handlers/chats.append.contract.test.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/main/handlers/chats.append.contract.test.ts b/main/handlers/chats.append.contract.test.ts index 7cef4556..8d843fad 100644 --- a/main/handlers/chats.append.contract.test.ts +++ b/main/handlers/chats.append.contract.test.ts @@ -163,7 +163,9 @@ test("append envelopes reject many extra properties without materializing Object }); test("append admission charges encoded image representation and metadata", () => { - const data = Buffer.alloc(3).toString("base64"); + const data = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4z8DwHwAFgAI/ScL2aQAAAABJRU5ErkJggg=="; + const size = Buffer.byteLength(data, "base64"); const parsed = parseChatAppend( "chat-1", { @@ -175,15 +177,12 @@ test("append admission charges encoded image representation and metadata", () => name: "a.png", mimeType: "image/png", kind: "image", - size: 3, + size, data, }, ], }, { turnId: "turn-1", providerId: "provider", model: "model" }, ); - assert.ok( - parsed.retainedBytes >= - data.length + Buffer.byteLength("providermodel", "utf8"), - ); + assert.ok(parsed.retainedBytes >= data.length + Buffer.byteLength("providermodel", "utf8")); }); From 8e8ba36ee2333dc7a854b29ab964456f87cfc7e4 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Mon, 10 Aug 2026 16:51:21 -0400 Subject: [PATCH 29/62] Cover fixed attachment IPC surface --- main/handlers/ipc-contract.test.ts | 106 +++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) diff --git a/main/handlers/ipc-contract.test.ts b/main/handlers/ipc-contract.test.ts index ab962965..39fb4b84 100644 --- a/main/handlers/ipc-contract.test.ts +++ b/main/handlers/ipc-contract.test.ts @@ -12,6 +12,19 @@ import { NATIVE_INVOKE_CHANNELS, NOTIFICATION_CHANNELS, } from "../../renderer/preload-channels.js"; +import { + createAttachmentPreloadBridge, + PRELOAD_MAX_ATTACHMENT_BYTES, + PRELOAD_MAX_ATTACHMENT_PATHS, + PRELOAD_MAX_CLIPBOARD_IMAGES, + PRELOAD_MAX_IMAGE_BYTES, +} from "../../renderer/preload-attachments.js"; +import { + MAX_ATTACHMENT_BATCH_BYTES, + MAX_CLIPBOARD_IMAGES, + MAX_IMAGE_BYTES, +} from "../services/attachments.js"; +import { MAX_ATTACHMENTS_PER_MESSAGE } from "../../renderer/shared/attachment-contract.js"; interface IpcInventory { handlers: Set; @@ -20,6 +33,9 @@ interface IpcInventory { const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); const MAIN_ROOT = path.join(REPO_ROOT, "main"); +const PRELOAD_PATH = path.join(REPO_ROOT, "renderer", "preload.ts"); +const RENDERER_IPC_PATH = path.join(REPO_ROOT, "renderer", "lib", "ipc.ts"); +const ATTACHMENT_HANDLER_PATH = path.join(MAIN_ROOT, "handlers", "attachments.ts"); function calleeName(expression: ts.LeftHandSideExpression): string | undefined { if (ts.isIdentifier(expression)) return expression.text; @@ -154,6 +170,96 @@ test("dedicated native bridge channels exactly match the live native handlers", ); }); +test("fixed attachment bridge preserves OS drop and bounded clipboard flows", async () => { + const calls: Array<{ channel: string; args: unknown[] }> = []; + const trustedFile = {} as File; + const onePixelPng = new Uint8Array( + Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4z8DwHwAFgAI/ScL2aQAAAABJRU5ErkJggg==", + "base64", + ), + ); + const bridge = createAttachmentPreloadBridge({ + invoke: async (channel: string, ...args: unknown[]) => { + calls.push({ channel, args }); + return [] as unknown as T; + }, + getPathForFile: (file) => { + if (file === trustedFile) return "/trusted/from-finder.png"; + throw new Error("not an OS-backed File"); + }, + }); + + const beforeForgedDrop = calls.length; + assert.deepEqual( + await bridge.readDroppedFiles( + ["/private/renderer-authored" as unknown as File], + 1, + true, + PRELOAD_MAX_ATTACHMENT_BYTES, + ), + [], + ); + assert.equal(calls.length, beforeForgedDrop, "arbitrary path strings must not reach IPC"); + + await bridge.readDroppedFiles([trustedFile], 1, true, PRELOAD_MAX_ATTACHMENT_BYTES); + assert.deepEqual(calls[calls.length - 1], { + channel: NATIVE_INVOKE_CHANNELS.attachmentDroppedRead, + args: [["/trusted/from-finder.png"], 1, true, PRELOAD_MAX_ATTACHMENT_BYTES], + }); + + const clipboard = [{ mimeType: "image/png", bytes: onePixelPng }]; + await bridge.readClipboardImages(clipboard, 1, PRELOAD_MAX_ATTACHMENT_BYTES); + assert.deepEqual(calls[calls.length - 1], { + channel: NATIVE_INVOKE_CHANNELS.attachmentClipboardRead, + args: [clipboard, 1, PRELOAD_MAX_ATTACHMENT_BYTES], + }); + + const maximumImage = new Uint8Array(PRELOAD_MAX_IMAGE_BYTES); + maximumImage.set(onePixelPng); + const beforeOversizedClipboard = calls.length; + await assert.rejects( + bridge.readClipboardImages( + Array.from({ length: 5 }, () => ({ mimeType: "image/png", bytes: maximumImage })), + 5, + PRELOAD_MAX_ATTACHMENT_BYTES, + ), + /aggregate byte limit/u, + ); + assert.equal(calls.length, beforeOversizedClipboard, "oversized bytes must not reach IPC"); + await assert.rejects( + bridge.readDroppedFiles( + Array.from({ length: PRELOAD_MAX_ATTACHMENT_PATHS + 1 }, () => trustedFile), + PRELOAD_MAX_ATTACHMENT_PATHS, + true, + PRELOAD_MAX_ATTACHMENT_BYTES, + ), + /Invalid dropped file selection/u, + ); + + assert.equal(PRELOAD_MAX_ATTACHMENT_PATHS, MAX_ATTACHMENTS_PER_MESSAGE); + assert.equal(PRELOAD_MAX_CLIPBOARD_IMAGES, MAX_CLIPBOARD_IMAGES); + assert.equal(PRELOAD_MAX_IMAGE_BYTES, MAX_IMAGE_BYTES); + assert.equal(PRELOAD_MAX_ATTACHMENT_BYTES, MAX_ATTACHMENT_BATCH_BYTES); +}); + +test("generic renderer IPC exposes only the main-owned picker, never path reads", async () => { + const [preload, rendererIpc, attachmentHandler] = await Promise.all([ + fs.readFile(PRELOAD_PATH, "utf8"), + fs.readFile(RENDERER_IPC_PATH, "utf8"), + fs.readFile(ATTACHMENT_HANDLER_PATH, "utf8"), + ]); + assert.match(preload, /import \{ contextBridge, ipcRenderer, webUtils \} from "electron"/u); + assert.match(preload, /getPathForFile: \(file: File\) => webUtils\.getPathForFile\(file\)/u); + assert.doesNotMatch(preload, /file\.path/u); + assert.equal(INVOKE_PREFIXES.includes("attachments:"), true); + assert.match(rendererIpc, /"attachments:pickAndRead"/u); + assert.doesNotMatch(rendererIpc, /attachments:(?:drop|read|clipboard)/u); + assert.match(attachmentHandler, /"attachments:pickAndRead"/u); + assert.match(attachmentHandler, /"aiden:attachments:dropped-read"/u); + assert.match(attachmentHandler, /"aiden:attachments:clipboard-read"/u); +}); + test("live notification sites exactly match the preload notification allowlist", () => { assert.deepEqual( sorted(inventory.notifications), From 3e85d5ccdf65174f9e6ca2e05da9aadc9f49f52c Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Mon, 10 Aug 2026 16:51:21 -0400 Subject: [PATCH 30/62] Support safe drop and paste attachments --- renderer/components/composer.tsx | 201 +++++++++++++++++++++++++++---- 1 file changed, 178 insertions(+), 23 deletions(-) diff --git a/renderer/components/composer.tsx b/renderer/components/composer.tsx index c2d617e9..b70894af 100644 --- a/renderer/components/composer.tsx +++ b/renderer/components/composer.tsx @@ -91,6 +91,17 @@ import { } from "../shared/attachment-contract"; import { MAX_CHAT_MESSAGE_CONTENT_BYTES } from "../shared/chat-message-contract"; +const CLIPBOARD_IMAGE_MIME_TYPES = new Set([ + "image/png", + "image/jpeg", + "image/gif", + "image/webp", + "image/bmp", + "image/heic", + "image/heif", +]); +const MAX_CLIPBOARD_IMAGE_BYTES = 8 * 1024 * 1024; + interface ComposerProps { /** True when a provider + model are selected and a message can be sent. */ ready: boolean; @@ -304,6 +315,8 @@ export function Composer({ const selectedSkill = skillSelection.selected; const [attaching, setAttaching] = React.useState(false); const [attachmentStatus, setAttachmentStatus] = React.useState(""); + const attachmentOperationRef = React.useRef(false); + const attachmentDescriptionId = React.useId(); const [sending, setSending] = React.useState(false); const [permissionSaving, setPermissionSaving] = React.useState(false); const [confirmFullAccess, setConfirmFullAccess] = React.useState(false); @@ -814,14 +827,48 @@ export function Composer({ } }, [onRenameChat, renameTitle, renaming]); - const handleAttach = async () => { + const beginAttachmentRead = (status: string): boolean => { if (gitOperationBusy) { toast.info("Wait for the current Git operation to finish before attaching files."); - return; + return false; + } + if (attachmentOperationRef.current) { + toast.info("Wait for the current attachments to finish loading."); + return false; } - if (attaching) return; + attachmentOperationRef.current = true; setAttaching(true); - setAttachmentStatus("Attachment picker open. Selected files will load before sending."); + setAttachmentStatus(status); + return true; + }; + + const finishAttachmentRead = () => { + attachmentOperationRef.current = false; + setAttaching(false); + requestAnimationFrame(() => inputRef?.current?.focus({ preventScroll: true })); + }; + + const acceptReadAttachments = (added: Attachment[], emptyStatus: string): number => { + if (visionSupported === false && added.some((attachment) => attachment.kind === "image")) { + added = added.filter((attachment) => attachment.kind !== "image"); + toast.info("The selected model can't read images — image attachments were skipped."); + } + if (added.length === 0) { + setAttachmentStatus(emptyStatus); + return 0; + } + attachmentRevisionRef.current += 1; + setAttachments((current) => [...current, ...added]); + setAttachmentStatus( + `${added.length} ${added.length === 1 ? "attachment is" : "attachments are"} ready.`, + ); + return added.length; + }; + + const handleAttach = async () => { + if (!beginAttachmentRead("Attachment picker open. Selected files will load before sending.")) { + return; + } try { const remainingSlots = attachmentSlotsRemaining(attachments.length); if (remainingSlots <= 0) { @@ -845,32 +892,130 @@ export function Composer({ `${picked.skipped} selected ${picked.skipped === 1 ? "file was" : "files were"} skipped because of the attachment limit or model support.`, ); } - let added = picked.attachments; - if (added.length === 0) { - setAttachmentStatus("No attachments were added."); + acceptReadAttachments(picked.attachments, "No compatible attachments were added."); + } catch (error) { + setAttachmentStatus("Attachments could not be loaded."); + toast.error(error instanceof Error ? error.message : "Couldn't read that file."); + } finally { + finishAttachmentRead(); + } + }; + + const readDroppedAttachments = async (files: File[]) => { + if (!beginAttachmentRead("Dropped files are loading before sending.")) return; + try { + const remainingSlots = attachmentSlotsRemaining(attachments.length); + const remainingInlineBytes = attachmentInlineBytesRemaining(attachments); + if (remainingSlots <= 0 || remainingInlineBytes <= 0) { + toast.info("This message has reached its attachment limit."); + setAttachmentStatus("The attachment limit has been reached."); return; } - // Drop images when the model can't see them, with a hint. - if (visionSupported === false && added.some((a) => a.kind === "image")) { - added = added.filter((a) => a.kind !== "image"); - toast.info("The selected model can't read images — image attachments were skipped."); + const added = await attachmentsApi.readDroppedFiles( + files, + remainingSlots, + visionSupported !== false, + remainingInlineBytes, + ); + const accepted = acceptReadAttachments(added, "No compatible dropped files were added."); + if (accepted < files.length) { + toast.info( + `${files.length - accepted} dropped ${files.length - accepted === 1 ? "file was" : "files were"} skipped because of the attachment limit or model support.`, + ); + } + } catch (error) { + setAttachmentStatus("Dropped files could not be loaded."); + toast.error(error instanceof Error ? error.message : "Couldn't read that dropped file."); + } finally { + finishAttachmentRead(); + } + }; + + const readClipboardImages = async (files: File[]) => { + if (!beginAttachmentRead("Clipboard images are loading before sending.")) return; + try { + const remainingSlots = attachmentSlotsRemaining(attachments.length); + const remainingInlineBytes = attachmentInlineBytesRemaining(attachments); + const eligible: File[] = []; + let plannedBytes = 0; + for (const file of files) { + if ( + eligible.length >= remainingSlots || + file.size <= 0 || + file.size > MAX_CLIPBOARD_IMAGE_BYTES || + !CLIPBOARD_IMAGE_MIME_TYPES.has(file.type.toLowerCase()) || + plannedBytes + file.size > remainingInlineBytes + ) { + continue; + } + plannedBytes += file.size; + eligible.push(file); } - if (added.length === 0) { - setAttachmentStatus("No compatible attachments were added."); + if (eligible.length === 0 || remainingInlineBytes <= 0) { + setAttachmentStatus("No compatible clipboard images were added."); + toast.info("Clipboard images were empty, unsupported, or beyond the attachment limit."); return; } - attachmentRevisionRef.current += 1; - setAttachments((prev) => [...prev, ...added]); - setAttachmentStatus( - `${added.length} ${added.length === 1 ? "attachment is" : "attachments are"} ready.`, + const payload = await Promise.all( + eligible.map(async (file) => ({ + mimeType: file.type.toLowerCase(), + bytes: new Uint8Array(await file.arrayBuffer()), + })), + ); + const added = await attachmentsApi.readClipboardImages( + payload, + remainingSlots, + remainingInlineBytes, ); + const accepted = acceptReadAttachments(added, "No clipboard images were added."); + if (accepted < files.length) { + toast.info( + `${files.length - accepted} clipboard ${files.length - accepted === 1 ? "image was" : "images were"} skipped because of the attachment limit or model support.`, + ); + } } catch (error) { - setAttachmentStatus("Attachments could not be loaded."); - toast.error(error instanceof Error ? error.message : "Couldn't read that file."); + setAttachmentStatus("Clipboard images could not be loaded."); + toast.error(error instanceof Error ? error.message : "Couldn't read that clipboard image."); } finally { - setAttaching(false); - requestAnimationFrame(() => inputRef?.current?.focus({ preventScroll: true })); + finishAttachmentRead(); + } + }; + + const handleDrop = (event: React.DragEvent) => { + const files = Array.from(event.dataTransfer.files); + if (files.length === 0) return; + event.preventDefault(); + void readDroppedAttachments(files); + }; + + const handleDragOver = (event: React.DragEvent) => { + if (event.dataTransfer.types.includes("Files")) event.preventDefault(); + }; + + const handlePaste = (event: React.ClipboardEvent) => { + const images = Array.from(event.clipboardData.items).flatMap((item) => { + if (item.kind !== "file" || !CLIPBOARD_IMAGE_MIME_TYPES.has(item.type.toLowerCase())) { + return []; + } + const file = item.getAsFile(); + return file ? [file] : []; + }); + if (images.length === 0) return; + event.preventDefault(); + + const pastedText = event.clipboardData.getData("text/plain"); + if (pastedText) { + const target = event.currentTarget; + const start = target.selectionStart; + const end = target.selectionEnd; + const cursor = start + pastedText.length; + setText((current) => `${current.slice(0, start)}${pastedText}${current.slice(end)}`); + requestAnimationFrame(() => { + target.setSelectionRange(cursor, cursor); + updateSelection({ start: cursor, end: cursor }); + }); } + void readClipboardImages(images); }; const removeAttachment = (id: string) => { @@ -879,7 +1024,7 @@ export function Composer({ }; const submit = async () => { - if (attaching) { + if (attachmentOperationRef.current || attaching) { toast.info("Wait for the selected attachments to finish loading before sending."); return; } @@ -1205,7 +1350,15 @@ export function Composer({ ) : null} -
+
+ + Drag files here or paste an image to attach it. Use Attach files or images to choose + files with the keyboard. + {selectedSkill ? (
updateSelection({ start: event.currentTarget.selectionStart, @@ -1326,6 +1480,7 @@ export function Composer({ }} onFocus={markSlashInteraction} aria-autocomplete={slashSession ? "list" : undefined} + aria-describedby={attachmentDescriptionId} aria-controls={slashSession ? COMPOSER_SLASH_PALETTE_ID : undefined} aria-activedescendant={slashSession ? effectiveActiveSlashId : undefined} placeholder={composerPlaceholder({ From fb96b978bed52518a4b6362df95c21502637a40f Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Mon, 10 Aug 2026 16:51:21 -0400 Subject: [PATCH 31/62] Cover composer attachment interactions --- renderer/components/composer.test.tsx | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/renderer/components/composer.test.tsx b/renderer/components/composer.test.tsx index 821942b0..e0f4d145 100644 --- a/renderer/components/composer.test.tsx +++ b/renderer/components/composer.test.tsx @@ -39,6 +39,23 @@ test("composer context controls stay compact without exposing provider copy", () assert.doesNotMatch(modelPicker, /\$\{selected\.label\} · \$\{selected\.providerLabel\}/u); }); +test("composer routes Finder drops and raster paste through the fixed preload bridge", () => { + const composer = source("./composer.tsx"); + const preload = source("../preload-attachments.ts"); + const ipc = source("../lib/ipc.ts"); + + assert.match(composer, /onDragOver=\{handleDragOver\}/u); + assert.match(composer, /onDrop=\{handleDrop\}/u); + assert.match(composer, /onPaste=\{handlePaste\}/u); + assert.match(composer, /attachmentOperationRef\.current \|\| attaching/u); + assert.match(composer, /Wait for the current attachments to finish loading/u); + assert.match(composer, /plannedBytes \+ file\.size > remainingInlineBytes/u); + assert.match(ipc, /window\.aidenAPI\.attachments\.readDroppedFiles/u); + assert.match(ipc, /window\.aidenAPI\.attachments\.readClipboardImages/u); + assert.match(preload, /getPathForFile\(file: File\): string/u); + assert.doesNotMatch(preload, /file\.path/u); +}); + test("chat surfaces share the responsive centered chat-column contract", () => { const composer = source("./composer.tsx"); const messages = source("./message-list.tsx"); From 14872e4ef227df022e63120d1b986e2a2b0a5491 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Mon, 10 Aug 2026 16:51:21 -0400 Subject: [PATCH 32/62] Add deterministic Electron Playwright config --- playwright.config.ts | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 playwright.config.ts diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 00000000..aaa876d4 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,31 @@ +import type { PlaywrightTestConfig } from "@playwright/test"; + +const liveLmStudioAcceptance = process.env.AIDEN_E2E_LIVE_LMSTUDIO === "1"; + +/** + * Electron tests are intentionally serial: Aiden owns global Electron state + * (single-instance handling, native menus, and optional global shortcuts). + * Every test still gets independent user-data and portable-config roots. + */ +const config: PlaywrightTestConfig = { + testDir: "./tests/e2e", + testMatch: liveLmStudioAcceptance ? "**/*.live.spec.ts" : "**/*.spec.ts", + testIgnore: liveLmStudioAcceptance ? undefined : "**/*.live.spec.ts", + outputDir: "./test-results/e2e", + fullyParallel: false, + workers: 1, + timeout: 45_000, + expect: { timeout: 10_000 }, + retries: process.env.CI ? 1 : 0, + forbidOnly: Boolean(process.env.CI), + reporter: process.env.CI + ? [["line"], ["html", { outputFolder: "playwright-report/e2e", open: "never" }]] + : "line", + use: { + screenshot: "only-on-failure", + trace: "retain-on-failure", + video: "off", + }, +}; + +export default config; From 1305eadedb2aa9e53268577ffc852816e9439785 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Mon, 10 Aug 2026 16:51:21 -0400 Subject: [PATCH 33/62] Add strict E2E TypeScript project --- tests/e2e/tsconfig.json | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 tests/e2e/tsconfig.json diff --git a/tests/e2e/tsconfig.json b/tests/e2e/tsconfig.json new file mode 100644 index 00000000..b199eac5 --- /dev/null +++ b/tests/e2e/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "noEmit": true, + "types": ["node"] + }, + "include": ["../../playwright.config.ts", "./**/*.ts"] +} From 7b63e85d3d2613e7d45f84e8e28a21636975c291 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Mon, 10 Aug 2026 16:51:21 -0400 Subject: [PATCH 34/62] Isolate Electron E2E runtime paths --- tests/e2e/electron-test-bootstrap.cjs | 54 +++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 tests/e2e/electron-test-bootstrap.cjs diff --git a/tests/e2e/electron-test-bootstrap.cjs b/tests/e2e/electron-test-bootstrap.cjs new file mode 100644 index 00000000..6b289d54 --- /dev/null +++ b/tests/e2e/electron-test-bootstrap.cjs @@ -0,0 +1,54 @@ +"use strict"; + +const process = require("node:process"); +const { URL } = require("node:url"); +const { app } = require("electron"); + +// Electron resolves its native home directory independently from Node's HOME +// on macOS. Point both at the fixture root before Aiden's runtime-profile +// bootstrap reads app.getPath("home"). +const testHome = process.env.HOME; +if (!testHome) throw new Error("The Electron E2E bootstrap requires an isolated HOME."); +app.setPath("home", testHome); + +// The fresh-config onboarding case must persist Aiden's real default LM Studio +// URL while discovery still reaches its own random-port fixture. Rewrite only +// that exact loopback origin before app code (and Pi) capture global fetch; +// every other request is left untouched. +const REDIRECT_ENV = "AIDEN_E2E_LMSTUDIO_REDIRECT_ORIGIN"; +const DEFAULT_LM_STUDIO_ORIGINS = new Set(["http://127.0.0.1:1234", "http://localhost:1234"]); +const configuredOrigin = process.env[REDIRECT_ENV]; + +if (configuredOrigin) { + const redirect = new URL(configuredOrigin); + if ( + redirect.protocol !== "http:" || + redirect.hostname !== "127.0.0.1" || + !redirect.port || + redirect.port === "1234" || + redirect.pathname !== "/" || + redirect.search || + redirect.hash || + redirect.username || + redirect.password + ) { + throw new Error(`${REDIRECT_ENV} must be a random-port HTTP loopback origin.`); + } + + const originalFetch = globalThis.fetch.bind(globalThis); + const RequestConstructor = globalThis.Request; + globalThis.fetch = (input, init) => { + const sourceUrl = new URL(input instanceof RequestConstructor ? input.url : input); + if (!DEFAULT_LM_STUDIO_ORIGINS.has(sourceUrl.origin)) { + return originalFetch(input, init); + } + + sourceUrl.protocol = redirect.protocol; + sourceUrl.hostname = redirect.hostname; + sourceUrl.port = redirect.port; + if (input instanceof RequestConstructor) { + return originalFetch(new RequestConstructor(sourceUrl, input), init); + } + return originalFetch(sourceUrl, init); + }; +} From 2c2579b67b82f008dcc6549d54b835c777e003a1 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Mon, 10 Aug 2026 16:51:21 -0400 Subject: [PATCH 35/62] Add hermetic Electron E2E fixture --- tests/e2e/fixtures.ts | 693 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 693 insertions(+) create mode 100644 tests/e2e/fixtures.ts diff --git a/tests/e2e/fixtures.ts b/tests/e2e/fixtures.ts new file mode 100644 index 00000000..40d1fd49 --- /dev/null +++ b/tests/e2e/fixtures.ts @@ -0,0 +1,693 @@ +import { type ElectronApplication, type Page } from "@playwright/test"; +import playwrightTest from "@playwright/test"; +import type * as PlaywrightTestModule from "@playwright/test"; +import type { ChildProcess } from "node:child_process"; +import { access, mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { + createServer, + type IncomingHttpHeaders, + type IncomingMessage, + type Server, +} from "node:http"; +import type { AddressInfo } from "node:net"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +export const REPOSITORY_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +export const LM_STUDIO_PROVIDER_ID = "custom:lmstudio"; +export const E2E_MODEL_ID = "aiden-e2e-vision"; +export const E2E_MODEL_DISPLAY_NAME = "Aiden E2E Vision"; +export const E2E_PROFILE_NAME = "E2E Local User"; +export const E2E_ASSISTANT_RESPONSE = "Deterministic E2E response received."; +export const LIVE_LM_STUDIO_ACCEPTANCE = process.env.AIDEN_E2E_LIVE_LMSTUDIO === "1"; + +// Playwright's config loader currently resolves its test package through the +// CommonJS condition in this ESM repository. The default runtime object carries +// the named APIs; this module-type assertion keeps every destructured API strict. +const { + _electron: electron, + expect, + test: base, +} = playwrightTest as unknown as typeof PlaywrightTestModule; + +const MAX_REQUEST_BYTES = 16 * 1024 * 1024; +const PROCESS_EXIT_TIMEOUT_MS = 10_000; +const DEFAULT_LIVE_LM_STUDIO_BASE_URL = "http://127.0.0.1:1234/v1"; +const DEFAULT_LM_STUDIO_ORIGIN = new URL(DEFAULT_LIVE_LM_STUDIO_BASE_URL).origin; +const LM_STUDIO_REDIRECT_ENV = "AIDEN_E2E_LMSTUDIO_REDIRECT_ORIGIN"; +const ELECTRON_TEST_BOOTSTRAP = path.join( + REPOSITORY_ROOT, + "tests", + "e2e", + "electron-test-bootstrap.cjs", +); +const APP_ENV_PASSTHROUGH = [ + "LANG", + "LC_ALL", + "LC_CTYPE", + "LOGNAME", + "PATH", + "SHELL", + "TEMP", + "TMP", + "TMPDIR", + "TZ", + "USER", +] as const; +const PI_AMBIENT_AUTH_ENV_NAMES = new Set([ + "AWS_ACCESS_KEY_ID", + "AWS_BEARER_TOKEN_BEDROCK", + "AWS_CONTAINER_CREDENTIALS_FULL_URI", + "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", + "AWS_PROFILE", + "AWS_SECRET_ACCESS_KEY", + "AWS_WEB_IDENTITY_TOKEN_FILE", + "GCLOUD_PROJECT", + "GOOGLE_APPLICATION_CREDENTIALS", + "GOOGLE_CLOUD_API_KEY", + "GOOGLE_CLOUD_LOCATION", + "GOOGLE_CLOUD_PROJECT", +]); +const OS_INJECTED_ENV_NAMES = new Set(["__CF_USER_TEXT_ENCODING"]); +const CREDENTIAL_ENV_NAME = + /(?:^|_)(?:API_KEY|ACCESS_KEY(?:_ID)?|TOKEN|CREDENTIALS?|SECRET(?:_ACCESS)?_KEY|PASSWORD)$/u; + +export type PortableConfigSeed = "empty" | "lmstudio"; + +export type CapturedLmStudioRequest = { + method: string; + url: string; + headers: IncomingHttpHeaders; + body: unknown; +}; + +export type LmStudioEndpoint = { + baseUrl: string; + live: boolean; + requests: CapturedLmStudioRequest[]; +}; + +export type AidenE2e = { + app: ElectronApplication; + page: Page; + userDataDir: string; + configDir: string; + rootDir: string; + lmStudio: LmStudioEndpoint; + relaunch: () => Promise; +}; + +type AidenE2eOptions = { + portableConfigSeed: PortableConfigSeed; +}; + +type MockLmStudio = LmStudioEndpoint & { + server: Server; +}; + +async function assertBuiltElectronApp(): Promise { + try { + await Promise.all([ + access(path.join(REPOSITORY_ROOT, "build", "main", "index.js")), + access(path.join(REPOSITORY_ROOT, "build", "renderer", "main-window.html")), + ]); + } catch { + throw new Error("Electron bundles are missing. Run `npm run build` before Playwright."); + } +} + +function resolveLiveLmStudioBaseUrl(): string { + const input = process.env.AIDEN_E2E_LMSTUDIO_BASE_URL?.trim() || DEFAULT_LIVE_LM_STUDIO_BASE_URL; + const url = new URL(input); + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error("AIDEN_E2E_LMSTUDIO_BASE_URL must use HTTP or HTTPS."); + } + url.pathname = url.pathname.replace(/\/+$/u, ""); + url.search = ""; + url.hash = ""; + return url.toString().replace(/\/$/u, ""); +} + +async function readJsonBody(request: IncomingMessage): Promise { + const chunks: Buffer[] = []; + let total = 0; + for await (const chunk of request) { + total += chunk.length; + if (total > MAX_REQUEST_BYTES) { + throw new Error(`E2E model request exceeded ${MAX_REQUEST_BYTES} bytes.`); + } + chunks.push(chunk); + } + const text = Buffer.concat(chunks).toString("utf8"); + return text ? (JSON.parse(text) as unknown) : null; +} + +function writeJson(response: import("node:http").ServerResponse, value: unknown): void { + response.writeHead(200, { + "cache-control": "no-store", + "content-type": "application/json; charset=utf-8", + }); + response.end(JSON.stringify(value)); +} + +function writeCompletion(response: import("node:http").ServerResponse): void { + const common = { + id: "chatcmpl-aiden-e2e", + object: "chat.completion.chunk", + created: 0, + model: E2E_MODEL_ID, + }; + response.writeHead(200, { + "cache-control": "no-store", + "content-type": "text/event-stream; charset=utf-8", + }); + response.write( + `data: ${JSON.stringify({ + ...common, + choices: [ + { + index: 0, + delta: { role: "assistant", content: E2E_ASSISTANT_RESPONSE }, + finish_reason: null, + }, + ], + })}\n\n`, + ); + response.write( + `data: ${JSON.stringify({ + ...common, + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + })}\n\n`, + ); + response.end("data: [DONE]\n\n"); +} + +async function startMockLmStudio(): Promise { + const requests: CapturedLmStudioRequest[] = []; + const nativeModel = { + key: E2E_MODEL_ID, + display_name: E2E_MODEL_DISPLAY_NAME, + type: "llm", + state: "loaded", + loaded_instances: [{ id: "aiden-e2e-loaded-instance" }], + capabilities: { + vision: true, + trained_for_tool_use: true, + reasoning: false, + }, + max_context_length: 32_768, + params_string: "1B", + quantization: { name: "Q4_K_M" }, + }; + const server = createServer(async (request, response) => { + const method = request.method ?? "GET"; + const url = request.url ?? "/"; + try { + if (method === "GET" && url === "/api/v1/models") { + requests.push({ method, url, headers: { ...request.headers }, body: null }); + writeJson(response, { models: [nativeModel] }); + return; + } + if (method === "GET" && url === "/api/v0/models") { + requests.push({ method, url, headers: { ...request.headers }, body: null }); + writeJson(response, { data: [{ id: E2E_MODEL_ID, state: "loaded" }] }); + return; + } + if (method === "GET" && url === "/v1/models") { + requests.push({ method, url, headers: { ...request.headers }, body: null }); + writeJson(response, { data: [{ id: E2E_MODEL_ID, type: "llm" }] }); + return; + } + if (method === "POST" && url === "/v1/chat/completions") { + const body = await readJsonBody(request); + requests.push({ method, url, headers: { ...request.headers }, body }); + writeCompletion(response); + return; + } + response.writeHead(404, { "content-type": "application/json; charset=utf-8" }); + response.end(JSON.stringify({ error: { message: `Unhandled E2E route: ${method} ${url}` } })); + } catch (error) { + if (!response.headersSent) { + response.writeHead(500, { "content-type": "application/json; charset=utf-8" }); + } + response.end( + JSON.stringify({ + error: { message: error instanceof Error ? error.message : String(error) }, + }), + ); + } + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + server.off("error", reject); + resolve(); + }); + }); + const address = server.address() as AddressInfo | null; + if (!address) throw new Error("The deterministic LM Studio server did not bind a TCP port."); + return { + baseUrl: `http://127.0.0.1:${address.port}/v1`, + live: false, + requests, + server, + }; +} + +async function closeMockLmStudio(mock: MockLmStudio): Promise { + const closed = new Promise((resolve, reject) => { + mock.server.close((error) => (error ? reject(error) : resolve())); + }); + mock.server.closeAllConnections(); + await withTimeout(closed, "deterministic LM Studio server shutdown", PROCESS_EXIT_TIMEOUT_MS); +} + +function isolatedAppEnvironment(): Record { + const environment: Record = {}; + for (const key of APP_ENV_PASSTHROUGH) { + const value = process.env[key]; + if (value !== undefined) environment[key] = value; + } + return environment; +} + +async function writePrivateJson(filePath: string, value: unknown): Promise { + await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, { + encoding: "utf8", + mode: 0o600, + }); +} + +async function seedPortableConfig( + configDir: string, + baseUrl: string, + seed: PortableConfigSeed, +): Promise { + await writePrivateJson(path.join(configDir, "config.json"), { + providers: + seed === "lmstudio" + ? [ + { + id: LM_STUDIO_PROVIDER_ID, + kind: "openai", + label: "LM Studio (local)", + baseUrl, + needsKey: false, + deployment: "local", + }, + ] + : [], + providerIdAliases: {}, + mcpServers: [], + skills: [], + }); +} + +/** Wait for the one main window without assuming its initial route or title. */ +export async function firstAidenWindow(app: ElectronApplication): Promise { + const page = await app.firstWindow(); + await page.waitForLoadState("domcontentloaded"); + await expect(page.locator("body")).toBeVisible(); + return page; +} + +async function assertRuntimeIsolation( + app: ElectronApplication, + expected: { + userDataDir: string; + configDir: string; + homeDir: string; + xdgCacheDir: string; + xdgConfigDir: string; + xdgDataDir: string; + environment: Record; + }, +): Promise { + const runtime = await app.evaluate(({ app: electronApp }) => ({ + userDataDir: electronApp.getPath("userData"), + sessionDataDir: electronApp.getPath("sessionData"), + appHomeDir: electronApp.getPath("home"), + configDir: process.env.AIDEN_CONFIG_DIR ?? "", + homeDir: process.env.HOME ?? "", + xdgCacheDir: process.env.XDG_CACHE_HOME ?? "", + xdgConfigDir: process.env.XDG_CONFIG_HOME ?? "", + xdgDataDir: process.env.XDG_DATA_HOME ?? "", + runtimeProfile: process.env.AIDEN_RUNTIME_PROFILE ?? "", + environmentKeys: Object.keys(process.env), + })); + const userDataDir = path.resolve(expected.userDataDir); + const configDir = path.resolve(expected.configDir); + const homeDir = path.resolve(expected.homeDir); + const xdgCacheDir = path.resolve(expected.xdgCacheDir); + const xdgConfigDir = path.resolve(expected.xdgConfigDir); + const xdgDataDir = path.resolve(expected.xdgDataDir); + if (path.resolve(runtime.userDataDir) !== userDataDir) { + throw new Error( + `Electron ignored the E2E user-data root: expected ${userDataDir}, received ${runtime.userDataDir}.`, + ); + } + if (path.resolve(runtime.sessionDataDir) !== userDataDir) { + throw new Error( + `Electron ignored the isolated session-data root: expected ${userDataDir}, received ${runtime.sessionDataDir}.`, + ); + } + if (path.resolve(runtime.configDir) !== configDir) { + throw new Error( + `Aiden ignored the E2E portable-config root: expected ${configDir}, received ${runtime.configDir}.`, + ); + } + if (path.resolve(runtime.homeDir) !== homeDir) { + throw new Error( + `The E2E app inherited the developer home: expected ${homeDir}, received ${runtime.homeDir}.`, + ); + } + if (path.resolve(runtime.appHomeDir) !== homeDir) { + throw new Error( + `Electron ignored the isolated home root: expected ${homeDir}, received ${runtime.appHomeDir}.`, + ); + } + if ( + path.resolve(runtime.xdgCacheDir) !== xdgCacheDir || + path.resolve(runtime.xdgConfigDir) !== xdgConfigDir || + path.resolve(runtime.xdgDataDir) !== xdgDataDir + ) { + throw new Error("The E2E app ignored one or more isolated XDG roots."); + } + if (userDataDir === configDir || runtime.runtimeProfile !== "development") { + throw new Error("The E2E launch did not establish distinct development profile roots."); + } + const expectedEnvironmentKeys = Object.keys(expected.environment).sort(); + const runtimeEnvironmentKeys = runtime.environmentKeys + .filter((key) => !OS_INJECTED_ENV_NAMES.has(key)) + .sort(); + if (JSON.stringify(runtimeEnvironmentKeys) !== JSON.stringify(expectedEnvironmentKeys)) { + throw new Error( + `The E2E app environment was not hermetic: expected ${expectedEnvironmentKeys.join(", ")}; received ${runtimeEnvironmentKeys.join(", ")}.`, + ); + } + const forbiddenAuthKeys = runtime.environmentKeys.filter( + (key) => PI_AMBIENT_AUTH_ENV_NAMES.has(key) || CREDENTIAL_ENV_NAME.test(key), + ); + if (forbiddenAuthKeys.length > 0) { + throw new Error( + `The E2E app inherited ambient provider auth: ${forbiddenAuthKeys.join(", ")}.`, + ); + } +} + +/** Complete first-run setup with the disposable keyless LM Studio connection. */ +export async function finishLmStudioOnboarding(page: Page): Promise { + const onboarding = page.locator('section[aria-label="Set up Aiden"]'); + await expect(onboarding).toBeVisible(); + const next = onboarding.getByRole("button", { name: /^Next/u }); + await expect(next).toBeDisabled(); + await onboarding.getByPlaceholder("Your name").fill(E2E_PROFILE_NAME); + await next.click(); + + await expect(onboarding.getByRole("heading", { name: "Add a model provider" })).toBeVisible(); + const lmStudio = onboarding.getByRole("button", { + name: /LM Studio.*Use models running in LM Studio/u, + }); + await lmStudio.click(); + await expect(lmStudio).toHaveAttribute("aria-pressed", "true"); + + // Discovery must return and persist at least one model before onboarding can advance. + await next.click(); + await expect( + onboarding.getByRole("heading", { name: "Everything Aiden brings together" }), + ).toBeVisible(); + await onboarding.getByRole("button", { name: "Start using Aiden" }).click(); + await expect(onboarding).toBeHidden(); + await expect( + page.getByRole("button", { + name: /^Selected model: .+\. Choose a model\.$/u, + }), + ).toBeVisible(); +} + +function withTimeout(promise: Promise, label: string, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error(`${label} timed out after ${timeoutMs} ms.`)), + timeoutMs, + ); + promise.then( + (value) => { + clearTimeout(timeout); + resolve(value); + }, + (error: unknown) => { + clearTimeout(timeout); + reject(error); + }, + ); + }); +} + +function processIsAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code === "EPERM"; + } +} + +async function waitForProcessExit(pid: number, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (!processIsAlive(pid)) return true; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + return !processIsAlive(pid); +} + +async function terminateOwnedProcess(child: ChildProcess): Promise { + const pid = child.pid; + if (!pid || !processIsAlive(pid)) return; + child.kill("SIGTERM"); + if (await waitForProcessExit(pid, 2_000)) return; + child.kill("SIGKILL"); + if (!(await waitForProcessExit(pid, 2_000))) { + throw new Error(`Test-owned Electron process ${pid} survived SIGKILL.`); + } +} + +/** Close Electron and fail observably if its test-owned main process leaks. */ +export async function closeAiden(app: ElectronApplication | undefined): Promise { + if (!app) return; + const child = app.process(); + const pid = child.pid; + let closeError: unknown; + try { + await withTimeout(app.close(), "Electron shutdown", PROCESS_EXIT_TIMEOUT_MS); + } catch (error) { + closeError = error; + } + const leaked = Boolean(pid && processIsAlive(pid)); + if (leaked) await terminateOwnedProcess(child); + if (closeError || leaked) { + const detail = closeError instanceof Error ? ` ${closeError.message}` : ""; + throw new Error( + `Electron teardown did not finish cleanly${pid ? ` for PID ${pid}` : ""}.${detail}`, + ); + } +} + +export async function readJsonFile(filePath: string): Promise { + return JSON.parse(await readFile(filePath, "utf8")) as T; +} + +async function readOptionalJson(filePath: string): Promise { + try { + return await readJsonFile(filePath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw error; + } +} + +/** Fresh keyless profiles must contain no encrypted provider credential entries. */ +export async function assertNoPersistedProviderCredentials(aiden: AidenE2e): Promise { + const providerKeys = await readOptionalJson(path.join(aiden.userDataDir, "provider-keys.json")); + if (providerKeys !== undefined) { + if (!providerKeys || typeof providerKeys !== "object" || Array.isArray(providerKeys)) { + throw new Error("provider-keys.json has an unexpected shape."); + } + const entries = Object.keys(providerKeys); + if (entries.length > 0) { + throw new Error(`The keyless E2E profile persisted provider keys: ${entries.join(", ")}.`); + } + } + + const piCredentials = await readOptionalJson( + path.join(aiden.userDataDir, "pi-provider-credentials.json"), + ); + if (piCredentials !== undefined) { + const entries = + piCredentials && typeof piCredentials === "object" && !Array.isArray(piCredentials) + ? (piCredentials as Record).entries + : undefined; + if (!entries || typeof entries !== "object" || Array.isArray(entries)) { + throw new Error("pi-provider-credentials.json has an unexpected shape."); + } + const providerIds = Object.keys(entries); + if (providerIds.length > 0) { + throw new Error( + `The keyless E2E profile persisted Pi credentials: ${providerIds.join(", ")}.`, + ); + } + } +} + +function formatFailure(error: unknown): string { + return error instanceof Error ? `${error.name}: ${error.message}` : String(error); +} + +export const test = base.extend({ + portableConfigSeed: ["lmstudio", { option: true }], + aiden: async ({ browserName: _browserName, portableConfigSeed }, use, testInfo) => { + let rootDir: string | undefined; + let mock: MockLmStudio | undefined; + let app: ElectronApplication | undefined; + let state: AidenE2e | undefined; + let primaryFailure: unknown; + let failed = false; + try { + await assertBuiltElectronApp(); + const testRootDir = await mkdtemp(path.join(tmpdir(), "aiden-e2e-")); + const testUserDataDir = path.join(testRootDir, "user-data"); + const testConfigDir = path.join(testRootDir, "portable-config"); + const testXdgCacheDir = path.join(testRootDir, "xdg-cache"); + const testXdgConfigDir = path.join(testRootDir, "xdg-config"); + const testXdgDataDir = path.join(testRootDir, "xdg-data"); + rootDir = testRootDir; + await Promise.all([ + mkdir(testUserDataDir, { recursive: true, mode: 0o700 }), + mkdir(testConfigDir, { recursive: true, mode: 0o700 }), + mkdir(testXdgCacheDir, { recursive: true, mode: 0o700 }), + mkdir(testXdgConfigDir, { recursive: true, mode: 0o700 }), + mkdir(testXdgDataDir, { recursive: true, mode: 0o700 }), + ]); + + mock = LIVE_LM_STUDIO_ACCEPTANCE ? undefined : await startMockLmStudio(); + const lmStudio: LmStudioEndpoint = mock ?? { + baseUrl: resolveLiveLmStudioBaseUrl(), + live: true, + requests: [], + }; + await seedPortableConfig(testConfigDir, lmStudio.baseUrl, portableConfigSeed); + + const redirectDefaultLmStudio = portableConfigSeed === "empty" && !lmStudio.live; + const redirectOrigin = redirectDefaultLmStudio ? new URL(lmStudio.baseUrl).origin : undefined; + if (redirectOrigin === DEFAULT_LM_STUDIO_ORIGIN) { + throw new Error( + "The deterministic LM Studio fixture did not receive a random loopback port.", + ); + } + + const launch = async (): Promise => { + const launchEnvironment: Record = { + ...isolatedAppEnvironment(), + AIDEN_CONFIG_DIR: testConfigDir, + AIDEN_RUNTIME_PROFILE: "development", + HOME: testRootDir, + XDG_CACHE_HOME: testXdgCacheDir, + XDG_CONFIG_HOME: testXdgConfigDir, + XDG_DATA_HOME: testXdgDataDir, + ...(redirectOrigin ? { [LM_STUDIO_REDIRECT_ENV]: redirectOrigin } : {}), + }; + const launchArgs = [ + "-r", + ELECTRON_TEST_BOOTSTRAP, + "--force-renderer-accessibility", + `--user-data-dir=${testUserDataDir}`, + REPOSITORY_ROOT, + ]; + const launchedApp = await electron.launch({ + args: launchArgs, + cwd: REPOSITORY_ROOT, + env: launchEnvironment, + }); + app = launchedApp; + await assertRuntimeIsolation(launchedApp, { + userDataDir: testUserDataDir, + configDir: testConfigDir, + homeDir: testRootDir, + xdgCacheDir: testXdgCacheDir, + xdgConfigDir: testXdgConfigDir, + xdgDataDir: testXdgDataDir, + environment: launchEnvironment, + }); + const page = await firstAidenWindow(launchedApp); + if (state) { + state.app = launchedApp; + state.page = page; + } + return page; + }; + + const firstPage = await launch(); + const aidenState: AidenE2e = { + app: app!, + page: firstPage, + userDataDir: testUserDataDir, + configDir: testConfigDir, + rootDir: testRootDir, + lmStudio, + relaunch: async () => { + const previous = app; + app = undefined; + await closeAiden(previous); + return launch(); + }, + }; + state = aidenState; + await use(aidenState); + } catch (error) { + primaryFailure = error; + failed = true; + } + + const teardownFailures: unknown[] = []; + try { + await closeAiden(app); + } catch (error) { + teardownFailures.push(error); + } + if (mock) { + try { + await closeMockLmStudio(mock); + } catch (error) { + teardownFailures.push(error); + } + } + if (rootDir) { + try { + await rm(rootDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 150 }); + } catch (error) { + teardownFailures.push(error); + } + } + + if (teardownFailures.length > 0) { + const details = teardownFailures.map(formatFailure).join("\n"); + try { + await testInfo.attach("e2e-teardown-failures", { + body: Buffer.from(`${details}\n`, "utf8"), + contentType: "text/plain", + }); + } catch (error) { + process.stderr.write( + `Could not attach E2E teardown diagnostics: ${formatFailure(error)}\n`, + ); + } + process.stderr.write(`E2E teardown failures:\n${details}\n`); + if (!failed) throw new Error(`E2E teardown failed:\n${details}`); + } + if (failed) throw primaryFailure; + }, +}); + +export { expect }; From 5edd7e38531297e6a62a5bc748657ddf824891aa Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Mon, 10 Aug 2026 16:51:21 -0400 Subject: [PATCH 36/62] Test LM Studio onboarding persistence --- tests/e2e/onboarding-lmstudio.spec.ts | 184 ++++++++++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 tests/e2e/onboarding-lmstudio.spec.ts diff --git a/tests/e2e/onboarding-lmstudio.spec.ts b/tests/e2e/onboarding-lmstudio.spec.ts new file mode 100644 index 00000000..ead83ec7 --- /dev/null +++ b/tests/e2e/onboarding-lmstudio.spec.ts @@ -0,0 +1,184 @@ +import path from "node:path"; +import { + assertNoPersistedProviderCredentials, + E2E_MODEL_DISPLAY_NAME, + E2E_MODEL_ID, + E2E_PROFILE_NAME, + expect, + finishLmStudioOnboarding, + LM_STUDIO_PROVIDER_ID, + readJsonFile, + test, + type AidenE2e, +} from "./fixtures"; + +const DEFAULT_LM_STUDIO_BASE_URL = "http://127.0.0.1:1234/v1"; + +function record(value: unknown, label: string): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`${label} was not a JSON object.`); + } + return value as Record; +} + +async function persistedProviderState(aiden: AidenE2e) { + const [portableValue, cacheValue, localValue] = await Promise.all([ + readJsonFile(path.join(aiden.configDir, "config.json")), + readJsonFile(path.join(aiden.userDataDir, "provider-model-cache.json")), + readJsonFile(path.join(aiden.userDataDir, "config.json")), + ]); + const portable = record(portableValue, "portable config"); + const providers = portable.providers; + if (!Array.isArray(providers)) throw new Error("Portable providers were not an array."); + const provider = providers.find( + (value) => record(value, "portable provider").id === LM_STUDIO_PROVIDER_ID, + ); + if (!provider) throw new Error("The canonical LM Studio provider was not persisted."); + + const cache = record(cacheValue, "provider model cache"); + const byProvider = record(cache.byProvider, "provider model cache entries"); + const providerCache = record(byProvider[LM_STUDIO_PROVIDER_ID], "LM Studio model cache"); + return { + portable, + provider: record(provider, "canonical LM Studio provider"), + cache, + byProvider, + providerCache, + local: record(localValue, "machine-local config"), + }; +} + +test.describe("fresh portable config", () => { + test.use({ portableConfigSeed: "empty" }); + + test("onboarding creates the canonical LM Studio provider and survives relaunch", async ({ + aiden, + }) => { + const initialPortable = record( + await readJsonFile(path.join(aiden.configDir, "config.json")), + "initial portable config", + ); + expect(initialPortable.providers).toEqual([]); + expect(aiden.lmStudio.baseUrl).not.toBe(DEFAULT_LM_STUDIO_BASE_URL); + + await finishLmStudioOnboarding(aiden.page); + + const beforeRelaunch = await persistedProviderState(aiden); + expect(Object.keys(beforeRelaunch.byProvider)).toEqual([LM_STUDIO_PROVIDER_ID]); + expect(beforeRelaunch.provider).toMatchObject({ + id: LM_STUDIO_PROVIDER_ID, + kind: "openai", + label: "LM Studio (local)", + baseUrl: DEFAULT_LM_STUDIO_BASE_URL, + defaultModel: E2E_MODEL_ID, + needsKey: false, + deployment: "local", + }); + expect(beforeRelaunch.provider).not.toHaveProperty("models"); + expect(beforeRelaunch.provider).not.toHaveProperty("modelMetadata"); + expect(beforeRelaunch.providerCache).toMatchObject({ + models: [E2E_MODEL_ID], + modelMetadata: { + [E2E_MODEL_ID]: { + source: "lmstudio", + name: E2E_MODEL_DISPLAY_NAME, + type: "llm", + vision: true, + }, + }, + }); + expect(aiden.lmStudio.requests).toEqual( + expect.arrayContaining([expect.objectContaining({ method: "GET", url: "/api/v1/models" })]), + ); + await assertNoPersistedProviderCredentials(aiden); + + const page = await aiden.relaunch(); + await expect(page.locator('section[aria-label="Set up Aiden"]')).toHaveCount(0); + await expect( + page.getByRole("button", { + name: new RegExp(`^Selected model: ${E2E_MODEL_DISPLAY_NAME}\\. Choose a model\\.$`, "u"), + }), + ).toBeVisible(); + const afterRelaunch = await persistedProviderState(aiden); + expect(afterRelaunch.provider).toEqual(beforeRelaunch.provider); + expect(afterRelaunch.providerCache).toEqual(beforeRelaunch.providerCache); + await assertNoPersistedProviderCredentials(aiden); + }); +}); + +test("onboarding preserves an existing keyless LM Studio profile across a full relaunch", async ({ + aiden, +}) => { + await finishLmStudioOnboarding(aiden.page); + + await expect + .poll(() => + aiden.page.evaluate(() => ({ + providerId: localStorage.getItem("aiden-agent.providerId"), + model: localStorage.getItem("aiden-agent.model"), + })), + ) + .toEqual({ providerId: LM_STUDIO_PROVIDER_ID, model: E2E_MODEL_ID }); + + await expect + .poll(async () => (await persistedProviderState(aiden)).provider.defaultModel) + .toBe(E2E_MODEL_ID); + const beforeRelaunch = await persistedProviderState(aiden); + expect(beforeRelaunch.provider).toMatchObject({ + id: LM_STUDIO_PROVIDER_ID, + kind: "openai", + label: "LM Studio (local)", + baseUrl: aiden.lmStudio.baseUrl, + defaultModel: E2E_MODEL_ID, + needsKey: false, + deployment: "local", + }); + expect(beforeRelaunch.provider).not.toHaveProperty("models"); + expect(beforeRelaunch.provider).not.toHaveProperty("modelMetadata"); + expect(Object.keys(beforeRelaunch.byProvider)).toEqual([LM_STUDIO_PROVIDER_ID]); + expect(beforeRelaunch.providerCache).toMatchObject({ + models: [E2E_MODEL_ID], + modelMetadata: { + [E2E_MODEL_ID]: { + source: "lmstudio", + name: E2E_MODEL_DISPLAY_NAME, + type: "llm", + vision: true, + toolCall: true, + reasoning: false, + contextLength: 32_768, + parameterCount: "1B", + format: "Q4_K_M", + }, + }, + }); + expect(beforeRelaunch.local).toMatchObject({ seeded: true }); + expect(beforeRelaunch.local).toHaveProperty("workspaces"); + expect(beforeRelaunch.local).not.toHaveProperty("providers"); + await assertNoPersistedProviderCredentials(aiden); + + const page = await aiden.relaunch(); + await expect(page.locator('section[aria-label="Set up Aiden"]')).toHaveCount(0); + await expect( + page.getByRole("button", { + name: new RegExp(`^Selected model: ${E2E_MODEL_DISPLAY_NAME}\\. Choose a model\\.$`, "u"), + }), + ).toBeVisible(); + await expect + .poll(() => + page.evaluate(() => ({ + providerId: localStorage.getItem("aiden-agent.providerId"), + model: localStorage.getItem("aiden-agent.model"), + })), + ) + .toEqual({ providerId: LM_STUDIO_PROVIDER_ID, model: E2E_MODEL_ID }); + + await page.getByRole("button", { name: "Profile", exact: true }).click(); + await expect( + page.getByRole("heading", { level: 2, name: E2E_PROFILE_NAME, exact: true }), + ).toBeVisible(); + const afterRelaunch = await persistedProviderState(aiden); + expect(afterRelaunch.provider).toEqual(beforeRelaunch.provider); + expect(afterRelaunch.providerCache).toEqual(beforeRelaunch.providerCache); + await assertNoPersistedProviderCredentials(aiden); +}); From f7ce6aacec618113f55f127a01a118c10a2b2a0b Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Mon, 10 Aug 2026 16:51:21 -0400 Subject: [PATCH 37/62] Test exact multimodal chat payload --- tests/e2e/lmstudio-chat-attachments.spec.ts | 140 ++++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 tests/e2e/lmstudio-chat-attachments.spec.ts diff --git a/tests/e2e/lmstudio-chat-attachments.spec.ts b/tests/e2e/lmstudio-chat-attachments.spec.ts new file mode 100644 index 00000000..f278f3f4 --- /dev/null +++ b/tests/e2e/lmstudio-chat-attachments.spec.ts @@ -0,0 +1,140 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { + assertNoPersistedProviderCredentials, + E2E_ASSISTANT_RESPONSE, + E2E_MODEL_ID, + expect, + finishLmStudioOnboarding, + REPOSITORY_ROOT, + test, + type CapturedLmStudioRequest, +} from "./fixtures"; + +const CLIPBOARD_IMAGE_NAME = "Pasted image.png"; + +function record(value: unknown, label: string): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`${label} was not an object.`); + } + return value as Record; +} + +async function safePngBase64(): Promise { + const fixture = await readFile(path.join(REPOSITORY_ROOT, "resources", "app-icon.png")); + return fixture.toString("base64"); +} + +async function pasteImage( + page: Parameters[0], + base64: string, + text = "", +): Promise { + const composer = page.locator("textarea"); + await composer.evaluate( + (element, { imageBase64, pastedText }) => { + const binary = atob(imageBase64); + const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0)); + const image = new File([bytes], "clipboard.png", { type: "image/png" }); + const clipboard = new DataTransfer(); + clipboard.items.add(image); + clipboard.setData("text/plain", pastedText); + element.dispatchEvent( + new ClipboardEvent("paste", { + bubbles: true, + cancelable: true, + clipboardData: clipboard, + }), + ); + }, + { imageBase64: base64, pastedText: text }, + ); + await expect(page.getByRole("button", { name: `Remove ${CLIPBOARD_IMAGE_NAME}` })).toBeVisible(); +} + +function multimodalRequest( + requests: CapturedLmStudioRequest[], + prompt: string, +): CapturedLmStudioRequest { + const matches = requests.filter((request) => { + if (request.method !== "POST" || request.url !== "/v1/chat/completions") return false; + const body = record(request.body, "chat completion body"); + const messages = body.messages; + return ( + Array.isArray(messages) && + messages.some((message) => { + const candidate = record(message, "chat message"); + if (candidate.role !== "user" || !Array.isArray(candidate.content)) return false; + return candidate.content.some((part) => record(part, "message part").text === prompt); + }) + ); + }); + if (matches.length !== 1) { + throw new Error(`Expected one captured multimodal request, received ${matches.length}.`); + } + return matches[0]; +} + +test("deterministic LM Studio chat sends the exact keyless multimodal payload", async ({ + aiden, +}) => { + const { page } = aiden; + const imageBase64 = await safePngBase64(); + await finishLmStudioOnboarding(page); + + const composer = page.locator("textarea"); + await pasteImage(page, imageBase64); + await expect(composer).toHaveValue(""); + await page.getByRole("button", { name: `Remove ${CLIPBOARD_IMAGE_NAME}` }).click(); + await expect(page.getByRole("button", { name: `Remove ${CLIPBOARD_IMAGE_NAME}` })).toHaveCount(0); + + const prompt = "Deterministic multimodal request from the Aiden E2E suite."; + await pasteImage(page, imageBase64, prompt); + await expect(composer).toHaveValue(prompt); + await page.getByRole("button", { name: "Send message" }).click(); + + // The mock can complete before a transient Stop button paints. The durable + // assistant message after the streaming-reveal handoff is the contract. + await expect(page.getByRole("button", { name: "Copy message" })).toHaveCount(2); + await expect(page.locator(".streaming-reveal")).toHaveCount(0); + const assistantResponse = page.getByText(E2E_ASSISTANT_RESPONSE, { exact: true }); + await expect(assistantResponse).toHaveCount(1); + await expect(assistantResponse).toBeVisible(); + await expect(page.getByText("Generation failed", { exact: true })).toHaveCount(0); + + const captured = multimodalRequest(aiden.lmStudio.requests, prompt); + expect(captured.headers.authorization).toBeUndefined(); + expect(captured.headers["x-api-key"]).toBeUndefined(); + const body = record(captured.body, "captured chat completion"); + expect(body.model).toBe(E2E_MODEL_ID); + expect(body.stream).toBe(true); + const messages = body.messages; + if (!Array.isArray(messages)) throw new Error("Captured request messages were not an array."); + const userMessage = messages + .map((message) => record(message, "captured message")) + .find( + (message) => + message.role === "user" && + Array.isArray(message.content) && + message.content.some((part) => record(part, "captured message part").text === prompt), + ); + if (!userMessage || !Array.isArray(userMessage.content)) { + throw new Error("The captured request did not contain the submitted user message."); + } + const parts = userMessage.content.map((part) => record(part, "captured user content part")); + expect(parts).toEqual([ + { type: "text", text: prompt }, + { + type: "image_url", + image_url: { url: `data:image/png;base64,${imageBase64}` }, + }, + ]); + await assertNoPersistedProviderCredentials(aiden); + + await composer.fill("Unsaved draft must stay out of the next chat."); + await pasteImage(page, imageBase64); + await page.getByRole("button", { name: "New Agent", exact: true }).click(); + await expect(page.getByRole("heading", { level: 1 })).toHaveText("New agent"); + await expect(page.locator("textarea")).toHaveValue(""); + await expect(page.getByRole("button", { name: `Remove ${CLIPBOARD_IMAGE_NAME}` })).toHaveCount(0); +}); From 754ff7f66d56af3612720ef414efa6acb17e4617 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Mon, 10 Aug 2026 16:51:21 -0400 Subject: [PATCH 38/62] Add opt-in live LM Studio vision test --- .../lmstudio-chat-attachments.live.spec.ts | 126 ++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 tests/e2e/lmstudio-chat-attachments.live.spec.ts diff --git a/tests/e2e/lmstudio-chat-attachments.live.spec.ts b/tests/e2e/lmstudio-chat-attachments.live.spec.ts new file mode 100644 index 00000000..a399b53c --- /dev/null +++ b/tests/e2e/lmstudio-chat-attachments.live.spec.ts @@ -0,0 +1,126 @@ +import { randomInt } from "node:crypto"; +import { expect, finishLmStudioOnboarding, LIVE_LM_STUDIO_ACCEPTANCE, test } from "./fixtures"; + +const INFERENCE_TIMEOUT_MS = 120_000; +const CLIPBOARD_IMAGE_NAME = "Pasted image.png"; + +type LiveModel = { + key: string; + displayName: string; + loaded: boolean; +}; + +async function resolveVisionModel(baseUrl: string): Promise { + const inventoryUrl = new URL(baseUrl); + inventoryUrl.pathname = "/api/v1/models"; + const response = await fetch(inventoryUrl); + if (!response.ok) { + throw new Error(`LM Studio model inventory failed: ${response.status} ${response.statusText}.`); + } + const payload = (await response.json()) as { models?: unknown }; + const models = Array.isArray(payload.models) ? payload.models : []; + const candidates = models.flatMap((value): LiveModel[] => { + if (!value || typeof value !== "object" || Array.isArray(value)) return []; + const model = value as Record; + const capabilities = model.capabilities; + if (!capabilities || typeof capabilities !== "object" || Array.isArray(capabilities)) return []; + if ((capabilities as Record).vision !== true || model.type !== "llm") + return []; + if (typeof model.key !== "string" || typeof model.display_name !== "string") return []; + return [ + { + key: model.key, + displayName: model.display_name, + loaded: Array.isArray(model.loaded_instances) && model.loaded_instances.length > 0, + }, + ]; + }); + const selected = candidates.find((model) => model.loaded) ?? candidates[0]; + if (selected) return selected; + throw new Error(`LM Studio has no vision-capable LLM at ${baseUrl}.`); +} + +async function selectVisionModel( + page: Parameters[0], + model: LiveModel, +): Promise { + await page.getByRole("button", { name: /^Selected model:/u }).click(); + const listTab = page.getByRole("tab", { name: "List" }); + if (await listTab.count()) await listTab.click(); + await page + .getByRole("listbox", { name: "Suggestions" }) + .getByRole("option") + .filter({ hasText: model.displayName }) + .click(); +} + +async function pasteVisionToken( + page: Parameters[0], + text: string, + token: string, +): Promise { + await page.locator("textarea").evaluate( + async (element, { pastedText, visionToken }) => { + const canvas = document.createElement("canvas"); + canvas.width = 720; + canvas.height = 320; + const context = canvas.getContext("2d"); + if (!context) throw new Error("Canvas is unavailable."); + context.fillStyle = "white"; + context.fillRect(0, 0, canvas.width, canvas.height); + context.fillStyle = "black"; + context.font = "bold 110px sans-serif"; + context.textAlign = "center"; + context.textBaseline = "middle"; + context.fillText(visionToken, canvas.width / 2, canvas.height / 2); + const blob = await new Promise((resolve, reject) => + canvas.toBlob( + (value) => (value ? resolve(value) : reject(new Error("PNG encoding failed."))), + "image/png", + ), + ); + const clipboard = new DataTransfer(); + clipboard.items.add(new File([blob], "vision-token.png", { type: "image/png" })); + clipboard.setData("text/plain", pastedText); + element.dispatchEvent( + new ClipboardEvent("paste", { + bubbles: true, + cancelable: true, + clipboardData: clipboard, + }), + ); + }, + { pastedText: text, visionToken: token }, + ); + await expect(page.getByText(CLIPBOARD_IMAGE_NAME, { exact: true })).toBeVisible(); +} + +test.skip( + !LIVE_LM_STUDIO_ACCEPTANCE, + "Run only through the explicit test:e2e:live:lmstudio acceptance command.", +); + +test("opt-in live LM Studio vision acceptance", async ({ aiden }) => { + test.setTimeout(INFERENCE_TIMEOUT_MS + 60_000); + const model = await resolveVisionModel(aiden.lmStudio.baseUrl); + await finishLmStudioOnboarding(aiden.page); + await selectVisionModel(aiden.page, model); + + const visionToken = randomInt(1000, 10_000).toString(); + const prompt = "Read the attached image and reply with only the four-digit code it contains."; + await pasteVisionToken(aiden.page, prompt, visionToken); + await aiden.page.getByRole("button", { name: "Send message" }).click(); + const copyButtons = aiden.page.getByRole("button", { name: "Copy message" }); + await expect(copyButtons).toHaveCount(2, { + timeout: INFERENCE_TIMEOUT_MS, + }); + await expect(aiden.page.getByText("Generation failed", { exact: true })).toHaveCount(0); + await expect(aiden.page.getByText(prompt, { exact: true })).toBeVisible(); + await expect(aiden.page.getByRole("img", { name: CLIPBOARD_IMAGE_NAME })).toHaveCount(1); + const assistantMessage = copyButtons + .last() + .locator( + "xpath=ancestor::div[contains(concat(' ', normalize-space(@class), ' '), ' group ')][1]", + ); + await expect(assistantMessage).toContainText(new RegExp(`\\b${visionToken}\\b`, "u")); +}); From 09d946d004908dbb68a49261af4c201981367b6b Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Mon, 10 Aug 2026 16:51:21 -0400 Subject: [PATCH 39/62] Test chat shell interactions --- tests/e2e/chat-shell-interactions.spec.ts | 133 ++++++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 tests/e2e/chat-shell-interactions.spec.ts diff --git a/tests/e2e/chat-shell-interactions.spec.ts b/tests/e2e/chat-shell-interactions.spec.ts new file mode 100644 index 00000000..6ea0e190 --- /dev/null +++ b/tests/e2e/chat-shell-interactions.spec.ts @@ -0,0 +1,133 @@ +import { expect, finishLmStudioOnboarding, test } from "./fixtures"; + +const PASTED_IMAGE_NAME = "Pasted image.png"; +const ONE_PIXEL_PNG_BASE64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4z8DwHwAFgAI/ScL2aQAAAABJRU5ErkJggg=="; + +async function pasteImage(page: Parameters[0]): Promise { + const composer = page.locator("textarea"); + await composer.evaluate((element, base64) => { + const bytes = Uint8Array.from(atob(base64), (character) => character.charCodeAt(0)); + const clipboard = new DataTransfer(); + clipboard.items.add(new File([bytes], "clipboard.png", { type: "image/png" })); + element.dispatchEvent( + new ClipboardEvent("paste", { + bubbles: true, + cancelable: true, + clipboardData: clipboard, + }), + ); + }, ONE_PIXEL_PNG_BASE64); + await expect(page.getByRole("button", { name: `Remove ${PASTED_IMAGE_NAME}` })).toBeVisible(); +} + +test("chat shell keeps local interactions isolated and keyboard-accessible", async ({ aiden }) => { + const { page } = aiden; + await finishLmStudioOnboarding(page); + + const composer = page.locator("textarea"); + const modelPicker = page.getByRole("button", { name: /^Selected model:/u }); + await modelPicker.focus(); + await page.keyboard.press("Enter"); + await expect(page.getByRole("tablist", { name: "Model picker view" })).toBeVisible(); + await page.getByRole("tab", { name: "List" }).click(); + const modelFilter = page.getByRole("combobox", { name: "Chat model" }); + await expect(modelFilter).toBeFocused(); + await page.keyboard.press("Escape"); + await expect(modelFilter).toBeHidden(); + await expect(modelPicker).toBeFocused(); + + await composer.fill("/"); + const slashCommands = page.getByRole("listbox", { name: "Slash commands" }); + await expect(slashCommands).toBeVisible(); + await expect(slashCommands.getByRole("option").filter({ hasText: "/model" })).toBeVisible(); + await composer.press("Escape"); + await expect(slashCommands).toBeHidden(); + await expect(composer).toHaveValue("/"); + + await composer.fill("$"); + const skills = page.getByRole("listbox", { name: "Skills" }); + await expect(skills).toBeVisible(); + await expect(skills.getByText("No skills match this query.", { exact: true })).toBeVisible(); + await composer.press("Escape"); + await expect(skills).toBeHidden(); + await composer.fill(""); + + const environment = page.getByRole("button", { name: "Show environment" }); + const environmentSummary = page.getByRole("complementary", { + name: "Environment summary", + }); + await expect(environment).toHaveAttribute("aria-pressed", "false"); + await environment.click(); + await expect(environmentSummary).toBeVisible(); + await expect(environmentSummary.getByText("No workspace folder", { exact: true })).toBeVisible(); + await expect( + environmentSummary.getByText( + "Choose a local workspace to see its environment, changes, and branch.", + { exact: true }, + ), + ).toBeVisible(); + await expect(page.getByRole("button", { name: "Hide environment" })).toHaveAttribute( + "aria-pressed", + "true", + ); + await page.getByRole("button", { name: "Hide environment" }).click(); + await expect(environmentSummary).toBeHidden(); + + const terminal = page.getByRole("button", { name: "Show terminal" }); + await expect(terminal).toBeDisabled(); + await expect(terminal).toHaveAttribute("aria-pressed", "false"); + + const permission = page.getByRole("button", { + name: /^Workspace access: Ask first/u, + }); + await permission.click(); + const noAccess = page.getByRole("menuitemcheckbox", { name: "No access" }); + await expect(noAccess).toHaveAttribute("aria-checked", "false"); + await noAccess.click(); + await expect(page.getByRole("button", { name: /^Workspace access: No access/u })).toBeVisible(); + await page.getByRole("button", { name: /^Workspace access: No access/u }).click(); + await page.getByRole("menuitemcheckbox", { name: "Ask first" }).click(); + await expect(permission).toBeVisible(); + + await composer.fill("Draft and attachment stay with this chat only."); + await pasteImage(page); + await page.getByRole("button", { name: `Remove ${PASTED_IMAGE_NAME}` }).click(); + await expect(page.getByRole("button", { name: `Remove ${PASTED_IMAGE_NAME}` })).toHaveCount(0); + await pasteImage(page); + + await page.getByRole("button", { name: "New Agent", exact: true }).click(); + await expect(page.getByRole("heading", { level: 1 })).toHaveText("New agent"); + await expect(composer).toHaveValue(""); + await expect(page.getByRole("button", { name: `Remove ${PASTED_IMAGE_NAME}` })).toHaveCount(0); + + const sidebarSearch = page.getByRole("searchbox", { name: "Search chats…" }); + await sidebarSearch.fill("not-a-real-chat-title"); + await expect(page.getByText("No matches", { exact: true })).toBeVisible(); + await sidebarSearch.fill(""); + await expect(page.getByText("No matches", { exact: true })).toBeHidden(); + + await sidebarSearch.evaluate((element) => element.blur()); + const visibleSidebarToggle = page.getByRole("button", { name: "Hide sidebar" }); + await expect(visibleSidebarToggle).toHaveAttribute("aria-keyshortcuts", "Meta+B"); + await page.keyboard.press("Meta+B"); + const sidebarToggle = page.getByRole("button", { name: "Show sidebar" }); + await expect(sidebarToggle).toHaveAttribute("aria-pressed", "false"); + await expect( + page.locator("aside").filter({ has: page.locator("[data-sidebar]") }), + ).toHaveAttribute("aria-hidden", "true"); + await page.keyboard.press("Meta+B"); + await expect(page.getByRole("button", { name: "Hide sidebar" })).toHaveAttribute( + "aria-pressed", + "true", + ); + + await page.keyboard.press("Meta+K"); + const palette = page.locator("[data-command-palette-content]"); + await expect(palette).toBeVisible(); + const commandSearch = page.getByRole("combobox", { name: "Search commands" }); + await commandSearch.fill("toggle sidebar"); + await expect(palette.getByText("Toggle sidebar", { exact: true })).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(palette).toBeHidden(); +}); From e5a443b6c2cb8d9be26fb4b4c09f06d4890cf7e2 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Mon, 10 Aug 2026 16:51:21 -0400 Subject: [PATCH 40/62] Test secondary application surfaces --- tests/e2e/assistant-scheduled-profile.spec.ts | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 tests/e2e/assistant-scheduled-profile.spec.ts diff --git a/tests/e2e/assistant-scheduled-profile.spec.ts b/tests/e2e/assistant-scheduled-profile.spec.ts new file mode 100644 index 00000000..ca5633ac --- /dev/null +++ b/tests/e2e/assistant-scheduled-profile.spec.ts @@ -0,0 +1,92 @@ +import { expect, finishLmStudioOnboarding, test } from "./fixtures"; + +test("local Assistant, Scheduled, Profile, and About surfaces stay safe to explore", async ({ + aiden, +}) => { + const { page } = aiden; + await finishLmStudioOnboarding(page); + + // Assistant is a local dock. Exercise its state without submitting a prompt + // (and therefore without creating a provider request or an assistant thread). + await page.getByRole("button", { name: "Open Aiden" }).click(); + const assistantComposer = page.getByRole("textbox", { name: "Message Aiden" }); + const assistantPanel = assistantComposer.locator( + "xpath=ancestor::div[.//button[@aria-label='New conversation']][1]", + ); + await expect(assistantComposer).toBeVisible(); + await expect(page.getByRole("button", { name: "New conversation" })).toBeVisible(); + await expect(page.getByText("Try asking", { exact: true })).toBeVisible(); + // Main chat history has its own sidebar “Recent” bucket. A fresh Assistant + // session deliberately has no saved Assistant threads to list yet. + await expect(assistantPanel.getByText("Recent", { exact: true })).toHaveCount(0); + await assistantComposer.fill("Unsaved assistant draft"); + await page.getByRole("button", { name: "Minimize Aiden" }).click(); + await expect(page.getByRole("button", { name: "Open Aiden" })).toBeVisible(); + await page.getByRole("button", { name: "Open Aiden" }).click(); + await expect(assistantComposer).toHaveValue("Unsaved assistant draft"); + await assistantComposer.fill(""); + await page.getByRole("button", { name: "Minimize Aiden" }).click(); + + // Scheduled templates only open an editor. Escape closes it without saving; + // unavailable creation remains a valid, explicit product state. + await page.getByRole("button", { name: "Scheduled", exact: true }).click(); + await expect(page.getByText("Scheduled tasks", { exact: true }).first()).toBeVisible(); + const taskSearch = page.getByRole("searchbox", { name: "Search scheduled tasks" }); + await taskSearch.fill("definitely-not-a-schedule"); + await expect(page.getByText("No matching tasks", { exact: true })).toBeVisible(); + await taskSearch.fill(""); + await page.getByRole("tab", { name: "Active", exact: true }).click(); + await expect(page.getByRole("tab", { name: "Active", exact: true })).toHaveAttribute( + "aria-selected", + "true", + ); + await page.getByRole("tab", { name: "Paused", exact: true }).click(); + await expect(page.getByRole("tab", { name: "Paused", exact: true })).toHaveAttribute( + "aria-selected", + "true", + ); + await page.getByRole("tab", { name: "All", exact: true }).click(); + const dailyBrief = page.getByRole("button", { name: /Daily brief/u }); + await expect(dailyBrief).toBeVisible(); + if (await dailyBrief.isEnabled()) { + await dailyBrief.click(); + await expect(page.getByRole("dialog")).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(page.getByRole("dialog")).toHaveCount(0); + } else { + await expect(page.getByRole("button", { name: "Create" })).toBeDisabled(); + } + + await page.getByRole("button", { name: "Profile", exact: true }).click(); + await expect(page.getByRole("heading", { name: "Profile", exact: true })).toBeVisible(); + const profileName = page.getByRole("heading", { + level: 2, + name: "E2E Local User", + exact: true, + }); + await expect(profileName).toHaveText("E2E Local User"); + await page.getByRole("button", { name: "Edit profile name" }).click(); + const profileInput = page.getByRole("textbox", { name: "Profile name" }); + await profileInput.fill(" "); + await expect(page.getByRole("button", { name: "Save profile name" })).toBeDisabled(); + await profileInput.fill("Temporary E2E name"); + await expect(page.getByRole("button", { name: "Save profile name" })).toBeEnabled(); + await page.getByRole("button", { name: "Cancel editing profile name" }).click(); + await expect(profileName).toHaveText("E2E Local User"); + + await page.getByRole("button", { name: "Settings", exact: true }).click(); + await expect(page.getByText("All settings", { exact: true })).toBeVisible(); + await page.getByRole("button", { name: "About", exact: true }).click(); + await expect(page.getByRole("heading", { name: "About", exact: true })).toBeVisible(); + await expect(page.getByText(/^Version .+ Beta/u)).toBeVisible(); + + await page.getByRole("button", { name: "Back to app", exact: true }).click(); + await expect(page.getByRole("heading", { name: "Profile", exact: true })).toBeVisible(); + await page.getByRole("button", { name: "New Agent", exact: true }).click(); + const mainComposer = page.locator("textarea"); + await expect(mainComposer).toBeVisible(); + await expect(mainComposer).toHaveValue(""); + await expect( + page.getByRole("button", { name: /^Selected model: .+\. Choose a model\.$/u }), + ).toBeVisible(); +}); From c3b545c2ceeff201267b7681fcc61bf56ca0c170 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Mon, 10 Aug 2026 16:51:22 -0400 Subject: [PATCH 41/62] Test settings and local model selection --- tests/e2e/settings-model-picker.spec.ts | 162 ++++++++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 tests/e2e/settings-model-picker.spec.ts diff --git a/tests/e2e/settings-model-picker.spec.ts b/tests/e2e/settings-model-picker.spec.ts new file mode 100644 index 00000000..f2165938 --- /dev/null +++ b/tests/e2e/settings-model-picker.spec.ts @@ -0,0 +1,162 @@ +import { E2E_MODEL_DISPLAY_NAME, expect, finishLmStudioOnboarding, test } from "./fixtures"; + +const SETTINGS_SECTIONS = [ + "Providers", + "Model Pad", + "Skills", + "MCP Servers", + "Web Search", + "Scheduled tasks", + "Aiden", + "Computer Use", + "Voice", + "Keyboard shortcuts", + "Appearance", + "About", +] as const; + +async function assertRenderedSettingsDestination( + page: Parameters[0], + section: (typeof SETTINGS_SECTIONS)[number], +): Promise { + switch (section) { + case "Providers": + await expect( + page.getByText(/Pi-native providers need only their credentials/u), + ).toBeVisible(); + return; + case "Model Pad": + await expect( + page.getByRole("heading", { level: 2, name: "Personal Model Pad", exact: true }), + ).toBeVisible(); + return; + case "Skills": + await expect( + page.getByText(/Reusable instruction sets the assistant can invoke/u), + ).toBeVisible(); + return; + case "MCP Servers": + await expect(page.getByText(/Connect tool providers or add your own server/u)).toBeVisible(); + return; + case "Web Search": + await expect( + page.getByRole("heading", { level: 2, name: "Web Search (Exa)", exact: true }), + ).toBeVisible(); + return; + case "Scheduled tasks": + await expect( + page.getByRole("heading", { level: 2, name: "Scheduled tasks", exact: true }), + ).toBeVisible(); + return; + case "Aiden": + await expect( + page.getByRole("heading", { level: 2, name: "How Aiden works", exact: true }), + ).toBeVisible(); + return; + case "Computer Use": + await expect(page.getByRole("heading", { level: 2, name: /^Computer Use/u })).toBeVisible(); + return; + case "Voice": + await expect( + page.getByRole("heading", { level: 2, name: "Voice Input", exact: true }), + ).toBeVisible(); + return; + case "Keyboard shortcuts": + await expect( + page.getByRole("heading", { level: 1, name: "Keyboard shortcuts", exact: true }), + ).toBeVisible(); + return; + case "Appearance": + await expect( + page.getByRole("heading", { level: 1, name: "Appearance", exact: true }), + ).toBeVisible(); + return; + case "About": + await expect( + page.getByRole("heading", { level: 2, name: "About", exact: true }), + ).toBeVisible(); + } +} + +test("every Settings destination renders and a one-model local inventory stays usable", async ({ + aiden, +}) => { + const { page } = aiden; + await finishLmStudioOnboarding(page); + + const modelTrigger = page.getByRole("button", { name: /^Selected model:/u }); + await page.getByRole("button", { name: "Settings", exact: true }).click(); + const settingsNavigation = page.getByRole("navigation", { name: "Settings" }); + const settingsSearch = page.getByRole("searchbox", { name: "Search settings" }); + await expect(settingsSearch).toBeVisible(); + + await settingsSearch.fill("not-a-real-settings-section"); + await expect(page.getByText(/No settings match “not-a-real-settings-section”/u)).toBeVisible(); + await settingsSearch.press("Escape"); + await expect(settingsSearch).toHaveValue(""); + await expect( + settingsNavigation.getByRole("button", { name: "Providers", exact: true }), + ).toBeVisible(); + + for (const section of SETTINGS_SECTIONS) { + const destination = settingsNavigation.getByRole("button", { name: section, exact: true }); + await destination.click(); + await expect(destination).toHaveAttribute("aria-current", "page"); + await assertRenderedSettingsDestination(page, section); + } + + const providers = settingsNavigation.getByRole("button", { name: "Providers", exact: true }); + await providers.click(); + const lmStudioRow = page + .getByText("LM Studio (local)", { exact: true }) + .locator("xpath=ancestor::div[.//button[normalize-space()='Configure']][1]"); + const configure = lmStudioRow.getByRole("button", { name: "Configure", exact: true }); + await configure.click(); + const providerDialog = page.getByRole("dialog", { name: "Configure LM Studio (local)" }); + await expect( + providerDialog.getByRole("group", { name: "Base URL" }).locator("input"), + ).toHaveValue(aiden.lmStudio.baseUrl); + await expect(providerDialog.getByText("No authentication", { exact: true })).toBeVisible(); + await expect(providerDialog.locator('input[type="password"]')).toHaveCount(0); + await expect(providerDialog.getByRole("button", { name: "Discover models" })).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(providerDialog).toBeHidden(); + await expect(configure).toBeFocused(); + + await page.getByRole("button", { name: "Back to app" }).click(); + await expect(modelTrigger).toBeVisible(); + + await modelTrigger.click(); + await page.getByRole("tab", { name: "List", exact: true }).click(); + const filter = page.getByRole("combobox", { name: "Chat model" }); + await expect(filter).toBeFocused(); + await filter.fill("this-model-does-not-exist"); + await expect(page.getByText("No models found.", { exact: true })).toBeVisible(); + await filter.press("Escape"); + await expect(filter).toBeHidden(); + await expect(modelTrigger).toBeFocused(); + + await modelTrigger.click(); + await page.getByRole("tab", { name: "List", exact: true }).click(); + const options = page.locator("[cmdk-item]"); + await expect(options).toHaveCount(1); + await expect(options.first()).toContainText(E2E_MODEL_DISPLAY_NAME); + await options.first().click(); + await expect(modelTrigger).toHaveAttribute( + "aria-label", + new RegExp(`^Selected model: ${E2E_MODEL_DISPLAY_NAME}\\. Choose a model\\.$`, "u"), + ); + + await page.getByRole("button", { name: "Settings", exact: true }).click(); + const appearance = settingsNavigation.getByRole("button", { name: "Appearance", exact: true }); + await appearance.click(); + const dark = page.getByRole("radio", { name: "Dark", exact: true }); + await dark.click(); + await expect(dark).toHaveAttribute("aria-checked", "true"); + await providers.click(); + await appearance.click(); + await expect(page.getByRole("radio", { name: "Dark", exact: true })).toHaveAttribute( + "aria-checked", + "true", + ); +}); From afca1650c844b847667c3f06f22f9e7159927d31 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Mon, 10 Aug 2026 16:51:22 -0400 Subject: [PATCH 42/62] Document Electron E2E workflows --- tests/e2e/README.md | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 tests/e2e/README.md diff --git a/tests/e2e/README.md b/tests/e2e/README.md new file mode 100644 index 00000000..9a415ea0 --- /dev/null +++ b/tests/e2e/README.md @@ -0,0 +1,38 @@ +# Electron E2E tests + +The default suite launches the built Electron app through Playwright with a +fresh temporary `--user-data-dir`, a separate absolute `AIDEN_CONFIG_DIR`, and +a test-owned LM Studio-compatible server bound to a random loopback port. It +does not read or contact a developer's Aiden profile, credentials, or LM Studio +server. + +Run the deterministic PR/release gate with: + +```sh +npm run test:e2e +``` + +Useful static checks are `npm run type-check:e2e` and +`npm run test:e2e:list`. The fixture constructs the app environment from a +small system-variable allowlist, adds only its owned storage values, and rejects +any inherited Pi provider auth. It verifies `HOME`, all XDG roots, `userData`, +`sessionData`, and the portable config root from Electron main. A test-only main +preload aligns Electron's native home path with the fixture root. One onboarding +case starts with an empty portable provider list; that same preload routes only +LM Studio's default loopback origin to the case's random-port server while the +persisted product endpoint remains unchanged. Teardown reports +server/process leaks and preserves an original test failure when cleanup also +fails. + +The separately selected live vision acceptance is opt-in and is never part of +the CI or release gate: + +```sh +npm run test:e2e:live:lmstudio +``` + +That command contacts `http://127.0.0.1:1234/v1` by default. Override only for +an explicitly chosen compatible server with `AIDEN_E2E_LMSTUDIO_BASE_URL`. + +Artifacts are written to `test-results/e2e` for traces and failure screenshots, +and `playwright-report/e2e` for the CI HTML report. From 69572a0d538acc4f6bebef5769688f1c3c0c7e4e Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Mon, 10 Aug 2026 16:51:22 -0400 Subject: [PATCH 43/62] Ignore Playwright artifacts --- .gitignore | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.gitignore b/.gitignore index e1182f77..a3495c9f 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,11 @@ Thumbs.db # Logs *.log +# Playwright artifacts +/test-results/ +/playwright-report/ +/blob-report/ + # Temporary files tmp/* From c1ea2f81727661f47ec4099ea84336e71501fded Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Mon, 10 Aug 2026 16:51:22 -0400 Subject: [PATCH 44/62] Register Electron E2E commands --- package.json | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 02c977c5..55d035fe 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,9 @@ "test:subagents:packaged": "node scripts/subagent-packaged-soak.mjs", "test:assistant-automations": "tsx --test main/handlers/scheduled-tasks-parse.test.ts main/services/assistant/automation-runtime-contract.test.ts main/services/assistant/mcp-tool.test.ts main/services/assistant/project-tool.test.ts main/services/assistant/system-prompt.test.ts main/services/assistant/tool-loop-guard.test.ts main/services/mcp-selection.test.ts main/services/scheduled-settings-core.test.ts main/services/schedule-guard.test.ts main/services/schedule-store.test.ts main/services/schedule-tool.test.ts renderer/components/assistant/assistant-ui.test.tsx renderer/lib/scheduled-mcp-access-contract.test.ts renderer/shared/assistant.test.ts", "test:onboarding": "tsx --test main/services/onboarding-reset-core.test.ts main/services/onboarding-reset-lifecycle.test.ts renderer/components/onboarding-flow.test.tsx renderer/lib/onboarding-state.test.ts", + "test:e2e": "npm run type-check:e2e && npm run build && playwright test --config=playwright.config.ts --fail-on-flaky-tests", + "test:e2e:list": "playwright test --config=playwright.config.ts --list", + "test:e2e:live:lmstudio": "npm run type-check:e2e && npm run build && AIDEN_E2E_LIVE_LMSTUDIO=1 playwright test --config=playwright.config.ts", "test:compaction": "tsx --test main/services/pi-compaction-core.test.ts", "test": "tsx --test main/handlers/assistant-parse.test.ts main/services/assistant/system-prompt.test.ts main/services/chat-generation-start.test.ts main/services/chat-title-policy.test.ts main/services/chat-title-routing.test.ts main/services/chat-store-core.test.ts main/services/codex-provider.test.ts main/services/coding-tools.test.ts main/services/config-store-core.test.ts main/services/computer-use/computer-use-foundation.test.ts main/services/computer-use/computer-use-tool.test.ts main/services/computer-use/generation-gate.test.ts main/services/computer-use/safety.test.ts main/services/computer-use/settings-core.test.ts main/services/computer-use/status-core.test.ts main/services/data-store.test.ts main/services/data-store.resilience.test.ts main/services/aiden-config-dir.test.ts main/services/portable-config-core.test.ts main/services/portable-config-core.roundtrip.test.ts main/services/portable-config-watch-core.test.ts main/services/secret-map-core.test.ts main/services/dev-log.test.ts main/services/dictation-coordinator.test.ts main/services/dictation-paste.test.ts main/services/foundation-models-connection.test.ts main/services/foundation-models-connection-core.test.ts main/services/gemini-context-cache.test.ts main/services/generation-bound-connection-cache.test.ts main/services/generation-context.test.ts main/services/generation-messages.test.ts main/services/generation-runtime.test.ts main/services/external-editors.test.ts main/services/git.test.ts main/services/model-runtime-core.test.ts main/services/models.test.ts main/services/mcp-oauth-operation.test.ts main/services/mcp-oauth-session.test.ts main/services/mcp-presets.test.ts main/services/pi-credential-store-core.test.ts main/services/pi-provider-contract.test.ts main/services/profile-share-core.test.ts main/services/profile-share-files.test.ts main/services/profile.test.ts main/services/provider-auth-flow-core.test.ts main/services/provider-auth-owner.test.ts main/services/provider-key-policy.test.ts main/services/provider-list-core.test.ts main/services/quit-barrier.test.ts main/services/scratch-workspace.test.ts main/services/skills-discovery.test.ts main/services/tool-approval.test.ts main/services/local-runtime-status.test.ts main/services/usage-store-core.test.ts main/services/workspace-files.test.ts main/windows/pill-window-security.test.ts renderer/components/assistant/use-assistant-chat.test.ts renderer/components/assistant/assistant-ui.test.tsx renderer/components/activity-feed.test.tsx renderer/components/environment-subagents-contract.test.ts renderer/components/subagents-panel.test.tsx renderer/components/chat-sidebar.test.tsx renderer/components/composer.test.tsx renderer/main/chat-transition.test.tsx renderer/components/usage/profile-share-card.test.tsx renderer/lib/accessibility-refresh.test.ts renderer/lib/agent-activity.test.ts renderer/lib/assistant-dock.test.ts renderer/lib/assistant-motion-contract.test.ts renderer/lib/dialog-motion-contract.test.ts renderer/lib/chat-deletion-cache.test.ts renderer/lib/chat-terminal-sync.test.ts renderer/lib/ipc-stream.test.ts renderer/lib/chat-title-reveal.test.ts renderer/lib/codex-auth-session.test.ts renderer/lib/codex-provider-cache.test.ts renderer/lib/composer-placeholder.test.ts renderer/lib/computer-use-notice.test.ts renderer/lib/dictation-operation-gate.test.ts renderer/lib/editor-preference.test.ts renderer/lib/environment-panel-layout.test.ts renderer/lib/subagent-view-state.test.ts renderer/lib/truncate-path.test.ts renderer/lib/mcp-preset-state.test.ts renderer/lib/model-display.test.ts renderer/lib/model-picker-data.test.ts renderer/lib/profile-share-data.test.ts renderer/lib/sidebar-chat-shortcuts.test.ts renderer/lib/usage-profile-data.test.ts renderer/shared/appearance.test.ts renderer/shared/provider-deployment.test.ts main/handlers/ipc-contract.test.ts main/handlers/chat.parse.test.ts main/handlers/voice-codec.test.ts main/handlers/phase2-parse.test.ts scripts/apple-developer-tools.test.mjs scripts/check-macos-release.test.mjs scripts/computer-use-packaged-acceptance.test.mjs scripts/configure-electron-fuses.test.mjs scripts/model-snapshot-core.test.mjs scripts/prepare-macos-dev-runtime.test.mjs scripts/prepare-macos-package-output.test.mjs scripts/run-macos-distribution.test.mjs scripts/sign-macos.test.mjs scripts/hide-dmg-support-files.test.mjs scripts/update-model-capabilities.test.mjs scripts/vendor-cua-driver.test.mjs scripts/verify-macos-package.test.mjs && npm run test:worktree-remover:native && npm run test:computer-use:native", "test:coverage": "tsx --test --experimental-test-coverage main/handlers/assistant-parse.test.ts main/services/assistant/system-prompt.test.ts main/services/chat-generation-start.test.ts main/services/chat-title-policy.test.ts main/services/chat-title-routing.test.ts main/services/chat-store-core.test.ts main/services/codex-provider.test.ts main/services/coding-tools.test.ts main/services/config-store-core.test.ts main/services/computer-use/computer-use-foundation.test.ts main/services/computer-use/computer-use-tool.test.ts main/services/computer-use/generation-gate.test.ts main/services/computer-use/safety.test.ts main/services/computer-use/settings-core.test.ts main/services/computer-use/status-core.test.ts main/services/data-store.test.ts main/services/data-store.resilience.test.ts main/services/aiden-config-dir.test.ts main/services/portable-config-core.test.ts main/services/portable-config-core.roundtrip.test.ts main/services/portable-config-watch-core.test.ts main/services/dev-log.test.ts main/services/dictation-coordinator.test.ts main/services/dictation-paste.test.ts main/services/foundation-models-connection.test.ts main/services/foundation-models-connection-core.test.ts main/services/generation-bound-connection-cache.test.ts main/services/generation-context.test.ts main/services/generation-messages.test.ts main/services/generation-runtime.test.ts main/services/external-editors.test.ts main/services/git.test.ts main/services/model-runtime-core.test.ts main/services/pi-compaction-core.test.ts main/services/models.test.ts main/services/mcp-oauth-operation.test.ts main/services/mcp-oauth-session.test.ts main/services/mcp-presets.test.ts main/services/pi-credential-store-core.test.ts main/services/pi-provider-contract.test.ts main/services/profile-share-core.test.ts main/services/profile-share-files.test.ts main/services/profile.test.ts main/services/provider-auth-flow-core.test.ts main/services/provider-auth-owner.test.ts main/services/provider-key-policy.test.ts main/services/provider-list-core.test.ts main/services/quit-barrier.test.ts main/services/scratch-workspace.test.ts main/services/skills-discovery.test.ts main/services/tool-approval.test.ts main/services/local-runtime-status.test.ts main/services/usage-store-core.test.ts main/services/workspace-files.test.ts main/windows/pill-window-security.test.ts renderer/components/assistant/use-assistant-chat.test.ts renderer/components/assistant/assistant-ui.test.tsx renderer/components/environment-subagents-contract.test.ts renderer/components/subagents-panel.test.tsx renderer/components/chat-sidebar.test.tsx renderer/components/composer.test.tsx renderer/main/chat-transition.test.tsx renderer/components/usage/profile-share-card.test.tsx renderer/lib/accessibility-refresh.test.ts renderer/lib/agent-activity.test.ts renderer/lib/assistant-dock.test.ts renderer/lib/assistant-motion-contract.test.ts renderer/lib/dialog-motion-contract.test.ts renderer/lib/chat-deletion-cache.test.ts renderer/lib/chat-terminal-sync.test.ts renderer/lib/ipc-stream.test.ts renderer/lib/chat-title-reveal.test.ts renderer/lib/codex-auth-session.test.ts renderer/lib/codex-auth-view-state.test.ts renderer/lib/codex-provider-cache.test.ts renderer/lib/composer-placeholder.test.ts renderer/lib/computer-use-notice.test.ts renderer/lib/dictation-operation-gate.test.ts renderer/lib/editor-preference.test.ts renderer/lib/environment-panel-layout.test.ts renderer/lib/subagent-view-state.test.ts renderer/lib/truncate-path.test.ts renderer/lib/mcp-preset-state.test.ts renderer/lib/model-display.test.ts renderer/lib/profile-share-data.test.ts renderer/lib/sidebar-chat-shortcuts.test.ts renderer/lib/usage-profile-data.test.ts renderer/shared/appearance.test.ts renderer/shared/provider-deployment.test.ts main/handlers/ipc-contract.test.ts main/handlers/chat.parse.test.ts main/handlers/voice-codec.test.ts main/handlers/phase2-parse.test.ts scripts/apple-developer-tools.test.mjs scripts/check-macos-release.test.mjs scripts/computer-use-packaged-acceptance.test.mjs scripts/configure-electron-fuses.test.mjs scripts/model-snapshot-core.test.mjs scripts/prepare-macos-dev-runtime.test.mjs scripts/prepare-macos-package-output.test.mjs scripts/run-macos-distribution.test.mjs scripts/sign-macos.test.mjs scripts/update-model-capabilities.test.mjs scripts/vendor-cua-driver.test.mjs scripts/verify-macos-package.test.mjs", @@ -78,6 +81,7 @@ "test:subagent-file-mutator:native": "node scripts/build-subagent-file-mutator.mjs && node scripts/build-subagent-file-mutator.mjs --test && node --test scripts/subagent-file-mutator.test.mjs", "test:native": "CLANG_MODULE_CACHE_PATH=build/native-test-module-cache SWIFT_MODULECACHE_PATH=build/native-test-module-cache node scripts/run-with-apple-developer-tools.mjs swift test --disable-sandbox --package-path native/apple-foundation-models --scratch-path build/native-swift-tests", "type-check": "tsc --noEmit", + "type-check:e2e": "tsc --noEmit --project tests/e2e/tsconfig.json", "format": "oxfmt .", "package": "node scripts/prepare-macos-package-output.mjs development && npm run computer-use:vendor && npm run build:native && npm run build && electron-builder --mac dir --config.mac.type=development --config.mac.notarize=false --config.directories.output=release/development", "package:verify": "node scripts/verify-macos-package.mjs --development", @@ -129,6 +133,7 @@ "@electron/fuses": "2.1.3", "@electron/osx-sign": "1.3.3", "@eslint/js": "^9.22.0", + "@playwright/test": "^1.62.1", "@rolldown/plugin-babel": "^0.2.1", "@tailwindcss/vite": "^4.2.2", "@types/node": "^24.3.1", @@ -141,7 +146,7 @@ "concurrently": "^10.0.3", "electron": "^43.1.1", "electron-builder": "^26.15.3", - "esbuild": "^0.27.4", + "esbuild": "^0.28.2", "eslint": "^9.39.2", "eslint-plugin-import": "^2.31.0", "globals": "^17.3.0", From ef7e491ddcbad932c3be9815d9d35b134f3f95a4 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Mon, 10 Aug 2026 16:51:22 -0400 Subject: [PATCH 45/62] Lock E2E test dependencies --- package-lock.json | 776 +++++++++++----------------------------------- 1 file changed, 178 insertions(+), 598 deletions(-) diff --git a/package-lock.json b/package-lock.json index 288c0c92..0686c3e4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -47,6 +47,7 @@ "@electron/fuses": "2.1.3", "@electron/osx-sign": "1.3.3", "@eslint/js": "^9.22.0", + "@playwright/test": "^1.62.1", "@rolldown/plugin-babel": "^0.2.1", "@tailwindcss/vite": "^4.2.2", "@types/node": "^24.3.1", @@ -59,7 +60,7 @@ "concurrently": "^10.0.3", "electron": "^43.1.1", "electron-builder": "^26.15.3", - "esbuild": "^0.27.4", + "esbuild": "^0.28.2", "eslint": "^9.39.2", "eslint-plugin-import": "^2.31.0", "globals": "^17.3.0", @@ -1138,9 +1139,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", - "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", "cpu": [ "ppc64" ], @@ -1155,9 +1156,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", - "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", "cpu": [ "arm" ], @@ -1172,9 +1173,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", - "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", "cpu": [ "arm64" ], @@ -1189,9 +1190,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", - "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", "cpu": [ "x64" ], @@ -1206,9 +1207,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", - "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", "cpu": [ "arm64" ], @@ -1223,9 +1224,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", - "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", "cpu": [ "x64" ], @@ -1240,9 +1241,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", - "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", "cpu": [ "arm64" ], @@ -1257,9 +1258,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", - "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", "cpu": [ "x64" ], @@ -1274,9 +1275,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", - "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", "cpu": [ "arm" ], @@ -1291,9 +1292,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", - "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", "cpu": [ "arm64" ], @@ -1308,9 +1309,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", - "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", "cpu": [ "ia32" ], @@ -1325,9 +1326,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", - "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", "cpu": [ "loong64" ], @@ -1342,9 +1343,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", - "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", "cpu": [ "mips64el" ], @@ -1359,9 +1360,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", - "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", "cpu": [ "ppc64" ], @@ -1376,9 +1377,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", - "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", "cpu": [ "riscv64" ], @@ -1393,9 +1394,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", - "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", "cpu": [ "s390x" ], @@ -1410,9 +1411,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", - "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", "cpu": [ "x64" ], @@ -1427,9 +1428,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", - "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", "cpu": [ "arm64" ], @@ -1444,9 +1445,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", - "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", "cpu": [ "x64" ], @@ -1461,9 +1462,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", - "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", "cpu": [ "arm64" ], @@ -1478,9 +1479,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", - "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", "cpu": [ "x64" ], @@ -1495,9 +1496,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", - "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", "cpu": [ "arm64" ], @@ -1512,9 +1513,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", - "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", "cpu": [ "x64" ], @@ -1529,9 +1530,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", - "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", "cpu": [ "arm64" ], @@ -1546,9 +1547,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", - "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", "cpu": [ "ia32" ], @@ -1563,9 +1564,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", - "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", "cpu": [ "x64" ], @@ -2414,6 +2415,22 @@ "node": ">=14.18.0" } }, + "node_modules/@playwright/test": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", + "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -7702,9 +7719,9 @@ "optional": true }, "node_modules/esbuild": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", - "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -7715,32 +7732,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.7", - "@esbuild/android-arm": "0.27.7", - "@esbuild/android-arm64": "0.27.7", - "@esbuild/android-x64": "0.27.7", - "@esbuild/darwin-arm64": "0.27.7", - "@esbuild/darwin-x64": "0.27.7", - "@esbuild/freebsd-arm64": "0.27.7", - "@esbuild/freebsd-x64": "0.27.7", - "@esbuild/linux-arm": "0.27.7", - "@esbuild/linux-arm64": "0.27.7", - "@esbuild/linux-ia32": "0.27.7", - "@esbuild/linux-loong64": "0.27.7", - "@esbuild/linux-mips64el": "0.27.7", - "@esbuild/linux-ppc64": "0.27.7", - "@esbuild/linux-riscv64": "0.27.7", - "@esbuild/linux-s390x": "0.27.7", - "@esbuild/linux-x64": "0.27.7", - "@esbuild/netbsd-arm64": "0.27.7", - "@esbuild/netbsd-x64": "0.27.7", - "@esbuild/openbsd-arm64": "0.27.7", - "@esbuild/openbsd-x64": "0.27.7", - "@esbuild/openharmony-arm64": "0.27.7", - "@esbuild/sunos-x64": "0.27.7", - "@esbuild/win32-arm64": "0.27.7", - "@esbuild/win32-ia32": "0.27.7", - "@esbuild/win32-x64": "0.27.7" + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" } }, "node_modules/escalade": { @@ -10034,9 +10051,9 @@ "peer": true }, "node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", "funding": [ { "type": "github", @@ -11633,9 +11650,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.16", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", - "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "dev": true, "funding": [ { @@ -12375,6 +12392,53 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/plist": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz", @@ -14338,490 +14402,6 @@ "fsevents": "~2.3.3" } }, - "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", - "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/android-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", - "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/android-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", - "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/android-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", - "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", - "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/darwin-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", - "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", - "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", - "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", - "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", - "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", - "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-loong64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", - "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", - "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", - "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", - "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-s390x": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", - "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", - "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", - "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", - "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", - "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", - "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", - "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/sunos-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", - "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/win32-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", - "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/win32-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", - "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/win32-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", - "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/esbuild": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", - "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.1", - "@esbuild/android-arm": "0.28.1", - "@esbuild/android-arm64": "0.28.1", - "@esbuild/android-x64": "0.28.1", - "@esbuild/darwin-arm64": "0.28.1", - "@esbuild/darwin-x64": "0.28.1", - "@esbuild/freebsd-arm64": "0.28.1", - "@esbuild/freebsd-x64": "0.28.1", - "@esbuild/linux-arm": "0.28.1", - "@esbuild/linux-arm64": "0.28.1", - "@esbuild/linux-ia32": "0.28.1", - "@esbuild/linux-loong64": "0.28.1", - "@esbuild/linux-mips64el": "0.28.1", - "@esbuild/linux-ppc64": "0.28.1", - "@esbuild/linux-riscv64": "0.28.1", - "@esbuild/linux-s390x": "0.28.1", - "@esbuild/linux-x64": "0.28.1", - "@esbuild/netbsd-arm64": "0.28.1", - "@esbuild/netbsd-x64": "0.28.1", - "@esbuild/openbsd-arm64": "0.28.1", - "@esbuild/openbsd-x64": "0.28.1", - "@esbuild/openharmony-arm64": "0.28.1", - "@esbuild/sunos-x64": "0.28.1", - "@esbuild/win32-arm64": "0.28.1", - "@esbuild/win32-ia32": "0.28.1", - "@esbuild/win32-x64": "0.28.1" - } - }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", From ee4921316070037ed94d2ccfd7275a996518a42e Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Mon, 10 Aug 2026 16:51:22 -0400 Subject: [PATCH 46/62] Gate CI with deterministic Electron E2E --- .github/workflows/ci.yml | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fec54ba5..efcac39c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,3 +51,34 @@ jobs: - name: Build production bundles run: npm run build + + e2e: + name: Deterministic Electron E2E + runs-on: macos-26 + timeout-minutes: 30 + steps: + - name: Check out source + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 + + - name: Use Node.js 22 + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 + with: + node-version: 22.22.3 + cache: npm + + - name: Install locked dependencies + run: npm ci + + - name: Run deterministic Electron E2E gate + run: npm run test:e2e + + - name: Upload Playwright failure artifacts + if: ${{ failure() }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: playwright-e2e-ci-${{ github.run_id }}-${{ github.run_attempt }} + path: | + test-results/e2e + playwright-report/e2e + if-no-files-found: warn + retention-days: 14 From c0dbca94f358a8c2749f4fc85b8f6367f3ba4131 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Mon, 10 Aug 2026 16:51:22 -0400 Subject: [PATCH 47/62] Gate releases with deterministic Electron E2E --- .github/workflows/release.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ad48540d..0a35e352 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -68,6 +68,20 @@ jobs: - name: Run Apple Foundation Models tests run: npm run test:native + - name: Run deterministic Electron E2E gate + run: npm run test:e2e + + - name: Upload Playwright failure artifacts + if: ${{ failure() }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: playwright-e2e-release-${{ github.run_id }}-${{ github.run_attempt }} + path: | + test-results/e2e + playwright-report/e2e + if-no-files-found: warn + retention-days: 14 + - name: Prepare App Store Connect key shell: bash env: From d0a8c6560ec89217a851344748f0ba3deb3dd912 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Mon, 10 Aug 2026 19:09:57 -0400 Subject: [PATCH 48/62] fix(terminal): chmod+verify node-pty spawn-helper and verify shell before spawn (macOS posix_spawnp fix) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit node-pty 1.1.0's npm prebuilt tarball restores spawn-helper without its execute bit (0644). posix_spawn of a non-executable file is exactly what surfaces to users as 'posix_spawnp failed.' the first time they open the terminal drawer. TerminalService.ensureSpawnHelperExecutable now resolves every prebuilds/* helper (including app.asar.unpacked), chmods only when the execute bit is missing, and verifies afterward — throwing a path-bearing remediation error instead of silently swallowing (the prior catch {}). Shell resolution now verifies the candidate is executable with a fallback chain (/bin/zsh -> /bin/zsh -> /bin/bash -> /bin/sh) so a stale SHELL can no longer break terminal creation. --- main/services/terminal.ts | 141 +++++++++++++++++++++++++++++++++----- 1 file changed, 123 insertions(+), 18 deletions(-) diff --git a/main/services/terminal.ts b/main/services/terminal.ts index f7484540..8f3482dc 100644 --- a/main/services/terminal.ts +++ b/main/services/terminal.ts @@ -4,6 +4,7 @@ import * as path from "path"; import * as fs from "fs/promises"; +import { accessSync, constants as fsConstants } from "node:fs"; import { createRequire } from "module"; import { spawn, type IPty } from "node-pty"; import type { RendererDocumentOwner } from "./renderer-document-owner.js"; @@ -40,6 +41,16 @@ interface TerminalSession extends TerminalSessionInfo { export interface TerminalServiceOptions { prepareSpawnHelper?: () => Promise; spawnPty?: typeof spawn; + /** + * Ordered shell candidates. The first that exists and is executable wins. + * Exposed for tests; production resolves `$SHELL` then the macOS defaults. + */ + shellCandidates?: () => string[]; + /** + * Returns the `spawn-helper` paths node-pty will use. Exposed for tests so + * the chmod/verify path can be exercised without touching node_modules. + */ + spawnHelperPaths?: () => Promise; } function terminalId(): string { @@ -51,9 +62,41 @@ function clamp(value: unknown, min: number, max: number, fallback: number): numb return Number.isFinite(numeric) ? Math.min(max, Math.max(min, Math.round(numeric))) : fallback; } -function terminalShell(): string { +/** + * Ordered candidate shells. `$SHELL` is honored first when it is absolute + * (Terminal.app and friends set it to the user's default), then the macOS + * default (`/bin/zsh`), then the POSIX fallbacks. The spawn-helper execs the + * shell via `execvp`, so every candidate must be verified executable before + * being handed to node-pty: a stale `$SHELL` pointing at a removed Homebrew + * install would otherwise surface as an opaque `posix_spawnp failed.`. + */ +function defaultShellCandidates(): string[] { + const candidates: string[] = []; const shell = process.env.SHELL; - return shell && path.isAbsolute(shell) ? shell : "/bin/zsh"; + if (shell && path.isAbsolute(shell)) candidates.push(shell); + candidates.push("/bin/zsh", "/bin/bash", "/bin/sh"); + // De-duplicate while preserving order (e.g. SHELL=/bin/zsh). + return [...new Set(candidates)]; +} + +function isExecutable(filePath: string): boolean { + try { + accessSync(filePath, fsConstants.X_OK); + return true; + } catch { + return false; + } +} + +async function resolveShell(candidates: string[]): Promise { + for (const candidate of candidates) { + if (isExecutable(candidate)) return candidate; + } + throw new Error( + `No executable shell found on this Mac (checked ${candidates + .map((candidate) => JSON.stringify(candidate)) + .join(", ")}). Set $SHELL to an installed shell, or reinstall macOS.`, + ); } export class TerminalService { @@ -93,7 +136,8 @@ export class TerminalService { ); } const id = terminalId(); - const pty = (this.options.spawnPty ?? spawn)(terminalShell(), [], { + const shell = await resolveShell((this.options.shellCandidates ?? defaultShellCandidates)()); + const pty = (this.options.spawnPty ?? spawn)(shell, [], { name: "xterm-256color", cols: 120, rows: 30, @@ -248,27 +292,88 @@ export class TerminalService { } // node-pty's macOS helper can be restored without its execute bit by npm's - // prebuilt archive. T3 Code guards this same boundary before opening a PTY. + // prebuilt archive, and `posix_spawn` of a non-executable file is exactly + // what surfaces to users as `posix_spawnp failed.`. Guard every helper that + // node-pty may load: chmod if needed, then verify (never assume). A failure + // here must be descriptive so the user can fix it, not opaque. private ensureSpawnHelperExecutable(): Promise { if (this.options.prepareSpawnHelper) return this.options.prepareSpawnHelper(); + const resolveHelpers = this.options.spawnHelperPaths ?? defaultSpawnHelperPaths; this.spawnHelperReady ??= (async () => { - const require = createRequire(import.meta.url); - const packageDir = path.dirname(require.resolve("node-pty/package.json")); - const helper = path.join( - packageDir, - "prebuilds", - `${process.platform}-${process.arch}`, - "spawn-helper", - ); - try { - await fs.chmod(helper, 0o755); - } catch { - // Some package layouts do not ship a separate helper; node-pty then - // uses its own fallback path, so this stays a best-effort preparation. - } + const helpers = await resolveHelpers(); + await Promise.all(helpers.map((helper) => ensureHelperExecutable(helper))); })(); return this.spawnHelperReady; } } +/** + * Resolve every `spawn-helper` node-pty may load on this machine. + * + * node-pty 1.1.0 loads the helper from `prebuilds/-/spawn-helper` + * via `utils.loadNativeModule`, and in a packaged Electron app the same file + * lives under `app.asar.unpacked`. We resolve from `node-pty/package.json` and + * enumerate every `prebuilds/*` directory so a wrong-arch guess, a Rosetta run, + * or an extra prebuild still gets fixed up. + */ +async function defaultSpawnHelperPaths(): Promise { + const require = createRequire(import.meta.url); + let packageDir: string; + try { + packageDir = path.dirname(require.resolve("node-pty/package.json")); + } catch { + // Without node-pty resolvable there is no helper to fix; node-pty's own + // spawn will surface the underlying error. + return []; + } + const prebuildsDir = path.join(packageDir, "prebuilds"); + let entries: string[]; + try { + entries = await fs.readdir(prebuildsDir); + } catch { + return []; + } + const helpers: string[] = []; + for (const entry of entries) { + helpers.push(path.join(prebuildsDir, entry, "spawn-helper")); + // Packaged apps unpack node-pty to app.asar.unpacked; mirror node-pty's + // own helperPath rewrite so the on-disk copy there is fixed too. + if (packageDir.includes("app.asar")) { + helpers.push( + path.join(prebuildsDir, entry, "spawn-helper").replace("app.asar", "app.asar.unpacked"), + ); + } + } + return helpers; +} + +async function ensureHelperExecutable(helper: string): Promise { + let info; + try { + info = await fs.stat(helper); + } catch { + return; // This prebuild dir has no helper; node-pty picks another path. + } + if (!info.isFile()) return; + if ((info.mode & 0o111) === 0) { + try { + await fs.chmod(helper, 0o755); + } catch (error) { + throw new Error( + `Aiden could not make node-pty's spawn-helper executable (${helper}): ${ + error instanceof Error ? error.message : String(error) + }. Run "chmod 755 ${helper}" or reinstall dependencies.`, + ); + } + } + // Verify, never assume: a read-only packaged copy or a permission loss + // would otherwise leave the terminal broken with an opaque error. + const after = await fs.stat(helper); + if ((after.mode & 0o111) === 0) { + throw new Error( + `node-pty's spawn-helper is still not executable after chmod (${helper}). Run "chmod 755 ${helper}" or reinstall dependencies.`, + ); + } +} + export const terminalService = new TerminalService(); From 474af5f74996599fb0f367c19e0c93b296ad1bc1 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Mon, 10 Aug 2026 19:10:02 -0400 Subject: [PATCH 49/62] fix(build): chmod+verify node-pty spawn-helper in afterPack on macOS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Packaged builds must never ship spawn-helper without its execute bit. The afterPack hook now walks app.asar.unpacked/.../node-pty/prebuilds/*, chmods each spawn-helper to 0755, and throws in CI if any is missing or still non-executable — so a broken package fails at build time rather than at the user's first terminal open. --- scripts/configure-electron-fuses.mjs | 59 ++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/scripts/configure-electron-fuses.mjs b/scripts/configure-electron-fuses.mjs index 572e743c..a1ec4255 100644 --- a/scripts/configure-electron-fuses.mjs +++ b/scripts/configure-electron-fuses.mjs @@ -1,4 +1,5 @@ import path from "node:path"; +import { chmod, readdir, stat } from "node:fs/promises"; import { flipFuses, FuseState, @@ -65,11 +66,69 @@ export async function verifyAidenFuses(appPath) { assertAidenFuseWire(wire); } +/** + * Make node-pty's `spawn-helper` executable inside a packaged macOS app. + * + * node-pty 1.1.0's npm prebuilt tarball restores the helper without its + * execute bit, and `posix_spawn` of a non-executable file is exactly what + * surfaces to users as `posix_spawnp failed.` the first time they open the + * terminal drawer. electron-builder unpacks node-pty (`asarUnpack`) into + * `app.asar.unpacked`, so we walk that tree, chmod each helper, and verify. + * A broken build must fail here in CI rather than at the user's first PTY. + */ +export async function makeSpawnHelpersExecutable(appPath) { + const nodePtyPrebuilds = path.join( + appPath, + "Contents", + "Resources", + "app.asar.unpacked", + "node_modules", + "node-pty", + "prebuilds", + ); + let archDirs; + try { + archDirs = await readdir(nodePtyPrebuilds, { withFileTypes: true }); + } catch (error) { + if (error?.code === "ENOENT") return; // No node-pty in this package layout. + throw error; + } + let fixedAny = false; + for (const entry of archDirs) { + if (!entry.isDirectory()) continue; + const helper = path.join(nodePtyPrebuilds, entry.name, "spawn-helper"); + let info; + try { + info = await stat(helper); + } catch (error) { + if (error?.code === "ENOENT") continue; // This prebuild ships no helper. + throw error; + } + if (!info.isFile()) continue; + if ((info.mode & 0o111) === 0) { + await chmod(helper, 0o755); + } + const after = await stat(helper); + if ((after.mode & 0o111) === 0) { + throw new Error( + `node-pty spawn-helper is not executable after packaging (${helper}). The prebuilt archive may be corrupt; reinstall node-pty.`, + ); + } + fixedAny = true; + } + if (!fixedAny) { + throw new Error( + `No node-pty spawn-helper was found under ${nodePtyPrebuilds}. Terminal creation will fail with "posix_spawnp failed." unless node-pty ships a helper.`, + ); + } +} + export async function configureElectronFuses(context) { if (context.electronPlatformName !== "darwin") return; const appPath = path.join(context.appOutDir, `${context.packager.appInfo.productFilename}.app`); await flipFuses(appPath, AIDEN_FUSE_CONFIG); await verifyAidenFuses(appPath); + await makeSpawnHelpersExecutable(appPath); } export default configureElectronFuses; From f48747ed6df855beba88e414170beb800ba458a8 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Mon, 10 Aug 2026 19:10:02 -0400 Subject: [PATCH 50/62] test(terminal): cover shell fallback, spawn-helper chmod/verify, no-op paths --- main/services/terminal.test.ts | 94 ++++++++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) diff --git a/main/services/terminal.test.ts b/main/services/terminal.test.ts index 8be567d6..acbdc811 100644 --- a/main/services/terminal.test.ts +++ b/main/services/terminal.test.ts @@ -1,5 +1,8 @@ import assert from "node:assert/strict"; import test from "node:test"; +import { mkdtemp, open, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; import type { IPty, spawn } from "node-pty"; import { TerminalService } from "./terminal.js"; import type { RendererDocumentOwner } from "./renderer-document-owner.js"; @@ -152,3 +155,94 @@ test("a throwing PTY kill cannot escape renderer-document teardown", async () => assert.equal(killAttempted, true); assert.throws(() => service.workspaceId(session.id, original.owner), /unavailable/u); }); + +test("shell resolution falls back to the first executable candidate", async () => { + const owner = ownerState(); + let spawnedShell = ""; + const service = new TerminalService({ + prepareSpawnHelper: async () => undefined, + // A missing first choice and a real executable second choice; the second + // must win without surfacing the missing one as an error. + shellCandidates: () => ["/definitely/not/a/shell", "/bin/sh"], + spawnPty: ((file: string) => { + spawnedShell = file; + return fakePty().pty; + }) as typeof spawn, + }); + + await service.create("workspace-1", "/tmp", owner.owner); + assert.equal(spawnedShell, "/bin/sh"); +}); + +test("shell resolution rejects descriptively when no candidate is executable", async () => { + const owner = ownerState(); + const service = new TerminalService({ + prepareSpawnHelper: async () => undefined, + shellCandidates: () => ["/definitely/not/a/shell", "/also/not/real"], + spawnPty: (() => fakePty().pty) as typeof spawn, + }); + + await assert.rejects( + service.create("workspace-1", "/tmp", owner.owner), + /No executable shell found/u, + ); +}); + +test("a non-executable spawn-helper is chmod'd and verified before spawn", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "pty-helper-")); + const helper = path.join(dir, "spawn-helper"); + // Create the helper without its execute bit, exactly as npm's prebuilt + // archive restores it. open() defaults to 0o666 masked by umask. + const handle = await open(helper, "w", 0o644); + await handle.close(); + + const owner = ownerState(); + const service = new TerminalService({ + spawnHelperPaths: async () => [helper], + spawnPty: (() => fakePty().pty) as typeof spawn, + }); + + await service.create("workspace-1", "/tmp", owner.owner); + const { mode } = await stat(helper); + assert.notEqual(mode & 0o111, 0, "spawn-helper should have been made executable"); + + await rm(dir, { recursive: true, force: true }); +}); + +test("a missing prebuilds directory is a no-op (node-pty picks its own path)", async () => { + const owner = ownerState(); + // A non-existent helper path: the guard must not throw, because some layouts + // legitimately have no separate helper. node-pty then surfaces any real + // failure with its own error. + const service = new TerminalService({ + spawnHelperPaths: async () => [path.join(tmpdir(), "definitely-missing-pty-helper")], + spawnPty: (() => fakePty().pty) as typeof spawn, + }); + + const session = await service.create("workspace-1", "/tmp", owner.owner); + // Reaching here without throwing is the assertion: a missing helper path must + // not break terminal creation, since the guard treats it as "not present". + assert.equal(typeof session.id, "string"); +}); + +test("a spawn-helper that is already executable is left untouched", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "pty-helper-ok-")); + const helper = path.join(dir, "spawn-helper"); + const handle = await open(helper, "w", 0o755); + await handle.close(); + const before = (await stat(helper)).mode; + + const owner = ownerState(); + const service = new TerminalService({ + spawnHelperPaths: async () => [helper], + spawnPty: (() => fakePty().pty) as typeof spawn, + }); + + await service.create("workspace-1", "/tmp", owner.owner); + const after = (await stat(helper)).mode; + // An already-executable helper must not be needlessly rewritten (avoids + // touching read-only packaged copies on every terminal open). + assert.equal(after, before); + + await rm(dir, { recursive: true, force: true }); +}); From 350dea4f52619d9229c8c3e3c76338aa448ab5e9 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Mon, 10 Aug 2026 19:10:03 -0400 Subject: [PATCH 51/62] test(build): cover makeSpawnHelpersExecutable chmod, missing-helper throw, no-op --- scripts/configure-electron-fuses.test.mjs | 64 +++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/scripts/configure-electron-fuses.test.mjs b/scripts/configure-electron-fuses.test.mjs index 60dfc118..7d51fab8 100644 --- a/scripts/configure-electron-fuses.test.mjs +++ b/scripts/configure-electron-fuses.test.mjs @@ -1,5 +1,8 @@ import assert from "node:assert/strict"; +import { mkdtemp, mkdir, open, rm, stat, writeFile } from "node:fs/promises"; import { readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; import test from "node:test"; import { URL } from "node:url"; import { FuseState, FuseV1Options, FuseVersion } from "@electron/fuses"; @@ -7,6 +10,7 @@ import { AIDEN_FUSE_CONFIG, AIDEN_FUSE_VALUES, assertAidenFuseWire, + makeSpawnHelpersExecutable, } from "./configure-electron-fuses.mjs"; const packageJson = JSON.parse( @@ -39,3 +43,63 @@ test("fuse verifier rejects missing, added, and incorrectly flipped fuses", () = assert.throws(() => assertAidenFuseWire(missing), /schema drifted/); assert.throws(() => assertAidenFuseWire({ ...wire, 9: FuseState.ENABLE }), /schema drifted/); }); + +async function buildFakeAppWithHelper(helperMode) { + const appDir = await mkdtemp(path.join(tmpdir(), "fuses-pty-")); + const prebuilds = path.join( + appDir, + "Contents", + "Resources", + "app.asar.unpacked", + "node_modules", + "node-pty", + "prebuilds", + "darwin-arm64", + ); + await mkdir(prebuilds, { recursive: true }); + const helper = path.join(prebuilds, "spawn-helper"); + const handle = await open(helper, "w", helperMode); + await handle.close(); + return { appDir, helper }; +} + +test("makeSpawnHelpersExecutable chmods a non-executable spawn-helper to 0755", async () => { + const { appDir, helper } = await buildFakeAppWithHelper(0o644); + try { + await makeSpawnHelpersExecutable(appDir); + const { mode } = await stat(helper); + assert.notEqual(mode & 0o111, 0, "helper should be executable after afterPack"); + } finally { + await rm(appDir, { recursive: true, force: true }); + } +}); + +test("makeSpawnHelpersExecutable throws when no spawn-helper ships", async () => { + const appDir = await mkdtemp(path.join(tmpdir(), "fuses-pty-empty-")); + const prebuilds = path.join( + appDir, + "Contents", + "Resources", + "app.asar.unpacked", + "node_modules", + "node-pty", + "prebuilds", + "darwin-arm64", + ); + await mkdir(prebuilds, { recursive: true }); + try { + await assert.rejects(makeSpawnHelpersExecutable(appDir), /No node-pty spawn-helper/u); + } finally { + await rm(appDir, { recursive: true, force: true }); + } +}); + +test("makeSpawnHelpersExecutable is a no-op when node-pty is absent", async () => { + const appDir = await mkdtemp(path.join(tmpdir(), "fuses-pty-noop-")); + try { + // No app.asar.unpacked tree at all: must not throw. + await makeSpawnHelpersExecutable(appDir); + } finally { + await rm(appDir, { recursive: true, force: true }); + } +}); From 80753e7da59232729bc42a242d7715ae81a2c1aa Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Mon, 10 Aug 2026 19:15:11 -0400 Subject: [PATCH 52/62] chore: remove unused writeFile import (fix CI lint) --- scripts/configure-electron-fuses.test.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/configure-electron-fuses.test.mjs b/scripts/configure-electron-fuses.test.mjs index 7d51fab8..ed0d6851 100644 --- a/scripts/configure-electron-fuses.test.mjs +++ b/scripts/configure-electron-fuses.test.mjs @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { mkdtemp, mkdir, open, rm, stat, writeFile } from "node:fs/promises"; +import { mkdtemp, mkdir, open, rm, stat } from "node:fs/promises"; import { readFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; From dac9d3a5d060ed61db539f0c1f96f77bba744f6c Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Mon, 10 Aug 2026 20:46:30 -0400 Subject: [PATCH 53/62] feat(terminal): per-workspace history store with device-query sanitization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds TerminalHistoryStore: a debounced, line-capped, per-workspace log of terminal output rooted at /terminal-history. Output is sanitized before persisting so a replayed snapshot cannot trigger fresh shell replies — CSI cursor-position reports, device-attributes/status queries, DECRQM/PM, XTVERSION, Kitty keyboard, DCS DECRQSS/XTGETTCAP, and OSC color queries are stripped while benign SGR/cursor sequences survive. Partial sequences split across chunks are carried via a pending prefix. Ported from t3code's sanitizeTerminalHistoryChunk (Manager.ts:953). --- main/services/terminal-history.ts | 363 ++++++++++++++++++++++++++++++ 1 file changed, 363 insertions(+) create mode 100644 main/services/terminal-history.ts diff --git a/main/services/terminal-history.ts b/main/services/terminal-history.ts new file mode 100644 index 00000000..dec22fbd --- /dev/null +++ b/main/services/terminal-history.ts @@ -0,0 +1,363 @@ +// Per-workspace terminal output history with control-sequence sanitization. +// +// PTY data arrives as a raw byte stream that mixes visible text with device +// control sequences. Replaying that stream verbatim — on terminal reopen, or +// when the renderer re-hydrates from snapshot — replays device *queries* too, +// and the shell answers them by echoing junk at the prompt. This store strips +// query/reply traffic (CSI/DCS/OSC) before persisting or returning history, so +// what gets replayed is only what the user actually saw. +// +// The store is deliberately network-free and synchronous-safe: it debounces +// disk writes per workspace so a noisy `npm install` doesn't thrash, and every +// write is best-effort (a terminal must never block or fail on disk trouble). + +import * as fs from "fs/promises"; +import * as path from "path"; +import { createHash } from "node:crypto"; +import { ensureUserDataDir } from "./data-store.js"; +import type { TerminalHistoryStoreLike } from "./terminal.js"; + +export const MAX_HISTORY_LINES = 5_000; +const PERSIST_DEBOUNCE_MS = 40; + +export interface TerminalHistoryStoreOptions { + /** Directory holding one `.log` per workspace. */ + logsDir: string; + /** Override for tests; production resolves via ensureUserDataDir. */ + maxLines?: number; + /** Test seam for the debounce window. */ + debounceMs?: number; + /** Test seam: custom timers. */ + now?: () => number; + schedule?: (fn: () => void, ms: number) => () => void; +} + +/** + * Strip device-query and device-reply escape sequences from a chunk of PTY + * output, carrying any half-sequence across chunk boundaries via + * `pendingControlSequence`. Returns the sanitized visible text and the new + * pending prefix to feed into the next call. + * + * Stripped (so a replayed history cannot trigger a fresh shell reply): + * - CSI cursor-position reports (…R), device-status (…n), + * device-attributes (…c), DECRQM/DECRPM (…$p/…$y), XTVERSION (>q), + * Kitty keyboard (?u). + * - DCS DECRQSS ($q) and XTGETTCAP (+q) queries and their replies. + * - OSC foreground/background/color queries (10;? / 11;? / rgb:…). + * Benign sequences (SGR colors, cursor moves, DECSTR, etc.) are preserved. + * + * Ported from t3code's `sanitizeTerminalHistoryChunk` (Manager.ts:953). + */ +export function sanitizeTerminalHistoryChunk( + pendingControlSequence: string, + data: string, +): { visibleText: string; pendingControlSequence: string } { + const input = `${pendingControlSequence}${data}`; + let visibleText = ""; + let index = 0; + + const append = (value: string) => { + visibleText += value; + }; + + while (index < input.length) { + const codePoint = input.charCodeAt(index); + + // ESC (0x1b) introduces a multi-byte escape sequence. + if (codePoint === 0x1b) { + const nextCodePoint = input.charCodeAt(index + 1); + if (Number.isNaN(nextCodePoint)) { + return { visibleText, pendingControlSequence: input.slice(index) }; + } + + // CSI: ESC [ …final-byte (0x40..0x7e). + if (nextCodePoint === 0x5b) { + let cursor = index + 2; + while (cursor < input.length) { + if (isCsiFinalByte(input.charCodeAt(cursor))) { + const sequence = input.slice(index, cursor + 1); + const body = input.slice(index + 2, cursor); + if (!shouldStripCsiSequence(body, input[cursor] ?? "")) { + append(sequence); + } + index = cursor + 1; + break; + } + cursor += 1; + } + if (cursor >= input.length) { + return { visibleText, pendingControlSequence: input.slice(index) }; + } + continue; + } + + // String-terminated sequences: OSC (]), DCS (P), SOS (^), PM (^), APC (_). + if ( + nextCodePoint === 0x5d || + nextCodePoint === 0x50 || + nextCodePoint === 0x5e || + nextCodePoint === 0x5f + ) { + const terminatorIndex = findStringTerminatorIndex(input, index + 2); + if (terminatorIndex === null) { + return { visibleText, pendingControlSequence: input.slice(index) }; + } + const sequence = input.slice(index, terminatorIndex); + const content = stripStringTerminator(input.slice(index + 2, terminatorIndex)); + const strip = + (nextCodePoint === 0x5d && shouldStripOscSequence(content)) || + (nextCodePoint === 0x50 && shouldStripDcsSequence(content)); + if (!strip) { + append(sequence); + } + index = terminatorIndex; + continue; + } + + // ESC + intermediate (0x20..0x2f) + final (0x30..0x7e): e.g. ESC ! p. + const escapeSequenceEndIndex = findEscapeSequenceEndIndex(input, index + 1); + if (escapeSequenceEndIndex === null) { + return { visibleText, pendingControlSequence: input.slice(index) }; + } + append(input.slice(index, escapeSequenceEndIndex)); + index = escapeSequenceEndIndex; + continue; + } + + // C1 CSI (0x9b) — the single-byte form of ESC [. + if (codePoint === 0x9b) { + let cursor = index + 1; + while (cursor < input.length) { + if (isCsiFinalByte(input.charCodeAt(cursor))) { + const sequence = input.slice(index, cursor + 1); + const body = input.slice(index + 1, cursor); + if (!shouldStripCsiSequence(body, input[cursor] ?? "")) { + append(sequence); + } + index = cursor + 1; + break; + } + cursor += 1; + } + if (cursor >= input.length) { + return { visibleText, pendingControlSequence: input.slice(index) }; + } + continue; + } + + // C1 OSC/DCS/SOS/PM/APC single-byte forms. + if (codePoint === 0x9d || codePoint === 0x90 || codePoint === 0x9e || codePoint === 0x9f) { + const terminatorIndex = findStringTerminatorIndex(input, index + 1); + if (terminatorIndex === null) { + return { visibleText, pendingControlSequence: input.slice(index) }; + } + const sequence = input.slice(index, terminatorIndex); + const content = stripStringTerminator(input.slice(index + 1, terminatorIndex)); + const strip = + (codePoint === 0x9d && shouldStripOscSequence(content)) || + (codePoint === 0x90 && shouldStripDcsSequence(content)); + if (!strip) { + append(sequence); + } + index = terminatorIndex; + continue; + } + + append(input[index] ?? ""); + index += 1; + } + + return { visibleText, pendingControlSequence: "" }; +} + +function isCsiFinalByte(codePoint: number): boolean { + return codePoint >= 0x40 && codePoint <= 0x7e; +} + +function shouldStripCsiSequence(body: string, finalByte: string): boolean { + // Device-status report (CSI … n). + if (finalByte === "n") return true; + // Cursor-position report (CSI row ; col R). + if (finalByte === "R" && /^[0-9;?]*$/.test(body)) return true; + // Device-attributes report (CSI … c). + if (finalByte === "c" && /^[>0-9;?]*$/.test(body)) return true; + // DECRQM mode queries (…$p) and DECRPM replies (…$y). The `$` guard keeps + // setters like DECSTR (!p) and DECSCL ("p) intact. + if ((finalByte === "p" || finalByte === "y") && /^[0-9;?]*\$$/.test(body)) return true; + // XTVERSION query (>q). DECSCUSR (space-intermediate q) stays. + if (finalByte === "q" && /^>[0-9;]*$/.test(body)) return true; + // Kitty keyboard protocol query/reply (?u). Restore-cursor (bare u) stays. + if (finalByte === "u" && body.startsWith("?")) return true; + return false; +} + +// DECRQSS ($q) and XTGETTCAP (+q) queries plus their replies ([01]$r / [01]+r): +// pure request/response traffic with no visual value. +function shouldStripDcsSequence(content: string): boolean { + return /^[01]?[$+][qr]/.test(content); +} + +// OSC 10/11/12 foreground/background/cursor color queries and rgb: replies. +function shouldStripOscSequence(content: string): boolean { + return /^(10|11|12);(?:\?|rgb:)/.test(content); +} + +function stripStringTerminator(value: string): string { + if (value.endsWith("\u001b\\")) return value.slice(0, -2); + const last = value.length > 0 ? value[value.length - 1] : ""; + if (last === "\u0007" || last === "\u009c") return value.slice(0, -1); + return value; +} + +function findStringTerminatorIndex(input: string, start: number): number | null { + for (let index = start; index < input.length; index += 1) { + const codePoint = input.charCodeAt(index); + // BEL (0x07) or ST (0x9c) terminate. + if (codePoint === 0x07 || codePoint === 0x9c) return index + 1; + // ESC \ (0x1b 0x5c) terminates. + if (codePoint === 0x1b && input.charCodeAt(index + 1) === 0x5c) return index + 2; + } + return null; +} + +function isEscapeIntermediateByte(codePoint: number): boolean { + return codePoint >= 0x20 && codePoint <= 0x2f; +} + +function isEscapeFinalByte(codePoint: number): boolean { + return codePoint >= 0x30 && codePoint <= 0x7e; +} + +function findEscapeSequenceEndIndex(input: string, start: number): number | null { + let cursor = start; + while (cursor < input.length && isEscapeIntermediateByte(input.charCodeAt(cursor))) { + cursor += 1; + } + if (cursor >= input.length) return null; + return isEscapeFinalByte(input.charCodeAt(cursor)) ? cursor + 1 : start + 1; +} + +/** + * Keep only the most recent `maxLines` lines so a long-running terminal does + * not grow without bound. A trailing newline is preserved if present. + */ +export function capHistory(history: string, maxLines: number): string { + if (history.length === 0) return history; + const hasTrailingNewline = history.endsWith("\n"); + const lines = history.split("\n"); + if (hasTrailingNewline) lines.pop(); + if (lines.length <= maxLines) return history; + const capped = lines.slice(lines.length - maxLines).join("\n"); + return hasTrailingNewline ? `${capped}\n` : capped; +} + +function safeWorkspaceId(workspaceId: string): string { + return createHash("sha256").update(workspaceId).digest("hex"); +} + +interface PendingWorkspaceState { + /** Sanitized history accumulated since the last disk write. */ + history: string; + /** Carried half-sequence across chunk boundaries. */ + pendingControlSequence: string; + /** Pending debounced write canceller. */ + cancel: () => void; + /** True when a write is scheduled but has not fired yet. */ + writeScheduled: boolean; +} + +export class TerminalHistoryStore implements TerminalHistoryStoreLike { + private readonly maxLines: number; + private readonly debounceMs: number; + private readonly schedule: (fn: () => void, ms: number) => () => void; + private readonly pending = new Map(); + + constructor(private readonly options: TerminalHistoryStoreOptions) { + this.maxLines = options.maxLines ?? MAX_HISTORY_LINES; + this.debounceMs = options.debounceMs ?? PERSIST_DEBOUNCE_MS; + this.schedule = options.schedule ?? ((fn, ms) => { + const handle = setTimeout(fn, ms); + return () => clearTimeout(handle); + }); + } + + /** Create the default store rooted at `/terminal-history`. */ + static async create(): Promise { + const logsDir = await ensureUserDataDir("terminal-history"); + return new TerminalHistoryStore({ logsDir }); + } + + async read(workspaceId: string): Promise { + const state = this.pending.get(workspaceId); + if (state) return state.history; + try { + const file = path.join(this.options.logsDir, `${safeWorkspaceId(workspaceId)}.log`); + const raw = await fs.readFile(file, "utf8"); + return capHistory(raw, this.maxLines); + } catch { + return ""; + } + } + + append(workspaceId: string, data: string): void { + let state = this.pending.get(workspaceId); + if (!state) { + state = { + history: "", + pendingControlSequence: "", + cancel: () => {}, + writeScheduled: false, + }; + this.pending.set(workspaceId, state); + } + const sanitized = sanitizeTerminalHistoryChunk(state.pendingControlSequence, data); + state.pendingControlSequence = sanitized.pendingControlSequence; + if (sanitized.visibleText.length > 0) { + state.history = capHistory(`${state.history}${sanitized.visibleText}`, this.maxLines); + } + this.scheduleDebouncedWrite(workspaceId); + } + + async flush(workspaceId: string): Promise { + const state = this.pending.get(workspaceId); + if (!state?.writeScheduled) return; + state.cancel(); + state.writeScheduled = false; + await this.persist(workspaceId); + } + + async clear(workspaceId: string): Promise { + const state = this.pending.get(workspaceId); + if (state) { + state.cancel(); + this.pending.delete(workspaceId); + } + try { + await fs.unlink(path.join(this.options.logsDir, `${safeWorkspaceId(workspaceId)}.log`)); + } catch { + // Already absent or unreadable — clearing is idempotent. + } + } + + private scheduleDebouncedWrite(workspaceId: string): void { + const state = this.pending.get(workspaceId); + if (!state || state.writeScheduled) return; + state.writeScheduled = true; + state.cancel = this.schedule(() => { + void this.persist(workspaceId); + }, this.debounceMs); + } + + private async persist(workspaceId: string): Promise { + const state = this.pending.get(workspaceId); + if (!state) return; + try { + const file = path.join(this.options.logsDir, `${safeWorkspaceId(workspaceId)}.log`); + await fs.writeFile(file, state.history, "utf8"); + } catch { + // A terminal must never fail or block on history-disk trouble. + } finally { + if (state) state.writeScheduled = false; + } + } +} From b26f879215595953e2755c34edae851ad33b5cb8 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Mon, 10 Aug 2026 20:46:47 -0400 Subject: [PATCH 54/62] feat(terminal): shell-candidate retry loop + persisted history wiring Shell fallback: the spawn path now walks an executable candidate list ($SHELL -> /bin/zsh -> /bin/bash -> /bin/sh) and retries the next on a retryable failure (posix_spawnp failed, ENOENT, not found). A broken $SHELL self-heals instead of throwing. Non-retryable errors (EINVAL, out of fds) surface immediately. The session result gains resolvedShell and preferredShellSkipped so the renderer can tell the user which shell launched. History wiring: TerminalService now accepts an optional historyStore. On open the prior sanitized output seeds the buffer (the renderer re-hydrates xterm from snapshot, so no renderer change is needed for the seed); each PTY data event appends to the store; terminate/exit flush the final chunk. --- main/services/terminal.ts | 171 ++++++++++++++++++++++++++++++++++---- 1 file changed, 154 insertions(+), 17 deletions(-) diff --git a/main/services/terminal.ts b/main/services/terminal.ts index 8f3482dc..2c51f73d 100644 --- a/main/services/terminal.ts +++ b/main/services/terminal.ts @@ -21,6 +21,14 @@ export interface TerminalSessionInfo { id: string; workspaceId: string; cwd: string; + /** The shell that actually launched this session (e.g. `/bin/zsh`). */ + resolvedShell: string; + /** + * True when the preferred shell was skipped and a fallback launched the + * session. The renderer surfaces a one-time toast so the user knows their + * `$SHELL` was unavailable. + */ + preferredShellSkipped: boolean; } export interface TerminalSnapshot { @@ -36,6 +44,7 @@ interface TerminalSession extends TerminalSessionInfo { removeOwnerInvalidation: () => void; buffer: string; sequence: number; + historyWorkspaceId: string; } export interface TerminalServiceOptions { @@ -51,6 +60,23 @@ export interface TerminalServiceOptions { * the chmod/verify path can be exercised without touching node_modules. */ spawnHelperPaths?: () => Promise; + /** + * Optional persisted-history store. When present, prior output is restored on + * open and new output is debounced-to-disk so a terminal survives close and + * app restart. Defaults to none (in-memory only) for tests and legacy paths. + */ + historyStore?: TerminalHistoryStoreLike; +} + +/** + * The history-store surface `TerminalService` depends on. The real + * implementation lives in `terminal-history.ts`; this structural interface keeps + * the service testable without the filesystem and without a circular import. + */ +export interface TerminalHistoryStoreLike { + read(workspaceId: string): Promise; + append(workspaceId: string, data: string): void; + flush(workspaceId: string): Promise; } function terminalId(): string { @@ -88,14 +114,96 @@ function isExecutable(filePath: string): boolean { } } -async function resolveShell(candidates: string[]): Promise { - for (const candidate of candidates) { - if (isExecutable(candidate)) return candidate; +/** + * Whether a shell spawn failure is worth retrying against the next candidate. + * Walks the error + `cause` chain collecting messages (a wrapping layer like + * node-pty often buries the real string on `cause`) and matches the substrings + * that mean "this shell is missing or not launchable": `posix_spawnp failed`, + * `ENOENT`, `not found`, `file not found`, `no such file`. Genuine errors + * (e.g. `EINVAL`, out of fds) are NOT retryable and must surface immediately. + * + * Ported from t3code's `isRetryableShellSpawnError` (Manager.ts:567). + */ +function isRetryableShellSpawnError(error: unknown): boolean { + const queue: unknown[] = [error]; + const seen = new Set(); + const messages: string[] = []; + while (queue.length > 0) { + const current = queue.shift(); + if (!current || seen.has(current)) continue; + seen.add(current); + if (typeof current === "string") { + messages.push(current); + } else if (current instanceof Error) { + messages.push(current.message); + const cause = (current as { cause?: unknown }).cause; + if (cause) queue.push(cause); + } else if (typeof current === "object" && current !== null) { + const value = current as { message?: unknown; cause?: unknown }; + if (typeof value.message === "string") messages.push(value.message); + if (value.cause) queue.push(value.cause); + } + } + const message = messages.join(" ").toLowerCase(); + return ( + message.includes("posix_spawnp failed") || + message.includes("enoent") || + message.includes("not found") || + message.includes("file not found") || + message.includes("no such file") + ); +} + +interface ShellSpawnOptions { + cwd: string; + env: Record; + cols?: number; + rows?: number; + name?: string; +} + +/** + * Try each shell candidate in order, retrying the next on a retryable spawn + * failure (missing binary, `posix_spawnp failed`). Returns the launched pty and + * the shell that won. A non-retryable error rethrows immediately so it isn't + * masked by the fallback chain. If every candidate fails to launch, throws a + * descriptive error listing every attempted shell and the last cause. + * + * Ported from t3code's `trySpawn` (Manager.ts:1830). + */ +async function trySpawnShell( + candidates: string[], + spawnPty: typeof spawn, + options: ShellSpawnOptions, +): Promise<{ pty: IPty; shell: string; preferredShellSkipped: boolean }> { + let lastError: unknown = null; + for (let index = 0; index < candidates.length; index += 1) { + const shell = candidates[index]; + if (!shell) continue; + try { + const pty = spawnPty(shell, [], { + name: options.name ?? "xterm-256color", + cols: options.cols ?? 120, + rows: options.rows ?? 30, + cwd: options.cwd, + env: options.env, + }); + return { pty, shell, preferredShellSkipped: index > 0 }; + } catch (error) { + lastError = error; + if (!isRetryableShellSpawnError(error)) throw error; + // Retryable: try the next candidate. + } } + const attempted = candidates.filter(Boolean).map((shell) => JSON.stringify(shell)).join(", "); + const causeMessage = + lastError instanceof Error + ? lastError.message + : typeof lastError === "string" + ? lastError + : "unknown error"; throw new Error( - `No executable shell found on this Mac (checked ${candidates - .map((candidate) => JSON.stringify(candidate)) - .join(", ")}). Set $SHELL to an installed shell, or reinstall macOS.`, + `Could not launch any shell (tried ${attempted}). Last failure: ${causeMessage}. Set $SHELL to an installed shell or reinstall macOS.`, ); } @@ -136,14 +244,33 @@ export class TerminalService { ); } const id = terminalId(); - const shell = await resolveShell((this.options.shellCandidates ?? defaultShellCandidates)()); - const pty = (this.options.spawnPty ?? spawn)(shell, [], { - name: "xterm-256color", - cols: 120, - rows: 30, - cwd, - env: { ...process.env, TERM: "xterm-256color" } as Record, - }); + const candidates = (this.options.shellCandidates ?? defaultShellCandidates)(); + // Verify each candidate exists+is executable before spawning, so the retry + // loop below only fights launch failures (not obvious "no such file" ones). + const executableCandidates = candidates.filter(isExecutable); + if (executableCandidates.length === 0) { + throw new Error( + `No executable shell found on this Mac (checked ${candidates + .map((candidate) => JSON.stringify(candidate)) + .join(", ")}). Set $SHELL to an installed shell, or reinstall macOS.`, + ); + } + const { pty, shell: resolvedShell, preferredShellSkipped } = await trySpawnShell( + executableCandidates, + this.options.spawnPty ?? spawn, + { + cwd, + env: { ...process.env, TERM: "xterm-256color" } as Record, + }, + ); + if (ownerInvalidated()) { + this.terminatePty(pty); + throw new Error("The workspace changed before the terminal could start."); + } + // Restore the sanitized prior-session output so the terminal reopens with + // its history. The renderer writes this buffer to xterm on hydrate, so no + // renderer change is required for the seed. + const restoredHistory = await this.options.historyStore?.read(workspaceId); if (ownerInvalidated()) { this.terminatePty(pty); throw new Error("The workspace changed before the terminal could start."); @@ -152,13 +279,16 @@ export class TerminalService { id, workspaceId, cwd, + resolvedShell, + preferredShellSkipped, pty, ownerWebContentsId: owner.id, ownerDocumentId: owner.documentId, owner, removeOwnerInvalidation: () => {}, - buffer: "", - sequence: 0, + buffer: restoredHistory ?? "", + sequence: restoredHistory ? 1 : 0, + historyWorkspaceId: workspaceId, }; this.sessions.set(id, session); const removeOwnerInvalidation = owner.onInvalidated(() => { @@ -180,6 +310,8 @@ export class TerminalService { if (!current) return; current.buffer = `${current.buffer}${data}`.slice(-MAX_BUFFER_CHARS); current.sequence += 1; + // Persist new output (the store sanitizes and debounces the disk write). + this.options.historyStore?.append(workspaceId, data); try { owner.send("terminal:data", { sessionId: id, sequence: current.sequence, data }); } catch { @@ -191,6 +323,8 @@ export class TerminalService { if (current !== session) return; this.sessions.delete(id); current.removeOwnerInvalidation(); + // Flush the final chunk before the session goes away. + void this.options.historyStore?.flush(workspaceId); try { owner.send("terminal:exit", { sessionId: id, exitCode, signal }); } catch { @@ -198,7 +332,7 @@ export class TerminalService { } }); - return { id, workspaceId, cwd }; + return { id, workspaceId, cwd, resolvedShell, preferredShellSkipped }; } snapshot(id: string, owner: RendererDocumentOwner): TerminalSnapshot { @@ -261,6 +395,9 @@ export class TerminalService { this.sessions.delete(id); session.removeOwnerInvalidation(); this.terminatePty(session.pty); + // Best-effort flush so the last output chunk is on disk before the session + // is torn down (window close, workspace switch, quit). Never block on it. + void this.options.historyStore?.flush(session.historyWorkspaceId); try { session.owner.send("terminal:exit", { sessionId: id, From f5380f705b82639e032319431bd7c570222dbf84 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Mon, 10 Aug 2026 20:46:53 -0400 Subject: [PATCH 55/62] test(terminal): cover history store round-trip, sanitization, debounce, clear --- main/services/terminal-history.test.ts | 169 +++++++++++++++++++++++++ 1 file changed, 169 insertions(+) create mode 100644 main/services/terminal-history.test.ts diff --git a/main/services/terminal-history.test.ts b/main/services/terminal-history.test.ts new file mode 100644 index 00000000..db790090 --- /dev/null +++ b/main/services/terminal-history.test.ts @@ -0,0 +1,169 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createHash } from "node:crypto"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { + TerminalHistoryStore, + capHistory, + sanitizeTerminalHistoryChunk, +} from "./terminal-history.js"; + +function safeId(workspaceId: string): string { + return createHash("sha256").update(workspaceId).digest("hex"); +} + +// Device-query / reply sequences that MUST be stripped from replayed history. +// CSI cursor-position report (CSI 6 n). +const CSI_CPR = "\u001b[6n"; +// CSI device-attributes query (CSI c). +const CSI_DA = "\u001b[c"; +// CSI device-status report (CSI 5 n). +const CSI_DSR = "\u001b[5n"; + +// Benign sequences that MUST survive sanitization. +// SGR red (CSI 3 1 m). +const SGR_RED = "\u001b[31m"; +// SGR reset (CSI 0 m). +const SGR_RESET = "\u001b[0m"; + +test("sanitizer strips CSI cursor-position, device-attributes, and device-status queries", () => { + const input = `hello ${CSI_CPR}${CSI_DA}world${CSI_DSR}!`; + const { visibleText, pendingControlSequence } = sanitizeTerminalHistoryChunk("", input); + assert.equal(pendingControlSequence, ""); + assert.equal(visibleText, "hello world!"); +}); + +test("sanitizer preserves benign SGR color sequences", () => { + const input = `${SGR_RED}error${SGR_RESET}`; + const { visibleText } = sanitizeTerminalHistoryChunk("", input); + assert.equal(visibleText, input); +}); + +test("sanitizer carries an incomplete escape sequence across chunk boundaries", () => { + // Split right in the middle of a CSI sequence: "\x1b[6" then "n". + const first = sanitizeTerminalHistoryChunk("", "a\u001b[6"); + assert.equal(first.visibleText, "a"); + assert.equal(first.pendingControlSequence, "\u001b[6"); + + const second = sanitizeTerminalHistoryChunk(first.pendingControlSequence, "nb"); + assert.equal(second.pendingControlSequence, ""); + // The full CSI 6 n was recognized and stripped; "b" is the only new visible text. + assert.equal(second.visibleText, "b"); +}); + +test("sanitizer strips OSC color queries (10;?) and rgb: replies", () => { + // OSC 10 ; ? ST — a foreground-color query. ST is ESC \. + const oscQuery = "\u001b]10;?\u001b\\"; + const { visibleText } = sanitizeTerminalHistoryChunk("", `x${oscQuery}y`); + assert.equal(visibleText, "xy"); +}); + +test("sanitizer strips DCS DECRQSS ($q) queries", () => { + // DCS $ q m ST — a DECRQSS query for SGR. + const dcsQuery = "\u001bP$qm\u001b\\"; + const { visibleText } = sanitizeTerminalHistoryChunk("", `pre${dcsQuery}post`); + assert.equal(visibleText, "prepost"); +}); + +test("capHistory keeps only the most recent N lines", () => { + const lines = Array.from({ length: 10 }, (_, i) => `line${i}`).join("\n"); + const capped = capHistory(lines, 3); + assert.equal(capped, "line7\nline8\nline9"); +}); + +test("capHistory preserves a trailing newline", () => { + const lines = Array.from({ length: 10 }, (_, i) => `line${i}`).join("\n") + "\n"; + const capped = capHistory(lines, 2); + assert.equal(capped, "line8\nline9\n"); +}); + +test("TerminalHistoryStore round-trips appended chunks after flush", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "pty-history-")); + const store = new TerminalHistoryStore({ logsDir: dir, debounceMs: 0 }); + try { + store.append("ws-1", "hello "); + store.append("ws-1", "world"); + await store.flush("ws-1"); + const read = await store.read("ws-1"); + assert.equal(read, "hello world"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("TerminalHistoryStore persists sanitized output to disk", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "pty-history-sanitize-")); + const store = new TerminalHistoryStore({ logsDir: dir, debounceMs: 0 }); + try { + store.append("ws-1", `visible ${CSI_CPR}text`); + await store.flush("ws-1"); + const file = path.join(dir, `${safeId("ws-1")}.log`); + const raw = await readFile(file, "utf8"); + // The device query must not be in the persisted file. + assert.ok(!raw.includes(CSI_CPR)); + assert.equal(raw, "visible text"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("TerminalHistoryStore isolates histories per workspace", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "pty-history-iso-")); + const store = new TerminalHistoryStore({ logsDir: dir, debounceMs: 0 }); + try { + store.append("ws-1", "alpha"); + store.append("ws-2", "beta"); + await store.flush("ws-1"); + await store.flush("ws-2"); + assert.equal(await store.read("ws-1"), "alpha"); + assert.equal(await store.read("ws-2"), "beta"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("TerminalHistoryStore.clear removes the persisted log", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "pty-history-clear-")); + const store = new TerminalHistoryStore({ logsDir: dir, debounceMs: 0 }); + try { + store.append("ws-1", "data"); + await store.flush("ws-1"); + assert.equal(await store.read("ws-1"), "data"); + await store.clear("ws-1"); + assert.equal(await store.read("ws-1"), ""); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("TerminalHistoryStore coalesces rapid appends into one debounced write", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "pty-history-coalesce-")); + // Use a fake scheduler so the test is deterministic and fast. + let scheduledCount = 0; + let fire: () => void = () => {}; + const store = new TerminalHistoryStore({ + logsDir: dir, + debounceMs: 100, + schedule: (fn) => { + scheduledCount += 1; + fire = fn; + return () => { + fire = () => {}; + }; + }, + }); + try { + // Many rapid appends should schedule the write exactly once. + for (let i = 0; i < 50; i += 1) store.append("ws-1", "x"); + assert.equal(scheduledCount, 1); + // Fire the coalesced write. + fire(); + // Allow the writeFile to settle. + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(await store.read("ws-1"), "x".repeat(50)); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); From 5f517e152af6ad8052ec04e15e9ab043a8e19fe6 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Mon, 10 Aug 2026 20:46:53 -0400 Subject: [PATCH 56/62] test(terminal): cover shell retry/fallback/non-retryable and history seeding --- main/services/terminal.test.ts | 100 +++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/main/services/terminal.test.ts b/main/services/terminal.test.ts index acbdc811..58ecfc2b 100644 --- a/main/services/terminal.test.ts +++ b/main/services/terminal.test.ts @@ -246,3 +246,103 @@ test("a spawn-helper that is already executable is left untouched", async () => await rm(dir, { recursive: true, force: true }); }); + +test("shell retry loop falls back when the preferred shell fails to spawn", async () => { + const owner = ownerState(); + let spawnedShell = ""; + // The first candidate throws the exact retryable error; the second wins. + const service = new TerminalService({ + prepareSpawnHelper: async () => undefined, + // Both candidates must be "executable" so they enter the spawn loop; the + // spawn itself is what throws here. + shellCandidates: () => ["/bin/zsh", "/bin/sh"], + spawnPty: ((file: string) => { + if (file === "/bin/zsh") throw new Error("posix_spawnp failed."); + spawnedShell = file; + return fakePty().pty; + }) as typeof spawn, + }); + + const session = await service.create("workspace-1", "/tmp", owner.owner); + assert.equal(spawnedShell, "/bin/sh"); + assert.equal(session.resolvedShell, "/bin/sh"); + assert.equal(session.preferredShellSkipped, true); +}); + +test("a non-retryable spawn error surfaces immediately instead of falling back", async () => { + const owner = ownerState(); + let attempts = 0; + const service = new TerminalService({ + prepareSpawnHelper: async () => undefined, + shellCandidates: () => ["/bin/zsh", "/bin/sh"], + spawnPty: (() => { + attempts += 1; + // EINVAL is NOT a "missing shell" error — it must not be masked. + const error = new Error("EINVAL"); + (error as Error & { code?: string }).code = "EINVAL"; + throw error; + }) as typeof spawn, + }); + + await assert.rejects(service.create("workspace-1", "/tmp", owner.owner), /EINVAL/u); + // Only the first candidate was tried; no fallback. + assert.equal(attempts, 1); +}); + +test("all shells failing to spawn throws a descriptive error listing attempts", async () => { + const owner = ownerState(); + const service = new TerminalService({ + prepareSpawnHelper: async () => undefined, + shellCandidates: () => ["/bin/zsh", "/bin/bash"], + spawnPty: (() => { + throw new Error("posix_spawnp failed."); + }) as typeof spawn, + }); + + await assert.rejects( + service.create("workspace-1", "/tmp", owner.owner), + /Could not launch any shell.*\/bin\/zsh.*\/bin\/bash/u, + ); +}); + +test("the first candidate succeeding reports preferredShellSkipped false", async () => { + const owner = ownerState(); + let spawnedShell = ""; + const service = new TerminalService({ + prepareSpawnHelper: async () => undefined, + shellCandidates: () => ["/bin/zsh", "/bin/sh"], + spawnPty: ((file: string) => { + spawnedShell = file; + return fakePty().pty; + }) as typeof spawn, + }); + + const session = await service.create("workspace-1", "/tmp", owner.owner); + assert.equal(spawnedShell, "/bin/zsh"); + assert.equal(session.resolvedShell, "/bin/zsh"); + assert.equal(session.preferredShellSkipped, false); +}); + +test("persisted history seeds a reopened terminal buffer", async () => { + const owner = ownerState(); + let history = "prior output\n"; + // A minimal in-memory history store stub. + const historyStore = { + read: async () => history, + append: (_ws: string, data: string) => { + history += data; + }, + flush: async () => undefined, + }; + const service = new TerminalService({ + prepareSpawnHelper: async () => undefined, + spawnPty: (() => fakePty().pty) as typeof spawn, + historyStore, + }); + + const session = await service.create("workspace-1", "/tmp", owner.owner); + // The restored history is available via snapshot, so the renderer can + // re-hydrate xterm with the prior session's output. + const snapshot = service.snapshot(session.id, owner.owner); + assert.equal(snapshot.buffer, "prior output\n"); +}); From 6f5870657c3afb32393382cca541260053561424 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Mon, 10 Aug 2026 20:46:53 -0400 Subject: [PATCH 57/62] feat(ipc): add resolvedShell and preferredShellSkipped to TerminalSession --- renderer/lib/ipc.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/renderer/lib/ipc.ts b/renderer/lib/ipc.ts index 8725764e..53e8b788 100644 --- a/renderer/lib/ipc.ts +++ b/renderer/lib/ipc.ts @@ -455,6 +455,10 @@ export interface TerminalSession { id: string; workspaceId: string; cwd: string; + /** The shell that actually launched this session (e.g. `/bin/zsh`). */ + resolvedShell: string; + /** True when the preferred shell was skipped and a fallback launched it. */ + preferredShellSkipped: boolean; } export interface TerminalSnapshot { From da5dbd30ce70ba7ac90663185d0216d013cf5b7c Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Mon, 10 Aug 2026 20:46:53 -0400 Subject: [PATCH 58/62] feat(terminal-drawer): toast when a fallback shell launched the terminal --- renderer/components/terminal-drawer.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/renderer/components/terminal-drawer.tsx b/renderer/components/terminal-drawer.tsx index d2ea0063..312e51a4 100644 --- a/renderer/components/terminal-drawer.tsx +++ b/renderer/components/terminal-drawer.tsx @@ -180,6 +180,13 @@ export function WorkspaceTerminalProvider({ children }: { children: React.ReactN } try { const session = await terminalApi.create(active.id); + // Surface once when the preferred shell was unavailable and a fallback + // launched the terminal — the user should know their $SHELL is broken. + if (session.preferredShellSkipped) { + toast.info( + `Used ${session.resolvedShell} because $SHELL was unavailable. Check your shell preference if this is unexpected.`, + ); + } setSessions((previous) => [...previous, session]); setActiveId(session.id); setOpen(true); From 15ad2d25c6329f76b233fc04e66dfefca6e177a2 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Mon, 10 Aug 2026 20:46:53 -0400 Subject: [PATCH 59/62] chore(test): register terminal + terminal-history tests in test/test:coverage --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 02c977c5..c94a257e 100644 --- a/package.json +++ b/package.json @@ -69,8 +69,8 @@ "test:assistant-automations": "tsx --test main/handlers/scheduled-tasks-parse.test.ts main/services/assistant/automation-runtime-contract.test.ts main/services/assistant/mcp-tool.test.ts main/services/assistant/project-tool.test.ts main/services/assistant/system-prompt.test.ts main/services/assistant/tool-loop-guard.test.ts main/services/mcp-selection.test.ts main/services/scheduled-settings-core.test.ts main/services/schedule-guard.test.ts main/services/schedule-store.test.ts main/services/schedule-tool.test.ts renderer/components/assistant/assistant-ui.test.tsx renderer/lib/scheduled-mcp-access-contract.test.ts renderer/shared/assistant.test.ts", "test:onboarding": "tsx --test main/services/onboarding-reset-core.test.ts main/services/onboarding-reset-lifecycle.test.ts renderer/components/onboarding-flow.test.tsx renderer/lib/onboarding-state.test.ts", "test:compaction": "tsx --test main/services/pi-compaction-core.test.ts", - "test": "tsx --test main/handlers/assistant-parse.test.ts main/services/assistant/system-prompt.test.ts main/services/chat-generation-start.test.ts main/services/chat-title-policy.test.ts main/services/chat-title-routing.test.ts main/services/chat-store-core.test.ts main/services/codex-provider.test.ts main/services/coding-tools.test.ts main/services/config-store-core.test.ts main/services/computer-use/computer-use-foundation.test.ts main/services/computer-use/computer-use-tool.test.ts main/services/computer-use/generation-gate.test.ts main/services/computer-use/safety.test.ts main/services/computer-use/settings-core.test.ts main/services/computer-use/status-core.test.ts main/services/data-store.test.ts main/services/data-store.resilience.test.ts main/services/aiden-config-dir.test.ts main/services/portable-config-core.test.ts main/services/portable-config-core.roundtrip.test.ts main/services/portable-config-watch-core.test.ts main/services/secret-map-core.test.ts main/services/dev-log.test.ts main/services/dictation-coordinator.test.ts main/services/dictation-paste.test.ts main/services/foundation-models-connection.test.ts main/services/foundation-models-connection-core.test.ts main/services/gemini-context-cache.test.ts main/services/generation-bound-connection-cache.test.ts main/services/generation-context.test.ts main/services/generation-messages.test.ts main/services/generation-runtime.test.ts main/services/external-editors.test.ts main/services/git.test.ts main/services/model-runtime-core.test.ts main/services/models.test.ts main/services/mcp-oauth-operation.test.ts main/services/mcp-oauth-session.test.ts main/services/mcp-presets.test.ts main/services/pi-credential-store-core.test.ts main/services/pi-provider-contract.test.ts main/services/profile-share-core.test.ts main/services/profile-share-files.test.ts main/services/profile.test.ts main/services/provider-auth-flow-core.test.ts main/services/provider-auth-owner.test.ts main/services/provider-key-policy.test.ts main/services/provider-list-core.test.ts main/services/quit-barrier.test.ts main/services/scratch-workspace.test.ts main/services/skills-discovery.test.ts main/services/tool-approval.test.ts main/services/local-runtime-status.test.ts main/services/usage-store-core.test.ts main/services/workspace-files.test.ts main/windows/pill-window-security.test.ts renderer/components/assistant/use-assistant-chat.test.ts renderer/components/assistant/assistant-ui.test.tsx renderer/components/activity-feed.test.tsx renderer/components/environment-subagents-contract.test.ts renderer/components/subagents-panel.test.tsx renderer/components/chat-sidebar.test.tsx renderer/components/composer.test.tsx renderer/main/chat-transition.test.tsx renderer/components/usage/profile-share-card.test.tsx renderer/lib/accessibility-refresh.test.ts renderer/lib/agent-activity.test.ts renderer/lib/assistant-dock.test.ts renderer/lib/assistant-motion-contract.test.ts renderer/lib/dialog-motion-contract.test.ts renderer/lib/chat-deletion-cache.test.ts renderer/lib/chat-terminal-sync.test.ts renderer/lib/ipc-stream.test.ts renderer/lib/chat-title-reveal.test.ts renderer/lib/codex-auth-session.test.ts renderer/lib/codex-provider-cache.test.ts renderer/lib/composer-placeholder.test.ts renderer/lib/computer-use-notice.test.ts renderer/lib/dictation-operation-gate.test.ts renderer/lib/editor-preference.test.ts renderer/lib/environment-panel-layout.test.ts renderer/lib/subagent-view-state.test.ts renderer/lib/truncate-path.test.ts renderer/lib/mcp-preset-state.test.ts renderer/lib/model-display.test.ts renderer/lib/model-picker-data.test.ts renderer/lib/profile-share-data.test.ts renderer/lib/sidebar-chat-shortcuts.test.ts renderer/lib/usage-profile-data.test.ts renderer/shared/appearance.test.ts renderer/shared/provider-deployment.test.ts main/handlers/ipc-contract.test.ts main/handlers/chat.parse.test.ts main/handlers/voice-codec.test.ts main/handlers/phase2-parse.test.ts scripts/apple-developer-tools.test.mjs scripts/check-macos-release.test.mjs scripts/computer-use-packaged-acceptance.test.mjs scripts/configure-electron-fuses.test.mjs scripts/model-snapshot-core.test.mjs scripts/prepare-macos-dev-runtime.test.mjs scripts/prepare-macos-package-output.test.mjs scripts/run-macos-distribution.test.mjs scripts/sign-macos.test.mjs scripts/hide-dmg-support-files.test.mjs scripts/update-model-capabilities.test.mjs scripts/vendor-cua-driver.test.mjs scripts/verify-macos-package.test.mjs && npm run test:worktree-remover:native && npm run test:computer-use:native", - "test:coverage": "tsx --test --experimental-test-coverage main/handlers/assistant-parse.test.ts main/services/assistant/system-prompt.test.ts main/services/chat-generation-start.test.ts main/services/chat-title-policy.test.ts main/services/chat-title-routing.test.ts main/services/chat-store-core.test.ts main/services/codex-provider.test.ts main/services/coding-tools.test.ts main/services/config-store-core.test.ts main/services/computer-use/computer-use-foundation.test.ts main/services/computer-use/computer-use-tool.test.ts main/services/computer-use/generation-gate.test.ts main/services/computer-use/safety.test.ts main/services/computer-use/settings-core.test.ts main/services/computer-use/status-core.test.ts main/services/data-store.test.ts main/services/data-store.resilience.test.ts main/services/aiden-config-dir.test.ts main/services/portable-config-core.test.ts main/services/portable-config-core.roundtrip.test.ts main/services/portable-config-watch-core.test.ts main/services/dev-log.test.ts main/services/dictation-coordinator.test.ts main/services/dictation-paste.test.ts main/services/foundation-models-connection.test.ts main/services/foundation-models-connection-core.test.ts main/services/generation-bound-connection-cache.test.ts main/services/generation-context.test.ts main/services/generation-messages.test.ts main/services/generation-runtime.test.ts main/services/external-editors.test.ts main/services/git.test.ts main/services/model-runtime-core.test.ts main/services/pi-compaction-core.test.ts main/services/models.test.ts main/services/mcp-oauth-operation.test.ts main/services/mcp-oauth-session.test.ts main/services/mcp-presets.test.ts main/services/pi-credential-store-core.test.ts main/services/pi-provider-contract.test.ts main/services/profile-share-core.test.ts main/services/profile-share-files.test.ts main/services/profile.test.ts main/services/provider-auth-flow-core.test.ts main/services/provider-auth-owner.test.ts main/services/provider-key-policy.test.ts main/services/provider-list-core.test.ts main/services/quit-barrier.test.ts main/services/scratch-workspace.test.ts main/services/skills-discovery.test.ts main/services/tool-approval.test.ts main/services/local-runtime-status.test.ts main/services/usage-store-core.test.ts main/services/workspace-files.test.ts main/windows/pill-window-security.test.ts renderer/components/assistant/use-assistant-chat.test.ts renderer/components/assistant/assistant-ui.test.tsx renderer/components/environment-subagents-contract.test.ts renderer/components/subagents-panel.test.tsx renderer/components/chat-sidebar.test.tsx renderer/components/composer.test.tsx renderer/main/chat-transition.test.tsx renderer/components/usage/profile-share-card.test.tsx renderer/lib/accessibility-refresh.test.ts renderer/lib/agent-activity.test.ts renderer/lib/assistant-dock.test.ts renderer/lib/assistant-motion-contract.test.ts renderer/lib/dialog-motion-contract.test.ts renderer/lib/chat-deletion-cache.test.ts renderer/lib/chat-terminal-sync.test.ts renderer/lib/ipc-stream.test.ts renderer/lib/chat-title-reveal.test.ts renderer/lib/codex-auth-session.test.ts renderer/lib/codex-auth-view-state.test.ts renderer/lib/codex-provider-cache.test.ts renderer/lib/composer-placeholder.test.ts renderer/lib/computer-use-notice.test.ts renderer/lib/dictation-operation-gate.test.ts renderer/lib/editor-preference.test.ts renderer/lib/environment-panel-layout.test.ts renderer/lib/subagent-view-state.test.ts renderer/lib/truncate-path.test.ts renderer/lib/mcp-preset-state.test.ts renderer/lib/model-display.test.ts renderer/lib/profile-share-data.test.ts renderer/lib/sidebar-chat-shortcuts.test.ts renderer/lib/usage-profile-data.test.ts renderer/shared/appearance.test.ts renderer/shared/provider-deployment.test.ts main/handlers/ipc-contract.test.ts main/handlers/chat.parse.test.ts main/handlers/voice-codec.test.ts main/handlers/phase2-parse.test.ts scripts/apple-developer-tools.test.mjs scripts/check-macos-release.test.mjs scripts/computer-use-packaged-acceptance.test.mjs scripts/configure-electron-fuses.test.mjs scripts/model-snapshot-core.test.mjs scripts/prepare-macos-dev-runtime.test.mjs scripts/prepare-macos-package-output.test.mjs scripts/run-macos-distribution.test.mjs scripts/sign-macos.test.mjs scripts/update-model-capabilities.test.mjs scripts/vendor-cua-driver.test.mjs scripts/verify-macos-package.test.mjs", + "test": "tsx --test main/handlers/assistant-parse.test.ts main/services/assistant/system-prompt.test.ts main/services/chat-generation-start.test.ts main/services/chat-title-policy.test.ts main/services/chat-title-routing.test.ts main/services/chat-store-core.test.ts main/services/codex-provider.test.ts main/services/coding-tools.test.ts main/services/config-store-core.test.ts main/services/computer-use/computer-use-foundation.test.ts main/services/computer-use/computer-use-tool.test.ts main/services/computer-use/generation-gate.test.ts main/services/computer-use/safety.test.ts main/services/computer-use/settings-core.test.ts main/services/computer-use/status-core.test.ts main/services/data-store.test.ts main/services/data-store.resilience.test.ts main/services/terminal.test.ts main/services/terminal-history.test.ts main/services/aiden-config-dir.test.ts main/services/portable-config-core.test.ts main/services/portable-config-core.roundtrip.test.ts main/services/portable-config-watch-core.test.ts main/services/secret-map-core.test.ts main/services/dev-log.test.ts main/services/dictation-coordinator.test.ts main/services/dictation-paste.test.ts main/services/foundation-models-connection.test.ts main/services/foundation-models-connection-core.test.ts main/services/gemini-context-cache.test.ts main/services/generation-bound-connection-cache.test.ts main/services/generation-context.test.ts main/services/generation-messages.test.ts main/services/generation-runtime.test.ts main/services/external-editors.test.ts main/services/git.test.ts main/services/model-runtime-core.test.ts main/services/models.test.ts main/services/mcp-oauth-operation.test.ts main/services/mcp-oauth-session.test.ts main/services/mcp-presets.test.ts main/services/pi-credential-store-core.test.ts main/services/pi-provider-contract.test.ts main/services/profile-share-core.test.ts main/services/profile-share-files.test.ts main/services/profile.test.ts main/services/provider-auth-flow-core.test.ts main/services/provider-auth-owner.test.ts main/services/provider-key-policy.test.ts main/services/provider-list-core.test.ts main/services/quit-barrier.test.ts main/services/scratch-workspace.test.ts main/services/skills-discovery.test.ts main/services/tool-approval.test.ts main/services/local-runtime-status.test.ts main/services/usage-store-core.test.ts main/services/workspace-files.test.ts main/windows/pill-window-security.test.ts renderer/components/assistant/use-assistant-chat.test.ts renderer/components/assistant/assistant-ui.test.tsx renderer/components/activity-feed.test.tsx renderer/components/environment-subagents-contract.test.ts renderer/components/subagents-panel.test.tsx renderer/components/chat-sidebar.test.tsx renderer/components/composer.test.tsx renderer/main/chat-transition.test.tsx renderer/components/usage/profile-share-card.test.tsx renderer/lib/accessibility-refresh.test.ts renderer/lib/agent-activity.test.ts renderer/lib/assistant-dock.test.ts renderer/lib/assistant-motion-contract.test.ts renderer/lib/dialog-motion-contract.test.ts renderer/lib/chat-deletion-cache.test.ts renderer/lib/chat-terminal-sync.test.ts renderer/lib/ipc-stream.test.ts renderer/lib/chat-title-reveal.test.ts renderer/lib/codex-auth-session.test.ts renderer/lib/codex-provider-cache.test.ts renderer/lib/composer-placeholder.test.ts renderer/lib/computer-use-notice.test.ts renderer/lib/dictation-operation-gate.test.ts renderer/lib/editor-preference.test.ts renderer/lib/environment-panel-layout.test.ts renderer/lib/subagent-view-state.test.ts renderer/lib/truncate-path.test.ts renderer/lib/mcp-preset-state.test.ts renderer/lib/model-display.test.ts renderer/lib/model-picker-data.test.ts renderer/lib/profile-share-data.test.ts renderer/lib/sidebar-chat-shortcuts.test.ts renderer/lib/usage-profile-data.test.ts renderer/shared/appearance.test.ts renderer/shared/provider-deployment.test.ts main/handlers/ipc-contract.test.ts main/handlers/chat.parse.test.ts main/handlers/voice-codec.test.ts main/handlers/phase2-parse.test.ts scripts/apple-developer-tools.test.mjs scripts/check-macos-release.test.mjs scripts/computer-use-packaged-acceptance.test.mjs scripts/configure-electron-fuses.test.mjs scripts/model-snapshot-core.test.mjs scripts/prepare-macos-dev-runtime.test.mjs scripts/prepare-macos-package-output.test.mjs scripts/run-macos-distribution.test.mjs scripts/sign-macos.test.mjs scripts/hide-dmg-support-files.test.mjs scripts/update-model-capabilities.test.mjs scripts/vendor-cua-driver.test.mjs scripts/verify-macos-package.test.mjs && npm run test:worktree-remover:native && npm run test:computer-use:native", + "test:coverage": "tsx --test --experimental-test-coverage main/handlers/assistant-parse.test.ts main/services/assistant/system-prompt.test.ts main/services/chat-generation-start.test.ts main/services/chat-title-policy.test.ts main/services/chat-title-routing.test.ts main/services/chat-store-core.test.ts main/services/codex-provider.test.ts main/services/coding-tools.test.ts main/services/config-store-core.test.ts main/services/computer-use/computer-use-foundation.test.ts main/services/computer-use/computer-use-tool.test.ts main/services/computer-use/generation-gate.test.ts main/services/computer-use/safety.test.ts main/services/computer-use/settings-core.test.ts main/services/computer-use/status-core.test.ts main/services/data-store.test.ts main/services/data-store.resilience.test.ts main/services/terminal.test.ts main/services/terminal-history.test.ts main/services/aiden-config-dir.test.ts main/services/portable-config-core.test.ts main/services/portable-config-core.roundtrip.test.ts main/services/portable-config-watch-core.test.ts main/services/dev-log.test.ts main/services/dictation-coordinator.test.ts main/services/dictation-paste.test.ts main/services/foundation-models-connection.test.ts main/services/foundation-models-connection-core.test.ts main/services/generation-bound-connection-cache.test.ts main/services/generation-context.test.ts main/services/generation-messages.test.ts main/services/generation-runtime.test.ts main/services/external-editors.test.ts main/services/git.test.ts main/services/model-runtime-core.test.ts main/services/pi-compaction-core.test.ts main/services/models.test.ts main/services/mcp-oauth-operation.test.ts main/services/mcp-oauth-session.test.ts main/services/mcp-presets.test.ts main/services/pi-credential-store-core.test.ts main/services/pi-provider-contract.test.ts main/services/profile-share-core.test.ts main/services/profile-share-files.test.ts main/services/profile.test.ts main/services/provider-auth-flow-core.test.ts main/services/provider-auth-owner.test.ts main/services/provider-key-policy.test.ts main/services/provider-list-core.test.ts main/services/quit-barrier.test.ts main/services/scratch-workspace.test.ts main/services/skills-discovery.test.ts main/services/tool-approval.test.ts main/services/local-runtime-status.test.ts main/services/usage-store-core.test.ts main/services/workspace-files.test.ts main/windows/pill-window-security.test.ts renderer/components/assistant/use-assistant-chat.test.ts renderer/components/assistant/assistant-ui.test.tsx renderer/components/environment-subagents-contract.test.ts renderer/components/subagents-panel.test.tsx renderer/components/chat-sidebar.test.tsx renderer/components/composer.test.tsx renderer/main/chat-transition.test.tsx renderer/components/usage/profile-share-card.test.tsx renderer/lib/accessibility-refresh.test.ts renderer/lib/agent-activity.test.ts renderer/lib/assistant-dock.test.ts renderer/lib/assistant-motion-contract.test.ts renderer/lib/dialog-motion-contract.test.ts renderer/lib/chat-deletion-cache.test.ts renderer/lib/chat-terminal-sync.test.ts renderer/lib/ipc-stream.test.ts renderer/lib/chat-title-reveal.test.ts renderer/lib/codex-auth-session.test.ts renderer/lib/codex-auth-view-state.test.ts renderer/lib/codex-provider-cache.test.ts renderer/lib/composer-placeholder.test.ts renderer/lib/computer-use-notice.test.ts renderer/lib/dictation-operation-gate.test.ts renderer/lib/editor-preference.test.ts renderer/lib/environment-panel-layout.test.ts renderer/lib/subagent-view-state.test.ts renderer/lib/truncate-path.test.ts renderer/lib/mcp-preset-state.test.ts renderer/lib/model-display.test.ts renderer/lib/profile-share-data.test.ts renderer/lib/sidebar-chat-shortcuts.test.ts renderer/lib/usage-profile-data.test.ts renderer/shared/appearance.test.ts renderer/shared/provider-deployment.test.ts main/handlers/ipc-contract.test.ts main/handlers/chat.parse.test.ts main/handlers/voice-codec.test.ts main/handlers/phase2-parse.test.ts scripts/apple-developer-tools.test.mjs scripts/check-macos-release.test.mjs scripts/computer-use-packaged-acceptance.test.mjs scripts/configure-electron-fuses.test.mjs scripts/model-snapshot-core.test.mjs scripts/prepare-macos-dev-runtime.test.mjs scripts/prepare-macos-package-output.test.mjs scripts/run-macos-distribution.test.mjs scripts/sign-macos.test.mjs scripts/update-model-capabilities.test.mjs scripts/vendor-cua-driver.test.mjs scripts/verify-macos-package.test.mjs", "test:computer-use": "tsx --test main/services/computer-use/computer-use-foundation.test.ts main/services/computer-use/computer-use-tool.test.ts main/services/computer-use/generation-gate.test.ts main/services/computer-use/safety.test.ts main/services/computer-use/settings-core.test.ts main/services/computer-use/status-core.test.ts main/services/data-store.test.ts main/services/generation-messages.test.ts main/services/generation-runtime.test.ts main/services/quit-barrier.test.ts main/services/tool-approval.test.ts scripts/check-macos-release.test.mjs scripts/computer-use-packaged-acceptance.test.mjs scripts/configure-electron-fuses.test.mjs scripts/prepare-macos-package-output.test.mjs scripts/run-macos-distribution.test.mjs scripts/sign-macos.test.mjs scripts/vendor-cua-driver.test.mjs scripts/verify-macos-package.test.mjs && npm run test:computer-use:native", "test:computer-use:packaged": "node scripts/computer-use-packaged-acceptance.mjs", "test:computer-use:native": "cd native/computer-use-broker && CARGO_TARGET_DIR=../../build/computer-use-broker-test cargo fmt -- --check && CARGO_TARGET_DIR=../../build/computer-use-broker-test cargo test --locked && CARGO_TARGET_DIR=../../build/computer-use-broker-test cargo clippy --locked --all-targets -- -D warnings", From 432ea212aa7b57c68ec3593d4b0d1e8c7659d983 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Mon, 10 Aug 2026 20:51:09 -0400 Subject: [PATCH 60/62] test(subagents): update terminal spawn source assertion for trySpawnShell refactor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The phase3 contract test asserts the terminal.ts source orders revalidate → abort-check → spawn → abort-check. The spawn call changed shape (single spawn → trySpawnShell destructure) in the shell-fallback PR; update the assertion to match while preserving the ordering invariant it protects. --- main/services/subagents/subagent-phase3-contract.test.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/main/services/subagents/subagent-phase3-contract.test.ts b/main/services/subagents/subagent-phase3-contract.test.ts index 0f0b86cd..b6ff3daa 100644 --- a/main/services/subagents/subagent-phase3-contract.test.ts +++ b/main/services/subagents/subagent-phase3-contract.test.ts @@ -560,10 +560,7 @@ test("managed worktree deletion and terminal creation share workspace mutation a "if (ownerInvalidated())", revalidate, ); - const spawn = terminalService.indexOf( - "const pty = (this.options.spawnPty ?? spawn)(", - finalAbortCheck, - ); + const spawn = terminalService.indexOf("const { pty,", finalAbortCheck); const postSpawnCheck = terminalService.indexOf( "if (ownerInvalidated())", spawn, From bb914c26e21054e4592c09b5c75af4940d670c22 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Tue, 11 Aug 2026 11:45:31 -0400 Subject: [PATCH 61/62] test(terminal): make shell retries portable --- main/services/terminal-history.ts | 1 - main/services/terminal.test.ts | 4 ++++ main/services/terminal.ts | 5 ++++- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/main/services/terminal-history.ts b/main/services/terminal-history.ts index dec22fbd..366846fe 100644 --- a/main/services/terminal-history.ts +++ b/main/services/terminal-history.ts @@ -28,7 +28,6 @@ export interface TerminalHistoryStoreOptions { /** Test seam for the debounce window. */ debounceMs?: number; /** Test seam: custom timers. */ - now?: () => number; schedule?: (fn: () => void, ms: number) => () => void; } diff --git a/main/services/terminal.test.ts b/main/services/terminal.test.ts index 58ecfc2b..5cc7dc2c 100644 --- a/main/services/terminal.test.ts +++ b/main/services/terminal.test.ts @@ -256,6 +256,7 @@ test("shell retry loop falls back when the preferred shell fails to spawn", asyn // Both candidates must be "executable" so they enter the spawn loop; the // spawn itself is what throws here. shellCandidates: () => ["/bin/zsh", "/bin/sh"], + shellIsExecutable: () => true, spawnPty: ((file: string) => { if (file === "/bin/zsh") throw new Error("posix_spawnp failed."); spawnedShell = file; @@ -275,6 +276,7 @@ test("a non-retryable spawn error surfaces immediately instead of falling back", const service = new TerminalService({ prepareSpawnHelper: async () => undefined, shellCandidates: () => ["/bin/zsh", "/bin/sh"], + shellIsExecutable: () => true, spawnPty: (() => { attempts += 1; // EINVAL is NOT a "missing shell" error — it must not be masked. @@ -294,6 +296,7 @@ test("all shells failing to spawn throws a descriptive error listing attempts", const service = new TerminalService({ prepareSpawnHelper: async () => undefined, shellCandidates: () => ["/bin/zsh", "/bin/bash"], + shellIsExecutable: () => true, spawnPty: (() => { throw new Error("posix_spawnp failed."); }) as typeof spawn, @@ -311,6 +314,7 @@ test("the first candidate succeeding reports preferredShellSkipped false", async const service = new TerminalService({ prepareSpawnHelper: async () => undefined, shellCandidates: () => ["/bin/zsh", "/bin/sh"], + shellIsExecutable: () => true, spawnPty: ((file: string) => { spawnedShell = file; return fakePty().pty; diff --git a/main/services/terminal.ts b/main/services/terminal.ts index 2c51f73d..37b4abe4 100644 --- a/main/services/terminal.ts +++ b/main/services/terminal.ts @@ -55,6 +55,8 @@ export interface TerminalServiceOptions { * Exposed for tests; production resolves `$SHELL` then the macOS defaults. */ shellCandidates?: () => string[]; + /** Test seam for candidate executability checks. */ + shellIsExecutable?: (filePath: string) => boolean; /** * Returns the `spawn-helper` paths node-pty will use. Exposed for tests so * the chmod/verify path can be exercised without touching node_modules. @@ -247,7 +249,8 @@ export class TerminalService { const candidates = (this.options.shellCandidates ?? defaultShellCandidates)(); // Verify each candidate exists+is executable before spawning, so the retry // loop below only fights launch failures (not obvious "no such file" ones). - const executableCandidates = candidates.filter(isExecutable); + const shellIsExecutable = this.options.shellIsExecutable ?? isExecutable; + const executableCandidates = candidates.filter(shellIsExecutable); if (executableCandidates.length === 0) { throw new Error( `No executable shell found on this Mac (checked ${candidates From 8a91093dda3dc83d0cf347a46aba59fe18732fb2 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Tue, 11 Aug 2026 12:11:45 -0400 Subject: [PATCH 62/62] fix(terminal): activate persisted sanitized history --- main/index.ts | 11 ++ main/services/terminal-history.test.ts | 82 +++++++++++++ main/services/terminal-history.ts | 114 +++++++++++++++---- main/services/terminal.test.ts | 9 +- main/services/terminal.ts | 36 +++++- renderer/components/onboarding-flow.test.tsx | 1 + renderer/components/onboarding-flow.tsx | 3 +- 7 files changed, 230 insertions(+), 26 deletions(-) diff --git a/main/index.ts b/main/index.ts index b892e995..cef1be06 100644 --- a/main/index.ts +++ b/main/index.ts @@ -13,6 +13,7 @@ import path from "node:path"; import { registerHandlers } from "./handlers/index.js"; import { terminalService } from "./services/terminal.js"; +import { TerminalHistoryStore } from "./services/terminal-history.js"; import { getPreloadPath, getWindowUrl } from "./windows/window-paths.js"; import { initShortcut, @@ -324,6 +325,7 @@ async function shutdownAndQuit(settingsPrepared = false): Promise { await subagentRunStore.flush(); await subagentRunStore.close(); })(), + terminalService.flushHistory(), ]); } catch (error) { logger.error("main", "Application service shutdown did not complete cleanly.", error); @@ -1203,6 +1205,15 @@ if (!ownsSingleInstanceLock) { initDevLog(path.join(runtimeProfile.logsPath, "aiden-dev.log")); logger.info("dev-log", `Writing dev log to ${devLogPath() ?? "unknown"}`); } + try { + terminalService.installHistoryStore(await TerminalHistoryStore.create()); + } catch (error) { + logger.warn( + "terminal", + "Persisted terminal history is unavailable; terminals will remain session-only.", + error, + ); + } // Reconcile every persisted active child at the actual restart boundary, // before a renderer can read or append run history. await subagentRunStore.initialize(); diff --git a/main/services/terminal-history.test.ts b/main/services/terminal-history.test.ts index db790090..8f87d575 100644 --- a/main/services/terminal-history.test.ts +++ b/main/services/terminal-history.test.ts @@ -5,6 +5,7 @@ import { mkdtemp, readFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { + MAX_HISTORY_CHARS, TerminalHistoryStore, capHistory, sanitizeTerminalHistoryChunk, @@ -79,6 +80,11 @@ test("capHistory preserves a trailing newline", () => { assert.equal(capped, "line8\nline9\n"); }); +test("capHistory bounds a long history without line breaks", () => { + const capped = capHistory("x".repeat(MAX_HISTORY_CHARS + 100), 5_000); + assert.equal(capped.length, MAX_HISTORY_CHARS); +}); + test("TerminalHistoryStore round-trips appended chunks after flush", async () => { const dir = await mkdtemp(path.join(tmpdir(), "pty-history-")); const store = new TerminalHistoryStore({ logsDir: dir, debounceMs: 0 }); @@ -93,6 +99,72 @@ test("TerminalHistoryStore round-trips appended chunks after flush", async () => } }); +test("TerminalHistoryStore preserves restored history when appending after restart", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "pty-history-restart-")); + try { + const first = new TerminalHistoryStore({ logsDir: dir, debounceMs: 0 }); + first.append("ws-1", "before restart\n"); + await first.flush("ws-1"); + + const restarted = new TerminalHistoryStore({ logsDir: dir, debounceMs: 0 }); + assert.equal(await restarted.read("ws-1"), "before restart\n"); + restarted.append("ws-1", "after restart\n"); + await restarted.flush("ws-1"); + + const verified = new TerminalHistoryStore({ logsDir: dir }); + assert.equal(await verified.read("ws-1"), "before restart\nafter restart\n"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("TerminalHistoryStore flushAll settles every workspace before shutdown", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "pty-history-flush-all-")); + const store = new TerminalHistoryStore({ logsDir: dir, debounceMs: 60_000 }); + try { + store.append("ws-1", "alpha"); + store.append("ws-2", "beta"); + await store.flushAll(); + + const verified = new TerminalHistoryStore({ logsDir: dir }); + assert.equal(await verified.read("ws-1"), "alpha"); + assert.equal(await verified.read("ws-2"), "beta"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("TerminalHistoryStore flushAll retains output appended during an active write", async () => { + const writes: string[] = []; + let fire: () => void = () => {}; + let releaseFirstWrite: () => void = () => {}; + const firstWriteBlocked = new Promise((resolve) => { + releaseFirstWrite = resolve; + }); + const store = new TerminalHistoryStore({ + logsDir: "/unused-test-history", + schedule: (fn) => { + fire = fn; + return () => { + fire = () => {}; + }; + }, + writeFile: async (_filePath, history) => { + writes.push(history); + if (writes.length === 1) await firstWriteBlocked; + }, + }); + + store.append("ws-1", "alpha"); + fire(); + await new Promise((resolve) => setImmediate(resolve)); + store.append("ws-1", " beta"); + releaseFirstWrite(); + await store.flushAll(); + + assert.deepEqual(writes, ["alpha", "alpha beta"]); +}); + test("TerminalHistoryStore persists sanitized output to disk", async () => { const dir = await mkdtemp(path.join(tmpdir(), "pty-history-sanitize-")); const store = new TerminalHistoryStore({ logsDir: dir, debounceMs: 0 }); @@ -167,3 +239,13 @@ test("TerminalHistoryStore coalesces rapid appends into one debounced write", as await rm(dir, { recursive: true, force: true }); } }); + +test("production startup installs and flushes persisted terminal history", async () => { + const main = await readFile(new URL("../index.ts", import.meta.url), "utf8"); + assert.match(main, /TerminalHistoryStore/u); + assert.match( + main, + /terminalService\.installHistoryStore\(await TerminalHistoryStore\.create\(\)\)/u, + ); + assert.match(main, /terminalService\.flushHistory\(\)/u); +}); diff --git a/main/services/terminal-history.ts b/main/services/terminal-history.ts index 366846fe..2cf1617a 100644 --- a/main/services/terminal-history.ts +++ b/main/services/terminal-history.ts @@ -18,6 +18,7 @@ import { ensureUserDataDir } from "./data-store.js"; import type { TerminalHistoryStoreLike } from "./terminal.js"; export const MAX_HISTORY_LINES = 5_000; +export const MAX_HISTORY_CHARS = 200_000; const PERSIST_DEBOUNCE_MS = 40; export interface TerminalHistoryStoreOptions { @@ -25,10 +26,14 @@ export interface TerminalHistoryStoreOptions { logsDir: string; /** Override for tests; production resolves via ensureUserDataDir. */ maxLines?: number; + /** Hard memory and disk bound, including histories without line breaks. */ + maxChars?: number; /** Test seam for the debounce window. */ debounceMs?: number; /** Test seam: custom timers. */ schedule?: (fn: () => void, ms: number) => () => void; + /** Test seam for deterministic write-race coverage. */ + writeFile?: (filePath: string, history: string) => Promise; } /** @@ -240,14 +245,20 @@ function findEscapeSequenceEndIndex(input: string, start: number): number | null * Keep only the most recent `maxLines` lines so a long-running terminal does * not grow without bound. A trailing newline is preserved if present. */ -export function capHistory(history: string, maxLines: number): string { +export function capHistory( + history: string, + maxLines: number, + maxChars = MAX_HISTORY_CHARS, +): string { if (history.length === 0) return history; const hasTrailingNewline = history.endsWith("\n"); const lines = history.split("\n"); if (hasTrailingNewline) lines.pop(); - if (lines.length <= maxLines) return history; - const capped = lines.slice(lines.length - maxLines).join("\n"); - return hasTrailingNewline ? `${capped}\n` : capped; + const lineCapped = + lines.length <= maxLines ? history : lines.slice(lines.length - maxLines).join("\n"); + const withTrailingNewline = + lines.length > maxLines && hasTrailingNewline ? `${lineCapped}\n` : lineCapped; + return withTrailingNewline.slice(-maxChars); } function safeWorkspaceId(workspaceId: string): string { @@ -263,21 +274,32 @@ interface PendingWorkspaceState { cancel: () => void; /** True when a write is scheduled but has not fired yet. */ writeScheduled: boolean; + /** Monotonic in-memory content revision. */ + revision: number; + /** Latest revision handed to the best-effort disk writer. */ + persistedRevision: number; + /** The one serialized disk write, if any. */ + writeInFlight?: Promise; } export class TerminalHistoryStore implements TerminalHistoryStoreLike { private readonly maxLines: number; + private readonly maxChars: number; private readonly debounceMs: number; private readonly schedule: (fn: () => void, ms: number) => () => void; + private readonly writeFile: (filePath: string, history: string) => Promise; private readonly pending = new Map(); constructor(private readonly options: TerminalHistoryStoreOptions) { this.maxLines = options.maxLines ?? MAX_HISTORY_LINES; + this.maxChars = options.maxChars ?? MAX_HISTORY_CHARS; this.debounceMs = options.debounceMs ?? PERSIST_DEBOUNCE_MS; this.schedule = options.schedule ?? ((fn, ms) => { const handle = setTimeout(fn, ms); return () => clearTimeout(handle); }); + this.writeFile = + options.writeFile ?? ((filePath, history) => fs.writeFile(filePath, history, "utf8")); } /** Create the default store rooted at `/terminal-history`. */ @@ -289,13 +311,25 @@ export class TerminalHistoryStore implements TerminalHistoryStoreLike { async read(workspaceId: string): Promise { const state = this.pending.get(workspaceId); if (state) return state.history; + let history = ""; try { const file = path.join(this.options.logsDir, `${safeWorkspaceId(workspaceId)}.log`); const raw = await fs.readFile(file, "utf8"); - return capHistory(raw, this.maxLines); + history = capHistory(raw, this.maxLines, this.maxChars); } catch { - return ""; + // Missing or unreadable history is a safe empty starting point. } + const existing = this.pending.get(workspaceId); + if (existing) return existing.history; + this.pending.set(workspaceId, { + history, + pendingControlSequence: "", + cancel: () => {}, + writeScheduled: false, + revision: 0, + persistedRevision: 0, + }); + return history; } append(workspaceId: string, data: string): void { @@ -306,23 +340,45 @@ export class TerminalHistoryStore implements TerminalHistoryStoreLike { pendingControlSequence: "", cancel: () => {}, writeScheduled: false, + revision: 0, + persistedRevision: 0, }; this.pending.set(workspaceId, state); } const sanitized = sanitizeTerminalHistoryChunk(state.pendingControlSequence, data); state.pendingControlSequence = sanitized.pendingControlSequence; if (sanitized.visibleText.length > 0) { - state.history = capHistory(`${state.history}${sanitized.visibleText}`, this.maxLines); + state.history = capHistory( + `${state.history}${sanitized.visibleText}`, + this.maxLines, + this.maxChars, + ); + state.revision += 1; + this.scheduleDebouncedWrite(workspaceId); } - this.scheduleDebouncedWrite(workspaceId); } async flush(workspaceId: string): Promise { const state = this.pending.get(workspaceId); - if (!state?.writeScheduled) return; - state.cancel(); - state.writeScheduled = false; - await this.persist(workspaceId); + if (!state) return; + while (this.pending.get(workspaceId) === state) { + if (state.writeScheduled) { + state.cancel(); + state.writeScheduled = false; + } + await this.persist(workspaceId); + if ( + !state.writeScheduled && + !state.writeInFlight && + state.persistedRevision >= state.revision + ) { + return; + } + } + } + + async flushAll(): Promise { + await Promise.all([...this.pending.keys()].map((workspaceId) => this.flush(workspaceId))); } async clear(workspaceId: string): Promise { @@ -330,6 +386,7 @@ export class TerminalHistoryStore implements TerminalHistoryStoreLike { if (state) { state.cancel(); this.pending.delete(workspaceId); + await state.writeInFlight; } try { await fs.unlink(path.join(this.options.logsDir, `${safeWorkspaceId(workspaceId)}.log`)); @@ -343,6 +400,7 @@ export class TerminalHistoryStore implements TerminalHistoryStoreLike { if (!state || state.writeScheduled) return; state.writeScheduled = true; state.cancel = this.schedule(() => { + state.writeScheduled = false; void this.persist(workspaceId); }, this.debounceMs); } @@ -350,13 +408,31 @@ export class TerminalHistoryStore implements TerminalHistoryStoreLike { private async persist(workspaceId: string): Promise { const state = this.pending.get(workspaceId); if (!state) return; - try { - const file = path.join(this.options.logsDir, `${safeWorkspaceId(workspaceId)}.log`); - await fs.writeFile(file, state.history, "utf8"); - } catch { - // A terminal must never fail or block on history-disk trouble. - } finally { - if (state) state.writeScheduled = false; + if (state.writeInFlight) { + await state.writeInFlight; + return; } + if (state.persistedRevision >= state.revision) return; + const revision = state.revision; + const history = state.history; + const file = path.join(this.options.logsDir, `${safeWorkspaceId(workspaceId)}.log`); + const operation = Promise.resolve() + .then(() => this.writeFile(file, history)) + .catch(() => { + // A terminal must never fail or block on history-disk trouble. + }) + .finally(() => { + state.persistedRevision = Math.max(state.persistedRevision, revision); + if (state.writeInFlight === operation) state.writeInFlight = undefined; + if ( + this.pending.get(workspaceId) === state && + state.revision > state.persistedRevision && + !state.writeScheduled + ) { + this.scheduleDebouncedWrite(workspaceId); + } + }); + state.writeInFlight = operation; + await operation; } } diff --git a/main/services/terminal.test.ts b/main/services/terminal.test.ts index 5cc7dc2c..aec197ef 100644 --- a/main/services/terminal.test.ts +++ b/main/services/terminal.test.ts @@ -330,6 +330,7 @@ test("the first candidate succeeding reports preferredShellSkipped false", async test("persisted history seeds a reopened terminal buffer", async () => { const owner = ownerState(); let history = "prior output\n"; + let flushAllCount = 0; // A minimal in-memory history store stub. const historyStore = { read: async () => history, @@ -337,16 +338,22 @@ test("persisted history seeds a reopened terminal buffer", async () => { history += data; }, flush: async () => undefined, + flushAll: async () => { + flushAllCount += 1; + }, }; const service = new TerminalService({ prepareSpawnHelper: async () => undefined, spawnPty: (() => fakePty().pty) as typeof spawn, - historyStore, }); + service.installHistoryStore(historyStore); + assert.throws(() => service.installHistoryStore(historyStore), /already initialized/u); const session = await service.create("workspace-1", "/tmp", owner.owner); // The restored history is available via snapshot, so the renderer can // re-hydrate xterm with the prior session's output. const snapshot = service.snapshot(session.id, owner.owner); assert.equal(snapshot.buffer, "prior output\n"); + await service.flushHistory(); + assert.equal(flushAllCount, 1); }); diff --git a/main/services/terminal.ts b/main/services/terminal.ts index 37b4abe4..8783403a 100644 --- a/main/services/terminal.ts +++ b/main/services/terminal.ts @@ -79,6 +79,7 @@ export interface TerminalHistoryStoreLike { read(workspaceId: string): Promise; append(workspaceId: string, data: string): void; flush(workspaceId: string): Promise; + flushAll?(): Promise; } function terminalId(): string { @@ -213,8 +214,33 @@ export class TerminalService { private readonly sessions = new Map(); private readonly webContentsEpochs = new Map(); private spawnHelperReady: Promise | undefined; + private historyStore: TerminalHistoryStoreLike | undefined; - constructor(private readonly options: TerminalServiceOptions = {}) {} + constructor(private readonly options: TerminalServiceOptions = {}) { + this.historyStore = options.historyStore; + } + + /** Install the production history store before any renderer can open a terminal. */ + installHistoryStore(historyStore: TerminalHistoryStoreLike): void { + if (this.historyStore) throw new Error("Terminal history is already initialized."); + if (this.sessions.size > 0) { + throw new Error("Terminal history must be initialized before opening a terminal."); + } + this.historyStore = historyStore; + } + + /** Settle every pending history write before application shutdown. */ + async flushHistory(): Promise { + if (!this.historyStore) return; + if (this.historyStore.flushAll) { + await this.historyStore.flushAll(); + return; + } + const workspaceIds = new Set( + [...this.sessions.values()].map((session) => session.historyWorkspaceId), + ); + await Promise.all([...workspaceIds].map((workspaceId) => this.historyStore!.flush(workspaceId))); + } async create( workspaceId: string, @@ -273,7 +299,7 @@ export class TerminalService { // Restore the sanitized prior-session output so the terminal reopens with // its history. The renderer writes this buffer to xterm on hydrate, so no // renderer change is required for the seed. - const restoredHistory = await this.options.historyStore?.read(workspaceId); + const restoredHistory = await this.historyStore?.read(workspaceId); if (ownerInvalidated()) { this.terminatePty(pty); throw new Error("The workspace changed before the terminal could start."); @@ -314,7 +340,7 @@ export class TerminalService { current.buffer = `${current.buffer}${data}`.slice(-MAX_BUFFER_CHARS); current.sequence += 1; // Persist new output (the store sanitizes and debounces the disk write). - this.options.historyStore?.append(workspaceId, data); + this.historyStore?.append(workspaceId, data); try { owner.send("terminal:data", { sessionId: id, sequence: current.sequence, data }); } catch { @@ -327,7 +353,7 @@ export class TerminalService { this.sessions.delete(id); current.removeOwnerInvalidation(); // Flush the final chunk before the session goes away. - void this.options.historyStore?.flush(workspaceId); + void this.historyStore?.flush(workspaceId); try { owner.send("terminal:exit", { sessionId: id, exitCode, signal }); } catch { @@ -400,7 +426,7 @@ export class TerminalService { this.terminatePty(session.pty); // Best-effort flush so the last output chunk is on disk before the session // is torn down (window close, workspace switch, quit). Never block on it. - void this.options.historyStore?.flush(session.historyWorkspaceId); + void this.historyStore?.flush(session.historyWorkspaceId); try { session.owner.send("terminal:exit", { sessionId: id, diff --git a/renderer/components/onboarding-flow.test.tsx b/renderer/components/onboarding-flow.test.tsx index 53f9d051..3e88eaf1 100644 --- a/renderer/components/onboarding-flow.test.tsx +++ b/renderer/components/onboarding-flow.test.tsx @@ -268,6 +268,7 @@ test("the final step is a complete grouped bento gallery with hover and keyboard ]) { assert.match(featurePresentation, new RegExp(title, "u")); } + assert.match(featurePresentation, /reopen it with sanitized local history/u); assert.equal(featurePresentation.match(/imageUrl: FEATURE_ILLUSTRATIONS\./gu)?.length, 22); assert.doesNotMatch(featurePresentation, /Designer Mode|Image Generation|Proactive nudges/u); }); diff --git a/renderer/components/onboarding-flow.tsx b/renderer/components/onboarding-flow.tsx index 6ee07771..8c1cae48 100644 --- a/renderer/components/onboarding-flow.tsx +++ b/renderer/components/onboarding-flow.tsx @@ -202,7 +202,8 @@ const featureBentos: FeatureBento[] = [ id: "terminal", group: "create", title: "Integrated Terminal", - description: "Run a workspace shell in a resizable drawer with tabs and split panes.", + description: + "Run a workspace shell in tabs or split panes, then reopen it with sanitized local history.", icon: SquareTerminal, imageUrl: FEATURE_ILLUSTRATIONS.terminal, size: "standard",