You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by as...@apache.org on 2026/08/20 00:49:26 UTC
(maka) branch main updated: refactor(desktop): remove superseded local authority paths (#3252)
This is an automated email from the ASF dual-hosted git repository.
Astro-Han pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/maka.git
The following commit(s) were added to refs/heads/main by this push:
new 2bb59062e refactor(desktop): remove superseded local authority paths (#3252)
2bb59062e is described below
commit 2bb59062e66c8154fcbf2bd52ca523e6c098293e
Author: jakevin <ja...@gmail.com>
AuthorDate: Thu Aug 20 08:49:22 2026 +0800
refactor(desktop): remove superseded local authority paths (#3252)
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
---
.../__tests__/attachment-ingest-resolve.test.ts | 30 +-
.../src/main/__tests__/attachment-ingest.test.ts | 68 ---
.../src/main/__tests__/chat-readiness.test.ts | 550 ---------------------
.../main/__tests__/config-transfer-service.test.ts | 51 +-
.../src/main/__tests__/git-review-main.test.ts | 92 +---
.../__tests__/runtime-host-config-ipc-main.test.ts | 115 +++++
apps/desktop/src/main/attachment-ingest.ts | 113 +----
apps/desktop/src/main/chat-readiness.ts | 291 -----------
apps/desktop/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 -
.../renderer/app-shell-session-start-actions.ts | 2 +-
apps/desktop/src/renderer/session-health-notice.ts | 16 +-
apps/desktop/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(-)
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 28a02466c..6ce6d8062 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 bb6400ec9..000000000
--- 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 f43a60a0a..000000000
--- 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<unknown>, includes: string, reason: string): Promise<void> {
- 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<string, { connection: LlmConnection; apiKey?: string | null }>): 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> = {}): 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<SessionHeader> = {}): Pick<SessionHeader, 'backend' | 'llmConnectionSlug' | 'model' | 'connectionLocked'> {
- 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<string, number> = { '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<string, LlmConnection>;
- 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<never>(() => {});
- 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 d9a17265b..d02308a32 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<ConfigTransferDeps> = {}): {
deps: ConfigTransferDeps;
saved: LlmConnection[];
@@ -37,9 +28,7 @@ function makeDeps(overrides: Partial<ConfigTransferDeps> = {}): {
const updatedSettings: unknown[] = [];
const setCreds: Array<{ slug: string; kind: CredentialKind; value: string }> = [];
const writtenMemory: string[] = [];
- const secretsBySlugKind = new Map<string, string>([['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<ConfigTransferDeps> = {}): {
},
},
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<ConfigTransferDeps> = {}): {
}
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<string, any>;
- 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<string, any>;
- 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 1550398ee..8aa1c9d06 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<string>();
@@ -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<string> {
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 000000000..1b9c923ae
--- /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<string, any>;
+ 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<string, any>;
+ 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 3ca4143c4..f6ffa7bde 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<Uint8Array>;
- realpath?: (path: string) => Promise<string>;
- now?: () => number;
- maxBytes?: number;
-}): Promise<AttachmentRef[]> {
- 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<AttachmentRef>;
resizeImage?: (bytes: Uint8Array) => Promise<Uint8Array>;
- realpath?: (path: string) => Promise<string>;
maxBytes?: number;
}): Promise<AttachmentRef[]> {
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<string>): Promise<string> {
- const resolveFn = realpath ?? fsRealpath;
- try {
- return await resolveFn(path);
- } catch {
- return path;
- }
-}
-
-async function isInsideCwdReal(cwd: string, target: string, realpath?: (path: string) => Promise<string>): Promise<boolean> {
- 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 1aa161622..000000000
--- 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<LlmConnection | null>;
- getApiKey(slug: string): Promise<string | null | undefined>;
-}
-
-export interface ReadyConnection {
- connection: LlmConnection;
- apiKey: string;
- model: string;
-}
-
-export interface SessionRebindDeps {
- readyConnectionDeps: ReadyConnectionDeps;
- getDefaultSlug(): Promise<string | null>;
- listConnectionSlugs(): Promise<string[]>;
- updateSession(
- sessionId: string,
- patch: Pick<SessionHeader, 'backend' | 'llmConnectionSlug' | 'model' | 'connectionLocked'>,
- ): Promise<unknown>;
-}
-
-export interface SessionRebindResult {
- rebound: boolean;
- connectionSlug?: string;
- modelId?: string;
-}
-
-export async function requireReadyConnection(
- slug: string | null | undefined,
- deps: ReadyConnectionDeps,
- requestedModel?: string,
-): Promise<ReadyConnection> {
- // 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<SessionHeader, 'backend' | 'llmConnectionSlug' | 'model'>,
- deps: ReadyConnectionDeps,
-): Promise<void> {
- 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<SessionHeader, 'backend' | 'llmConnectionSlug' | 'model' | 'connectionLocked'>,
- deps: SessionRebindDeps,
-): Promise<SessionRebindResult> {
- // #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<string, boolean>();
- 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<boolean> {
- 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 95cbd7b39..458fec554 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<string> = new Set<CredentialKind>([
'api_key',
'oauth_token',
@@ -47,55 +33,11 @@ const VALID_CREDENTIAL_KINDS: ReadonlySet<string> = new Set<CredentialKind>([
export interface ConfigTransferDeps {
connectionStore: { list(): Promise<LlmConnection[]>; save(c: LlmConnection): Promise<LlmConnection> };
- settingsStore: { get(): Promise<AppSettings>; update(patch: UpdateAppSettingsInput): Promise<AppSettings> };
+ settingsStore: { update(patch: UpdateAppSettingsInput): Promise<AppSettings> };
credentialStore: {
- getSecret(slug: string, kind: CredentialKind): Promise<string | null>;
setSecret(slug: string, kind: CredentialKind, value: string): Promise<void>;
};
- readMemory(): Promise<string | null>;
writeMemory(content: string): Promise<void>;
- appVersion: string;
-}
-
-export async function gatherConfigExport(
- categories: readonly ConfigCategory[],
- deps: ConfigTransferDeps,
-): Promise<ConfigBundle> {
- 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 bb46ae7ba..0d8b7dae5 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<GitReviewMutationResult> {
- 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 f7680f4bd..b84a22967 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 1498f2048..a8919e226 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 eec5c9cf9..d383d91ec 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 4aa513043..5f708d56e 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:<reason>:` (chat-readiness.ts)
+ // setup_required → `NO_REAL_CONNECTION:<reason>:` (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 8cce09e7e..4d5f5dbcf 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 93791a478..b30f7c87b 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 477329c13..4e0b4d630 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 316067629..9f7209f20 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 ae013058f..3958aa312 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 896a56389..4ea17a49c 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. */
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org