From a349227f70ab369f26aab8d97ab0a7b28db6fcc2 Mon Sep 17 00:00:00 2001 From: jackwener Date: Wed, 19 Aug 2026 21:35:40 +0800 Subject: [PATCH] refactor(desktop): remove superseded local authority paths Delete obsolete Desktop chat admission, Git Review mutation, store-backed config export, and direct ArtifactStore attachment paths now that Runtime Host owns those boundaries. Preserve the live compatibility projection and move config/attachment security coverage to the authoritative paths. Generated-by: Codex --- .../attachment-ingest-resolve.test.ts | 30 +- .../main/__tests__/attachment-ingest.test.ts | 68 --- .../src/main/__tests__/chat-readiness.test.ts | 550 ------------------ .../__tests__/config-transfer-service.test.ts | 51 +- .../main/__tests__/git-review-main.test.ts | 92 +-- .../runtime-host-config-ipc-main.test.ts | 115 ++++ apps/desktop/src/main/attachment-ingest.ts | 113 +--- apps/desktop/src/main/chat-readiness.ts | 291 --------- .../src/main/config-transfer-service.ts | 64 +- apps/desktop/src/main/git-review-main.ts | 76 +-- apps/desktop/src/main/main-window.ts | 4 +- .../src/main/runtime-host-config-ipc-main.ts | 6 +- ...runtime-host-session-execution-ipc-main.ts | 3 - .../app-shell-session-start-actions.ts | 2 +- .../src/renderer/session-health-notice.ts | 16 +- .../src/renderer/task-readiness-notice.ts | 6 + packages/core/src/connection-readiness.ts | 14 +- packages/core/src/git-review.ts | 9 - packages/core/src/session-send-projection.ts | 59 +- packages/core/src/session.ts | 5 +- 20 files changed, 210 insertions(+), 1364 deletions(-) delete mode 100644 apps/desktop/src/main/__tests__/attachment-ingest.test.ts delete mode 100644 apps/desktop/src/main/__tests__/chat-readiness.test.ts create mode 100644 apps/desktop/src/main/__tests__/runtime-host-config-ipc-main.test.ts delete mode 100644 apps/desktop/src/main/chat-readiness.ts diff --git a/apps/desktop/src/main/__tests__/attachment-ingest-resolve.test.ts b/apps/desktop/src/main/__tests__/attachment-ingest-resolve.test.ts index 28a02466c2..6ce6d8062d 100644 --- a/apps/desktop/src/main/__tests__/attachment-ingest-resolve.test.ts +++ b/apps/desktop/src/main/__tests__/attachment-ingest-resolve.test.ts @@ -1,6 +1,9 @@ import assert from 'node:assert/strict'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { describe, test } from 'node:test'; -import { resolveIngestItems } from '../attachment-ingest.js'; +import { resolveAttachmentRefs, resolveIngestItems } from '../attachment-ingest.js'; import { createAttachmentApprovalRegistry } from '../attachment-approval.js'; describe('resolveIngestItems (pre-read validation)', () => { @@ -211,3 +214,28 @@ describe('resolveIngestItems (pre-read validation)', () => { ); }); }); + +describe('resolveAttachmentRefs', () => { + test('rejects a path grown beyond the cap before creating a Host artifact', async () => { + const dir = await mkdtemp(join(tmpdir(), 'att-cap-')); + const path = join(dir, 'grew.bin'); + await writeFile(path, Buffer.alloc(11)); + let snapshots = 0; + try { + await assert.rejects( + resolveAttachmentRefs({ + files: [{ path, mimeType: 'application/octet-stream', size: 5 }], + maxBytes: 10, + snapshot: async () => { + snapshots += 1; + throw new Error('snapshot must not run'); + }, + }), + /超出大小限制/, + ); + assert.equal(snapshots, 0); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/desktop/src/main/__tests__/attachment-ingest.test.ts b/apps/desktop/src/main/__tests__/attachment-ingest.test.ts deleted file mode 100644 index bb6400ec9c..0000000000 --- a/apps/desktop/src/main/__tests__/attachment-ingest.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import assert from 'node:assert/strict'; -import { mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { describe, test } from 'node:test'; -import { createSqliteArtifactStore } from '@maka/storage'; -import { ingestAttachments } from '../attachment-ingest.js'; - -describe('ingestAttachments', () => { - test('workspace symlink escaping cwd is snapshotted, not exposed as a live workspace_file', async () => { - const dir = await mkdtemp(join(tmpdir(), 'att-sym-')); - const outsideDir = await mkdtemp(join(tmpdir(), 'att-sym-out-')); - try { - const store = createSqliteArtifactStore(dir); - const outsideFile = join(outsideDir, 'secret.md'); - await writeFile(outsideFile, 'secret'); - // symlink inside the workspace that resolves to a file outside it - const linkPath = join(dir, 'escape.md'); - await symlink(outsideFile, linkPath); - const refs = await ingestAttachments({ - files: [{ path: linkPath, mimeType: 'text/markdown', size: 6 }], - cwd: dir, - sessionId: 's1', - artifactStore: store, - }); - assert.equal(refs.length, 1); - assert.equal( - refs[0].ref.kind, - 'session_file', - 'a symlink that escapes the workspace must be snapshotted, not read live via workspace_file', - ); - } finally { - await rm(dir, { recursive: true, force: true }); - await rm(outsideDir, { recursive: true, force: true }); - } - }); - - test('path attachment grown between stat and read is rejected, no artifact created', async () => { - const dir = await mkdtemp(join(tmpdir(), 'att-cap-')); - const externalPath = join(tmpdir(), `grew-${Date.now()}.bin`); - try { - const store = createSqliteArtifactStore(dir); - // real file is 11 bytes; files[].size lies small (TOCTOU: stat said 5) - await writeFile(externalPath, Buffer.alloc(11)); - let storeCreates = 0; - const realCreate = store.create.bind(store); - store.create = async (input) => { - storeCreates += 1; - return realCreate(input); - }; - await assert.rejects( - ingestAttachments({ - files: [{ path: externalPath, mimeType: 'application/octet-stream', size: 5 }], - cwd: dir, - sessionId: 's1', - artifactStore: store, - resizeImage: async (b) => b, - maxBytes: 10, - }), - /超出大小限制/, - ); - assert.equal(storeCreates, 0, 'must not create an artifact for an oversized read'); - } finally { - await rm(dir, { recursive: true, force: true }); - await rm(externalPath, { force: true }); - } - }); -}); diff --git a/apps/desktop/src/main/__tests__/chat-readiness.test.ts b/apps/desktop/src/main/__tests__/chat-readiness.test.ts deleted file mode 100644 index f43a60a0a1..0000000000 --- a/apps/desktop/src/main/__tests__/chat-readiness.test.ts +++ /dev/null @@ -1,550 +0,0 @@ -import assert from 'node:assert/strict'; -import { describe, test } from 'node:test'; -import type { LlmConnection } from '@maka/core/llm-connections'; -import type { SessionHeader } from '@maka/core/session'; -import { - NO_REAL_CONNECTION_CODE, - assertSessionCanSend, - ensureSessionCanSendOrRebind, - errorCode, - requireReadyConnection, - errorReason, - shouldRebindSessionToDefault, - type ReadyConnectionDeps, -} from '../chat-readiness.js'; - -describe('chat readiness guard', () => { - test('blocks missing, fake, missing, disabled, and secretless model references', async () => { - const table: Array<{ - name: string; - slug: string | null | undefined; - deps: ReadyConnectionDeps; - includes: string; - reason: string; - }> = [ - { - name: 'no default model', - slug: null, - deps: deps(), - includes: '等待配置默认模型', - reason: 'missing_default_connection', - }, - { - name: 'implicit fake slug', - slug: 'fake', - deps: deps(), - includes: '等待配置默认模型', - reason: 'missing_default_connection', - }, - { - name: 'malformed model ref', - slug: 'missing', - deps: deps(), - includes: '找不到模型连接 "missing"', - reason: 'connection_missing', - }, - { - name: 'disabled provider', - slug: 'anthropic', - deps: deps({ connection: connection({ enabled: false }), apiKey: 'sk-test' }), - includes: '已禁用', - reason: 'connection_disabled', - }, - { - name: 'provider requires secret but has none', - slug: 'anthropic', - deps: deps({ connection: connection(), apiKey: null }), - includes: '等待填写 API key', - reason: 'missing_api_key', - }, - { - name: 'OAuth provider requires login token', - slug: 'claude-subscription', - deps: deps({ - connection: connection({ - slug: 'claude-subscription', - name: 'Claude OAuth', - providerType: 'claude-subscription', - }), - apiKey: null, - }), - includes: '等待完成 OAuth 登录', - reason: 'missing_api_key', - }, - ]; - - for (const entry of table) { - await assertRejectsReadiness(entry.name, () => requireReadyConnection(entry.slug, entry.deps), entry.includes, entry.reason); - } - }); - - test('blocks connections with no usable model or model outside enabled list', async () => { - await assertRejectsReadiness( - 'blank default model', - () => requireReadyConnection('custom', deps({ - connection: connection({ slug: 'custom', providerType: 'openai-compatible', defaultModel: '' }), - apiKey: 'sk-test', - })), - '没有可用模型', - 'missing_model', - ); - - await assertRejectsReadiness( - 'empty model list', - () => requireReadyConnection('custom', deps({ - connection: connection({ slug: 'custom', models: [] }), - apiKey: 'sk-test', - })), - '没有启用任何模型', - 'empty_model_list', - ); - - await assertRejectsReadiness( - 'requested model outside enabled list', - () => requireReadyConnection('custom', deps({ - connection: connection({ - slug: 'custom', - defaultModel: 'glm-4.7', - models: [{ id: 'glm-4.7' }], - }), - apiKey: 'sk-test', - }), 'gpt-4o'), - '不在连接 "Anthropic" 的启用模型列表中', - 'model_not_enabled', - ); - - await assertRejectsReadiness( - 'default model explicitly unsupported for chat', - () => requireReadyConnection('custom', deps({ - connection: connection({ - slug: 'custom', - defaultModel: 'gpt-image-1', - models: [{ id: 'gpt-image-1', capabilities: { chat: false, imageGeneration: true } }], - }), - apiKey: 'sk-test', - })), - '不能用于聊天', - 'model_not_chat_capable', - ); - }); - - test('send path blocks explicit fake sessions and revalidates old ai sessions', async () => { - await assertRejectsReadiness( - 'explicit fake session', - () => assertSessionCanSend(header({ backend: 'fake', llmConnectionSlug: 'fake', model: 'fake-model' }), deps()), - '旧的本地模拟连接', - 'fake_backend', - ); - - await assertRejectsReadiness( - 'old ai session after provider deletion', - () => assertSessionCanSend(header({ llmConnectionSlug: 'deleted' }), deps()), - '找不到模型连接 "deleted"', - 'connection_missing', - ); - - await assertRejectsReadiness( - 'old ai session after key removal', - () => assertSessionCanSend(header(), deps({ connection: connection(), apiKey: null })), - '等待填写 API key', - 'missing_api_key', - ); - - await assert.doesNotReject(() => - assertSessionCanSend(header(), deps({ connection: connection(), apiKey: 'sk-test' })), - ); - }); - - test('model_not_enabled names the requested model instead of the default', async () => { - await assert.rejects( - () => requireReadyConnection( - 'anthropic', - deps({ - connection: connection({ - defaultModel: 'claude-3-5-sonnet-20241022', - models: [{ id: 'claude-3-5-sonnet-20241022' }], - }), - apiKey: 'sk-test', - }), - 'gpt-4o-NOT-IN-LIST', - ), - (error) => { - const message = (error as Error).message; - assert.match(message, /gpt-4o-NOT-IN-LIST/, 'requested model must appear in error copy'); - assert.doesNotMatch(message, /claude-3-5-sonnet-20241022/, 'defaultModel must NOT leak into requested-model error'); - assert.equal(errorReason(error), 'model_not_enabled'); - return true; - }, - ); - }); - - test('credential validation status does not gate a real send attempt', async () => { - const ready = await requireReadyConnection( - 'anthropic', - deps({ - connection: connection({ lastTestStatus: 'error' }), - apiKey: 'sk-test', - }), - ); - assert.equal(ready.connection.slug, 'anthropic'); - assert.equal(ready.model, 'claude-3-5-sonnet-20241022'); - }); - - test('classifies stale sessions that can be rebound to the current default model', () => { - assert.equal(shouldRebindSessionToDefault('model_not_enabled'), true); - assert.equal(shouldRebindSessionToDefault('missing_api_key'), false); - assert.equal(shouldRebindSessionToDefault(undefined), false); - }); - - test('rebinds stale ai-sdk sessions to a ready default connection before send', async () => { - const updates: unknown[] = []; - const result = await ensureSessionCanSendOrRebind( - 'session-1', - header({ llmConnectionSlug: 'fake-claude', model: 'fake-model' }), - { - readyConnectionDeps: keyedDeps({ - 'zai-coding-plan': { - connection: connection({ - slug: 'zai-coding-plan', - name: 'Z.AI Coding Plan', - providerType: 'zai-coding-plan', - defaultModel: 'glm-4.7', - models: [{ id: 'glm-4.7' }], - }), - apiKey: 'sk-zai', - }, - }), - async getDefaultSlug() { - return 'zai-coding-plan'; - }, - async listConnectionSlugs() { - return []; - }, - async updateSession(_sessionId, patch) { - updates.push(patch); - }, - }, - ); - - assert.deepEqual(result, { rebound: true, connectionSlug: 'zai-coding-plan', modelId: 'glm-4.7' }); - assert.deepEqual(updates, [{ - backend: 'ai-sdk', - llmConnectionSlug: 'zai-coding-plan', - model: 'glm-4.7', - connectionLocked: true, - }]); - }); - - test('does not rebind locked sessions when their sticky model becomes invalid', async () => { - const updates: unknown[] = []; - - await assertRejectsReadiness( - 'locked sticky model outside enabled list', - () => ensureSessionCanSendOrRebind( - 'session-locked', - header({ - connectionLocked: true, - llmConnectionSlug: 'anthropic', - model: 'claude-old-sticky', - }), - { - readyConnectionDeps: keyedDeps({ - anthropic: { - connection: connection({ - slug: 'anthropic', - defaultModel: 'claude-new-default', - models: [{ id: 'claude-new-default' }], - }), - apiKey: 'sk-test', - }, - 'zai-coding-plan': { - connection: connection({ - slug: 'zai-coding-plan', - name: 'Z.AI Coding Plan', - providerType: 'zai-coding-plan', - defaultModel: 'glm-4.7', - models: [{ id: 'glm-4.7' }], - }), - apiKey: 'sk-zai', - }, - }), - async getDefaultSlug() { - return 'zai-coding-plan'; - }, - async listConnectionSlugs() { - return []; - }, - async updateSession(_sessionId, patch) { - updates.push(patch); - }, - }, - ), - 'claude-old-sticky', - 'model_not_enabled', - ); - - assert.deepEqual(updates, []); - }); - - test('rebinds an unknown-provider session to the first existing ready connection', async () => { - const updates: unknown[] = []; - const rebindDeps = { - readyConnectionDeps: keyedDeps({ - 'branch-only-provider': { - connection: connection({ - slug: 'branch-only-provider', - name: 'Branch-only provider', - providerType: 'branch-only-provider' as never, - defaultModel: 'branch-model', - }), - apiKey: 'gsk-test', - }, - anthropic: { connection: connection(), apiKey: 'sk-test' }, - }), - async getDefaultSlug() { - return 'branch-only-provider'; - }, - async listConnectionSlugs() { - return ['branch-only-provider', 'anthropic']; - }, - async updateSession(_sessionId: string, patch: unknown) { - updates.push(patch); - }, - }; - - const result = await ensureSessionCanSendOrRebind( - 'session-unknown-provider', - header({ llmConnectionSlug: 'branch-only-provider', model: 'branch-model' }), - rebindDeps, - ); - - assert.deepEqual(result, { - rebound: true, - connectionSlug: 'anthropic', - modelId: 'claude-3-5-sonnet-20241022', - }); - assert.equal(updates.length, 1); - }); - - test('keeps the original readiness error when no ready default exists for rebind', async () => { - await assertRejectsReadiness( - 'fake session without ready default', - () => ensureSessionCanSendOrRebind( - 'session-1', - header({ backend: 'fake', llmConnectionSlug: 'fake', model: 'fake-model' }), - { - readyConnectionDeps: keyedDeps({}), - async getDefaultSlug() { - return null; - }, - async listConnectionSlugs() { - return ['missing']; - }, - async updateSession() { - throw new Error('must not update'); - }, - }, - ), - '旧的本地模拟连接', - 'fake_backend', - ); - }); - -}); - -async function assertRejectsReadiness(name: string, fn: () => Promise, includes: string, reason: string): Promise { - await assert.rejects( - fn, - (error) => { - assert.equal(errorCode(error), NO_REAL_CONNECTION_CODE, name); - assert.equal(errorReason(error), reason, name); - assert.match((error as Error).message, new RegExp(escapeRegExp(includes)), name); - return true; - }, - ); -} - -function deps(input: { connection?: LlmConnection | null; apiKey?: string | null } = {}): ReadyConnectionDeps { - return { - async getConnection(_slug: string) { - return input.connection ?? null; - }, - async getApiKey(_slug: string) { - return input.apiKey ?? null; - }, - }; -} - -function keyedDeps(entries: Record): ReadyConnectionDeps { - return { - async getConnection(slug: string) { - return entries[slug]?.connection ?? null; - }, - async getApiKey(slug: string) { - return entries[slug]?.apiKey ?? null; - }, - }; -} - -function connection(patch: Partial = {}): LlmConnection { - return { - slug: 'anthropic', - name: 'Anthropic', - providerType: 'anthropic', - defaultModel: 'claude-3-5-sonnet-20241022', - enabled: true, - createdAt: 1, - updatedAt: 1, - ...patch, - }; -} - -function header(patch: Partial = {}): Pick { - return { - backend: 'ai-sdk', - llmConnectionSlug: 'anthropic', - model: 'claude-3-5-sonnet-20241022', - connectionLocked: false, - ...patch, - }; -} - -describe('send-gate fact resolution stays staged', () => { - test('a healthy send resolves only its own connection facts', async () => { - const secretReads: string[] = []; - const result = await ensureSessionCanSendOrRebind( - 'session-1', - header(), - { - readyConnectionDeps: { - async getConnection(slug: string) { - return slug === 'anthropic' ? connection() : connection({ slug }); - }, - async getApiKey(slug: string) { - secretReads.push(slug); - if (slug === 'unrelated') throw new Error('credential store blew up'); - return 'sk-test'; - }, - }, - async getDefaultSlug() { - throw new Error('default store must not be read'); - }, - async listConnectionSlugs() { - throw new Error('connection list must not be read'); - }, - async updateSession() { - throw new Error('must not rebind'); - }, - }, - ); - - assert.deepEqual(result, { rebound: false }); - assert.deepEqual(secretReads, ['anthropic'], 'only the session’s own connection is probed on the healthy path'); - }); - - test('rebind picks the first ready candidate in persisted order, not async completion order', async () => { - const delays: Record = { 'default-broken': 0, 'slow-first': 30, 'fast-second': 1 }; - const bySlug = { - 'default-broken': connection({ slug: 'default-broken', enabled: false }), - 'slow-first': connection({ slug: 'slow-first' }), - 'fast-second': connection({ slug: 'fast-second' }), - } as Record; - const updates: unknown[] = []; - const result = await ensureSessionCanSendOrRebind( - 'session-1', - header({ backend: 'fake', llmConnectionSlug: 'fake', model: 'fake-model' }), - { - readyConnectionDeps: { - async getConnection(slug: string) { - await new Promise((resolve) => setTimeout(resolve, delays[slug] ?? 0)); - return bySlug[slug] ?? null; - }, - async getApiKey() { - return 'sk-test'; - }, - }, - async getDefaultSlug() { - return 'default-broken'; - }, - async listConnectionSlugs() { - return ['default-broken', 'slow-first', 'fast-second']; - }, - async updateSession(_id, patch) { - updates.push(patch); - }, - }, - ); - - assert.deepEqual(result, { rebound: true, connectionSlug: 'slow-first', modelId: 'claude-3-5-sonnet-20241022' }); - assert.equal(updates.length, 1); - }); - - test('rebind walk stops probing after the first ready candidate, so a hanging later candidate cannot stall recovery', async () => { - const secretReads: string[] = []; - const updates: unknown[] = []; - const gate = ensureSessionCanSendOrRebind( - 'session-1', - header({ backend: 'fake', llmConnectionSlug: 'fake', model: 'fake-model' }), - { - readyConnectionDeps: { - async getConnection(slug: string) { - return connection({ slug }); - }, - async getApiKey(slug: string) { - secretReads.push(slug); - if (slug === 'hanging-oauth') return new Promise(() => {}); - return 'sk-test'; - }, - }, - async getDefaultSlug() { - return 'ready-default'; - }, - async listConnectionSlugs() { - return ['ready-default', 'hanging-oauth']; - }, - async updateSession(_id, patch) { - updates.push(patch); - }, - }, - ); - const outcome = await Promise.race([ - gate, - new Promise((resolve) => setTimeout(() => resolve('timed_out'), 500)), - ]); - - assert.deepEqual(outcome, { rebound: true, connectionSlug: 'ready-default', modelId: 'claude-3-5-sonnet-20241022' }); - assert.equal(updates.length, 1); - assert.deepEqual(secretReads, ['ready-default'], 'candidates after the first ready one must never be probed'); - }); - - test('rebind walk skips candidates whose facts cannot be read', async () => { - const result = await ensureSessionCanSendOrRebind( - 'session-1', - header({ backend: 'fake', llmConnectionSlug: 'fake', model: 'fake-model' }), - { - readyConnectionDeps: { - async getConnection(slug: string) { - if (slug === 'flaky') throw new Error('read failed'); - return slug === 'anthropic' ? connection() : null; - }, - async getApiKey() { - return 'sk-test'; - }, - }, - async getDefaultSlug() { - return 'flaky'; - }, - async listConnectionSlugs() { - return ['flaky', 'anthropic']; - }, - async updateSession() {}, - }, - ); - - assert.deepEqual(result, { rebound: true, connectionSlug: 'anthropic', modelId: 'claude-3-5-sonnet-20241022' }); - }); -}); - -function escapeRegExp(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} diff --git a/apps/desktop/src/main/__tests__/config-transfer-service.test.ts b/apps/desktop/src/main/__tests__/config-transfer-service.test.ts index d9a17265b5..d02308a327 100644 --- a/apps/desktop/src/main/__tests__/config-transfer-service.test.ts +++ b/apps/desktop/src/main/__tests__/config-transfer-service.test.ts @@ -3,7 +3,7 @@ import { describe, it } from 'node:test'; import type { AppSettings } from '@maka/core/settings'; import type { LlmConnection } from '@maka/core/llm-connections'; import type { CredentialKind } from '@maka/storage'; -import { applyConfigImport, gatherConfigExport, type ConfigTransferDeps } from '../config-transfer-service.js'; +import { applyConfigImport, type ConfigTransferDeps } from '../config-transfer-service.js'; function conn(slug: string): LlmConnection { return { @@ -17,15 +17,6 @@ function conn(slug: string): LlmConnection { }; } -function settingsWithSecrets(): AppSettings { - return { - theme: 'dark', - network: { proxy: { host: '127.0.0.1', password: 'proxy-secret' } }, - botChat: { channels: { telegram: { chatId: '42', token: 'bot-secret', appSecret: 'app-secret' } } }, - webSearch: { providers: { tavily: { apiKey: 'tavily-secret' } } }, - } as unknown as AppSettings; -} - function makeDeps(overrides: Partial = {}): { deps: ConfigTransferDeps; saved: LlmConnection[]; @@ -37,9 +28,7 @@ function makeDeps(overrides: Partial = {}): { const updatedSettings: unknown[] = []; const setCreds: Array<{ slug: string; kind: CredentialKind; value: string }> = []; const writtenMemory: string[] = []; - const secretsBySlugKind = new Map([['deepseek-main::api_key', 'sk-real-key']]); const deps: ConfigTransferDeps = { - appVersion: '0.1.0', connectionStore: { list: async () => [conn('deepseek-main')], save: async (c) => { @@ -48,19 +37,16 @@ function makeDeps(overrides: Partial = {}): { }, }, settingsStore: { - get: async () => settingsWithSecrets(), update: async (patch) => { updatedSettings.push(patch); return patch as unknown as AppSettings; }, }, credentialStore: { - getSecret: async (slug, kind) => secretsBySlugKind.get(`${slug}::${kind}`) ?? null, setSecret: async (slug, kind, value) => { setCreds.push({ slug, kind, value }); }, }, - readMemory: async () => '# MEMORY\n- note', writeMemory: async (content) => { writtenMemory.push(content); }, @@ -70,41 +56,6 @@ function makeDeps(overrides: Partial = {}): { } describe('config-transfer-service', () => { - it('exports only selected categories', async () => { - const { deps } = makeDeps(); - const bundle = await gatherConfigExport(['connections'], deps); - assert.deepEqual(bundle.includedData, ['connections']); - assert.equal(bundle.data.settings, undefined); - assert.equal(bundle.data.credentials, undefined); - }); - - it('omits (does not blank) settings secrets when credentials are NOT included', async () => { - // Secret keys must be ABSENT, not '' — mergeSettings deep-merges to the - // leaf, so an absent key preserves the target machine's existing secret on - // import, whereas '' would overwrite and wipe it. - const { deps } = makeDeps(); - const bundle = await gatherConfigExport(['settings'], deps); - const s = bundle.data.settings as Record; - assert.equal('password' in s.network.proxy, false, 'proxy password key omitted'); - assert.equal('token' in s.botChat.channels.telegram, false, 'bot token key omitted'); - assert.equal('appSecret' in s.botChat.channels.telegram, false, 'bot appSecret key omitted'); - assert.equal('apiKey' in s.webSearch.providers.tavily, false, 'tavily apiKey key omitted'); - // Non-secret fields at every level pass through untouched. - assert.equal(s.theme, 'dark'); - assert.equal(s.network.proxy.host, '127.0.0.1'); - assert.equal(s.botChat.channels.telegram.chatId, '42'); - }); - - it('keeps settings secrets and enumerates credentials when credentials ARE included', async () => { - const { deps } = makeDeps(); - const bundle = await gatherConfigExport(['settings', 'credentials'], deps); - const s = bundle.data.settings as Record; - assert.equal(s.network.proxy.password, 'proxy-secret', 'secrets retained alongside credentials'); - assert.deepEqual(bundle.data.credentials, [ - { slug: 'deepseek-main', kind: 'api_key', value: 'sk-real-key' }, - ]); - }); - it('applies an imported bundle to the stores and summarizes', async () => { const { deps, saved, updatedSettings, setCreds, writtenMemory } = makeDeps(); const bundle = { diff --git a/apps/desktop/src/main/__tests__/git-review-main.test.ts b/apps/desktop/src/main/__tests__/git-review-main.test.ts index 1550398ee8..8aa1c9d063 100644 --- a/apps/desktop/src/main/__tests__/git-review-main.test.ts +++ b/apps/desktop/src/main/__tests__/git-review-main.test.ts @@ -1,11 +1,11 @@ import assert from 'node:assert/strict'; import { execFile } from 'node:child_process'; -import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { promisify } from 'node:util'; import { after, describe, it } from 'node:test'; -import { mutateGitReview, readGitReview } from '../git-review-main.js'; +import { readGitReview } from '../git-review-main.js'; const execFileAsync = promisify(execFile); const roots = new Set(); @@ -126,94 +126,6 @@ describe('Git Review snapshot authority', () => { ); }); - it('stages and unstages only paths admitted by the current snapshot', async () => { - const root = await repository(); - await writeFile(join(root, 'base.txt'), 'base\nchanged\n', 'utf8'); - await writeFile(join(root, 'staged.txt'), 'staged\n', 'utf8'); - await git(root, 'add', 'staged.txt'); - - const unstaged = await readGitReview(root, 'unstaged'); - assert.equal(unstaged.ok, true); - if (!unstaged.ok) return; - const stagedBase = await mutateGitReview({ - cwd: root, - source: 'unstaged', - revision: unstaged.snapshot.revision, - path: 'base.txt', - action: 'stage', - }); - assert.equal(stagedBase.ok, true); - if (!stagedBase.ok) return; - assert.equal(stagedBase.review.ok, true); - if (stagedBase.review.ok) { - assert.deepEqual(stagedBase.review.snapshot.files, []); - } - - const staged = await readGitReview(root, 'staged'); - assert.equal(staged.ok, true); - if (!staged.ok) return; - assert.deepEqual( - staged.snapshot.files.map((file) => file.path).sort(), - ['base.txt', 'staged.txt'], - ); - const unstagedFile = await mutateGitReview({ - cwd: root, - source: 'staged', - revision: staged.snapshot.revision, - path: 'staged.txt', - action: 'unstage', - }); - assert.equal(unstagedFile.ok, true); - if (unstagedFile.ok && unstagedFile.review.ok) { - assert.deepEqual( - unstagedFile.review.snapshot.files.map((file) => file.path), - ['base.txt'], - ); - } - - assert.deepEqual( - await mutateGitReview({ - cwd: root, - source: 'staged', - revision: '0'.repeat(64), - path: 'base.txt', - action: 'unstage', - }), - { ok: false, reason: 'stale_snapshot' }, - ); - }); - - it('reverts tracked changes and deletes an admitted untracked file', async () => { - const root = await repository(); - await writeFile(join(root, 'base.txt'), 'base\nchanged\n', 'utf8'); - await writeFile(join(root, 'untracked.txt'), 'temporary\n', 'utf8'); - - const snapshot = await readGitReview(root, 'unstaged'); - assert.equal(snapshot.ok, true); - if (!snapshot.ok) return; - const reverted = await mutateGitReview({ - cwd: root, - source: 'unstaged', - revision: snapshot.snapshot.revision, - path: 'base.txt', - action: 'revert', - }); - assert.equal(reverted.ok, true); - assert.equal(await readFile(join(root, 'base.txt'), 'utf8'), 'base\n'); - - const afterTracked = await readGitReview(root, 'unstaged'); - assert.equal(afterTracked.ok, true); - if (!afterTracked.ok) return; - const removed = await mutateGitReview({ - cwd: root, - source: 'unstaged', - revision: afterTracked.snapshot.revision, - path: 'untracked.txt', - action: 'revert', - }); - assert.equal(removed.ok, true); - assert.equal(await stat(join(root, 'untracked.txt')).catch(() => null), null); - }); }); async function repository(): Promise { diff --git a/apps/desktop/src/main/__tests__/runtime-host-config-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-config-ipc-main.test.ts new file mode 100644 index 0000000000..1b9c923ae7 --- /dev/null +++ b/apps/desktop/src/main/__tests__/runtime-host-config-ipc-main.test.ts @@ -0,0 +1,115 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { AppSettings } from '@maka/core/settings'; +import type { + ConnectionCatalogSnapshot, + CredentialLocator, +} from '@maka/core/runtime-policy'; +import { gatherRuntimeHostConfig } from '../runtime-host-config-ipc-main.js'; + +const CATALOG: ConnectionCatalogSnapshot = { + revision: 1, + defaultTarget: { + connectionId: '00000000-0000-4000-8000-000000000001', + modelId: 'deepseek-v4-pro', + }, + connections: [ + { + connectionId: '00000000-0000-4000-8000-000000000001', + revision: 1, + slug: 'deepseek-main', + name: 'DeepSeek', + providerType: 'deepseek', + enabled: true, + enabledModelIds: ['deepseek-v4-pro'], + models: [{ id: 'deepseek-v4-pro' }], + }, + ], +}; + +test('Runtime Host config export omits settings secrets unless credentials are selected', async () => { + let credentialExports = 0; + const bundle = await gatherRuntimeHostConfig( + ['settings'], + { + client: { + exportConfigurationCredentials: async () => { + credentialExports += 1; + return { credential: null }; + }, + }, + appVersion: '0.1.0', + getSettings: async () => settingsWithSecrets(), + } as never, + ); + + const settings = bundle.data.settings as Record; + assert.deepEqual(bundle.includedData, ['settings']); + assert.equal(credentialExports, 0); + assert.equal('password' in settings.network.proxy, false); + assert.equal('token' in settings.botChat.channels.telegram, false); + assert.equal('appSecret' in settings.botChat.channels.telegram, false); + assert.equal('apiKey' in settings.webSearch.providers.tavily, false); + assert.equal(settings.network.proxy.host, '127.0.0.1'); +}); + +test('Runtime Host config export reads selected credentials from Host authority', async () => { + const bundle = await gatherRuntimeHostConfig( + ['connections', 'settings', 'credentials'], + { + client: { + loadConnectionCatalog: async () => CATALOG, + exportConfigurationCredentials: async ({ locator }: { locator: CredentialLocator }) => { + const secret = secretFor(locator); + return { + credential: + secret === null + ? null + : { + locator, + secretBase64: Buffer.from(secret).toString('base64'), + }, + }; + }, + }, + appVersion: '0.1.0', + getSettings: async () => settingsWithSecrets(), + } as never, + ); + + const settings = bundle.data.settings as Record; + assert.deepEqual(bundle.data.credentials, [ + { slug: 'deepseek-main', kind: 'api_key', value: 'sk-host' }, + ]); + assert.equal(settings.network.proxy.password, 'proxy-host'); + assert.equal(settings.webSearch.providers.tavily.apiKey, 'tavily-host'); + assert.equal(settings.botChat.channels.telegram.token, 'bot-secret'); +}); + +function settingsWithSecrets(): AppSettings { + return { + theme: 'dark', + network: { proxy: { host: '127.0.0.1', password: 'local-proxy-secret' } }, + botChat: { + channels: { + telegram: { + chatId: '42', + token: 'bot-secret', + appSecret: 'app-secret', + }, + }, + }, + webSearch: { + providers: { tavily: { apiKey: 'local-tavily-secret' } }, + }, + } as unknown as AppSettings; +} + +function secretFor(locator: CredentialLocator): string | null { + if (locator.scope === 'network_proxy') return 'proxy-host'; + if (locator.scope === 'web_search') return 'tavily-host'; + if (locator.scope === 'connection' && locator.kind === 'api_key') { + return 'sk-host'; + } + return null; +} diff --git a/apps/desktop/src/main/attachment-ingest.ts b/apps/desktop/src/main/attachment-ingest.ts index 3ca4143c4c..f6ffa7bde4 100644 --- a/apps/desktop/src/main/attachment-ingest.ts +++ b/apps/desktop/src/main/attachment-ingest.ts @@ -1,15 +1,14 @@ import { Buffer } from 'node:buffer'; -import { open, realpath as fsRealpath } from 'node:fs/promises'; -import { basename, relative, sep } from 'node:path'; +import { open } from 'node:fs/promises'; +import { basename } from 'node:path'; import { attachmentKindFromMimeType, guessMimeFromName, MAX_ATTACHMENT_BYTES, MAX_ATTACHMENT_COUNT, } from '@maka/core/attachments'; -import type { ArtifactKind, ArtifactSource } from '@maka/core/artifacts'; +import type { ArtifactKind } from '@maka/core/artifacts'; import type { AttachmentRef } from '@maka/core/events'; -import type { ArtifactStore } from '@maka/storage'; import type { AttachmentApprovalRegistry } from './attachment-approval.js'; export type AttachmentIngestFile = @@ -25,75 +24,14 @@ export interface AttachmentSnapshotInput { } /** - * Resolve a selected/dropped file into an {@link AttachmentRef} the runtime - * can consume: - * - image (anywhere): resize → ArtifactStore snapshot → session_file ref. - * Images must become provider image parts, so they are always snapshotted - * (an external image path could vanish or be swapped before the turn runs). - * - non-image inside the workspace: workspace_file ref, no copy. The model - * reads it on demand via the Read tool, which is already cwd-bound. - * - non-image outside the workspace: ArtifactStore snapshot → session_file - * ref. Snapshots the bytes at attach time so a symlink swap (TOCTOU) or a - * deleted temp file cannot change what the model later reads. - * - * `resizeImage` is injected because it depends on Electron's nativeImage; - * tests pass a fake. `turnId` is not known at attach time, so the snapshot is - * filed under the sessionId. - */ -export async function ingestAttachments(input: { - files: AttachmentIngestFile[]; - cwd: string; - sessionId: string; - artifactStore: ArtifactStore; - resizeImage?: (bytes: Uint8Array) => Promise; - realpath?: (path: string) => Promise; - now?: () => number; - maxBytes?: number; -}): Promise { - return resolveAttachmentRefs({ - ...input, - workspaceFiles: 'reference', - snapshot: async ({ name, mimeType, artifactKind, attachmentKind, content }) => { - const source: ArtifactSource = 'user_upload'; - const record = await input.artifactStore.create({ - sessionId: input.sessionId, - turnId: input.sessionId, - name, - kind: artifactKind, - content, - mimeType, - source, - ...(input.now ? { now: input.now() } : {}), - }); - return { - kind: attachmentKind, - name, - mimeType, - bytes: content.byteLength, - ref: { - kind: 'session_file', - sessionId: input.sessionId, - relativePath: record.id, - }, - }; - }, - }); -} - -/** - * Resolve attachment references while making workspace-file ownership - * explicit. Embedded execution can preserve cwd-contained files as workspace - * references; the Runtime Host adapter snapshots every selected path because - * hosted Turn attachments accept only canonical Session Artifacts. + * Snapshot selected files through Runtime Host. Hosted Turn attachments accept + * only canonical Session Artifacts, so every path is read once under the byte + * cap and handed to the Host-owned ingest boundary. */ export async function resolveAttachmentRefs(input: { files: AttachmentIngestFile[]; - cwd: string; - sessionId: string; - workspaceFiles: 'reference' | 'snapshot'; snapshot: (input: AttachmentSnapshotInput) => Promise; resizeImage?: (bytes: Uint8Array) => Promise; - realpath?: (path: string) => Promise; maxBytes?: number; }): Promise { const maxBytes = input.maxBytes ?? MAX_ATTACHMENT_BYTES; @@ -103,24 +41,6 @@ export async function resolveAttachmentRefs(input: { const mimeType = file.mimeType && file.mimeType.length > 0 ? file.mimeType : guessMimeFromName(name); const kind = attachmentKindFromMimeType(mimeType, name); - if ( - input.workspaceFiles === 'reference' && - kind !== 'image' && - isPathAttachment(file) && - (await isInsideCwdReal(input.cwd, file.path, input.realpath)) - ) { - const realCwd = await resolveReal(input.cwd, input.realpath); - const realTarget = await resolveReal(file.path, input.realpath); - refs.push({ - kind, - name, - mimeType, - bytes: file.size, - ref: { kind: 'workspace_file', relativePath: relative(realCwd, realTarget) }, - }); - continue; - } - let bytes: Uint8Array = isPathAttachment(file) ? await readFileCapped(file.path, maxBytes) : file.content; if (kind === 'image' && input.resizeImage) { bytes = await input.resizeImage(bytes); @@ -166,27 +86,6 @@ function attachmentFileName(file: AttachmentIngestFile): string { return name || 'attachment'; } -async function resolveReal(path: string, realpath?: (path: string) => Promise): Promise { - const resolveFn = realpath ?? fsRealpath; - try { - return await resolveFn(path); - } catch { - return path; - } -} - -async function isInsideCwdReal(cwd: string, target: string, realpath?: (path: string) => Promise): Promise { - const realCwd = await resolveReal(cwd, realpath); - const realTarget = await resolveReal(target, realpath); - return isInsideCwd(realCwd, realTarget); -} - -function isInsideCwd(cwd: string, target: string): boolean { - if (target === cwd) return true; - const rel = relative(cwd, target); - return rel !== '' && !rel.startsWith('..') && rel !== '..' && !rel.includes(`..${sep}`) && !rel.startsWith(sep); -} - /** A renderer-supplied ingest item: either a main-issued approval token (for * user-picked files, whose path never leaves main) or inline base64 bytes (for * dragged/pasted blobs, which have no trustworthy path). */ diff --git a/apps/desktop/src/main/chat-readiness.ts b/apps/desktop/src/main/chat-readiness.ts deleted file mode 100644 index 1aa1616225..0000000000 --- a/apps/desktop/src/main/chat-readiness.ts +++ /dev/null @@ -1,291 +0,0 @@ -import { - isConnectionReady, - normalizeOpenAiCodexConnection, - type ChatConfigurationReason, -} from '@maka/core/connection-readiness'; -import { NO_REAL_CONNECTION_CODE } from '@maka/core/connection-error-copy'; -import { - projectSessionSendOutcome, - sessionOwnConnectionBlockReason, - shouldRebindSessionToDefault, -} from '@maka/core/session-send-projection'; -import { type LlmConnection } from '@maka/core/llm-connections'; -import { type SessionHeader } from '@maka/core/session'; -import { PROVIDER_DEFAULTS } from '@maka/core/llm-connections'; - -// The rebind-eligibility taxonomy moved to `@maka/core/session-send-projection` -// (#1038) so the send gate and the renderer health notice share one -// decision source. Re-exported here for back-compat. -export { NO_REAL_CONNECTION_CODE } from '@maka/core/connection-error-copy'; - -// The rebind-eligibility taxonomy moved to `@maka/core/session-send-projection` -// (#1038) so the send gate and the renderer health notice share one -// decision source. Re-exported here for back-compat. -export { shouldRebindSessionToDefault } from '@maka/core/session-send-projection'; - -// `ChatConfigurationReason` moved to `@maka/core/connection-readiness` -// (PR110a) so the same taxonomy is shared between the send path and -// onboarding. Re-exported here for back-compat — any -// future addition belongs in core, not here. -export type { ChatConfigurationReason }; - -export interface ReadyConnectionDeps { - getConnection(slug: string): Promise; - getApiKey(slug: string): Promise; -} - -export interface ReadyConnection { - connection: LlmConnection; - apiKey: string; - model: string; -} - -export interface SessionRebindDeps { - readyConnectionDeps: ReadyConnectionDeps; - getDefaultSlug(): Promise; - listConnectionSlugs(): Promise; - updateSession( - sessionId: string, - patch: Pick, - ): Promise; -} - -export interface SessionRebindResult { - rebound: boolean; - connectionSlug?: string; - modelId?: string; -} - -export async function requireReadyConnection( - slug: string | null | undefined, - deps: ReadyConnectionDeps, - requestedModel?: string, -): Promise { - // Slug missing / explicit 'fake' shortcut is checked before reaching - // the core helper because we lack a connection object to evaluate. - if (!slug || slug === 'fake') { - throw chatConfigurationError( - '等待配置默认模型。请到 设置 · 模型 添加 Anthropic / OpenAI / GLM 等 API key。', - 'missing_default_connection', - ); - } - - const connection = await deps.getConnection(slug); - if (!connection) { - throw chatConfigurationError( - `找不到模型连接 "${slug}"。请到 设置 · 模型 重新选择默认模型。`, - 'connection_missing', - ); - } - - // PR110a: delegate the actual ready judgment to the pure core helper - // so onboarding and the send path share a single source of truth. The - // desktop side only owns: (1) async secret lookup, (2) Chinese error - // copy, (3) the throw-error API the rest of main.ts expects. - const normalizedConnection = normalizeOpenAiCodexConnection(connection); - const apiKey = await deps.getApiKey(normalizedConnection.slug); - const verdict = isConnectionReady({ - connection: normalizedConnection, - hasSecret: typeof apiKey === 'string' && apiKey.length > 0, - requestedModel, - }); - - if (verdict.ready === false) { - throw chatConfigurationError( - messageForReason(verdict.reason, normalizedConnection, requestedModel), - verdict.reason, - ); - } - - return { connection: normalizedConnection, apiKey: apiKey ?? '', model: verdict.model }; -} - -/** - * Map a core readiness reason to the Chinese error copy that - * `requireReadyConnection` has historically thrown. Centralized here - * so the copy stays close to its existing semantics (PR110a refactor - * is behavior-preserving — only the judgment moved to core). - */ -/** - * Two paths reach `fake_backend`: the reason table below, and the header check - * in `assertSessionCanSend`, which never gets far enough to look a connection - * up. They are the same sentence to the user, so they are the same string here - * — the rename that moved 会话 to 任务 had to be applied twice, which is what a - * second copy costs. - */ -const FAKE_BACKEND_MESSAGE = - '当前任务来自旧的本地模拟连接,不能直接发送。请到 设置 · 模型 添加真实模型后新建任务。'; - -function messageForReason( - reason: ChatConfigurationReason, - connection: LlmConnection, - requestedModel: string | undefined, -): string { - switch (reason) { - case 'connection_disabled': - return `模型连接 "${connection.name}" 已禁用。请到 设置 · 模型 启用或选择其他默认模型。`; - case 'missing_api_key': - if (PROVIDER_DEFAULTS[connection.providerType].authKind === 'oauth_token') { - return `模型连接 "${connection.name}" 等待完成 OAuth 登录。请到 设置 · 模型 重新登录后再聊天。`; - } - return `模型连接 "${connection.name}" 等待填写 API key。请到 设置 · 模型 补齐密钥后再聊天。`; - case 'missing_model': - return `模型连接 "${connection.name}" 没有可用模型。请到 设置 · 模型 选择一个默认模型。`; - case 'empty_model_list': - return `模型连接 "${connection.name}" 没有启用任何模型。请到 设置 · 模型 先添加模型。`; - case 'model_not_enabled': { - const model = requestedModel || connection.defaultModel; - return `模型 "${model}" 不在连接 "${connection.name}" 的启用模型列表中。请到 设置 · 模型 重新选择。`; - } - case 'model_not_chat_capable': { - const model = requestedModel || connection.defaultModel; - return `模型 "${model}" 不能用于聊天。请到 设置 · 模型 选择支持聊天的模型。`; - } - case 'fake_backend': - return FAKE_BACKEND_MESSAGE; - case 'missing_default_connection': - case 'connection_missing': - // These reasons are handled before we reach isConnectionReady, - // but kept here for exhaustive switch. - return '等待配置默认模型。请到 设置 · 模型 添加 Anthropic / OpenAI / GLM 等 API key。'; - } -} - -export async function assertSessionCanSend( - header: Pick, - deps: ReadyConnectionDeps, -): Promise { - if (header.backend === 'fake') { - throw chatConfigurationError(FAKE_BACKEND_MESSAGE, 'fake_backend'); - } - await requireReadyConnection(header.llmConnectionSlug, deps, header.model); -} - -export async function ensureSessionCanSendOrRebind( - sessionId: string, - header: Pick, - deps: SessionRebindDeps, -): Promise { - // #1038: the send/rebind DECISION lives in the core projection so the - // renderer health notice answers "will the next send fail?" from the - // same facts and the same code. Main resolves the async facts, - // delegates the decision, then owns the side effects: the rebind - // mutation and the canonical error copy. - // - // Fact resolution stays staged exactly like the pre-projection send - // path (#1038 review): phase 1 resolves only the session's OWN - // connection, so a healthy session never waits on — and is never - // failed by — unrelated connections, the default store, or the - // connection list. Phase 2 gathers rebind candidates only when an - // unlocked session actually needs the walk. - const ownSlug = header.llmConnectionSlug; - const ownResolvable = header.backend !== 'fake' && Boolean(ownSlug) && ownSlug !== 'fake'; - const ownConnection = ownResolvable ? await deps.readyConnectionDeps.getConnection(ownSlug) : null; - const ownHasSecret = ownConnection - ? await hasUsableSecret(deps.readyConnectionDeps, ownConnection.slug) - : false; - const ownReason = sessionOwnConnectionBlockReason(header, ownConnection, () => ownHasSecret); - - if (ownReason === undefined) return { rebound: false }; - - if (header.connectionLocked || !shouldRebindSessionToDefault(ownReason)) { - // Blocked with no rebind walk. Re-run the throwing authority so the - // exact historical error copy surfaces unchanged; if the facts - // shifted underneath us (e.g. a key was just saved), the send may - // proceed after all. - await assertSessionCanSend(header, deps.readyConnectionDeps); - return { rebound: false }; - } - - // Phase 2: rebind candidates, resolved in deterministic order - // (default first, then persisted order) with the historical - // short-circuit: candidates are probed one at a time and the walk - // stops as soon as the projection can decide, so the recovery path - // never waits on — nor even probes — later candidates once a ready - // one is found (a slow or hanging OAuth refresh on an unrelated - // connection cannot stall the send). getDefaultSlug stays - // fail-closed (a default-store read error rejects the send rather - // than risking a rebind picked from incomplete facts), while the - // list read fails open to [] — the default alone can still serve. - const [defaultSlug, connectionSlugs] = await Promise.all([ - deps.getDefaultSlug(), - deps.listConnectionSlugs().catch(() => []), - ]); - const candidateSlugs = [...new Set([defaultSlug, ...connectionSlugs])] - .filter((slug): slug is string => typeof slug === 'string' && slug.length > 0); - const connections: LlmConnection[] = []; - const secretPresence = new Map(); - for (const slug of candidateSlugs) { - try { - const connection = await deps.readyConnectionDeps.getConnection(slug); - if (connection) { - connections.push(connection); - secretPresence.set(connection.slug, await hasUsableSecret(deps.readyConnectionDeps, connection.slug)); - } - } catch { - // Unreadable candidate: skipped, exactly like the historical - // walk's per-candidate catch. - } - // Re-run the projection over the resolved prefix. The winner is - // identical to a full assembly (the projection picks the FIRST - // ready candidate in this same order), but `blocked` here only - // means "not yet" — a later candidate may still serve, so only - // ready/rebind stop the walk. - const outcome = projectSessionSendOutcome({ - session: header, - connections, - defaultSlug, - hasSecret: (candidateSlug) => secretPresence.get(candidateSlug) === true, - }); - - if (outcome.kind === 'ready') return { rebound: false }; // facts shifted mid-flight - - if (outcome.kind === 'rebind') { - await deps.updateSession(sessionId, { - backend: 'ai-sdk', - llmConnectionSlug: outcome.connectionSlug, - model: outcome.model, - connectionLocked: true, - }); - return { - rebound: true, - connectionSlug: outcome.connectionSlug, - modelId: outcome.model, - }; - } - } - - // Blocked after the full walk: same canonical-error re-run as above. - await assertSessionCanSend(header, deps.readyConnectionDeps); - return { rebound: false }; -} - -async function hasUsableSecret(deps: ReadyConnectionDeps, slug: string): Promise { - const apiKey = await deps.getApiKey(slug); - return typeof apiKey === 'string' && apiKey.length > 0; -} - -export function chatConfigurationError(message: string, reason: ChatConfigurationReason): Error { - const error = new Error(`${NO_REAL_CONNECTION_CODE}:${reason}: ${message}`); - (error as Error & { code: string; reason: ChatConfigurationReason }).code = NO_REAL_CONNECTION_CODE; - (error as Error & { code: string; reason: ChatConfigurationReason }).reason = reason; - return error; -} - -export function errorCode(error: unknown): string | undefined { - if (error instanceof Error && 'code' in error) { - return String((error as { code?: unknown }).code); - } - return undefined; -} - -export function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - -export function errorReason(error: unknown): string | undefined { - if (error instanceof Error && 'reason' in error) { - return String((error as { reason?: unknown }).reason); - } - return undefined; -} diff --git a/apps/desktop/src/main/config-transfer-service.ts b/apps/desktop/src/main/config-transfer-service.ts index 95cbd7b397..458fec5545 100644 --- a/apps/desktop/src/main/config-transfer-service.ts +++ b/apps/desktop/src/main/config-transfer-service.ts @@ -5,23 +5,14 @@ import { } from '@maka/core/llm-connections'; import { type ConfigBundle, - type ConfigCategory, - type ConfigData, type ConnectionConflictStrategy, type CredentialKind, - buildConfigBundle, planConnectionMerge, } from '@maka/storage'; -import { stripSettingsSecretsForExport } from './settings-ipc-helpers.js'; /** - * Desktop-side orchestration for config import/export. Kept store-injected and - * Electron-free so it is unit-testable; the IPC handlers in main.ts are thin - * wrappers that supply the real stores + file dialogs. - * - * The credential category (opt-in, plaintext) is gathered by walking the - * connection list and reading each slug's connection credentials — the - * credential store exposes no bulk-list, so enumeration is the only path. + * Electron-free config import orchestration. Runtime Host owns export because + * it is the authority for connection, credential, and memory state. */ export interface ExportedCredential { @@ -30,11 +21,6 @@ export interface ExportedCredential { value: string; } -const CONNECTION_CREDENTIAL_KINDS: readonly CredentialKind[] = [ - 'api_key', - 'oauth_token', - 'request_headers', -]; const VALID_CREDENTIAL_KINDS: ReadonlySet = new Set([ 'api_key', 'oauth_token', @@ -47,55 +33,11 @@ const VALID_CREDENTIAL_KINDS: ReadonlySet = new Set([ export interface ConfigTransferDeps { connectionStore: { list(): Promise; save(c: LlmConnection): Promise }; - settingsStore: { get(): Promise; update(patch: UpdateAppSettingsInput): Promise }; + settingsStore: { update(patch: UpdateAppSettingsInput): Promise }; credentialStore: { - getSecret(slug: string, kind: CredentialKind): Promise; setSecret(slug: string, kind: CredentialKind, value: string): Promise; }; - readMemory(): Promise; writeMemory(content: string): Promise; - appVersion: string; -} - -export async function gatherConfigExport( - categories: readonly ConfigCategory[], - deps: ConfigTransferDeps, -): Promise { - const selected = new Set(categories); - const data: ConfigData = {}; - - const connections = selected.has('connections') || selected.has('credentials') - ? await deps.connectionStore.list() - : []; - - if (selected.has('connections')) { - data.connections = connections; - } - - if (selected.has('settings')) { - const settings = await deps.settingsStore.get(); - // When credentials are included, settings keeps its embedded secrets - // (proxy password, bot tokens, Tavily key); otherwise strip. - data.settings = selected.has('credentials') ? settings : stripSettingsSecretsForExport(settings); - } - - if (selected.has('credentials')) { - const creds: ExportedCredential[] = []; - for (const connection of connections) { - for (const kind of CONNECTION_CREDENTIAL_KINDS) { - const value = await deps.credentialStore.getSecret(connection.slug, kind); - if (value) creds.push({ slug: connection.slug, kind, value }); - } - } - data.credentials = creds; - } - - if (selected.has('memory')) { - const memory = await deps.readMemory(); - if (memory !== null) data.memory = memory; - } - - return buildConfigBundle({ appVersion: deps.appVersion, data }); } export interface ConfigImportResult { diff --git a/apps/desktop/src/main/git-review-main.ts b/apps/desktop/src/main/git-review-main.ts index bb46ae7ba2..0d8b7dae56 100644 --- a/apps/desktop/src/main/git-review-main.ts +++ b/apps/desktop/src/main/git-review-main.ts @@ -1,14 +1,12 @@ import { execFile } from 'node:child_process'; import { createHash } from 'node:crypto'; -import { lstat, readFile, rm } from 'node:fs/promises'; +import { lstat, readFile } from 'node:fs/promises'; import { isAbsolute, relative, resolve } from 'node:path'; import { promisify } from 'node:util'; import { countDiffLineStats } from '@maka/core/unified-diff'; import { type GitReviewFile, type GitReviewFileStatus, - type GitReviewMutationAction, - type GitReviewMutationResult, type GitReviewReadResult, type GitReviewSource, } from '@maka/core/git-review'; @@ -128,78 +126,6 @@ export async function readGitReview( } } -export async function mutateGitReview(input: { - cwd: string; - source: GitReviewSource; - revision: string; - path: string; - action: GitReviewMutationAction; - runGit?: GitReviewCommandRunner; -}): Promise { - const runGit = input.runGit ?? runGitCommand; - try { - const current = await readGitReview(input.cwd, input.source, runGit); - if (!current.ok) return { ok: false, reason: 'git_failed' }; - if (current.snapshot.revision !== input.revision) { - return { ok: false, reason: 'stale_snapshot' }; - } - if (!current.snapshot.files.some((file) => file.path === input.path)) { - return { ok: false, reason: 'path_not_found' }; - } - - const file = current.snapshot.files.find( - (candidate) => candidate.path === input.path, - ); - if (!file) return { ok: false, reason: 'path_not_found' }; - - if (input.action === 'stage') { - await runGit(current.snapshot.repositoryRoot, ['add', '--', input.path]); - } else if (input.action === 'revert') { - if (input.source !== 'unstaged') { - return { ok: false, reason: 'git_failed' }; - } - if (file.status === 'untracked') { - const target = resolve(current.snapshot.repositoryRoot, input.path); - const child = relative(current.snapshot.repositoryRoot, target); - if (!child || child.startsWith('..') || isAbsolute(child)) { - return { ok: false, reason: 'path_not_found' }; - } - await rm(target, { force: true }); - } else { - await runGit(current.snapshot.repositoryRoot, [ - 'restore', - '--worktree', - '--', - input.path, - ]); - } - } else if ( - await gitRefExists(current.snapshot.repositoryRoot, 'HEAD', runGit) - ) { - await runGit(current.snapshot.repositoryRoot, [ - 'restore', - '--staged', - '--', - input.path, - ]); - } else { - await runGit(current.snapshot.repositoryRoot, [ - 'rm', - '--cached', - '--', - input.path, - ]); - } - - return { - ok: true, - review: await readGitReview(input.cwd, input.source, runGit), - }; - } catch { - return { ok: false, reason: 'git_failed' }; - } -} - async function readTrackedChanges(input: { repositoryRoot: string; source: GitReviewSource; diff --git a/apps/desktop/src/main/main-window.ts b/apps/desktop/src/main/main-window.ts index f7680f4bd5..b84a229671 100644 --- a/apps/desktop/src/main/main-window.ts +++ b/apps/desktop/src/main/main-window.ts @@ -4,7 +4,6 @@ import { join } from 'node:path'; import { pathToFileURL } from 'node:url'; import type { AppSettings } from '@maka/core/settings'; import { isExternalUrl } from './external-link-guard.js'; -import { errorMessage } from './chat-readiness.js'; import { readSavedBounds, writeSavedBounds, SAFE_MIN_HEIGHT, SAFE_MIN_WIDTH, type SavedBounds } from './window-state.js'; import { BrowserViewController } from './browser/controller.js'; import { BrowserViewManager } from './browser/view-manager.js'; @@ -701,6 +700,7 @@ function emitRealWindowSmokeDiagnostic(stage: string): void { console.log(`[real-window-smoke] diagnostic ${JSON.stringify({ ...windowState, renderer: rendererState })}`); }) .catch((err: unknown) => { - console.log(`[real-window-smoke] diagnostic ${JSON.stringify({ ...windowState, rendererError: errorMessage(err) })}`); + const rendererError = err instanceof Error ? err.message : String(err); + console.log(`[real-window-smoke] diagnostic ${JSON.stringify({ ...windowState, rendererError })}`); }); } diff --git a/apps/desktop/src/main/runtime-host-config-ipc-main.ts b/apps/desktop/src/main/runtime-host-config-ipc-main.ts index 1498f20480..a8919e2267 100644 --- a/apps/desktop/src/main/runtime-host-config-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-config-ipc-main.ts @@ -110,7 +110,7 @@ export function registerRuntimeHostConfigIpc( ); } -async function gatherRuntimeHostConfig( +export async function gatherRuntimeHostConfig( categories: readonly ConfigCategory[], deps: RuntimeHostConfigIpcDeps, ) { @@ -173,18 +173,14 @@ function runtimeHostTransferDeps( save: (connection) => saveConnection(deps.client, connection), }, settingsStore: { - get: deps.getSettings, update: deps.updateSettings, }, credentialStore: { - getSecret: async () => null, setSecret: (slug, kind, value) => saveConnectionCredential(deps.client, slug, kind, value), }, - readMemory: () => readRuntimeHostMemoryDocument(deps.client, 'memory'), writeMemory: (content) => replaceRuntimeHostMemoryDocument(deps.client, content), - appVersion: deps.appVersion, }; } diff --git a/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts index eec5c9cf9e..d383d91ec9 100644 --- a/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts @@ -199,9 +199,6 @@ export function registerRuntimeHostSessionExecutionIpc( }); attachments = await resolveAttachmentRefs({ files, - cwd: session.workspace.hostCwd, - sessionId, - workspaceFiles: "snapshot", resizeImage: deps.resizeImage, snapshot: ({ name, mimeType, content }) => deps.client.ingestAttachment({ diff --git a/apps/desktop/src/renderer/app-shell-session-start-actions.ts b/apps/desktop/src/renderer/app-shell-session-start-actions.ts index 4aa5130430..5f708d56e9 100644 --- a/apps/desktop/src/renderer/app-shell-session-start-actions.ts +++ b/apps/desktop/src/renderer/app-shell-session-start-actions.ts @@ -103,7 +103,7 @@ export function createAppShellSessionStartActions(deps: { // is not the other one": // // workspace_unavailable → `SESSION_WORKSPACE_UNAVAILABLE:` (project-context-root.ts) - // setup_required → `NO_REAL_CONNECTION::` (chat-readiness.ts) + // setup_required → `NO_REAL_CONNECTION::` (Runtime Host execution composition) // // Anything else is a genuine failure (storage, disk, a bug) and must // not be silently relabelled as "your setup is incomplete". diff --git a/apps/desktop/src/renderer/session-health-notice.ts b/apps/desktop/src/renderer/session-health-notice.ts index 8cce09e7e9..4d5f5dbcf6 100644 --- a/apps/desktop/src/renderer/session-health-notice.ts +++ b/apps/desktop/src/renderer/session-health-notice.ts @@ -3,12 +3,13 @@ * * #1038 — the notice answers exactly one question: "will the next send * fail for a recoverable connection/session reason, and where should the - * user go?". The answer comes from `projectSessionSendOutcome` — the - * same core projection the main-process send gate delegates to, already - * resolved by main and carried in the onboarding snapshot. The renderer - * only maps that authoritative outcome to copy: + * user go?". The answer comes from `projectSessionSendOutcome`, already + * resolved by main and carried in the onboarding snapshot. Runtime Host + * remains the submission authority; the renderer only maps this + * compatibility projection to copy: * - * - `ready` / `rebind` → no notice (silent rebind stays silent, #1032). + * - `ready` / `rebind` → no notice (`rebind` supplies a compatible + * target for renderer readiness checks, #1032). * - `blocked` → destructive notice whose copy names the failing * connection and points at the matching Settings section. * @@ -17,8 +18,9 @@ * must never claim send is blocked either: it renders only as a * `warning`, only when the projection says the session's own connection * will serve the next send (`ready`), and its copy states plainly that - * the send is not intercepted. When the projection rebinds away from the - * connection, the reminder is noise and stays silent. + * the send is not intercepted. When the projection selects a compatibility + * target instead, the reminder about the stored connection is noise and + * stays silent. */ import { type LlmConnection } from '@maka/core/llm-connections'; diff --git a/apps/desktop/src/renderer/task-readiness-notice.ts b/apps/desktop/src/renderer/task-readiness-notice.ts index 93791a478c..b30f7c87b1 100644 --- a/apps/desktop/src/renderer/task-readiness-notice.ts +++ b/apps/desktop/src/renderer/task-readiness-notice.ts @@ -4,6 +4,12 @@ import type { TaskSubmissionReadinessDimension, TaskSubmissionReadinessSnapshot import type { UiLocale } from '@maka/core/ui-locale'; +/** + * Selects the model target for the renderer's readiness probe. A `rebind` + * projection supplies a compatible target for an empty legacy session; it + * does not mutate the session or admit the submission. Runtime Host owns + * those decisions. + */ export function resolveTaskReadinessModelTarget( session: { llmConnectionSlug: string; model: string } | undefined, sendOutcome: SessionSendProjection | undefined, diff --git a/packages/core/src/connection-readiness.ts b/packages/core/src/connection-readiness.ts index 477329c133..4e0b4d630f 100644 --- a/packages/core/src/connection-readiness.ts +++ b/packages/core/src/connection-readiness.ts @@ -1,7 +1,7 @@ /** - * Connection readiness — pure, sync judgment shared between the - * send-path (chat-readiness.ts) and the onboarding state machine - * (onboarding.ts). PR110a. + * Connection readiness — pure, sync judgment shared by task-submission + * readiness, the onboarding state machine, and legacy-session health + * projection. * * Source of truth for "is this LlmConnection ready to send a message * right now?". Caller is responsible for resolving async inputs @@ -34,8 +34,8 @@ import { isModelExplicitlyUnsupportedForChat } from './model-catalog.js'; /** * Canonical reasons why an LlmConnection is not ready to send. * - * Moved from `apps/desktop/src/main/chat-readiness.ts` to keep the - * taxonomy stable across the send path and onboarding surfaces. + * Kept in core so the taxonomy stays stable across readiness and onboarding + * surfaces. * Adding a new reason MUST update both this enum AND the matching * `OnboardingState` mapping in `onboarding.ts`. */ @@ -145,8 +145,8 @@ export function isConnectionReady(input: IsConnectionReadyInput): IsConnectionRe * the enabled list and the default falls back to the first servable * model, so the readiness gate below judges the models that would * actually be used. Pure; returns the input unchanged for non-Codex - * providers. Moved from the desktop send path (#1038) so the send gate - * and the session send projection share one normalization. + * providers. Moved from the former desktop send gate (#1038) so onboarding + * and the session compatibility projection share one normalization. */ export function normalizeOpenAiCodexConnection(connection: LlmConnection): LlmConnection { if (connection.providerType !== 'openai-codex') return connection; diff --git a/packages/core/src/git-review.ts b/packages/core/src/git-review.ts index 3160676294..9f7209f20d 100644 --- a/packages/core/src/git-review.ts +++ b/packages/core/src/git-review.ts @@ -42,12 +42,3 @@ export type GitReviewReadResult = | 'invalid_base_branch' | 'git_failed'; }; - -export type GitReviewMutationAction = 'stage' | 'unstage' | 'revert'; - -export type GitReviewMutationResult = - | { ok: true; review: GitReviewReadResult } - | { - ok: false; - reason: 'stale_snapshot' | 'path_not_found' | 'git_failed'; - }; diff --git a/packages/core/src/session-send-projection.ts b/packages/core/src/session-send-projection.ts index ae013058fb..3958aa312c 100644 --- a/packages/core/src/session-send-projection.ts +++ b/packages/core/src/session-send-projection.ts @@ -1,24 +1,22 @@ /** - * Session send projection — pure, sync answer to "will the next send on - * this session succeed, silently rebind, or fail?". #1038. + * Session send projection — pure, sync compatibility answer for an existing + * session's stored model target. #1038. * - * This is the single decision source shared by: - * - the main-process send gate (`ensureSessionCanSendOrRebind` in - * apps/desktop/src/main/chat-readiness.ts), which resolves the async - * facts (connections, secrets), calls this projection, then performs - * the actual rebind mutation / throws the canonical error copy; - * - the renderer session health notice above the composer, which maps - * a `blocked` outcome to actionable copy. + * This is the shared compatibility projection used by Desktop onboarding and + * the renderer session-health notice above the composer. Runtime Host owns the + * authoritative submission and execution path; this projection only explains + * whether that target looks usable or whether an empty legacy session has a + * compatible fallback for presentation and readiness checks. * - * The logic mirrors the send path exactly: + * The compatibility rules are: * 1. The session's own connection must pass `isConnectionReady` with * the sticky session model. - * 2. A locked session (has user messages) can never rebind — any - * failure of its own connection blocks the send. - * 3. An unlocked session may silently rebind only for reasons in + * 2. A locked session (has user messages) can never select a fallback — any + * failure of its own connection projects as blocked. + * 3. An unlocked session may select a fallback only for reasons in * `shouldRebindSessionToDefault`; the walk tries the default * connection first, then every other persisted connection. - * 4. Otherwise the send is blocked. + * 4. Otherwise the compatibility projection is blocked. * * `lastTestStatus` deliberately plays no part here (E4): telemetry about * a past credential test must not gate send, so it must not gate the @@ -53,9 +51,8 @@ export interface SessionSendProjectionInput { connections: readonly LlmConnection[]; defaultSlug: string | null; /** - * Secret presence per connection slug, resolved by the caller - * (credential store in main, `connections:hasSecret` IPC probe in the - * renderer). Only consulted for connections that exist. + * Secret presence per connection slug, resolved by the caller. Only + * consulted for connections that exist. */ hasSecret(slug: string): boolean; } @@ -99,18 +96,12 @@ export function projectSessionSendOutcome( } /** - * Why the session's own connection cannot send, or `undefined` when it - * can. Mirrors `assertSessionCanSend` + `requireReadyConnection` in the - * desktop main process — keep the reason order in sync with the throwing - * path so both surfaces report identical causes. - * - * Exported for the send gate's staged fact resolution (#1038 review): - * main resolves only the session's OWN connection in phase 1 and calls - * this directly, so a healthy session never waits on — nor is failed - * by — unrelated connections. The full projection reuses the same - * helper, keeping one implementation of the own-connection judgment. + * Why the session's own connection cannot satisfy the compatibility + * projection, or `undefined` when it can. Kept private so Runtime Host + * admission cannot accidentally grow a second dependency on this UI-facing + * legacy-session policy. */ -export function sessionOwnConnectionBlockReason( +function sessionOwnConnectionBlockReason( session: SessionSendProjectionSession, ownConnection: LlmConnection | null, hasSecret: (slug: string) => boolean, @@ -141,13 +132,13 @@ function ownConnectionBlockReason( } /** - * Whether an unlocked session whose own connection failed with `reason` - * may silently rebind to another ready connection on send. Failures not - * listed here (e.g. `missing_api_key`, `connection_disabled`) block the - * send even when unlocked — silently moving a session off a connection - * the user explicitly configured would be surprising. + * Whether an unlocked session whose own connection failed with `reason` may + * project another ready connection as a compatibility target. Failures not + * listed here (e.g. `missing_api_key`, `connection_disabled`) stay blocked + * even when unlocked because masking an explicitly configured connection + * would make the health/readiness UI misleading. */ -export function shouldRebindSessionToDefault(reason: string | undefined): boolean { +function shouldRebindSessionToDefault(reason: string | undefined): boolean { return ( reason === 'fake_backend' || reason === 'connection_missing' || diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 896a563891..4ea17a49cd 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -319,9 +319,8 @@ export interface SessionSummary { llmConnectionSlug: string; /** * True once the session has user messages — its connection/model is - * sticky and the send path will never silently rebind it. Surfaced so - * the renderer can project send outcomes (#1038) without a main - * round-trip. + * sticky and compatibility projections never select a replacement target. + * Surfaced so onboarding can project existing-session health (#1038). */ connectionLocked: boolean; /** Sticky session default model id for renderer/header display. */