From 9ff267510d381aba89f255b0d77bf6d277601de2 Mon Sep 17 00:00:00 2001 From: Xiao Liu Date: Sun, 30 Aug 2026 16:46:13 +0800 Subject: [PATCH 01/71] fix(desktop): ratchet renderer debt against the base tree, not its ledger (#4249) Closes #4250 Generated-by: Claude Code (Opus 4.8) --- apps/desktop/renderer-architecture.json | 111 +++++++++++++++++- .../scripts/check-renderer-architecture.mjs | 62 +++++++++- 2 files changed, 165 insertions(+), 8 deletions(-) diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index b71da97554..6c76a0ebad 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -71,6 +71,7 @@ "src/renderer/locales/onboarding-copy.ts", "src/renderer/locales/permission-center-copy.ts", "src/renderer/locales/plan-mode-copy.ts", + "src/renderer/locales/session-collaboration-copy.ts", "src/renderer/locales/settings-bot-copy.ts", "src/renderer/locales/settings-daily-review-copy.ts", "src/renderer/locales/settings-data-copy.ts", @@ -113,6 +114,7 @@ "src/renderer/remote-project-directory-dialog.tsx", "src/renderer/scroll-motion-policy.ts", "src/renderer/session-catalog-state.ts", + "src/renderer/session-collaboration-dialog.tsx", "src/renderer/session-copy-attempt.ts", "src/renderer/session-error-presentation.ts", "src/renderer/session-event-health.ts", @@ -121,6 +123,7 @@ "src/renderer/session-read-state.ts", "src/renderer/session-status-presentation.ts", "src/renderer/session-trace-refresh.ts", + "src/renderer/session-turn-request-composer.tsx", "src/renderer/session-workspace-actions.ts", "src/renderer/session-workspace-errors.ts", "src/renderer/settings/about-settings-page.tsx", @@ -234,6 +237,7 @@ "src/renderer/use-new-task-choice.ts", "src/renderer/use-onboarding-snapshot.ts", "src/renderer/use-project-context.ts", + "src/renderer/use-session-collaboration-dialog.ts", "src/renderer/use-session-setting-intent.ts", "src/renderer/use-settings-modal.ts", "src/renderer/use-shell-appearance.ts", @@ -583,7 +587,7 @@ "react": 1 }, "importSpecifiers": 39, - "nonTriviaTokens": 3829 + "nonTriviaTokens": 3877 }, "src/renderer/app-shell-overlays.tsx": { "importDeclarations": 14, @@ -861,7 +865,7 @@ "nonTriviaTokens": 1425 }, "src/renderer/app-shell.tsx": { - "importDeclarations": 103, + "importDeclarations": 107, "bridgePaths": { "window.maka.app.installUpdate": 1, "window.maka.app.retryUpdateDownload": 1, @@ -928,6 +932,7 @@ "useOnboardingSnapshot": 1, "usePlanModeState": 1, "useRef": 25, + "useSessionCollaborationDialog": 1, "useSessionEventHealthPolling": 1, "useSessionNavigationReads": 1, "useSessionSettingIntent": 2, @@ -994,6 +999,7 @@ "./live-content-seed": 1, "./live-turn-reconciler": 1, "./locales/conversation-copy": 1, + "./locales/session-collaboration-copy": 1, "./locales/shell-copy": 1, "./locales/shell-remaining-copy.js": 1, "./model-connection-errors": 1, @@ -1002,6 +1008,8 @@ "./pending-session-view": 1, "./plan-mode-panel": 1, "./scroll-motion-policy": 1, + "./session-collaboration-dialog": 1, + "./session-turn-request-composer.js": 1, "./session-workspace-errors": 1, "./settings/provider-brand-marks": 1, "./settings/provider-display": 1, @@ -1018,6 +1026,7 @@ "./use-new-task-choice": 1, "./use-onboarding-snapshot": 1, "./use-project-context": 1, + "./use-session-collaboration-dialog": 1, "./use-session-setting-intent": 1, "./use-settings-modal": 1, "./use-shell-appearance": 1, @@ -1054,8 +1063,8 @@ "@maka/ui/icons": 1, "react": 1 }, - "importSpecifiers": 183, - "nonTriviaTokens": 15553 + "importSpecifiers": 187, + "nonTriviaTokens": 15912 }, "src/renderer/use-app-shell-composer-quotes.ts": { "importDeclarations": 3, @@ -1851,6 +1860,17 @@ "@maka/core/ui-locale": 1 } }, + "src/renderer/locales/session-collaboration-copy.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "@maka/core/ui-locale": 1 + } + }, "src/renderer/locales/settings-bot-copy.ts": { "bridgePaths": {}, "environmentCapabilities": {}, @@ -2439,6 +2459,42 @@ "react": 1 } }, + "src/renderer/session-collaboration-dialog.tsx": { + "bridgePaths": { + "window.maka.localRuntimeHostRemoteAccess.getSnapshot": 2, + "window.maka.sessionCollaboration.decideTurnRequest": 1, + "window.maka.sessionCollaboration.getAccess": 1, + "window.maka.sessionCollaboration.getTurnRequests": 1, + "window.maka.sessionCollaboration.importInvitation": 1, + "window.maka.sessionCollaboration.prepareInvitation": 1, + "window.maka.sessionCollaboration.revokeGrant": 1, + "window.maka.sessionCollaboration.revokePrincipal": 1 + }, + "environmentCapabilities": { + "navigator.clipboard.writeText": 1, + "window.clearTimeout": 1, + "window.setTimeout": 1 + }, + "hookCalls": { + "useEffect": 1, + "useState": 8, + "useToast": 2, + "useUiLocale": 2 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "./locales/session-collaboration-copy.js": 1, + "./session-turn-request-composer.js": 1, + "@astryxdesign/core": 1, + "@astryxdesign/core/Dialog": 1, + "@astryxdesign/core/Layout": 1, + "@maka/runtime-host/protocol": 1, + "@maka/ui": 1, + "react": 1 + } + }, "src/renderer/session-copy-attempt.ts": { "bridgePaths": {}, "environmentCapabilities": { @@ -2545,6 +2601,33 @@ "@maka/core/events": 1 } }, + "src/renderer/session-turn-request-composer.tsx": { + "bridgePaths": { + "window.maka.sessionCollaboration.acknowledgeTurnRequest": 1, + "window.maka.sessionCollaboration.getTurnRequests": 2, + "window.maka.sessionCollaboration.requestTurn": 1 + }, + "environmentCapabilities": { + "window.clearTimeout": 1, + "window.setTimeout": 1 + }, + "hookCalls": { + "useEffect": 1, + "useRef": 2, + "useState": 6, + "useToast": 1, + "useUiLocale": 1 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "./locales/session-collaboration-copy.js": 1, + "@maka/runtime-host/protocol": 1, + "@maka/ui": 1, + "react": 1 + } + }, "src/renderer/session-workspace-actions.ts": { "bridgePaths": { "window.maka.sessions.queryCancelledMessages": 1 @@ -3877,7 +3960,7 @@ "hookCalls": { "useEffect": 1, "useMountedRef": 1, - "useState": 9, + "useState": 10, "useToast": 1, "useUiLocale": 1 }, @@ -3886,7 +3969,9 @@ "actionFactories": [], "dependencyPaths": { "../../preload/bridge-contract.js": 1, + "../locales/session-collaboration-copy.js": 1, "../locales/settings-projects-copy.js": 1, + "../session-collaboration-dialog.js": 1, "./password-input.js": 1, "./runtime-host-connection-code-dialog.js": 1, "./runtime-host-management-dialog.js": 1, @@ -4928,6 +5013,19 @@ "react": 1 } }, + "src/renderer/use-session-collaboration-dialog.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": { + "useState": 1 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "react": 1 + } + }, "src/renderer/use-session-setting-intent.ts": { "bridgePaths": {}, "environmentCapabilities": {}, @@ -5263,7 +5361,8 @@ "unresolvedDependencies": 0, "actionFactories": [], "dependencyPaths": { - "../preload/bridge-contract.js": 1 + "../preload/bridge-contract.js": 1, + "../shared/runtime-host-identity.js": 1 } }, "src/renderer/workhub-coordination-port.ts": { diff --git a/apps/desktop/scripts/check-renderer-architecture.mjs b/apps/desktop/scripts/check-renderer-architecture.mjs index 548c3128af..68fe532f16 100644 --- a/apps/desktop/scripts/check-renderer-architecture.mjs +++ b/apps/desktop/scripts/check-renderer-architecture.mjs @@ -18,8 +18,9 @@ */ import { execFileSync } from 'node:child_process'; -import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; import { builtinModules } from 'node:module'; +import { tmpdir } from 'node:os'; import { dirname, join, relative, resolve, sep } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { parse } from '@babel/parser'; @@ -2516,6 +2517,46 @@ export function checkRendererArchitecture({ return violations.sort(); } +// The monotonic-debt ratchet must measure debt against what the base commit's +// source tree *actually* contained, not against the numbers its ledger happened +// to record. A ledger that under-reports its own tree (for example, one +// generated on a branch that predated files already merged into main) would +// otherwise make a faithful baseline correction look like brand-new debt and +// wedge the ledger permanently. We materialize the base tree and re-derive its +// debt, keeping the base ledger only as the source of policy fields (hook +// transitions, growth directories, root-debt key set, ownership). +function deriveBaseTreeConfig(repoRoot, desktopRoot, base, baseCommittedConfig) { + const scratch = mkdtempSync(join(tmpdir(), 'renderer-arch-base-')); + const worktreePath = join(scratch, 'tree'); + try { + execFileSync('git', ['worktree', 'add', '--detach', worktreePath, base], { + cwd: repoRoot, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + const baseDesktopRoot = resolve(worktreePath, relative(repoRoot, desktopRoot)); + return generateArchitectureConfig(baseDesktopRoot, baseCommittedConfig); + } finally { + try { + execFileSync('git', ['worktree', 'remove', '--force', worktreePath], { + cwd: repoRoot, + stdio: 'ignore', + }); + } catch { + try { + execFileSync('git', ['worktree', 'prune'], { cwd: repoRoot, stdio: 'ignore' }); + } catch { + // Ignore prune failures; the scratch removal below is the real cleanup. + } + } + try { + rmSync(scratch, { recursive: true, force: true }); + } catch { + // Best-effort cleanup of the scratch directory. + } + } +} + function loadBaseConfig(repoRoot, desktopRoot, base) { if (!base) return { baseConfig: undefined, introducedLedger: false }; const relativeConfig = normalizePath(relative(repoRoot, join(desktopRoot, 'renderer-architecture.json'))); @@ -2557,13 +2598,30 @@ function loadBaseConfig(repoRoot, desktopRoot, base) { throw new Error(`base ledger is missing at ${base}:${relativeConfig}`); } + let baseCommittedConfig; try { - return { baseConfig: JSON.parse(source), introducedLedger: false }; + baseCommittedConfig = JSON.parse(source); } catch (error) { throw new Error( `base ledger is invalid JSON at ${base}:${relativeConfig}: ${error instanceof Error ? error.message : String(error)}`, ); } + + try { + return { + baseConfig: deriveBaseTreeConfig(repoRoot, desktopRoot, base, baseCommittedConfig), + introducedLedger: false, + }; + } catch (error) { + // If the base tree cannot be materialized or analyzed (e.g. git worktree is + // unavailable), fall back to the committed base ledger so the ratchet still + // runs. This restores the pre-fix behavior rather than crashing the check. + console.warn( + `Renderer architecture check: could not derive base tree debt at ${base}; ` + + `falling back to the committed base ledger. (${error instanceof Error ? error.message : String(error)})`, + ); + return { baseConfig: baseCommittedConfig, introducedLedger: false }; + } } function parseCliArguments(args) { From bdb103d5937690de6d176292b06fe31e054c3bda Mon Sep 17 00:00:00 2001 From: ooopSok <81428141+Sun-GLiang@users.noreply.github.com> Date: Sun, 30 Aug 2026 18:20:51 +0800 Subject: [PATCH 02/71] fix(desktop): remove first-send empty hero flash (#4246) Project the first optimistic message before activating its new Session and suppress empty-chat content whenever a transient row is visible. Generated-by: Maka --- .../app-shell-first-send-cleanup.test.ts | 9 ++++--- .../main/__tests__/streaming-handoff.test.ts | 23 ++++++++++++++++ .../src/renderer/app-shell-chat-actions.ts | 27 +++++++++++-------- packages/ui/src/chat-view.tsx | 9 ++++++- 4 files changed, 53 insertions(+), 15 deletions(-) diff --git a/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts b/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts index bbe0d85849..8079b0d17c 100644 --- a/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-first-send-cleanup.test.ts @@ -302,7 +302,7 @@ describe('composer first-send cleanup', () => { assert.deepEqual(removed, []); }); - it('waits for the new session observation before submitting its first message', async () => { + it('projects the first message before activation while waiting to submit until observation', async () => { const observation = deferred(); const order: string[] = []; const activeIdRef = { current: undefined as string | undefined }; @@ -325,6 +325,9 @@ describe('composer first-send cleanup', () => { const sending = createAppShellChatActions({ ...createActionsDeps(), activeIdRef, + addTransientMessage: () => { + order.push('optimistic'); + }, activateSessionForFirstSend: async (sessionId) => { order.push('observe'); activeIdRef.current = sessionId; @@ -333,11 +336,11 @@ describe('composer first-send cleanup', () => { }, }).send('hello'); await new Promise((resolve) => setImmediate(resolve)); - assert.deepEqual(order, ['create', 'observe']); + assert.deepEqual(order, ['create', 'optimistic', 'observe']); observation.resolve(); assert.equal(await sending, true); - assert.deepEqual(order, ['create', 'observe', 'seeded', 'submit']); + assert.deepEqual(order, ['create', 'optimistic', 'observe', 'seeded', 'submit']); } finally { restoreWindow(); } diff --git a/apps/desktop/src/main/__tests__/streaming-handoff.test.ts b/apps/desktop/src/main/__tests__/streaming-handoff.test.ts index 0988ac8275..a798334884 100644 --- a/apps/desktop/src/main/__tests__/streaming-handoff.test.ts +++ b/apps/desktop/src/main/__tests__/streaming-handoff.test.ts @@ -115,6 +115,29 @@ describe('single live-turn handoff', () => { assert.match(markup, />send now { + const markup = renderWithLocale(createElement(ChatView, { + activeSession: { + id: 'session-1', name: 'pending', status: 'active', backend: 'ai-sdk', + labels: [], isFlagged: false, isArchived: false, hasUnread: false, + llmConnectionSlug: 'conn', connectionLocked: false, model: 'model', permissionMode: 'ask', + }, + messages: [], + transientMessages: [ + { + id: 'message-pending', ts: 1, + text: 'inspect this image', transientPlacement: 'current_turn', + }, + ], + scrollBehavior: 'smooth', + onNew() {}, + } satisfies Parameters[0])); + + assert.match(markup, /data-transient-message-id="message-pending"/); + assert.match(markup, />inspect this image { const markup = renderWithLocale(createElement(ChatView, { activeSession: { diff --git a/apps/desktop/src/renderer/app-shell-chat-actions.ts b/apps/desktop/src/renderer/app-shell-chat-actions.ts index 300d5d5e9e..641fb3d5b7 100644 --- a/apps/desktop/src/renderer/app-shell-chat-actions.ts +++ b/apps/desktop/src/renderer/app-shell-chat-actions.ts @@ -463,6 +463,21 @@ export function createAppShellChatActions(deps: { }); unsentSessionId = session.id; optimisticSessionId = session.id; + optimisticMessageId = messageId; + // Stage the first row before activation. `setActiveId` projects this + // session-owned transient in the same state transition that replaces + // the new-chat surface, so the empty-session Maka hero cannot paint + // between observation settling and the submitted content appearing. + showTransientUserMessage( + session.id, + messageId, + options.displayText ?? text, + [], + { + ...(quotes && quotes.length > 0 ? { quotes } : {}), + inlineReferences: [], + }, + ); // Consumed: the choice is now the created Session's, not the next // draft's. A failed create leaves it in place so a retry keeps it. if (newChatPermissionChoice) clearNewChatPermissionChoice(); @@ -472,20 +487,10 @@ export function createAppShellChatActions(deps: { // cannot become durable text without live identity. await activateSessionForFirstSend(session.id); if (activeIdRef.current !== session.id) { + removeOptimisticUserMessage(session.id, messageId); await discardUnsentSession(); return false; } - optimisticMessageId = messageId; - showTransientUserMessage( - session.id, - messageId, - options.displayText ?? text, - [], - { - ...(quotes && quotes.length > 0 ? { quotes } : {}), - inlineReferences: [], - }, - ); if (exactTurn) armTurnActive(session.id, messageId); const attachmentItems = pending && pending.length > 0 diff --git a/packages/ui/src/chat-view.tsx b/packages/ui/src/chat-view.tsx index 80be6a215a..af4ec6cf23 100644 --- a/packages/ui/src/chat-view.tsx +++ b/packages/ui/src/chat-view.tsx @@ -700,7 +700,14 @@ export function ChatView(props: { > {showEmptyState ? null : ( <> - {chat.length === 0 && !streamingActive ? emptyContent : null} + {/* A transient is already the first visible conversation row. + Do not prepend the empty-chat Maka hero while that optimistic + message waits for durable transcript or live-turn identity. */} + {chat.length === 0 + && transientMessages.length === 0 + && !streamingActive + ? emptyContent + : null} {turns.map((turn) => { return (
Date: Sun, 30 Aug 2026 18:21:28 +0800 Subject: [PATCH 03/71] fix(desktop): retire consumed steering queue cards (#4232) Clear the Desktop queue projection as soon as the matching steering message is delivered, while preserving unrelated follow-ups and allowing later nack projections to restore the entry. Generated-by: Maka --- .../__tests__/message-queue-ui-state.test.ts | 56 +++++++++++++++++++ .../src/renderer/app-shell-session-events.ts | 16 +++++- 2 files changed, 70 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/main/__tests__/message-queue-ui-state.test.ts b/apps/desktop/src/main/__tests__/message-queue-ui-state.test.ts index d9db2f1602..ece5f859fc 100644 --- a/apps/desktop/src/main/__tests__/message-queue-ui-state.test.ts +++ b/apps/desktop/src/main/__tests__/message-queue-ui-state.test.ts @@ -109,6 +109,16 @@ test('queue_update events drive the independent desktop queue projection', () => content: { text: 'adjust this run' }, }); assert.deepEqual(removedTransientMessageIds, ['message-steer']); + assert.deepEqual(controller.getState().messageQueueBySession['session-1'], { + queueRevision: 3, + entries: [{ + entryId: 'entry-next', + messageId: 'message-next', + content: { text: 'do this next' }, + placement: 'next_turn', + state: 'queued', + }], + }); handlers.handleEvent('session-1', { type: 'queue_update', @@ -148,6 +158,52 @@ test('queue_update events drive the independent desktop queue projection', () => assert.deepEqual(removedTransientMessageIds, ['message-steer', 'message-next']); }); +test('steering delivery clears a promoted follow-up from the desktop queue', () => { + const controller = createAppShellSessionUiStateController(); + const handlers = createAppShellSessionEventHandlers({ + uiLocale: 'en', + activeIdRef: { current: 'session-1' }, + liveTurnBySessionRef: controller.liveTurnBySessionRef, + refreshMessages: async () => true, + refreshSessions: async () => [], + setLiveTurnBySession: controller.setLiveTurnBySession, + setInteractionBySession: controller.setInteractionBySession, + setMessageQueueBySession: controller.setMessageQueueBySession, + showModelSetupToast() {}, + toastApi: { error() {} }, + }); + + handlers.handleEvent('session-1', { + type: 'queue_update', + id: 'queue-followup', + turnId: 'turn-1', + ts: 1, + queueRevision: 1, + steering: [], + followup: ['adjust this run'], + steeringEntries: [], + followupEntries: [{ + entryId: 'entry-followup', + messageId: 'message-followup', + content: { text: 'adjust this run' }, + placement: 'next_turn', + state: 'queued', + }], + }); + assert.equal(controller.getState().messageQueueBySession['session-1']?.entries.length, 1); + + handlers.handleEvent('session-1', { + type: 'steering_message', + id: 'steering-message-followup', + turnId: 'turn-1', + messageId: 'message-followup', + ts: 2, + content: { text: 'adjust this run' }, + }); + + assert.equal(controller.getState().messageQueueBySession['session-1'], undefined); +}); + test('complete events deliver the durable context compaction outcome to Desktop', () => { const controller = createAppShellSessionUiStateController(); const outcomes: unknown[] = []; diff --git a/apps/desktop/src/renderer/app-shell-session-events.ts b/apps/desktop/src/renderer/app-shell-session-events.ts index fde66da7ca..6d04ef0de5 100644 --- a/apps/desktop/src/renderer/app-shell-session-events.ts +++ b/apps/desktop/src/renderer/app-shell-session-events.ts @@ -367,9 +367,21 @@ export function createAppShellSessionEventHandlers(options: { break; case 'steering_message': // The live Turn projection now renders this same messageId in place. - // Retire only the renderer-owned tail row; a later nack queue_update - // will project it again if the Host returns the message to the queue. + // Retire the renderer-owned tail row and its pending-queue card; a + // later nack queue_update will project both again if the Host returns + // the message to the queue. removeTransientMessage?.(sessionId, event.messageId); + setMessageQueueBySession?.((current) => { + const queue = current[sessionId]; + if (!queue?.entries.some((entry) => entry.messageId === event.messageId)) return current; + const entries = queue.entries.filter((entry) => entry.messageId !== event.messageId); + if (entries.length > 0) { + return { ...current, [sessionId]: { ...queue, entries } }; + } + const next = { ...current }; + delete next[sessionId]; + return next; + }); break; case 'text_complete': void refreshMessages(sessionId, { requiredAssistantMessageId: event.messageId }).catch(() => false); From 1451071ffabc89e16129a8e4d9c07dad25134056 Mon Sep 17 00:00:00 2001 From: Xiao Liu Date: Sun, 30 Aug 2026 18:27:51 +0800 Subject: [PATCH 04/71] test(desktop): cover model picker failure and edge states (#4162) Exercise the production model picker across saving, empty, high-cardinality, long-label, and stale-model states so Storybook and accessibility checks cover the real UI boundaries. --- packages/ui/stories/model-picker.stories.tsx | 223 ++++++++++++++++++- 1 file changed, 222 insertions(+), 1 deletion(-) diff --git a/packages/ui/stories/model-picker.stories.tsx b/packages/ui/stories/model-picker.stories.tsx index 769f2ac493..b7819fb016 100644 --- a/packages/ui/stories/model-picker.stories.tsx +++ b/packages/ui/stories/model-picker.stories.tsx @@ -23,12 +23,16 @@ import { expect, userEvent, within } from 'storybook/test'; import type { ProviderType } from '@maka/core/llm-connections'; import type { ThinkingLevel } from '@maka/core/model-thinking'; import type { SessionSummary } from '@maka/core/session'; -import { ChatModelSwitcher, NewChatModelPicker, ThinkingLevelSelector } from '../src/chat-model-switcher.js'; +import { ChatModelSwitcher, ModelChipStatic, NewChatModelPicker, ThinkingLevelSelector } from '../src/chat-model-switcher.js'; import { exactModelChoiceValue, + modelChoiceValue, + modelMenuGroups, type ChatModelChoice, } from '../src/chat-model-helpers.js'; import { ModelPicker } from '../src/model-picker.js'; +import { getConversationCopy } from '../src/conversation-copy.js'; +import { useUiLocale } from '../src/locale-context.js'; // Fidelity convention (#1433): every story below names the real app path // that reaches it. See apps/desktop/stories/FIDELITY.md. @@ -65,6 +69,53 @@ const CHOICES: ChatModelChoice[] = [ // Canonical user-facing ladder when a model offers the common set. const THINKING_LEVELS: ThinkingLevel[] = ['off', 'low', 'medium', 'high', 'xhigh']; +// A workspace with far more connections than any reference screen probes. Two +// OpenAI keys share a provider, so `modelMenuGroups` disambiguates their +// headings with the connection slug. +const MANY_CHOICES: ChatModelChoice[] = ( + [ + { slug: 'openai-main', type: 'openai', label: 'OpenAI', models: ['gpt-5', 'gpt-5-mini', 'gpt-5-nano', 'o3', 'o4-mini', 'gpt-4.1'] }, + { slug: 'openai-alt', type: 'openai', label: 'OpenAI', models: ['gpt-5', 'o3'] }, + { slug: 'anthropic-team', type: 'anthropic', label: 'Anthropic', models: ['claude-opus-4-1', 'claude-sonnet-4', 'claude-haiku-4-5'] }, + { slug: 'google-lab', type: 'google', label: 'Google Gemini', models: ['gemini-3-pro', 'gemini-3-flash'] }, + { slug: 'deepseek-main', type: 'deepseek', label: 'DeepSeek', models: ['deepseek-chat', 'deepseek-reasoner'] }, + { slug: 'moonshot-main', type: 'moonshot', label: 'Moonshot', models: ['kimi-k2-0711', 'kimi-k1-8k'] }, + { slug: 'relay', type: 'openai-compatible', label: 'Custom relay', models: ['vendor/alpha', 'vendor/beta', 'vendor/gamma'] }, + ] satisfies Array<{ slug: string; type: ProviderType; label: string; models: string[] }> +).flatMap((group) => group.models.map((model) => choice(group.slug, group.type, group.label, model, model))); + +// A relay whose connection name, model ids, and descriptions all overflow the +// trigger and option widths — the "very long text" state truncation must honour. +const LONG_MODEL_LABEL = + 'A very long model name that keeps going well past any reasonable trigger width so wrapping and truncation get exercised'; +const LONG_CHOICES: ChatModelChoice[] = [ + { + connectionId: 'connection-relay-verbose', + connectionSlug: 'relay-verbose', + providerType: 'openai-compatible', + providerLabel: 'Custom relay', + connectionName: 'My self-hosted relay with an unusually descriptive connection name that also overflows', + model: 'vendor/a-very-long-model-identifier-with-reasoning-tools-and-a-2026-preview-suffix', + label: LONG_MODEL_LABEL, + description: + 'A deliberately verbose description that runs onto several lines so the option body’s overflow handling stays legible instead of pushing the menu wider.', + knowledgeCutoff: '2026-01', + isDefault: false, + thinkingLevels: [], + }, + { + connectionId: 'connection-relay-verbose', + connectionSlug: 'relay-verbose', + providerType: 'openai-compatible', + providerLabel: 'Custom relay', + connectionName: 'My self-hosted relay with an unusually descriptive connection name that also overflows', + model: 'vendor/second-extremely-long-model-identifier-preview-with-an-extended-context-window', + label: 'Another exhaustively named preview model with an extended context window and a trailing note', + isDefault: false, + thinkingLevels: [], + }, +]; + function providerMark(type: ProviderType) { const labels: Partial> = { openai: 'O', @@ -291,3 +342,173 @@ export const ThinkingLevelSeparate: Story = { await within(document.body).findByRole('menuitem', { name: '中' }); }, }; + +// Real path: Settings → 通用 → default model, while the just-picked model is +// being saved. Production drives `ModelPicker.loading` from the save in flight +// (general-settings-page.tsx `loading={saving}`), with the catalog present and +// the row disabled — not an empty catalog. (When the catalog itself is +// unavailable the settings row renders a skeleton, which is a different +// component, so that is not modelled here.) +export const SavingDefaultModel: Story = { + render: () => ( +
+ {}} + /> +
+ ), +}; + +// Real path: composer left footer when no connection yields a usable model — +// what a failed / offline / unauthorised catalog fetch all collapse to. The +// picker cannot exist without choices, so the composer swaps in an honest +// "configure a connection" chip (ModelChipStatic's onOpenSettings button) +// rather than a dropdown with nothing behind it. +export const NoModelsAvailable: Story = { + render: function NoModelsAvailableRender() { + const copy = getConversationCopy(useUiLocale()).composer; + return ( +
+ {}} /> +
+ ); + }, + play: async ({ canvasElement }) => { + // It is a real button into Settings, not inert text wearing a dead chevron. + await expect( + within(canvasElement).getByRole('button', { + name: /配置模型连接|Configure model connections/, + }), + ).toBeInTheDocument(); + }, +}; + +// Real path: home / new-chat model control for a workspace with many configured +// connections — the breadth #3446 F5 says a single reference screen never +// exercises. Two OpenAI keys land in the same provider, so their headings carry +// the disambiguating slug suffix. +export const ManyConnections: Story = { + render: function ManyConnectionsRender() { + const [value, setValue] = useState(choiceValue(MANY_CHOICES[0]!)); + return ( +
+ choiceValue(candidate) === value)?.label ?? value} + choices={MANY_CHOICES} + currentValue={value} + currentProviderType="openai" + renderProviderMark={providerMark} + onPick={(next) => { + const picked = MANY_CHOICES.find( + (candidate) => + candidate.connectionId === next.llmConnectionId && + candidate.connectionSlug === next.llmConnectionSlug && + candidate.model === next.model, + ); + if (picked) setValue(choiceValue(picked)); + }} + /> +
+ ); + }, + play: async ({ canvasElement }) => { + const trigger = within(canvasElement).getByRole('button', { + name: /选择新任务模型|Choose a model for the new task/, + }); + await userEvent.click(trigger); + const menu = within(document.body); + // Every connection is its own labelled group and the last group's model is + // reachable in the menu's accessibility tree. This drives the visual state; + // selection behaviour and scroll geometry are contracts left to focused + // tests / e2e, not asserted here. + const groups = await menu.findAllByRole('group'); + await expect(groups.length).toBeGreaterThanOrEqual(7); + await menu.findByRole('menuitem', { name: 'vendor/gamma' }); + }, +}; + +// Real path: a custom relay connection exposing verbose model identifiers with +// a long user-set connection name — very long text in the trigger, the option +// labels, and the descriptions at once. +export const LongModelNames: Story = { + render: () => ( +
+ undefined} + /> +
+ ), + play: async ({ canvasElement }) => { + const trigger = within(canvasElement).getByRole('button', { + name: /选择新任务模型|Choose a model for the new task/, + }); + await userEvent.click(trigger); + // Verifies the long-labelled model is reachable as a menuitem. Whether the + // long text truncates or wraps within the menu bounds is a visual check, + // not asserted here. + await within(document.body).findByRole('menuitem', { + name: /A very long model name that keeps going/, + }); + }, +}; + +// Real path: an existing Session whose connection is still configured but whose +// pinned model was dropped from that connection's catalog. ChatModelSwitcher +// surfaces the unknown current model as a leading row above the connection's +// remaining models (the `leadingOption` branch), labelled with the raw model id +// the session carries — not a hand-written label, and without removing the +// connection itself. +export const StaleCurrentModel: Story = { + render: function StaleCurrentModelRender() { + return ( +
+ undefined} + /> +
+ ); + }, + play: async ({ canvasElement }) => { + const trigger = within(canvasElement).getByRole('button', { + name: /切换当前任务模型|Switch model for this task/, + }); + await userEvent.click(trigger); + const menu = within(document.body); + // The dropped model leads the menu as the current selection… + await menu.findByRole('menuitem', { name: /claude-opus-3-retired/ }); + // …while its connection's remaining models still follow underneath. + await menu.findByRole('menuitem', { name: 'Claude Sonnet 4' }); + }, +}; From 1f95a3769c2edc2f6004a65c4c08294bc8079834 Mon Sep 17 00:00:00 2001 From: Xiao Liu Date: Sun, 30 Aug 2026 18:28:12 +0800 Subject: [PATCH 05/71] test(desktop): cover toast error and edge states (#4167) Exercise the production ToastProvider across diagnostics failures, destructive confirmation, long content, and dense notification stacks while preserving Astryx overlay behavior. --- packages/ui/stories/toast.stories.tsx | 127 ++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) diff --git a/packages/ui/stories/toast.stories.tsx b/packages/ui/stories/toast.stories.tsx index 58ebeaa7f2..90916e5b4d 100644 --- a/packages/ui/stories/toast.stories.tsx +++ b/packages/ui/stories/toast.stories.tsx @@ -111,3 +111,130 @@ export const ConfirmQueued: Story = { await expect(opener).toHaveFocus(); }, }; + +// The diagnostics affordance the provider wires onto every error toast — and +// the branch where opening it also fails, so the provider raises its own +// failure toast instead of swallowing the rejection. +const DIAGNOSTICS_ERROR_ACTION = { + label: '查看详情', + failureTitle: '无法打开诊断', + failureDescription: '日志服务未响应,请稍后重试。', + onClick: async () => { + throw new Error('diagnostics unavailable'); + }, +}; + +// Real trigger: any failed operation surfaced through `toast.error` (a failed +// save, an offline sync, an unauthorised request — this surface has one error +// affordance, so they all share its shape). A bare `error` variant never +// exercises the action button or its own failure path. +export const ErrorWithDiagnostics: Story = { + decorators: [ + (Story) => ( + + + + ), + ], + render: () => { + const toast = useToast(); + useEffect(() => { + toast.toast({ + title: '保存失败', + description: '网络中断,更改未同步。', + variant: 'error', + duration: 0, + }); + }, [toast]); + return
; + }, + play: async () => { + const page = within(document.body); + // The error toast carries the provider's diagnostics action… + const diagnostics = await page.findByRole('button', { name: '查看详情' }); + await userEvent.click(diagnostics); + // …and when that action itself fails, the provider surfaces its own + // failure toast rather than dropping the rejection on the floor. + await page.findByText('无法打开诊断'); + }, +}; + +function DestructiveConfirmExample() { + const toast = useToast(); + return ( +
+
+ ); +} + +// Real trigger: an irreversible action (delete a project / task). The confirm +// button wears the destructive variant and initial focus rests on cancel, so +// the dangerous path is never the default. +export const DestructiveConfirm: Story = { + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const page = within(document.body); + await userEvent.click(canvas.getByRole('button', { name: '删除项目' })); + const dialog = await page.findByRole('alertdialog', { name: '删除项目?' }); + await expect(within(dialog).getByRole('button', { name: '删除' })).toBeInTheDocument(); + // The least-destructive choice holds initial focus. + await expect(within(dialog).getByRole('button', { name: '取消' })).toHaveFocus(); + }, +}; + +const LONG_TOAST_TITLE = + '同步到远程工作区时发生了一个需要你注意的、标题本身就相当冗长的问题'; +const LONG_TOAST_DESCRIPTION = + '远程主机在多次重试后仍未确认写入:连接在握手阶段被重置,随后的退避重试也接连超时。' + + '本地更改已安全保留,你可以稍后重新同步;这段说明刻意写得很长,用来检验 toast 卡片在多行文本下是否仍然清晰,而不会把操作区挤出可视范围。'; + +// Very long title and body: the card must wrap legibly rather than clip text +// or shove the layout off-screen. +export const LongContent: Story = { + render: () => { + const toast = useToast(); + useEffect(() => { + toast.toast({ + title: LONG_TOAST_TITLE, + description: LONG_TOAST_DESCRIPTION, + variant: 'warning', + duration: 0, + }); + }, [toast]); + return
; + }, +}; + +// Many notifications at once — a burst of background results stacking up. +// Exercises the stack's density and whatever cap or scroll the layer applies, +// which a single seeded toast never shows. +export const ManyStacked: Story = { + render: () => { + const toast = useToast(); + useEffect(() => { + for (let i = 1; i <= 8; i += 1) { + toast.toast({ + title: `后台任务 ${i} 已完成`, + description: `第 ${i} 条通知的说明文字。`, + variant: VARIANTS[i % VARIANTS.length]!, + duration: 0, + }); + } + }, [toast]); + return
; + }, +}; From 2db04c2b8a43ebeb533c558acff0c7d311bc06ff Mon Sep 17 00:00:00 2001 From: Xiao Liu Date: Sun, 30 Aug 2026 18:28:48 +0800 Subject: [PATCH 06/71] fix(desktop): preserve external-session rows across source switches (#3905) Cache the loaded external-session catalog per source, archive filter, and search selection so revisits render immediately while Runtime Host remains the catalog authority and background refreshes stay generation-safe. --- .../import-tasks-settings-page.test.ts | 618 +++++++++++++++++- .../settings/import-tasks-settings-page.tsx | 172 ++++- 2 files changed, 765 insertions(+), 25 deletions(-) diff --git a/apps/desktop/src/main/__tests__/import-tasks-settings-page.test.ts b/apps/desktop/src/main/__tests__/import-tasks-settings-page.test.ts index 6b4cb62aeb..b69c9bf3bb 100644 --- a/apps/desktop/src/main/__tests__/import-tasks-settings-page.test.ts +++ b/apps/desktop/src/main/__tests__/import-tasks-settings-page.test.ts @@ -610,6 +610,577 @@ describe('ImportTasksSettingsPage durable import state', () => { }); }); +describe('ImportTasksSettingsPage source switching', () => { + const LOADING = /Reading external conversations/; + + it('shows the reading spinner the first time a source is opened', async () => { + let settle: ((r: CatalogResult) => void) | undefined; + const pending = new Promise((resolve) => { + settle = resolve; + }); + const harness = await renderPage({ + adapterIds: ['codex', 'claude-code'], + bySource: { + codex: [catalog(externalSession({ id: 's-codex', name: 'Codex conv' }))], + 'claude-code': [pending], + }, + }); + + assert.match(harness.container.textContent, /Codex conv/, 'codex loads on mount'); + assert.doesNotMatch(harness.container.textContent, LOADING, 'no spinner once codex is loaded'); + + const cc = segment(harness.container, 'claude-code'); + assert.ok(cc, 'claude-code segment renders'); + await act(async () => { + cc.click(); + await Promise.resolve(); + await Promise.resolve(); + }); + + // First visit to claude-code: nothing cached, so the blank + spinner shows. + assert.match(harness.container.textContent, LOADING, 'first-time load shows the spinner'); + assert.doesNotMatch(harness.container.textContent, /Codex conv/, 'codex rows are cleared'); + + await act(async () => { + settle?.(catalog(externalSession({ id: 's-cc', name: 'CC conv' }))); + await Promise.resolve(); + await Promise.resolve(); + }); + + assert.match(harness.container.textContent, /CC conv/, 'claude-code rows arrive'); + assert.doesNotMatch(harness.container.textContent, LOADING, 'spinner clears when loaded'); + + await act(async () => harness.root.unmount()); + }); + + it('shows a previously-loaded source instantly with no spinner, then refreshes in place', async () => { + let settleRevisit: ((r: CatalogResult) => void) | undefined; + const revisitRefresh = new Promise((resolve) => { + settleRevisit = resolve; + }); + const harness = await renderPage({ + adapterIds: ['codex', 'claude-code'], + bySource: { + // [initial load, background refresh on revisit] + codex: [catalog(externalSession({ id: 's-codex', name: 'Codex conv' })), revisitRefresh], + 'claude-code': [catalog(externalSession({ id: 's-cc', name: 'CC conv' }))], + }, + }); + + assert.match(harness.container.textContent, /Codex conv/); + + await act(async () => { + segment(harness.container, 'claude-code')!.click(); + await Promise.resolve(); + await Promise.resolve(); + }); + assert.match(harness.container.textContent, /CC conv/, 'claude-code loaded'); + + // Revisit codex: cached rows appear immediately with no blanking spinner + // (the background refresh is still pending here). + await act(async () => { + segment(harness.container, 'codex')!.click(); + await Promise.resolve(); + await Promise.resolve(); + }); + assert.match(harness.container.textContent, /Codex conv/, 'cached codex rows shown instantly'); + assert.doesNotMatch(harness.container.textContent, LOADING, 'no spinner on revisit'); + + await act(async () => { + settleRevisit?.(catalog(externalSession({ id: 's-codex', name: 'Codex conv refreshed' }))); + await Promise.resolve(); + await Promise.resolve(); + }); + assert.match(harness.container.textContent, /Codex conv refreshed/, 'background refresh lands'); + assert.doesNotMatch(harness.container.textContent, LOADING, 'still no spinner after refresh'); + + await act(async () => harness.root.unmount()); + }); + + it('does not let a stale background refresh overwrite a newer source selection', async () => { + let settleStaleCodexRefresh: ((r: CatalogResult) => void) | undefined; + const staleCodexRefresh = new Promise((resolve) => { + settleStaleCodexRefresh = resolve; + }); + const harness = await renderPage({ + adapterIds: ['codex', 'claude-code'], + bySource: { + codex: [catalog(externalSession({ id: 's-codex', name: 'Codex conv' })), staleCodexRefresh], + 'claude-code': [ + catalog(externalSession({ id: 's-cc', name: 'CC conv' })), + catalog(externalSession({ id: 's-cc', name: 'CC conv' })), + ], + }, + }); + + await act(async () => { + segment(harness.container, 'claude-code')!.click(); + await Promise.resolve(); + await Promise.resolve(); + }); + assert.match(harness.container.textContent, /CC conv/); + + // Revisit codex (cache hit → background refresh left pending)... + await act(async () => { + segment(harness.container, 'codex')!.click(); + await Promise.resolve(); + await Promise.resolve(); + }); + // ...then switch straight back to claude-code before that refresh resolves. + await act(async () => { + segment(harness.container, 'claude-code')!.click(); + await Promise.resolve(); + await Promise.resolve(); + }); + assert.match(harness.container.textContent, /CC conv/, 'claude-code is the current source'); + + await act(async () => { + settleStaleCodexRefresh?.( + catalog(externalSession({ id: 's-codex', name: 'Stale codex conv' })), + ); + await Promise.resolve(); + await Promise.resolve(); + }); + + assert.match(harness.container.textContent, /CC conv/, 'claude-code rows remain'); + assert.doesNotMatch( + harness.container.textContent, + /Stale codex conv/, + 'the superseded codex refresh never lands under claude-code', + ); + + await act(async () => harness.root.unmount()); + }); + + it('drops an in-flight import poll after switching source, with no stuck spinner', async (context) => { + context.mock.timers.enable({ apis: ['setTimeout'] }); + let settleStalePoll: ((r: CatalogResult) => void) | undefined; + const stalePoll = new Promise((resolve) => { + settleStalePoll = resolve; + }); + const importing = externalSession({ + id: 's-codex', + name: 'Codex conv', + importState: { importedCount: 0, importedSessionIds: [], isImporting: true }, + }); + const harness = await renderPage({ + adapterIds: ['codex', 'claude-code'], + bySource: { + // [initial load with an import in flight, background poll read left pending] + codex: [{ sessions: [importing], nextCursor: null }, stalePoll], + 'claude-code': [catalog(externalSession({ id: 's-cc', name: 'CC conv' }))], + }, + }); + assert.match(harness.container.textContent, /Codex conv/); + + // The importing row schedules a poll; fire it so refreshLoadedCatalog is in + // flight against the pending read. + await act(async () => { + context.mock.timers.runAll(); + await Promise.resolve(); + await Promise.resolve(); + }); + + // Switch to claude-code while the codex poll is still in flight. + await act(async () => { + segment(harness.container, 'claude-code')!.click(); + await Promise.resolve(); + await Promise.resolve(); + }); + assert.match(harness.container.textContent, /CC conv/, 'claude-code loaded'); + assert.doesNotMatch(harness.container.textContent, LOADING, 'no stuck reading spinner'); + + // The stale codex poll resolves last — it must not overwrite claude-code. + await act(async () => { + settleStalePoll?.({ + sessions: [externalSession({ id: 's-codex', name: 'Codex conv refreshed' })], + nextCursor: null, + }); + await Promise.resolve(); + await Promise.resolve(); + }); + assert.match(harness.container.textContent, /CC conv/, 'still showing claude-code'); + assert.doesNotMatch( + harness.container.textContent, + /Codex conv refreshed/, + 'stale poll result is dropped', + ); + assert.doesNotMatch(harness.container.textContent, LOADING, 'still no spinner'); + + await act(async () => harness.root.unmount()); + }); + + it('clears the reading spinner when a pending search returns to a cached term', async (context) => { + context.mock.timers.enable({ apis: ['setTimeout'] }); + // The uncached search never resolves, so its spinner generation stays in + // flight; the return-to-'' refresh never resolves either, so only the + // cache-hit path — not a completed refresh — can retire the spinner. + const pendingSearch = new Promise(() => {}); + const refreshPending = new Promise(() => {}); + const harness = await renderPage({ + // [initial '' load, uncached 'zzz' search, background refresh on return to ''] + catalogs: [ + catalog(externalSession({ id: 's-codex', name: 'Codex conv' })), + pendingSearch, + refreshPending, + ], + }); + assert.match(harness.container.textContent, /Codex conv/, 'initial load shows rows'); + + // Type an uncached term (the source and archived controls disable during a + // load, but the search box does not, so this is the reachable way to leave a + // request pending). It blanks to the spinner and never resolves. + await act(async () => { + setSearchInput(harness.container, 'zzz'); + await Promise.resolve(); + }); + // Fire the 250ms debounce only after the effect above has registered it. + await act(async () => { + context.mock.timers.runAll(); + await Promise.resolve(); + await Promise.resolve(); + }); + assert.match(harness.container.textContent, LOADING, 'uncached search shows the spinner'); + + // Return the search to the already-loaded empty term. The cached rows must + // come back with no spinner even though the older 'zzz' load is still + // pending and this hit's own refresh has not landed — the cache hit has to + // clear the stranded loading state itself. + await act(async () => { + setSearchInput(harness.container, ''); + await Promise.resolve(); + }); + await act(async () => { + context.mock.timers.runAll(); + await Promise.resolve(); + await Promise.resolve(); + }); + assert.match(harness.container.textContent, /Codex conv/, 'cached rows shown instantly'); + assert.doesNotMatch( + harness.container.textContent, + LOADING, + 'the stranded search spinner is cleared on the cache hit', + ); + + await act(async () => harness.root.unmount()); + }); + + it('clears a pending Load More lock when switching back to a cached source', async () => { + // Both revisit refreshes and the Load More append are left pending, so the + // only thing that can release the Load More lock is the cache-hit reset. + const codexRefreshPending = new Promise(() => {}); + const codexLoadMorePending = new Promise(() => {}); + const claudeCodeRefreshPending = new Promise(() => {}); + const harness = await renderPage({ + adapterIds: ['codex', 'claude-code'], + bySource: { + // [initial load (paged), revisit refresh, Load More append] + codex: [ + { sessions: [externalSession({ id: 's-codex', name: 'Codex conv' })], nextCursor: 'c1' }, + codexRefreshPending, + codexLoadMorePending, + ], + // [initial load (paged), revisit refresh] + 'claude-code': [ + { sessions: [externalSession({ id: 's-cc', name: 'CC conv' })], nextCursor: 'cc1' }, + claudeCodeRefreshPending, + ], + }, + }); + + // Load claude-code so it is cached with its own paged Load More. + await act(async () => { + segment(harness.container, 'claude-code')!.click(); + await Promise.resolve(); + await Promise.resolve(); + }); + assert.match(harness.container.textContent, /CC conv/, 'claude-code loaded'); + + // Revisit codex (cache hit; background refresh left pending), then start a + // Load More whose append never resolves so `loadingMore` stays set. + await act(async () => { + segment(harness.container, 'codex')!.click(); + await Promise.resolve(); + await Promise.resolve(); + }); + const codexLoadMore = buttonWithText(harness.container, 'Load more'); + assert.ok(codexLoadMore, 'codex Load More renders'); + await act(async () => { + codexLoadMore.click(); + await Promise.resolve(); + await Promise.resolve(); + }); + const busyLoadMore = Array.from( + harness.container.querySelectorAll('button'), + ).find((button) => button.textContent?.includes('Loading…')); + assert.ok(busyLoadMore, 'Load More shows the pending label while the append is in flight'); + + // Switch back to the cached claude-code before that append resolves. Its + // Load More must not inherit the stranded lock from codex's pending append. + await act(async () => { + segment(harness.container, 'claude-code')!.click(); + await Promise.resolve(); + await Promise.resolve(); + }); + assert.match(harness.container.textContent, /CC conv/, 'cached claude-code rows shown'); + const cachedLoadMore = buttonWithText(harness.container, 'Load more'); + assert.ok(cachedLoadMore, "claude-code's Load More is released, not stuck on 'Loading…'"); + assert.equal(cachedLoadMore.hasAttribute('disabled'), false, 'Load More is enabled again'); + + await act(async () => harness.root.unmount()); + }); + + it('keeps every loaded page when a revisited multi-page source refreshes', async () => { + const harness = await renderPage({ + adapterIds: ['codex', 'claude-code'], + bySource: { + // [page 1, page 2 via Load More, refresh page 1, refresh page 2] + codex: [ + { + sessions: [externalSession({ id: 's-codex-1', name: 'Codex page one' })], + nextCursor: 'codex-cursor-1', + }, + { sessions: [externalSession({ id: 's-codex-2', name: 'Codex page two' })], nextCursor: null }, + { + sessions: [externalSession({ id: 's-codex-1', name: 'Codex page one' })], + nextCursor: 'codex-cursor-1', + }, + { sessions: [externalSession({ id: 's-codex-2', name: 'Codex page two' })], nextCursor: null }, + ], + 'claude-code': [catalog(externalSession({ id: 's-cc', name: 'CC conv' }))], + }, + }); + + // Page in the second page of codex via Load More. + const loadMore = buttonWithText(harness.container, 'Load more'); + assert.ok(loadMore, 'codex has a second page to load'); + await act(async () => { + loadMore.click(); + await Promise.resolve(); + await Promise.resolve(); + }); + assert.match(harness.container.textContent, /Codex page one/); + assert.match(harness.container.textContent, /Codex page two/, 'both pages are loaded'); + + // Switch away to claude-code, then back to codex. + await act(async () => { + segment(harness.container, 'claude-code')!.click(); + await Promise.resolve(); + await Promise.resolve(); + }); + assert.match(harness.container.textContent, /CC conv/); + + await act(async () => { + segment(harness.container, 'codex')!.click(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + + // The cache hit shows both pages instantly; the background refresh must + // re-read the *whole* loaded window rather than shrink the list back to the + // first page. + assert.match(harness.container.textContent, /Codex page one/, 'first page kept'); + assert.match( + harness.container.textContent, + /Codex page two/, + 'the second page survives the background refresh', + ); + // codex page 1 + Load More + refresh page 1 + refresh page 2, plus the one + // claude-code load = 5. A first-page-only refresh would stop at 4. + assert.equal(harness.listCalls(), 5, 'the revisit refresh re-read every loaded page'); + + await act(async () => harness.root.unmount()); + }); + + it('does not start a second catalog read when revisiting a still-importing source', async (context) => { + context.mock.timers.enable({ apis: ['setTimeout'] }); + const importing = externalSession({ + id: 's-codex', + name: 'Codex conv', + importState: { importedCount: 0, importedSessionIds: [], isImporting: true }, + }); + const harness = await renderPage({ + adapterIds: ['codex', 'claude-code'], + bySource: { + codex: [{ sessions: [importing], nextCursor: null }], + 'claude-code': [catalog(externalSession({ id: 's-cc', name: 'CC conv' }))], + }, + }); + assert.match(harness.container.textContent, /Codex conv/); + assert.equal(harness.listCalls(), 1, 'codex loaded once on mount'); + + // Load claude-code (now cached), then return to the still-importing codex. + await act(async () => { + segment(harness.container, 'claude-code')!.click(); + await Promise.resolve(); + await Promise.resolve(); + }); + assert.equal(harness.listCalls(), 2, 'claude-code loaded'); + + await act(async () => { + segment(harness.container, 'codex')!.click(); + await Promise.resolve(); + await Promise.resolve(); + }); + // The cache hit shows the importing rows again, but must NOT kick off its own + // background readCatalogWindow: the 1s import poll is the single refresher for + // an importing selection, and a second concurrent read shares the same request + // generation and can land a stale pre-import page on top of a newer poll + // result (the timer is deliberately left un-fired here). + assert.match(harness.container.textContent, /Codex conv/, 'cached codex rows shown'); + assert.equal( + harness.listCalls(), + 2, + 'revisiting an importing source starts no second catalog read', + ); + + await act(async () => harness.root.unmount()); + }); + + it('updates the cache for a recovered import even after switching away', async () => { + let settleImport: ((r: { ok: false; reason: 'commit_outcome_unknown' }) => void) | undefined; + const importResult = new Promise<{ ok: false; reason: 'commit_outcome_unknown' }>((resolve) => { + settleImport = resolve; + }); + const codexRecovered = externalSession({ + id: 's-codex', + name: 'Codex conv', + importState: { importedCount: 1, importedSessionIds: ['codex-task'], isImporting: false }, + }); + // The revisit's background refresh never resolves, so the returned view is the + // cache alone — proving the cache itself holds the recovered state. + const codexRevisitPending = new Promise(() => {}); + const harness = await renderPage({ + adapterIds: ['codex', 'claude-code'], + bySource: { + // [mount, recovery readCatalogWindow, revisit background refresh] + codex: [ + { sessions: [externalSession({ id: 's-codex', name: 'Codex conv' })], nextCursor: null }, + { sessions: [codexRecovered], nextCursor: null }, + codexRevisitPending, + ], + 'claude-code': [catalog(externalSession({ id: 's-cc', name: 'CC conv' }))], + }, + importResult, + }); + + // Start an import on codex, then switch to claude-code before the (unknown) + // outcome resolves. + const importButton = harness.container.querySelector( + 'button[aria-label="Import Codex conv"]', + ); + assert.ok(importButton); + await act(async () => importButton.click()); + + await act(async () => { + segment(harness.container, 'claude-code')!.click(); + await Promise.resolve(); + await Promise.resolve(); + }); + assert.match(harness.container.textContent, /CC conv/); + + // The import comes back unknown; recovery confirms it landed while codex is not + // the current selection. + await act(async () => { + settleImport?.({ ok: false, reason: 'commit_outcome_unknown' }); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + assert.match(harness.container.textContent, /The imported task is available now/); + assert.match(harness.container.textContent, /CC conv/, 'the current view is untouched by recovery'); + + // Returning to codex must show the recovered "imported" state straight from + // the cache — not the stale pre-import row that would invite a duplicate + // import — even though the background refresh has not landed. + await act(async () => { + segment(harness.container, 'codex')!.click(); + await Promise.resolve(); + await Promise.resolve(); + }); + assert.match(harness.container.textContent, /Codex conv/); + assert.match( + harness.container.textContent, + /Imported once/, + 'the cache reflects the recovered import on return', + ); + + await act(async () => harness.root.unmount()); + }); + + it('publishes a recovered import to the current view after leaving and returning to its source', async () => { + let settleImport: ((r: { ok: false; reason: 'commit_outcome_unknown' }) => void) | undefined; + const importResult = new Promise<{ ok: false; reason: 'commit_outcome_unknown' }>((resolve) => { + settleImport = resolve; + }); + const codexRecovered = externalSession({ + id: 's-codex', + name: 'Codex conv', + importState: { importedCount: 1, importedSessionIds: ['codex-task'], isImporting: false }, + }); + // The revisit's own background refresh never resolves, so recovery is the only + // thing that can update the screen — proving recovery publishes rather than + // leaving the view to wait on a slow refresh. + const codexRevisitPending = new Promise(() => {}); + const harness = await renderPage({ + adapterIds: ['codex', 'claude-code'], + bySource: { + // [mount, revisit background refresh (pending), recovery readCatalogWindow] + codex: [ + { sessions: [externalSession({ id: 's-codex', name: 'Codex conv' })], nextCursor: null }, + codexRevisitPending, + { sessions: [codexRecovered], nextCursor: null }, + ], + 'claude-code': [catalog(externalSession({ id: 's-cc', name: 'CC conv' }))], + }, + importResult, + }); + + const importButton = harness.container.querySelector( + 'button[aria-label="Import Codex conv"]', + ); + assert.ok(importButton); + await act(async () => importButton.click()); + + // Switch to claude-code, then back to codex — all before the import resolves. + await act(async () => { + segment(harness.container, 'claude-code')!.click(); + await Promise.resolve(); + await Promise.resolve(); + }); + await act(async () => { + segment(harness.container, 'codex')!.click(); + await Promise.resolve(); + await Promise.resolve(); + }); + assert.match(harness.container.textContent, /Codex conv/, 'back on codex, pre-import rows shown'); + assert.doesNotMatch(harness.container.textContent, /Imported once/, 'not recovered yet'); + + // Recovery lands. codex is the current selection again, but at a *newer* + // generation than when the import started, so a generation check would refuse + // to publish. Matching the selection tuple, recovery must still reach the + // screen — not just the cache — even though the revisit refresh is pending. + await act(async () => { + settleImport?.({ ok: false, reason: 'commit_outcome_unknown' }); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + assert.match(harness.container.textContent, /The imported task is available now/); + assert.match( + harness.container.textContent, + /Imported once/, + 'recovery publishes to the returned-to view, not only the cache', + ); + + await act(async () => harness.root.unmount()); + }); +}); + function externalSession( overrides: Partial = {}, ): DesktopExternalSessionCatalogItem { @@ -626,6 +1197,10 @@ function externalSession( async function renderPage(options: { catalog?: CatalogResult; catalogs?: Array>; + // Multi-source tests: `listSources` reports these, and `list` draws per-source + // queues from `bySource` (keyed by adapterId) instead of the flat `catalogs`. + adapterIds?: string[]; + bySource?: Record>>; importResult?: | { ok: false; reason: 'commit_outcome_unknown' } | Promise<{ ok: false; reason: 'commit_outcome_unknown' }>; @@ -671,16 +1246,29 @@ async function renderPage(options: { host?: DesktopRuntimeHostRef; }> = []; const catalogs = options.catalogs ?? [options.catalog ?? { sessions: [], nextCursor: null }]; + const sourceCounts: Record = {}; (window as unknown as { maka: unknown }).maka = { externalSessions: { listSources: async (host?: DesktopRuntimeHostRef) => { hostCalls.push({ operation: 'listSources', host }); - return { adapterIds: ['codex'] }; + return { adapterIds: options.adapterIds ?? ['codex'] }; }, - list: async (input: { includeArchived?: boolean }, host?: DesktopRuntimeHostRef) => { + list: async ( + input: { includeArchived?: boolean; adapterId: string }, + host?: DesktopRuntimeHostRef, + ) => { hostCalls.push({ operation: 'list', host }); listInputs.push({ includeArchived: input.includeArchived === true }); - const result = catalogs[Math.min(listCalls++, catalogs.length - 1)]; + listCalls++; + if (options.bySource) { + const queue = options.bySource[input.adapterId] ?? [{ sessions: [], nextCursor: null }]; + const index = Math.min(sourceCounts[input.adapterId] ?? 0, queue.length - 1); + sourceCounts[input.adapterId] = (sourceCounts[input.adapterId] ?? 0) + 1; + const perSource = queue[index]; + if (perSource instanceof Error) throw perSource; + return perSource; + } + const result = catalogs[Math.min(listCalls - 1, catalogs.length - 1)]; if (result instanceof Error) throw result; return result; }, @@ -729,3 +1317,27 @@ function buttonWithText(container: HTMLElement, text: string): HTMLButtonElement (button) => button.textContent === text, ); } + +// Drives the search TextInput the way goal-dialog.test does: set the value and +// invoke the React onChange the renderer wired to it, so `searchDraft` updates +// without a real input event. The caller fires the debounce timer afterward. +function setSearchInput(container: HTMLElement, value: string): void { + const input = Array.from(container.querySelectorAll('input')).find( + (element) => element.type !== 'checkbox' && element.type !== 'radio', + ); + assert.ok(input, 'search input renders'); + input.value = value; + const propsKey = Object.keys(input).find((key) => key.startsWith('__reactProps$')); + assert.ok(propsKey, 'missing React props on the search input'); + const props = (input as unknown as Record)[propsKey] as { + onChange?: (event: { target: HTMLInputElement; defaultPrevented: boolean }) => void; + }; + assert.ok(props.onChange, 'missing search change handler'); + props.onChange({ target: input, defaultPrevented: false }); +} + +function segment(container: HTMLElement, value: string): HTMLButtonElement | undefined { + return Array.from( + container.querySelectorAll('button[role="radio"]'), + ).find((button) => button.getAttribute('data-value') === value); +} diff --git a/apps/desktop/src/renderer/settings/import-tasks-settings-page.tsx b/apps/desktop/src/renderer/settings/import-tasks-settings-page.tsx index c154954ef2..bd7740f55d 100644 --- a/apps/desktop/src/renderer/settings/import-tasks-settings-page.tsx +++ b/apps/desktop/src/renderer/settings/import-tasks-settings-page.tsx @@ -48,6 +48,36 @@ type CatalogState = { const EMPTY_CATALOG: CatalogState = { sessions: [], nextCursor: null }; const EXTERNAL_SESSION_IMPORT_POLL_MS = 1_000; +/** + * A loaded catalog is cached per (source, archived filter, search) so switching + * back to a source or filter already viewed shows its rows instantly instead of + * blanking to the "reading external conversations" spinner on every switch. The + * key is the same tuple `catalogSelectionRef` tracks, so a cache hit and a + * selection match always agree on what "this catalog" is. + */ +function catalogSelectionKey(adapterId: string, includeArchived: boolean, search: string): string { + return `${adapterId} ${includeArchived ? '1' : '0'} ${search}`; +} + +// A few dozen selections is plenty to make back-and-forth switching instant +// without letting a long search session grow the cache without bound. Re-insert +// on write so the oldest untouched selection is the one evicted. +const CATALOG_CACHE_LIMIT = 24; + +function writeCatalogCache( + cache: Map, + key: string, + value: CatalogState, +): void { + cache.delete(key); + cache.set(key, value); + while (cache.size > CATALOG_CACHE_LIMIT) { + const oldest = cache.keys().next().value; + if (oldest === undefined) break; + cache.delete(oldest); + } +} + type CatalogWindow = CatalogState & { targetSource: DesktopExternalSessionCatalogItem | undefined; }; @@ -114,7 +144,6 @@ type ImportAttempt = { importedCountBefore: number; latestImportedSessionIdBefore: string | undefined; loadedCatalogItemCountBefore: number; - catalogSelectionGeneration: number; }; type ImportRecovery = @@ -199,6 +228,16 @@ export function ImportTasksSettingsPage(props: { // source's rows under the new source's label. const requestGeneration = useRef(0); const recoveryGeneration = useRef(0); + // Last loaded catalog per selection key, so revisiting a source or filter is + // instant. Read/written only inside the async loaders; never rendered + // directly (the `catalog` state is what renders). + const catalogCacheRef = useRef(new Map()); + // Mirrors the committed `catalog` so a page append can extend the visible + // window without threading it through a stale render closure. + const catalogStateRef = useRef(catalog); + useEffect(() => { + catalogStateRef.current = catalog; + }, [catalog]); const catalogSelectionRef = useRef({ adapterId, includeArchived, search, generation: 0 }); if ( catalogSelectionRef.current.adapterId !== adapterId || @@ -231,6 +270,10 @@ export function ImportTasksSettingsPage(props: { setImportError(null); setAdapterIds([]); setAdapterId(null); + // A different host (or locale-driven remount) is a different catalog space; + // the cache keys carry neither, so drop everything rather than serve a + // previous host's rows. + catalogCacheRef.current.clear(); setCatalog(EMPTY_CATALOG); try { const result = await window.maka.externalSessions.listSources(host); @@ -252,8 +295,24 @@ export function ImportTasksSettingsPage(props: { async (sourceId: string, cursor?: string) => { const generation = ++requestGeneration.current; const append = cursor !== undefined; - if (append) setLoadingMore(true); - else { + const key = catalogSelectionKey(sourceId, includeArchived, search); + const cached = append ? undefined : catalogCacheRef.current.get(key); + if (append) { + setLoadingMore(true); + } else if (cached !== undefined) { + // Already loaded this selection once. Show it immediately and refresh in + // the background instead of blanking to the spinner on every switch. + setCatalog(cached); + // Clear any spinner/Load More lock left by a superseded request. A + // still-pending search or pagination load from the previous selection + // will never reach its own `finally` reset (its generation is now + // stale), so restoring cached rows without this would strand the + // full-page spinner or a disabled Load More over an otherwise complete + // view until this hit's background refresh happens to land. + setCatalogLoading(false); + setLoadingMore(false); + setImportRecovery(null); + } else { setCatalogLoading(true); setCatalog(EMPTY_CATALOG); setImportRecovery(null); @@ -261,17 +320,52 @@ export function ImportTasksSettingsPage(props: { setCatalogError(null); setImportError(null); try { - const result = await window.maka.externalSessions.list({ - adapterId: sourceId, - includeArchived, - ...(search ? { text: search } : {}), - ...(cursor === undefined ? {} : { cursor }), - }, host); - if (generation !== requestGeneration.current) return; - setCatalog((current) => ({ - sessions: append ? [...current.sessions, ...result.sessions] : result.sessions, - nextCursor: result.nextCursor, - })); + if (cached !== undefined) { + // While a revisited selection still shows an import in flight, the 1s + // import poll owns refreshing it. Starting our own readCatalogWindow + // here would run a second read under the *same* request generation as + // that poll, and a pre-import page read started here can land after a + // newer poll result and snap "Imported once" back to "Importing…". + // Leave the poll as the single refresher and keep the cached rows up. + if (cached.sessions.some((session) => session.importState.isImporting)) { + return; + } + // Refresh a revisited selection by re-reading the *whole* loaded + // window, not just page one: the cache can be several pages deep from + // Load More, and a bare first-page read here would overwrite it and + // silently drop every page the user already paged in. + const refreshed = await readCatalogWindow({ + adapterId: sourceId, + includeArchived, + text: search, + minimumItemCount: cached.sessions.length, + host, + isCurrent: () => mountedRef.current && generation === requestGeneration.current, + }); + if (refreshed === undefined) return; + const next: CatalogState = { + sessions: refreshed.sessions, + nextCursor: refreshed.nextCursor, + }; + writeCatalogCache(catalogCacheRef.current, key, next); + setCatalog(next); + } else { + const result = await window.maka.externalSessions.list({ + adapterId: sourceId, + includeArchived, + ...(search ? { text: search } : {}), + ...(cursor === undefined ? {} : { cursor }), + }, host); + if (generation !== requestGeneration.current) return; + const next: CatalogState = { + sessions: append + ? [...catalogStateRef.current.sessions, ...result.sessions] + : result.sessions, + nextCursor: result.nextCursor, + }; + writeCatalogCache(catalogCacheRef.current, key, next); + setCatalog(next); + } } catch (error) { if (generation !== requestGeneration.current) return; setCatalogError(localizedShellErrorMessage(error, copy.loadFailedFallback, locale)); @@ -282,12 +376,15 @@ export function ImportTasksSettingsPage(props: { } } }, - [copy.loadFailedFallback, host, includeArchived, locale, search], + [copy.loadFailedFallback, host, includeArchived, locale, mountedRef, search], ); const refreshLoadedCatalog = useCallback( async (sourceId: string, loadedItemCount: number) => { - const generation = ++requestGeneration.current; + // Observe, don't preempt: the poll captures the current generation rather + // than claiming a new one, so it defers to any in-flight authoritative + // load/recovery instead of retiring that load's own catalogLoading reset. + const generation = requestGeneration.current; try { const result = await readCatalogWindow({ adapterId: sourceId, @@ -297,7 +394,18 @@ export function ImportTasksSettingsPage(props: { host, isCurrent: () => mountedRef.current && generation === requestGeneration.current, }); - if (result !== undefined) setCatalog(result); + if (result !== undefined) { + const refreshed: CatalogState = { + sessions: result.sessions, + nextCursor: result.nextCursor, + }; + writeCatalogCache( + catalogCacheRef.current, + catalogSelectionKey(sourceId, includeArchived, search), + refreshed, + ); + setCatalog(refreshed); + } } catch { // Catalog polling is best-effort. Keep the last authoritative page // visible and retry rather than replacing it with a transient error. @@ -375,19 +483,40 @@ export function ImportTasksSettingsPage(props: { throw new Error('External Session source disappeared during import recovery'); } + const recoveredState: CatalogState = { + sessions: result.sessions, + nextCursor: result.nextCursor, + }; + // Always refresh the cache for the selection this attempt came from, even + // if the user has since switched source or filter. Recovery has confirmed + // the import landed; leaving the pre-import rows in the cache would greet + // the user with an "Import" button (not "Import again") when they return, + // inviting a duplicate import until a later background refresh corrects it. + writeCatalogCache( + catalogCacheRef.current, + catalogSelectionKey(attempt.adapterId, attempt.includeArchived, attempt.text), + recoveredState, + ); + + // Publish to screen whenever the user is currently viewing the same + // selection tuple this attempt came from. Match the tuple, not the + // generation captured at import time: navigating A→B→A lands back on the + // same selection with a *newer* generation, and a generation check would + // refuse to publish there — leaving a clickable "Import" on a row that + // already imported until a slow background refresh happened to catch up. const currentSelection = catalogSelectionRef.current; if ( currentSelection.adapterId === attempt.adapterId && currentSelection.includeArchived === attempt.includeArchived && - currentSelection.generation === attempt.catalogSelectionGeneration + currentSelection.search === attempt.text ) { // Recovery is the newest authoritative read for this exact catalog - // selection. Retire an older poll/load-more response before publishing - // it so that response cannot put pre-import state back on screen. + // selection. Retire an older poll/load-more/revisit response before + // publishing it so that response cannot put pre-import state back up. requestGeneration.current += 1; setCatalogLoading(false); setLoadingMore(false); - setCatalog(result); + setCatalog(recoveredState); } setUncertainImports((current) => current.filter( @@ -441,7 +570,6 @@ export function ImportTasksSettingsPage(props: { importedCountBefore: session.importState.importedCount, latestImportedSessionIdBefore: session.importState.importedSessionIds[0], loadedCatalogItemCountBefore: catalog.sessions.length, - catalogSelectionGeneration: catalogSelectionRef.current.generation, }; setActiveImport(attempt); setImportError(null); From 7eb1ea452846b5b8702f8a56c49d3b0827d31e5f Mon Sep 17 00:00:00 2001 From: Xiao Liu Date: Sun, 30 Aug 2026 18:29:11 +0800 Subject: [PATCH 07/71] fix(core): quarantine retired OpenCode Free model (#4216) Remove x-preview-f-free consistently from derived defaults, persisted picker synthesis, and send authorization while preserving live alternatives until upstream metadata catches up. --- .../src/__tests__/llm-connections.test.ts | 27 +++++++++++++++++ .../provider-catalog-contract.test.ts | 29 +++++++++++++++++++ packages/core/src/model-catalog.ts | 13 ++++++++- packages/core/src/provider-registry.ts | 11 ++++++- 4 files changed, 78 insertions(+), 2 deletions(-) diff --git a/packages/core/src/__tests__/llm-connections.test.ts b/packages/core/src/__tests__/llm-connections.test.ts index 1674479ed8..a258cb84e1 100644 --- a/packages/core/src/__tests__/llm-connections.test.ts +++ b/packages/core/src/__tests__/llm-connections.test.ts @@ -301,3 +301,30 @@ test('a quarantined model id is vetoed even when enabled and present in the inve id: 'nemotron-3-ultra-free', }); }); + +test('a quarantined stored default is dropped from the picker, not re-added as a missing-default row', () => { + // The retired `x-preview-f-free` was picker-visible before the quarantine, so + // an upgrade connection can carry it as `defaultModel` and enabled. `models` + // and `enabledModelIds` are filtered against `brokenModelIds`, but the raw + // `defaultModel` used to pass through unfiltered and `makeMissingDefaultEntry` + // re-added it as a selectable `provider_default` row — visible and pickable + // while `authorizeConnectionModel` vetoed the same id. The picker and the send + // authority must agree: neither offers it, and the live model still renders. + const connection = { + connectionId: 'connection-opencode-free', + slug: 'opencode-free', + name: 'OpenCode Free', + providerType: 'opencode-free' as ProviderType, + enabled: true, + defaultModel: 'x-preview-f-free', + enabledModelIds: ['x-preview-f-free', 'nemotron-3-ultra-free'], + models: [{ id: 'x-preview-f-free' }, { id: 'nemotron-3-ultra-free' }], + modelSource: 'fetched' as const, + createdAt: 1, + updatedAt: 1, + }; + const models = buildChatModelChoices([connection]).map(({ model }) => model); + assert.ok(!models.includes('x-preview-f-free'), 'quarantined default must not be offered'); + assert.ok(models.includes('nemotron-3-ultra-free'), 'live enabled model still renders'); + assert.equal(authorizeConnectionModel(connection, 'x-preview-f-free'), undefined); +}); diff --git a/packages/core/src/__tests__/provider-catalog-contract.test.ts b/packages/core/src/__tests__/provider-catalog-contract.test.ts index ef03f43fa2..2db51dde6a 100644 --- a/packages/core/src/__tests__/provider-catalog-contract.test.ts +++ b/packages/core/src/__tests__/provider-catalog-contract.test.ts @@ -213,3 +213,32 @@ describe('provider catalog contract — fallback lifecycle', () => { assert.deepEqual(regressed, []); }); }); + +describe('opencode-free retired-model quarantine', () => { + const opencodeFree = PROVIDER_REGISTRY['opencode-free']; + + // Ox Alpha Free (x-preview-f-free) was retired upstream on OpenCode Zen while + // models.dev still snapshots it free+active, so the derivation would keep + // offering it as a default-enabled, picker-visible row. Remove this assertion + // together with the OPENCODE_FREE_BROKEN_MODEL_IDS entry once the snapshot + // marks it deprecated (or upstream serves it again). + it('quarantines x-preview-f-free out of the offered free models', () => { + assert.ok(opencodeFree.brokenModelIds?.includes('x-preview-f-free')); + assert.ok(!(opencodeFree.fallbackModels ?? []).includes('x-preview-f-free')); + assert.ok(!(opencodeFree.defaultEnabledModelIds ?? []).includes('x-preview-f-free')); + }); + + // Mechanism guard, independent of which ids the deny-list holds: a quarantined + // id must never leak back into the offered candidates through either path. + it('never offers a quarantined broken id as a free candidate', () => { + const broken = new Set(opencodeFree.brokenModelIds ?? []); + const offered = [ + ...(opencodeFree.fallbackModels ?? []), + ...(opencodeFree.defaultEnabledModelIds ?? []), + ]; + assert.deepEqual( + offered.filter((id) => broken.has(id)), + [], + ); + }); +}); diff --git a/packages/core/src/model-catalog.ts b/packages/core/src/model-catalog.ts index 72a56c67a4..ed2a5c7751 100644 --- a/packages/core/src/model-catalog.ts +++ b/packages/core/src/model-catalog.ts @@ -264,10 +264,21 @@ export function buildConnectionModelCatalogEntries( const fallbackModels = [...(catalogFallbackModels ?? defaults.fallbackModels)].filter( (id) => !broken.has(id), ); + // A quarantined id persisted as this connection's `defaultModel` must not + // re-enter the catalog either. `models` and `enabledModelIds` are filtered + // below, but a broken default reaches `makeMissingDefaultEntry` unfiltered and + // would be re-added as a selectable `provider_default` row — picker-visible + // and default-capable while `authorizeConnectionModel` vetoes the same id. A + // reachable persisted state: the id was picker-visible before the quarantine. + // Dropping it leaves the connection with no valid default (readiness reports + // `missing_model`), which is what a model that can no longer send warrants. + const defaultModel = broken.has((connection.defaultModel ?? '').trim()) + ? undefined + : connection.defaultModel; return buildModelCatalogEntries({ providerType: connection.providerType, connectionSlug: connection.slug, - defaultModel: connection.defaultModel, + defaultModel, models: connection.models?.filter(({ id }) => !broken.has(id)), modelSource: connection.modelSource, modelsFetchedAt: connection.modelsFetchedAt, diff --git a/packages/core/src/provider-registry.ts b/packages/core/src/provider-registry.ts index 36c3c44d0a..b14019357b 100644 --- a/packages/core/src/provider-registry.ts +++ b/packages/core/src/provider-registry.ts @@ -673,7 +673,16 @@ const opencodeGoModelIds = toolCallingModelIds( // probes, max_tokens 8–200) — a failure shape that even "the send settles it" // cannot surface, which is why these ids are also vetoed in // `authorizeConnectionModel` rather than merely dropped from this derivation. -const OPENCODE_FREE_BROKEN_MODEL_IDS = new Set(['muse-spark-1.2-contributor-free']); +// 2026-08-30 x-preview-f-free (Ox Alpha Free): retired upstream — dropped from +// the anonymous /models listing and every completion returns HTTP 401 +// {"type":"ModelError","message":"Model x-preview-f-free is not supported"}. +// models.dev still snapshots it as free+active, so the derivation kept offering +// it as a default-enabled, picker-visible row until this quarantine. Remove +// once the snapshot marks it deprecated (or upstream serves it again). +const OPENCODE_FREE_BROKEN_MODEL_IDS = new Set([ + 'muse-spark-1.2-contributor-free', + 'x-preview-f-free', +]); const opencodeFreeModelIds = toolCallingModelIds( 'OpenCode Free', Object.fromEntries( From 2e2c552c7c0b9c7473e1ee6070f95327e5adca0b Mon Sep 17 00:00:00 2001 From: Xiao Liu Date: Sun, 30 Aug 2026 18:29:35 +0800 Subject: [PATCH 08/71] test(desktop): cover change panel failure and edge states (#4169) Exercise the production Workbar change panel across empty, failure, source-truncated, file-truncated, and edge-diff states while keeping each truncation authority distinct. --- .../stories/session-workbar.stories.tsx | 267 +++++++++++++++++- 1 file changed, 262 insertions(+), 5 deletions(-) diff --git a/apps/desktop/stories/session-workbar.stories.tsx b/apps/desktop/stories/session-workbar.stories.tsx index 2efa3082ea..77039bc2a1 100644 --- a/apps/desktop/stories/session-workbar.stories.tsx +++ b/apps/desktop/stories/session-workbar.stories.tsx @@ -22,7 +22,7 @@ import type { Decorator, Meta, StoryObj } from '@storybook/react-vite'; import { expect, userEvent, waitFor, within } from 'storybook/test'; import type { ArtifactRecord } from '@maka/core/artifacts'; import type { BrowserState } from '@maka/core/browser'; -import type { GitReviewSnapshot } from '@maka/core/git-review'; +import type { GitReviewReadResult, GitReviewSnapshot } from '@maka/core/git-review'; import type { SessionSummary } from '@maka/core/session'; import type { Task } from '@maka/core/task-ledger'; import type { SessionTrace } from '@maka/core/session-trace'; @@ -289,6 +289,169 @@ const gitReviewSnapshot: GitReviewSnapshot = { files: gitReviewFiles, }; +// A session whose branch matches its base — the panel's own empty state, not an +// error and not a spinner. +const emptyGitReviewSnapshot: GitReviewSnapshot = { + ...gitReviewSnapshot, + revision: 'storybook-git-review-empty', + additions: 0, + deletions: 0, + truncated: false, + files: [], +}; + +// Two independent truncation authorities, kept in separate stories: the +// snapshot-level `truncated` flag (the source dropped files from a huge +// changeset) and the per-file 500-line `boundedDiff` cap (one file's body is +// clipped). A single large file only trips the latter, so they are not merged. +const largeAddedFilePath = 'src/generated/catalog.ts'; +const largeGitReviewFile: GitReviewSnapshot['files'][number] = { + path: largeAddedFilePath, + status: 'added', + additions: 620, + deletions: 0, + diff: [ + `diff --git a/${largeAddedFilePath} b/${largeAddedFilePath}`, + 'new file mode 100644', + 'index 0000000..a1b2c3d', + '--- /dev/null', + `+++ b/${largeAddedFilePath}`, + '@@ -0,0 +1,620 @@', + ...Array.from({ length: 620 }, (_, index) => `+ export const ENTRY_${index} = ${index};`), + ].join('\n'), +}; +// Per-file cap only: one 620-line file, snapshot NOT truncated. +const fileLineCapGitReviewSnapshot: GitReviewSnapshot = { + ...gitReviewSnapshot, + revision: 'storybook-git-review-file-cap', + additions: largeGitReviewFile.additions, + deletions: 0, + truncated: false, + files: [largeGitReviewFile], +}; +// Source-level truncation: a large changeset whose file list the source capped, +// so `truncated` is set and the panel shows its 变化过多 banner. Each file is +// ordinary — no single file trips the per-file cap here. +const sourceTruncatedFiles: GitReviewSnapshot['files'] = Array.from({ length: 24 }, (_, index) => { + const path = `src/feature-${String(index).padStart(2, '0')}.ts`; + return { + path, + status: 'modified', + additions: 3, + deletions: 1, + diff: [ + `diff --git a/${path} b/${path}`, + `--- a/${path}`, + `+++ b/${path}`, + '@@ -1,2 +1,4 @@', + ' import { register } from "./registry";', + `+export const FLAG_${index} = true;`, + `-const legacy${index} = null;`, + ].join('\n'), + }; +}); +const sourceTruncatedGitReviewSnapshot: GitReviewSnapshot = { + ...gitReviewSnapshot, + revision: 'storybook-git-review-truncated', + additions: sourceTruncatedFiles.reduce((total, file) => total + file.additions, 0), + deletions: sourceTruncatedFiles.reduce((total, file) => total + file.deletions, 0), + truncated: true, + files: sourceTruncatedFiles, +}; + +// The diff shapes that never appear in a routine "a few lines changed" review: +// a binary blob, a rename with no body, a deletion, a no-newline-at-EOF marker, +// and a single pathologically long minified line. Each is just a different +// unified-diff string handed to the same DiffCodePreview. +const edgeGitReviewFiles: GitReviewSnapshot['files'] = [ + { + path: 'assets/logo.png', + status: 'modified', + additions: 0, + deletions: 0, + diff: [ + 'diff --git a/assets/logo.png b/assets/logo.png', + 'index e69de29..d95f3ad 100644', + 'Binary files a/assets/logo.png and b/assets/logo.png differ', + ].join('\n'), + }, + { + path: 'src/renamed-module.ts', + previousPath: 'src/old-module.ts', + status: 'renamed', + additions: 1, + deletions: 1, + diff: [ + 'diff --git a/src/old-module.ts b/src/renamed-module.ts', + 'similarity index 92%', + 'rename from src/old-module.ts', + 'rename to src/renamed-module.ts', + '--- a/src/old-module.ts', + '+++ b/src/renamed-module.ts', + '@@ -1,3 +1,3 @@', + ' import { foo } from "./foo";', + '-export const NAME = "old";', + '+export const NAME = "renamed";', + ' export default NAME;', + ].join('\n'), + }, + { + path: 'src/deprecated.ts', + status: 'deleted', + additions: 0, + deletions: 3, + diff: [ + 'diff --git a/src/deprecated.ts b/src/deprecated.ts', + 'deleted file mode 100644', + 'index 1a2b3c4..0000000', + '--- a/src/deprecated.ts', + '+++ /dev/null', + '@@ -1,3 +0,0 @@', + '-export function legacy() {', + '- return true;', + '-}', + ].join('\n'), + }, + { + path: 'config/version', + status: 'modified', + additions: 1, + deletions: 1, + diff: [ + 'diff --git a/config/version b/config/version', + '--- a/config/version', + '+++ b/config/version', + '@@ -1 +1 @@', + '-1.2.3', + '\\ No newline at end of file', + '+1.2.4', + '\\ No newline at end of file', + ].join('\n'), + }, + { + path: 'src/minified.bundle.js', + status: 'modified', + additions: 1, + deletions: 0, + diff: [ + 'diff --git a/src/minified.bundle.js b/src/minified.bundle.js', + '--- a/src/minified.bundle.js', + '+++ b/src/minified.bundle.js', + '@@ -1,1 +1,2 @@', + ' /* build output */', + `+const PAYLOAD="${'a'.repeat(1800)}";`, + ].join('\n'), + }, +]; +const edgeGitReviewSnapshot: GitReviewSnapshot = { + ...gitReviewSnapshot, + revision: 'storybook-git-review-edge', + additions: edgeGitReviewFiles.reduce((total, file) => total + file.additions, 0), + deletions: edgeGitReviewFiles.reduce((total, file) => total + file.deletions, 0), + truncated: false, + files: edgeGitReviewFiles, +}; + const populatedTrace: SessionTrace = { schemaVersion: 1, sessionId: SESSION_ID, @@ -634,6 +797,10 @@ function bridge(options: { browserState?: BrowserState; /** Make `browser.navigate` reject, so a valid address surfaces the navigation-failed toast. */ browserNavigateFails?: boolean; + /** The git-review read result the 变更 panel receives (empty / source error / truncated / edge diffs). */ + review?: GitReviewReadResult; + /** Make `review.read` reject, so the panel shows its load-error banner. */ + reviewFail?: boolean; } = {}): Decorator { const browserState = options.browserState ?? EMPTY_BROWSER_STATE; const services = createFakeWorkbarServices({ @@ -684,10 +851,10 @@ function bridge(options: { subscribeSessionEvents: unsubscribe, }, review: { - read: async () => ({ - ok: true, - snapshot: gitReviewSnapshot, - }), + read: async () => { + if (options.reviewFail) throw new Error('读取变更失败:无法运行 git diff'); + return options.review ?? { ok: true, snapshot: gitReviewSnapshot }; + }, subscribeSessionEvents: unsubscribe, }, terminal: { @@ -862,6 +1029,96 @@ export const Changes: Story = { render: () => , }; +// Real path: 任务工作栏 → 变更 on a session whose branch matches its base. The +// panel's own empty state (icon + help), not a spinner and not an error. +export const ChangesEmpty: Story = { + decorators: [bridge({ review: { ok: true, snapshot: emptyGitReviewSnapshot } })], + render: () => , + play: async ({ canvasElement }) => { + await within(canvasElement).findByText('当前 Git 工作区没有变化'); + }, +}; + +// Real path: 任务工作栏 → 变更 when `review.read` rejects (the git command +// failed); the error takes a Banner with 重试, the same shape as +// TasksLoadFailed — not an empty state. +export const ChangesLoadFailed: Story = { + decorators: [bridge({ reviewFail: true })], + render: () => , + play: async ({ canvasElement }) => { + await within(canvasElement).findByRole('button', { name: '重试' }); + }, +}; + +// Real path: 任务工作栏 → 变更 when the session cwd is not a Git repository. A +// source that cannot be read is a failure (error Banner + 重试), not an +// absence — the other read reasons (workspace unavailable, unborn repo, +// invalid base branch, git failed) share this branch. +export const ChangesSourceNotGit: Story = { + decorators: [bridge({ review: { ok: false, reason: 'not_git_repository' } })], + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await canvas.findByText('当前任务目录不是 Git 仓库'); + await canvas.findByRole('button', { name: '重试' }); + }, +}; + +// Real path: 任务工作栏 → 变更 on a changeset so large the source capped its +// file list — the snapshot-level 变化过多 banner. The per-file line cap is a +// separate authority (see ChangesFileLineCap), not combined here. +export const ChangesTruncated: Story = { + decorators: [bridge({ review: { ok: true, snapshot: sourceTruncatedGitReviewSnapshot } })], + render: () => , + play: async ({ canvasElement }) => { + await within(canvasElement).findByText('变化过多,仅显示前一部分文件'); + }, +}; + +// Real path: 任务工作栏 → 变更 with one file whose body runs past the 500-line +// cap — expanding it clips the body and notes the hidden remainder. The +// snapshot is NOT truncated, so only the per-file authority fires (no 变化过多 +// banner). +export const ChangesFileLineCap: Story = { + decorators: [bridge({ review: { ok: true, snapshot: fileLineCapGitReviewSnapshot } })], + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const file = await canvas.findByText('src/generated/catalog.ts'); + expect(canvas.queryByText('变化过多,仅显示前一部分文件')).toBeNull(); + await userEvent.click(file); + await canvas.findByText(/另有 \d+ 行未显示/); + }, +}; + +// Real path: 任务工作栏 → 变更 across the diff shapes a routine review never +// shows — binary, rename, deletion, no-newline-at-EOF, and a very long minified +// line — each rendered by the same DiffCodePreview. +export const ChangesEdgeContent: Story = { + decorators: [bridge({ review: { ok: true, snapshot: edgeGitReviewSnapshot } })], + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + // Expand each edge file in turn (the group is single-open) and confirm its + // OWN body renders through the diff surface — scoped to that file's row, + // since collapsed rows keep their diff mounted. + const expectDiffBody = async (path: string, marker: string) => { + await userEvent.click(await canvas.findByText(path)); + await waitFor(() => { + const row = Array.from( + canvasElement.querySelectorAll('.maka-session-review-file'), + ).find((el) => el.textContent?.includes(path)); + expect(row?.querySelector('.maka-session-review-diff')?.textContent ?? '').toContain(marker); + }); + }; + await expectDiffBody('assets/logo.png', 'Binary files a/assets/logo.png and b/assets/logo.png differ'); + await expectDiffBody('src/renamed-module.ts', 'rename from src/old-module.ts'); + await expectDiffBody('src/deprecated.ts', 'export function legacy'); + await expectDiffBody('config/version', 'No newline at end of file'); + await expectDiffBody('src/minified.bundle.js', 'PAYLOAD'); + }, +}; + // Real path: 任务工作栏 → 终端 after Desktop has created a PTY resource. The // service fake hydrates the real xterm surface without an Electron bridge. export const Terminal: Story = { From 19e211e5654a3f3f8e1a1be2ed4c69605475a162 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 30 Aug 2026 18:51:23 +0800 Subject: [PATCH 09/71] fix: align Desktop Nightly with the Infra rsync contract Remove the unsupported known_hosts prerequisite and align Desktop Nightly publishing with the five rsync secrets Apache Infra provides. Keep historical cleanup project-owned and lock the transport contract with regression coverage. Generated-by: Codex --- .github/DESKTOP_NIGHTLY.md | 6 +++--- .github/workflows/desktop-nightly.yml | 7 ++----- scripts/desktop-nightly-workflow-policy.test.mjs | 16 ++++++++++------ 3 files changed, 15 insertions(+), 14 deletions(-) diff --git a/.github/DESKTOP_NIGHTLY.md b/.github/DESKTOP_NIGHTLY.md index 6e0661ce9e..f3f701eacb 100644 --- a/.github/DESKTOP_NIGHTLY.md +++ b/.github/DESKTOP_NIGHTLY.md @@ -27,13 +27,13 @@ Nightly currently uses the same application identity as the formal Desktop. Inst ## One-time setup -1. Ask Apache Infra to allow `apache/maka` to publish GitHub Actions output to `nightlies.apache.org`, provide the SSH `known_hosts` entry through an authenticated channel, and confirm whether retention is service-managed or requires a separate project cleanup job. Do not enable scheduled publication until that retention owner is explicit. -2. Create a GitHub Environment named `nightly` that permits only `main`. Store `NIGHTLIES_RSYNC_PATH`, `NIGHTLIES_RSYNC_HOST`, `NIGHTLIES_RSYNC_PORT`, `NIGHTLIES_RSYNC_USER`, `NIGHTLIES_RSYNC_KEY`, and the Infra-verified `NIGHTLIES_RSYNC_KNOWN_HOSTS` value as Environment secrets. Configure its macOS signing and notarization secrets: `CSC_LINK`, `CSC_KEY_PASSWORD`, `APPLE_API_KEY`, `APPLE_API_KEY_ID`, and `APPLE_API_ISSUER`. Do not expose these secrets to repository-wide or pull-request workflows. +1. Ask Apache Infra to allow `apache/maka` to publish GitHub Actions output to `nightlies.apache.org` and whitelist the repository for the standard `NIGHTLIES_RSYNC_HOST`, `NIGHTLIES_RSYNC_KEY`, `NIGHTLIES_RSYNC_PATH`, `NIGHTLIES_RSYNC_PORT`, and `NIGHTLIES_RSYNC_USER` secrets. +2. Create a GitHub Environment named `nightly` that permits only `main`. Verify that its jobs can read the five Infra-provided Nightlies secrets. Configure its macOS signing and notarization secrets: `CSC_LINK`, `CSC_KEY_PASSWORD`, `APPLE_API_KEY`, `APPLE_API_KEY_ID`, and `APPLE_API_ISSUER`. Do not expose these secrets to repository-wide or pull-request workflows. 3. Configure npm Trusted Publishing for `apache/maka` and `.github/workflows/npm-publication.yml`, restricted to the `npm-publication` Environment and with both `npm publish` and `npm stage publish` allowed. Do not create or store a long-lived npm token. 4. After npm Trusted Publishing is ready, set `NPM_NIGHTLY_ENABLED` to `true`, run `npm publication` from `main` with `channel=nightly`, and verify the exact npm version and `nightly` dist-tag. This does not depend on Desktop Infra. 5. After Infra publishing and the `nightly` Environment secrets are ready, set `DESKTOP_NIGHTLY_ENABLED` to `true` and start a fresh npm Nightly. Confirm that its successful run triggers `Desktop Nightly`. 6. Verify the download page, `latest-mac.yml`, and `latest.yml` under `https://nightlies.apache.org/maka/desktop/`, install both platform artifacts on clean machines, and confirm one automatic update before sharing the channel with testers. -The npm schedule starts at 18:17 UTC. Before changing the npm tag, the workflow requires its run number to exceed the current `nightly` version. Desktop applies the same check against both remote feed files before uploading anything. It then appends a new immutable Desktop version directory and advances the mutable update metadata last. Each platform feed file is replaced independently after its complete payload exists, so an interrupted feed transfer may temporarily leave macOS and Windows on different valid Nightly versions. Do not rerun a failed workflow attempt in place; start a fresh npm Nightly so it receives a new version. Historical payload cleanup is separate from publication, targets the Nightlies retention policy, and must never rewrite a published version or delete one referenced by a feed. Apache Nightlies storage is temporary; it must not be used as a formal release archive. +The npm schedule starts at 18:17 UTC. Before changing the npm tag, the workflow requires its run number to exceed the current `nightly` version. Desktop applies the same check against both remote feed files before uploading anything. It then appends a new immutable Desktop version directory and advances the mutable update metadata last. Each platform feed file is replaced independently after its complete payload exists, so an interrupted feed transfer may temporarily leave macOS and Windows on different valid Nightly versions. Do not rerun a failed workflow attempt in place; start a fresh npm Nightly so it receives a new version. The Maka project owns historical payload cleanup separately from publication. Cleanup must never rewrite a published version or delete one referenced by a feed. Apache Nightlies storage is temporary; it must not be used as a formal release archive. Remote Runtime Host setup uses the exact `maka-agent@` package embedded in the Desktop manifest. The npm package is verified before Desktop artifacts become visible, so clean remote setup never depends on an unpublished Runtime Host version. diff --git a/.github/workflows/desktop-nightly.yml b/.github/workflows/desktop-nightly.yml index b763d8149d..70ab7e04a7 100644 --- a/.github/workflows/desktop-nightly.yml +++ b/.github/workflows/desktop-nightly.yml @@ -293,14 +293,12 @@ jobs: env: NIGHTLIES_RSYNC_HOST: ${{ secrets.NIGHTLIES_RSYNC_HOST }} NIGHTLIES_RSYNC_KEY: ${{ secrets.NIGHTLIES_RSYNC_KEY }} - NIGHTLIES_RSYNC_KNOWN_HOSTS: ${{ secrets.NIGHTLIES_RSYNC_KNOWN_HOSTS }} NIGHTLIES_RSYNC_PATH: ${{ secrets.NIGHTLIES_RSYNC_PATH }} NIGHTLIES_RSYNC_PORT: ${{ secrets.NIGHTLIES_RSYNC_PORT }} NIGHTLIES_RSYNC_USER: ${{ secrets.NIGHTLIES_RSYNC_USER }} run: | test -n "$NIGHTLIES_RSYNC_HOST" test -n "$NIGHTLIES_RSYNC_KEY" - test -n "$NIGHTLIES_RSYNC_KNOWN_HOSTS" test -n "$NIGHTLIES_RSYNC_PATH" test -n "$NIGHTLIES_RSYNC_USER" [[ "$NIGHTLIES_RSYNC_PORT" =~ ^[0-9]{1,5}$ ]] @@ -309,10 +307,9 @@ jobs: install -m 700 -d "$ssh_directory" umask 077 printf '%s\n' "$NIGHTLIES_RSYNC_KEY" > "$ssh_directory/key" - printf '%s\n' "$NIGHTLIES_RSYNC_KNOWN_HOSTS" > "$ssh_directory/known_hosts" { echo "NIGHTLIES_RSYNC_TARGET=$NIGHTLIES_RSYNC_USER@$NIGHTLIES_RSYNC_HOST:${NIGHTLIES_RSYNC_PATH%/}/maka/desktop" - echo "RSYNC_RSH=ssh -i $ssh_directory/key -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=$ssh_directory/known_hosts -p $NIGHTLIES_RSYNC_PORT" + echo "RSYNC_RSH=ssh -i $ssh_directory/key -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p $NIGHTLIES_RSYNC_PORT" } >> "$GITHUB_ENV" - name: Require the Desktop Nightly feed to advance @@ -338,4 +335,4 @@ jobs: - name: Remove the temporary Nightlies credentials if: always() - run: rm -f "$RUNNER_TEMP/nightlies-ssh/key" "$RUNNER_TEMP/nightlies-ssh/known_hosts" + run: rm -f "$RUNNER_TEMP/nightlies-ssh/key" diff --git a/scripts/desktop-nightly-workflow-policy.test.mjs b/scripts/desktop-nightly-workflow-policy.test.mjs index c1897c6f4d..ffeb68fcc3 100644 --- a/scripts/desktop-nightly-workflow-policy.test.mjs +++ b/scripts/desktop-nightly-workflow-policy.test.mjs @@ -120,12 +120,16 @@ test('the protected Desktop publisher appends payloads before advancing the feed (step) => step.name === 'Prepare authenticated Nightlies SSH transport', ); assert.equal(transport.env.NIGHTLIES_RSYNC_KEY, '${{ secrets.NIGHTLIES_RSYNC_KEY }}'); - assert.equal( - transport.env.NIGHTLIES_RSYNC_KNOWN_HOSTS, - '${{ secrets.NIGHTLIES_RSYNC_KNOWN_HOSTS }}', - ); - assert.match(transport.run, /StrictHostKeyChecking=yes/u); - assert.doesNotMatch(transport.run, /ssh-keyscan|StrictHostKeyChecking=no/u); + assert.deepEqual(Object.keys(transport.env).toSorted(), [ + 'NIGHTLIES_RSYNC_HOST', + 'NIGHTLIES_RSYNC_KEY', + 'NIGHTLIES_RSYNC_PATH', + 'NIGHTLIES_RSYNC_PORT', + 'NIGHTLIES_RSYNC_USER', + ]); + assert.match(transport.run, /StrictHostKeyChecking=no/u); + assert.match(transport.run, /UserKnownHostsFile=\/dev\/null/u); + assert.doesNotMatch(transport.run, /ssh-keyscan|KNOWN_HOSTS/u); const steps = publish.steps; const positions = [ 'Attest the exact Nightly payloads', From 2b82b9d087666b121377a54a5f66850721c0b32e Mon Sep 17 00:00:00 2001 From: Guangdong Liu Date: Sun, 30 Aug 2026 19:14:50 +0800 Subject: [PATCH 10/71] fix(runtime): fall back when Codex compaction is rejected (#4253) Keep native Codex compaction preferred and use the existing text summarizer once when a non-retryable compaction rejection occurs. --- ...-compaction-events-log-projection-draft.md | 10 ++- ...ction-events-log-projection-draft.zh-CN.md | 10 ++- .../execution-model-composition.test.ts | 84 ++++++++++++++++--- .../src/server/execution-model-composition.ts | 29 ++++--- .../openai-codex-history-compactor.test.ts | 66 +++++++++++++++ .../provider-request-telemetry.test.ts | 58 +++++++++++++ .../runtime/src/history-compact-summarizer.ts | 1 + .../src/openai-codex-history-compactor.ts | 53 +++++++++++- .../runtime/src/provider-request-telemetry.ts | 18 +++- 9 files changed, 294 insertions(+), 35 deletions(-) diff --git a/docs/architecture/llm-compaction-events-log-projection-draft.md b/docs/architecture/llm-compaction-events-log-projection-draft.md index 20ce170beb..406941ed7f 100644 --- a/docs/architecture/llm-compaction-events-log-projection-draft.md +++ b/docs/architecture/llm-compaction-events-log-projection-draft.md @@ -38,7 +38,7 @@ This chapter builds on Chapter 1's log-first Runtime and Chapter 2's distinction The primary subject is **RuntimeEvent history compaction**: a compactor produces either a continuation summary or provider-native compact state, the checkpoint covers a safe prefix of RuntimeEvents, and later requests use that projection in place of the prefix. The same planner and checkpoint transaction serve manual, pre-turn, mid-turn, and overflow triggers. The chapter does not fully cover active or stale pruning of individual Tool Results; those reduce provider messages without creating another LLM compaction mechanism. -This chapter describes the implementation current as of 2026-08-28. Ledger-backed checkpoints use schema V2 for text summaries and schema V3 for provider-native state. OpenAI Codex subscription models use Codex remote compaction V2 by default; other providers retain text-summary behavior. +This chapter describes the implementation current as of 2026-08-30. Ledger-backed checkpoints use schema V2 for text summaries and schema V3 for provider-native state. OpenAI Codex subscription models prefer Codex remote compaction V2 and retain the text summarizer as a narrow liveness fallback; other providers use text-summary behavior directly. ## Start with a long-running Session @@ -232,9 +232,11 @@ The LLM is therefore the generator of the projection value, not the projection a When the selected connection has `providerType: openai-codex`, Maka uses Codex's server-side compactor by default instead of asking the model for a text summary. The provider request is still built from the validated RuntimeEvent prefix. The dedicated compactor sets `providerOptions.openai.compactionTrigger: true`, which appends one terminal `{ "type": "compaction_trigger" }` input item. The compactor uses the streaming Responses path and consumes the full stream because a compaction-only response has no ordinary generated-text result. Ordinary Codex requests do not set this option and are unchanged. +The portable text summarizer remains a bounded liveness fallback. Maka retries once through it when the native request receives a non-retryable protocol `RequestRejected`, returns no unique valid compact state, or cannot fit its native history projection. Cancellation, authentication, billing, rate-limit, and provider-availability failures retain their original outcome instead of doubling traffic through the same unhealthy connection. The two physical attempts share one logical compaction call but record `provider_native` and `text_summary` independently in telemetry. + Compaction input preserves assistant-step chronology. Because the Responses converter cannot resend provider-executed tool results under `store:false`, a settled hosted call/result is lowered only for this compaction request into a paired ordinary function call and output, followed by the grounded assistant text. This keeps the available tool evidence in the request without producing an orphan output. -The compaction call receives the active history-input budget. If its RuntimeEvent projection exceeds that estimate, Maka replaces older Tool Result payloads with a fixed omission marker while retaining every call/result pair and all later grounded text. If the remaining non-tool history still cannot fit, Runtime does not dispatch an already over-capacity compaction request and follows the normal fail-open path. +The compaction call receives the active history-input budget. If its RuntimeEvent projection exceeds that estimate, Maka replaces older Tool Result payloads with a fixed omission marker while retaining every call/result pair and all later grounded text. If the remaining non-tool history still cannot fit, Runtime does not dispatch an already over-capacity native request and gives the text summarizer its one fallback opportunity before following the normal fail-open path. This is deliberately a history-only contract. Maka does not send the current system prompt or tool catalog to the remote compactor, unlike the Codex CLI's whole-request assembly. Those values are neither part of checkpoint source coverage nor frozen into the checkpoint; the subsequent model request always applies its current system prompt and tools. This keeps provider-native and text-summary compactors behind the same small contract, at the cost of not giving the compactor that extra request-shape context. @@ -393,8 +395,8 @@ Compaction crosses token estimation, an LLM call, schema construction, durable a | Below high water | Keep the existing projection or apply ordinary budget selection | Create an unsourced summary as a speculative optimization | | LLM returns an empty summary | Record no new checkpoint. Automatic pre-turn compaction keeps the original source-derived projection and, if it remains over budget, terminates with `context_budget_exhausted` without writing a failure note; manual compaction records one visible `context_compaction_failed_open` note | Treat an empty projection as covered history | | Text summary is malformed | Spend one stricter repair attempt, then fail open with a granular reason; do not redispatch an unchanged failed fingerprint | Persist incomplete structure or loop on the same doomed compaction input | -| Codex returns no unique valid compact item | Record no new checkpoint and use the same fail-open path | Persist partial or ambiguous provider state | -| Compaction input cannot fit after bounded Tool Result omission | Do not dispatch the compaction request; use the same fail-open path | Ask the provider to compact an already over-capacity request | +| Codex returns no unique valid compact item | Try one portable text-summary checkpoint, then fail open if that also fails | Persist partial or ambiguous provider state | +| Native compaction input cannot fit after bounded Tool Result omission | Do not dispatch the native request; try one bounded text-summary checkpoint | Ask the provider to compact an already over-capacity request | | Rolling summarizer fails | Reuse the old checkpoint if it still matches and fits, then add the newest complete raw Turns that fit | Pretend the old checkpoint covers newly evicted events | | Durable checkpoint append fails | Do not use the candidate; fall back to the old checkpoint or safe tail | Put an uncommitted projection into the model and later claim it is recoverable | | Prefix or digest mismatch | Reject the checkpoint | Replace canonical events through approximate matching | diff --git a/docs/architecture/llm-compaction-events-log-projection-draft.zh-CN.md b/docs/architecture/llm-compaction-events-log-projection-draft.zh-CN.md index 6fc47609ed..e70bf18a39 100644 --- a/docs/architecture/llm-compaction-events-log-projection-draft.zh-CN.md +++ b/docs/architecture/llm-compaction-events-log-projection-draft.zh-CN.md @@ -38,7 +38,7 @@ owners: 本文主要讨论 **RuntimeEvent history compaction**:compactor 生成 continuation summary 或 provider-native compact state,checkpoint 覆盖一段安全的 RuntimeEvent 前缀,并在以后请求中用该投影替代前缀。手动、pre-turn、mid-turn 与 overflow 触发器共用同一个 planner 和 checkpoint transaction。本文不完整展开单个 Tool Result 的 active/stale prune;它们会缩小 provider messages,但不会形成另一套 LLM compaction 机制。 -本文描述截至 2026-08-28 的当前实现。ledger-backed checkpoint 中,schema V2 保存文本摘要,schema V3 保存 provider-native state。OpenAI Codex 订阅模型默认使用 Codex remote compaction V2,其他 provider 维持文本摘要行为。 +本文描述截至 2026-08-30 的当前实现。ledger-backed checkpoint 中,schema V2 保存文本摘要,schema V3 保存 provider-native state。OpenAI Codex 订阅模型优先使用 Codex remote compaction V2,并保留文本 summarizer 作为范围严格的 liveness fallback;其他 provider 直接使用文本摘要行为。 ## 从一个长期会话开始 @@ -232,9 +232,11 @@ Repair 之外的 malformed retry 也有上限。Runtime 为每个 Session backen 当所选 connection 的 `providerType` 为 `openai-codex` 时,Maka 默认使用 Codex 服务端 compactor,不再让模型生成文本摘要。provider request 仍由已校验的 RuntimeEvent prefix 构建。专用 compactor 会设置 `providerOptions.openai.compactionTrigger: true`,从而追加唯一一个位于 input 末尾的 `{ "type": "compaction_trigger" }` item。compactor 使用流式 Responses 路径并消费完整 stream,因为只有 compaction output 的响应不存在普通 generated-text result;普通 Codex 请求不会设置这个选项,因此行为不变。 +可移植的文本 summarizer 仍作为有界的 liveness fallback。当 native request 收到不可重试的协议级 `RequestRejected`、没有返回唯一合法的 compact state,或无法容纳 native history projection 时,Maka 会通过文本 summarizer 重试一次。Cancellation、鉴权、计费、限流和 provider 不可用仍保留原始结果,不会向同一个异常连接发送双倍流量。两次物理请求属于同一个逻辑 compaction call,但 telemetry 会分别记录 `provider_native` 与 `text_summary`。 + Compaction input 会保留 assistant step 的时序。由于 Responses converter 在 `store:false` 下无法重新发送 provider-executed tool result,已经完整结算的 hosted call/result 只在这次 compaction request 中降级为成对的普通 function call 与 output,之后再放 grounded assistant text。这样既保留了现有 tool evidence,也不会生成悬空 output。 -Compaction call 会收到当前 history input budget。若 RuntimeEvent projection 超出该估算值,Maka 会把较旧的 Tool Result payload 替换为固定 omission marker,同时保留每一组 call/result 配对和之后的 grounded text。若剩余的非工具历史仍无法容纳,Runtime 不会发送一条已经超出容量的 compaction request,而是进入正常 fail-open 路径。 +Compaction call 会收到当前 history input budget。若 RuntimeEvent projection 超出该估算值,Maka 会把较旧的 Tool Result payload 替换为固定 omission marker,同时保留每一组 call/result 配对和之后的 grounded text。若剩余的非工具历史仍无法容纳,Runtime 不会发送一条已经超出容量的 native request,而是先给文本 summarizer 一次 fallback 机会,再进入正常 fail-open 路径。 这是有意设计成 history-only 的契约。与 Codex CLI 的 whole-request assembly 不同,Maka 不会把当前 system prompt 或 tool catalog 发给 remote compactor;它们既不属于 checkpoint source coverage,也不会被冻结进 checkpoint,后续模型请求始终使用当时最新的 system prompt 和 tools。这样 provider-native 与 text-summary compactor 可以共享同一份小契约,代价是 compactor 无法利用这部分额外的 request-shape context。 @@ -393,8 +395,8 @@ Compaction 跨越 token estimation、LLM call、schema construction、durable ap | 未超过 high water | 保持原投影或普通预算裁剪 | 为了“提前优化”制造无来源摘要 | | LLM 返回空 summary | 不记录新 checkpoint。自动 pre-turn compaction 保留原有的 source-derived projection;如果它仍然超出预算,则以 `context_budget_exhausted` 结束且不写入失败 note;手动 compaction 则记录一次可见的 `context_compaction_failed_open` note | 把空 projection 当作 covered history | | Text summary 格式不合法 | 只进行一次更严格的 repair,之后以细分 reason fail open;同一失败 fingerprint 不再 dispatch | 持久化不完整结构,或在相同 doomed input 上循环 | -| Codex 没有返回唯一且合法的 compact item | 不记录新 checkpoint,走同一 fail-open 路径 | 持久化残缺或有歧义的 provider state | -| Compaction input 在有界省略 Tool Result 后仍无法容纳 | 不发送 compaction request,走同一 fail-open 路径 | 要求 provider 压缩一条已经超出容量的请求 | +| Codex 没有返回唯一且合法的 compact item | 尝试一次可移植文本摘要 checkpoint;若仍失败再 fail open | 持久化残缺或有歧义的 provider state | +| Native compaction input 在有界省略 Tool Result 后仍无法容纳 | 不发送 native request,尝试一次有界文本摘要 checkpoint | 要求 provider 压缩一条已经超出容量的请求 | | Rolling summarizer 失败 | 若旧 checkpoint 仍匹配且符合当前限制,则复用它并拼接能容纳的最新完整 raw Turns | 假装旧 checkpoint 已覆盖 newly evicted events | | Durable checkpoint append 失败 | 不使用 candidate;回退旧 checkpoint 或安全 tail | 让未提交 projection 进入模型后再声称可恢复 | | Prefix 或 digest 不匹配 | 拒绝 checkpoint | 用近似匹配替代 canonical events | diff --git a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts index 2b8b245124..3483b11db3 100644 --- a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts +++ b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts @@ -676,10 +676,24 @@ test('provider dispatch fails closed when the Run Composition commit fails', asy } }); -test('Codex OAuth history compaction uses the provider-native route and preserves failure facts', async () => { +test('Codex OAuth history compaction falls back to a text checkpoint after native rejection', async () => { const modelId = 'gpt-5.6-sol'; const requests: Array<{ url: string; body: Record }> = []; const attempts: ModelCallAttempt[] = []; + let recordedTextCheckpoint = false; + const fallbackSummary = [ + '## Goal', + 'Continue the existing task.', + '', + '## Progress', + '- Preserved the completed work.', + '', + '## Next Steps', + '1. Continue from the recent context.', + '', + '## Critical Context', + '- The portable fallback remains available.', + ].join('\n'); const oauthTokens: OAuthSubscriptionTokens = { access_token: codexAccessToken('compact-account'), refresh_token: 'compact-refresh-token', @@ -716,21 +730,59 @@ test('Codex OAuth history compaction uses the provider-native route and preserve secretMaterial: { connection: { secret: 'oauth-material' } }, }), readPricing: async () => ({ revision: 0, overrides: [] }), - recordHistoryCompactCheckpoint: async () => undefined, + recordHistoryCompactCheckpoint: async (checkpoint) => { + recordedTextCheckpoint = 'summary' in checkpoint; + }, recordModelCallAttempt: async ({ attempt }) => { attempts.push(attempt); }, createFetchTransport: () => ({ fetch: async (url, init) => { + const body = JSON.parse(String(init?.body)) as Record; requests.push({ url: String(url), - body: JSON.parse(String(init?.body)) as Record, + body, }); + const providerInput = Array.isArray(body.input) ? body.input : []; + if ( + !providerInput.some( + (item) => + typeof item === 'object' && + item !== null && + 'type' in item && + item.type === 'compaction_trigger', + ) + ) { + return Response.json({ + id: 'resp-text-fallback', + object: 'response', + created_at: 1, + status: 'completed', + model: modelId, + output: [ + { + type: 'message', + id: 'msg-text-fallback', + status: 'completed', + role: 'assistant', + content: [ + { + type: 'output_text', + text: fallbackSummary, + annotations: [], + logprobs: [], + }, + ], + }, + ], + usage: { input_tokens: 4_000, output_tokens: 60, total_tokens: 4_060 }, + }); + } return Response.json( { error: { message: 'request rejected without echoing this body', - code: 'invalid_request_error', + code: 'missing_required_parameter', }, }, { @@ -774,23 +826,31 @@ test('Codex OAuth history compaction uses the provider-native route and preserve runtimeContext, }); - assert.equal(requests.length, 1, JSON.stringify(result)); + assert.equal(requests.length, 2, JSON.stringify(result)); assert.match(requests[0]!.url, /\/codex\/responses$/); - const requestText = JSON.stringify(requests[0]!.body); - assert.match(requestText, /"type":"compaction_trigger"/); - assert.doesNotMatch(requestText, /context summarization assistant/i); - assert.deepEqual(result.outcome, { kind: 'failed', reason: 'provider_error' }); - assert.equal(result.contextBudget?.compactionDecisions?.[0]?.decision, 'failedOpen'); - assert.equal(attempts.length, 1); + assert.match(requests[1]!.url, /\/codex\/responses$/); + const nativeRequestText = JSON.stringify(requests[0]!.body); + const fallbackRequestText = JSON.stringify(requests[1]!.body); + assert.match(nativeRequestText, /"type":"compaction_trigger"/); + assert.doesNotMatch(nativeRequestText, /context summarization assistant/i); + assert.doesNotMatch(fallbackRequestText, /"type":"compaction_trigger"/); + assert.match(fallbackRequestText, /context summarization assistant/i); + assert.equal(result.outcome.kind, 'compacted'); + assert.equal(recordedTextCheckpoint, true); + assert.equal(attempts.length, 2); assert.equal(attempts[0]?.callKind, 'history_compact'); assert.equal(attempts[0]?.providerId, 'openai-codex'); assert.equal(attempts[0]?.historyCompactRoute, 'provider_native'); assert.equal(attempts[0]?.status, 'failed'); assert.equal(attempts[0]?.errorClass, 'RequestRejected'); assert.equal(attempts[0]?.httpStatus, 400); - assert.equal(attempts[0]?.providerCode, 'invalid_request_error'); + assert.equal(attempts[0]?.providerCode, 'missing_required_parameter'); assert.equal(attempts[0]?.providerRequestId, 'req-codex-compact'); assert.equal(attempts[0]?.retryable, false); + assert.equal(attempts[1]?.logicalCallId, attempts[0]?.logicalCallId); + assert.equal(attempts[1]?.attempt, 1); + assert.equal(attempts[1]?.historyCompactRoute, 'text_summary'); + assert.equal(attempts[1]?.status, 'completed'); } finally { await backend.dispose(); } diff --git a/packages/runtime-host/src/server/execution-model-composition.ts b/packages/runtime-host/src/server/execution-model-composition.ts index 34337a5215..f9659c9523 100644 --- a/packages/runtime-host/src/server/execution-model-composition.ts +++ b/packages/runtime-host/src/server/execution-model-composition.ts @@ -30,7 +30,10 @@ import { resolveSelectedModelContextWindow, } from '@maka/runtime/context-budget-policy'; import { buildLlmHistorySummarizer } from '@maka/runtime/history-compact-summarizer'; -import { buildOpenAiCodexHistoryCompactor } from '@maka/runtime/openai-codex-history-compactor'; +import { + buildOpenAiCodexHistoryCompactor, + withOpenAiCodexHistoryCompactionFallback, +} from '@maka/runtime/openai-codex-history-compactor'; import { buildPricingLookup, recordToolInvocation } from '@maka/runtime/telemetry'; import { buildProviderOptions, getAIModel } from '@maka/runtime/model-factory'; import { createProviderRequestCaptureRecorder } from '@maka/runtime/provider-request-telemetry'; @@ -188,18 +191,22 @@ export async function createHostAiSdkBackend(input: HostAiSdkBackendInput): Prom fetch: modelFetch, requestHeaders: target.requestHeaders, }); + const textHistorySummarizer = buildLlmHistorySummarizer({ + resolveModel: resolveHistoryCompactModel, + providerOptions, + }); const summarizeHistoryCompact = target.connection.providerType === 'openai-codex' - ? buildOpenAiCodexHistoryCompactor({ - resolveModel: resolveHistoryCompactModel, - connectionSlug: target.connection.slug, - modelId: target.model, - providerOptions, - }) - : buildLlmHistorySummarizer({ - resolveModel: resolveHistoryCompactModel, - providerOptions, - }); + ? withOpenAiCodexHistoryCompactionFallback( + buildOpenAiCodexHistoryCompactor({ + resolveModel: resolveHistoryCompactModel, + connectionSlug: target.connection.slug, + modelId: target.model, + providerOptions, + }), + textHistorySummarizer, + ) + : textHistorySummarizer; const historyCompactRoute = target.connection.providerType === 'openai-codex' ? 'provider_native' : 'text_summary'; let telemetryDrainRequested = false; diff --git a/packages/runtime/src/__tests__/openai-codex-history-compactor.test.ts b/packages/runtime/src/__tests__/openai-codex-history-compactor.test.ts index 63a56949ba..af38785d32 100644 --- a/packages/runtime/src/__tests__/openai-codex-history-compactor.test.ts +++ b/packages/runtime/src/__tests__/openai-codex-history-compactor.test.ts @@ -22,7 +22,11 @@ import { describe, test } from 'node:test'; import { extractOpenAiCodexCompactionState, fitOpenAiCodexCompactionMessages, + shouldFallbackFromOpenAiCodexHistoryCompaction, + withOpenAiCodexHistoryCompactionFallback, } from '../openai-codex-history-compactor.js'; +import { HistoryCompactSummarizerError } from '../history-compact-error.js'; +import type { HistoryCompactSummaryInput } from '../ai-sdk-compaction-contract.js'; test('preserves the provider-specific input-fitting export', () => { assert.deepEqual( @@ -34,6 +38,68 @@ test('preserves the provider-specific input-fitting export', () => { ); }); +describe('OpenAI Codex compaction fallback', () => { + const input: HistoryCompactSummaryInput = { + sessionId: 'session-1', + turnId: 'turn-1', + source: { foldedRuntimeEvents: [] }, + }; + + test('falls back after a non-retryable native protocol rejection', async () => { + const providerError = Object.assign(new Error('private provider message'), { + statusCode: 400, + data: { error: { code: 'missing_required_parameter' } }, + }); + const nativeError = new HistoryCompactSummarizerError('provider_error', { + cause: providerError, + }); + let fallbackCalls = 0; + const summarize = withOpenAiCodexHistoryCompactionFallback( + async () => { + throw nativeError; + }, + async () => { + fallbackCalls += 1; + return 'portable checkpoint'; + }, + ); + + assert.equal(await summarize(input), 'portable checkpoint'); + assert.equal(fallbackCalls, 1); + }); + + test('falls back when native state is unusable or its projection cannot fit', () => { + assert.equal( + shouldFallbackFromOpenAiCodexHistoryCompaction( + new HistoryCompactSummarizerError('invalid_provider_state'), + ), + true, + ); + assert.equal( + shouldFallbackFromOpenAiCodexHistoryCompaction( + new HistoryCompactSummarizerError('input_too_large'), + ), + true, + ); + }); + + test('does not fall back on cancellation, rate limits, or provider availability', () => { + const rateLimit = new HistoryCompactSummarizerError('provider_error', { + cause: Object.assign(new Error('private provider message'), { statusCode: 429 }), + }); + const unavailable = new HistoryCompactSummarizerError('provider_error', { + cause: Object.assign(new Error('private provider message'), { statusCode: 503 }), + }); + const aborted = new HistoryCompactSummarizerError('provider_error', { + cause: new DOMException('cancelled', 'AbortError'), + }); + + assert.equal(shouldFallbackFromOpenAiCodexHistoryCompaction(rateLimit), false); + assert.equal(shouldFallbackFromOpenAiCodexHistoryCompaction(unavailable), false); + assert.equal(shouldFallbackFromOpenAiCodexHistoryCompaction(aborted), false); + }); +}); + describe('OpenAI Codex compaction output', () => { const compactPart = (itemId: unknown, encryptedContent: unknown) => ({ type: 'custom', diff --git a/packages/runtime/src/__tests__/provider-request-telemetry.test.ts b/packages/runtime/src/__tests__/provider-request-telemetry.test.ts index 9ef2d8412e..308787e7f0 100644 --- a/packages/runtime/src/__tests__/provider-request-telemetry.test.ts +++ b/packages/runtime/src/__tests__/provider-request-telemetry.test.ts @@ -1045,6 +1045,64 @@ describe('canonical model-call accounting', () => { assert.doesNotMatch(JSON.stringify(attempt), /private|prompt|response body/i); }); + test('records the physical route when one compaction call falls back', async () => { + const recorded: ModelCallAttempt[] = []; + const tracker = accountingTracker({ + callKind: 'history_compact', + historyCompactRoute: 'provider_native', + record: ({ attempt }) => { + recorded.push(attempt); + }, + }); + const rejection = Object.assign(new Error('native protocol rejected'), { + statusCode: 400, + data: { error: { code: 'missing_required_parameter' } }, + }); + + await assert.rejects( + tracker.trackGenerate({ + providerId: 'openai.responses', + modelId: 'gpt-codex-test', + historyCompactRoute: 'provider_native', + params: preparedParams('native compact'), + doGenerate: async () => { + throw rejection; + }, + }), + (error) => error === rejection, + ); + await tracker.trackGenerate({ + providerId: 'openai.responses', + modelId: 'gpt-codex-test', + historyCompactRoute: 'text_summary', + params: preparedParams('portable summary'), + doGenerate: async () => ({ finishReason: 'stop' }), + }); + + assert.deepEqual( + recorded.map((attempt) => ({ + logicalCallId: attempt.logicalCallId, + attempt: attempt.attempt, + route: attempt.historyCompactRoute, + status: attempt.status, + })), + [ + { + logicalCallId: recorded[0]?.logicalCallId, + attempt: 0, + route: 'provider_native', + status: 'failed', + }, + { + logicalCallId: recorded[0]?.logicalCallId, + attempt: 1, + route: 'text_summary', + status: 'completed', + }, + ], + ); + }); + test('a call the provider reported no usage for records usageBasis missing', async () => { // The alternative is a record claiming zero tokens, which is a measurement // nobody made. `missing` says the call happened and the meter did not read. diff --git a/packages/runtime/src/history-compact-summarizer.ts b/packages/runtime/src/history-compact-summarizer.ts index 9ffebc8a48..f952b600df 100644 --- a/packages/runtime/src/history-compact-summarizer.ts +++ b/packages/runtime/src/history-compact-summarizer.ts @@ -129,6 +129,7 @@ export function buildLlmHistorySummarizer(options: BuildLlmHistorySummarizerOpti model: options.resolveModel(), wrapLanguageModel: ai!.wrapLanguageModel, tracker: providerRequestTracker, + historyCompactRoute: 'text_summary', ...(input.abortSignal ? { abortSignal: input.abortSignal } : {}), }) : options.resolveModel(); diff --git a/packages/runtime/src/openai-codex-history-compactor.ts b/packages/runtime/src/openai-codex-history-compactor.ts index 5b2f987a6c..b147a24fb0 100644 --- a/packages/runtime/src/openai-codex-history-compactor.ts +++ b/packages/runtime/src/openai-codex-history-compactor.ts @@ -17,7 +17,10 @@ * under the License. */ -import type { HistoryCompactSummaryInput } from './ai-sdk-compaction-contract.js'; +import type { + HistoryCompactSummarizer, + HistoryCompactSummaryInput, +} from './ai-sdk-compaction-contract.js'; import { HistoryCompactSummarizerError } from './history-compact-error.js'; import { historyCompactCheckpointToModelMessage, @@ -33,6 +36,7 @@ import { } from './model-history.js'; import { withProviderStreamTracking } from './provider-request-telemetry.js'; import { toolResultOutput } from './tool-result-output.js'; +import { providerFailureDiagnostic } from './provider-error-classification.js'; export { fitHistoryCompactMessages as fitOpenAiCodexCompactionMessages } from './history-compact-input-fit.js'; @@ -84,6 +88,7 @@ export function buildOpenAiCodexHistoryCompactor(options: BuildOpenAiCodexHistor model: options.resolveModel(), wrapLanguageModel: ai.wrapLanguageModel, tracker: providerRequestTracker, + historyCompactRoute: 'provider_native', ...(input.abortSignal ? { abortSignal: input.abortSignal } : {}), }) : options.resolveModel(); @@ -118,6 +123,52 @@ export function buildOpenAiCodexHistoryCompactor(options: BuildOpenAiCodexHistor }; } +/** + * Prefer the Codex-native checkpoint, but retain a portable liveness path when + * the native protocol is unavailable for this request. The fallback is narrow: + * provider capacity/auth/transient failures keep their original outcome rather + * than doubling traffic through the same unhealthy connection. + */ +export function withOpenAiCodexHistoryCompactionFallback( + preferred: HistoryCompactSummarizer, + fallback: HistoryCompactSummarizer, +): HistoryCompactSummarizer { + return async (input) => { + try { + return await preferred(input); + } catch (error) { + if (!shouldFallbackFromOpenAiCodexHistoryCompaction(error, input.abortSignal)) throw error; + return await fallback(input); + } + }; +} + +export function shouldFallbackFromOpenAiCodexHistoryCompaction( + error: unknown, + abortSignal?: AbortSignal, +): boolean { + if (abortSignal?.aborted || hasAbortCause(error)) return false; + if (!(error instanceof HistoryCompactSummarizerError)) return false; + if (error.reason === 'input_too_large' || error.reason === 'invalid_provider_state') return true; + if (error.reason !== 'provider_error') return false; + const diagnostic = providerFailureDiagnostic(error); + return diagnostic.errorClass === 'RequestRejected' && !diagnostic.retryable; +} + +function hasAbortCause(error: unknown): boolean { + const seen = new Set(); + let current = error; + for (let depth = 0; depth < 6 && current !== undefined && !seen.has(current); depth += 1) { + seen.add(current); + if (current instanceof Error && current.name === 'AbortError') return true; + current = + typeof current === 'object' && current !== null && 'cause' in current + ? (current as { cause?: unknown }).cause + : undefined; + } + return false; +} + /** * Materialize provider history by assistant step, not ledger append order. * Runtime persists tool calls/results before a step's reasoning/text closer; diff --git a/packages/runtime/src/provider-request-telemetry.ts b/packages/runtime/src/provider-request-telemetry.ts index 5a7d40f64a..73d0c99160 100644 --- a/packages/runtime/src/provider-request-telemetry.ts +++ b/packages/runtime/src/provider-request-telemetry.ts @@ -19,6 +19,7 @@ import { MODEL_CALL_ATTEMPT_SCHEMA_VERSION, + type HistoryCompactRoute, type ModelCallAttempt, type ModelCallKind, type ModelCallUsageBasis, @@ -217,6 +218,8 @@ export interface TrackProviderStreamInput { * may never have seen. */ historyCompactBoundary?: ContextDiagnosticsCompaction; + /** Physical history-compaction route used by this provider request. */ + historyCompactRoute?: HistoryCompactRoute; } export interface TrackProviderGenerateInput { @@ -224,6 +227,8 @@ export interface TrackProviderGenerateInput { modelId: string; /** As `TrackProviderStreamInput.historyCompactBoundary`. */ historyCompactBoundary?: ContextDiagnosticsCompaction; + /** Physical history-compaction route used by this provider request. */ + historyCompactRoute?: HistoryCompactRoute; params: Record; abortSignal?: AbortSignal; doGenerate: () => PromiseLike; @@ -255,6 +260,7 @@ export function withProviderGenerateTracking(input: { wrapLanguageModel: (input: Record) => unknown; tracker: ProviderRequestTracker; abortSignal?: AbortSignal; + historyCompactRoute?: HistoryCompactRoute; }): unknown { return input.wrapLanguageModel({ model: input.model, @@ -265,6 +271,7 @@ export function withProviderGenerateTracking(input: { modelId: model.modelId, params, ...(input.abortSignal ? { abortSignal: input.abortSignal } : {}), + ...(input.historyCompactRoute ? { historyCompactRoute: input.historyCompactRoute } : {}), doGenerate, }), }, @@ -277,6 +284,7 @@ export function withProviderStreamTracking(input: { wrapLanguageModel: (input: Record) => unknown; tracker: ProviderRequestTracker; abortSignal?: AbortSignal; + historyCompactRoute?: HistoryCompactRoute; }): unknown { return input.wrapLanguageModel({ model: input.model, @@ -287,6 +295,7 @@ export function withProviderStreamTracking(input: { modelId: model.modelId, params, ...(input.abortSignal ? { abortSignal: input.abortSignal } : {}), + ...(input.historyCompactRoute ? { historyCompactRoute: input.historyCompactRoute } : {}), doStream, }), }, @@ -476,7 +485,7 @@ export class ProviderRequestTracker { capture: StoredCapture, input: Pick< TrackProviderStreamInput | TrackProviderGenerateInput, - 'providerId' | 'modelId' | 'abortSignal' | 'historyCompactBoundary' + 'providerId' | 'modelId' | 'abortSignal' | 'historyCompactBoundary' | 'historyCompactRoute' >, ): { observeOutput(): void; @@ -563,6 +572,8 @@ export class ProviderRequestTracker { // mid-flight by another turn cannot be sealed into a prompt built // before it existed. historyCompactBoundary: input.historyCompactBoundary, + historyCompactRoute: + input.historyCompactRoute ?? this.input.accounting?.historyCompactRoute, }); }); await accountingSettlement; @@ -601,6 +612,7 @@ export class ProviderRequestTracker { usage: ProviderRequestUsage | undefined; contextWindow: number | undefined; historyCompactBoundary: ContextDiagnosticsCompaction | undefined; + historyCompactRoute: HistoryCompactRoute | undefined; }, ): Promise { const accounting = this.input.accounting; @@ -629,8 +641,8 @@ export class ProviderRequestTracker { step: Math.max(0, record.step), attempt: Math.max(0, record.attempt - 1), callKind: accounting.callKind, - ...(accounting.historyCompactRoute !== undefined - ? { historyCompactRoute: accounting.historyCompactRoute } + ...(context.historyCompactRoute !== undefined + ? { historyCompactRoute: context.historyCompactRoute } : {}), providerId: accounting.providerId ?? record.providerId, modelId: record.modelId, From f2e1033061c7b23652b50700a5d50a22d4ac3497 Mon Sep 17 00:00:00 2001 From: skblue Date: Sun, 30 Aug 2026 19:15:00 +0800 Subject: [PATCH 11/71] fix(runtime): allow reading workspace ancestor directories under macOS Seatbelt (#4237) Allow directory-only ancestor traversal under macOS Seatbelt so Bun can reach an explicitly readable workspace. --- .../__tests__/macos-seatbelt-smoke.test.ts | 21 ++++++++++++++++++- .../src/__tests__/macos-seatbelt.test.ts | 14 +++++++++++++ .../runtime/src/sandbox/macos-seatbelt.ts | 10 ++++++++- 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/packages/runtime/src/__tests__/macos-seatbelt-smoke.test.ts b/packages/runtime/src/__tests__/macos-seatbelt-smoke.test.ts index 1afadf5e69..011194568d 100644 --- a/packages/runtime/src/__tests__/macos-seatbelt-smoke.test.ts +++ b/packages/runtime/src/__tests__/macos-seatbelt-smoke.test.ts @@ -20,7 +20,7 @@ import assert from 'node:assert/strict'; import { after, describe, it } from 'node:test'; import { existsSync } from 'node:fs'; -import { mkdtemp, readFile, realpath, rm } from 'node:fs/promises'; +import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises'; import { join, resolve } from 'node:path'; import { spawnSync } from 'node:child_process'; import { tmpdir } from 'node:os'; @@ -110,6 +110,25 @@ describe('macOS Seatbelt smoke', { skip: !canRunSeatbelt }, () => { assert.equal(await readFile(join(workspaceRoot, 'allowed.txt'), 'utf8'), 'ok'); }); + it('allows ancestor directory reads without exposing ancestor file contents', async () => { + const ancestorRoot = await realpath(await mkdtemp(join(tmpdir(), 'maka-seatbelt-ancestors-'))); + const workspaceRoot = join(ancestorRoot, 'level-one', 'level-two'); + const ancestorFile = join(ancestorRoot, 'private.txt'); + await mkdir(workspaceRoot, { recursive: true }); + await writeFile(ancestorFile, 'private'); + cleanup.push(ancestorRoot); + + const listAncestor = runSeatbeltCommand(workspaceRoot, '/bin/ls ..'); + assert.equal(listAncestor.status, 0, listAncestor.stderr); + + const readAncestorFile = runSeatbeltCommand( + workspaceRoot, + `/bin/cat ${JSON.stringify(ancestorFile)}`, + ); + assert.notEqual(readAncestorFile.status, 0); + assert.match(readAncestorFile.stderr, /Operation not permitted/); + }); + it('denies writes outside the workspace root', async () => { const workspaceRoot = await makeWorkspace(); const outsideRoot = await realpath(await mkdtemp(join(tmpdir(), 'maka-seatbelt-outside-'))); diff --git a/packages/runtime/src/__tests__/macos-seatbelt.test.ts b/packages/runtime/src/__tests__/macos-seatbelt.test.ts index 67d4980f0b..431247fd35 100644 --- a/packages/runtime/src/__tests__/macos-seatbelt.test.ts +++ b/packages/runtime/src/__tests__/macos-seatbelt.test.ts @@ -170,6 +170,15 @@ describe('buildSeatbeltPolicy', () => { ]); }); + it('allows directory reads along every readable root ancestor chain', () => { + const policy = policyText(createReadOnlyPermissionProfile()); + + assert.match( + policy, + /\(allow file-read-data\n \(require-all\n \(path-ancestors \(param "READABLE_ROOT_0"\)\)\n \(vnode-type DIRECTORY\)\n \)\)/, + ); + }); + it('keeps workspace metadata writable in the standard workspace profile', () => { const policy = policyText(createWorkspaceWritePermissionProfile()); @@ -198,6 +207,10 @@ describe('buildSeatbeltPolicy', () => { policy, /\(allow file-write\*\n \(require-all\n \(subpath \(param "WRITABLE_ROOT_0"\)\)\n \(require-not \(regex #"\^\/repo\/secret\(\/\.\*\)\?\$"\)\)\n \)\)/, ); + assert.match( + policy, + /\(allow file-read-data\n \(require-all\n \(path-ancestors \(param "READABLE_ROOT_0"\)\)\n \(vnode-type DIRECTORY\)\n \(require-not \(regex #"\^\/repo\/secret\(\/\.\*\)\?\$"\)\)\n \)\)/, + ); }); it('escapes workspace root before building protected metadata regex requirements', () => { @@ -235,6 +248,7 @@ describe('buildSeatbeltPolicy', () => { assert.match(result.policy, /\(literal \(param "READABLE_ROOT_0"\)\)/); assert.match(result.policy, /\(subpath \(param "READABLE_ROOT_1"\)\)/); assert.match(result.policy, /\(literal \(param "WRITABLE_ROOT_0"\)\)/); + assert.match(result.policy, /\(path-ancestors \(param "READABLE_ROOT_0"\)\)/); assert.deepEqual(result.definitionArgs.slice(0, 3), [ '-DREADABLE_ROOT_0=/outside/file.txt', '-DREADABLE_ROOT_1=/outside/tree', diff --git a/packages/runtime/src/sandbox/macos-seatbelt.ts b/packages/runtime/src/sandbox/macos-seatbelt.ts index c272856ed8..fb0dd5d3a2 100644 --- a/packages/runtime/src/sandbox/macos-seatbelt.ts +++ b/packages/runtime/src/sandbox/macos-seatbelt.ts @@ -421,7 +421,15 @@ function buildReadableRootsPolicy(roots: ResolvedRoots): string { accessRootClause(seatbeltPathClause(root, `READABLE_ROOT_${index}`), denyRequirements), ) .join('\n'); - return `(allow file-read*\n${params})`; + const ancestorParams = roots.readableRoots + .map((_, index) => + accessRootClause(`(path-ancestors (param "READABLE_ROOT_${index}"))`, [ + '(vnode-type DIRECTORY)', + ...denyRequirements, + ]), + ) + .join('\n'); + return `(allow file-read*\n${params})\n\n(allow file-read-data\n${ancestorParams})`; } function buildWritableRootsPolicy(roots: ResolvedRoots): string { From 0c4367821fd51c989e9eb038965c4f9cf1d3b6a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C4=81na=28Bass=20Ver=2E=29?= <1759138827@qq.com> Date: Sun, 30 Aug 2026 19:15:26 +0800 Subject: [PATCH 12/71] fix(core,storage): unify the Codex thread source gate (#3702) Use one shared source policy for Codex thread discovery and import across the core and SQLite-backed paths. --- .../src/__tests__/foreign-session.test.ts | Bin 21168 -> 23726 bytes packages/core/src/foreign-session.ts | 72 +++++++++++++----- .../__tests__/codex-session-adapter.test.ts | 58 ++++++++++++++ .../__tests__/foreign-session-store.test.ts | 26 ++++++- packages/storage/src/codex-session-adapter.ts | 22 +----- packages/storage/src/foreign-session-store.ts | 25 +++--- 6 files changed, 152 insertions(+), 51 deletions(-) diff --git a/packages/core/src/__tests__/foreign-session.test.ts b/packages/core/src/__tests__/foreign-session.test.ts index 89398d4791201e569e14bf95a8f4d11d8475e392..199fe6d47ebed21119f91a0ff17e2fe09816e4d6 100644 GIT binary patch delta 1822 zcmaJ>&u<$=6qcn1QD{{K5}_2gyt=K|A)7?4TVf2Q2d<@3NPDVEJ>Gq`hm2=7GqZLr z%W{#pBQa+rE^q@y3KuS@*Zw#C2atHPKboW=@xfm2ym{|?-}~OT|6AqHzbpGcE>yYM zNs~lri}BYg#^;X)8dD~kS zY;Jg$0|F~36grVdpP}ne8s7wrnSl}CHoTjq)++4}e2Fqy#4Ho44@^=< zIpQY*GuRthc%-DUdGq(&_S$9lQ~g2(-7obO_t&TQ&-o_YUQo>+>T5Tj_&4`AcpO52 z!Ri;3WJcfg!)+rt0nC1GZ7v^qoyuLQ#*z-)53O5Qr(n`9>l2G@ zm==O~W)e1*^nd35t-_W@U)c-03@tHn30-9exj?--#+oGwVeTnCBuv-ci(3os_pM8J z%bT_Kw3e$bFXI1GpH%)?@tViAb}gyRX0yO8eiVA{&X>0XSGlt6UbQaG&sxVIgvKh| z`wFI|isB*n_=(zFF9oHv83Ow(nvi}6IP`j=9tuhg7|SNK7n$0R15^#i?P-bodX223}0n8rS1gC&LLKp2A zQTpWtJ66zV7EKV&j}VgCgZG?fI}0lv$G7gU9d{>Wq;--D&6JTJ?NV-oxv5aFd1DbV zMm(mVj4m#?oj}ZcJ@k_mlR3ngpp3l(l2_#$WK!K@5z@x&Vw{_YZ?%y-NU8cBJP9Jf ziT_|^ym$GhY=!7ImpDB{L`FZc!PC`j=-vdGF{teZ?xjq){pPtlr_Ug-RZPJfrZ@F$ z8t&KT(iy|+Ol$eNkz{X7^Lw+}tn+`V$k&d0o40lY32i=cwBAka`Vs3YK+x>KErlyL v&(vB_?6mu)2`i_z={8!I-FCD3@u?Awz-PE|`uUyP)LD-Sg9k5LuUGy9u$poY delta 28 kcmZ3tlX1gR#tkj3o0qV9sck-}_nT*Ow;liHYfd*L0k_QzlmGw# diff --git a/packages/core/src/foreign-session.ts b/packages/core/src/foreign-session.ts index 2813daf350..fdc091fcc6 100644 --- a/packages/core/src/foreign-session.ts +++ b/packages/core/src/foreign-session.ts @@ -392,8 +392,21 @@ export function claudeToolFilePaths(record: Record): string[] { * content. * ------------------------------------------------------------------ */ -/** Codex thread sources eligible for import (per issue #1057). */ -export const CODEX_SUPPORTED_THREAD_SOURCES = ['cli', 'vscode', 'atlas', 'chatgpt'] as const; +/** + * Codex thread sources eligible for import — the single authority for both the + * foreign-session scanner below and the Codex Session adapter in `@maka/storage`. + * #1057 defined the original list; `exec` covers headless `codex exec` runs, + * which #2502 listed as root Sessions from the adapter's first commit. The two + * gates drifted apart while each owned its own token set, so the same thread was + * visible through one surface and invisible through the other. + */ +export const CODEX_SUPPORTED_THREAD_SOURCES = [ + 'cli', + 'exec', + 'vscode', + 'atlas', + 'chatgpt', +] as const; /** * Timestamps below this (2020-01-01 UTC in ms) are treated as seconds and @@ -421,34 +434,51 @@ function normalizeEpochMs(value: unknown): number | undefined { } /** - * Codex persists `source` either as a bare token (`cli`, `vscode`) or as a - * JSON object string (`{"custom":"atlas"}`, `{"custom":"chatgpt"}`). Return - * the canonical token, or undefined when it isn't a supported source — a - * bare-string equality check would silently drop every atlas/chatgpt thread. + * Codex persists `source` as a bare token (`cli`, `exec`, `vscode`), as a JSON + * object string (`{"custom":"atlas"}`), or — in rollout `session_meta` payloads, + * which arrive already parsed — as the object itself. Return the canonical + * token, or undefined when it isn't a supported source: a bare-string equality + * check would silently drop every atlas/chatgpt thread, and a token set that + * only knows the wrapped form would drop the bare one. + * + * Unsupported shapes (notably `{"subagent":{…}}`) resolve to undefined, which is + * what keeps internal subagent threads out of both surfaces. */ export function codexSourceToken(value: unknown): string | undefined { - if (typeof value !== 'string' || value.length === 0) return undefined; - if ((CODEX_SUPPORTED_THREAD_SOURCES as readonly string[]).includes(value)) return value; - if (value.startsWith('{')) { + if (typeof value === 'string') { + if (value.length === 0) return undefined; + if (isSupportedCodexSourceToken(value)) return value; + // Only an object string can carry a `custom` token; anything else is a + // plain unsupported source and must not reach JSON.parse. + if (!value.startsWith('{')) return undefined; try { - const parsed = JSON.parse(value) as unknown; - const custom = - typeof parsed === 'object' && parsed !== null - ? (parsed as Record).custom - : undefined; - if ( - typeof custom === 'string' && - (CODEX_SUPPORTED_THREAD_SOURCES as readonly string[]).includes(custom) - ) { - return custom; - } + return codexSourceToken(JSON.parse(value) as unknown); } catch { return undefined; } } + if (typeof value === 'object' && value !== null) { + const custom = (value as Record).custom; + return typeof custom === 'string' && isSupportedCodexSourceToken(custom) ? custom : undefined; + } return undefined; } +/** + * Whether a recorded `source` makes a Codex thread eligible. An absent source is + * eligible: older Codex schemas have no `source` column and older rollout + * `session_meta` payloads omit the field, so dropping those would hide every + * legacy thread. A present-but-unsupported source is a hard drop. + */ +export function isSupportedCodexThreadSource(value: unknown): boolean { + if (value === undefined || value === null) return true; + return codexSourceToken(value) !== undefined; +} + +function isSupportedCodexSourceToken(value: string): boolean { + return (CODEX_SUPPORTED_THREAD_SOURCES as readonly string[]).includes(value); +} + export interface CodexThreadRow { id?: unknown; rollout_path?: unknown; @@ -476,7 +506,7 @@ export function normalizeCodexThreadRow( if (row.archived === 1 || row.archived === true) return undefined; // A present-but-unsupported source is a hard drop; an absent source column // (older schema) is allowed through — the SELECT simply didn't project it. - if (row.source !== undefined && codexSourceToken(row.source) === undefined) return undefined; + if (!isSupportedCodexThreadSource(row.source)) return undefined; const updatedAtMs = normalizeEpochMs(row.updated_at_ms) ?? normalizeEpochMs(row.updated_at) ?? 0; const title = sanitizeForeignTitle(row.title) || sanitizeForeignTitle(row.first_user_message) || row.id; diff --git a/packages/storage/src/__tests__/codex-session-adapter.test.ts b/packages/storage/src/__tests__/codex-session-adapter.test.ts index e9d92a7951..7add9b40ca 100644 --- a/packages/storage/src/__tests__/codex-session-adapter.test.ts +++ b/packages/storage/src/__tests__/codex-session-adapter.test.ts @@ -129,6 +129,64 @@ describe('CodexSessionAdapter', () => { }); }); + test('lists every thread source the foreign-session scanner accepts (#3693)', async () => { + // The adapter owned its own token set, so bare `atlas`/`chatgpt` and a + // wrapped `{"custom":"cli"}` were dropped here while the scanner in + // `@maka/core/foreign-session` listed them. Both gates now share one + // authority, so the catalog and the scan agree on every shape. + await withCodexHome(async (codexHome) => { + const sources = ['cli', 'exec', 'vscode', 'atlas', 'chatgpt'] as const; + const rows: StateRow[] = []; + for (const [index, source] of sources.entries()) { + const bareId = `codex-bare-${source}`; + const wrappedId = `codex-wrapped-${source}`; + rows.push({ + id: bareId, + rolloutPath: await seedMinimalRollout(codexHome, bareId, false, '/workspace', 'Task'), + cwd: '/workspace', + name: `bare ${source}`, + createdAtMs: 1000 + index, + updatedAtMs: 3000 + index, + archived: false, + source, + }); + rows.push({ + id: wrappedId, + rolloutPath: await seedMinimalRollout(codexHome, wrappedId, false, '/workspace', 'Task'), + cwd: '/workspace', + name: `wrapped ${source}`, + createdAtMs: 1100 + index, + updatedAtMs: 3100 + index, + archived: false, + source: JSON.stringify({ custom: source }), + }); + } + const subagentId = 'codex-subagent-drop'; + rows.push({ + id: subagentId, + rolloutPath: await seedMinimalRollout(codexHome, subagentId, false, '/workspace', 'Task'), + cwd: '/workspace', + name: 'internal child', + createdAtMs: 2000, + updatedAtMs: 4000, + archived: false, + source: '{"subagent":{"thread_spawn":{"parent_thread_id":"parent"}}}', + }); + await seedStateDatabase(codexHome, rows); + + const listed = new Set( + (await new CodexSessionAdapter({ codexHome }).listSessions()).map((session) => session.id), + ); + for (const source of sources) { + assert.ok(listed.has(`codex-bare-${source}`), `bare ${source} was dropped`); + assert.ok(listed.has(`codex-wrapped-${source}`), `wrapped ${source} was dropped`); + } + // Internal subagent threads stay out of the catalog. + assert.equal(listed.has(subagentId), false); + assert.equal(listed.size, sources.length * 2); + }); + }); + test('a Windows path spelling reaches the matcher instead of being lost in SQL', async () => { // The SQL used to prefilter with `cwd IN ()`, and // SQLite compares those exactly — a row stored `C:\\Repo\\App` was diff --git a/packages/storage/src/__tests__/foreign-session-store.test.ts b/packages/storage/src/__tests__/foreign-session-store.test.ts index 81e268ead4..72b8f6a86c 100644 --- a/packages/storage/src/__tests__/foreign-session-store.test.ts +++ b/packages/storage/src/__tests__/foreign-session-store.test.ts @@ -112,7 +112,7 @@ type CodexThreadSeed = { title?: string; updatedAtMs?: number; archived?: number; - source?: string; + source?: string | null; rolloutRelPath?: string; }; @@ -179,7 +179,7 @@ async function seedCodexSqliteGen( t.title ?? null, t.updatedAtMs ?? NOW - 60_000, t.archived ?? 0, - t.source ?? 'cli', + t.source === undefined ? 'cli' : t.source, ); } db.close(); @@ -351,6 +351,28 @@ describe('foreign session store — Codex scan', () => { assert.deepEqual((await store.listSessions()).map((s) => s.id).sort(), ['atl', 'gpt']); }); + it('routes every supported sqlite source shape through the shared gate', async () => { + const home = await tempHome(); + await seedCodexSqlite(home, [ + { id: 'bare-exec', cwd: '/repo', source: 'exec' }, + { id: 'bare-atlas', cwd: '/repo', source: 'atlas' }, + { id: 'bare-chatgpt', cwd: '/repo', source: 'chatgpt' }, + { id: 'wrapped-cli', cwd: '/repo', source: '{ "custom": "cli" }' }, + { id: 'wrapped-vscode', cwd: '/repo', source: '{"custom":"vscode"}' }, + { id: 'legacy-null', cwd: '/repo', source: null }, + { id: 'unsupported', cwd: '/repo', source: '{"custom":"other"}' }, + ]); + const store = createForeignSessionStore({ homeDir: home, env: {} }); + assert.deepEqual((await store.listSessions()).map((session) => session.id).sort(), [ + 'bare-atlas', + 'bare-chatgpt', + 'bare-exec', + 'legacy-null', + 'wrapped-cli', + 'wrapped-vscode', + ]); + }); + it('rejects rollout paths that escape ~/.codex', async () => { const home = await tempHome(); const outside = join(home, 'outside.jsonl'); diff --git a/packages/storage/src/codex-session-adapter.ts b/packages/storage/src/codex-session-adapter.ts index be3c48c4bc..4c3164db69 100644 --- a/packages/storage/src/codex-session-adapter.ts +++ b/packages/storage/src/codex-session-adapter.ts @@ -22,7 +22,7 @@ import { open, readdir, realpath, stat } from 'node:fs/promises'; import { homedir } from 'node:os'; import { basename, join, resolve, sep } from 'node:path'; import type { StoredMessage } from '@maka/core/session'; -import { sanitizeForeignTitle } from '@maka/core/foreign-session'; +import { isSupportedCodexThreadSource, sanitizeForeignTitle } from '@maka/core/foreign-session'; import { externalSessionMatchesQuery } from '@maka/core/external-session'; import type { ExternalMakaSession, @@ -38,7 +38,6 @@ const CODEX_ROLLOUT_HEAD_BYTES = 512 * 1024; const CODEX_SESSION_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/; const CODEX_UNSAFE_PATH_CHARS = /[\u0000-\u001F\u007F\u0080-\u009F\u061C\u200B-\u200F\u202A-\u202E\u2060-\u2064\u2066-\u2069\uFEFF]/; -const CODEX_ROOT_SOURCE_TOKENS = new Set(['cli', 'exec', 'vscode']); export interface CodexSessionAdapterOptions { /** Codex's state root. Defaults to `$CODEX_HOME`, then `~/.codex`. */ @@ -156,7 +155,7 @@ export class CodexSessionAdapter implements ExternalSessionAdapter { private async entryFromRow(row: CodexThreadRow): Promise { if (!isSafeCodexSessionId(row.id)) return undefined; if (typeof row.rollout_path !== 'string' || row.rollout_path.length === 0) return undefined; - if (!isRootCodexSource(row.source)) return undefined; + if (!isSupportedCodexThreadSource(row.source)) return undefined; const rolloutPath = await this.resolveRolloutPath(row.rollout_path, row.id); if (!rolloutPath) return undefined; @@ -575,7 +574,7 @@ function catalogEntryFromRolloutHead( const payload = asRecord(record.payload); if (!payload) continue; if (record.type === 'session_meta') { - if (!isRootCodexSource(payload.source)) return undefined; + if (!isSupportedCodexThreadSource(payload.source)) return undefined; id = stringField(payload, 'session_id') ?? stringField(payload, 'id') ?? id; cwd = safeCodexCwd(payload.cwd) || cwd; createdAt = @@ -794,21 +793,6 @@ function firstNonEmptyTitle(...values: unknown[]): string | undefined { return undefined; } -function isRootCodexSource(value: unknown): boolean { - if (value === undefined || value === null) return true; - if (typeof value === 'string') { - if (CODEX_ROOT_SOURCE_TOKENS.has(value)) return true; - if (!value.startsWith('{')) return false; - try { - return isRootCodexSource(JSON.parse(value) as unknown); - } catch { - return false; - } - } - if (!isRecord(value)) return false; - return value.custom === 'atlas' || value.custom === 'chatgpt'; -} - function codexErrorAffectsTurnStatus(payload: JsonRecord): boolean { const info = payload.codex_error_info; if (info === 'thread_rollback_failed' || info === 'active_turn_not_steerable') return false; diff --git a/packages/storage/src/foreign-session-store.ts b/packages/storage/src/foreign-session-store.ts index d130110634..e74db57c9b 100644 --- a/packages/storage/src/foreign-session-store.ts +++ b/packages/storage/src/foreign-session-store.ts @@ -47,6 +47,7 @@ import { open, readdir, realpath, stat, type FileHandle } from 'node:fs/promises import { homedir } from 'node:os'; import { basename, join, resolve, sep } from 'node:path'; import { + CODEX_SUPPORTED_THREAD_SOURCES, FOREIGN_SESSION_DIGEST_MAX_READ_BYTES, FOREIGN_SESSION_HEAD_BYTES, FOREIGN_SESSION_SCAN_MAX_AGE_MS, @@ -537,13 +538,6 @@ async function codexStateDbsNewestFirst(codexRoot: string): Promise { } } -/** - * Codex source tokens as stored in the DB — bare for cli/vscode, JSON-wrapped - * for the `custom` variants. Used as bound `source IN (…)` params so archived - * / foreign-source rows are excluded IN SQL (before LIMIT), not after. - */ -const CODEX_SOURCE_SQL_VALUES = ['cli', 'vscode', '{"custom":"atlas"}', '{"custom":"chatgpt"}']; - /** * Read candidate thread rows from one state DB, filtered and ordered in SQL. * undefined = DB unusable (cannot open, or lacks the id/rollout_path columns) @@ -582,8 +576,21 @@ async function readCodexThreadRows( const params: string[] = []; if (columns.has('archived')) where.push('(archived IS NULL OR archived = 0)'); if (columns.has('source')) { - where.push(`source IN (${CODEX_SOURCE_SQL_VALUES.map(() => '?').join(', ')})`); - params.push(...CODEX_SOURCE_SQL_VALUES); + const sourceTokens = [...CODEX_SUPPORTED_THREAD_SOURCES]; + const placeholders = sourceTokens.map(() => '?').join(', '); + // Keep unsupported rows from consuming the bounded SQL window, but + // derive this coarse prefilter from the same token authority as + // normalizeCodexThreadRow(). The JS gate remains authoritative over + // exact shapes after bare, wrapped-custom, and legacy NULL sources + // have survived the query. + where.push(`( + source IS NULL + OR source IN (${placeholders}) + OR CASE WHEN json_valid(source) + THEN json_extract(source, '$.custom') + END IN (${placeholders}) + )`); + params.push(...sourceTokens, ...sourceTokens); } // Filter cwd IN SQL, before LIMIT: otherwise a multi-project store with // many newer threads from other directories fills the LIMIT window and From ef073ff933b93d46d6e169e7068284ef5c1293a3 Mon Sep 17 00:00:00 2001 From: NekoPunch <95899648+orangeCatDeveloper@users.noreply.github.com> Date: Sun, 30 Aug 2026 04:16:03 -0700 Subject: [PATCH 13/71] refactor(cli): centralize localized TUI copy (#3990) Centralize CLI/TUI user-facing copy and preserve stable localized message contracts across locales. --- package-lock.json | 5 +- packages/cli/THIRD_PARTY_NOTICES.txt | 96 +++++ .../cli/src/__tests__/pi-tui-runner.test.ts | 129 +++++- .../__tests__/runtime-host-onboarding.test.ts | 54 ++- .../src/__tests__/tui-copy-catalog.test.ts | 121 ++++++ packages/cli/src/pi-tui-contracts.ts | 43 +- packages/cli/src/pi-tui-mcp-status.ts | 103 +++-- packages/cli/src/pi-tui-pickers.ts | 264 ++++++++---- packages/cli/src/pi-tui-runner.ts | 62 ++- packages/cli/src/runtime-host-onboarding.ts | 52 +-- packages/cli/src/tui-copy-catalog.ts | 380 ++++++++++++++++++ packages/cli/src/tui-primary-guidance.ts | 112 +----- packages/cli/src/tui-session-status.ts | 28 +- packages/core/package.json | 3 + packages/core/src/__tests__/ui-locale.test.ts | 68 ++++ packages/core/src/ui-locale.ts | 91 +++++ 16 files changed, 1259 insertions(+), 352 deletions(-) create mode 100644 packages/cli/src/__tests__/tui-copy-catalog.test.ts create mode 100644 packages/cli/src/tui-copy-catalog.ts create mode 100644 packages/core/src/__tests__/ui-locale.test.ts diff --git a/package-lock.json b/package-lock.json index c0c1f26999..c20fb1d782 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14265,7 +14265,10 @@ "packages/core": { "name": "@maka/core", "version": "0.1.0", - "license": "Apache-2.0" + "license": "Apache-2.0", + "dependencies": { + "intl-messageformat": "11.2.12" + } }, "packages/eval": { "name": "@maka/eval", diff --git a/packages/cli/THIRD_PARTY_NOTICES.txt b/packages/cli/THIRD_PARTY_NOTICES.txt index d1b9143775..f1950bb8bd 100644 --- a/packages/cli/THIRD_PARTY_NOTICES.txt +++ b/packages/cli/THIRD_PARTY_NOTICES.txt @@ -878,6 +878,60 @@ SOFTWARE. ================================================================================ +Package: @formatjs/fast-memoize@3.1.7 +Declared license: MIT +Selected license: MIT +Repository: formatjs/formatjs.git + +--- LICENSE.md --- +MIT License + +Copyright (c) 2023 FormatJS + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +================================================================================ + +Package: @formatjs/icu-messageformat-parser@3.5.15 +Declared license: MIT +Selected license: MIT +Repository: https://github.com/formatjs/formatjs.git#packages/icu-messageformat-parser + +--- LICENSE.md --- +MIT License + +Copyright (c) 2023 FormatJS + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +================================================================================ + +Package: @formatjs/icu-skeleton-parser@2.1.11 +Declared license: MIT +Selected license: MIT +Repository: https://github.com/formatjs/formatjs.git#packages/icu-skeleton-parser + +--- LICENSE.md --- +MIT License + +Copyright (c) 2023 FormatJS + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +================================================================================ + Package: @larksuiteoapi/node-sdk@1.73.0 Declared license: MIT Selected license: MIT @@ -4751,6 +4805,48 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI ================================================================================ +Package: intl-messageformat@11.2.12 +Declared license: BSD-3-Clause +Selected license: BSD-3-Clause +Repository: git@github.com:formatjs/formatjs.git + +--- LICENSE.md --- +Copyright (c) 2023, Oath Inc. + +Licensed under the terms of the New BSD license. See below for terms. + +Redistribution and use of this software in source and binary forms, +with or without modification, are permitted provided that the following +conditions are met: + +- Redistributions of source code must retain the above + copyright notice, this list of conditions and the + following disclaimer. + +- Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the + following disclaimer in the documentation and/or other + materials provided with the distribution. + +- Neither the name of Oath Inc. nor the names of its + contributors may be used to endorse or promote products + derived from this software without specific prior + written permission of Oath Inc. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS +IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED +TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A +PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +================================================================================ + Package: ip-address@10.4.0 Declared license: MIT Selected license: MIT diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts index f6db8e7598..bc0524f253 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -181,12 +181,12 @@ function historicalGraphSnapshot(graphId: string): AgentGraphClientSnapshot { } /** Catalog API-key providers as wizard entries with no existing connection — - * the default `/setup` provider list for tests that don't need 已设置 state. */ + * the default `/setup` provider list for tests that don't need configured state. */ function defaultOnboardingProviders(): OnboardingProviderEntry[] { return listApiKeyOnboardableProviders().map((provider) => ({ ...provider, target: { kind: 'create', providerType: provider.providerType }, - label: `${provider.label} · 添加账号`, + label: provider.label, enabledModelIds: [], })); } @@ -238,7 +238,7 @@ function savedOnboardingRefreshFailed(connectionId = 'saved-connection-id'): Onb }, refresh: { kind: 'failed', - warning: '账号已保存,但模型列表暂未刷新。重启 Maka 后会重新载入。', + reason: 'catalog_unavailable', }, }; } @@ -795,12 +795,13 @@ describe('Maka Pi TUI runner', () => { model: 'claude-sonnet-4-5', connectionSlug: 'claude-subscription', permissionMode: 'bypass', + locale: 'zh', terminal, onboarding: fakeOnboardingSurface({ verify: async (input) => { verifyCalls.push(input.apiKey ?? ''); return verifyCalls.length === 1 - ? { kind: 'error', text: 'HTTP 401 Unauthorized' } + ? { kind: 'rejected', reason: 'connection_not_found' } : { kind: 'ok', models: [{ id: 'gpt-5.5' }] }; }, }), @@ -811,7 +812,7 @@ describe('Maka Pi TUI runner', () => { terminal.input('\r'); await waitFor(() => { try { - return latestPlainLineContaining(terminal.writes.join(''), 'Set Up Provider') !== null; + return latestPlainLineContaining(terminal.writes.join(''), '配置模型提供商') !== null; } catch { return false; } @@ -829,7 +830,7 @@ describe('Maka Pi TUI runner', () => { await waitFor(() => verifyCalls.length === 1); await waitFor(() => { try { - return latestPlainLineContaining(terminal.writes.join(''), '验证失败') !== null; + return latestPlainLineContaining(terminal.writes.join(''), '该连接已不存在') !== null; } catch { return false; } @@ -850,6 +851,51 @@ describe('Maka Pi TUI runner', () => { ]); }); + test('localizes a save rejection without displaying Host text', async () => { + const terminal = new FakeTerminal(); + const driver = new SlashCommandDriver(); + let saveCalls = 0; + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'bypass', + locale: 'zh', + terminal, + onboarding: fakeOnboardingSurface({ + save: async () => { + saveCalls += 1; + return { kind: 'rejected', reason: 'superseded' }; + }, + }), + }); + + await waitForTuiPaint(terminal); + terminal.input('/setup'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('配置模型提供商')); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('API key')); + terminal.input('sk-test'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('3/3')); + terminal.input(' '); + terminal.input('\r'); + await waitFor(() => saveCalls === 1); + await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('该连接已发生变化')); + assert.doesNotMatch(plainTerminalOutput(terminal.screenOutput()), /This connection changed/); + + process.emit('SIGTERM'); + await Promise.race([ + run, + delay(CLOSE_BUDGET_MS).then(() => { + throw new Error('TUI did not close after SIGTERM'); + }), + ]); + }); + test('threads the selected same-provider account identity through verify and save', async () => { const terminal = new FakeTerminal(); const driver = new SlashCommandDriver(); @@ -1024,7 +1070,9 @@ describe('Maka Pi TUI runner', () => { terminal.input('\r'); await waitFor(() => { try { - return latestPlainLineContaining(terminal.writes.join(''), 'Onboarding 不可用') !== null; + return ( + latestPlainLineContaining(terminal.writes.join(''), 'Onboarding is unavailable') !== null + ); } catch { return false; } @@ -1063,11 +1111,14 @@ describe('Maka Pi TUI runner', () => { terminal.input('/setup'); terminal.input('\r'); await waitFor(() => - plainTerminalOutput(terminal.screenOutput()).includes('storage read failed'), + plainTerminalOutput(terminal.screenOutput()).includes( + 'Could not read configured connections', + ), ); + assert.doesNotMatch(plainTerminalOutput(terminal.screenOutput()), /storage read failed/); assert.doesNotMatch( plainTerminalOutput(terminal.screenOutput()), - /没有可配置的 API key 类供应商/, + /No configurable API key providers are available/, ); process.emit('SIGTERM'); @@ -1127,7 +1178,7 @@ describe('Maka Pi TUI runner', () => { await waitFor(() => verifyCalls.length === 1); await waitFor(() => { try { - return latestPlainLineContaining(terminal.writes.join(''), '验证') !== null; + return latestPlainLineContaining(terminal.writes.join(''), 'Verifying') !== null; } catch { return false; } @@ -1155,7 +1206,7 @@ describe('Maka Pi TUI runner', () => { await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('sk-b')); // A's verify now resolves with a failure. It must not clobber attempt B: // no failure status line, and the key being typed for B survives. - resolveFirst({ kind: 'error', text: 'HTTP 401 Unauthorized' }); + resolveFirst({ kind: 'failed', errorClass: 'auth' }); // Sentinel render: one more typed key char forces a full repaint that lands // after A's settled continuation, so a wrongly-applied failure line would be // in this exact frame. @@ -1224,7 +1275,7 @@ describe('Maka Pi TUI runner', () => { await waitFor(() => saveCalls.length === 1); await waitFor(() => { try { - return latestPlainLineContaining(terminal.writes.join(''), '保存') !== null; + return latestPlainLineContaining(terminal.writes.join(''), 'Saving') !== null; } catch { return false; } @@ -1247,7 +1298,7 @@ describe('Maka Pi TUI runner', () => { // would be in this exact frame. terminal.input('sk-z'); await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('sk-z')); - assert.doesNotMatch(plainTerminalOutput(terminal.screenOutput()), /已启用/); + assert.doesNotMatch(plainTerminalOutput(terminal.screenOutput()), /Models enabled/u); terminal.input('\r'); // verify the now-existing Connection await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('3/3')); @@ -1373,7 +1424,7 @@ describe('Maka Pi TUI runner', () => { // A malformed endpoint is rejected in place, before any host call. terminal.input('not a url'); terminal.input('\r'); - await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('不是有效的 URL')); + await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('not a valid URL')); for (let i = 0; i < 'not a url'.length; i++) terminal.input('\x7f'); // clear the field terminal.input('https://relay.example.test/v1'); terminal.input('\r'); @@ -1393,8 +1444,11 @@ describe('Maka Pi TUI runner', () => { terminal.input('\r'); // save await waitFor(() => saveCalls.length === 1); assert.equal(saveCalls[0]?.baseUrl, 'https://relay.example.test/v1'); - await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('已启用')); - assert.match(plainTerminalOutput(terminal.screenOutput()), /账号已保存,但模型列表暂未刷新/); + await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('Models enabled')); + assert.match( + plainTerminalOutput(terminal.screenOutput()), + /Account saved, but the model list has not refreshed/, + ); process.emit('SIGTERM'); await Promise.race([ @@ -3595,6 +3649,42 @@ describe('Maka Pi TUI runner', () => { ]); }); + test('localizes generic model and thinking picker hints in Chinese', async () => { + const terminal = new FakeTerminal(); + const driver = new SlashCommandDriver(); + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'gpt-5', + models: ['gpt-5', 'gpt-5-mini'], + connectionSlug: 'openai', + providerType: 'openai', + permissionMode: 'ask', + locale: 'zh', + terminal, + }); + + terminal.input('/model'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('选择模型')); + assert.match(plainTerminalOutput(terminal.screenOutput()), /↑↓ 选择 · Enter 确认 · Esc 关闭/u); + terminal.input('\x1b'); + + terminal.input('/thinking'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.screenOutput()).includes('选择思考级别')); + assert.match(plainTerminalOutput(terminal.screenOutput()), /↑↓ 选择 · Enter 确认 · Esc 关闭/u); + assert.doesNotMatch( + plainTerminalOutput(terminal.screenOutput()), + /enter select \/ esc close/iu, + ); + + terminal.input('\x1b'); + exitMaka(terminal); + await run; + }); + test('resumes a read-only session as Read only, and never marks Auto as current', async () => { // #1611 in the TUI: the resumed boundary is read-only, so the status line // must name it and the picker must not present Auto as "the option you are @@ -3657,6 +3747,7 @@ describe('Maka Pi TUI runner', () => { connectionId: 'connection-openai', connectionSlug: 'openai', providerType: 'openai', + locale: 'en', modelChoices: [ { connectionId: 'connection-openai', @@ -3691,7 +3782,7 @@ describe('Maka Pi TUI runner', () => { await waitFor(() => terminal.output().includes('GLM 5.2')); assert.match( plainTerminalOutput(terminal.screenOutput()), - /切换模型可能需要重建提示缓存;下一次请求可能更慢或成本更高/, + /Switching models may rebuild the prompt cache; the next request may be slower/, ); // The picker opens on the current model (gpt-5.5); move down to the choice on // the other connection and select it. @@ -3773,7 +3864,7 @@ describe('Maka Pi TUI runner', () => { }); assert.doesNotMatch( plainTerminalOutput(terminal.screenOutput()), - /切换模型可能需要重建提示缓存/, + /Switching models may rebuild the prompt cache/, ); // Each query isolates exactly one of the five match criteria named by #1098 // (model id, connection name, connection slug, provider type, provider @@ -3855,7 +3946,7 @@ describe('Maka Pi TUI runner', () => { await waitFor(() => terminal.output().includes('gpt-5.6')); assert.doesNotMatch( plainTerminalOutput(terminal.screenOutput()), - /切换模型可能需要重建提示缓存/, + /Switching models may rebuild the prompt cache/, ); terminal.input('\x1b[B'); terminal.input('\r'); diff --git a/packages/cli/src/__tests__/runtime-host-onboarding.test.ts b/packages/cli/src/__tests__/runtime-host-onboarding.test.ts index 105b80ffb4..2da8d3cbed 100644 --- a/packages/cli/src/__tests__/runtime-host-onboarding.test.ts +++ b/packages/cli/src/__tests__/runtime-host-onboarding.test.ts @@ -43,6 +43,54 @@ const live = { } as const; describe('createRuntimeHostOnboardingSurface', () => { + test('preserves Host failure codes without projecting backend text', async () => { + const connection = { + request: async (operation: string) => { + if (operation === 'connection.onboarding.verify') { + return { kind: 'rejected', reason: 'connection_not_found' }; + } + if (operation === 'connection.onboarding.save') { + return { kind: 'failed', errorClass: 'network' }; + } + throw new Error(`Unexpected operation ${operation}`); + }, + } as unknown as RuntimeHostConnection; + const surface = createRuntimeHostOnboardingSurface(connection); + + assert.deepEqual( + await surface.verify({ + target: { kind: 'existing', connectionId: 'gone-id' }, + apiKey: 'sk-test', + }), + { kind: 'rejected', reason: 'connection_not_found' }, + ); + assert.deepEqual( + await surface.save({ + target: { kind: 'existing', connectionId: 'live-id' }, + apiKey: 'sk-test', + enabledModelIds: ['gpt-5-mini'], + models: [{ id: 'gpt-5-mini' }], + }), + { kind: 'failed', errorClass: 'network' }, + ); + }); + + test('classifies transport exceptions without exposing their message', async () => { + const connection = { + request: async () => { + throw new Error('Host transport leaked this English detail'); + }, + } as unknown as RuntimeHostConnection; + + assert.deepEqual( + await createRuntimeHostOnboardingSurface(connection).verify({ + target: { kind: 'create', providerType: 'openai' }, + apiKey: 'sk-test', + }), + { kind: 'unavailable' }, + ); + }); + test('keeps the committed Connection when the follow-up catalog refresh fails', async () => { const committed = { connectionId: 'committed-openai-id', @@ -74,7 +122,7 @@ describe('createRuntimeHostOnboardingSurface', () => { connection: committed, refresh: { kind: 'failed', - warning: '账号已保存,但模型列表暂未刷新。重启 Maka 后会重新载入。', + reason: 'catalog_unavailable', }, }); }); @@ -147,6 +195,10 @@ describe('projectProviders', () => { kind: 'create', providerType: 'openai-compatible', }); + assert.equal( + entries.find(({ target }) => target.kind === 'create')?.label, + 'Custom relay (OpenAI Chat-compatible)', + ); }); test('several non-canonical connections remain independently editable', () => { diff --git a/packages/cli/src/__tests__/tui-copy-catalog.test.ts b/packages/cli/src/__tests__/tui-copy-catalog.test.ts new file mode 100644 index 0000000000..b59509f2a4 --- /dev/null +++ b/packages/cli/src/__tests__/tui-copy-catalog.test.ts @@ -0,0 +1,121 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; +import { formatUiMessage } from '@maka/core/ui-locale'; +import { getTuiPickerCopy, onboardingFailureMessage } from '../pi-tui-pickers.js'; +import { TUI_COPY_RESOURCES } from '../tui-copy-catalog.js'; + +const MESSAGE_VALUES = { + count: 2, + detail: 'HTTP 401', + hasDetail: true, +} as const; + +describe('TUI copy resources', () => { + test('registers every domain without a locale-specific getter branch', () => { + for (const [domain, catalog] of Object.entries(TUI_COPY_RESOURCES)) { + assert.ok(catalog.en, `${domain}/en`); + assert.ok(catalog.zh, `${domain}/zh`); + } + }); + + test('keeps Chinese coverage, variables, and ICU formatting aligned with English', () => { + for (const [domain, catalog] of Object.entries(TUI_COPY_RESOURCES)) { + const enLeaves = new Map(leafEntries(catalog.en)); + const zhLeaves = new Map(leafEntries(catalog.zh)); + + assert.deepEqual([...zhLeaves.keys()].sort(), [...enLeaves.keys()].sort(), `${domain}/zh`); + for (const [path, enTemplate] of enLeaves) { + const zhTemplate = zhLeaves.get(path)!; + const enVariables = messageVariables(enTemplate); + const zhVariables = messageVariables(zhTemplate); + assert.deepEqual(zhVariables, enVariables, `${domain}/zh/${path}`); + if (enVariables.length === 0) continue; + assert.notEqual( + formatUiMessage(enTemplate, MESSAGE_VALUES, 'en'), + enTemplate, + `${domain}/en/${path}`, + ); + assert.notEqual( + formatUiMessage(zhTemplate, MESSAGE_VALUES, 'zh'), + zhTemplate, + `${domain}/zh/${path}`, + ); + } + } + }); + + test('keeps model picker copy locale-specific', () => { + assert.equal(getTuiPickerCopy('en').modelPickerTitle, 'Select Model'); + assert.equal(getTuiPickerCopy('en').searchLabel, 'Search'); + assert.equal(getTuiPickerCopy('zh').modelPickerTitle, '选择模型'); + assert.equal(getTuiPickerCopy('zh').searchLabel, '搜索'); + assert.equal(getTuiPickerCopy('zh').selectPickerHint, '↑↓ 选择 · Enter 确认 · Esc 关闭'); + assert.equal(getTuiPickerCopy('zh').currentMarker, '当前'); + assert.equal(getTuiPickerCopy('zh').defaultMarker, '默认'); + }); + + test('formats the English MCP count with ICU plural rules', () => { + const template = TUI_COPY_RESOURCES['mcp-status'].en.toolCount; + + assert.equal(formatUiMessage(template, { count: 1 }, 'en'), '1 tool'); + assert.equal(formatUiMessage(template, { count: 2 }, 'en'), '2 tools'); + }); + + test('localizes stable onboarding failure codes at the TUI boundary', () => { + assert.equal( + onboardingFailureMessage({ kind: 'rejected', reason: 'connection_not_found' }, 'en'), + 'This connection no longer exists. Reopen /setup and try again.', + ); + assert.equal( + onboardingFailureMessage({ kind: 'rejected', reason: 'connection_not_found' }, 'zh'), + '该连接已不存在,请重新打开 /setup 后重试。', + ); + assert.equal( + onboardingFailureMessage({ kind: 'failed', errorClass: 'auth' }, 'zh'), + '服务商身份验证失败,请检查 API key 后重试。', + ); + assert.equal( + onboardingFailureMessage({ kind: 'unavailable' }, 'zh'), + '无法连接 Runtime Host,请重试。', + ); + }); +}); + +function leafEntries(value: unknown, prefix = ''): [string, string][] { + if (Array.isArray(value)) { + return value.flatMap((item, index) => leafEntries(item, `${prefix}[${index}]`)); + } + if (value && typeof value === 'object') { + return Object.entries(value).flatMap(([key, item]) => + leafEntries(item, prefix ? `${prefix}.${key}` : key), + ); + } + return [[prefix, String(value)]]; +} + +function messageVariables(template: string): string[] { + return [ + ...new Set( + [...template.matchAll(/\{([A-Za-z][A-Za-z0-9]*)(?=[,}])/gu)].map((match) => match[1]!), + ), + ].sort(); +} diff --git a/packages/cli/src/pi-tui-contracts.ts b/packages/cli/src/pi-tui-contracts.ts index 7bbf0de444..957c75ae56 100644 --- a/packages/cli/src/pi-tui-contracts.ts +++ b/packages/cli/src/pi-tui-contracts.ts @@ -21,6 +21,11 @@ import type { ForeignSessionDigest, ForeignSessionSummary } from '@maka/core/for import type { ModelInfo, ProviderType } from '@maka/core/llm-connections'; import type { ThinkingLevel } from '@maka/core/model-thinking'; import type { ConnectionOnboardingTarget } from '@maka/core/runtime-policy'; +import type { + ConnectionEffectFailureClass, + ConnectionOnboardingSaveResult as RuntimeHostOnboardingSaveResult, + ConnectionOnboardingVerifyResult as RuntimeHostOnboardingVerifyResult, +} from '@maka/runtime-host/protocol'; import type { MakaPiTuiTurnActivity } from './pi-tui-turn.js'; export interface ModelChoice { @@ -73,16 +78,32 @@ export interface OnboardingVerifyInput { baseUrl?: string; } +export type OnboardingVerifyRejectionReason = Extract< + RuntimeHostOnboardingVerifyResult, + { readonly kind: 'rejected' } +>['reason']; + +export type OnboardingSaveRejectionReason = Extract< + RuntimeHostOnboardingSaveResult, + { readonly kind: 'rejected' } +>['reason']; + +export type OnboardingRejectionReason = + | OnboardingVerifyRejectionReason + | OnboardingSaveRejectionReason; + +export type OnboardingFailureClass = ConnectionEffectFailureClass; + +export type OnboardingFailure = + | { kind: 'rejected'; reason: OnboardingRejectionReason } + | { kind: 'failed'; errorClass: ConnectionEffectFailureClass } + | { kind: 'unavailable' }; + export type OnboardingVerifyResult = | { kind: 'ok'; models: ModelInfo[] } - | { - kind: 'error'; - text: string; - /** The wizard's provider snapshot is outdated (e.g. the targeted - * connection is gone) — retyping the key cannot fix this, so the - * runner shows the text without its retype-the-key framing. */ - stale?: boolean; - }; + | { kind: 'rejected'; reason: OnboardingVerifyRejectionReason } + | { kind: 'failed'; errorClass: ConnectionEffectFailureClass } + | { kind: 'unavailable' }; export interface OnboardingSaveInput { target: ConnectionOnboardingTarget; @@ -104,9 +125,11 @@ export type OnboardingSaveResult = | { kind: 'ok'; connection: OnboardingSavedConnection; - refresh: { kind: 'ok'; modelChoices: ModelChoice[] } | { kind: 'failed'; warning: string }; + refresh: + | { kind: 'ok'; modelChoices: ModelChoice[] } + | { kind: 'failed'; reason: 'catalog_unavailable' }; } - | { kind: 'error'; text: string }; + | OnboardingFailure; export interface MakaOnboardingSurface { listProviders(): Promise; diff --git a/packages/cli/src/pi-tui-mcp-status.ts b/packages/cli/src/pi-tui-mcp-status.ts index a4f967eb7a..8f9f03b9b1 100644 --- a/packages/cli/src/pi-tui-mcp-status.ts +++ b/packages/cli/src/pi-tui-mcp-status.ts @@ -27,7 +27,12 @@ import { type TUI, } from '@earendil-works/pi-tui'; import type { McpProtocolPreference, McpServerConfig } from '@maka/core/mcp'; -import type { UiLocale } from '@maka/core/ui-locale'; +import { + defineUiMessageCatalog, + formatUiMessage, + resolveUiMessageCatalog, + type UiLocale, +} from '@maka/core/ui-locale'; import { normalizeMcpConfig } from '@maka/storage/mcp-config-store'; import type { TuiMcpAction, @@ -37,9 +42,35 @@ import type { TuiMcpServerSnapshot, } from './tui-mcp-control.js'; import { ansi, editorTheme } from './tui-ansi.js'; +import { TUI_COPY_RESOURCES } from './tui-copy-catalog.js'; const CHROME_ROWS = 2; +interface TuiMcpStatusCopy { + readonly title: string; + readonly footer: { + readonly back: string; + readonly readOnly: string; + readonly manage: string; + }; + readonly unavailableTitle: string; + readonly unavailableDetail: string; + readonly loading: string; + readonly loadError: string; + readonly noServers: string; + readonly publication: Readonly< + Record['publication'], string> + >; + readonly serverState: Readonly, string>>; + readonly configuredOnly: string; + readonly configPending: string; + readonly toolCount: string; +} + +const MCP_STATUS_COPY = resolveUiMessageCatalog( + defineUiMessageCatalog()(TUI_COPY_RESOURCES['mcp-status']), +); + type GuidedDraft = { serverId: string; transport?: 'stdio' | 'remote'; @@ -171,7 +202,7 @@ export class McpManagementOverlay implements Component { const visible = document.slice(this.top, this.top + this.bodyRows); const start = visible.length === 0 ? 0 : this.top + 1; const end = visible.length === 0 ? 0 : this.top + visible.length; - const title = this.input.locale === 'zh' ? 'MCP 服务器' : 'MCP SERVERS'; + const title = MCP_STATUS_COPY[this.input.locale].title; const header = padLine( `${ansi.bold(title)} ${ansi.dim(`${start}-${end} / ${document.length}`)}`, safeWidth, @@ -463,13 +494,10 @@ export class McpManagementOverlay implements Component { } private footer(): string { - if (this.phase.kind !== 'list') return this.input.locale === 'zh' ? 'Esc 返回' : 'Esc back'; - if (!this.management()) { - return this.input.locale === 'zh' ? '↑/↓ 滚动 · q/Esc 关闭' : '↑/↓ scroll · q/Esc close'; - } - return this.input.locale === 'zh' - ? 'a 添加 · Enter 编辑 · Space 启用/停用 · t 测试 · r 重连 · d 删除 · Esc 关闭' - : 'a Add · Enter Edit · Space Enable/disable · t Test · r Reconnect · d Remove · Esc Close'; + const copy = MCP_STATUS_COPY[this.input.locale].footer; + if (this.phase.kind !== 'list') return copy.back; + if (!this.management()) return copy.readOnly; + return copy.manage; } private backToList(clearNotice = true): void { @@ -581,15 +609,9 @@ function publicationLine( snapshot: ReturnType, locale: UiLocale, ): string { - const publication = { - waiting: locale === 'zh' ? '等待发布' : 'waiting to publish', - host_unavailable: locale === 'zh' ? 'Runtime Host 重连中' : 'Runtime Host reconnecting', - publishing: locale === 'zh' ? '正在发布' : 'publishing', - published: locale === 'zh' ? '已发布' : 'published', - not_published: locale === 'zh' ? '未发布' : 'not published', - error: locale === 'zh' ? '发布失败' : 'publication failed', - }[snapshot.publication]; - const tools = locale === 'zh' ? `${snapshot.toolCount} 个工具` : `${snapshot.toolCount} tools`; + const copy = MCP_STATUS_COPY[locale]; + const publication = copy.publication[snapshot.publication]; + const tools = formatUiMessage(copy.toolCount, { count: snapshot.toolCount }, locale); return `${ansi.bold(publication)} · ${tools}`; } @@ -598,12 +620,9 @@ function serverLines(server: TuiMcpServerSnapshot, locale: UiLocale, selected: b ? `${server.negotiatedProtocol.era} ${server.negotiatedProtocol.revision}` : server.configuredProtocol; const transport = server.transport ?? server.configuredTransport; - const tools = locale === 'zh' ? `${server.toolCount} 个工具` : `${server.toolCount} tools`; - const sync = !server.synchronized - ? locale === 'zh' - ? '配置待同步' - : 'config pending' - : undefined; + const copy = MCP_STATUS_COPY[locale]; + const tools = formatUiMessage(copy.toolCount, { count: server.toolCount }, locale); + const sync = server.synchronized ? undefined : copy.configPending; const details = [stateLabel(server.state, locale), transport, protocol, tools, sync] .filter(Boolean) .join(' · '); @@ -754,15 +773,8 @@ function configurationLine(state: 'synchronizing' | 'out_of_sync', locale: UiLoc } function unavailableDocument(locale: UiLocale): string[] { - return locale === 'zh' - ? [ - ansi.yellow('当前 TUI 未连接本地 MCP 控制面。'), - '远程 Runtime Host 的客户端 MCP 工具关联将在后续版本提供。', - ] - : [ - ansi.yellow('This TUI is not connected to a local MCP control plane.'), - 'Client MCP tool association for remote Runtime Hosts is planned for a later release.', - ]; + const copy = MCP_STATUS_COPY[locale]; + return [ansi.yellow(copy.unavailableTitle), copy.unavailableDetail]; } function heading(locale: UiLocale, en: string, zh: string): string { @@ -774,21 +786,15 @@ function confirmCopy(locale: UiLocale): string { } function loadingCopy(locale: UiLocale): string { - return locale === 'zh' - ? '正在读取 mcp.json 并发现工具…' - : 'Loading mcp.json and discovering tools…'; + return MCP_STATUS_COPY[locale].loading; } function loadErrorCopy(locale: UiLocale): string { - return locale === 'zh' - ? '无法读取或应用 MCP 配置;没有向 Runtime Host 发布工具。' - : 'MCP configuration could not be loaded; no tools were published to the Runtime Host.'; + return MCP_STATUS_COPY[locale].loadError; } function emptyCopy(locale: UiLocale): string { - return locale === 'zh' - ? '尚未配置 MCP 服务器。按 a 添加。' - : 'No MCP servers are configured. Press a to add one.'; + return MCP_STATUS_COPY[locale].noServers; } function statusMarker(state: TuiMcpServerSnapshot['state']): string { @@ -799,16 +805,9 @@ function statusMarker(state: TuiMcpServerSnapshot['state']): string { } function stateLabel(state: TuiMcpServerSnapshot['state'], locale: UiLocale): string { - if (!state) return locale === 'zh' ? '仅已配置' : 'configured only'; - if (locale === 'en') return state; - return { - disabled: '已停用', - disconnected: '未连接', - connecting: '连接中', - connected: '已连接', - 'needs-auth': '需要登录', - error: '错误', - }[state]; + const copy = MCP_STATUS_COPY[locale]; + if (!state) return copy.configuredOnly; + return copy.serverState[state] ?? state; } function clamp(value: number, min: number, max: number): number { diff --git a/packages/cli/src/pi-tui-pickers.ts b/packages/cli/src/pi-tui-pickers.ts index 4e4852ff55..098e4c2133 100644 --- a/packages/cli/src/pi-tui-pickers.ts +++ b/packages/cli/src/pi-tui-pickers.ts @@ -37,10 +37,90 @@ import { import type { UserQuestionOption } from '@maka/core/user-question'; import type { PermissionMode } from '@maka/core/permission'; import type { ThinkingLevel } from '@maka/core/model-thinking'; +import { + defineUiMessageCatalog, + formatUiMessage, + resolveUiMessageCatalog, + type UiLocale, +} from '@maka/core/ui-locale'; import type { InvocableSkillEntry } from '@maka/runtime/skill-invocation'; import { PROVIDER_DEFAULTS, type ModelInfo, type ProviderType } from '@maka/core/llm-connections'; -import type { ModelChoice, OnboardingProviderEntry } from './pi-tui-contracts.js'; +import type { + ModelChoice, + OnboardingFailure, + OnboardingFailureClass, + OnboardingProviderEntry, + OnboardingRejectionReason, +} from './pi-tui-contracts.js'; import { ansi, editorTheme, selectListTheme, stripAnsi } from './tui-ansi.js'; +import { TUI_COPY_RESOURCES } from './tui-copy-catalog.js'; + +interface TuiPickerCopy { + readonly modelPickerTitle: string; + readonly modelSwitchCacheWarning: string; + readonly modelSearchHint: string; + readonly searchLabel: string; + readonly noMatchingModels: string; + readonly selectPickerHint: string; + readonly providerConfigured: string; + readonly addAccount: string; + readonly thinkingLevels: Readonly>; + readonly defaultThinkingLevel: string; + readonly thinkingPickerTitle: string; + readonly currentMarker: string; + readonly defaultMarker: string; + readonly setupTitle: string; + readonly baseUrlLabel: string; + readonly apiKeyLabel: string; + readonly onboardingUnavailable: string; + readonly onboardingRequestFailed: string; + readonly onboardingRejections: Readonly>; + readonly onboardingFailures: Readonly>; + readonly accountSavedRefreshFailed: string; + readonly listProvidersFailed: string; + readonly noConfigurableProviders: string; + readonly baseUrlRequired: string; + readonly baseUrlInvalid: string; + readonly baseUrlProtocol: string; + readonly baseUrlCredentials: string; + readonly baseUrlQuery: string; + readonly baseUrlTooLong: string; + readonly selectModelBeforeSaving: string; + readonly reuseBaseUrlHint: string; + readonly enterBaseUrlHint: string; + readonly continueAction: string; + readonly providerSearchHint: string; + readonly noMatchingProviders: string; + readonly keyHints: Readonly< + Record<'reuse' | 'enter', Readonly>> + >; + readonly submitAction: string; + readonly verifyingKey: string; + readonly modelSelectionHint: string; + readonly selectedModels: string; + readonly selectedModelsAndSave: string; + readonly saving: string; + readonly complete: string; + readonly enabledModels: string; + readonly closeAction: string; + readonly thinkingUnsupported: string; +} + +const TUI_PICKER_COPY = resolveUiMessageCatalog( + defineUiMessageCatalog()(TUI_COPY_RESOURCES.pickers), +); + +export function onboardingFailureMessage(failure: OnboardingFailure, locale: UiLocale): string { + const copy = TUI_PICKER_COPY[locale]; + switch (failure.kind) { + case 'rejected': + return copy.onboardingRejections[failure.reason]; + case 'failed': + return copy.onboardingFailures[failure.errorClass]; + case 'unavailable': + return copy.onboardingRequestFailed; + } +} export class MakaAutocompleteProvider implements AutocompleteProvider { private readonly fileProvider: CombinedAutocompleteProvider | undefined; @@ -342,7 +422,7 @@ export class PickerOverlay implements Component { private readonly input: { title: string; rightLabel: string; - hint?: string; + hint: string; notice?: string; onInput?: (data: string) => boolean; }, @@ -361,7 +441,7 @@ export class PickerOverlay implements Component { const safeWidth = Math.max(1, width); return [ padLine(`${this.input.title} ${ansi.accent(this.input.rightLabel)}`, safeWidth), - padLine(ansi.dim(this.input.hint ?? 'enter select / esc close'), safeWidth), + padLine(ansi.dim(this.input.hint), safeWidth), ...(this.input.notice ? [padLine(ansi.yellow(this.input.notice), safeWidth)] : []), padLine('', safeWidth), ...this.list.render(safeWidth).map((line) => formatPickerItemLine(line, safeWidth)), @@ -569,7 +649,9 @@ export class UserQuestionOverlay implements Component { export function modelPickerItems( currentModel: string, models: readonly string[] | undefined, + locale: UiLocale, ): SelectItem[] { + const copy = getTuiPickerCopy(locale); const ids: string[] = []; const seen = new Set(); for (const candidate of [currentModel, ...(models ?? [])]) { @@ -581,7 +663,7 @@ export function modelPickerItems( return ids.map((id) => ({ value: id, label: id, - ...(id === currentModel ? { description: 'current' } : {}), + ...(id === currentModel ? { description: copy.currentMarker } : {}), })); } @@ -634,6 +716,7 @@ export function modelChoiceConnectionLabels(choices: readonly ModelChoice[]): Ma function modelChoicePickerItems( choices: readonly ModelChoice[], current: { model: string; connectionId?: string; connectionSlug: string }, + copy: TuiPickerCopy, ): SelectItem[] { const connectionLabels = modelChoiceConnectionLabels(choices); return choices.map((choice, index) => { @@ -642,8 +725,8 @@ function modelChoicePickerItems( choice.connectionId === current.connectionId && choice.connectionSlug === current.connectionSlug; const tags = [connectionLabels.get(choice.connectionSlug) ?? choice.connectionSlug]; - if (isCurrent) tags.push('current'); - else if (choice.isDefaultConnection) tags.push('default'); + if (isCurrent) tags.push(copy.currentMarker); + else if (choice.isDefaultConnection) tags.push(copy.defaultMarker); return { value: String(index), label: choice.displayName?.trim() || choice.model, @@ -669,6 +752,7 @@ function matchesModelChoice(choice: ModelChoice, query: string): boolean { } export interface ModelSearchOverlayInput { + locale: UiLocale; choices: readonly ModelChoice[]; current: { model: string; connectionId?: string; connectionSlug: string }; showCacheWarning?: boolean; @@ -676,8 +760,9 @@ export interface ModelSearchOverlayInput { onCancel: () => void; } -export const MODEL_SWITCH_CACHE_WARNING = - '⚠ 切换模型可能需要重建提示缓存;下一次请求可能更慢或成本更高。'; +export function getTuiPickerCopy(locale: UiLocale): TuiPickerCopy { + return TUI_PICKER_COPY[locale]; +} /** * One bottom search field + a bounded single-select list, for the cross- @@ -693,11 +778,13 @@ export class ModelSearchOverlay implements Component { private filtered: readonly ModelChoice[]; private list: SelectList; private readonly initialIndex: number; + private readonly copy: TuiPickerCopy; constructor( private readonly tui: TUI, private readonly input: ModelSearchOverlayInput, ) { + this.copy = getTuiPickerCopy(input.locale); this.filtered = [...input.choices]; this.initialIndex = input.choices.findIndex( (choice) => @@ -713,7 +800,7 @@ export class ModelSearchOverlay implements Component { private buildList(): SelectList { const list = new SelectList( - modelChoicePickerItems(this.filtered, this.input.current), + modelChoicePickerItems(this.filtered, this.input.current, this.copy), 10, selectListTheme(), { minPrimaryColumnWidth: 24, maxPrimaryColumnWidth: 48 }, @@ -765,16 +852,19 @@ export class ModelSearchOverlay implements Component { const safeWidth = Math.max(1, width); this.searchEditor.focused = true; return [ - padLine(`Select Model ${ansi.accent(String(this.filtered.length))}`, safeWidth), - padLine(ansi.dim('搜索模型 / 服务商 / 连接 · ↑↓ 选择 · Enter 确认 · Esc 取消'), safeWidth), + padLine( + `${this.copy.modelPickerTitle} ${ansi.accent(String(this.filtered.length))}`, + safeWidth, + ), + padLine(ansi.dim(this.copy.modelSearchHint), safeWidth), ...(this.input.showCacheWarning - ? [padLine(ansi.yellow(MODEL_SWITCH_CACHE_WARNING), safeWidth)] + ? [padLine(ansi.yellow(this.copy.modelSwitchCacheWarning), safeWidth)] : []), padLine('', safeWidth), - ...this.renderFieldRow(this.searchEditor, '搜索', safeWidth), + ...this.renderFieldRow(this.searchEditor, this.copy.searchLabel, safeWidth), padLine('', safeWidth), ...(this.filtered.length === 0 - ? [padLine(ansi.dim('没有匹配的模型'), safeWidth)] + ? [padLine(ansi.dim(this.copy.noMatchingModels), safeWidth)] : this.list.render(safeWidth).map((line) => formatPickerItemLine(line, safeWidth))), padLine(ansi.accent('-'.repeat(safeWidth)), safeWidth), ]; @@ -832,18 +922,19 @@ export function skillPickerItems(skills: readonly InvocableSkillEntry[]): Select })); } -/** Provider search items for `/setup`, marking connections that already exist - * `已设置` so a re-onboard reads as edit/rotate rather than create. */ +/** Provider search items for `/setup`, distinguishing existing connections. */ export function onboardingProviderPickerItems( providers: readonly OnboardingProviderEntry[], + locale: UiLocale, ): SelectItem[] { + const copy = getTuiPickerCopy(locale); return providers.map((provider) => ({ value: onboardingProviderKey(provider), label: provider.label, description: 'connectionSlug' in provider - ? `${provider.providerType} · ${provider.connectionSlug} · 已设置` - : `${provider.providerType} · 添加账号`, + ? `${provider.providerType} · ${provider.connectionSlug} · ${copy.providerConfigured}` + : `${provider.providerType} · ${copy.addAccount}`, })); } @@ -853,30 +944,22 @@ function onboardingProviderKey(provider: OnboardingProviderEntry): string { : `create:${provider.target.providerType}`; } -const THINKING_LEVEL_LABELS: Record = { - off: '关', - minimal: '最小', - low: '低', - medium: '中', - high: '高', - xhigh: '超高', - max: '最高', -}; - export function thinkingLevelPickerItems( levels: readonly ThinkingLevel[], current: ThinkingLevel | undefined, + locale: UiLocale, ): SelectItem[] { + const copy = getTuiPickerCopy(locale); return [ { value: 'default', - label: '默认', - ...(current === undefined ? { description: 'current' } : {}), + label: copy.defaultThinkingLevel, + ...(current === undefined ? { description: copy.currentMarker } : {}), }, ...levels.map((level) => ({ value: level, - label: THINKING_LEVEL_LABELS[level], - ...(level === current ? { description: 'current' } : {}), + label: copy.thinkingLevels[level], + ...(level === current ? { description: copy.currentMarker } : {}), })), ]; } @@ -892,6 +975,16 @@ function padLine(text: string, width: number): string { return `${trimmed}${' '.repeat(Math.max(0, safeWidth - visibleWidth(trimmed)))}`; } +function keyEntryHint( + copy: TuiPickerCopy, + hasConnection: boolean, + returnsToBaseUrl: boolean, +): string { + const action = hasConnection ? 'reuse' : 'enter'; + const destination = returnsToBaseUrl ? 'baseUrl' : 'provider'; + return copy.keyHints[action][destination]; +} + export type OnboardingWizardPhase = 'search' | 'baseUrl' | 'key' | 'models' | 'success'; export type OnboardingWizardStatus = @@ -901,6 +994,7 @@ export type OnboardingWizardStatus = | { kind: 'saving' }; export interface OnboardingWizardInput { + locale: UiLocale; providers: readonly OnboardingProviderEntry[]; /** search→key: the user picked a provider. The runner records it — and the * existing connection's identity, when the catalog resolved one — for @@ -954,11 +1048,13 @@ export class OnboardingWizard implements Component { private modelScroll = 0; private successCount = 0; private successWarning: string | undefined; + private readonly copy: TuiPickerCopy; constructor( private readonly tui: TUI, private readonly input: OnboardingWizardInput, ) { + this.copy = getTuiPickerCopy(input.locale); this.filtered = input.providers; this.list = this.buildList(); this.searchEditor = new Editor(tui, editorTheme(), { paddingX: 0 }); @@ -986,7 +1082,7 @@ export class OnboardingWizard implements Component { private buildList(): SelectList { const list = new SelectList( - onboardingProviderPickerItems(this.filtered), + onboardingProviderPickerItems(this.filtered, this.input.locale), 10, selectListTheme(), { minPrimaryColumnWidth: 16, maxPrimaryColumnWidth: 32 }, @@ -1042,21 +1138,21 @@ export class OnboardingWizard implements Component { */ private validateBaseUrl(trimmed: string): string | null { if (!trimmed) { - return this.picked?.target.kind === 'existing' ? null : '需要填写 Base URL'; + return this.picked?.target.kind === 'existing' ? null : this.copy.baseUrlRequired; } let parsed: URL; try { parsed = new URL(trimmed); } catch { - return 'Base URL 不是有效的 URL'; + return this.copy.baseUrlInvalid; } if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { - return 'Base URL 必须使用 http 或 https'; + return this.copy.baseUrlProtocol; } - if (parsed.username || parsed.password) return 'Base URL 不能包含账号密码'; - if (trimmed.includes('?') || trimmed.includes('#')) return 'Base URL 不能包含查询串或片段'; + if (parsed.username || parsed.password) return this.copy.baseUrlCredentials; + if (trimmed.includes('?') || trimmed.includes('#')) return this.copy.baseUrlQuery; if (new TextEncoder().encode(parsed.toString()).byteLength > 2_048) { - return 'Base URL 不能超过 2048 字节'; + return this.copy.baseUrlTooLong; } return null; } @@ -1270,7 +1366,7 @@ export class OnboardingWizard implements Component { if (matchesKey(data, Key.enter) || matchesKey(data, Key.return)) { if (isKeyRepeat(data)) return; if (this.selectedIds.size === 0) { - this.status = { kind: 'error', text: '至少选择一个模型再保存' }; + this.status = { kind: 'error', text: this.copy.selectModelBeforeSaving }; return; } this.input.onSubmitModels([...this.selectedIds]); @@ -1310,7 +1406,6 @@ export class OnboardingWizard implements Component { if (!model) return; if (this.selectedIds.has(model.id)) this.selectedIds.delete(model.id); else this.selectedIds.add(model.id); - // Clear a stale "至少选择一个模型" error once a selection exists. if (this.status.kind === 'error' && this.selectedIds.size > 0) { this.status = { kind: 'prompt' }; } @@ -1340,16 +1435,21 @@ export class OnboardingWizard implements Component { const label = this.picked?.label ?? ''; const hint = this.picked?.target.kind === 'existing' - ? '留空复用已保存的 Base URL,或输入新地址替换 · Esc 返回选择服务商' - : '输入中转站的 Base URL(http/https)· Esc 返回选择服务商'; + ? this.copy.reuseBaseUrlHint + : this.copy.enterBaseUrlHint; return [ - padLine(`Set Up Provider ${ansi.dim(`· ${this.step(2)}`)} ${ansi.accent(label)}`, width), + padLine( + `${this.copy.setupTitle} ${ansi.dim(`· ${this.step(2)}`)} ${ansi.accent(label)}`, + width, + ), padLine(ansi.dim(hint), width), padLine('', width), - ...this.renderFieldRow(this.baseUrlEditor, 'Base URL', width), + ...this.renderFieldRow(this.baseUrlEditor, this.copy.baseUrlLabel, width), padLine('', width), padLine( - this.status.kind === 'error' ? ansi.red(`✗ ${this.status.text}`) : ansi.dim('Enter 继续'), + this.status.kind === 'error' + ? ansi.red(`✗ ${this.status.text}`) + : ansi.dim(this.copy.continueAction), width, ), padLine(ansi.accent('-'.repeat(width)), width), @@ -1363,15 +1463,15 @@ export class OnboardingWizard implements Component { this.modelsSearchEditor.focused = false; return [ padLine( - `Set Up Provider ${ansi.dim(`· ${this.step(1)}`)} ${ansi.accent(String(this.filtered.length))}`, + `${this.copy.setupTitle} ${ansi.dim(`· ${this.step(1)}`)} ${ansi.accent(String(this.filtered.length))}`, width, ), - padLine(ansi.dim('搜索服务商,↑↓ 选择 · Enter 确认 · Esc 取消'), width), + padLine(ansi.dim(this.copy.providerSearchHint), width), padLine('', width), - ...this.renderFieldRow(this.searchEditor, '搜索', width), + ...this.renderFieldRow(this.searchEditor, this.copy.searchLabel, width), padLine('', width), ...(this.filtered.length === 0 - ? [padLine(ansi.dim('没有匹配的服务商'), width)] + ? [padLine(ansi.dim(this.copy.noMatchingProviders), width)] : this.list.render(width).map((line) => formatPickerItemLine(line, width))), padLine(ansi.accent('-'.repeat(width)), width), ]; @@ -1383,19 +1483,19 @@ export class OnboardingWizard implements Component { this.keyEditor.focused = this.status.kind === 'prompt' || this.status.kind === 'error'; this.modelsSearchEditor.focused = false; const label = this.picked?.label ?? ''; - const backTarget = this.picked?.requiresBaseUrl ? 'Esc 返回 Base URL' : 'Esc 返回选择服务商'; - const hint = - this.picked?.target.kind === 'existing' - ? `留空复用已保存的 key,或输入新 key 轮换 · ${backTarget}` - : `输入 API key · 仅本机存储 · ${backTarget}`; + const hint = keyEntryHint( + this.copy, + this.picked?.target.kind === 'existing', + this.picked?.requiresBaseUrl === true, + ); return [ padLine( - `Set Up Provider ${ansi.dim(`· ${this.step(this.picked?.requiresBaseUrl ? 3 : 2)}`)} ${ansi.accent(label)}`, + `${this.copy.setupTitle} ${ansi.dim(`· ${this.step(this.picked?.requiresBaseUrl ? 3 : 2)}`)} ${ansi.accent(label)}`, width, ), padLine(ansi.dim(hint), width), padLine('', width), - ...this.renderFieldRow(this.keyEditor, 'API key', width), + ...this.renderFieldRow(this.keyEditor, this.copy.apiKeyLabel, width), padLine('', width), padLine(this.renderKeyStatusLine(), width), padLine(ansi.accent('-'.repeat(width)), width), @@ -1405,13 +1505,13 @@ export class OnboardingWizard implements Component { private renderKeyStatusLine(): string { switch (this.status.kind) { case 'prompt': - return ansi.dim('Enter 提交'); + return ansi.dim(this.copy.submitAction); case 'verifying': - return `${ansi.yellow('⠋')} 正在验证 key…`; + return `${ansi.yellow('⠋')} ${this.copy.verifyingKey}`; case 'error': return ansi.red(`✗ ${this.status.text}`); case 'saving': - return ansi.dim('Enter 提交'); + return ansi.dim(this.copy.submitAction); } } @@ -1423,16 +1523,16 @@ export class OnboardingWizard implements Component { const label = this.picked?.label ?? ''; const lines = [ padLine( - `Set Up Provider ${ansi.dim(`· ${this.step(this.picked?.requiresBaseUrl ? 4 : 3)}`)} ${ansi.accent(label)}`, + `${this.copy.setupTitle} ${ansi.dim(`· ${this.step(this.picked?.requiresBaseUrl ? 4 : 3)}`)} ${ansi.accent(label)}`, width, ), - padLine(ansi.dim('搜索模型,↑↓ 选择 · Space 切换 · Enter 保存 · Esc 返回'), width), + padLine(ansi.dim(this.copy.modelSelectionHint), width), padLine('', width), - ...this.renderFieldRow(this.modelsSearchEditor, '搜索', width), + ...this.renderFieldRow(this.modelsSearchEditor, this.copy.searchLabel, width), padLine('', width), ]; if (this.filteredModels.length === 0) { - lines.push(padLine(ansi.dim('没有匹配的模型'), width)); + lines.push(padLine(ansi.dim(this.copy.noMatchingModels), width)); } else { const end = Math.min( this.modelScroll + ONBOARDING_MODELS_MAX_VISIBLE, @@ -1455,11 +1555,23 @@ export class OnboardingWizard implements Component { private renderModelsStatusLine(): string { switch (this.status.kind) { case 'prompt': - return ansi.dim(`已选 ${this.selectedIds.size} · Enter 保存`); + return ansi.dim( + formatUiMessage( + this.copy.selectedModelsAndSave, + { count: this.selectedIds.size }, + this.input.locale, + ), + ); case 'verifying': - return ansi.dim(`已选 ${this.selectedIds.size}`); + return ansi.dim( + formatUiMessage( + this.copy.selectedModels, + { count: this.selectedIds.size }, + this.input.locale, + ), + ); case 'saving': - return `${ansi.yellow('⠋')} 正在保存…`; + return `${ansi.yellow('⠋')} ${this.copy.saving}`; case 'error': return ansi.red(`✗ ${this.status.text}`); } @@ -1472,11 +1584,23 @@ export class OnboardingWizard implements Component { this.modelsSearchEditor.focused = false; const label = this.picked?.label ?? ''; return [ - padLine(`Set Up Provider ${ansi.dim('· 完成')} ${ansi.accent(label)}`, width), - padLine(ansi.green(`✓ 已启用 ${this.successCount} 个模型`), width), + padLine( + `${this.copy.setupTitle} ${ansi.dim(`· ${this.copy.complete}`)} ${ansi.accent(label)}`, + width, + ), + padLine( + ansi.green( + `✓ ${formatUiMessage( + this.copy.enabledModels, + { count: this.successCount }, + this.input.locale, + )}`, + ), + width, + ), ...(this.successWarning ? [padLine(ansi.yellow(this.successWarning), width)] : []), padLine('', width), - padLine(ansi.dim('Enter 关闭'), width), + padLine(ansi.dim(this.copy.closeAction), width), padLine(ansi.accent('-'.repeat(width)), width), ]; } diff --git a/packages/cli/src/pi-tui-runner.ts b/packages/cli/src/pi-tui-runner.ts index 45f0842fd8..3580b89432 100644 --- a/packages/cli/src/pi-tui-runner.ts +++ b/packages/cli/src/pi-tui-runner.ts @@ -52,7 +52,7 @@ import { type SessionSummary, type StoredMessage, } from '@maka/core/session'; -import type { UiLocale } from '@maka/core/ui-locale'; +import { formatUiMessage, type UiLocale } from '@maka/core/ui-locale'; import { buildForeignSessionHandoffMessage, foreignSessionHandoffDisplayText, @@ -144,13 +144,14 @@ import { import { MakaAutocompleteProvider, DirectoryPickerOverlay, - MODEL_SWITCH_CACHE_WARNING, ModelSearchOverlay, OnboardingWizard, PickerOverlay, UserQuestionOverlay, modelChoiceConnectionLabels, + getTuiPickerCopy, modelPickerItems, + onboardingFailureMessage, permissionModePickerItems, skillPickerItems, thinkingLevelPickerItems, @@ -361,6 +362,7 @@ function sessionConnectionIdentityNotice( export async function runMakaPiTui(input: MakaPiTuiInput): Promise { const locale = input.locale ?? 'en'; + const pickerCopy = getTuiPickerCopy(locale); const primaryGuidance = getTuiPrimaryGuidance(locale); const terminal = input.terminal ?? new ProcessTerminal(); const taskbarProgress = resolveTaskbarProgress(input.taskbarProgress); @@ -2078,7 +2080,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { const picker = new PickerOverlay(list, { title, rightLabel, - hint: options.hint, + hint: options.hint ?? pickerCopy.selectPickerHint, notice: options.notice, }); let overlay: OverlayHandle | undefined; @@ -2116,7 +2118,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { return; } if (!input.onboarding) { - wizard.setKeyError('Onboarding 不可用:当前运行环境未提供配置入口。'); + wizard.setKeyError(pickerCopy.onboardingUnavailable); requestRender(); return; } @@ -2128,14 +2130,8 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { void input.onboarding.verify({ target, apiKey, baseUrl: wizardBaseUrl }).then( (result) => { if (closed || wizard !== targetWizard || attempt !== wizardAttempt) return; - if (result.kind === 'error') { - // Probe failed: re-arm the key field in place. The host stores nothing - // during verify, so retrying with a corrected key is clean. - // A stale snapshot (the targeted connection is gone) is not a key - // problem — retyping cannot fix it, so skip that framing. - wizard.setKeyError( - result.stale ? result.text : `API key 验证失败:${result.text}。请检查后重新输入。`, - ); + if (result.kind !== 'ok') { + wizard.setKeyError(onboardingFailureMessage(result, locale)); requestRender(); return; } @@ -2143,9 +2139,9 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { wizard.setModels(result.models); // advance to the models step requestRender(); }, - (error) => { + () => { if (closed || wizard !== targetWizard || attempt !== wizardAttempt) return; - wizard.setKeyError(`配置失败:${error instanceof Error ? error.message : String(error)}`); + wizard.setKeyError(onboardingFailureMessage({ kind: 'unavailable' }, locale)); requestRender(); }, ); @@ -2159,7 +2155,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { const target = wizardTarget; if (!target || !wizard) return; if (!input.onboarding) { - wizard.setModelError('Onboarding 不可用:当前运行环境未提供配置入口。'); + wizard.setModelError(pickerCopy.onboardingUnavailable); requestRender(); return; } @@ -2177,9 +2173,9 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { }) .then( (result) => { - if (result.kind === 'error') { + if (result.kind !== 'ok') { if (closed || wizard !== targetWizard || attempt !== wizardAttempt) return; - wizard.setModelError(result.text); + wizard.setModelError(onboardingFailureMessage(result, locale)); requestRender(); return; } @@ -2205,15 +2201,13 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { } wizard.setSuccess( enabledModelIds.length, - result.refresh.kind === 'failed' ? result.refresh.warning : undefined, + result.refresh.kind === 'failed' ? pickerCopy.accountSavedRefreshFailed : undefined, ); requestRender(); }, - (error) => { + () => { if (closed || wizard !== targetWizard || attempt !== wizardAttempt) return; - wizard.setModelError( - `保存失败:${error instanceof Error ? error.message : String(error)}`, - ); + wizard.setModelError(onboardingFailureMessage({ kind: 'unavailable' }, locale)); requestRender(); }, ); @@ -2224,11 +2218,11 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { if (input.onboarding) { try { providers = await input.onboarding.listProviders(); - } catch (error) { + } catch { state.entries.push({ kind: 'notice', level: 'info', - text: `无法读取已配置的连接:${error instanceof Error ? error.message : String(error)}`, + text: pickerCopy.listProvidersFailed, }); requestRender(); return; @@ -2239,7 +2233,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { providers = listApiKeyOnboardableProviders().map((provider) => ({ ...provider, target: { kind: 'create' as const, providerType: provider.providerType }, - label: `${provider.label} · 添加账号`, + label: provider.label, enabledModelIds: [], })); } @@ -2247,13 +2241,14 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { state.entries.push({ kind: 'notice', level: 'info', - text: '没有可配置的 API key 类供应商。', + text: pickerCopy.noConfigurableProviders, }); requestRender(); return; } wizardOverlay?.hide(); wizard = new OnboardingWizard(tui, { + locale, providers, onPickProvider: (provider) => { // Each picker selection is a new logical intent, even when the user @@ -2760,6 +2755,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { if (choices && choices.length > 0) { let overlay: OverlayHandle | undefined; const picker = new ModelSearchOverlay(tui, { + locale, choices, current: { model, connectionId, connectionSlug }, showCacheWarning: hasConversationHistory, @@ -2773,16 +2769,16 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { return; } showSelectPicker( - 'Select Model', + pickerCopy.modelPickerTitle, connectionSlug, - modelPickerItems(model, input.models), + modelPickerItems(model, input.models, locale), (item) => { void runControl(() => setModel(item.value)); }, { minPrimaryColumnWidth: 24, maxPrimaryColumnWidth: 48, - notice: hasConversationHistory ? MODEL_SWITCH_CACHE_WARNING : undefined, + notice: hasConversationHistory ? pickerCopy.modelSwitchCacheWarning : undefined, }, ); }; @@ -2815,9 +2811,9 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { }; const showThinkingLevelList = () => { - const items = thinkingLevelPickerItems(thinkingLevels, thinkingLevel); + const items = thinkingLevelPickerItems(thinkingLevels, thinkingLevel, locale); showSelectPicker( - 'Select Thinking Level', + pickerCopy.thinkingPickerTitle, thinkingLevel ?? 'default', items, (item) => { @@ -3412,7 +3408,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { state.entries.push({ kind: 'notice', level: 'info', - text: '当前模型不支持思考级别切换。', + text: pickerCopy.thinkingUnsupported, }); requestRender(); return; @@ -3433,7 +3429,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { level: 'error', text: thinkingLevels.length === 0 - ? '当前模型不支持思考级别切换。' + ? pickerCopy.thinkingUnsupported : `Usage: /thinking ${['default', ...thinkingLevels].join('|')}`, }); requestRender(); diff --git a/packages/cli/src/runtime-host-onboarding.ts b/packages/cli/src/runtime-host-onboarding.ts index 0ec2cdd6f1..610d05c766 100644 --- a/packages/cli/src/runtime-host-onboarding.ts +++ b/packages/cli/src/runtime-host-onboarding.ts @@ -44,15 +44,9 @@ export function createRuntimeHostOnboardingSurface( baseUrl: trimmedOrNull(input.baseUrl), }); if (result.kind === 'verified') return { kind: 'ok', models: [...result.models] }; - return { - kind: 'error', - text: onboardingFailureText(result), - ...(result.kind === 'rejected' && result.reason === 'connection_not_found' - ? { stale: true } - : {}), - }; - } catch (error) { - return { kind: 'error', text: errorText(error) }; + return result; + } catch { + return { kind: 'unavailable' }; } }, save: async (input) => { @@ -64,7 +58,7 @@ export function createRuntimeHostOnboardingSurface( enabledModelIds: [...input.enabledModelIds], }); if (result.kind !== 'saved') { - return { kind: 'error', text: onboardingFailureText(result) }; + return result; } try { return { @@ -86,12 +80,12 @@ export function createRuntimeHostOnboardingSurface( connection: result.connection, refresh: { kind: 'failed', - warning: '账号已保存,但模型列表暂未刷新。重启 Maka 后会重新载入。', + reason: 'catalog_unavailable', }, }; } - } catch (error) { - return { kind: 'error', text: errorText(error) }; + } catch { + return { kind: 'unavailable' }; } }, }; @@ -142,7 +136,7 @@ export function projectProviders(catalog: ConnectionCatalogSnapshot): Onboarding entries.push({ ...provider, target: { kind: 'create', providerType: provider.providerType }, - label: `${provider.label} · 添加账号`, + label: provider.label, enabledModelIds: [], }); } @@ -153,33 +147,3 @@ function trimmedOrNull(value: string | undefined): string | null { const secret = value?.trim() ?? ''; return secret.length === 0 ? null : secret; } - -function onboardingFailureText(input: { - readonly kind: 'rejected' | 'failed'; - readonly reason?: string; - readonly errorClass?: string; -}): string { - if (input.kind === 'failed') return `Connection verification failed: ${input.errorClass}`; - switch (input.reason) { - case 'credential_not_configured': - return 'API key is required'; - case 'base_url_not_configured': - return 'A base URL is required for this provider'; - case 'connection_not_found': - return 'The existing connection is gone — reopen /setup and try again'; - case 'superseded': - return 'The connection changed while onboarding — reopen /setup and try again'; - case 'provider_unsupported': - return 'This provider does not support API-key onboarding'; - case 'catalog_full': - return 'The connection catalog is full'; - case 'model_unavailable': - return 'The selected model is no longer available'; - default: - return 'Connection onboarding was rejected'; - } -} - -function errorText(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} diff --git a/packages/cli/src/tui-copy-catalog.ts b/packages/cli/src/tui-copy-catalog.ts new file mode 100644 index 0000000000..7576f96b38 --- /dev/null +++ b/packages/cli/src/tui-copy-catalog.ts @@ -0,0 +1,380 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export const TUI_COPY_RESOURCES = { + 'mcp-status': { + en: { + title: 'MCP SERVERS', + footer: { + back: 'Esc back', + readOnly: '↑/↓ scroll · q/Esc close', + manage: + 'a Add · Enter Edit · Space Enable/disable · t Test · r Reconnect · d Remove · Esc Close', + }, + unavailableTitle: 'This TUI is not connected to a local MCP control plane.', + unavailableDetail: + 'Client MCP tool association for remote Runtime Hosts is planned for a later release.', + loading: 'Loading mcp.json and discovering tools…', + loadError: + 'MCP configuration could not be loaded; no tools were published to the Runtime Host.', + noServers: 'No MCP servers are configured. Press a to add one.', + publication: { + waiting: 'waiting to publish', + host_unavailable: 'Runtime Host reconnecting', + publishing: 'publishing', + published: 'published', + not_published: 'not published', + error: 'publication failed', + }, + serverState: { + disabled: 'disabled', + disconnected: 'disconnected', + connecting: 'connecting', + connected: 'connected', + 'needs-auth': 'needs-auth', + error: 'error', + }, + configuredOnly: 'configured only', + configPending: 'config pending', + toolCount: '{count, plural, one {# tool} other {# tools}}', + }, + zh: { + title: 'MCP 服务器', + footer: { + back: 'Esc 返回', + readOnly: '↑/↓ 滚动 · q/Esc 关闭', + manage: 'a 添加 · Enter 编辑 · Space 启用/停用 · t 测试 · r 重连 · d 删除 · Esc 关闭', + }, + unavailableTitle: '当前 TUI 未连接本地 MCP 控制面。', + unavailableDetail: '远程 Runtime Host 的客户端 MCP 工具关联将在后续版本提供。', + loading: '正在读取 mcp.json 并发现工具…', + loadError: '无法读取或应用 MCP 配置;没有向 Runtime Host 发布工具。', + noServers: '尚未配置 MCP 服务器。按 a 添加。', + publication: { + waiting: '等待发布', + host_unavailable: 'Runtime Host 重连中', + publishing: '正在发布', + published: '已发布', + not_published: '未发布', + error: '发布失败', + }, + serverState: { + disabled: '已停用', + disconnected: '未连接', + connecting: '连接中', + connected: '已连接', + 'needs-auth': '需要登录', + error: '错误', + }, + configuredOnly: '仅已配置', + configPending: '配置待同步', + toolCount: '{count} 个工具', + }, + }, + pickers: { + en: { + modelPickerTitle: 'Select Model', + modelSwitchCacheWarning: + '⚠ Switching models may rebuild the prompt cache; the next request may be slower or cost more.', + modelSearchHint: + 'Search models / providers / connections · ↑↓ select · Enter confirm · Esc cancel', + searchLabel: 'Search', + noMatchingModels: 'No matching models', + selectPickerHint: '↑↓ select · Enter confirm · Esc close', + providerConfigured: 'configured', + addAccount: 'add account', + thinkingLevels: { + off: 'Off', + minimal: 'Minimal', + low: 'Low', + medium: 'Medium', + high: 'High', + xhigh: 'Extra high', + max: 'Maximum', + }, + defaultThinkingLevel: 'Default', + thinkingPickerTitle: 'Select Thinking Level', + currentMarker: 'current', + defaultMarker: 'default', + setupTitle: 'Set Up Provider', + baseUrlLabel: 'Base URL', + apiKeyLabel: 'API key', + onboardingUnavailable: + 'Onboarding is unavailable: this environment does not provide a setup surface.', + onboardingRequestFailed: 'Could not reach the Runtime Host. Try again.', + onboardingRejections: { + provider_unsupported: 'This provider does not support API-key onboarding.', + connection_not_found: 'This connection no longer exists. Reopen /setup and try again.', + credential_not_configured: 'An API key is required.', + base_url_not_configured: 'A Base URL is required for this provider.', + catalog_full: 'The connection catalog is full.', + model_unavailable: 'A selected model is no longer available. Verify and select again.', + superseded: 'This connection changed. Reopen /setup and try again.', + }, + onboardingFailures: { + auth: 'Provider authentication failed. Check the API key and try again.', + timeout: 'Provider verification timed out. Try again.', + provider_unavailable: 'The provider is unavailable. Try again later.', + network: 'A network error interrupted provider verification. Try again.', + invalid_response: 'The provider returned an invalid response. Try again.', + unknown: 'Provider verification failed. Try again.', + }, + accountSavedRefreshFailed: + 'Account saved, but the model list has not refreshed. Restart Maka to reload it.', + listProvidersFailed: 'Could not read configured connections.', + noConfigurableProviders: 'No configurable API key providers are available.', + baseUrlRequired: 'Enter a Base URL', + baseUrlInvalid: 'Base URL is not a valid URL', + baseUrlProtocol: 'Base URL must use http or https', + baseUrlCredentials: 'Base URL cannot include credentials', + baseUrlQuery: 'Base URL cannot include a query or fragment', + baseUrlTooLong: 'Base URL cannot exceed 2048 bytes', + selectModelBeforeSaving: 'Select at least one model before saving', + reuseBaseUrlHint: + 'Leave blank to reuse the saved Base URL, or enter a replacement · Esc returns to provider selection', + enterBaseUrlHint: 'Enter the relay Base URL (http/https) · Esc returns to provider selection', + continueAction: 'Enter to continue', + providerSearchHint: 'Search providers · ↑↓ select · Enter confirm · Esc cancel', + noMatchingProviders: 'No matching providers', + keyHints: { + reuse: { + baseUrl: + 'Leave blank to reuse the saved key, or enter a new key to rotate it · Esc returns to Base URL', + provider: + 'Leave blank to reuse the saved key, or enter a new key to rotate it · Esc returns to provider selection', + }, + enter: { + baseUrl: 'Enter an API key · stored only on this machine · Esc returns to Base URL', + provider: + 'Enter an API key · stored only on this machine · Esc returns to provider selection', + }, + }, + submitAction: 'Enter to submit', + verifyingKey: 'Verifying key…', + modelSelectionHint: 'Search models · ↑↓ select · Space toggle · Enter save · Esc back', + selectedModels: '{count} selected', + selectedModelsAndSave: '{count} selected · Enter to save', + saving: 'Saving…', + complete: 'Complete', + enabledModels: 'Models enabled: {count}', + closeAction: 'Enter to close', + thinkingUnsupported: 'This model does not support changing the thinking level.', + }, + zh: { + modelPickerTitle: '选择模型', + modelSwitchCacheWarning: '⚠ 切换模型可能需要重建提示缓存;下一次请求可能更慢或成本更高。', + modelSearchHint: '搜索模型 / 服务商 / 连接 · ↑↓ 选择 · Enter 确认 · Esc 取消', + searchLabel: '搜索', + noMatchingModels: '没有匹配的模型', + selectPickerHint: '↑↓ 选择 · Enter 确认 · Esc 关闭', + providerConfigured: '已设置', + addAccount: '添加账号', + thinkingLevels: { + off: '关', + minimal: '最小', + low: '低', + medium: '中', + high: '高', + xhigh: '超高', + max: '最高', + }, + defaultThinkingLevel: '默认', + thinkingPickerTitle: '选择思考级别', + currentMarker: '当前', + defaultMarker: '默认', + setupTitle: '配置模型提供商', + baseUrlLabel: 'Base URL', + apiKeyLabel: 'API key', + onboardingUnavailable: 'Onboarding 不可用:当前运行环境未提供配置入口。', + onboardingRequestFailed: '无法连接 Runtime Host,请重试。', + onboardingRejections: { + provider_unsupported: '该服务商不支持 API key 配置。', + connection_not_found: '该连接已不存在,请重新打开 /setup 后重试。', + credential_not_configured: '需要填写 API key。', + base_url_not_configured: '该服务商需要填写 Base URL。', + catalog_full: '连接目录已满。', + model_unavailable: '所选模型已不可用,请重新验证并选择。', + superseded: '该连接已发生变化,请重新打开 /setup 后重试。', + }, + onboardingFailures: { + auth: '服务商身份验证失败,请检查 API key 后重试。', + timeout: '服务商验证超时,请重试。', + provider_unavailable: '服务商当前不可用,请稍后重试。', + network: '网络错误中断了服务商验证,请重试。', + invalid_response: '服务商返回了无效响应,请重试。', + unknown: '服务商验证失败,请重试。', + }, + accountSavedRefreshFailed: '账号已保存,但模型列表暂未刷新。重启 Maka 后会重新载入。', + listProvidersFailed: '无法读取已配置的连接。', + noConfigurableProviders: '没有可配置的 API key 类供应商。', + baseUrlRequired: '需要填写 Base URL', + baseUrlInvalid: 'Base URL 不是有效的 URL', + baseUrlProtocol: 'Base URL 必须使用 http 或 https', + baseUrlCredentials: 'Base URL 不能包含账号密码', + baseUrlQuery: 'Base URL 不能包含查询串或片段', + baseUrlTooLong: 'Base URL 不能超过 2048 字节', + selectModelBeforeSaving: '至少选择一个模型再保存', + reuseBaseUrlHint: '留空复用已保存的 Base URL,或输入新地址替换 · Esc 返回选择服务商', + enterBaseUrlHint: '输入中转站的 Base URL(http/https)· Esc 返回选择服务商', + continueAction: 'Enter 继续', + providerSearchHint: '搜索服务商,↑↓ 选择 · Enter 确认 · Esc 取消', + noMatchingProviders: '没有匹配的服务商', + keyHints: { + reuse: { + baseUrl: '留空复用已保存的 key,或输入新 key 轮换 · Esc 返回 Base URL', + provider: '留空复用已保存的 key,或输入新 key 轮换 · Esc 返回选择服务商', + }, + enter: { + baseUrl: '输入 API key · 仅本机存储 · Esc 返回 Base URL', + provider: '输入 API key · 仅本机存储 · Esc 返回选择服务商', + }, + }, + submitAction: 'Enter 提交', + verifyingKey: '正在验证 key…', + modelSelectionHint: '搜索模型,↑↓ 选择 · Space 切换 · Enter 保存 · Esc 返回', + selectedModels: '已选 {count}', + selectedModelsAndSave: '已选 {count} · Enter 保存', + saving: '正在保存…', + complete: '完成', + enabledModels: '已启用模型:{count}', + closeAction: 'Enter 关闭', + thinkingUnsupported: '当前模型不支持思考级别切换。', + }, + }, + 'primary-guidance': { + en: { + welcome: { + tagline: 'Get things done together', + start: 'Type a message to start', + session: 'Switch or resume a session', + model: 'Switch models', + setup: 'Set up a model provider', + }, + commands: { + compact: 'Compact session context', + context: 'Show latest request context usage', + exit: 'Exit Maka', + goal: 'Show autonomous goal status', + graph: 'Show, enable, disable, or run one Graph turn', + help: 'Show commands and keybindings', + mcp: 'Manage client-owned MCP servers and tool publication status', + model: 'Select model', + move: 'Move current session to another directory', + new: 'Start a new session', + permissions: 'Set session permissions', + recap: 'One-sentence recap of the session so far', + rename: 'Rename current session', + resume: 'Resume latest interrupted run at a safe boundary', + rewind: 'Rewind to an earlier turn', + session: 'Resume session', + setup: 'Set up a model provider (API key)', + side: 'Open a temporary side conversation', + skill: 'Invoke a skill (or type /skill: inline)', + swarm: 'Show, enable, disable, or run one Swarm turn', + thinking: 'Set thinking level', + transcript: 'Browse the full transcript inside Maka', + }, + help: { + commandsHeading: 'Commands', + userCommand: ' ! — run one shell command visible only to you', + keybindingsHeading: 'Keybindings', + keybindings: [ + ' Ctrl+O — expand or collapse all tool output', + ' Ctrl+T — expand or collapse all thinking in view', + ' Scroll the transcript with your terminal or trackpad', + ' Enter (during a turn) — steer: inject a message into the running turn', + ' Alt+Enter (during a turn) — queue a message for the next turn', + ' Alt+↑ — take queued messages back into the editor to re-edit', + ' Esc Esc (during a turn) — interrupt the turn', + ' Esc Esc (when idle) — rewind to an earlier turn', + ' Ctrl+C — stop the turn, clear input, or press twice to exit', + ' Ctrl+D — exit when input is empty', + ], + }, + }, + zh: { + welcome: { + tagline: '陪你把事做完', + start: '输入消息开始对话', + session: '切换或恢复会话', + model: '切换模型', + setup: '配置模型提供商', + }, + commands: { + compact: '压缩会话上下文', + context: '查看最近一次请求的上下文用量', + exit: '退出 Maka', + goal: '查看自主目标状态', + graph: '查看、启用、停用 Graph 模式,或执行一次 Graph 任务', + help: '查看命令和快捷键', + mcp: '管理客户端 MCP 服务器和工具发布状态', + model: '选择模型', + move: '将当前会话移到其他目录', + new: '新建会话', + permissions: '设置会话权限', + recap: '用一句话总结当前会话', + rename: '重命名当前会话', + resume: '从安全边界恢复最近一次中断的执行', + rewind: '回退到较早的对话轮次', + session: '切换或恢复会话', + setup: '配置模型提供商(API Key)', + side: '打开临时 Side Conversation', + skill: '调用 Skill(也可直接输入 /skill:)', + swarm: '查看、启用、停用 Swarm 模式,或执行一次 Swarm 任务', + thinking: '设置思考级别', + transcript: '在 Maka 内浏览完整对话记录', + }, + help: { + commandsHeading: '命令', + userCommand: ' ! — 执行一次仅用户可见的 shell 命令', + keybindingsHeading: '快捷键', + keybindings: [ + ' Ctrl+O — 展开或折叠所有工具输出', + ' Ctrl+T — 展开或折叠视图中的所有思考块', + ' 使用终端或触控板滚动对话记录', + ' Enter(任务运行中)— 将消息注入当前任务', + ' Alt+Enter(任务运行中)— 将消息排入下一轮', + ' Alt+↑ — 将已排队消息取回编辑器重新编辑', + ' Esc Esc(任务运行中)— 中断当前任务', + ' Esc Esc(空闲时)— 回退到较早的轮次', + ' Ctrl+C — 停止任务、清空输入,或连续按两次退出', + ' Ctrl+D — 输入为空时退出', + ], + }, + }, + }, + 'session-status': { + en: { + running: 'running', + waitingForUser: 'waiting for you', + permissionRequired: 'needs permission', + connectionRequired: 'needs connection', + signInRequired: 'needs sign-in', + stopped: 'stopped', + }, + zh: { + running: '进行中', + waitingForUser: '等你确认', + permissionRequired: '需要权限', + connectionRequired: '需要连接', + signInRequired: '需要重新登录', + stopped: '已中止', + }, + }, +} as const; diff --git a/packages/cli/src/tui-primary-guidance.ts b/packages/cli/src/tui-primary-guidance.ts index 552c41df0f..398ccac2f1 100644 --- a/packages/cli/src/tui-primary-guidance.ts +++ b/packages/cli/src/tui-primary-guidance.ts @@ -18,8 +18,13 @@ */ import type { SlashCommandIdForSurface } from '@maka/core/slash-command-catalog'; -import type { UiCatalog, UiLocale } from '@maka/core/ui-locale'; +import { + defineUiMessageCatalog, + resolveUiMessageCatalog, + type UiLocale, +} from '@maka/core/ui-locale'; import { renderTuiShortcutCopy } from './tui-shortcut-copy.js'; +import { TUI_COPY_RESOURCES } from './tui-copy-catalog.js'; type TuiCommandId = SlashCommandIdForSurface<'tui'>; @@ -40,108 +45,9 @@ export interface TuiPrimaryGuidanceCopy { }; } -const TUI_PRIMARY_GUIDANCE = { - zh: { - welcome: { - tagline: '陪你把事做完', - start: '输入消息开始对话', - session: '切换或恢复会话', - model: '切换模型', - setup: '配置模型提供商', - }, - commands: { - compact: '压缩会话上下文', - context: '查看最近一次请求的上下文用量', - exit: '退出 Maka', - goal: '查看自主目标状态', - graph: '查看、启用、停用 Graph 模式,或执行一次 Graph 任务', - help: '查看命令和快捷键', - mcp: '管理客户端 MCP 服务器和工具发布状态', - model: '选择模型', - move: '将当前会话移到其他目录', - new: '新建会话', - permissions: '设置会话权限', - recap: '用一句话总结当前会话', - rename: '重命名当前会话', - resume: '从安全边界恢复最近一次中断的执行', - rewind: '回退到较早的对话轮次', - session: '切换或恢复会话', - setup: '配置模型提供商(API Key)', - side: '打开临时 Side Conversation', - skill: '调用 Skill(也可直接输入 /skill:)', - swarm: '查看、启用、停用 Swarm 模式,或执行一次 Swarm 任务', - thinking: '设置思考级别', - transcript: '在 Maka 内浏览完整对话记录', - }, - help: { - commandsHeading: '命令', - userCommand: ' ! — 执行一次仅用户可见的 shell 命令', - keybindingsHeading: '快捷键', - keybindings: [ - ' Ctrl+O — 展开或折叠所有工具输出', - ' Ctrl+T — 展开或折叠视图中的所有思考块', - ' 使用终端或触控板滚动对话记录', - ' Enter(任务运行中)— 将消息注入当前任务', - ' Alt+Enter(任务运行中)— 将消息排入下一轮', - ' Alt+↑ — 将已排队消息取回编辑器重新编辑', - ' Esc Esc(任务运行中)— 中断当前任务', - ' Esc Esc(空闲时)— 回退到较早的轮次', - ' Ctrl+C — 停止任务、清空输入,或连续按两次退出', - ' Ctrl+D — 输入为空时退出', - ], - }, - }, - en: { - welcome: { - tagline: 'Get things done together', - start: 'Type a message to start', - session: 'Switch or resume a session', - model: 'Switch models', - setup: 'Set up a model provider', - }, - commands: { - compact: 'Compact session context', - context: 'Show latest request context usage', - exit: 'Exit Maka', - goal: 'Show autonomous goal status', - graph: 'Show, enable, disable, or run one Graph turn', - help: 'Show commands and keybindings', - mcp: 'Manage client-owned MCP servers and tool publication status', - model: 'Select model', - move: 'Move current session to another directory', - new: 'Start a new session', - permissions: 'Set session permissions', - recap: 'One-sentence recap of the session so far', - rename: 'Rename current session', - resume: 'Resume latest interrupted run at a safe boundary', - rewind: 'Rewind to an earlier turn', - session: 'Resume session', - setup: 'Set up a model provider (API key)', - side: 'Open a temporary side conversation', - skill: 'Invoke a skill (or type /skill: inline)', - swarm: 'Show, enable, disable, or run one Swarm turn', - thinking: 'Set thinking level', - transcript: 'Browse the full transcript inside Maka', - }, - help: { - commandsHeading: 'Commands', - userCommand: ' ! — run one shell command visible only to you', - keybindingsHeading: 'Keybindings', - keybindings: [ - ' Ctrl+O — expand or collapse all tool output', - ' Ctrl+T — expand or collapse all thinking in view', - ' Scroll the transcript with your terminal or trackpad', - ' Enter (during a turn) — steer: inject a message into the running turn', - ' Alt+Enter (during a turn) — queue a message for the next turn', - ' Alt+↑ — take queued messages back into the editor to re-edit', - ' Esc Esc (during a turn) — interrupt the turn', - ' Esc Esc (when idle) — rewind to an earlier turn', - ' Ctrl+C — stop the turn, clear input, or press twice to exit', - ' Ctrl+D — exit when input is empty', - ], - }, - }, -} satisfies UiCatalog; +const TUI_PRIMARY_GUIDANCE = resolveUiMessageCatalog( + defineUiMessageCatalog()(TUI_COPY_RESOURCES['primary-guidance']), +); export function getTuiPrimaryGuidance( locale: UiLocale, diff --git a/packages/cli/src/tui-session-status.ts b/packages/cli/src/tui-session-status.ts index 40747880d9..1042a29712 100644 --- a/packages/cli/src/tui-session-status.ts +++ b/packages/cli/src/tui-session-status.ts @@ -18,7 +18,12 @@ */ import type { SessionSummary } from '@maka/core/session'; -import type { UiCatalog, UiLocale } from '@maka/core/ui-locale'; +import { + defineUiMessageCatalog, + resolveUiMessageCatalog, + type UiLocale, +} from '@maka/core/ui-locale'; +import { TUI_COPY_RESOURCES } from './tui-copy-catalog.js'; interface TuiSessionStatusCopy { readonly running: string; @@ -29,24 +34,9 @@ interface TuiSessionStatusCopy { readonly stopped: string; } -const TUI_SESSION_STATUS_COPY = { - zh: { - running: '进行中', - waitingForUser: '等你确认', - permissionRequired: '需要权限', - connectionRequired: '需要连接', - signInRequired: '需要重新登录', - stopped: '已中止', - }, - en: { - running: 'running', - waitingForUser: 'waiting for you', - permissionRequired: 'needs permission', - connectionRequired: 'needs connection', - signInRequired: 'needs sign-in', - stopped: 'stopped', - }, -} satisfies UiCatalog; +const TUI_SESSION_STATUS_COPY = resolveUiMessageCatalog( + defineUiMessageCatalog()(TUI_COPY_RESOURCES['session-status']), +); /** * Present the runtime Session state as compact picker copy. diff --git a/packages/core/package.json b/packages/core/package.json index bab15ee8f4..7c0dfeec11 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -148,5 +148,8 @@ "typecheck": "tsc -p tsconfig.json --noEmit", "test": "npm run clean && npm run build && npm run test:dist", "test:dist": "node --test \"dist/**/*.test.js\"" + }, + "dependencies": { + "intl-messageformat": "11.2.12" } } diff --git a/packages/core/src/__tests__/ui-locale.test.ts b/packages/core/src/__tests__/ui-locale.test.ts new file mode 100644 index 0000000000..6fcc097723 --- /dev/null +++ b/packages/core/src/__tests__/ui-locale.test.ts @@ -0,0 +1,68 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { + UI_LOCALES, + defineUiMessageCatalog, + formatUiMessage, + isUiLocale, + resolveSystemUiLocale, + resolveUiMessageCatalog, + uiLocaleToIntlLocale, +} from '../ui-locale.js'; + +describe('UI message catalogs', () => { + it('falls back to complete English copy for missing translations', () => { + const catalog = defineUiMessageCatalog<{ + title: string; + detail: { ready: string; waiting: string }; + }>()({ + en: { title: 'Status', detail: { ready: 'Ready', waiting: 'Waiting' } }, + zh: { title: '状态', detail: { ready: '就绪' } }, + }); + + assert.deepEqual(resolveUiMessageCatalog(catalog), { + en: { title: 'Status', detail: { ready: 'Ready', waiting: 'Waiting' } }, + zh: { title: '状态', detail: { ready: '就绪', waiting: 'Waiting' } }, + }); + }); + + it('uses locale-aware ICU plural rules', () => { + const template = '{count, plural, one {# tool} other {# tools}}'; + + assert.equal(formatUiMessage(template, { count: 1 }, 'en'), '1 tool'); + assert.equal(formatUiMessage(template, { count: 3 }, 'en'), '3 tools'); + }); + + it('fails soft for missing or inherited interpolation values', () => { + assert.equal(formatUiMessage('Hello {name}', {}, 'en'), 'Hello {name}'); + assert.equal(formatUiMessage('{constructor}', {}, 'en'), '{constructor}'); + }); + + it('keeps every locale guard in step with UI_LOCALES', () => { + for (const locale of UI_LOCALES) { + assert.ok(isUiLocale(locale), locale); + assert.equal(resolveSystemUiLocale([locale]), locale); + } + const intlLocales = UI_LOCALES.map(uiLocaleToIntlLocale); + assert.equal(new Set(intlLocales).size, UI_LOCALES.length); + }); +}); diff --git a/packages/core/src/ui-locale.ts b/packages/core/src/ui-locale.ts index 1f58e75036..3a7d161734 100644 --- a/packages/core/src/ui-locale.ts +++ b/packages/core/src/ui-locale.ts @@ -17,6 +17,8 @@ * under the License. */ +import { IntlMessageFormat } from 'intl-messageformat'; + /** Resolved locales supported by human-facing Maka clients. */ export const UI_LOCALES = ['zh', 'en'] as const; @@ -30,6 +32,95 @@ export const UI_LOCALE_PREFERENCES = ['auto', ...UI_LOCALES] as const; /** A catalog must carry copy for every supported resolved locale. */ export type UiCatalog = Record; +type DeepPartial = T extends readonly unknown[] + ? T + : T extends object + ? { readonly [Key in keyof T]?: DeepPartial } + : T; + +export type UiMessageCatalog = { + readonly en: T; +} & Partial<{ + readonly [Locale in Exclude]: DeepPartial; +}>; + +type ExactMessageShape = Actual extends readonly unknown[] + ? NonNullable extends readonly unknown[] + ? Actual + : never + : Actual extends object + ? Exclude> extends never + ? { + readonly [Key in keyof Actual]: ExactMessageShape< + Actual[Key], + NonNullable[Key & keyof NonNullable] + >; + } + : never + : Actual; + +const messageFormatters = new Map(); + +export function defineUiMessageCatalog() { + return >( + catalog: Catalog & { + readonly en: ExactMessageShape; + } & { + readonly [Locale in Exclude]?: ExactMessageShape< + NonNullable, + DeepPartial + >; + }, + ): UiMessageCatalog => catalog; +} + +export function resolveUiMessageCatalog(catalog: UiMessageCatalog): UiCatalog { + return Object.fromEntries( + UI_LOCALES.map((locale) => [ + locale, + locale === 'en' + ? catalog.en + : mergeUiMessages(catalog.en, catalog[locale] as DeepPartial | undefined), + ]), + ) as UiCatalog; +} + +export function formatUiMessage( + template: string, + values: Readonly>, + locale: UiLocale, +): string { + try { + const intlLocale = uiLocaleToIntlLocale(locale); + const cacheKey = `${intlLocale}\u0000${template}`; + let formatter = messageFormatters.get(cacheKey); + if (!formatter) { + formatter = new IntlMessageFormat(template, intlLocale); + messageFormatters.set(cacheKey, formatter); + } + const safeValues = Object.assign(Object.create(null) as Record, values); + const formatted = formatter.format(safeValues); + return Array.isArray(formatted) ? formatted.join('') : String(formatted); + } catch { + return template; + } +} + +function mergeUiMessages(fallback: T, translation: DeepPartial | undefined): T { + if (translation === undefined) return fallback; + if (!isMessageRecord(fallback) || !isMessageRecord(translation)) return translation as T; + return Object.fromEntries( + Object.entries(fallback).map(([key, value]) => [ + key, + mergeUiMessages(value, Object.hasOwn(translation, key) ? translation[key] : undefined), + ]), + ) as T; +} + +function isMessageRecord(value: unknown): value is Readonly> { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + export function isUiLocale(value: unknown): value is UiLocale { return value === 'zh' || value === 'en'; } From d50efcf0805bdd898bc33afb00a52a70717bd7ed Mon Sep 17 00:00:00 2001 From: Nathan <370788475@qq.com> Date: Sun, 30 Aug 2026 19:16:07 +0800 Subject: [PATCH 14/71] fix(desktop): stop Runtime Host after launcher loss (#4114) Tie Desktop-owned ephemeral Runtime Hosts to the launcher lifecycle so an orphaned Host cannot block relaunch. --- .../main/runtime-host-desktop-candidate.ts | 1 + .../__tests__/fixtures/detached-launcher.ts | 29 ++++++++++++++----- .../src/__tests__/host-kernel.test.ts | 28 ++++++++++++++++++ .../src/candidate-launch-owner-guard.ts | 21 +++++++++----- .../src/client/connect-or-spawn.ts | 5 ++++ packages/runtime-host/src/client/launcher.ts | 19 ++++++++---- 6 files changed, 81 insertions(+), 22 deletions(-) diff --git a/apps/desktop/src/main/runtime-host-desktop-candidate.ts b/apps/desktop/src/main/runtime-host-desktop-candidate.ts index 53faa70a3b..e7af0a281f 100644 --- a/apps/desktop/src/main/runtime-host-desktop-candidate.ts +++ b/apps/desktop/src/main/runtime-host-desktop-candidate.ts @@ -932,6 +932,7 @@ function connectInput( : { handshakeTimeoutMs: input.handshakeTimeoutMs }), ...(input.signal === undefined ? {} : { signal: input.signal }), ...(input.onExit === undefined ? {} : { onExit: input.onExit }), + closeOnLauncherExit: true, }; } diff --git a/packages/runtime-host/src/__tests__/fixtures/detached-launcher.ts b/packages/runtime-host/src/__tests__/fixtures/detached-launcher.ts index d22842ad00..dd4eee9035 100644 --- a/packages/runtime-host/src/__tests__/fixtures/detached-launcher.ts +++ b/packages/runtime-host/src/__tests__/fixtures/detached-launcher.ts @@ -17,24 +17,37 @@ * under the License. */ -import { launchDetachedRuntimeHostCandidate } from '../../client/launcher.js'; +import { + launchDetachedRuntimeHostCandidate, + launchOwnedRuntimeHostCandidate, + type DetachedCandidateInput, +} from '../../client/launcher.js'; -const [rootPath, expectedRootId, stderrMarkerPath] = process.argv.slice(2); +const [rootPath, expectedRootId, mode] = process.argv.slice(2); if (!rootPath || !expectedRootId) { throw new Error('usage: detached-launcher '); } -const candidateEntrypoint = new URL( - stderrMarkerPath ? './stderr-after-launcher-exit.js' : './kernel-candidate.js', - import.meta.url, -); +const closeOnLauncherExit = mode === 'close-on-launcher-exit'; +const stderrMarkerPath = closeOnLauncherExit ? undefined : mode; +const candidateEntrypoint = closeOnLauncherExit + ? new URL('../../execution-candidate-main.js', import.meta.url) + : new URL( + stderrMarkerPath ? './stderr-after-launcher-exit.js' : './kernel-candidate.js', + import.meta.url, + ); -const attempt = await launchDetachedRuntimeHostCandidate({ +const launchInput = { rootPath, expectedRootId, entrypoint: candidateEntrypoint, idleGraceMs: 10_000, + ...(closeOnLauncherExit ? { closeOnLauncherExit: true } : {}), ...(stderrMarkerPath ? { env: { MAKA_TEST_STDERR_AFTER_PARENT_EXIT_MARKER: stderrMarkerPath } } : {}), -}).spawned; +} satisfies DetachedCandidateInput; +const launch = closeOnLauncherExit + ? launchOwnedRuntimeHostCandidate(launchInput) + : launchDetachedRuntimeHostCandidate(launchInput); +const attempt = await launch.spawned; process.send?.({ type: 'launched', pid: attempt.pid }); diff --git a/packages/runtime-host/src/__tests__/host-kernel.test.ts b/packages/runtime-host/src/__tests__/host-kernel.test.ts index cfd6ecd13e..3cb9781e0a 100644 --- a/packages/runtime-host/src/__tests__/host-kernel.test.ts +++ b/packages/runtime-host/src/__tests__/host-kernel.test.ts @@ -1814,6 +1814,34 @@ describe('non-serving Runtime Host kernel', () => { }); }); + test('a launcher-owned detached Host exits when its launcher is killed', async () => { + await withHostPaths(async (paths) => { + const capability = await resolveStorageRoot({ path: paths.root, kind: 'interactive' }); + const launcher = paths.resources.trackChild( + fork( + new URL('./fixtures/detached-launcher.js', import.meta.url), + [paths.root, capability.rootId, 'close-on-launcher-exit'], + { stdio: ['ignore', 'ignore', 'inherit', 'ipc'] }, + ), + ); + const launchedPid = paths.resources.trackPid(await waitForLaunch(launcher)); + const connected = await retryConnect(paths, CURRENT_PROTOCOL); + assert.equal(connected.kind, 'connected'); + if (connected.kind !== 'connected') return; + assert.equal(connected.registration.pid, launchedPid); + + launcher.kill('SIGKILL'); + await waitForExit(launcher); + await withTimeout( + connected.connection.closed, + 5_000, + 'launcher-owned detached Host survived its launcher', + ); + await waitForProcessExit(launchedPid); + paths.resources.forgetPid(launchedPid); + }); + }); + test('an authority-supervised Candidate exits if its launch owner is killed', async () => { await withHostPaths(async (paths) => { const capability = await resolveStorageRoot({ path: paths.root, kind: 'interactive' }); diff --git a/packages/runtime-host/src/candidate-launch-owner-guard.ts b/packages/runtime-host/src/candidate-launch-owner-guard.ts index 839f78e166..45dbf4f3fd 100644 --- a/packages/runtime-host/src/candidate-launch-owner-guard.ts +++ b/packages/runtime-host/src/candidate-launch-owner-guard.ts @@ -20,6 +20,7 @@ import { closeSync } from 'node:fs'; export const RUNTIME_HOST_LAUNCH_OWNER_LEASE_FD_ENV = 'MAKA_RUNTIME_HOST_LAUNCH_OWNER_LEASE_FD'; +export const RUNTIME_HOST_LAUNCH_OWNER_GUARD_ENV = 'MAKA_RUNTIME_HOST_LAUNCH_OWNER_GUARD'; export const RUNTIME_HOST_LAUNCH_OWNER_RELEASE_KIND = 'runtime-host-launch-owner-release'; export interface RuntimeHostLaunchOwnerGuard { @@ -28,18 +29,22 @@ export interface RuntimeHostLaunchOwnerGuard { } /** - * Keeps the updater's authority lease inside a Candidate until its launcher - * explicitly releases it. Launcher loss closes the Host before the lease, so - * no second owner can enter while the uncommitted target remains a writer. + * Closes a launcher-owned Host if its launcher disappears. An optional updater + * authority lease stays inside the Candidate until the launcher explicitly + * releases it, so launcher loss closes the Host before releasing that lease. */ export function createRuntimeHostLaunchOwnerGuard( env: NodeJS.ProcessEnv = process.env, ): RuntimeHostLaunchOwnerGuard | undefined { const rawFd = env[RUNTIME_HOST_LAUNCH_OWNER_LEASE_FD_ENV]; - if (rawFd === undefined) return undefined; - const leaseFd = Number(rawFd); - if (!Number.isSafeInteger(leaseFd) || leaseFd < 3) { - throw new Error('Runtime Host launch-owner authority descriptor is invalid'); + const guardRequested = env[RUNTIME_HOST_LAUNCH_OWNER_GUARD_ENV] === '1'; + if (rawFd === undefined && !guardRequested) return undefined; + let leaseFd: number | undefined; + if (rawFd !== undefined) { + leaseFd = Number(rawFd); + if (!Number.isSafeInteger(leaseFd) || leaseFd < 3) { + throw new Error('Runtime Host launch-owner authority descriptor is invalid'); + } } let state: 'owned' | 'released' | 'lost' = process.connected ? 'owned' : 'lost'; @@ -50,7 +55,7 @@ export function createRuntimeHostLaunchOwnerGuard( const closeLease = () => { if (leaseClosed) return; leaseClosed = true; - closeSync(leaseFd); + if (leaseFd !== undefined) closeSync(leaseFd); }; const settleLoss = () => { if (state !== 'lost' || !closeHost || lossSettlement) return; diff --git a/packages/runtime-host/src/client/connect-or-spawn.ts b/packages/runtime-host/src/client/connect-or-spawn.ts index 33c6a76f05..7a992708d2 100644 --- a/packages/runtime-host/src/client/connect-or-spawn.ts +++ b/packages/runtime-host/src/client/connect-or-spawn.ts @@ -89,6 +89,8 @@ export interface ConnectOrSpawnRuntimeHostInput { signal?: AbortSignal; /** Existing authority lease inherited by a launch-owner-supervised Candidate. */ inheritableAuthorityLeaseFd?: number; + /** Close a newly spawned ephemeral Candidate if this launcher exits. */ + closeOnLauncherExit?: boolean; /** Candidate-exit sink forwarded to the launcher; the embedder owns the sink. */ onExit?: (details: CandidateExitDetails) => void; } @@ -466,6 +468,9 @@ export async function connectOrSpawnRuntimeHostWithDependencies( ...(input.inheritableAuthorityLeaseFd === undefined ? {} : { inheritableAuthorityLeaseFd: input.inheritableAuthorityLeaseFd }), + ...(input.closeOnLauncherExit === undefined + ? {} + : { closeOnLauncherExit: input.closeOnLauncherExit }), }); candidateLaunches.add(launch); const attempt = await settleBeforeDeadline(launch.spawned, deadline, input.signal); diff --git a/packages/runtime-host/src/client/launcher.ts b/packages/runtime-host/src/client/launcher.ts index a957ac69e0..a8bdd15ce3 100644 --- a/packages/runtime-host/src/client/launcher.ts +++ b/packages/runtime-host/src/client/launcher.ts @@ -26,6 +26,7 @@ import { type CandidateStartupFailureReport, } from '../candidate-startup-failure.js'; import { + RUNTIME_HOST_LAUNCH_OWNER_GUARD_ENV, RUNTIME_HOST_LAUNCH_OWNER_LEASE_FD_ENV, runtimeHostLaunchOwnerReleaseMessage, } from '../candidate-launch-owner-guard.js'; @@ -53,6 +54,8 @@ export interface DetachedCandidateInput { env?: NodeJS.ProcessEnv; /** Existing authority lease inherited only by a launch-owner-supervised Candidate. */ inheritableAuthorityLeaseFd?: number; + /** Keep this Candidate bound to the launcher process for its whole lifetime. */ + closeOnLauncherExit?: boolean; /** Called with the candidate's exit details; the embedder owns the sink. */ readonly onExit?: (details: CandidateExitDetails) => void; } @@ -86,7 +89,7 @@ export function launchDetachedRuntimeHostCandidate( input: DetachedCandidateInput, ): DetachedCandidateLaunch { const startupAttemptId = randomUUID(); - const child = spawnCandidate(input, true, startupAttemptId, false); + const child = spawnCandidate(input, true, startupAttemptId, input.closeOnLauncherExit === true); const exited = observeCandidateExit(child); notifyCandidateExit(child, exited, input.onExit); const startupFailure = readStartupFailure(exited, startupAttemptId); @@ -101,7 +104,8 @@ export function launchOwnedRuntimeHostCandidate(input: DetachedCandidateInput): readonly spawned: Promise; } { const startupAttemptId = randomUUID(); - const guarded = input.inheritableAuthorityLeaseFd !== undefined; + const guarded = + input.inheritableAuthorityLeaseFd !== undefined || input.closeOnLauncherExit === true; const child = spawnCandidate(input, false, startupAttemptId, guarded); const exited = observeCandidateExit(child); notifyCandidateExit(child, exited, input.onExit); @@ -163,19 +167,22 @@ function spawnCandidate( // spawn() commits the side effect synchronously; spawned only reports that commit's outcome. const inheritedLeaseFd = input.inheritableAuthorityLeaseFd; - const childLeaseFd = guarded ? 4 : undefined; + const childLeaseFd = inheritedLeaseFd === undefined ? undefined : 4; + const guardedStdio: Array = + childLeaseFd === undefined + ? ['ignore', 'ignore', 'pipe', 'ipc'] + : ['ignore', 'ignore', 'pipe', 'ipc', inheritedLeaseFd!]; const child = spawn(executable, args, { cwd: dirname(isAbsolute(executable) ? executable : process.execPath), detached, - stdio: guarded - ? ['ignore', 'ignore', 'pipe', 'ipc', inheritedLeaseFd!] - : ['ignore', 'ignore', 'pipe'], + stdio: guarded ? guardedStdio : ['ignore', 'ignore', 'pipe'], windowsHide: true, env: { ...process.env, ...(process.versions.electron ? { ELECTRON_RUN_AS_NODE: '1' } : {}), ...input.env, [RUNTIME_HOST_STDERR_PIPE_ENV]: '1', + ...(guarded ? { [RUNTIME_HOST_LAUNCH_OWNER_GUARD_ENV]: '1' } : {}), ...(childLeaseFd === undefined ? {} : { [RUNTIME_HOST_LAUNCH_OWNER_LEASE_FD_ENV]: String(childLeaseFd) }), From 4c8b37c9ada45911d8c3e741e2cf9e0841bf861f Mon Sep 17 00:00:00 2001 From: NekoPunch <95899648+orangeCatDeveloper@users.noreply.github.com> Date: Sun, 30 Aug 2026 04:16:11 -0700 Subject: [PATCH 15/71] fix(cli): wait for Host readiness before pairing (#4230) Wait on the Runtime Host readiness authority before first-run pairing to avoid a transient host_not_ready failure. --- ...runtime-host-lifecycle-transaction.test.ts | 59 ++++++++++++++++++- .../cli/src/runtime-host-access-command.ts | 4 +- .../src/runtime-host-lifecycle-transaction.ts | 12 +++- .../cli/src/runtime-host-setup-command.ts | 9 ++- 4 files changed, 75 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/__tests__/runtime-host-lifecycle-transaction.test.ts b/packages/cli/src/__tests__/runtime-host-lifecycle-transaction.test.ts index 2f57af9c27..9411e86e34 100644 --- a/packages/cli/src/__tests__/runtime-host-lifecycle-transaction.test.ts +++ b/packages/cli/src/__tests__/runtime-host-lifecycle-transaction.test.ts @@ -31,6 +31,7 @@ import { type RuntimeHostManagedDeploymentConfig, type RuntimeHostSupervisorProvider, } from '@maka/runtime-host/operator'; +import type { connectExistingRuntimeHost } from '@maka/runtime-host/client'; import { resolveStorageRoot, tryAcquireStateRootOwner } from '@maka/storage/root-authority'; import type { RuntimeHostLifecycleProvider, @@ -44,6 +45,8 @@ import { retireRuntimeHostLifecycleOwner, runtimeHostReconciliationTriggerDefinition, runtimeHostSupervisorDefinition, + verifyRuntimeHostLifecycleReady, + type RuntimeHostLifecycleTransactionDeps, } from '../runtime-host-lifecycle-transaction.js'; const INTEGRITY = `sha512-${Buffer.alloc(64, 7).toString('base64')}`; @@ -488,8 +491,53 @@ test('does not consume replacement consent after the supervised Host exits', asy assert.equal(retired, false); }); +test('readiness waits for a reachable Host to leave the starting state', async (t) => { + const stateRoot = await mkdtemp(join(tmpdir(), 'maka-lifecycle-ready-')); + t.after(() => rm(stateRoot, { recursive: true, force: true })); + const capability = await resolveStorageRoot({ path: stateRoot, kind: 'interactive' }); + const supervised = config(capability.canonicalPath, capability.rootId, 1, 'launch_agent'); + const provider = new FakeLifecycleProvider('launch_agent', 'launch_agent_timer'); + provider.install(supervised); + provider.running = 42; + const states: ('starting' | 'ready')[] = ['starting', 'starting', 'ready']; + const observed: string[] = []; + let closed = 0; + const deps: RuntimeHostLifecycleTransactionDeps = { + resolveProvider: () => provider, + convergeOperator: async () => undefined, + verifyOperator: async () => undefined, + connectExisting: async () => + ({ + kind: 'connected', + connection: { + rootId: capability.rootId, + request: async () => ({ pid: 42 }), + status: async () => { + const state = states.length > 1 ? states.shift() : states[0]; + observed.push(state ?? 'ready'); + return { state }; + }, + close: async () => { + closed += 1; + }, + }, + }) as unknown as Awaited>, + }; + + await verifyRuntimeHostLifecycleReady(supervised, deps, 2_000); + assert.deepEqual(observed, ['starting', 'starting', 'ready']); + assert.equal(closed, 1); + + states.splice(0, states.length, 'starting'); + await assert.rejects(verifyRuntimeHostLifecycleReady(supervised, deps, 200), { + code: 'transition_failed', + message: /did not become ready/, + }); +}); + class FakeLifecycleProvider implements RuntimeHostLifecycleProvider { static failure: string | undefined; + running: number | null = null; readonly supervisor; readonly reconciliationTrigger; #supervisorDefinition: RuntimeHostProviderDefinition | undefined; @@ -514,9 +562,14 @@ class FakeLifecycleProvider implements RuntimeHostLifecycleProvider { provider: supervisorProvider, installed: this.#supervisorDefinition !== undefined, enabled: this.#supervisorDefinition !== undefined, - active: false, - state: this.#supervisorDefinition ? ('stopped' as const) : ('not_installed' as const), - pid: null, + active: this.running !== null, + state: + this.running !== null + ? ('running' as const) + : this.#supervisorDefinition + ? ('stopped' as const) + : ('not_installed' as const), + pid: this.running, lastExitCode: null, }), activate: async () => undefined, diff --git a/packages/cli/src/runtime-host-access-command.ts b/packages/cli/src/runtime-host-access-command.ts index 35c450738b..d86bd62d9e 100644 --- a/packages/cli/src/runtime-host-access-command.ts +++ b/packages/cli/src/runtime-host-access-command.ts @@ -366,7 +366,9 @@ async function connectLocalOwner(rootPath: string, expectedRootId?: string) { }, ); if (result.kind !== 'connected') { - throw new RuntimeHostAccessUnavailableError(result.kind); + throw new RuntimeHostAccessUnavailableError( + result.kind === 'unavailable' ? result.reason : result.kind, + ); } if (expectedRootId && result.connection.rootId !== expectedRootId) { await result.connection.close(); diff --git a/packages/cli/src/runtime-host-lifecycle-transaction.ts b/packages/cli/src/runtime-host-lifecycle-transaction.ts index 44e37cf465..5e6d089da3 100644 --- a/packages/cli/src/runtime-host-lifecycle-transaction.ts +++ b/packages/cli/src/runtime-host-lifecycle-transaction.ts @@ -28,6 +28,7 @@ import { import { connectExistingRuntimeHost, prepareConnectedRuntimeHostRetirement, + waitForRuntimeHostReady, } from '@maka/runtime-host/client'; import { RUNTIME_HOST_PROTOCOL_VERSION } from '@maka/runtime-host/protocol'; import { @@ -53,6 +54,9 @@ import type { RuntimeHostProviderDefinition, } from './runtime-host-lifecycle-provider.js'; +/** The budget a managed Runtime Host has to become reachable after its lifecycle is activated. */ +export const RUNTIME_HOST_READY_TIMEOUT_MS = 45_000; + export interface RuntimeHostLifecycleTransactionDeps { readonly resolveProvider: ( provider: RuntimeHostSupervisorProvider, @@ -62,6 +66,7 @@ export interface RuntimeHostLifecycleTransactionDeps { desired: RuntimeHostManagedDeploymentConfig | undefined, ) => Promise; readonly verifyOperator: (config: RuntimeHostManagedDeploymentConfig) => Promise; + readonly connectExisting?: typeof connectExistingRuntimeHost; /** Legacy migration keeps the validated old config until commit as its deterministic receipt. */ readonly uninstallLegacy?: ( transition: RuntimeHostManagedDeploymentTransition | RuntimeHostManagedDeploymentBlocked, @@ -720,7 +725,7 @@ export async function activateRuntimeHostLifecycle( export async function verifyRuntimeHostLifecycleReady( config: RuntimeHostManagedDeploymentConfig, deps: RuntimeHostLifecycleTransactionDeps, - timeoutMs = 45_000, + timeoutMs = RUNTIME_HOST_READY_TIMEOUT_MS, ): Promise { const canonical = decodeRuntimeHostManagedDeploymentConfig(config); await verifyRuntimeHostLifecycleProjection(canonical, deps); @@ -731,7 +736,7 @@ export async function verifyRuntimeHostLifecycleReady( while (Date.now() < deadline) { const status = await provider.supervisor.status(); if (status.pid !== null && status.active) { - const connected = await connectExistingRuntimeHost({ + const connected = await (deps.connectExisting ?? connectExistingRuntimeHost)({ rootPath: canonical.root.path, protocol: { min: RUNTIME_HOST_PROTOCOL_VERSION, @@ -745,9 +750,12 @@ export async function verifyRuntimeHostLifecycleReady( try { const diagnostics = await connected.connection.request('host.diagnostics.query', {}); if (diagnostics.pid === status.pid && connected.connection.rootId === canonical.root.id) { + await waitForRuntimeHostReady(connected.connection, Math.max(1, deadline - Date.now())); return; } lastFailure = new Error('Runtime Host process or Root identity did not match'); + } catch (error) { + lastFailure = error; } finally { await connected.connection.close().catch(() => undefined); } diff --git a/packages/cli/src/runtime-host-setup-command.ts b/packages/cli/src/runtime-host-setup-command.ts index 5a933d9383..caad04dd30 100644 --- a/packages/cli/src/runtime-host-setup-command.ts +++ b/packages/cli/src/runtime-host-setup-command.ts @@ -107,6 +107,7 @@ import { canDiscardRuntimeHostLifecycleDesiredArtifacts, replaceRuntimeHostLifecycle, resolveRecoverableRuntimeHostManagedDeployment, + RUNTIME_HOST_READY_TIMEOUT_MS, type RuntimeHostLifecycleTransactionDeps, } from './runtime-host-lifecycle-transaction.js'; import type { @@ -120,7 +121,6 @@ import { import { activateRuntimeHostManagedDeploymentWithReconciliation } from './runtime-host-activation-command.js'; const SETUP_LOCK_TIMEOUT_MS = 5 * 60_000; -const PAIRING_AVAILABILITY_TIMEOUT_MS = 10_000; const PAIRING_AVAILABILITY_POLL_MS = 100; export interface RuntimeHostSetupCliOptions { @@ -1089,7 +1089,7 @@ async function pairAndVerifyRuntimeHostSetup( preset: options.preset, ...(options.bindPairingToClient ? { bindClientInstance: true } : {}), }; - const deadline = Date.now() + PAIRING_AVAILABILITY_TIMEOUT_MS; + const deadline = Date.now() + RUNTIME_HOST_READY_TIMEOUT_MS; while (true) { try { paired = await pairCredential(credentialInput); @@ -1104,7 +1104,10 @@ async function pairAndVerifyRuntimeHostSetup( } } } catch (error) { - const reason = generalizedErrorMessage(error, 'Runtime Host access service is unavailable'); + const reason = + error instanceof RuntimeHostAccessUnavailableError + ? error.message + : generalizedErrorMessage(error, 'Runtime Host access service is unavailable'); throw new RuntimeHostSetupError( 'pairing_failed', `Runtime Host could not pair the requested Client identity: ${reason}`, From c62980ced62cad57460ddfebc5370990735cb955 Mon Sep 17 00:00:00 2001 From: ooopSok <81428141+Sun-GLiang@users.noreply.github.com> Date: Sun, 30 Aug 2026 19:16:15 +0800 Subject: [PATCH 16/71] fix(runtime): discover contained symlinked skill directories (#4116) Discover symlinked Skills that remain inside the configured root while preserving discoveryRoot as the containment authority. --- .github/workflows/windows-recovery.yml | 4 +- .../skill-catalog-repository.test.ts | 74 ++++++++++++- .../src/server/skill-catalog-repository.ts | 18 ++-- packages/runtime/src/__tests__/skills.test.ts | 100 ++++++++++++++++++ packages/runtime/src/skills-discovery.ts | 65 ++++++++---- scripts/ci-test-plan.test.mjs | 4 +- 6 files changed, 236 insertions(+), 29 deletions(-) diff --git a/.github/workflows/windows-recovery.yml b/.github/workflows/windows-recovery.yml index 1c978e38d8..2ea0d13e62 100644 --- a/.github/workflows/windows-recovery.yml +++ b/.github/workflows/windows-recovery.yml @@ -158,8 +158,8 @@ jobs: $exitCode = $LASTEXITCODE if ($exitCode -ne 0) { exit $exitCode } $output = Get-Content "$env:RUNNER_TEMP/skill-catalog.tap" - if ($output -notcontains '# tests 90' -or $output -notcontains '# pass 90' -or $output -notcontains '# skipped 0') { - Write-Error 'Skill catalog gate did not run exactly 90 passing Windows tests' + if ($output -notcontains '# tests 91' -or $output -notcontains '# pass 91' -or $output -notcontains '# skipped 0') { + Write-Error 'Skill catalog gate did not run exactly 91 passing Windows tests' exit 1 } diff --git a/packages/runtime-host/src/__tests__/skill-catalog-repository.test.ts b/packages/runtime-host/src/__tests__/skill-catalog-repository.test.ts index 919cfbc3d9..d2e6367dd3 100644 --- a/packages/runtime-host/src/__tests__/skill-catalog-repository.test.ts +++ b/packages/runtime-host/src/__tests__/skill-catalog-repository.test.ts @@ -21,7 +21,7 @@ import { RuntimeHostProtocolError } from '../protocol/errors.js'; import assert from 'node:assert/strict'; import { execFile } from 'node:child_process'; import { createHash } from 'node:crypto'; -import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { chmod, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, test } from 'node:test'; @@ -903,6 +903,78 @@ test('non-force managed update preserves a local edit made after its snapshot', assert.equal(await readFile(installedPath, 'utf8'), localEdit); }); +test('managed update blocks a symlink redirected outside its discovery root after scanning', async () => { + const fixture = await createFixture(); + const sourceId = 'managed-symlink-race'; + const installedContent = skillBody('Managed Symlink Race', 'installed'); + const updateContent = skillBody('Managed Symlink Race', 'source update'); + const outsideContent = skillBody('Managed Symlink Race', 'outside replacement'); + await createSkill(fixture.sources, sourceId, updateContent); + + const containedSkill = await createSkill( + join(fixture.root, 'linked-skill-sources'), + sourceId, + installedContent, + ); + await mkdir(join(containedSkill, '.maka', 'baseline'), { recursive: true }); + await writeFile( + join(containedSkill, 'skill.lock.json'), + `${JSON.stringify( + createManagedSkillLock(sourceId, sha256(installedContent), sha256(installedContent)), + null, + 2, + )}\n`, + ); + await writeFile(join(containedSkill, '.maka', 'baseline', 'SKILL.md'), installedContent); + + const skillsDirectory = join(fixture.root, 'skills'); + const linkedSkill = join(skillsDirectory, sourceId); + await mkdir(skillsDirectory, { recursive: true }); + await symlink(containedSkill, linkedSkill, 'dir'); + + const outsideSkill = await createSkill( + await tempDirectory('maka-skill-symlink-race-outside-'), + sourceId, + outsideContent, + ); + await mkdir(join(outsideSkill, '.maka', 'baseline'), { recursive: true }); + await writeFile( + join(outsideSkill, 'skill.lock.json'), + `${JSON.stringify( + createManagedSkillLock(sourceId, sha256(outsideContent), sha256(outsideContent)), + null, + 2, + )}\n`, + ); + await writeFile(join(outsideSkill, '.maka', 'baseline', 'SKILL.md'), outsideContent); + + let redirectAfterScan = false; + const repository = fixture.repository(undefined, { + beforeManagedInstalledArtifactsRead: async () => { + if (!redirectAfterScan) return; + redirectAfterScan = false; + await rm(linkedSkill); + await symlink(outsideSkill, linkedSkill, 'dir'); + }, + }); + const snapshot = await start(repository, fixture.project, 'governance'); + + redirectAfterScan = true; + const result = await repository.mutate({ + expectedRevision: snapshot.revision, + mutation: { + kind: 'update_managed', + ref: `workspace:legacy:${sourceId}`, + force: false, + expectedCurrentSha256: null, + expectedSourceSha256: null, + }, + }); + + assert.deepEqual(result, { kind: 'rejected', reason: 'metadata_error' }); + assert.equal(await readFile(join(outsideSkill, 'SKILL.md'), 'utf8'), outsideContent); +}); + test('revision covers exact managed lock and baseline bytes and rejects post-snapshot edits', async () => { const fixture = await createFixture(); const sourceId = 'managed-artifact-race'; diff --git a/packages/runtime-host/src/server/skill-catalog-repository.ts b/packages/runtime-host/src/server/skill-catalog-repository.ts index e4f85f3e96..65a1b7fd91 100644 --- a/packages/runtime-host/src/server/skill-catalog-repository.ts +++ b/packages/runtime-host/src/server/skill-catalog-repository.ts @@ -403,7 +403,10 @@ export class SkillCatalogRepository { if (!validateSkillMetadata(source.content).valid) { return { kind: 'rejected', reason: 'source_invalid' }; } - const current = await readContainedArtifact(fact.skill.path, join(fact.skill.path, SKILL_FILE)); + const current = await readContainedArtifact( + fact.skill.discoveryRoot, + join(fact.skill.path, SKILL_FILE), + ); if (current.status !== 'available') { return { kind: 'rejected', reason: 'metadata_error' }; } @@ -1012,7 +1015,10 @@ async function governanceForSkill( return { governance: missingSkillLockStatus(), baselineAvailable: false }; } const lockPath = join(skill.path, LOCK_FILE); - const baselineRead = await readContainedArtifact(skill.path, join(skill.path, BASELINE_FILE)); + const baselineRead = await readContainedArtifact( + skill.discoveryRoot, + join(skill.path, BASELINE_FILE), + ); const baselineAvailable = baselineRead.status === 'available'; const baselineSha256 = baselineRead.status === 'unavailable' ? undefined : baselineRead.sha256; const lockStat = await lstat(lockPath).catch((error: NodeJS.ErrnoException) => @@ -1039,7 +1045,7 @@ async function governanceForSkill( ...(baselineSha256 === undefined ? {} : { baselineSha256 }), }; } - const lockRead = await readContainedArtifact(skill.path, lockPath); + const lockRead = await readContainedArtifact(skill.discoveryRoot, lockPath); if (lockRead.status === 'unavailable') { return { governance: invalidSkillLockStatus('invalid_json', 'Skill lock could not be read safely.'), @@ -1681,9 +1687,9 @@ async function readManagedArtifacts(skill: ScannedSkill): Promise<{ baselineSha256: string; } | null> { const [skillFile, lock, baseline] = await Promise.all([ - readContainedArtifact(skill.path, join(skill.path, SKILL_FILE)), - readContainedArtifact(skill.path, join(skill.path, LOCK_FILE)), - readContainedArtifact(skill.path, join(skill.path, BASELINE_FILE)), + readContainedArtifact(skill.discoveryRoot, join(skill.path, SKILL_FILE)), + readContainedArtifact(skill.discoveryRoot, join(skill.path, LOCK_FILE)), + readContainedArtifact(skill.discoveryRoot, join(skill.path, BASELINE_FILE)), ]); if ( skillFile.status !== 'available' || diff --git a/packages/runtime/src/__tests__/skills.test.ts b/packages/runtime/src/__tests__/skills.test.ts index 2e92539dc6..8410e473d8 100644 --- a/packages/runtime/src/__tests__/skills.test.ts +++ b/packages/runtime/src/__tests__/skills.test.ts @@ -520,6 +520,106 @@ Open local targets carefully.`, }); }); + it('discovers contained symlinked skill directories using the link identity', async () => { + await withWorkspace(async (workspaceRoot) => { + const skillsDir = join(workspaceRoot, 'skills'); + const sourceDir = join(workspaceRoot, 'linked-skill-sources'); + const linkedSkill = join(skillsDir, 'linked-alias'); + await mkdir(skillsDir, { recursive: true }); + await writeSkillInDirectory( + sourceDir, + 'source-name', + 'Linked Skill', + 'A contained symlinked skill.', + ); + await symlink(join(sourceDir, 'source-name'), linkedSkill, 'dir'); + + const scan = await scanSkillsWithDiagnostics(workspaceRoot); + + assert.deepEqual( + scan.skills.map((skill) => ({ id: skill.id, ref: skill.ref, path: skill.path })), + [ + { + id: 'linked-alias', + ref: 'workspace:legacy:linked-alias', + path: linkedSkill, + }, + ], + ); + assert.deepEqual(scan.discoveryDiagnostics, []); + }); + }); + + it('diagnoses unusable symlinked skill directories without hiding valid skills', async () => { + await withWorkspace(async (workspaceRoot) => { + const outside = await mkdtemp(join(tmpdir(), 'maka-skill-entry-outside-')); + const skillsDir = join(workspaceRoot, 'skills'); + const containedFile = join(workspaceRoot, 'contained-file'); + const containedFileLink = join(skillsDir, 'contained-file-link'); + const cyclicSkill = join(skillsDir, 'cyclic-link'); + const danglingSkill = join(skillsDir, 'dangling-link'); + const notDirectorySkill = join(skillsDir, 'not-directory-link'); + const escapingSkill = join(skillsDir, 'escaping-link'); + try { + await writeSkill( + workspaceRoot, + 'valid', + `--- +name: Valid +description: A valid local skill. +--- +# Valid`, + ); + await writeSkillInDirectory(outside, 'source-name', 'Outside', 'An escaping skill.'); + await writeFile(containedFile, 'not a directory', 'utf8'); + await symlink(containedFile, containedFileLink, 'file'); + await symlink(cyclicSkill, cyclicSkill, 'dir'); + await symlink(join(workspaceRoot, 'missing-target'), danglingSkill, 'dir'); + await symlink(join(containedFile, 'child'), notDirectorySkill, 'dir'); + await symlink(join(outside, 'source-name'), escapingSkill, 'dir'); + + const scan = await scanSkillsWithDiagnostics(workspaceRoot); + + assert.deepEqual( + scan.skills.map((skill) => skill.id), + ['valid'], + ); + assert.deepEqual(scan.discoveryDiagnostics, [ + { + path: cyclicSkill, + scope: 'workspace', + source: 'legacy', + precedence: 0, + reason: 'read_failed', + }, + { + path: danglingSkill, + scope: 'workspace', + source: 'legacy', + precedence: 0, + reason: 'blocked_path', + }, + { + path: escapingSkill, + scope: 'workspace', + source: 'legacy', + precedence: 0, + reason: 'blocked_path', + }, + { + path: notDirectorySkill, + scope: 'workspace', + source: 'legacy', + precedence: 0, + reason: 'blocked_path', + }, + ]); + } finally { + await rm(outside, { recursive: true, force: true }); + } + }); + }); + it('readSkillRuntimeState does not read through a symlinked state file', async () => { await withWorkspace(async (workspaceRoot) => { const outside = await mkdtemp(join(tmpdir(), 'maka-skill-state-file-outside-')); diff --git a/packages/runtime/src/skills-discovery.ts b/packages/runtime/src/skills-discovery.ts index d971fc6c1e..f2ba5b73cd 100644 --- a/packages/runtime/src/skills-discovery.ts +++ b/packages/runtime/src/skills-discovery.ts @@ -19,7 +19,7 @@ import { createHash } from 'node:crypto'; import { homedir } from 'node:os'; -import { lstat, readdir, realpath } from 'node:fs/promises'; +import { lstat, readdir, realpath, stat } from 'node:fs/promises'; import { join } from 'node:path'; import { isPathInside, readContainedRegularFile } from './path-containment.js'; import { validateSkillMetadata } from './skills-metadata.js'; @@ -109,9 +109,10 @@ export interface ScannedSkill extends RuntimeSkillDefinition { contentSha256: string; /** * The containment root this skill was discovered under (e.g. workspace root, - * home dir). Used to compute `relativePath` in `loadSkillInstructions` so - * legacy callers see `skills//SKILL.md` while multi-path callers see - * the actual subpath. + * home dir). Remains the authority for later contained reads even when + * `path` is a mutable symlink, and is used to compute `relativePath` in + * `loadSkillInstructions` so legacy callers see `skills//SKILL.md` + * while multi-path callers see the actual subpath. */ discoveryRoot: string; } @@ -361,10 +362,11 @@ function normalizeSkillSource(source: SkillSource): { * `SKILL.md` is parsed. Metadata validation errors exclude only the malformed * skill and are returned as structured diagnostics. * - * The directory itself must be a real directory (not a symlink) and its - * realpath must be contained within the realpath of its parent directory. - * This prevents ancestor-level symlinks (e.g. `repo/.agents -> /outside`) - * from escaping the expected boundary. + * The discovery directory itself must be a real directory (not a symlink) and + * its realpath must be contained within its configured containment root. This + * prevents ancestor-level symlinks (e.g. `repo/.agents -> /outside`) from + * escaping the expected boundary. Immediate symlinked skill entries are + * followed only when their targets remain inside that same containment root. */ async function scanSkillDir( discovery: SkillDiscoveryEntry, @@ -382,26 +384,29 @@ async function scanSkillDir( discoveryDiagnostics, runtimeState, }); - const sourceDiagnostic = ( + const discoveryDiagnostic = ( + path: string, reason: SkillDiscoveryDiagnostic['reason'], ): SkillDiscoveryDiagnostic => ({ - path: dir, + path, scope, source, precedence, reason, }); let entries: import('node:fs').Dirent[]; + let rootReal: string; try { const dirStat = await lstat(dir); if (!dirStat.isDirectory() || dirStat.isSymbolicLink()) { - return empty([sourceDiagnostic('blocked_path')]); + return empty([discoveryDiagnostic(dir, 'blocked_path')]); } // Verify the resolved directory has not escaped its containment root via // an ancestor symlink (e.g. `repo/.agents -> /outside`). - const [rootReal, dirReal] = await Promise.all([realpath(containmentRoot), realpath(dir)]); + const [resolvedRoot, dirReal] = await Promise.all([realpath(containmentRoot), realpath(dir)]); + rootReal = resolvedRoot; if (!isPathInside(rootReal, dirReal)) { - return empty([sourceDiagnostic('blocked_path')]); + return empty([discoveryDiagnostic(dir, 'blocked_path')]); } entries = await readdir(dir, { withFileTypes: true }); entries.sort((a, b) => a.name.localeCompare(b.name)); @@ -409,18 +414,42 @@ async function scanSkillDir( // An absent optional discovery root is normal. A configured path that // exists but cannot be inspected is not: expose it to governance clients. if ((error as NodeJS.ErrnoException).code === 'ENOENT') return empty(); - return empty([sourceDiagnostic('read_failed')]); + return empty([discoveryDiagnostic(dir, 'read_failed')]); } const out: ScannedSkill[] = []; const rejected: RejectedSkillDefinition[] = []; const diagnostics: SkillScanDiagnostic[] = []; + const discoveryDiagnostics: SkillDiscoveryDiagnostic[] = []; for (const dirEntry of entries) { - if (!dirEntry.isDirectory()) continue; const skillPath = join(dir, dirEntry.name); - const skillFile = join(skillPath, 'SKILL.md'); + let skillReadRoot = skillPath; + let skillDirectory = skillPath; + if (!dirEntry.isDirectory()) { + if (!dirEntry.isSymbolicLink()) continue; + try { + const skillReal = await realpath(skillPath); + if (!isPathInside(rootReal, skillReal)) { + discoveryDiagnostics.push(discoveryDiagnostic(skillPath, 'blocked_path')); + continue; + } + if (!(await stat(skillReal)).isDirectory()) continue; + skillReadRoot = rootReal; + skillDirectory = skillReal; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + discoveryDiagnostics.push( + discoveryDiagnostic( + skillPath, + code === 'ENOENT' || code === 'ENOTDIR' ? 'blocked_path' : 'read_failed', + ), + ); + continue; + } + } + const skillFile = join(skillDirectory, 'SKILL.md'); try { - const read = await readContainedRegularFile(skillPath, skillFile); + const read = await readContainedRegularFile(skillReadRoot, skillFile); if (!read.ok) continue; const bytes = read.bytes; const text = bytes.toString('utf8'); @@ -485,7 +514,7 @@ async function scanSkillDir( inventory: out, rejected, diagnostics, - discoveryDiagnostics: [], + discoveryDiagnostics, runtimeState, }; } diff --git a/scripts/ci-test-plan.test.mjs b/scripts/ci-test-plan.test.mjs index f06a710408..c6c0adb5a0 100644 --- a/scripts/ci-test-plan.test.mjs +++ b/scripts/ci-test-plan.test.mjs @@ -645,8 +645,8 @@ test('Windows recovery executes the complete Skill catalog suite', () => { assert.match(recovery, /skill-catalog-repository\.test\.js/u); assert.match(recovery, /skill-catalog-transaction\.test\.js/u); assert.match(recovery, /skill-catalog-two-client-uds\.test\.js/u); - assert.match(recovery, /# tests 90/u); - assert.match(recovery, /# pass 90/u); + assert.match(recovery, /# tests 91/u); + assert.match(recovery, /# pass 91/u); assert.match(recovery, /# skipped 0/u); }); From 3c52d2c9fba806ec814586eadbb95027339dc185 Mon Sep 17 00:00:00 2001 From: Joseph B Date: Sun, 30 Aug 2026 07:16:19 -0400 Subject: [PATCH 17/71] refactor(core): report capability audit facts directly (#3815) Remove the fabricated permission vocabulary and report the capability facts owned by Skills and scheduled tasks directly. --- .../src/__tests__/capability-audit.test.ts | 89 +++++++++++++++++++ packages/core/src/capability-audit.ts | 30 +++---- .../capability-audit-strip.stories.tsx | 4 +- 3 files changed, 102 insertions(+), 21 deletions(-) create mode 100644 packages/core/src/__tests__/capability-audit.test.ts diff --git a/packages/core/src/__tests__/capability-audit.test.ts b/packages/core/src/__tests__/capability-audit.test.ts new file mode 100644 index 0000000000..1edcece4b2 --- /dev/null +++ b/packages/core/src/__tests__/capability-audit.test.ts @@ -0,0 +1,89 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { deriveCapabilityAuditReport } from '../capability-audit.js'; +import type { ScheduledTask, ScheduledTaskStatus } from '../scheduled-task.js'; + +describe('capability audit facts', () => { + it('reports skill tools and scheduled-task statuses without permission-mode aliases', () => { + const report = deriveCapabilityAuditReport({ + now: 1_000, + skills: [ + { id: 'with-tools', name: 'With tools', declaredTools: ['read', 'write'] }, + { id: 'without-tools', name: 'Without tools' }, + ], + scheduledTasks: [ + scheduledTask('active'), + scheduledTask('paused'), + scheduledTask('completed'), + scheduledTask('expired'), + ], + }); + + assert.deepEqual( + report.skills.map(({ id, hasDeclaredTools }) => ({ id, hasDeclaredTools })), + [ + { id: 'with-tools', hasDeclaredTools: true }, + { id: 'without-tools', hasDeclaredTools: false }, + ], + ); + assert.equal( + report.skills.some((skill) => 'permissionMode' in skill), + false, + ); + assert.deepEqual( + report.scheduledTasks.map(({ id, status }) => ({ id, status })), + [ + { id: 'active', status: 'active' }, + { id: 'paused', status: 'paused' }, + { id: 'completed', status: 'completed' }, + { id: 'expired', status: 'expired' }, + ], + ); + assert.equal( + report.scheduledTasks.some((task) => 'permissionMode' in task), + false, + ); + assert.equal(report.summary.activeScheduledTaskCount, 1); + assert.equal('executableScheduledTaskCount' in report.summary, false); + }); +}); + +function scheduledTask(status: ScheduledTaskStatus): ScheduledTask { + return { + id: status, + title: status, + intent: { kind: 'text', body: '' }, + schedule: { kind: 'once', runAt: 2_000 }, + effect: { kind: 'notify', channel: 'local' }, + status, + nextFireAt: status === 'active' ? 2_000 : null, + lastFireAt: null, + fireCount: 0, + maxFires: null, + expiresAt: null, + createdBy: { kind: 'user' }, + createdAt: 0, + updatedAt: 0, + runs: [], + lastError: null, + }; +} diff --git a/packages/core/src/capability-audit.ts b/packages/core/src/capability-audit.ts index b8a0df8149..5bf366c35c 100644 --- a/packages/core/src/capability-audit.ts +++ b/packages/core/src/capability-audit.ts @@ -17,7 +17,11 @@ * under the License. */ -import type { ScheduledTask, ScheduledTaskRunOutcome } from './scheduled-task.js'; +import type { + ScheduledTask, + ScheduledTaskRunOutcome, + ScheduledTaskStatus, +} from './scheduled-task.js'; export const SOURCE_RECORD_TYPES = ['mcp', 'api', 'local'] as const; export type SourceRecordType = (typeof SOURCE_RECORD_TYPES)[number]; @@ -28,9 +32,6 @@ export type SourceAuthType = (typeof SOURCE_AUTH_TYPES)[number]; export const SOURCE_RECORD_STATUSES = ['ready', 'needs_auth', 'error', 'disabled'] as const; export type SourceRecordStatus = (typeof SOURCE_RECORD_STATUSES)[number]; -export const CAPABILITY_AUDIT_PERMISSION_MODES = ['explore', 'ask'] as const; -export type CapabilityAuditPermissionMode = (typeof CAPABILITY_AUDIT_PERMISSION_MODES)[number]; - export const SCHEDULED_TASK_LAST_RUN_STATUSES = ['ok', 'error', 'skipped'] as const; export type ScheduledTaskLastRunStatus = (typeof SCHEDULED_TASK_LAST_RUN_STATUSES)[number]; @@ -62,16 +63,16 @@ export interface SkillAuditRecord { name: string; description: string; declaredTools: string[]; + hasDeclaredTools: boolean; enabled: boolean; sourceSlug: string; - permissionMode: CapabilityAuditPermissionMode; } export interface ScheduledTaskAuditRecord { id: string; name: string; enabled: boolean; - permissionMode: CapabilityAuditPermissionMode; + status: ScheduledTaskStatus; lastRunAt?: number; lastRunStatus?: ScheduledTaskLastRunStatus; } @@ -88,7 +89,7 @@ export interface CapabilityAuditSummary { declaredToolKindCount: number; scheduledTaskCount: number; enabledScheduledTaskCount: number; - executableScheduledTaskCount: number; + activeScheduledTaskCount: number; failedScheduledTaskCount: number; skippedScheduledTaskCount: number; } @@ -141,9 +142,9 @@ function normalizeSkillInputs(skills: readonly CapabilityAuditSkillInput[]): Ski name: normalizeNonEmptyString(skill.name) ?? id, description: normalizeNonEmptyString(skill.description) ?? '', declaredTools, + hasDeclaredTools: declaredTools.length > 0, enabled: skill.enabled ?? true, sourceSlug: normalizeNonEmptyString(skill.sourceSlug) ?? LOCAL_SKILL_SOURCE_SLUG, - permissionMode: declaredTools.length > 0 ? 'ask' : 'explore', }; }); } @@ -193,19 +194,12 @@ function scheduledTaskToAuditRecord(task: ScheduledTask): ScheduledTaskAuditReco id: task.id, name: task.title, enabled: task.status === 'active', - permissionMode: scheduledTaskPermissionMode(task), + status: task.status, ...(lastRun ? { lastRunAt: lastRun.at } : {}), ...(lastRun ? { lastRunStatus: mapScheduledTaskRunOutcome(lastRun.outcome) } : {}), }; } -function scheduledTaskPermissionMode(task: ScheduledTask): CapabilityAuditPermissionMode { - if (task.status === 'completed' || task.status === 'expired') return 'explore'; - // Active tasks run with `ask` approval — the retired `execute` mode folded to - // `ask` identically, so this preserves the historical behaviour. - return 'ask'; -} - function mapScheduledTaskRunOutcome(outcome: ScheduledTaskRunOutcome): ScheduledTaskLastRunStatus { if (outcome === 'ok') return 'ok'; if (outcome === 'blocked') return 'skipped'; @@ -229,9 +223,7 @@ function summarizeCapabilityAudit( declaredToolKindCount: distinctDeclaredToolKinds(skills).length, scheduledTaskCount: scheduledTasks.length, enabledScheduledTaskCount: scheduledTasks.filter((task) => task.enabled).length, - executableScheduledTaskCount: scheduledTasks.filter( - (task) => task.enabled && task.permissionMode !== 'explore', - ).length, + activeScheduledTaskCount: scheduledTasks.filter((task) => task.status === 'active').length, failedScheduledTaskCount: scheduledTasks.filter((task) => task.lastRunStatus === 'error') .length, skippedScheduledTaskCount: scheduledTasks.filter((task) => task.lastRunStatus === 'skipped') diff --git a/packages/ui/stories/capability-audit-strip.stories.tsx b/packages/ui/stories/capability-audit-strip.stories.tsx index d892c942ef..863c4bdc19 100644 --- a/packages/ui/stories/capability-audit-strip.stories.tsx +++ b/packages/ui/stories/capability-audit-strip.stories.tsx @@ -64,7 +64,7 @@ function report(input: Partial): CapabilityAud declaredToolKindCount: 0, scheduledTaskCount: 0, enabledScheduledTaskCount: 0, - executableScheduledTaskCount: 0, + activeScheduledTaskCount: 0, failedScheduledTaskCount: 0, skippedScheduledTaskCount: 0, ...input, @@ -123,7 +123,7 @@ export const WithRisks: Story = { declaredToolKindCount: 5, scheduledTaskCount: 6, enabledScheduledTaskCount: 5, - executableScheduledTaskCount: 4, + activeScheduledTaskCount: 4, failedScheduledTaskCount: 1, skippedScheduledTaskCount: 1, })} From be3a0291b5401ea23580062113bf9e0d20e6ca0f Mon Sep 17 00:00:00 2001 From: Xiao Liu Date: Sun, 30 Aug 2026 19:25:28 +0800 Subject: [PATCH 18/71] fix(desktop): realign renderer architecture ledger with source (#4255) Regenerate the committed renderer architecture ledger so it matches the current source and restores the required main CI check. Process follow-up remains tracked in #4257. --- apps/desktop/renderer-architecture.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 6c76a0ebad..5c663cf37a 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -390,7 +390,7 @@ "@maka/ui": 1 }, "importSpecifiers": 39, - "nonTriviaTokens": 4363 + "nonTriviaTokens": 4376 }, "src/renderer/app-shell-chrome-actions.tsx": { "importDeclarations": 5, @@ -714,7 +714,7 @@ "@maka/ui": 1 }, "importSpecifiers": 23, - "nonTriviaTokens": 2931 + "nonTriviaTokens": 3042 }, "src/renderer/app-shell-session-settings-actions.ts": { "importDeclarations": 9, @@ -3146,9 +3146,9 @@ "setTimeout": 2 }, "hookCalls": { - "useEffect": 4, + "useEffect": 5, "useMountedRef": 1, - "useRef": 3, + "useRef": 5, "useRuntimeHostSettingsTarget": 1, "useState": 18, "useUiLocale": 1 From 1c52241b335c94c55b127bf43b70cc2a5477b181 Mon Sep 17 00:00:00 2001 From: Yunpei Zhou Date: Sun, 30 Aug 2026 19:46:18 +0800 Subject: [PATCH 19/71] fix(core): refuse models whose declared output has no text (#4243) Reject image- and audio-only models through the shared chat-capability authority while preserving conservative handling for unknown output modalities. --- .../core/src/__tests__/model-catalog.test.ts | 56 +++++++++++++++ packages/core/src/model-catalog.ts | 32 ++++++++- .../src/__tests__/model-fetcher.test.ts | 69 +++++++++++++++++++ packages/runtime/src/model-fetcher.ts | 30 ++++++++ 4 files changed, 186 insertions(+), 1 deletion(-) diff --git a/packages/core/src/__tests__/model-catalog.test.ts b/packages/core/src/__tests__/model-catalog.test.ts index 81b36bbfff..6db41f0da2 100644 --- a/packages/core/src/__tests__/model-catalog.test.ts +++ b/packages/core/src/__tests__/model-catalog.test.ts @@ -82,6 +82,62 @@ test('chat-default validation blocks image-only models but accepts merged partia assert.deepEqual(verdict(partial), { ok: true }); }); +test('a declared output modality without text rules a model out of chat', () => { + // The shape this exists for: `gpt-image-2` on a relay. Bundled metadata + // records `modalities.output: ["image"]` and has never set + // `capabilities.imageGeneration` for any model, so before this the guard + // could not fire and an image model was selectable as a chat model. + const imageOnly = { + providerType: 'openai' as const, + defaultModel: 'gpt-image-2', + models: [{ id: 'gpt-image-2' }], + modelSource: 'fetched' as const, + }; + assert.deepEqual(verdict(imageOnly), { ok: false, reason: 'unsupported_for_chat' }); + + // Audio-only too, and a stray `reasoning: true` on a TTS model does not + // rescue it: reasoning describes how it composes speech, not that it can + // answer in text. + const audioOnly = { + providerType: 'google' as const, + defaultModel: 'gemini-3.1-flash-tts-preview', + models: [{ id: 'gemini-3.1-flash-tts-preview' }], + modelSource: 'fetched' as const, + }; + assert.deepEqual(verdict(audioOnly), { ok: false, reason: 'unsupported_for_chat' }); +}); + +test('an empty output modality list is not evidence against chat', () => { + // `modalities.output` is typed to text, image, and audio, so a video model's + // real output has no representation and serializes as `[]` — the same shape + // a generator bug would produce. Blocking on it would be guessing. + const video = { + providerType: 'google' as const, + defaultModel: 'gemini-omni-flash-preview', + models: [{ id: 'gemini-omni-flash-preview' }], + modelSource: 'fetched' as const, + }; + assert.deepEqual(verdict(video), { ok: true }); +}); + +test('an explicit chat capability outranks the declared output modality', () => { + // A provider that says both is contradicting itself, and the direct claim + // about chat is the more specific one. + const contradictory = { + providerType: 'openai-compatible' as const, + defaultModel: 'relay-omni', + models: [ + { + id: 'relay-omni', + capabilities: { chat: true }, + modalities: { input: ['text' as const], output: ['image' as const] }, + }, + ], + modelSource: 'fetched' as const, + }; + assert.deepEqual(verdict(contradictory), { ok: true }); +}); + test('catalog entries preserve advertised parallel tool-call support', () => { const [entry] = buildModelCatalogEntries({ providerType: 'openai-compatible', diff --git a/packages/core/src/model-catalog.ts b/packages/core/src/model-catalog.ts index ed2a5c7751..010c6e8fb0 100644 --- a/packages/core/src/model-catalog.ts +++ b/packages/core/src/model-catalog.ts @@ -356,9 +356,15 @@ function makeEntry( const lastUpdated = normalizedModel.lastUpdated ?? metadata.lastUpdated; const modalities = normalizedModel.modalities ?? metadata.modalities; const capabilities = mergeCapabilities(normalizedModel.capabilities, metadata.capabilities); + // `modalities` too, not just `capabilities`: both are merged from the + // provider row and the bundled metadata a few lines up, and the chat guard + // reads the modality. Passing the unmerged `normalizedModel.modalities` + // meant a bundled image-only model reached the guard with no output + // declaration at all. const unavailableReason = deriveModelUnavailableReason(input, { ...normalizedModel, capabilities, + ...(modalities !== undefined ? { modalities } : {}), }); return { id: normalizedModel.id, @@ -637,10 +643,34 @@ function isStale( return now - input.modelsFetchedAt > staleAfterMs; } +/** + * Whether a declared output modality rules the model out of chat. + * + * A model that answers only in images or only in audio cannot hold a + * conversation, and this is the form that fact actually arrives in: the + * generated metadata records `modalities.output` for every such model and has + * never set `capabilities.imageGeneration` for any of them, so the capability + * check below could not fire on bundled data. + * + * An EMPTY list is not evidence. `modalities.output` is typed to text, image, + * and audio, so a video model's real output has no representation and + * serializes as `[]` — the same shape a future generator bug would produce. + * Only a non-empty list says something, and what it says is what it lists. + */ +function declaresNoTextOutput(model: ModelInfo): boolean { + const output = model.modalities?.output; + if (output === undefined || output.length === 0) return false; + return !output.includes('text'); +} + export function isModelExplicitlyUnsupportedForChat(model: ModelInfo): boolean { const caps = model.capabilities; + if (caps?.chat === false) return true; + // Only an explicit `chat: true` outranks the modality. `reasoning` and + // `functionCalling` do not: a TTS model carrying `reasoning: true` is + // describing how it composes speech, and it still cannot answer in text. + if (caps?.chat !== true && declaresNoTextOutput(model)) return true; if (!caps) return false; - if (caps.chat === false) return true; return ( caps.imageGeneration === true && caps.chat !== true && diff --git a/packages/runtime/src/__tests__/model-fetcher.test.ts b/packages/runtime/src/__tests__/model-fetcher.test.ts index 9b81d427c8..5f067d6245 100644 --- a/packages/runtime/src/__tests__/model-fetcher.test.ts +++ b/packages/runtime/src/__tests__/model-fetcher.test.ts @@ -384,6 +384,75 @@ describe('fetchProviderModels', () => { assert.equal(JSON.stringify(outcome).includes(secret), false); } }); + + test('a declared output modality without text is recorded as a capability', async () => { + // `output_modalities` was validated and then dropped, so a relay that + // advertised an image-only model handed back a row indistinguishable from + // a chat model's and nothing downstream could refuse it. + const server = await startJsonServer((_request, response) => { + respondJson(response, 200, { + data: [ + { id: 'relay-image', input_modalities: ['text'], output_modalities: ['image'] }, + { id: 'relay-speech', input_modalities: ['text'], output_modalities: ['audio'] }, + { id: 'relay-chat', input_modalities: ['text'], output_modalities: ['text', 'image'] }, + { id: 'relay-video', input_modalities: ['text'], output_modalities: [] }, + { id: 'relay-silent', input_modalities: ['text'] }, + ], + }); + }); + + const models = await fetchProviderModels( + { ...zaiConnection(), baseUrl: server.url }, + 'zai-live-secret', + ); + const capabilitiesOf = (id: string) => models.find((model) => model.id === id)?.capabilities; + + assert.equal(capabilitiesOf('relay-image')?.chat, false); + assert.equal(capabilitiesOf('relay-image')?.imageGeneration, true); + // Audio-only is equally unable to answer in text, but it is not an image + // generator and must not be labelled one. + assert.equal(capabilitiesOf('relay-speech')?.chat, false); + assert.equal(capabilitiesOf('relay-speech')?.imageGeneration, undefined); + // Text among the outputs is a chat model whatever else it also emits. + assert.equal(capabilitiesOf('relay-chat')?.chat, undefined); + // An empty list and an absent one both say nothing, and nothing is not a + // refusal: a video model's output has no representation in this union. + assert.equal(capabilitiesOf('relay-video')?.chat, undefined); + assert.equal(capabilitiesOf('relay-silent')?.chat, undefined); + }); + + test('an unrecognized output modality never disables a model', async () => { + // The array is validated as an array and never item-by-item, so these + // reach the modality read intact. Every other modality read here ADDS a + // capability and an unrecognized value merely costs a fact; this one + // REMOVES chat, where the same miss would silently disable a model that + // works. Unrecognized has to mean "said nothing", not "said not text". + const server = await startJsonServer((_request, response) => { + respondJson(response, 200, { + data: [ + { id: 'relay-cased', output_modalities: ['Text'] }, + { id: 'relay-null', output_modalities: [null] }, + { id: 'relay-numeric', output_modalities: [42] }, + { id: 'relay-future', output_modalities: ['hologram'] }, + // A recognized value alongside an unrecognized one still counts: + // the provider named a modality this build understands. + { id: 'relay-mixed', output_modalities: ['image', 'hologram'] }, + ], + }); + }); + + const models = await fetchProviderModels( + { ...zaiConnection(), baseUrl: server.url }, + 'zai-live-secret', + ); + const capabilitiesOf = (id: string) => models.find((model) => model.id === id)?.capabilities; + + for (const id of ['relay-cased', 'relay-null', 'relay-numeric', 'relay-future']) { + assert.equal(capabilitiesOf(id)?.chat, undefined, id); + } + assert.equal(capabilitiesOf('relay-mixed')?.chat, false); + assert.equal(capabilitiesOf('relay-mixed')?.imageGeneration, true); + }); }); async function startJsonServer( diff --git a/packages/runtime/src/model-fetcher.ts b/packages/runtime/src/model-fetcher.ts index 3b4e478509..dd7845fb86 100644 --- a/packages/runtime/src/model-fetcher.ts +++ b/packages/runtime/src/model-fetcher.ts @@ -801,6 +801,22 @@ function toModelInfo(model: RawProviderModel): ModelInfo | null { if (model.tags?.includes('vision')) capabilities.vision = true; if (model.tags?.includes('reasoning')) capabilities.reasoning = true; if (model.tags?.includes('tool-use')) capabilities.functionCalling = true; + // `output_modalities` was validated above and then dropped, so a relay that + // advertised an image-only model handed back a row indistinguishable from a + // chat model's. Declared output that names modalities but not text is the + // provider stating the model cannot answer in text; record it the way the + // rest of this function records modality facts, as a capability. + // + // Read through `knownOutputModalities` rather than the raw array. Every + // other modality read here ADDS a capability, so a value this code fails to + // recognize costs a fact; this one REMOVES chat, where the same miss would + // silently disable a working model. `assertOptionalArray` checks the + // container and not its items, so `['Text']` or `[null]` reach here intact. + const declaredOutput = knownOutputModalities(model.output_modalities); + if (declaredOutput.length > 0 && !declaredOutput.includes('text')) { + capabilities.chat = false; + if (declaredOutput.includes('image')) capabilities.imageGeneration = true; + } if (model.providers) { capabilities.functionCalling = providers.some( (provider) => provider.status === 'live' && provider.supports_tools === true, @@ -853,6 +869,20 @@ function providerObjectArray( return value as T[]; } +/** + * The declared output modalities this build understands, in the provider's + * order. Anything else — a value from a newer spec, a capitalized spelling, a + * non-string — is dropped rather than guessed at, so an unrecognized list + * reads as "said nothing" instead of "said not text". + */ +function knownOutputModalities(declared: readonly unknown[] | undefined): string[] { + if (declared === undefined) return []; + return declared.filter( + (value): value is 'text' | 'image' | 'audio' => + value === 'text' || value === 'image' || value === 'audio', + ); +} + function assertOptionalArray( value: unknown, label: string, From 8f313057bbf4320cd1ad96501040921c17f6dfca Mon Sep 17 00:00:00 2001 From: Zhang <96464454+me2seeks@users.noreply.github.com> Date: Sun, 30 Aug 2026 19:55:03 +0800 Subject: [PATCH 20/71] feat(runtime-host): bind capability providers to Client owners (#4187) Bind companion capability providers to Host-authenticated Client identities while preserving a single access-authority association contract. --- .../runtime-host-operator-command.test.ts | 34 ++ packages/cli/src/cli-core.ts | 4 + .../cli/src/runtime-host-access-command.ts | 4 + packages/cli/src/runtime-host-cli.ts | 22 +- .../__tests__/authenticated-websocket.test.ts | 292 ++++++++++++++++++ .../client-capability-coordinator.test.ts | 169 ++++++++++ .../__tests__/fixtures/client-capability.ts | 11 +- .../src/__tests__/protocol.test.ts | 33 ++ .../src/protocol/access-authority.ts | 85 +++-- packages/runtime-host/src/protocol/index.ts | 5 +- .../src/server/access-authority.ts | 53 +++- .../src/server/access-credential-store.ts | 49 ++- .../server/client-capability-coordinator.ts | 115 +++++-- .../src/server/client-capability-service.ts | 4 + .../src/server/connection-authority.ts | 25 +- .../src/server/connection-session.ts | 9 + .../runtime-host/src/server/host-kernel.ts | 10 + 17 files changed, 874 insertions(+), 50 deletions(-) diff --git a/packages/cli/src/__tests__/runtime-host-operator-command.test.ts b/packages/cli/src/__tests__/runtime-host-operator-command.test.ts index 9179097b5b..47f058d8fb 100644 --- a/packages/cli/src/__tests__/runtime-host-operator-command.test.ts +++ b/packages/cli/src/__tests__/runtime-host-operator-command.test.ts @@ -317,6 +317,40 @@ describe('Runtime Host operator commands', () => { ]).kind, 'error', ); + assert.deepEqual( + parseRuntimeHostCommand([ + 'access', + 'issue', + '--kind', + 'capability-provider', + '--principal', + 'terminal-mcp-provider', + '--capability-owner-credential', + 'terminal-owner-credential', + ]), + { + kind: 'runtime-host-access-issue', + principalKind: 'capability_provider', + principalId: 'terminal-mcp-provider', + operationGrants: ['client.capability.replace', 'client.capability.unregister'], + canPublishClientCapabilities: true, + canUseHostPaths: false, + capabilityOwnerCredentialId: 'terminal-owner-credential', + }, + ); + assert.equal( + parseRuntimeHostCommand([ + 'access', + 'issue', + '--principal', + 'terminal-owner', + '--grant', + 'session.catalog.query', + '--capability-owner-credential', + 'terminal-owner-credential', + ]).kind, + 'error', + ); assert.deepEqual( parseRuntimeHostCommand([ 'access', diff --git a/packages/cli/src/cli-core.ts b/packages/cli/src/cli-core.ts index cedb82178b..1ed4c38329 100644 --- a/packages/cli/src/cli-core.ts +++ b/packages/cli/src/cli-core.ts @@ -228,6 +228,7 @@ function helpText(cliCommand: string): string { ' --preset Grant the desktop-client or terminal-client operation set', ' --publish-client-capabilities Allow Client Capability publication', ' --allow-host-paths Allow operations that submit Host paths', + ' --capability-owner-credential Bind a provider to one Client-bound owner credential', '', 'Runtime Host capability provider options:', ' --url Connect to an authenticated Runtime Host WebSocket', @@ -631,6 +632,9 @@ export async function runMakaCli( operationGrants: command.operationGrants, canPublishClientCapabilities: command.canPublishClientCapabilities, canUseHostPaths: command.canUseHostPaths, + ...(command.capabilityOwnerCredentialId + ? { capabilityOwnerCredentialId: command.capabilityOwnerCredentialId } + : {}), ...(command.preset ? { preset: command.preset } : {}), }); } diff --git a/packages/cli/src/runtime-host-access-command.ts b/packages/cli/src/runtime-host-access-command.ts index d86bd62d9e..86e58e624f 100644 --- a/packages/cli/src/runtime-host-access-command.ts +++ b/packages/cli/src/runtime-host-access-command.ts @@ -63,6 +63,7 @@ export interface RuntimeHostAccessIssueOptions { readonly operationGrants: readonly string[]; readonly canPublishClientCapabilities: boolean; readonly canUseHostPaths: boolean; + readonly capabilityOwnerCredentialId?: string; readonly preset?: RuntimeHostAccessPreset; readonly bindClientInstance?: boolean; } @@ -228,6 +229,9 @@ async function mutateRuntimeHostAccessCredential( operationGrants: resolved.operationGrants, canPublishClientCapabilities: resolved.canPublishClientCapabilities, canUseHostPaths: resolved.canUseHostPaths, + ...(operation !== 'access.credential.prepare' && options.capabilityOwnerCredentialId + ? { capabilityOwnerCredentialId: options.capabilityOwnerCredentialId } + : {}), ...(operation === 'access.credential.prepare' && options.bindClientInstance ? { bindClientInstance: true } : {}), diff --git a/packages/cli/src/runtime-host-cli.ts b/packages/cli/src/runtime-host-cli.ts index f05a3b0285..96bdee5300 100644 --- a/packages/cli/src/runtime-host-cli.ts +++ b/packages/cli/src/runtime-host-cli.ts @@ -254,6 +254,7 @@ export type RuntimeHostCliCommand = operationGrants: string[]; canPublishClientCapabilities: boolean; canUseHostPaths: boolean; + capabilityOwnerCredentialId?: string; preset?: 'desktop-client' | 'terminal-client'; } | { @@ -1994,6 +1995,7 @@ function parseAccessCommand(argv: string[]): RuntimeHostCliCommand { let principalKind: 'remote_owner' | 'capability_provider' = 'remote_owner'; let principalKindSpecified = false; let credentialId: string | undefined; + let capabilityOwnerCredentialId: string | undefined; let currentCredentialFingerprint: string | undefined; const operationGrants: string[] = []; let canPublishClientCapabilities = false; @@ -2022,6 +2024,7 @@ function parseAccessCommand(argv: string[]): RuntimeHostCliCommand { argument === '--principal' || argument === '--grant' || argument === '--credential' || + argument === '--capability-owner-credential' || argument === '--current-fingerprint' ) { const parsed = optionValue(argv, index, argument); @@ -2044,6 +2047,7 @@ function parseAccessCommand(argv: string[]): RuntimeHostCliCommand { if (argument === '--principal') principalId = parsed; if (argument === '--grant') operationGrants.push(parsed); if (argument === '--credential') credentialId = parsed; + if (argument === '--capability-owner-credential') capabilityOwnerCredentialId = parsed; if (argument === '--current-fingerprint') currentCredentialFingerprint = parsed; index += 1; continue; @@ -2062,6 +2066,7 @@ function parseAccessCommand(argv: string[]): RuntimeHostCliCommand { canUseHostPaths || preset || credentialId || + capabilityOwnerCredentialId || currentCredentialFingerprint ) { return error('Credential mutation options are not valid for access list'); @@ -2086,7 +2091,8 @@ function parseAccessCommand(argv: string[]): RuntimeHostCliCommand { canPublishClientCapabilities || canUseHostPaths || preset || - credentialId + credentialId || + capabilityOwnerCredentialId ) { return error('Credential issue options are not valid for access prepare'); } @@ -2113,6 +2119,9 @@ function parseAccessCommand(argv: string[]): RuntimeHostCliCommand { return error('--preset cannot be combined with --kind, --grant, or authority flags'); } if (preset) { + if (capabilityOwnerCredentialId) { + return error('--capability-owner-credential requires --kind capability-provider'); + } return { kind: 'runtime-host-access-issue', ...(rootPath ? { rootPath } : {}), @@ -2136,8 +2145,11 @@ function parseAccessCommand(argv: string[]): RuntimeHostCliCommand { return error('A capability provider may grant only Client Capability publication'); } canPublishClientCapabilities = true; - } else if (operationGrants.length === 0) { - return error('At least one --grant is required'); + } else { + if (capabilityOwnerCredentialId) { + return error('--capability-owner-credential requires --kind capability-provider'); + } + if (operationGrants.length === 0) return error('At least one --grant is required'); } return { kind: 'runtime-host-access-issue', @@ -2148,8 +2160,12 @@ function parseAccessCommand(argv: string[]): RuntimeHostCliCommand { operationGrants, canPublishClientCapabilities, canUseHostPaths, + ...(capabilityOwnerCredentialId ? { capabilityOwnerCredentialId } : {}), }; } + if (capabilityOwnerCredentialId) { + return error('--capability-owner-credential is only valid for access issue'); + } if (!credentialId) return error('--credential is required'); if (framed && !currentCredentialFingerprint) { return error('--current-fingerprint is required for framed access revoke'); diff --git a/packages/runtime-host/src/__tests__/authenticated-websocket.test.ts b/packages/runtime-host/src/__tests__/authenticated-websocket.test.ts index 99986ea8b2..f62cb2fb60 100644 --- a/packages/runtime-host/src/__tests__/authenticated-websocket.test.ts +++ b/packages/runtime-host/src/__tests__/authenticated-websocket.test.ts @@ -656,6 +656,291 @@ test('access credentials persist only as hashes and stay revoked after reload', } }); +test('capability-provider credentials retain a Host-verified Client owner identity', async () => { + const directory = await mkdtemp(join(tmpdir(), 'maka-access-authority-provider-owner-')); + try { + const authority = await openRuntimeHostAccessAuthority(directory); + const owner = await authority.prepare({ + principalKind: 'remote_owner', + principalId: 'terminal-owner', + operationGrants: ['access.credential.finalize', 'session.catalog.query'], + canPublishClientCapabilities: false, + canUseHostPaths: false, + bindClientInstance: true, + }); + await authority.finalize(owner.credentialId, 'terminal-client'); + const unboundOwner = await authority.issue({ + principalKind: 'remote_owner', + principalId: 'unbound-owner', + operationGrants: ['session.catalog.query'], + canPublishClientCapabilities: false, + canUseHostPaths: false, + }); + const providerInput = { + principalKind: 'capability_provider' as const, + principalId: 'terminal-mcp-provider', + operationGrants: ['client.capability.replace', 'client.capability.unregister'] as const, + canPublishClientCapabilities: true, + canUseHostPaths: false, + }; + const expectedOwner = { + principalId: 'terminal-owner', + clientInstanceId: 'terminal-client', + } as const; + await assert.rejects( + authority.issue({ + ...providerInput, + capabilityOwnerCredentialId: unboundOwner.credentialId, + }), + /must be bound to one Client identity/u, + ); + await assert.rejects( + authority.issue({ + principalKind: 'remote_owner', + principalId: 'invalid-owner-reference', + operationGrants: ['session.catalog.query'], + canPublishClientCapabilities: false, + canUseHostPaths: false, + capabilityOwnerCredentialId: owner.credentialId, + }), + /Only a capability provider/u, + ); + + const provider = await authority.issue({ + ...providerInput, + capabilityOwnerCredentialId: owner.credentialId, + }); + assert.deepEqual(provider.capabilityOwner, expectedOwner); + assert.equal( + JSON.parse(await readFile(join(directory, 'runtime-host-access.json'), 'utf8')).schemaVersion, + 4, + ); + const { consumeAccessCredentialDeliveryFromControlDirectory } = await import( + '../control/access-credential-delivery.js' + ); + const credential = await consumeAccessCredentialDeliveryFromControlDirectory( + directory, + provider.deliveryId, + provider.credentialId, + ); + assert.deepEqual(authority.authenticate(credential)?.capabilityOwner, expectedOwner); + + const replacementOwner = await authority.prepareRotation({ + replacementOfCredentialId: owner.credentialId, + }); + await authority.finalize(replacementOwner.credentialId, 'terminal-client'); + const replacementProvider = await authority.issue({ + ...providerInput, + principalId: 'rotated-terminal-mcp-provider', + capabilityOwnerCredentialId: replacementOwner.credentialId, + }); + assert.deepEqual(replacementProvider.capabilityOwner, provider.capabilityOwner); + + const reopened = await openRuntimeHostAccessAuthority(directory); + assert.deepEqual(reopened.authenticate(credential)?.capabilityOwner, expectedOwner); + await assert.rejects( + reopened.issue({ + ...providerInput, + principalId: 'missing-owner-provider', + capabilityOwnerCredentialId: 'missing-owner-credential', + }), + /active remote-owner credential/u, + ); + await authority.close(); + await reopened.close(); + const downgraded = JSON.parse( + await readFile(join(directory, 'runtime-host-access.json'), 'utf8'), + ) as Record; + await writeFile( + join(directory, 'runtime-host-access.json'), + `${JSON.stringify({ ...downgraded, schemaVersion: 3 })}\n`, + { mode: 0o600 }, + ); + await assert.rejects( + openRuntimeHostAccessAuthority(directory), + /Pre-association Runtime Host access files cannot declare capability owners/u, + ); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +test('an unbound WebSocket credential cannot claim an existing bound Client identity', { + timeout: 120_000, +}, async () => { + const base = await mkdtemp(join(tmpdir(), 'maka-bound-client-websocket-')); + const root = join(base, 'root'); + const capability = await resolveStorageRoot({ path: root, kind: 'interactive' }); + const host = await startExecutionRuntimeHostService({ + rootPath: root, + websocket: { host: '127.0.0.1', port: 0 }, + }); + let local: RuntimeHostConnection | undefined; + let owner: RuntimeHostConnection | undefined; + let provider: RuntimeHostConnection | undefined; + try { + local = requireConnection(await connectRuntimeHost({ rootPath: root, protocol: PROTOCOL })); + const ownerCandidate = await local.request('access.credential.prepare', { + principalKind: 'remote_owner', + principalId: 'shared-owner', + operationGrants: ['access.credential.finalize', 'session.catalog.query'], + canPublishClientCapabilities: false, + canUseHostPaths: false, + bindClientInstance: true, + }); + const ownerCredential = await consumeAccessCredentialDelivery( + root, + ownerCandidate.deliveryId, + ownerCandidate.credentialId, + ); + const url = host.websocketEndpoints[0]!; + const pairing = requireRemoteConnection( + await connectRemoteRuntimeHost({ + url, + credential: ownerCredential, + expectedRootId: capability.rootId, + compositionId: INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID, + clientInstanceId: 'client-a', + protocol: PROTOCOL, + }), + ); + assert.deepEqual(await pairing.request('access.credential.finalize', {}), { + reconnectRequired: true, + }); + await pairing.close(); + owner = requireRemoteConnection( + await connectRemoteRuntimeHost({ + url, + credential: ownerCredential, + expectedRootId: capability.rootId, + compositionId: INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID, + clientInstanceId: 'client-a', + protocol: PROTOCOL, + }), + ); + const unbound = await local.request('access.credential.issue', { + principalKind: 'remote_owner', + principalId: 'shared-owner', + operationGrants: ['session.catalog.query'], + canPublishClientCapabilities: false, + canUseHostPaths: false, + }); + const unboundCredential = await consumeAccessCredentialDelivery( + root, + unbound.deliveryId, + unbound.credentialId, + ); + const providerIssue = await local.request('access.credential.issue', { + principalKind: 'capability_provider', + principalId: 'shared-owner-provider', + operationGrants: ['client.capability.replace', 'client.capability.unregister'], + canPublishClientCapabilities: true, + canUseHostPaths: false, + capabilityOwnerCredentialId: ownerCandidate.credentialId, + }); + const providerCredential = await consumeAccessCredentialDelivery( + root, + providerIssue.deliveryId, + providerIssue.credentialId, + ); + provider = requireRemoteConnection( + await connectRemoteRuntimeHost({ + url, + credential: providerCredential, + expectedRootId: capability.rootId, + compositionId: INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID, + clientInstanceId: 'provider-a', + protocol: PROTOCOL, + }), + ); + await provider.replaceClientCapabilities({ + offers: () => [ + { + offerId: 'bound-provider', + version: '1', + affinity: 'session', + hostPathAccess: 'none', + label: 'Bound provider', + tools: [ + { + serverId: 'bound-provider', + name: 'echo', + inputSchema: { type: 'object' }, + }, + ], + }, + ], + call: async () => ({ content: [{ type: 'text', text: 'bound' }] }), + }); + + assert.deepEqual( + await connectRemoteRuntimeHost({ + url, + credential: unboundCredential, + expectedRootId: capability.rootId, + compositionId: INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID, + clientInstanceId: 'client-a', + protocol: PROTOCOL, + }), + { kind: 'unavailable', reason: 'handshake_failed' }, + ); + } finally { + await Promise.allSettled([provider?.close(), owner?.close(), local?.close()]); + await host.close().catch(() => undefined); + await rm(join(resolveRootControlNamespace(), capability.rootId), { + recursive: true, + force: true, + }); + await rm(base, { recursive: true, force: true }); + } +}); + +test('writes the capability-owner schema only after the association commits', async () => { + const directory = await mkdtemp(join(tmpdir(), 'maka-access-authority-owner-schema-')); + let rejectAssociation = false; + let authority: Awaited> | undefined; + try { + authority = await openRuntimeHostAccessAuthority(directory, { + writeFile: async (path, file) => { + if (rejectAssociation && file.schemaVersion === 4) { + throw new Error('association write rejected'); + } + await writeAccessCredentialFile(path, file); + }, + }); + const owner = await authority.prepare({ + principalKind: 'remote_owner', + principalId: 'schema-owner', + operationGrants: ['access.credential.finalize'], + canPublishClientCapabilities: false, + canUseHostPaths: false, + bindClientInstance: true, + }); + await authority.finalize(owner.credentialId, 'schema-client'); + const path = join(directory, 'runtime-host-access.json'); + assert.equal(JSON.parse(await readFile(path, 'utf8')).schemaVersion, 3); + + const providerInput = { + principalKind: 'capability_provider' as const, + principalId: 'schema-provider', + operationGrants: ['client.capability.replace', 'client.capability.unregister'] as const, + canPublishClientCapabilities: true, + canUseHostPaths: false, + capabilityOwnerCredentialId: owner.credentialId, + }; + rejectAssociation = true; + await assert.rejects(authority.issue(providerInput), /association write rejected/u); + assert.equal(JSON.parse(await readFile(path, 'utf8')).schemaVersion, 3); + + rejectAssociation = false; + await authority.issue(providerInput); + assert.equal(JSON.parse(await readFile(path, 'utf8')).schemaVersion, 4); + } finally { + await authority?.close(); + await rm(directory, { recursive: true, force: true }); + } +}); + test('credential rotation preserves authority and cannot outlive its active source', async () => { const directory = await mkdtemp(join(tmpdir(), 'maka-access-authority-rotation-')); const authority = await openRuntimeHostAccessAuthority(directory); @@ -1213,6 +1498,13 @@ function requireConnection( return result.connection; } +function requireRemoteConnection( + result: Awaited>, +): RuntimeHostConnection { + if (result.kind !== 'connected') throw new Error(`Remote Client did not connect: ${result.kind}`); + return result.connection; +} + async function waitForCondition(condition: () => Promise): Promise { for (let attempt = 0; attempt < 100; attempt += 1) { if (await condition()) return; diff --git a/packages/runtime-host/src/__tests__/client-capability-coordinator.test.ts b/packages/runtime-host/src/__tests__/client-capability-coordinator.test.ts index b8537d82ea..ddb155ab6d 100644 --- a/packages/runtime-host/src/__tests__/client-capability-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/client-capability-coordinator.test.ts @@ -301,6 +301,158 @@ describe('Host Client Capability coordinator', () => { await coordinator.close(); }); + test('selects only the provider bound to the exact initiating Client identity', async () => { + const coordinator = createCoordinator(); + const owner = coordinator.attachConnection( + clientCapabilityConnectionIdentity( + 'owner-connection', + 'owner-client', + 'owner-principal', + 'remote_owner', + ), + { send: async () => {} }, + ); + const unrelated = coordinator.attachConnection( + clientCapabilityConnectionIdentity( + 'unrelated-provider', + 'unrelated-provider-client', + 'unrelated-provider-principal', + 'capability_provider', + { principalId: 'other-principal', clientInstanceId: 'other-client' }, + ), + { send: async () => {} }, + ); + await replaceTrustedProvider( + coordinator, + 'unrelated-provider', + 'unrelated-registration', + 'inspect', + ); + + assert.deepEqual(await coordinator.bindSession('unrelated-only', 'owner-connection'), { + ok: true, + }); + assert.equal(coordinator.snapshotForSession('unrelated-only'), undefined); + + const associated = coordinator.attachConnection( + clientCapabilityConnectionIdentity( + 'associated-provider', + 'associated-provider-client', + 'associated-provider-principal', + 'capability_provider', + { principalId: 'owner-principal', clientInstanceId: 'owner-client' }, + ), + { send: async () => {} }, + ); + await replaceTrustedProvider( + coordinator, + 'associated-provider', + 'associated-registration', + 'inspect', + ); + assert.throws( + () => + coordinator.attachConnection( + clientCapabilityConnectionIdentity( + 'changed-owner-provider', + 'associated-provider-client', + 'associated-provider-principal', + 'capability_provider', + { principalId: 'other-principal', clientInstanceId: 'other-client' }, + ), + { send: async () => {} }, + ), + /provider owner changed/u, + ); + + assert.deepEqual(await coordinator.bindSession('associated', 'owner-connection'), { ok: true }); + const snapshot = coordinator.snapshotForSession('associated'); + assert.deepEqual(snapshot?.registrationIds, ['associated-registration']); + snapshot?.release(); + + const otherClient = coordinator.attachConnection( + clientCapabilityConnectionIdentity( + 'other-client-connection', + 'different-client', + 'owner-principal', + 'remote_owner', + ), + { send: async () => {} }, + ); + assert.deepEqual(await coordinator.bindSession('different-client', 'other-client-connection'), { + ok: true, + }); + assert.equal(coordinator.snapshotForSession('different-client'), undefined); + + const duplicate = coordinator.attachConnection( + clientCapabilityConnectionIdentity( + 'duplicate-provider', + 'duplicate-provider-client', + 'duplicate-provider-principal', + 'capability_provider', + { principalId: 'owner-principal', clientInstanceId: 'owner-client' }, + ), + { send: async () => {} }, + ); + await replaceTrustedProvider( + coordinator, + 'duplicate-provider', + 'duplicate-registration', + 'inspect', + ); + const ambiguous = await coordinator.bindSession('duplicate-owner', 'owner-connection'); + assert.equal(ambiguous.ok, false); + if (!ambiguous.ok) assert.match(ambiguous.message, /bound to the initiating Client/u); + + await Promise.all([ + owner.close(), + unrelated.close(), + associated.close(), + otherClient.close(), + duplicate.close(), + ]); + await coordinator.close(); + }); + + test('does not match a companion from a hello-only Client identity', async () => { + const coordinator = createCoordinator(); + const provider = coordinator.attachConnection( + clientCapabilityConnectionIdentity( + 'associated-provider', + 'provider-client', + 'provider-principal', + 'capability_provider', + { principalId: 'owner-principal', clientInstanceId: 'owner-client' }, + ), + { send: async () => {} }, + ); + await replaceTrustedProvider( + coordinator, + 'associated-provider', + 'associated-registration', + 'inspect', + ); + const unboundOwner = coordinator.attachConnection( + clientCapabilityConnectionIdentity( + 'unbound-owner', + 'owner-client', + 'owner-principal', + 'remote_owner', + undefined, + false, + ), + { send: async () => {} }, + ); + + assert.deepEqual(await coordinator.bindSession('unbound-owner', 'unbound-owner'), { + ok: true, + }); + assert.equal(coordinator.snapshotForSession('unbound-owner'), undefined); + + await Promise.all([provider.close(), unboundOwner.close()]); + await coordinator.close(); + }); + test('previews the initiating provider without persisting a Session binding', async () => { const coordinator = createCoordinator(); const connection = coordinator.attachConnection( @@ -1190,6 +1342,23 @@ async function replace( assert.equal(outcome.ok, true); } +async function replaceTrustedProvider( + coordinator: HostClientCapabilityCoordinator, + connectionId: string, + registrationId: string, + toolName: string, +): Promise { + const input = replacementInput(registrationId, toolName); + const outcome = await coordinator.handlers['client.capability.replace']( + { + ...input, + offers: input.offers.map((offer) => ({ ...offer, hostPathAccess: 'none' as const })), + }, + connectionContext(connectionId), + ); + assert.equal(outcome.ok, true); +} + function replacementInput( registrationId: string, toolName: string, diff --git a/packages/runtime-host/src/__tests__/fixtures/client-capability.ts b/packages/runtime-host/src/__tests__/fixtures/client-capability.ts index eb4ced75d9..6bdd854295 100644 --- a/packages/runtime-host/src/__tests__/fixtures/client-capability.ts +++ b/packages/runtime-host/src/__tests__/fixtures/client-capability.ts @@ -24,6 +24,15 @@ export function clientCapabilityConnectionIdentity( clientInstanceId = connectionId, principalId = 'test-principal', principalKind: ClientCapabilityConnectionIdentity['principalKind'] = 'local_owner', + capabilityOwner?: ClientCapabilityConnectionIdentity['capabilityOwner'], + credentialBound = principalKind === 'remote_owner', ): ClientCapabilityConnectionIdentity { - return { connectionId, principalId, clientInstanceId, principalKind }; + return { + connectionId, + principalId, + clientInstanceId, + ...(credentialBound ? { credentialBoundClientInstanceId: clientInstanceId } : {}), + principalKind, + ...(capabilityOwner ? { capabilityOwner } : {}), + }; } diff --git a/packages/runtime-host/src/__tests__/protocol.test.ts b/packages/runtime-host/src/__tests__/protocol.test.ts index bc348ac164..81bcfd7543 100644 --- a/packages/runtime-host/src/__tests__/protocol.test.ts +++ b/packages/runtime-host/src/__tests__/protocol.test.ts @@ -302,6 +302,39 @@ describe('Runtime Host bootstrap protocol', () => { ); }); + test('decodes Host-bound capability-provider ownership at a new compatibility boundary', () => { + const input = { + principalKind: 'capability_provider', + principalId: 'terminal-mcp-provider', + operationGrants: ['client.capability.replace', 'client.capability.unregister'], + canPublishClientCapabilities: true, + canUseHostPaths: false, + capabilityOwnerCredentialId: 'terminal-owner-credential', + }; + assert.deepEqual(HOST_OPERATION_SPECS['access.credential.issue'].decodeInput(input), input); + assert.throws(() => + HOST_OPERATION_SPECS['access.credential.prepare'].decodeInput({ + ...input, + bindClientInstance: true, + }), + ); + const output = { + credentialId: 'provider-credential', + deliveryId: 'provider-delivery', + principalKind: 'capability_provider', + principalId: 'terminal-mcp-provider', + operationGrants: ['client.capability.replace', 'client.capability.unregister'], + canPublishClientCapabilities: true, + canUseHostPaths: false, + capabilityOwner: { + principalId: 'terminal-owner', + clientInstanceId: 'terminal-client', + }, + }; + assert.deepEqual(HOST_OPERATION_SPECS['access.credential.issue'].decodeOutput(output), output); + assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 67); + }); + test('decodes atomic principal revocation and publishes its compatibility boundary', () => { assert.deepEqual( HOST_OPERATION_SPECS['access.principal.revoke'].decodeInput({ diff --git a/packages/runtime-host/src/protocol/access-authority.ts b/packages/runtime-host/src/protocol/access-authority.ts index 223b7fa668..7c07e49552 100644 --- a/packages/runtime-host/src/protocol/access-authority.ts +++ b/packages/runtime-host/src/protocol/access-authority.ts @@ -39,6 +39,11 @@ export type ManagedAccessCredentialPrincipalKind = Exclude< 'session_guest' >; +export interface ClientCapabilityOwnerIdentity { + readonly principalId: string; + readonly clientInstanceId: string; +} + const ACCESS_ERRORS = [ 'host_not_ready', 'host_draining', @@ -55,6 +60,7 @@ export interface AccessCredentialIssueInput { readonly operationGrants: readonly OperationKey[]; readonly canPublishClientCapabilities: boolean; readonly canUseHostPaths: boolean; + readonly capabilityOwnerCredentialId?: string; } export interface AccessCredentialIssueResult { @@ -65,11 +71,13 @@ export interface AccessCredentialIssueResult { readonly operationGrants: readonly OperationKey[]; readonly canPublishClientCapabilities: boolean; readonly canUseHostPaths: boolean; + readonly capabilityOwner?: ClientCapabilityOwnerIdentity; } export type AccessCredentialReplaceInput = AccessCredentialIssueInput; export type AccessCredentialReplaceResult = AccessCredentialIssueResult; -export interface AccessCredentialPrepareInput extends AccessCredentialIssueInput { +export interface AccessCredentialPrepareInput + extends Omit { readonly bindClientInstance?: boolean; } export type AccessCredentialPrepareResult = AccessCredentialIssueResult; @@ -202,15 +210,28 @@ export const ACCESS_AUTHORITY_OPERATION_SPECS = { } as const; export function decodeAccessCredentialIssueInput(value: unknown): AccessCredentialIssueInput { - const record = requireExactRecord(value, 'access credential issue input', [ - 'principalKind', - 'principalId', - 'operationGrants', - 'canPublishClientCapabilities', - 'canUseHostPaths', - ]); + const record = requireShapedRecord( + value, + 'access credential issue input', + [ + 'principalKind', + 'principalId', + 'operationGrants', + 'canPublishClientCapabilities', + 'canUseHostPaths', + ], + ['capabilityOwnerCredentialId'], + ); + const decodedPrincipalKind = principalKind(record.principalKind); + const capabilityOwnerCredentialId = + record.capabilityOwnerCredentialId === undefined + ? undefined + : requireId(record.capabilityOwnerCredentialId, 'capabilityOwnerCredentialId'); + if (capabilityOwnerCredentialId && decodedPrincipalKind !== 'capability_provider') { + throw invalidProtocolFrame('Only a capability provider credential may declare a Client owner'); + } return { - principalKind: principalKind(record.principalKind), + principalKind: decodedPrincipalKind, principalId: principalId(record.principalId), operationGrants: operationGrants(record.operationGrants), canPublishClientCapabilities: boolean( @@ -218,6 +239,7 @@ export function decodeAccessCredentialIssueInput(value: unknown): AccessCredenti 'canPublishClientCapabilities', ), canUseHostPaths: boolean(record.canUseHostPaths, 'canUseHostPaths'), + ...(capabilityOwnerCredentialId ? { capabilityOwnerCredentialId } : {}), }; } @@ -250,19 +272,32 @@ export function decodeAccessCredentialPrepareInput(value: unknown): AccessCreden } export function decodeAccessCredentialIssueResult(value: unknown): AccessCredentialIssueResult { - const record = requireExactRecord(value, 'access credential issue result', [ - 'credentialId', - 'deliveryId', - 'principalKind', - 'principalId', - 'operationGrants', - 'canPublishClientCapabilities', - 'canUseHostPaths', - ]); + const record = requireShapedRecord( + value, + 'access credential issue result', + [ + 'credentialId', + 'deliveryId', + 'principalKind', + 'principalId', + 'operationGrants', + 'canPublishClientCapabilities', + 'canUseHostPaths', + ], + ['capabilityOwner'], + ); + const decodedPrincipalKind = principalKind(record.principalKind); + const capabilityOwner = + record.capabilityOwner === undefined + ? undefined + : clientCapabilityOwnerIdentity(record.capabilityOwner); + if (capabilityOwner && decodedPrincipalKind !== 'capability_provider') { + throw invalidProtocolFrame('Only a capability provider credential may declare a Client owner'); + } return { credentialId: requireId(record.credentialId, 'credentialId'), deliveryId: requireId(record.deliveryId, 'deliveryId'), - principalKind: principalKind(record.principalKind), + principalKind: decodedPrincipalKind, principalId: principalId(record.principalId), operationGrants: operationGrants(record.operationGrants), canPublishClientCapabilities: boolean( @@ -270,6 +305,7 @@ export function decodeAccessCredentialIssueResult(value: unknown): AccessCredent 'canPublishClientCapabilities', ), canUseHostPaths: boolean(record.canUseHostPaths, 'canUseHostPaths'), + ...(capabilityOwner ? { capabilityOwner } : {}), }; } @@ -384,6 +420,17 @@ function principalId(value: unknown): string { return principal; } +function clientCapabilityOwnerIdentity(value: unknown): ClientCapabilityOwnerIdentity { + const record = requireExactRecord(value, 'Client Capability owner identity', [ + 'principalId', + 'clientInstanceId', + ]); + return { + principalId: principalId(record.principalId), + clientInstanceId: requireId(record.clientInstanceId, 'clientInstanceId'), + }; +} + function boolean(value: unknown, label: string): boolean { if (typeof value !== 'boolean') throw invalidProtocolFrame(`Invalid ${label}`); return value; diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index 8b03815a81..62fb76a362 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -95,7 +95,10 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const; export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // Increment when the same protocol version no longer guarantees safe Client-Host // interoperability. Mismatches are rejected before domain commands are admitted. -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 73 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 74 as const; +// 74: Capability-provider credentials may carry one Host-authenticated owner +// identity. Older peers cannot preserve the association and could select an +// unrelated provider for an interactive Session. // 73: Transcript pages carry a Host-owned Turn range boundary. Older peers // cannot preserve both the complete edge Turn and the bounded projection. // 71: Session Guests can submit durable exact Turn access requests and Owners diff --git a/packages/runtime-host/src/server/access-authority.ts b/packages/runtime-host/src/server/access-authority.ts index 83e5f13d8c..0d12781101 100644 --- a/packages/runtime-host/src/server/access-authority.ts +++ b/packages/runtime-host/src/server/access-authority.ts @@ -23,6 +23,7 @@ import { runtimeHostAccessCredentialHash } from '../access-credential-identity.j import { type AccessCredentialIssueInput, type AccessCredentialIssueResult, + type ClientCapabilityOwnerIdentity, type AccessCredentialFinalizeResult, type AccessCredentialPrepareInput, type AccessCredentialPrepareResult, @@ -150,6 +151,7 @@ export interface RuntimeHostAccessAuthority { principalId: string, kind: SessionCollaborationGrantKind, ): SessionCollaborationGrant | undefined; + hasActiveBoundClientIdentity(principalId: string, clientInstanceId: string): boolean; subscribeRevocations(listener: (credentialId: string) => void): () => void; subscribeGrantRevocations(listener: (grant: SessionCollaborationGrant) => void): () => void; subscribeApprovedTurnAccessRequests( @@ -229,6 +231,7 @@ class FileRuntimeHostAccessAuthority implements RuntimeHostAccessAuthority { !match.bindClientInstanceOnFinalize && match.canPublishClientCapabilities, canUseHostPaths: !match.bindClientInstanceOnFinalize && match.canUseHostPaths, ...(match.clientInstanceId ? { clientInstanceId: match.clientInstanceId } : {}), + ...(match.capabilityOwner ? { capabilityOwner: match.capabilityOwner } : {}), }) : undefined; } @@ -579,6 +582,16 @@ class FileRuntimeHostAccessAuthority implements RuntimeHostAccessAuthority { ); } + hasActiveBoundClientIdentity(principalId: string, clientInstanceId: string): boolean { + return this.#file.credentials.some( + (credential) => + credential.status === 'active' && + credential.principalKind === 'remote_owner' && + credential.principalId === principalId && + credential.clientInstanceId === clientInstanceId, + ); + } + prepareRotation( input: AccessCredentialRotationPrepareInput, ): Promise { @@ -601,7 +614,7 @@ class FileRuntimeHostAccessAuthority implements RuntimeHostAccessAuthority { operationGrants: current.operationGrants, canPublishClientCapabilities: current.canPublishClientCapabilities, canUseHostPaths: current.canUseHostPaths, - bindClientInstance: current.bindClientInstanceOnFinalize === true, + bindClientInstance: current.clientInstanceId !== undefined, }, 'prepare', current.operationGrants, @@ -622,6 +635,9 @@ class FileRuntimeHostAccessAuthority implements RuntimeHostAccessAuthority { operationGrants = issuedAccessGrants(input.operationGrants), ): Promise { assertCredentialAuthority(input, operationGrants); + const capabilityOwner = this.#resolveCapabilityOwner( + 'capabilityOwnerCredentialId' in input ? input.capabilityOwnerCredentialId : undefined, + ); if ( mode === 'prepare' && (input.principalKind !== 'remote_owner' || @@ -639,6 +655,7 @@ class FileRuntimeHostAccessAuthority implements RuntimeHostAccessAuthority { operationGrants, canPublishClientCapabilities: input.canPublishClientCapabilities, canUseHostPaths: input.canUseHostPaths, + ...(capabilityOwner ? { capabilityOwner } : {}), }); const credential = `${ACCESS_CREDENTIAL_PREFIX}${randomBytes(32).toString('base64url')}`; const createdAt = new Date(); @@ -651,6 +668,7 @@ class FileRuntimeHostAccessAuthority implements RuntimeHostAccessAuthority { operationGrants, canPublishClientCapabilities: input.canPublishClientCapabilities, canUseHostPaths: input.canUseHostPaths, + ...(capabilityOwner ? { capabilityOwner } : {}), createdAt: createdAt.toISOString(), ...(mode === 'prepare' ? { @@ -700,9 +718,33 @@ class FileRuntimeHostAccessAuthority implements RuntimeHostAccessAuthority { operationGrants, canPublishClientCapabilities: stored.canPublishClientCapabilities, canUseHostPaths: stored.canUseHostPaths, + ...(stored.capabilityOwner ? { capabilityOwner: stored.capabilityOwner } : {}), }; } + #resolveCapabilityOwner( + credentialId: string | undefined, + ): ClientCapabilityOwnerIdentity | undefined { + if (!credentialId) return undefined; + const owner = this.#file.credentials.find( + (credential) => credential.credentialId === credentialId && credential.status === 'active', + ); + if (!owner || owner.principalKind !== 'remote_owner') { + throw new RuntimeHostAccessInputError( + 'A capability provider owner must be one active remote-owner credential', + ); + } + if (!owner.clientInstanceId) { + throw new RuntimeHostAccessInputError( + 'A capability provider owner credential must be bound to one Client identity', + ); + } + return Object.freeze({ + principalId: owner.principalId, + clientInstanceId: owner.clientInstanceId, + }); + } + revoke(input: AccessCredentialRevokeInput): Promise { return this.#mutate(async () => { return this.#revoke(input.credentialId); @@ -1056,7 +1098,14 @@ function assertCredentialAuthority( input: AccessCredentialIssueInput, operationGrants: readonly string[], ): void { - if (input.principalKind !== 'capability_provider') return; + if (input.principalKind !== 'capability_provider') { + if (input.capabilityOwnerCredentialId) { + throw new RuntimeHostAccessInputError( + 'Only a capability provider credential may declare a Client owner', + ); + } + return; + } if (!input.canPublishClientCapabilities || input.canUseHostPaths) { throw new RuntimeHostAccessInputError( 'A capability provider must publish Client Capabilities without Host path authority', diff --git a/packages/runtime-host/src/server/access-credential-store.ts b/packages/runtime-host/src/server/access-credential-store.ts index 292cede830..77da3af44a 100644 --- a/packages/runtime-host/src/server/access-credential-store.ts +++ b/packages/runtime-host/src/server/access-credential-store.ts @@ -22,6 +22,7 @@ import { chmod, open, rename, rm, type FileHandle } from 'node:fs/promises'; import { dirname } from 'node:path'; import { type AccessCredentialPrincipalKind, + type ClientCapabilityOwnerIdentity, HOST_OPERATION_SPECS, operationAllowsRemoteOwner, type SessionCollaborationGrant, @@ -30,7 +31,11 @@ import { type OperationKey, } from '../protocol/index.js'; -const ACCESS_FILE_SCHEMA_VERSION = 3; +// Schema 4 makes provider ownership downgrade-safe once an association exists. +// Ordinary access files remain schema 3 so this feature does not fence a +// downgrade before there is an owner association to preserve. +const ACCESS_FILE_SCHEMA_VERSION = 4; +const PRE_CAPABILITY_OWNER_ACCESS_FILE_SCHEMA_VERSION = 3; const ACCESS_FILE_MAX_BYTES = 512 * 1024; const LEGACY_TRANSCRIPT_QUERY_GRANT = 'session.transcript.query'; const TRANSCRIPT_QUERY_REPLACEMENT_GRANTS = [ @@ -80,6 +85,7 @@ export interface StoredAccessCredential { readonly operationGrants: readonly OperationKey[]; readonly canPublishClientCapabilities: boolean; readonly canUseHostPaths: boolean; + readonly capabilityOwner?: ClientCapabilityOwnerIdentity; readonly createdAt: string; readonly bindClientInstanceOnFinalize?: true; readonly clientInstanceId?: string; @@ -88,7 +94,9 @@ export interface StoredAccessCredential { } export interface AccessCredentialFile { - readonly schemaVersion: typeof ACCESS_FILE_SCHEMA_VERSION; + readonly schemaVersion: + | typeof PRE_CAPABILITY_OWNER_ACCESS_FILE_SCHEMA_VERSION + | typeof ACCESS_FILE_SCHEMA_VERSION; readonly credentials: readonly StoredAccessCredential[]; readonly sessionGrants: readonly SessionCollaborationGrant[]; readonly turnAccessRequests: readonly SessionTurnAccessRequest[]; @@ -121,7 +129,9 @@ export function createAccessCredentialFile( turnAccessRequests: readonly SessionTurnAccessRequest[] = [], ): AccessCredentialFile { return { - schemaVersion: ACCESS_FILE_SCHEMA_VERSION, + schemaVersion: credentials.some((credential) => credential.capabilityOwner !== undefined) + ? ACCESS_FILE_SCHEMA_VERSION + : PRE_CAPABILITY_OWNER_ACCESS_FILE_SCHEMA_VERSION, credentials, sessionGrants, turnAccessRequests, @@ -225,12 +235,21 @@ function serializeAccessCredentialFile(file: AccessCredentialFile): string { function decodeAccessFile(value: unknown): AccessCredentialFile { if ( !isRecord(value) || - (value.schemaVersion !== 1 && value.schemaVersion !== 2 && value.schemaVersion !== 3) + (value.schemaVersion !== 1 && + value.schemaVersion !== 2 && + value.schemaVersion !== 3 && + value.schemaVersion !== 4) ) { throw new Error('Unsupported Runtime Host access file'); } if (!Array.isArray(value.credentials)) throw new Error('Invalid Runtime Host access file'); const credentials = value.credentials.map(decodeStoredCredential); + if ( + value.schemaVersion < ACCESS_FILE_SCHEMA_VERSION && + credentials.some((credential) => credential.capabilityOwner !== undefined) + ) { + throw new Error('Pre-association Runtime Host access files cannot declare capability owners'); + } if ( new Set(credentials.map((credential) => credential.credentialId)).size !== credentials.length ) { @@ -349,6 +368,10 @@ function decodeStoredCredential(value: unknown): StoredAccessCredential { ) { throw new Error('Invalid access credential Client binding state'); } + const capabilityOwner = decodeCapabilityOwner(value.capabilityOwner); + if (capabilityOwner && principalKind !== 'capability_provider') { + throw new Error('Only a capability provider may declare a Client Capability owner'); + } const expiresAt = value.expiresAt; if (value.status === 'pending') { if (typeof expiresAt !== 'string' || !Number.isFinite(Date.parse(expiresAt))) { @@ -370,6 +393,7 @@ function decodeStoredCredential(value: unknown): StoredAccessCredential { operationGrants, canPublishClientCapabilities: value.canPublishClientCapabilities, canUseHostPaths: value.canUseHostPaths, + ...(capabilityOwner ? { capabilityOwner } : {}), createdAt, ...(bindClientInstanceOnFinalize === true ? { bindClientInstanceOnFinalize } : {}), ...(typeof clientInstanceId === 'string' ? { clientInstanceId } : {}), @@ -399,6 +423,23 @@ function decodeStoredSessionGrant(value: unknown): SessionCollaborationGrant { }); } +function decodeCapabilityOwner(value: unknown): ClientCapabilityOwnerIdentity | undefined { + if (value === undefined) return undefined; + if (!isRecord(value)) throw new Error('Invalid Client Capability owner identity'); + const principalId = requireStoredString(value.principalId, 'capabilityOwner.principalId'); + if (!/^[A-Za-z0-9_.:-]{1,128}$/u.test(principalId)) { + throw new Error('Invalid capabilityOwner.principalId'); + } + const clientInstanceId = requireStoredString( + value.clientInstanceId, + 'capabilityOwner.clientInstanceId', + ); + if (clientInstanceId.length > 128) { + throw new Error('Invalid capabilityOwner.clientInstanceId'); + } + return Object.freeze({ principalId, clientInstanceId }); +} + function migrateStoredOperationGrants(grants: readonly string[]): readonly string[] { const migrated: string[] = []; const seen = new Set(); diff --git a/packages/runtime-host/src/server/client-capability-coordinator.ts b/packages/runtime-host/src/server/client-capability-coordinator.ts index 1e54236d92..f95b8d8c64 100644 --- a/packages/runtime-host/src/server/client-capability-coordinator.ts +++ b/packages/runtime-host/src/server/client-capability-coordinator.ts @@ -25,6 +25,7 @@ import type { RootExecutionDescriptor } from '@maka/core/agent-run'; import { type ToolGroup } from '@maka/runtime/tool-availability'; import { type ClientCapabilityOffer, + type ClientCapabilityOwnerIdentity, type ClientCapabilityReplaceInput, type ClientCapabilityServiceOffer, type ClientCapabilityToolDescriptor, @@ -65,7 +66,10 @@ interface ClientProviderState { readonly providerId: string; readonly principalId: string; readonly clientInstanceId: string; + readonly credentialBoundClientInstanceId?: string; + readonly principalKind: ClientCapabilityConnectionIdentity['principalKind']; readonly trustedProvider: boolean; + readonly capabilityOwner?: ClientCapabilityOwnerIdentity; activeConnectionId?: string; current?: CapabilityRegistration; readonly registrations: Map; @@ -333,14 +337,43 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService initiatingConnectionId: string, mode: SessionBindingMode, ): SessionBindingSelection { - const initiatingProviderId = this.#connections.get(initiatingConnectionId)?.provider.providerId; - const initiatingProvider = initiatingProviderId - ? this.#providers.get(initiatingProviderId) - : undefined; + const initiatingProvider = this.#connections.get(initiatingConnectionId)?.provider; + const directProvider = + initiatingProvider?.current && this.#activeConnection(initiatingProvider) + ? initiatingProvider + : undefined; + const associatedProviders = + initiatingProvider && + initiatingProvider.credentialBoundClientInstanceId === initiatingProvider.clientInstanceId + ? [...this.#providers.values()].filter( + (provider) => + provider.trustedProvider && + provider.current !== undefined && + this.#activeConnection(provider) !== undefined && + provider.capabilityOwner?.principalId === initiatingProvider.principalId && + provider.capabilityOwner.clientInstanceId === initiatingProvider.clientInstanceId, + ) + : []; + if (!directProvider && associatedProviders.length > 1) { + return { + ok: false, + message: 'Multiple Client Capability providers are bound to the initiating Client', + }; + } + const selectedInitiatingProvider = directProvider ?? associatedProviders[0]; + // A remote Client must never inherit an unrelated provider merely because + // it is the only candidate. Local-owner and recovery flows retain their + // existing provider-independent fallback when no provider was selected. + const initiatingProviderId = + selectedInitiatingProvider?.providerId ?? + (initiatingProvider?.principalKind === 'remote_owner' + ? initiatingProvider.providerId + : undefined); const previousState = this.#sessions.get(sessionId); const serviceProviderId = previousState?.serviceProviderId ?? - (initiatingProvider?.current && initiatingProvider.current.servicesByContract.size > 0 + (selectedInitiatingProvider?.current && + selectedInitiatingProvider.current.servicesByContract.size > 0 ? initiatingProviderId : undefined); const previous = previousState?.sessionBindings ?? new Map(); @@ -388,10 +421,8 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService }; } } else { - candidate = - candidates.find((entry) => entry.registration.providerId === initiatingProviderId) ?? - (candidates.length === 1 ? candidates[0] : undefined); - if (!candidate && candidates.length > 1) { + candidate = selectProviderCandidate(candidates, initiatingProviderId); + if (!candidate && initiatingProviderId === undefined && candidates.length > 1) { if (mode === 'degrade') continue; return { ok: false, @@ -436,9 +467,7 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService left.localeCompare(right), )) { if (candidates[0]?.offer.offer.affinity !== 'turn') continue; - const candidate = - candidates.find((entry) => entry.registration.providerId === initiatingProviderId) ?? - (candidates.length === 1 ? candidates[0] : undefined); + const candidate = selectProviderCandidate(candidates, initiatingProviderId); if (!candidate || offerConflictsWithProxyNames(candidate.offer, proxyNames)) continue; nextTurn.set(contractId, { kind: 'bound', @@ -510,7 +539,13 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService for (const [contractId, candidates] of [...eligible].sort(([left], [right]) => left.localeCompare(right), )) { - const offer = candidates[0]?.offer; + // A provider-independent snapshot keeps one representative descriptor so + // a dynamic call can report ambiguity. A remote Client's explicit + // selector must instead hide every unrelated provider. + const offer = + state?.initiatingProviderId === undefined + ? candidates[0]?.offer + : selectProviderCandidate(candidates, state.initiatingProviderId)?.offer; if ( !offer || offer.offer.affinity !== 'call' || @@ -987,18 +1022,36 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService #provider(identity: ClientCapabilityConnectionIdentity): ClientProviderState { const providerId = clientProviderId(identity.principalId, identity.clientInstanceId); const trustedProvider = identity.principalKind === 'capability_provider'; + if (identity.capabilityOwner && !trustedProvider) { + throw new Error('Only a capability provider may declare a Client Capability owner'); + } let provider = this.#providers.get(providerId); if (!provider) { provider = { providerId, principalId: identity.principalId, clientInstanceId: identity.clientInstanceId, + ...(identity.credentialBoundClientInstanceId + ? { credentialBoundClientInstanceId: identity.credentialBoundClientInstanceId } + : {}), + principalKind: identity.principalKind, trustedProvider, + ...(identity.capabilityOwner + ? { capabilityOwner: Object.freeze({ ...identity.capabilityOwner }) } + : {}), registrations: new Map(), }; this.#providers.set(providerId, provider); - } else if (provider.trustedProvider !== trustedProvider) { + } else if ( + provider.principalKind !== identity.principalKind || + provider.trustedProvider !== trustedProvider || + provider.credentialBoundClientInstanceId !== identity.credentialBoundClientInstanceId + ) { throw new Error('Client Capability provider authority changed across connections'); + } else if ( + !clientCapabilityOwnerIdentitiesEqual(provider.capabilityOwner, identity.capabilityOwner) + ) { + throw new Error('Client Capability provider owner changed across connections'); } return provider; } @@ -1033,13 +1086,13 @@ export class HostClientCapabilityCoordinator implements ClientCapabilityService readonly tool: FrozenToolBinding; } { const candidates = this.#eligibleOffersByContract().get(contractId) ?? []; - const candidate = - candidates.find((entry) => entry.registration.providerId === initiatingProviderId) ?? - (candidates.length === 1 ? candidates[0] : undefined); + const candidate = selectProviderCandidate(candidates, initiatingProviderId); if (!candidate) { throw new ClientCapabilityInvocationError( - candidates.length > 1 ? 'capability_ambiguous' : 'capability_lost', - candidates.length > 1 + initiatingProviderId === undefined && candidates.length > 1 + ? 'capability_ambiguous' + : 'capability_lost', + initiatingProviderId === undefined && candidates.length > 1 ? 'Multiple Client Capability providers offer this call-affine contract' : 'Client Capability provider is unavailable', ); @@ -1370,6 +1423,30 @@ function rememberOfferProxyNames(offer: FrozenOfferBinding, proxyNames: Map candidate.registration.providerId === initiatingProviderId); +} + +function clientCapabilityOwnerIdentitiesEqual( + left: ClientProviderState['capabilityOwner'], + right: ClientCapabilityConnectionIdentity['capabilityOwner'], +): boolean { + return ( + left === right || + (left !== undefined && + right !== undefined && + left.principalId === right.principalId && + left.clientInstanceId === right.clientInstanceId) + ); +} + function canonicalJson(value: unknown): string { if (value === null || typeof value === 'boolean' || typeof value === 'number') { return JSON.stringify(value); diff --git a/packages/runtime-host/src/server/client-capability-service.ts b/packages/runtime-host/src/server/client-capability-service.ts index 58ea30ec09..2c8ca26568 100644 --- a/packages/runtime-host/src/server/client-capability-service.ts +++ b/packages/runtime-host/src/server/client-capability-service.ts @@ -21,6 +21,7 @@ import type { AccessCredentialPrincipalKind, ClientCapabilityClientFrame, ClientCapabilityHostFrame, + ClientCapabilityOwnerIdentity, } from '../protocol/index.js'; export interface ClientCapabilityConnectionSender { @@ -31,7 +32,10 @@ export interface ClientCapabilityConnectionIdentity { readonly connectionId: string; readonly principalId: string; readonly clientInstanceId: string; + /** Present only when the access credential, rather than hello, authenticated this Client ID. */ + readonly credentialBoundClientInstanceId?: string; readonly principalKind: 'local_owner' | AccessCredentialPrincipalKind; + readonly capabilityOwner?: ClientCapabilityOwnerIdentity; } export interface ClientCapabilityConnection { diff --git a/packages/runtime-host/src/server/connection-authority.ts b/packages/runtime-host/src/server/connection-authority.ts index 8e7f83a1bf..359a81908e 100644 --- a/packages/runtime-host/src/server/connection-authority.ts +++ b/packages/runtime-host/src/server/connection-authority.ts @@ -22,6 +22,7 @@ import { operationAllowsRemoteOwner, operationUsesHostPaths, type AccessCredentialPrincipalKind, + type ClientCapabilityOwnerIdentity, type ClientCapabilityClientFrame, type OperationKey, type RequestFrame, @@ -32,6 +33,7 @@ export interface RuntimeHostConnectionAuthority { readonly principalId: string; readonly credentialId?: string; readonly clientInstanceId?: string; + readonly capabilityOwner?: ClientCapabilityOwnerIdentity; readonly operationGrants: 'all' | readonly OperationKey[]; readonly canPublishClientCapabilities: boolean; readonly canUseHostPaths: boolean; @@ -63,6 +65,20 @@ export function createRuntimeHostConnectionAuthority( ) { throw new Error('Runtime Host bound Client identity is invalid'); } + if (input.capabilityOwner) { + if (input.principalKind !== 'capability_provider') { + throw new Error('Only a capability provider may declare a Client Capability owner'); + } + if (!/^[A-Za-z0-9_.:-]{1,128}$/u.test(input.capabilityOwner.principalId)) { + throw new Error('Runtime Host Client Capability owner principal is invalid'); + } + if ( + input.capabilityOwner.clientInstanceId.length === 0 || + input.capabilityOwner.clientInstanceId.length > 128 + ) { + throw new Error('Runtime Host Client Capability owner identity is invalid'); + } + } const operationGrants = input.operationGrants === 'all' ? 'all' @@ -74,7 +90,14 @@ export function createRuntimeHostConnectionAuthority( return operation; }), ); - return Object.freeze({ ...input, operationGrants }); + const capabilityOwner = input.capabilityOwner + ? Object.freeze({ ...input.capabilityOwner }) + : undefined; + return Object.freeze({ + ...input, + operationGrants, + ...(capabilityOwner ? { capabilityOwner } : {}), + }); } export function authorizeRuntimeHostOperation( diff --git a/packages/runtime-host/src/server/connection-session.ts b/packages/runtime-host/src/server/connection-session.ts index 7e800e1755..d571557670 100644 --- a/packages/runtime-host/src/server/connection-session.ts +++ b/packages/runtime-host/src/server/connection-session.ts @@ -296,7 +296,16 @@ export class RuntimeHostConnectionSession { connectionId: this.#options.connection.connectionId, principalId: this.#options.connection.authority.principalId, clientInstanceId: this.#options.connection.clientInstanceId, + ...(this.#options.connection.authority.clientInstanceId + ? { + credentialBoundClientInstanceId: + this.#options.connection.authority.clientInstanceId, + } + : {}), principalKind: this.#options.connection.authority.principalKind, + ...(this.#options.connection.authority.capabilityOwner + ? { capabilityOwner: this.#options.connection.authority.capabilityOwner } + : {}), }, { send: (frame) => { diff --git a/packages/runtime-host/src/server/host-kernel.ts b/packages/runtime-host/src/server/host-kernel.ts index 4ab9603da7..ae628e18eb 100644 --- a/packages/runtime-host/src/server/host-kernel.ts +++ b/packages/runtime-host/src/server/host-kernel.ts @@ -480,6 +480,16 @@ export class RuntimeHostKernel { if (authority.clientInstanceId && authority.clientInstanceId !== hello.clientInstanceId) { throw new Error('Runtime Host access credential belongs to another Client'); } + if ( + authority.principalKind === 'remote_owner' && + authority.clientInstanceId === undefined && + this.#options.accessAuthority?.hasActiveBoundClientIdentity( + authority.principalId, + hello.clientInstanceId, + ) + ) { + throw new Error('Runtime Host Client identity is bound to another access credential'); + } const selectedProtocol = negotiateProtocol( { min: hello.protocolMin, max: hello.protocolMax }, HOST_PROTOCOL, From 296b05a26000df66e7fc35f38c5069a091ab1406 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 30 Aug 2026 20:00:04 +0800 Subject: [PATCH 21/71] ci: declare the Desktop Nightly environment (#4260) Declare a main-only, approval-free Nightly deployment environment through ASF's self-service repository configuration. Extend the repository control-plane contract so the environment cannot drift to another ref or approval model. Generated-by: Codex --- .asf.yaml | 9 +++++++++ .github/DESKTOP_NIGHTLY.md | 2 +- .github/RELEASE_CHECKLIST.md | 8 ++++---- docs/cli-npm-release.md | 3 ++- docs/cli-npm-release.zh-CN.md | 3 ++- scripts/product-release.test.mjs | 20 +++++++++++--------- 6 files changed, 29 insertions(+), 16 deletions(-) diff --git a/.asf.yaml b/.asf.yaml index ceacc9ef40..b85c3649de 100644 --- a/.asf.yaml +++ b/.asf.yaml @@ -110,6 +110,15 @@ github: policies: - name: main type: branch + nightly: + required_reviewers: [] + wait_timer: 0 + prevent_self_review: false + deployment_branch_policy: + protected_branches: false + policies: + - name: main + type: branch product-release: required_reviewers: - id: M4n5ter diff --git a/.github/DESKTOP_NIGHTLY.md b/.github/DESKTOP_NIGHTLY.md index f3f701eacb..91d2fd5d1f 100644 --- a/.github/DESKTOP_NIGHTLY.md +++ b/.github/DESKTOP_NIGHTLY.md @@ -28,7 +28,7 @@ Nightly currently uses the same application identity as the formal Desktop. Inst ## One-time setup 1. Ask Apache Infra to allow `apache/maka` to publish GitHub Actions output to `nightlies.apache.org` and whitelist the repository for the standard `NIGHTLIES_RSYNC_HOST`, `NIGHTLIES_RSYNC_KEY`, `NIGHTLIES_RSYNC_PATH`, `NIGHTLIES_RSYNC_PORT`, and `NIGHTLIES_RSYNC_USER` secrets. -2. Create a GitHub Environment named `nightly` that permits only `main`. Verify that its jobs can read the five Infra-provided Nightlies secrets. Configure its macOS signing and notarization secrets: `CSC_LINK`, `CSC_KEY_PASSWORD`, `APPLE_API_KEY`, `APPLE_API_KEY_ID`, and `APPLE_API_ISSUER`. Do not expose these secrets to repository-wide or pull-request workflows. +2. After the checked-in `.asf.yaml` reaches `main`, verify that ASF reconciliation created the `nightly` GitHub Environment with only `main` permitted and no approval gate. Do not maintain that policy manually in GitHub. Verify that its jobs can read the five Infra-provided Nightlies secrets. Configure its macOS signing and notarization secrets: `CSC_LINK`, `CSC_KEY_PASSWORD`, `APPLE_API_KEY`, `APPLE_API_KEY_ID`, and `APPLE_API_ISSUER`. Do not expose these secrets to repository-wide or pull-request workflows. 3. Configure npm Trusted Publishing for `apache/maka` and `.github/workflows/npm-publication.yml`, restricted to the `npm-publication` Environment and with both `npm publish` and `npm stage publish` allowed. Do not create or store a long-lived npm token. 4. After npm Trusted Publishing is ready, set `NPM_NIGHTLY_ENABLED` to `true`, run `npm publication` from `main` with `channel=nightly`, and verify the exact npm version and `nightly` dist-tag. This does not depend on Desktop Infra. 5. After Infra publishing and the `nightly` Environment secrets are ready, set `DESKTOP_NIGHTLY_ENABLED` to `true` and start a fresh npm Nightly. Confirm that its successful run triggers `Desktop Nightly`. diff --git a/.github/RELEASE_CHECKLIST.md b/.github/RELEASE_CHECKLIST.md index 0f78a82e5c..4402722337 100644 --- a/.github/RELEASE_CHECKLIST.md +++ b/.github/RELEASE_CHECKLIST.md @@ -63,10 +63,10 @@ Before the first product release, confirm the checked-in `.asf.yaml` has reconci - the `Immutable release tags` ruleset blocks updates, force-pushes, and deletions of `v*` tags; - the `release` Environment accepts only its declared source-candidate tag pattern and requires a reviewer other than the triggering user; -- `npm-publication` and `product-release` accept only `main`; `product-release` requires a reviewer - other than the triggering user. `npm-publication` has no GitHub - approval gate because scheduled Nightly publication is automatic; formal npm publication still - requires human 2FA approval after staging. +- `npm-publication`, `nightly`, and `product-release` accept only `main`; `product-release` requires + a reviewer other than the triggering user. `npm-publication` and `nightly` have no GitHub approval + gate because scheduled npm and Desktop Nightly publication is automatic; formal npm publication + still requires human 2FA approval after staging. These controls close the check-to-upload and check-to-stage windows. Finalize uses GitHub Actions OIDC rather than a stored signing key to attest every convenience artifact. Keep the Release in diff --git a/docs/cli-npm-release.md b/docs/cli-npm-release.md index b32c2ebaa4..1f5ee2b048 100644 --- a/docs/cli-npm-release.md +++ b/docs/cli-npm-release.md @@ -73,12 +73,13 @@ The workflow boundaries are: ### GitHub Environment -The checked-in `.asf.yaml` is the authority for the `npm-publication` and `product-release` +The checked-in `.asf.yaml` is the authority for the `npm-publication`, `nightly`, and `product-release` Environments. After it reaches `main`, confirm ASF reconciliation produced: - a selected `main` branch rule for `npm-publication`, with no approval gate so the scheduled Nightly can publish automatically; +- a selected `main` branch rule for `nightly`, with no approval gate so Desktop Nightly can publish automatically; - a selected `main` branch rule for `product-release`, with `M4n5ter` as required reviewer and self-review disabled; - administrator bypass disabled where repository policy permits it. diff --git a/docs/cli-npm-release.zh-CN.md b/docs/cli-npm-release.zh-CN.md index 012921f8bd..06f9bef79d 100644 --- a/docs/cli-npm-release.zh-CN.md +++ b/docs/cli-npm-release.zh-CN.md @@ -63,11 +63,12 @@ workflow 边界分别是: ### GitHub Environment -仓库中的 `.asf.yaml` 是 `npm-publication` 和 `product-release` Environment 的权威。该配置进入 `main` 后,确认 ASF +仓库中的 `.asf.yaml` 是 `npm-publication`、`nightly` 和 `product-release` Environment 的权威。该配置进入 `main` 后,确认 ASF 同步出的 live 配置满足: - `npm-publication` 只允许 selected `main` branch;为保证 Nightly 自动运行,不设置 approval gate; +- `nightly` 只允许 selected `main` branch;为保证 Desktop Nightly 自动运行,不设置 approval gate; - `product-release` 只允许 selected `main` branch,required reviewer 为 `M4n5ter`,并禁止 self-review; - 仓库策略允许时禁用 administrator bypass。 diff --git a/scripts/product-release.test.mjs b/scripts/product-release.test.mjs index 334554aa76..04023366bc 100644 --- a/scripts/product-release.test.mjs +++ b/scripts/product-release.test.mjs @@ -777,15 +777,17 @@ test('repository control plane admits only each release phase owner ref', async }, }); } - assert.deepEqual(environments['npm-publication'], { - required_reviewers: [], - wait_timer: 0, - prevent_self_review: false, - deployment_branch_policy: { - protected_branches: false, - policies: [{ name: 'main', type: 'branch' }], - }, - }); + for (const name of ['npm-publication', 'nightly']) { + assert.deepEqual(environments[name], { + required_reviewers: [], + wait_timer: 0, + prevent_self_review: false, + deployment_branch_policy: { + protected_branches: false, + policies: [{ name: 'main', type: 'branch' }], + }, + }); + } assert.deepEqual( config.github.rulesets.find((ruleset) => ruleset.name === 'Immutable release tags'), { From d07ff87c7a65e0395052c154ad97e67db64a7f07 Mon Sep 17 00:00:00 2001 From: zhiiw <53705571+zhiiw@users.noreply.github.com> Date: Sun, 30 Aug 2026 20:02:50 +0800 Subject: [PATCH 22/71] feat(runtime-host): add Gitoxide candidate and accepted-tree read data plane (#3857) * feat(runtime-host): add Gitoxide successor authority * feat(runtime-host): project accepted Gitoxide trees * feat(runtime-host): read accepted Gitoxide tree files * refactor(runtime-host): separate Gitoxide candidates from accepted state * fix(runtime-host): bind Gitoxide candidate outcomes * chore(test): refresh Windows skip inventory * fix(runtime-host): require direct Gitoxide receipt refs * fix(runtime-host): close Gitoxide operation evidence gaps * test(gitoxide): corrupt loose object portably * test(gitoxide): tamper retry blob portably * fix(gitoxide): verify exact candidate retry tree --- ...oxide-accepted-tree-data-plane-v1.zh-CN.md | 117 ++ ...epository-admission-capability-v1.zh-CN.md | 4 +- ...oxide-source-import-data-plane-v1.zh-CN.md | 2 +- docs/windows-test-inventory.md | 6 +- native/gitoxide-helper/Cargo.lock | 38 +- native/gitoxide-helper/Cargo.toml | 7 +- native/gitoxide-helper/src/main.rs | 1179 ++++++++++++++++- .../tests/repository_admission.rs | 911 ++++++++++++- ...helper-artifact-authority-internal.test.ts | 3 + ...itoxide-helper-invocation-internal.test.ts | 241 +++- ...itory-admission-authority-internal.test.ts | 329 ++++- ...xide-helper-artifact-authority-internal.ts | 56 +- .../gitoxide-helper-invocation-internal.ts | 589 +++++++- ...repository-admission-authority-internal.ts | 257 +++- 14 files changed, 3667 insertions(+), 72 deletions(-) create mode 100644 docs/architecture/gitoxide-accepted-tree-data-plane-v1.zh-CN.md diff --git a/docs/architecture/gitoxide-accepted-tree-data-plane-v1.zh-CN.md b/docs/architecture/gitoxide-accepted-tree-data-plane-v1.zh-CN.md new file mode 100644 index 0000000000..63528dbaf4 --- /dev/null +++ b/docs/architecture/gitoxide-accepted-tree-data-plane-v1.zh-CN.md @@ -0,0 +1,117 @@ + + +# Gitoxide candidate and accepted-tree read data plane v1 + +状态:建立在 Gitoxide repository admission/source import 之上的 enabling infrastructure。 + +最小因果主线是: + +```text +accepted A + -> 基于 A 生成 immutable staged candidate B + -> 用 operation-specific ref + MustNotExist CAS 发布 B + -> 后续 SQLite acceptance owner 决定是否 promote A -> B +``` + +本切片只拥有 stage,不拥有 promote;任何 candidate 路径都不得推进 `refs/maka/accepted` 或签发新的 +accepted capability。 + +## 1. 主要不变量 + +本切片只证明: + +> owner-bound accepted-repository capability 只能读取被冻结的 exact accepted SHA-1 commit/tree,或 +> 从该 exact base 生成 operation-bound immutable candidate proof。candidate 不能推进 accepted ref, +> 也不能签发新的 accepted capability。 + +SQLite accepted head 仍由后续 mutation authority 拥有。本切片不提供 filesystem projection;projection +必须由 storage-root owner 在后续 PR 中以独立 destination capability 和 crash protocol 实现。 + +## 2. Owner、原子边界与能力 + +- import authority 在任何 destination side effect 前验证 helper 的 operation feature attestation;导入成功 + 后只在固定 `refs/maka/accepted` namespace 捕获 helper capability、managed repository path、accepted + commit、tree 与 policy v3,并签发 + opaque accepted-repository capability; +- candidate helper 同时绑定 exact base commit/tree、accepted ref、operation-specific candidate ref、path + 与 result bytes;完整 verified walk 后,tree editor 仍只通过 checksum-verifying finder 加载 owned tree, + 禁止重新信任未验证 ODB bytes; +- published 与 no-change 都创建包含 request digest 的 deterministic candidate receipt commit,并以同一个 + operation-specific ref 作为线性化点;两种成功都返回 owner-bound candidate-outcome capability; +- accepted ref 与 candidate ref 都必须是直接指向 exact commit object 的 direct ref;symbolic ref 与 + annotated tag 不得通过 peel 后的语义相等冒充 receipt identity; +- candidate outcome 私下绑定 exact accepted capability identity、repository/ref、helper artifact、policy、 + request digest、result SHA-256 与 Git identities;验证时必须同时出示原 accepted capability; +- direct read 沿 accepted tree 的 exact path 加载并重验 tree/blob object identity,只返回最多 8 MiB 的 + UTF-8 内容;它不读取 projection filesystem; +- 每个公开入口都复用 admission 时捕获的 helper capability,并在短生命周期 helper 启动前重新验证 + artifact identity,同时要求 release claim 明确 attest 本次实际调用的 operation; +- candidate fresh self-check 与 exact retry 都必须实际加载 result blob,在 64 MiB 上限内验证 kind 与 + checksum,不能只相信 candidate tree 中记录的 OID edge; +- exact retry 从 verified base tree、继承的目标 mode、canonical path 与 result blob 纯计算唯一 expected + candidate tree OID,不写入新 object;receipt tree 必须与它完全相等。由此目标之外的额外变化、目标 mode + flip 和伪造 no-change receipt 都无法获得 outcome capability。 + +## 3. 失败与回滚 + +| 状态 | 处理 | +| --- | --- | +| accepted ref 在初次检查时不等于 capability base | 写 object 前返回 `base_commit_mismatch`,candidate ref 不存在 | +| accepted/candidate ref 是 symbolic ref 或指向 tag/非 commit object | 稳定返回 direct-ref/target-invalid 错误 | +| base commit/tree identity 不一致 | 写 object 前 fail closed | +| candidate 违反 policy v3 | candidate ref CAS 前 fail closed | +| candidate ref 已存在且 request digest 不同 | 写 object 前返回 `candidate_request_conflict`,不覆盖已有 candidate | +| existing receipt 的 tree 不是 exact base 单路径转换结果 | 返回 `candidate_ref_target_invalid`,不签发 capability | +| result 与 base tree 相同 | 写入 same-tree receipt commit 并 CAS operation ref,返回 `candidate_no_change` outcome capability | +| 同一 operation 后续给出不同 path/content/disposition | deterministic commit 不同,稳定 conflict | +| direct read 路径、类型、大小、UTF-8 或 object identity 不合法 | fail closed,不读取 filesystem | +| helper 超时、中断或输出不匹配 | 不签发新 capability;由调用者重试 exact operation 或 park | + +candidate outcome 的线性化点是 operation-specific direct candidate ref 的 `MustNotExist` CAS。任何 object +write 前先检查既有 direct receipt:request digest 相同则从 verified base 纯计算 expected successor tree, +要求 receipt tree 与之精确一致,再重验 result blob 后 exact retry;request 不同则稳定 conflict。CAS 竞争失败 +后再次重读 direct ref:相同 deterministic commit 视为 exact retry,不同 commit 视为 conflict;无法证明 +publication 状态时返回 `candidate_publication_indeterminate`。 + +accepted ref 在本切片中只读,并与 `refs/maka/candidates/` namespace 不重叠。第二次 accepted +ref 检查与 candidate CAS 不是一个跨 ref transaction:二者之间的 accepted drift 可能留下绑定旧 base 的 +stale candidate evidence,但不能推进 accepted truth。后续 SQLite acceptance 必须重新验证 canonical head 并 +拒绝 stale candidate。direct read 是 observation,不发布持久事实。 + +## 4. 平台与资源边界 + +- Linux/macOS/Windows 使用同一 Gitoxide protocol;本切片不写工作区 filesystem; +- policy v3 的 path、Unicode fold、tree/blob、`.gitattributes` 和 object identity 规则只由 Rust helper + 判定;TypeScript 只负责 transport byte bound,避免复制一份会漂移的 path policy; +- candidate 和 direct read 的绝对 deadline 为 10 分钟; +- candidate content 通过 bounded Base64 field 传输,64 MiB 业务上限与 JSON escaping 无关; +- direct read 单文件最多 8 MiB,helper stdout 以 JSON worst-case escaping 设置固定上限。 + +## 5. 后续依赖 + +后续 mutation lifecycle 把 accepted capability 与 SQLite T1 reservation 绑定;SQLite 原子接受 candidate +后,单独的 promotion/reconcile owner 才能更新 accepted-ref projection 并签发新 accepted capability。 +Desktop/CLI 消费者不得绕过 capability 直接传 repository path、helper path 或 accepted commit。 + +当前 accepted/candidate capability 是进程内 opaque authority。Git candidate ref/receipt commit 跨进程耐久, +但 Host crash 后重新签发 capability 需要后续 durable workspace receipt owner:它必须重开 exact repository、 +持有 storage-root lease,重验 direct accepted/candidate ref、receipt commit checksum、request digest、base +parent、candidate tree 与 path/result blob,再允许 SQLite 接受。本切片的 exact retry 承诺覆盖同一进程及 +helper process response loss;不把尚未接线的 cross-Host reopen 或 candidate GC 宣称为已完成能力。 diff --git a/docs/architecture/gitoxide-repository-admission-capability-v1.zh-CN.md b/docs/architecture/gitoxide-repository-admission-capability-v1.zh-CN.md index 070768b140..30d48864b9 100644 --- a/docs/architecture/gitoxide-repository-admission-capability-v1.zh-CN.md +++ b/docs/architecture/gitoxide-repository-admission-capability-v1.zh-CN.md @@ -93,7 +93,7 @@ capability 表示一次明确线性化点上的 immutable Git commit/tree snapsh 6. exact commit/tree/blob checksum verification、atomic destination claim 与 deterministic zero-parent baseline publication。 -`managedTreePolicyVersion: 2` 是当前唯一的 **portable lexical materialization policy**,而不只是 Git +`managedTreePolicyVersion: 3` 是当前唯一的 **portable lexical materialization policy**,而不只是 Git object import policy。未发布的 policy v1 已删除;helper、Host capability 和 import response 都拒绝把 version 1 重新解释成当前语义。v2 只证明 Git tree 的词法身份和受支持 attributes 满足同一套保守规则, 不宣称已证明某个真实 Windows volume 上的 path-length、8.3 alias、ACL 或大小写能力。 @@ -119,7 +119,7 @@ CRLF 或 tab;literal path 不接受任何 whitespace/control。外部 `filter` Unicode 17.0 NFC → Unicode 16.0 Default Full Case Folding(Non-Turkic)→ Unicode 17.0 NFC ``` -原路径和 folded key 各自执行单路径与累计 byte budget。后续 candidate、tree read 必须消费 policy 2; +原路径和 folded key 各自执行单路径与累计 byte budget。后续 candidate、tree read 必须消费 policy 3; 真实 projection 还必须由独立的 `FilesystemMaterializationProfileV1` 在 fresh destination 上证明 目标 filesystem 的 case/alias/path-length 能力并执行 create/post-observation,不能把本词法检查冒充为 真实文件系统准入。该 projection/candidate owner 还必须实现与 `text=auto eol=lf` 对称的确定性 LF diff --git a/docs/architecture/gitoxide-source-import-data-plane-v1.zh-CN.md b/docs/architecture/gitoxide-source-import-data-plane-v1.zh-CN.md index 6d723f722e..185c4d1b0e 100644 --- a/docs/architecture/gitoxide-source-import-data-plane-v1.zh-CN.md +++ b/docs/architecture/gitoxide-source-import-data-plane-v1.zh-CN.md @@ -95,7 +95,7 @@ hook/filter/submodule/LFS,也不接入 T1/T2。 process-crash 自动恢复或断电恢复; - Windows 保留 Git tree 中的 executable bit,不把它映射成 ACL 权威。 -上述规则是 portable lexical materialization policy v2。它不证明 Windows path-length、8.3 alias 或 +上述规则是 portable lexical materialization policy v3。它不证明 Windows path-length、8.3 alias 或 目标 volume 的大小写行为;这些能力必须由未来 projection owner 的 `FilesystemMaterializationProfileV1` 在 fresh destination 上独立验证。v2 import 只验证 attributes 语义并复制 immutable objects;真正 materialize 和 candidate 写回必须消费同一 policy,并对受支持文本 diff --git a/docs/windows-test-inventory.md b/docs/windows-test-inventory.md index c218eb71b2..ed2576185e 100644 --- a/docs/windows-test-inventory.md +++ b/docs/windows-test-inventory.md @@ -15,11 +15,11 @@ Locations intentionally omit line numbers so unrelated edits do not invalidate t | Classification | Count | |---|---:| -| windows-backend-gap | 25 | +| windows-backend-gap | 27 | | portable-candidate | 10 | | platform-contract | 31 | -Total Windows-excluded declarations: **66** +Total Windows-excluded declarations: **68** ## Inventory @@ -40,6 +40,8 @@ Total Windows-excluded declarations: **66** | windows-backend-gap | `packages/runtime-host/src/__tests__/gitoxide-helper-invocation-internal.test.ts` applies the import deadline and terminates the helper process tree | `process.platform === 'win32'` | | windows-backend-gap | `packages/runtime-host/src/__tests__/gitoxide-helper-invocation-internal.test.ts` rejects an import response that does not match the requested baseline ref | `process.platform === 'win32'` | | windows-backend-gap | `packages/runtime-host/src/__tests__/gitoxide-helper-invocation-internal.test.ts` rejects an import response that does not match the requested source HEAD | `process.platform === 'win32'` | +| windows-backend-gap | `packages/runtime-host/src/__tests__/gitoxide-helper-invocation-internal.test.ts` rejects a candidate response whose blob identity does not match the requested bytes | `process.platform === 'win32'` | +| windows-backend-gap | `packages/runtime-host/src/__tests__/gitoxide-helper-invocation-internal.test.ts` rejects a direct-read response whose blob identity does not match its content | `process.platform === 'win32'` | | windows-backend-gap | `packages/runtime-host/src/__tests__/host-kernel.test.ts` an automatic failed liveness check is connection-fatal and Client close stays local | `process.platform === 'win32'` | | windows-backend-gap | `packages/runtime-host/src/__tests__/host-kernel.test.ts` bounded election does not launch a Candidate after handshake exhausts the deadline | `process.platform === 'win32'` | | windows-backend-gap | `packages/runtime-host/src/__tests__/host-kernel.test.ts` a non-reading Client overload is isolated to its connection | `process.platform === 'win32'` | diff --git a/native/gitoxide-helper/Cargo.lock b/native/gitoxide-helper/Cargo.lock index 5e27951dca..db758e9891 100644 --- a/native/gitoxide-helper/Cargo.lock +++ b/native/gitoxide-helper/Cargo.lock @@ -17,6 +17,12 @@ dependencies = [ "rustversion", ] +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bisync" version = "0.3.0" @@ -549,14 +555,14 @@ dependencies = [ [[package]] name = "gix-hash" -version = "0.26.1" +version = "0.26.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caf125eae66b7d6e4395511a06c0d43a3c34eac96c8641fb98b22078faee65b8" +checksum = "380b9c423a54f0821064b954b5a5ab25c660812f6a91da03c3f1cb4b10762aa5" dependencies = [ "faster-hex", "gix-features", "sha1-checked", - "sha2", + "sha2 0.11.0", "thiserror", ] @@ -645,9 +651,9 @@ dependencies = [ [[package]] name = "gix-packetline" -version = "0.22.0" +version = "0.22.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3766025c72319c4accdd854a18e6f0dd176c8eb0f3bc8a60a7765be2b50cabf2" +checksum = "792bd92bea390087e6223b18d4d023decc36467de31e6b066b69b94beea1b9e2" dependencies = [ "bstr", "faster-hex", @@ -1039,10 +1045,13 @@ checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" name = "maka-gitoxide-helper" version = "0.0.0" dependencies = [ + "base64", "caseless", "gix", "serde", "serde_json", + "sha2 0.10.9", + "tempfile", "unicode-normalization", ] @@ -1226,7 +1235,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.4", ] [[package]] @@ -1263,6 +1272,17 @@ dependencies = [ "sha1", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + [[package]] name = "sha2" version = "0.11.0" @@ -1305,9 +1325,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.3" +version = "3.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" dependencies = [ "proc-macro2", "quote", @@ -1344,7 +1364,7 @@ checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.4", ] [[package]] diff --git a/native/gitoxide-helper/Cargo.toml b/native/gitoxide-helper/Cargo.toml index 0db69f5f78..86188666e0 100644 --- a/native/gitoxide-helper/Cargo.toml +++ b/native/gitoxide-helper/Cargo.toml @@ -28,8 +28,13 @@ name = "maka-gitoxide-helper" path = "src/main.rs" [dependencies] +base64 = "0.22" caseless = "=0.2.2" -gix = { version = "=0.86.0", default-features = false, features = ["sha1", "sha256"] } +gix = { version = "=0.86.0", default-features = false, features = ["sha1", "sha256", "tree-editor"] } serde = { version = "1", features = ["derive"] } serde_json = "1" +sha2 = "0.10" unicode-normalization = "0.1" + +[dev-dependencies] +tempfile = "3" diff --git a/native/gitoxide-helper/src/main.rs b/native/gitoxide-helper/src/main.rs index ac2326238c..929b1b4308 100644 --- a/native/gitoxide-helper/src/main.rs +++ b/native/gitoxide-helper/src/main.rs @@ -25,13 +25,16 @@ use std::{ process::ExitCode, }; +use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; use caseless::Caseless; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; use unicode_normalization::UnicodeNormalization; const PROTOCOL_VERSION: u8 = 1; -const MANAGED_TREE_POLICY_VERSION: u8 = 2; -const MAX_REQUEST_BYTES: u64 = 64 * 1024; +const MANAGED_TREE_POLICY_VERSION: u8 = 3; +const MAX_ENCODED_CONTENT_BYTES: u64 = MAX_IMPORT_FILE_BYTES.div_ceil(3) * 4; +const MAX_REQUEST_BYTES: u64 = MAX_ENCODED_CONTENT_BYTES + 64 * 1024; const MAX_REPOSITORY_METADATA_BYTES: u64 = 1024 * 1024; const MAX_REPOSITORY_METADATA_ENTRIES: u64 = 16_384; const MAX_REPOSITORY_METADATA_DEPTH: u64 = 64; @@ -40,13 +43,14 @@ const MAX_GITOXIDE_OBJECT_STORE_SLOTS: u16 = 1024; const MAX_IMPORT_FILE_BYTES: u64 = 64 * 1024 * 1024; const MAX_ATTRIBUTES_FILE_BYTES: u64 = 64 * 1024; const MAX_GIT_ATTRIBUTES_LINE_BYTES_V2: usize = 2048; +const MAX_TREE_FILE_BYTES: u64 = 8 * 1024 * 1024; const MAX_IMPORT_BYTES: u64 = 2 * 1024 * 1024 * 1024; const MAX_IMPORT_FILES: u64 = 200_000; const MAX_COMMIT_OBJECT_BYTES: u64 = 1024 * 1024; const MAX_SINGLE_TREE_OBJECT_BYTES: u64 = 8 * 1024 * 1024; const MAX_TOTAL_TREE_OBJECT_BYTES: u64 = 64 * 1024 * 1024; const MAX_GITOXIDE_OBJECT_ALLOCATION_BYTES: &str = "gitoxide.objects.allocLimit=67108864"; -const MANAGED_TREE_POLICY_V2: ManagedTreePolicy = ManagedTreePolicy { +const MANAGED_TREE_POLICY_V3: ManagedTreePolicy = ManagedTreePolicy { max_depth: 64, max_tree_visits: 250_000, max_entries: 400_000, @@ -78,7 +82,16 @@ const HELPER_ERROR_REASONS_V1: &[&str] = &[ "baseline_commit_write_failed", "baseline_publish_failed", "baseline_ref_outside_maka_namespace", + "base_commit_unavailable", + "base_commit_identity_mismatch", + "base_path_lookup_failed", + "base_tree_unavailable", + "base_tree_identity_mismatch", + "blob_write_failed", + "commit_write_failed", "invalid_baseline_ref", + "invalid_base_commit_oid", + "invalid_successor_path", "import_destination_create_failed", "import_destination_not_fresh", "import_destination_object_format_mismatch", @@ -112,6 +125,29 @@ const HELPER_ERROR_REASONS_V1: &[&str] = &[ "source_tree_observation_mismatch", "source_tree_unavailable", "source_tree_visit_limit_exceeded", + "successor_content_limit_exceeded", + "successor_commit_identity_mismatch", + "accepted_ref_not_direct", + "accepted_ref_target_invalid", + "candidate_ref_not_direct", + "candidate_ref_target_invalid", + "candidate_request_conflict", + "candidate_publication_indeterminate", + "target_ref_outside_maka_namespace", + "target_ref_unavailable", + "tree_edit_failed", + "tree_write_failed", + "unsupported_base_path_kind", + "accepted_commit_unavailable", + "accepted_tree_unavailable", + "invalid_accepted_commit_oid", + "invalid_tree_file_path", + "tree_file_identity_mismatch", + "tree_file_invalid", + "tree_file_lookup_failed", + "tree_file_not_utf8", + "tree_file_size_limit_exceeded", + "tree_file_unavailable", "unsupported_source_entry_kind", "unsupported_source_attributes", "unsupported_source_path", @@ -139,6 +175,24 @@ enum Request { baseline_ref: String, managed_tree_policy_version: u8, }, + CreateCandidate { + protocol_version: u8, + repository_path: PathBuf, + accepted_ref: String, + expected_base_commit_oid: String, + expected_base_tree_oid: String, + candidate_ref: String, + path: String, + content_base64: String, + managed_tree_policy_version: u8, + }, + ReadTreeFile { + protocol_version: u8, + repository_path: PathBuf, + accepted_commit_oid: String, + path: String, + managed_tree_policy_version: u8, + }, } #[derive(Serialize)] @@ -172,6 +226,59 @@ enum Response<'a> { bytes_imported: u64, }, #[serde(rename_all = "camelCase")] + CandidatePublished { + protocol_version: u8, + object_format: &'static str, + base_commit_oid: String, + base_tree_oid: String, + candidate_commit_oid: String, + candidate_tree_oid: String, + result_blob_oid: String, + request_digest_sha256: String, + accepted_ref: String, + candidate_ref: String, + path: String, + managed_tree_policy_version: u8, + }, + #[serde(rename_all = "camelCase")] + CandidateNoChange { + protocol_version: u8, + object_format: &'static str, + base_commit_oid: String, + base_tree_oid: String, + result_blob_oid: String, + candidate_commit_oid: String, + candidate_tree_oid: String, + request_digest_sha256: String, + accepted_ref: String, + candidate_ref: String, + path: String, + managed_tree_policy_version: u8, + }, + #[serde(rename_all = "camelCase")] + CandidateRejected { + protocol_version: u8, + reason: &'static str, + object_format: &'static str, + expected_base_commit_oid: String, + actual_base_commit_oid: String, + accepted_ref: String, + candidate_ref: String, + managed_tree_policy_version: u8, + }, + #[serde(rename_all = "camelCase")] + TreeFileRead { + protocol_version: u8, + object_format: &'static str, + accepted_commit_oid: String, + accepted_tree_oid: String, + blob_oid: String, + path: String, + content: String, + bytes_read: u64, + managed_tree_policy_version: u8, + }, + #[serde(rename_all = "camelCase")] HelperError { protocol_version: u8, reason: &'a str, @@ -223,6 +330,44 @@ fn run() -> Result { managed_tree_policy_version, ) } + Request::CreateCandidate { + protocol_version, + repository_path, + accepted_ref, + expected_base_commit_oid, + expected_base_tree_oid, + candidate_ref, + path, + content_base64, + managed_tree_policy_version, + } => { + assert_protocol_version(protocol_version)?; + create_candidate( + repository_path, + accepted_ref, + expected_base_commit_oid, + expected_base_tree_oid, + candidate_ref, + path, + content_base64, + managed_tree_policy_version, + ) + } + Request::ReadTreeFile { + protocol_version, + repository_path, + accepted_commit_oid, + path, + managed_tree_policy_version, + } => { + assert_protocol_version(protocol_version)?; + read_tree_file( + repository_path, + accepted_commit_oid, + path, + managed_tree_policy_version, + ) + } } } @@ -524,7 +669,7 @@ fn import_source_head( &source, expected_source_head, gix::objs::Kind::Commit, - MANAGED_TREE_POLICY_V2.max_commit_object_bytes, + MANAGED_TREE_POLICY_V3.max_commit_object_bytes, "source_head_commit_unavailable", "source_head_commit_unavailable", "commit_object_limit_exceeded", @@ -544,7 +689,7 @@ fn import_source_head( source_tree, "", 0, - MANAGED_TREE_POLICY_V2, + MANAGED_TREE_POLICY_V3, &mut stats, )?; let expected_files = stats.files; @@ -564,7 +709,7 @@ fn import_source_head( source_tree, "", 0, - MANAGED_TREE_POLICY_V2, + MANAGED_TREE_POLICY_V3, &mut copy_stats, )?; if copy_stats.files != expected_files || copy_stats.bytes != expected_bytes { @@ -611,6 +756,829 @@ fn import_source_head( Ok(ExitCode::SUCCESS) } +fn create_candidate( + repository_path: PathBuf, + accepted_ref: String, + expected_base_commit_oid: String, + expected_base_tree_oid: String, + candidate_ref: String, + path: String, + content_base64: String, + managed_tree_policy_version: u8, +) -> Result { + use gix::bstr::ByteSlice; + + if !accepted_ref.starts_with("refs/maka/") { + return Err("target_ref_outside_maka_namespace"); + } + gix::refs::FullName::try_from(accepted_ref.as_str()) + .map_err(|_| "target_ref_outside_maka_namespace")?; + if !candidate_ref.starts_with("refs/maka/candidates/") { + return Err("target_ref_outside_maka_namespace"); + } + gix::refs::FullName::try_from(candidate_ref.as_str()) + .map_err(|_| "target_ref_outside_maka_namespace")?; + if managed_tree_policy_version != MANAGED_TREE_POLICY_VERSION { + return Err("unsupported_managed_tree_policy"); + } + if !is_canonical_successor_path_v3(&path) { + return Err("invalid_successor_path"); + } + if content_base64.len() as u64 > MAX_ENCODED_CONTENT_BYTES { + return Err("successor_content_limit_exceeded"); + } + let content = BASE64_STANDARD + .decode(content_base64) + .map_err(|_| "invalid_request")?; + if content.len() as u64 > MAX_IMPORT_FILE_BYTES { + return Err("successor_content_limit_exceeded"); + } + std::str::from_utf8(&content).map_err(|_| "invalid_request")?; + let request_digest_sha256 = candidate_request_digest_sha256( + &accepted_ref, + &expected_base_commit_oid, + &expected_base_tree_oid, + &candidate_ref, + &path, + &content, + ); + + let repository = open_repository(repository_path)?; + if repository.object_hash() != gix::hash::Kind::Sha1 { + return Err("unsupported_object_format"); + } + let expected_base = gix::hash::ObjectId::from_hex(expected_base_commit_oid.as_bytes()) + .map_err(|_| "invalid_base_commit_oid")?; + let expected_base_tree = gix::hash::ObjectId::from_hex(expected_base_tree_oid.as_bytes()) + .map_err(|_| "invalid_base_commit_oid")?; + if expected_base.kind() != gix::hash::Kind::Sha1 { + return Err("invalid_base_commit_oid"); + } + let base_commit = load_verified_object( + &repository, + expected_base, + gix::objs::Kind::Commit, + MANAGED_TREE_POLICY_V3.max_commit_object_bytes, + "base_commit_unavailable", + "base_commit_unavailable", + "commit_object_limit_exceeded", + "base_commit_identity_mismatch", + )? + .try_into_commit() + .map_err(|_| "base_commit_unavailable")?; + let base_tree = base_commit + .tree_id() + .map_err(|_| "base_tree_unavailable")? + .detach(); + if base_tree != expected_base_tree { + return Err("base_tree_unavailable"); + } + load_verified_object( + &repository, + expected_base_tree, + gix::objs::Kind::Tree, + MANAGED_TREE_POLICY_V3.max_single_tree_object_bytes, + "base_tree_unavailable", + "base_tree_unavailable", + "source_tree_object_limit_exceeded", + "base_tree_identity_mismatch", + )?; + + let current = read_direct_commit_ref( + &repository, + &accepted_ref, + "accepted_ref_not_direct", + "accepted_ref_target_invalid", + )?; + if current != expected_base { + write_response(&Response::CandidateRejected { + protocol_version: PROTOCOL_VERSION, + reason: "base_commit_mismatch", + object_format: "sha1", + expected_base_commit_oid: expected_base.to_string(), + actual_base_commit_oid: current.to_string(), + accepted_ref, + candidate_ref, + managed_tree_policy_version: MANAGED_TREE_POLICY_VERSION, + }); + return Ok(ExitCode::from(3)); + } + + let result_blob = + gix::objs::compute_hash(repository.object_hash(), gix::objs::Kind::Blob, &content) + .map_err(|_| "blob_write_failed")?; + let existing_entry = lookup_verified_tree_entry(&repository, expected_base_tree, &path)?; + let entry_kind = match existing_entry.as_ref().map(|(kind, _)| *kind) { + Some(gix::objs::tree::EntryKind::BlobExecutable) => { + gix::objs::tree::EntryKind::BlobExecutable + } + Some(gix::objs::tree::EntryKind::Blob) | None => gix::objs::tree::EntryKind::Blob, + Some(_) => return Err("unsupported_base_path_kind"), + }; + let no_change = existing_entry + .as_ref() + .is_some_and(|(_, oid)| *oid == result_blob); + + if let Some(reference) = repository + .try_find_reference(candidate_ref.as_str()) + .map_err(|_| "target_ref_unavailable")? + { + let existing = read_direct_commit_reference( + &repository, + reference, + "candidate_ref_not_direct", + "candidate_ref_target_invalid", + )?; + let existing_tree = verify_existing_candidate_receipt( + &repository, + existing, + expected_base, + expected_base_tree, + &path, + entry_kind, + result_blob, + &request_digest_sha256, + )?; + let mut retry_stats = ManagedTreeStats::default(); + walk_verified_source_tree( + &repository, + None, + existing_tree, + "", + 0, + MANAGED_TREE_POLICY_V3, + &mut retry_stats, + )?; + write_candidate_response( + existing_tree == expected_base_tree, + expected_base, + expected_base_tree, + existing, + existing_tree, + result_blob, + &request_digest_sha256, + &accepted_ref, + &candidate_ref, + &path, + ); + return Ok(ExitCode::SUCCESS); + } + + let mut base_stats = ManagedTreeStats::default(); + walk_verified_source_tree( + &repository, + None, + expected_base_tree, + "", + 0, + MANAGED_TREE_POLICY_V3, + &mut base_stats, + )?; + drop(base_stats); + + let successor_tree = if no_change { + expected_base_tree + } else { + let written_blob = repository + .write_blob(&content) + .map_err(|_| "blob_write_failed")? + .detach(); + if written_blob != result_blob { + return Err("blob_write_failed"); + } + write_candidate_tree_from_verified_base( + &repository, + expected_base_tree, + &path, + entry_kind, + result_blob, + )? + }; + let mut stats = ManagedTreeStats::default(); + walk_verified_source_tree( + &repository, + None, + successor_tree, + "", + 0, + MANAGED_TREE_POLICY_V3, + &mut stats, + )?; + let signature = gix::actor::SignatureRef { + name: b"Maka Workspace Service".as_bstr(), + email: b"workspace@maka.invalid".as_bstr(), + time: "946684800 +0000", + }; + let commit_message = + format!("maka managed workspace candidate v3\nrequest-sha256 {request_digest_sha256}"); + let successor_commit = repository + .new_commit_as( + signature, + signature, + commit_message, + successor_tree, + [expected_base], + ) + .map_err(|_| "commit_write_failed")? + .id() + .detach(); + load_verified_object( + &repository, + successor_commit, + gix::objs::Kind::Commit, + MANAGED_TREE_POLICY_V3.max_commit_object_bytes, + "commit_write_failed", + "commit_write_failed", + "commit_object_limit_exceeded", + "successor_commit_identity_mismatch", + )?; + verify_candidate_commit_and_result( + &repository, + successor_commit, + expected_base, + successor_tree, + &path, + result_blob, + &request_digest_sha256, + )?; + + let accepted_current = read_direct_commit_ref( + &repository, + &accepted_ref, + "accepted_ref_not_direct", + "accepted_ref_target_invalid", + )?; + if accepted_current != expected_base { + write_response(&Response::CandidateRejected { + protocol_version: PROTOCOL_VERSION, + reason: "base_commit_mismatch", + object_format: "sha1", + expected_base_commit_oid: expected_base.to_string(), + actual_base_commit_oid: accepted_current.to_string(), + accepted_ref, + candidate_ref, + managed_tree_policy_version: MANAGED_TREE_POLICY_VERSION, + }); + return Ok(ExitCode::from(3)); + } + + match repository + .try_find_reference(candidate_ref.as_str()) + .map_err(|_| "target_ref_unavailable")? + { + Some(reference) => { + let existing = read_direct_commit_reference( + &repository, + reference, + "candidate_ref_not_direct", + "candidate_ref_target_invalid", + )?; + if existing != successor_commit { + return Err("candidate_request_conflict"); + } + } + None => match repository.reference( + candidate_ref.as_str(), + successor_commit, + gix::refs::transaction::PreviousValue::MustNotExist, + "maka managed workspace candidate", + ) { + Ok(_) => {} + Err(_) => { + let concurrent = repository + .try_find_reference(candidate_ref.as_str()) + .map_err(|_| "candidate_publication_indeterminate")? + .ok_or("candidate_publication_indeterminate")?; + let concurrent = read_direct_commit_reference( + &repository, + concurrent, + "candidate_ref_not_direct", + "candidate_ref_target_invalid", + )?; + if concurrent != successor_commit { + return Err("candidate_request_conflict"); + } + } + }, + } + + write_candidate_response( + no_change, + expected_base, + expected_base_tree, + successor_commit, + successor_tree, + result_blob, + &request_digest_sha256, + &accepted_ref, + &candidate_ref, + &path, + ); + Ok(ExitCode::SUCCESS) +} + +fn verify_existing_candidate_receipt( + repository: &gix::Repository, + candidate_commit_oid: gix::hash::ObjectId, + expected_base_commit_oid: gix::hash::ObjectId, + expected_base_tree_oid: gix::hash::ObjectId, + path: &str, + entry_kind: gix::objs::tree::EntryKind, + expected_result_blob_oid: gix::hash::ObjectId, + request_digest_sha256: &str, +) -> Result { + let commit = load_verified_object( + repository, + candidate_commit_oid, + gix::objs::Kind::Commit, + MANAGED_TREE_POLICY_V3.max_commit_object_bytes, + "candidate_ref_target_invalid", + "candidate_ref_target_invalid", + "commit_object_limit_exceeded", + "candidate_ref_target_invalid", + )? + .try_into_commit() + .map_err(|_| "candidate_ref_target_invalid")? + .decode() + .map_err(|_| "candidate_ref_target_invalid")? + .into_owned() + .map_err(|_| "candidate_ref_target_invalid")?; + let expected_message = + format!("maka managed workspace candidate v3\nrequest-sha256 {request_digest_sha256}"); + if commit.message.as_slice() != expected_message.as_bytes() { + return Err("candidate_request_conflict"); + } + if commit.parents.as_slice() != [expected_base_commit_oid] + || !has_managed_candidate_signature(&commit) + { + return Err("candidate_ref_target_invalid"); + } + let candidate_tree = commit.tree; + let expected_candidate_tree = compute_candidate_tree_from_verified_base( + repository, + expected_base_tree_oid, + path, + entry_kind, + expected_result_blob_oid, + )?; + if candidate_tree != expected_candidate_tree { + return Err("candidate_ref_target_invalid"); + } + let result_entry = lookup_verified_tree_entry(repository, candidate_tree, path)? + .ok_or("candidate_ref_target_invalid")?; + if result_entry.1 != expected_result_blob_oid { + return Err("candidate_ref_target_invalid"); + } + load_verified_object( + repository, + expected_result_blob_oid, + gix::objs::Kind::Blob, + MAX_IMPORT_FILE_BYTES, + "candidate_ref_target_invalid", + "candidate_ref_target_invalid", + "candidate_ref_target_invalid", + "candidate_ref_target_invalid", + )?; + Ok(candidate_tree) +} + +fn has_managed_candidate_signature(commit: &gix::objs::Commit) -> bool { + let expected_name = b"Maka Workspace Service"; + let expected_email = b"workspace@maka.invalid"; + commit.author.name.as_slice() == expected_name.as_slice() + && commit.author.email.as_slice() == expected_email.as_slice() + && commit.author.time.seconds == 946_684_800 + && commit.author.time.offset == 0 + && commit.committer.name.as_slice() == expected_name.as_slice() + && commit.committer.email.as_slice() == expected_email.as_slice() + && commit.committer.time.seconds == 946_684_800 + && commit.committer.time.offset == 0 + && commit.encoding.is_none() + && commit.extra_headers.is_empty() +} + +#[allow(clippy::too_many_arguments)] +fn write_candidate_response( + no_change: bool, + base_commit_oid: gix::hash::ObjectId, + base_tree_oid: gix::hash::ObjectId, + candidate_commit_oid: gix::hash::ObjectId, + candidate_tree_oid: gix::hash::ObjectId, + result_blob_oid: gix::hash::ObjectId, + request_digest_sha256: &str, + accepted_ref: &str, + candidate_ref: &str, + path: &str, +) { + if no_change { + write_response(&Response::CandidateNoChange { + protocol_version: PROTOCOL_VERSION, + object_format: "sha1", + base_commit_oid: base_commit_oid.to_string(), + base_tree_oid: base_tree_oid.to_string(), + result_blob_oid: result_blob_oid.to_string(), + candidate_commit_oid: candidate_commit_oid.to_string(), + candidate_tree_oid: candidate_tree_oid.to_string(), + request_digest_sha256: request_digest_sha256.to_owned(), + accepted_ref: accepted_ref.to_owned(), + candidate_ref: candidate_ref.to_owned(), + path: path.to_owned(), + managed_tree_policy_version: MANAGED_TREE_POLICY_VERSION, + }); + } else { + write_response(&Response::CandidatePublished { + protocol_version: PROTOCOL_VERSION, + object_format: "sha1", + base_commit_oid: base_commit_oid.to_string(), + base_tree_oid: base_tree_oid.to_string(), + candidate_commit_oid: candidate_commit_oid.to_string(), + candidate_tree_oid: candidate_tree_oid.to_string(), + result_blob_oid: result_blob_oid.to_string(), + request_digest_sha256: request_digest_sha256.to_owned(), + accepted_ref: accepted_ref.to_owned(), + candidate_ref: candidate_ref.to_owned(), + path: path.to_owned(), + managed_tree_policy_version: MANAGED_TREE_POLICY_VERSION, + }); + } +} + +fn read_direct_commit_ref( + repository: &gix::Repository, + ref_name: &str, + not_direct_error: &'static str, + invalid_target_error: &'static str, +) -> Result { + let reference = repository + .find_reference(ref_name) + .map_err(|_| "target_ref_unavailable")?; + read_direct_commit_reference( + repository, + reference, + not_direct_error, + invalid_target_error, + ) +} + +fn read_direct_commit_reference( + repository: &gix::Repository, + reference: gix::Reference<'_>, + not_direct_error: &'static str, + invalid_target_error: &'static str, +) -> Result { + let target = reference.try_id().ok_or(not_direct_error)?.detach(); + load_verified_object( + repository, + target, + gix::objs::Kind::Commit, + MANAGED_TREE_POLICY_V3.max_commit_object_bytes, + invalid_target_error, + invalid_target_error, + "commit_object_limit_exceeded", + invalid_target_error, + )?; + Ok(target) +} + +struct VerifiedTreeFinder<'repo> { + repository: &'repo gix::Repository, +} + +impl gix::objs::Find for VerifiedTreeFinder<'_> { + fn try_find<'a>( + &self, + id: &gix::hash::oid, + buffer: &'a mut Vec, + ) -> Result>, gix::objs::find::Error> { + let Some(data) = gix::objs::Find::try_find(self.repository, id, buffer)? else { + return Ok(None); + }; + if data.kind != gix::objs::Kind::Tree { + return Err(format!("expected tree object at {id}").into()); + } + data.verify_checksum(id)?; + Ok(Some(data)) + } +} + +fn write_candidate_tree_from_verified_base( + repository: &gix::Repository, + base_tree_oid: gix::hash::ObjectId, + path: &str, + entry_kind: gix::objs::tree::EntryKind, + result_blob_oid: gix::hash::ObjectId, +) -> Result { + edit_candidate_tree_from_verified_base( + repository, + base_tree_oid, + path, + entry_kind, + result_blob_oid, + |tree| { + repository + .write_object(tree) + .map(|id| id.detach()) + .map_err(|_| ()) + }, + ) +} + +fn compute_candidate_tree_from_verified_base( + repository: &gix::Repository, + base_tree_oid: gix::hash::ObjectId, + path: &str, + entry_kind: gix::objs::tree::EntryKind, + result_blob_oid: gix::hash::ObjectId, +) -> Result { + use gix::objs::WriteTo; + + edit_candidate_tree_from_verified_base( + repository, + base_tree_oid, + path, + entry_kind, + result_blob_oid, + |tree| { + let mut encoded = Vec::new(); + tree.write_to(&mut encoded).map_err(|_| ())?; + gix::objs::compute_hash(repository.object_hash(), gix::objs::Kind::Tree, &encoded) + .map_err(|_| ()) + }, + ) +} + +fn edit_candidate_tree_from_verified_base( + repository: &gix::Repository, + base_tree_oid: gix::hash::ObjectId, + path: &str, + entry_kind: gix::objs::tree::EntryKind, + result_blob_oid: gix::hash::ObjectId, + write_tree: impl FnMut(&gix::objs::Tree) -> Result, +) -> Result { + let root = load_verified_object( + repository, + base_tree_oid, + gix::objs::Kind::Tree, + MANAGED_TREE_POLICY_V3.max_single_tree_object_bytes, + "base_tree_unavailable", + "base_tree_unavailable", + "source_tree_object_limit_exceeded", + "base_tree_identity_mismatch", + )? + .try_into_tree() + .map_err(|_| "base_tree_unavailable")? + .decode() + .map_err(|_| "base_tree_unavailable")? + .into_owned(); + let finder = VerifiedTreeFinder { repository }; + let mut editor = gix::objs::tree::Editor::new(root, &finder, repository.object_hash()); + editor + .upsert(path.split('/'), entry_kind, result_blob_oid) + .map_err(|_| "tree_edit_failed")?; + editor.write(write_tree).map_err(|_| "tree_write_failed") +} + +fn lookup_verified_tree_entry( + repository: &gix::Repository, + root_tree_oid: gix::hash::ObjectId, + path: &str, +) -> Result, &'static str> { + let components = path.split('/').collect::>(); + let mut tree_oid = root_tree_oid; + for (index, component) in components.iter().enumerate() { + let tree = load_verified_object( + repository, + tree_oid, + gix::objs::Kind::Tree, + MANAGED_TREE_POLICY_V3.max_single_tree_object_bytes, + "base_tree_unavailable", + "base_tree_unavailable", + "source_tree_object_limit_exceeded", + "base_tree_identity_mismatch", + )? + .try_into_tree() + .map_err(|_| "base_tree_unavailable")?; + let entry = tree + .iter() + .find_map(|entry| match entry { + Ok(entry) if entry.filename() == component.as_bytes() => Some(Ok(entry)), + Ok(_) => None, + Err(_) => Some(Err("base_tree_unavailable")), + }) + .transpose()?; + let Some(entry) = entry else { + return Ok(None); + }; + if index + 1 == components.len() { + return Ok(Some((entry.mode().kind(), entry.object_id()))); + } + if entry.mode().kind() != gix::objs::tree::EntryKind::Tree { + return Err("base_path_lookup_failed"); + } + tree_oid = entry.object_id(); + } + Err("base_path_lookup_failed") +} + +fn verify_candidate_commit_and_result( + repository: &gix::Repository, + candidate_commit_oid: gix::hash::ObjectId, + expected_base_commit_oid: gix::hash::ObjectId, + expected_candidate_tree_oid: gix::hash::ObjectId, + path: &str, + expected_result_blob_oid: gix::hash::ObjectId, + request_digest_sha256: &str, +) -> Result<(), &'static str> { + let commit = load_verified_object( + repository, + candidate_commit_oid, + gix::objs::Kind::Commit, + MANAGED_TREE_POLICY_V3.max_commit_object_bytes, + "commit_write_failed", + "commit_write_failed", + "commit_object_limit_exceeded", + "successor_commit_identity_mismatch", + )? + .try_into_commit() + .map_err(|_| "commit_write_failed")? + .decode() + .map_err(|_| "commit_write_failed")? + .into_owned() + .map_err(|_| "commit_write_failed")?; + let expected_message = + format!("maka managed workspace candidate v3\nrequest-sha256 {request_digest_sha256}"); + if commit.tree != expected_candidate_tree_oid + || commit.parents.as_slice() != [expected_base_commit_oid] + || commit.message.as_slice() != expected_message.as_bytes() + || !has_managed_candidate_signature(&commit) + { + return Err("successor_commit_identity_mismatch"); + } + let result_entry = lookup_verified_tree_entry(repository, expected_candidate_tree_oid, path)? + .ok_or("tree_write_failed")?; + if result_entry.1 != expected_result_blob_oid { + return Err("tree_write_failed"); + } + load_verified_object( + repository, + expected_result_blob_oid, + gix::objs::Kind::Blob, + MAX_IMPORT_FILE_BYTES, + "blob_write_failed", + "blob_write_failed", + "successor_content_limit_exceeded", + "blob_write_failed", + )?; + Ok(()) +} + +fn candidate_request_digest_sha256( + accepted_ref: &str, + expected_base_commit_oid: &str, + expected_base_tree_oid: &str, + candidate_ref: &str, + path: &str, + content: &[u8], +) -> String { + let mut hasher = Sha256::new(); + hasher.update(b"maka.gitoxide.candidate-request.v1\0"); + for field in [ + accepted_ref.as_bytes(), + expected_base_commit_oid.as_bytes(), + expected_base_tree_oid.as_bytes(), + candidate_ref.as_bytes(), + path.as_bytes(), + content, + ] { + hasher.update((field.len() as u64).to_be_bytes()); + hasher.update(field); + } + format!("{:x}", hasher.finalize()) +} + +fn is_canonical_successor_path_v3(path: &str) -> bool { + let policy = MANAGED_TREE_POLICY_V3; + !path.is_empty() + && !path.starts_with('/') + && !path.contains('\0') + && path.len() as u64 <= policy.max_relative_path_bytes + && fold_managed_path_v3(path).len() as u64 <= policy.max_folded_relative_path_bytes + && path.split('/').all(|component| { + is_supported_source_component(component) + && component.len() as u64 <= policy.max_component_bytes + }) +} + +fn read_tree_file( + repository_path: PathBuf, + accepted_commit_oid: String, + path: String, + managed_tree_policy_version: u8, +) -> Result { + if managed_tree_policy_version != MANAGED_TREE_POLICY_VERSION { + return Err("unsupported_managed_tree_policy"); + } + if !is_canonical_successor_path_v3(&path) { + return Err("invalid_tree_file_path"); + } + let repository = open_repository(repository_path)?; + let (accepted_commit, accepted_tree) = + accepted_commit_identity(&repository, &accepted_commit_oid)?; + let components = path.split('/').collect::>(); + let mut tree_oid = accepted_tree; + let mut final_entry = None; + for (index, component) in components.iter().enumerate() { + let tree = load_verified_object( + &repository, + tree_oid, + gix::objs::Kind::Tree, + MANAGED_TREE_POLICY_V3.max_single_tree_object_bytes, + "tree_file_unavailable", + "tree_file_invalid", + "source_tree_object_limit_exceeded", + "source_tree_identity_mismatch", + )? + .try_into_tree() + .map_err(|_| "tree_file_invalid")?; + let entry = tree + .iter() + .find_map(|entry| match entry { + Ok(entry) if entry.filename() == component.as_bytes() => Some(Ok(entry)), + Ok(_) => None, + Err(_) => Some(Err("tree_file_lookup_failed")), + }) + .ok_or("tree_file_unavailable")??; + if index + 1 == components.len() { + final_entry = Some((entry.mode().kind(), entry.object_id())); + } else if entry.mode().kind() == gix::objs::tree::EntryKind::Tree { + tree_oid = entry.object_id(); + } else { + return Err("tree_file_invalid"); + } + } + let (kind, blob_oid) = final_entry.ok_or("tree_file_unavailable")?; + if !matches!( + kind, + gix::objs::tree::EntryKind::Blob | gix::objs::tree::EntryKind::BlobExecutable + ) { + return Err("tree_file_invalid"); + } + let blob = load_verified_object( + &repository, + blob_oid, + gix::objs::Kind::Blob, + MAX_TREE_FILE_BYTES, + "tree_file_unavailable", + "tree_file_invalid", + "tree_file_size_limit_exceeded", + "tree_file_identity_mismatch", + )? + .try_into_blob() + .map_err(|_| "tree_file_invalid")?; + let content = std::str::from_utf8(&blob.data) + .map_err(|_| "tree_file_not_utf8")? + .to_owned(); + write_response(&Response::TreeFileRead { + protocol_version: PROTOCOL_VERSION, + object_format: "sha1", + accepted_commit_oid: accepted_commit.to_string(), + accepted_tree_oid: accepted_tree.to_string(), + blob_oid: blob_oid.to_string(), + path, + content, + bytes_read: blob.data.len() as u64, + managed_tree_policy_version: MANAGED_TREE_POLICY_VERSION, + }); + Ok(ExitCode::SUCCESS) +} + +fn accepted_commit_identity( + repository: &gix::Repository, + accepted_commit_oid: &str, +) -> Result<(gix::hash::ObjectId, gix::hash::ObjectId), &'static str> { + if repository.object_hash() != gix::hash::Kind::Sha1 { + return Err("unsupported_object_format"); + } + let accepted_commit = gix::hash::ObjectId::from_hex(accepted_commit_oid.as_bytes()) + .map_err(|_| "invalid_accepted_commit_oid")?; + let commit = load_verified_object( + repository, + accepted_commit, + gix::objs::Kind::Commit, + MANAGED_TREE_POLICY_V3.max_commit_object_bytes, + "accepted_commit_unavailable", + "accepted_commit_unavailable", + "commit_object_limit_exceeded", + "head_commit_identity_mismatch", + )? + .try_into_commit() + .map_err(|_| "accepted_commit_unavailable")?; + let accepted_tree = commit + .tree_id() + .map_err(|_| "accepted_tree_unavailable")? + .detach(); + Ok((accepted_commit, accepted_tree)) +} fn claim_fresh_import_destination(path: &Path) -> Result { match fs::create_dir(path) { Ok(()) => {} @@ -865,7 +1833,7 @@ fn assert_canonical_tree_modes(mut data: &[u8]) -> Result<(), &'static str> { } fn is_supported_source_component(component: &str) -> bool { - let folded_component = fold_managed_path_v2(component); + let folded_component = fold_managed_path_v3(component); !component.is_empty() && component != "." && component != ".." @@ -882,8 +1850,21 @@ fn is_supported_source_component(component: &str) -> bool { && (component == ".gitattributes" || folded_component != ".gitattributes") } -fn fold_managed_path_v2(value: &str) -> String { - value.nfc().default_case_fold().nfc().collect() +fn fold_managed_path_v3(value: &str) -> String { + value + .chars() + .filter(|character| !is_git_hfs_ignorable(*character)) + .nfc() + .default_case_fold() + .nfc() + .collect() +} + +fn is_git_hfs_ignorable(character: char) -> bool { + matches!( + character, + '\u{200c}'..='\u{200f}' | '\u{202a}'..='\u{202e}' | '\u{206a}'..='\u{206f}' | '\u{feff}' + ) } fn validate_managed_attributes_v2(data: &[u8]) -> Result<(), &'static str> { @@ -1023,7 +2004,7 @@ impl ManagedTreeStats { .checked_add(path_bytes) .filter(|bytes| *bytes <= policy.max_total_path_bytes) .ok_or("source_path_byte_limit_exceeded")?; - let folded_path = fold_managed_path_v2(relative_path); + let folded_path = fold_managed_path_v3(relative_path); let folded_path_bytes = folded_path.len() as u64; if folded_path_bytes > policy.max_folded_relative_path_bytes { return Err("source_folded_path_length_exceeded"); @@ -1060,6 +2041,7 @@ impl ManagedTreeStats { #[cfg(test)] mod tests { use super::*; + use std::process::Command; fn tiny_policy() -> ManagedTreePolicy { ManagedTreePolicy { @@ -1080,6 +2062,177 @@ mod tests { } } + #[test] + fn candidate_editor_rejects_a_tree_replaced_after_the_verified_walk() { + let root = tempfile::tempdir().unwrap(); + run_git(root.path(), ["init", "--quiet"]); + fs::create_dir(root.path().join("dir")).unwrap(); + fs::write(root.path().join("dir/file.txt"), b"base\n").unwrap(); + run_git(root.path(), ["add", "dir/file.txt"]); + let base_tree = run_git_output(root.path(), ["write-tree"]); + let nested_tree = run_git_output(root.path(), ["rev-parse", &format!("{base_tree}:dir")]); + fs::write(root.path().join("dir/file.txt"), b"replacement\n").unwrap(); + run_git(root.path(), ["add", "dir/file.txt"]); + let replacement_root = run_git_output(root.path(), ["write-tree"]); + let replacement_tree = run_git_output( + root.path(), + ["rev-parse", &format!("{replacement_root}:dir")], + ); + let repository = managed_open_options() + .open(root.path()) + .unwrap() + .to_thread_local(); + let base_tree_oid = gix::hash::ObjectId::from_hex(base_tree.as_bytes()).unwrap(); + let mut stats = ManagedTreeStats::default(); + walk_verified_source_tree( + &repository, + None, + base_tree_oid, + "", + 0, + MANAGED_TREE_POLICY_V3, + &mut stats, + ) + .unwrap(); + fs::remove_file(loose_object_path(root.path(), &nested_tree)).unwrap(); + fs::copy( + loose_object_path(root.path(), &replacement_tree), + loose_object_path(root.path(), &nested_tree), + ) + .unwrap(); + let result_blob = repository.write_blob(b"result\n").unwrap().detach(); + + assert_eq!( + write_candidate_tree_from_verified_base( + &repository, + base_tree_oid, + "dir/file.txt", + gix::objs::tree::EntryKind::Blob, + result_blob, + ), + Err("tree_edit_failed") + ); + } + + #[test] + fn candidate_self_check_rejects_a_corrupt_result_blob() { + use gix::bstr::ByteSlice; + + let root = tempfile::tempdir().unwrap(); + run_git(root.path(), ["init", "--quiet"]); + fs::write(root.path().join("base.txt"), b"base\n").unwrap(); + run_git(root.path(), ["add", "base.txt"]); + run_git( + root.path(), + [ + "-c", + "user.name=Maka Test", + "-c", + "user.email=maka@example.invalid", + "commit", + "--quiet", + "-m", + "base", + ], + ); + let base_commit = run_git_output(root.path(), ["rev-parse", "HEAD"]); + let base_tree = run_git_output(root.path(), ["rev-parse", "HEAD^{tree}"]); + let repository = managed_open_options() + .open(root.path()) + .unwrap() + .to_thread_local(); + let base_commit_oid = gix::hash::ObjectId::from_hex(base_commit.as_bytes()).unwrap(); + let base_tree_oid = gix::hash::ObjectId::from_hex(base_tree.as_bytes()).unwrap(); + let result_blob_oid = repository.write_blob(b"result\n").unwrap().detach(); + let candidate_tree_oid = write_candidate_tree_from_verified_base( + &repository, + base_tree_oid, + "result.txt", + gix::objs::tree::EntryKind::Blob, + result_blob_oid, + ) + .unwrap(); + let request_digest = "0".repeat(64); + let signature = gix::actor::SignatureRef { + name: b"Maka Workspace Service".as_bstr(), + email: b"workspace@maka.invalid".as_bstr(), + time: "946684800 +0000", + }; + let candidate_commit_oid = repository + .new_commit_as( + signature, + signature, + format!("maka managed workspace candidate v3\nrequest-sha256 {request_digest}"), + candidate_tree_oid, + [base_commit_oid], + ) + .unwrap() + .id() + .detach(); + let replacement_blob_oid = repository.write_blob(b"replacement\n").unwrap().detach(); + drop(repository); + let result_object_path = loose_object_path(root.path(), &result_blob_oid.to_string()); + fs::rename( + &result_object_path, + root.path().join("original-result-object"), + ) + .unwrap(); + fs::copy( + loose_object_path(root.path(), &replacement_blob_oid.to_string()), + result_object_path, + ) + .unwrap(); + let repository = managed_open_options() + .open(root.path()) + .unwrap() + .to_thread_local(); + + assert_eq!( + verify_candidate_commit_and_result( + &repository, + candidate_commit_oid, + base_commit_oid, + candidate_tree_oid, + "result.txt", + result_blob_oid, + &request_digest, + ), + Err("blob_write_failed") + ); + } + + fn run_git(root: &Path, args: [&str; N]) { + assert!( + Command::new("git") + .args(args) + .current_dir(root) + .status() + .unwrap() + .success() + ); + } + + fn run_git_output(root: &Path, args: [&str; N]) -> String { + let output = Command::new("git") + .args(args) + .current_dir(root) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout).unwrap().trim().to_owned() + } + + fn loose_object_path(root: &Path, oid: &str) -> PathBuf { + root.join(".git") + .join("objects") + .join(&oid[..2]) + .join(&oid[2..]) + } + #[test] fn managed_tree_budget_bounds_depth_visits_and_entries() { let policy = tiny_policy(); @@ -1166,7 +2319,7 @@ mod tests { #[test] fn managed_tree_policy_uses_full_unicode_casefold_after_nfc() { - let policy = MANAGED_TREE_POLICY_V2; + let policy = MANAGED_TREE_POLICY_V3; for (first, second) in [ ("Σ.txt", "ς.txt"), ("STRASSE.txt", "Straße.txt"), @@ -1187,7 +2340,7 @@ mod tests { let policy = ManagedTreePolicy { max_relative_path_bytes: 2, max_folded_relative_path_bytes: 2, - ..MANAGED_TREE_POLICY_V2 + ..MANAGED_TREE_POLICY_V3 }; let mut stats = ManagedTreeStats::default(); assert_eq!( @@ -1198,7 +2351,7 @@ mod tests { let policy = ManagedTreePolicy { max_total_path_bytes: 3, max_total_folded_path_bytes: 3, - ..MANAGED_TREE_POLICY_V2 + ..MANAGED_TREE_POLICY_V3 }; let mut stats = ManagedTreeStats::default(); assert_eq!(stats.observe_entry("İ", policy), Ok(())); diff --git a/native/gitoxide-helper/tests/repository_admission.rs b/native/gitoxide-helper/tests/repository_admission.rs index 4207193a08..9cf5fd2175 100644 --- a/native/gitoxide-helper/tests/repository_admission.rs +++ b/native/gitoxide-helper/tests/repository_admission.rs @@ -367,7 +367,7 @@ fn rejects_full_unicode_casefold_collisions_before_claiming_the_destination() { } #[test] -fn rejects_protected_names_using_the_policy_v2_unicode_fold() { +fn rejects_protected_names_using_the_policy_v3_unicode_fold() { let (fixture, source_head) = RepositoryFixture::sha1_with_raw_tree(&[RawTreeEntry { mode: "100644", name: ".gitattributeſ", @@ -381,6 +381,31 @@ fn rejects_protected_names_using_the_policy_v2_unicode_fold() { assert!(!destination.exists()); } +#[test] +fn rejects_git_hfs_protected_name_aliases_before_claiming_the_destination() { + for (index, name) in [ + ".g\u{200c}it", + ".\u{200e}git", + ".git\u{feff}", + ".gitattr\u{200d}ibutes", + ] + .into_iter() + .enumerate() + { + let (fixture, source_head) = RepositoryFixture::sha1_with_raw_tree(&[RawTreeEntry { + mode: "100644", + name, + object_kind: RawObjectKind::Blob, + }]); + let destination = fixture.root.join(format!("hfs-protected-name-{index}.git")); + + let import = invoke_import(&fixture.root, &source_head, &destination); + + assert_helper_error(&import, "unsupported_source_path"); + assert!(!destination.exists()); + } +} + #[test] fn rejects_a_nonportable_raw_git_path_before_claiming_the_destination() { let (fixture, source_head) = RepositoryFixture::sha1_with_raw_tree(&[RawTreeEntry { @@ -422,7 +447,7 @@ fn imports_an_exact_source_head_into_a_fresh_managed_repository() { "expectedSourceHeadCommitOid": source_head, "destinationRepositoryPath": destination, "baselineRef": "refs/maka/baseline", - "managedTreePolicyVersion": 2, + "managedTreePolicyVersion": 3, })); assert!( @@ -436,7 +461,7 @@ fn imports_an_exact_source_head_into_a_fresh_managed_repository() { assert_eq!(response["sourceHeadCommitOid"], source_head); assert_eq!(response["sourceTreeOid"], source_tree); assert_eq!(response["baselineTreeOid"], source_tree); - assert_eq!(response["managedTreePolicyVersion"], 2); + assert_eq!(response["managedTreePolicyVersion"], 3); assert_eq!(response["filesImported"], 2); assert_eq!(response["bytesImported"], 29); let baseline_commit = response["baselineCommitOid"].as_str().unwrap(); @@ -466,7 +491,7 @@ fn imports_an_exact_source_head_into_a_fresh_managed_repository() { "expectedSourceHeadCommitOid": source_head, "destinationRepositoryPath": second_destination, "baselineRef": "refs/maka/baseline", - "managedTreePolicyVersion": 2, + "managedTreePolicyVersion": 3, })); assert!( second_output.status.success(), @@ -501,13 +526,799 @@ fn imports_an_exact_source_head_into_a_fresh_managed_repository() { "expectedSourceHeadCommitOid": source_head, "destinationRepositoryPath": destination, "baselineRef": "refs/maka/baseline", - "managedTreePolicyVersion": 2, + "managedTreePolicyVersion": 3, })); assert_helper_error(&retry, "import_destination_not_fresh"); } #[test] -fn imports_maka_attributes_under_managed_tree_policy_v2() { +fn publishes_and_exactly_retries_an_operation_candidate_without_advancing_accepted() { + let fixture = RepositoryFixture::sha1_with_commit(); + let source_head = fixture.git_output(["rev-parse", "HEAD"]); + let destination = fixture.root.join("successor.git"); + let imported = invoke_import(&fixture.root, &source_head, &destination); + assert!(imported.status.success()); + let imported: serde_json::Value = serde_json::from_slice(&imported.stdout).unwrap(); + let baseline = imported["baselineCommitOid"].as_str().unwrap(); + let baseline_tree = imported["baselineTreeOid"].as_str().unwrap(); + let request = serde_json::json!({ + "protocolVersion": 1, + "operation": "create_candidate", + "repositoryPath": destination, + "acceptedRef": "refs/maka/baseline", + "expectedBaseCommitOid": baseline, + "expectedBaseTreeOid": baseline_tree, + "candidateRef": "refs/maka/candidates/1111111111111111111111111111111111111111111111111111111111111111", + "path": "docs/result.txt", + "contentBase64": "c3VjY2Vzc29yIGNvbnRlbnQK", + "managedTreePolicyVersion": 3, + }); + + let first = invoke_request(request.clone()); + assert!( + first.status.success(), + "{}", + String::from_utf8_lossy(&first.stdout) + ); + let first: serde_json::Value = serde_json::from_slice(&first.stdout).unwrap(); + assert_eq!(first["kind"], "candidate_published"); + assert_eq!(first["managedTreePolicyVersion"], 3); + assert_eq!( + git_bare_output(&destination, ["rev-parse", "refs/maka/baseline"]), + baseline + ); + assert_eq!( + git_bare_output( + &destination, + ["rev-parse", first["candidateRef"].as_str().unwrap()] + ), + first["candidateCommitOid"].as_str().unwrap() + ); + assert_eq!( + git_bare_bytes( + &destination, + [ + "show", + &format!( + "{}:docs/result.txt", + first["candidateCommitOid"].as_str().unwrap() + ), + ], + ), + b"successor content\n" + ); + + let retry = invoke_request(request); + assert!(retry.status.success()); + let retry: serde_json::Value = serde_json::from_slice(&retry.stdout).unwrap(); + assert_eq!(retry, first); +} + +#[test] +fn rejects_an_existing_candidate_receipt_with_an_unrelated_tree_change() { + let fixture = RepositoryFixture::sha1_with_commit(); + let source_head = fixture.git_output(["rev-parse", "HEAD"]); + let destination = fixture.root.join("candidate-extra-change.git"); + let imported = invoke_import(&fixture.root, &source_head, &destination); + let imported: serde_json::Value = serde_json::from_slice(&imported.stdout).unwrap(); + let candidate_ref = + "refs/maka/candidates/1414141414141414141414141414141414141414141414141414141414141414"; + let request = serde_json::json!({ + "protocolVersion": 1, + "operation": "create_candidate", + "repositoryPath": destination, + "acceptedRef": "refs/maka/baseline", + "expectedBaseCommitOid": imported["baselineCommitOid"], + "expectedBaseTreeOid": imported["baselineTreeOid"], + "candidateRef": candidate_ref, + "path": "docs/result.txt", + "contentBase64": "ZXhhY3QgcmVzdWx0Cg==", + "managedTreePolicyVersion": 3, + }); + let published = invoke_request(request.clone()); + assert!(published.status.success()); + let published: serde_json::Value = serde_json::from_slice(&published.stdout).unwrap(); + + let unrelated_blob = git_bare_input_output( + &destination, + ["hash-object", "-t", "blob", "-w", "--stdin"], + b"unrelated\n", + ); + let mut forged_tree_input = git_bare_bytes( + &destination, + ["ls-tree", published["candidateTreeOid"].as_str().unwrap()], + ); + forged_tree_input + .extend_from_slice(format!("100644 blob {unrelated_blob}\tunrelated.txt\n").as_bytes()); + let forged_tree = git_bare_input_output(&destination, ["mktree"], &forged_tree_input); + replace_candidate_ref_tree(&destination, candidate_ref, &published, &forged_tree); + + let retry = invoke_request(request); + assert_helper_error(&retry, "candidate_ref_target_invalid"); +} + +#[test] +fn rejects_an_existing_candidate_receipt_with_a_target_mode_flip() { + let fixture = RepositoryFixture::sha1_with_commit(); + let source_head = fixture.git_output(["rev-parse", "HEAD"]); + let destination = fixture.root.join("candidate-mode-flip.git"); + let imported = invoke_import(&fixture.root, &source_head, &destination); + let imported: serde_json::Value = serde_json::from_slice(&imported.stdout).unwrap(); + let candidate_ref = + "refs/maka/candidates/1515151515151515151515151515151515151515151515151515151515151515"; + let request = serde_json::json!({ + "protocolVersion": 1, + "operation": "create_candidate", + "repositoryPath": destination, + "acceptedRef": "refs/maka/baseline", + "expectedBaseCommitOid": imported["baselineCommitOid"], + "expectedBaseTreeOid": imported["baselineTreeOid"], + "candidateRef": candidate_ref, + "path": "hello.txt", + "contentBase64": "bW9kZS1wcmVzZXJ2aW5nCg==", + "managedTreePolicyVersion": 3, + }); + let published = invoke_request(request.clone()); + assert!(published.status.success()); + let published: serde_json::Value = serde_json::from_slice(&published.stdout).unwrap(); + + let original_entry = format!( + "100644 blob {}\thello.txt", + published["resultBlobOid"].as_str().unwrap() + ); + let candidate_tree = String::from_utf8(git_bare_bytes( + &destination, + ["ls-tree", published["candidateTreeOid"].as_str().unwrap()], + )) + .unwrap(); + assert!(candidate_tree.contains(&original_entry)); + let forged_tree_input = candidate_tree.replacen( + &original_entry, + &original_entry.replacen("100644", "100755", 1), + 1, + ); + let forged_tree = git_bare_input_output(&destination, ["mktree"], forged_tree_input.as_bytes()); + replace_candidate_ref_tree(&destination, candidate_ref, &published, &forged_tree); + + let retry = invoke_request(request); + assert_helper_error(&retry, "candidate_ref_target_invalid"); +} + +#[test] +fn rejects_an_exact_candidate_retry_when_the_result_blob_is_missing() { + let fixture = RepositoryFixture::sha1_with_commit(); + let source_head = fixture.git_output(["rev-parse", "HEAD"]); + let destination = fixture.root.join("candidate-missing-result-blob.git"); + let imported = invoke_import(&fixture.root, &source_head, &destination); + let imported: serde_json::Value = serde_json::from_slice(&imported.stdout).unwrap(); + let request = serde_json::json!({ + "protocolVersion": 1, + "operation": "create_candidate", + "repositoryPath": destination, + "acceptedRef": "refs/maka/baseline", + "expectedBaseCommitOid": imported["baselineCommitOid"], + "expectedBaseTreeOid": imported["baselineTreeOid"], + "candidateRef": "refs/maka/candidates/1212121212121212121212121212121212121212121212121212121212121212", + "path": "docs/result.txt", + "contentBase64": "bWlzc2luZyBibG9iCg==", + "managedTreePolicyVersion": 3, + }); + let published = invoke_request(request.clone()); + assert!(published.status.success()); + let published: serde_json::Value = serde_json::from_slice(&published.stdout).unwrap(); + fs::remove_file(loose_object_path( + &destination, + published["resultBlobOid"].as_str().unwrap(), + )) + .unwrap(); + + let retry = invoke_request(request); + assert_helper_error(&retry, "candidate_ref_target_invalid"); +} + +#[test] +fn rejects_an_exact_candidate_retry_when_the_result_blob_is_corrupt() { + let fixture = RepositoryFixture::sha1_with_commit(); + let source_head = fixture.git_output(["rev-parse", "HEAD"]); + let destination = fixture.root.join("candidate-corrupt-result-blob.git"); + let imported = invoke_import(&fixture.root, &source_head, &destination); + let imported: serde_json::Value = serde_json::from_slice(&imported.stdout).unwrap(); + let request = serde_json::json!({ + "protocolVersion": 1, + "operation": "create_candidate", + "repositoryPath": destination, + "acceptedRef": "refs/maka/baseline", + "expectedBaseCommitOid": imported["baselineCommitOid"], + "expectedBaseTreeOid": imported["baselineTreeOid"], + "candidateRef": "refs/maka/candidates/1313131313131313131313131313131313131313131313131313131313131313", + "path": "docs/result.txt", + "contentBase64": "Y29ycnVwdCBibG9iCg==", + "managedTreePolicyVersion": 3, + }); + let published = invoke_request(request.clone()); + assert!(published.status.success()); + let published: serde_json::Value = serde_json::from_slice(&published.stdout).unwrap(); + let result_object_path = + loose_object_path(&destination, published["resultBlobOid"].as_str().unwrap()); + fs::rename( + &result_object_path, + fixture.root.join("original-corrupt-result-object"), + ) + .unwrap(); + fs::write(result_object_path, b"not a zlib object").unwrap(); + + let retry = invoke_request(request); + assert_helper_error(&retry, "candidate_ref_target_invalid"); +} + +#[test] +fn rejects_a_symbolic_candidate_ref_during_exact_retry() { + let fixture = RepositoryFixture::sha1_with_commit(); + let source_head = fixture.git_output(["rev-parse", "HEAD"]); + let destination = fixture.root.join("symbolic-candidate-ref.git"); + let imported = invoke_import(&fixture.root, &source_head, &destination); + let imported: serde_json::Value = serde_json::from_slice(&imported.stdout).unwrap(); + let candidate_ref = + "refs/maka/candidates/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + let request = serde_json::json!({ + "protocolVersion": 1, + "operation": "create_candidate", + "repositoryPath": destination, + "acceptedRef": "refs/maka/baseline", + "expectedBaseCommitOid": imported["baselineCommitOid"], + "expectedBaseTreeOid": imported["baselineTreeOid"], + "candidateRef": candidate_ref, + "path": "docs/result.txt", + "contentBase64": "c3ltYm9saWMgcmV0cnkK", + "managedTreePolicyVersion": 3, + }); + let published = invoke_request(request.clone()); + assert!(published.status.success()); + let published: serde_json::Value = serde_json::from_slice(&published.stdout).unwrap(); + let candidate_commit = published["candidateCommitOid"].as_str().unwrap(); + let alias_ref = "refs/maka/candidate-alias"; + git_bare_output(&destination, ["update-ref", alias_ref, candidate_commit]); + git_bare_output(&destination, ["symbolic-ref", candidate_ref, alias_ref]); + + let rejected = invoke_request(request); + assert_helper_error(&rejected, "candidate_ref_not_direct"); +} + +#[test] +fn rejects_an_annotated_tag_candidate_ref_during_exact_retry() { + let fixture = RepositoryFixture::sha1_with_commit(); + let source_head = fixture.git_output(["rev-parse", "HEAD"]); + let destination = fixture.root.join("tag-candidate-ref.git"); + let imported = invoke_import(&fixture.root, &source_head, &destination); + let imported: serde_json::Value = serde_json::from_slice(&imported.stdout).unwrap(); + let candidate_ref = + "refs/maka/candidates/cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; + let request = serde_json::json!({ + "protocolVersion": 1, + "operation": "create_candidate", + "repositoryPath": destination, + "acceptedRef": "refs/maka/baseline", + "expectedBaseCommitOid": imported["baselineCommitOid"], + "expectedBaseTreeOid": imported["baselineTreeOid"], + "candidateRef": candidate_ref, + "path": "docs/result.txt", + "contentBase64": "dGFnIHJldHJ5Cg==", + "managedTreePolicyVersion": 3, + }); + let published = invoke_request(request.clone()); + assert!(published.status.success()); + let published: serde_json::Value = serde_json::from_slice(&published.stdout).unwrap(); + let candidate_commit = published["candidateCommitOid"].as_str().unwrap(); + git_bare_output( + &destination, + [ + "-c", + "user.name=Maka Test", + "-c", + "user.email=maka@example.invalid", + "tag", + "-a", + "candidate-receipt", + candidate_commit, + "-m", + "candidate receipt", + ], + ); + let tag_oid = git_bare_output(&destination, ["rev-parse", "refs/tags/candidate-receipt"]); + git_bare_output(&destination, ["update-ref", candidate_ref, &tag_oid]); + + let rejected = invoke_request(request); + assert_helper_error(&rejected, "candidate_ref_target_invalid"); +} + +#[test] +fn rejects_a_symbolic_accepted_ref_before_candidate_creation() { + let fixture = RepositoryFixture::sha1_with_commit(); + let source_head = fixture.git_output(["rev-parse", "HEAD"]); + let destination = fixture.root.join("symbolic-accepted-ref.git"); + let imported = invoke_import(&fixture.root, &source_head, &destination); + let imported: serde_json::Value = serde_json::from_slice(&imported.stdout).unwrap(); + let baseline = imported["baselineCommitOid"].as_str().unwrap(); + let alias_ref = "refs/maka/accepted-alias"; + git_bare_output(&destination, ["update-ref", alias_ref, baseline]); + git_bare_output( + &destination, + ["symbolic-ref", "refs/maka/baseline", alias_ref], + ); + + let rejected = invoke_request(serde_json::json!({ + "protocolVersion": 1, + "operation": "create_candidate", + "repositoryPath": destination, + "acceptedRef": "refs/maka/baseline", + "expectedBaseCommitOid": baseline, + "expectedBaseTreeOid": imported["baselineTreeOid"], + "candidateRef": "refs/maka/candidates/dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "path": "docs/result.txt", + "contentBase64": "c3ltYm9saWMgYWNjZXB0ZWQK", + "managedTreePolicyVersion": 3, + })); + assert_helper_error(&rejected, "accepted_ref_not_direct"); +} + +#[test] +fn rejects_an_annotated_tag_accepted_ref_before_candidate_creation() { + let fixture = RepositoryFixture::sha1_with_commit(); + let source_head = fixture.git_output(["rev-parse", "HEAD"]); + let destination = fixture.root.join("tag-accepted-ref.git"); + let imported = invoke_import(&fixture.root, &source_head, &destination); + let imported: serde_json::Value = serde_json::from_slice(&imported.stdout).unwrap(); + let baseline = imported["baselineCommitOid"].as_str().unwrap(); + git_bare_output( + &destination, + [ + "-c", + "user.name=Maka Test", + "-c", + "user.email=maka@example.invalid", + "tag", + "-a", + "accepted-receipt", + baseline, + "-m", + "accepted receipt", + ], + ); + let tag_oid = git_bare_output(&destination, ["rev-parse", "refs/tags/accepted-receipt"]); + git_bare_output(&destination, ["update-ref", "refs/maka/baseline", &tag_oid]); + + let rejected = invoke_request(serde_json::json!({ + "protocolVersion": 1, + "operation": "create_candidate", + "repositoryPath": destination, + "acceptedRef": "refs/maka/baseline", + "expectedBaseCommitOid": baseline, + "expectedBaseTreeOid": imported["baselineTreeOid"], + "candidateRef": "refs/maka/candidates/eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "path": "docs/result.txt", + "contentBase64": "dGFnIGFjY2VwdGVkCg==", + "managedTreePolicyVersion": 3, + })); + assert_helper_error(&rejected, "accepted_ref_target_invalid"); +} + +#[test] +fn publishes_an_operation_bound_no_change_receipt() { + let fixture = RepositoryFixture::sha1_with_commit(); + let source_head = fixture.git_output(["rev-parse", "HEAD"]); + let destination = fixture.root.join("no-change.git"); + let imported = invoke_import(&fixture.root, &source_head, &destination); + let imported: serde_json::Value = serde_json::from_slice(&imported.stdout).unwrap(); + let baseline = imported["baselineCommitOid"].as_str().unwrap(); + let baseline_tree = imported["baselineTreeOid"].as_str().unwrap(); + let candidate_ref = + "refs/maka/candidates/4444444444444444444444444444444444444444444444444444444444444444"; + + let request = serde_json::json!({ + "protocolVersion": 1, + "operation": "create_candidate", + "repositoryPath": destination, + "acceptedRef": "refs/maka/baseline", + "expectedBaseCommitOid": baseline, + "expectedBaseTreeOid": baseline_tree, + "candidateRef": candidate_ref, + "path": "hello.txt", + "contentBase64": "aGVsbG8gZnJvbSBzaGExCg==", + "managedTreePolicyVersion": 3, + }); + let response = invoke_request(request.clone()); + assert!(response.status.success()); + let response: serde_json::Value = serde_json::from_slice(&response.stdout).unwrap(); + assert_eq!(response["kind"], "candidate_no_change"); + assert_eq!(response["baseCommitOid"], baseline); + assert_eq!(response["baseTreeOid"], baseline_tree); + assert_eq!(response["candidateRef"], candidate_ref); + assert_eq!(response["candidateTreeOid"], baseline_tree); + assert_eq!( + git_bare_output(&destination, ["rev-parse", candidate_ref]), + response["candidateCommitOid"].as_str().unwrap() + ); + assert_eq!( + git_bare_output(&destination, ["rev-parse", "refs/maka/baseline"]), + baseline + ); + let retry = invoke_request(request); + assert!(retry.status.success()); + let retry: serde_json::Value = serde_json::from_slice(&retry.stdout).unwrap(); + assert_eq!(retry, response); +} + +#[test] +fn rejects_a_second_terminal_interpretation_for_the_same_operation_ref() { + let fixture = RepositoryFixture::sha1_with_commit(); + let source_head = fixture.git_output(["rev-parse", "HEAD"]); + let destination = fixture.root.join("operation-linearity.git"); + let imported = invoke_import(&fixture.root, &source_head, &destination); + let imported: serde_json::Value = serde_json::from_slice(&imported.stdout).unwrap(); + let baseline = imported["baselineCommitOid"].as_str().unwrap(); + let baseline_tree = imported["baselineTreeOid"].as_str().unwrap(); + let candidate_ref = + "refs/maka/candidates/7777777777777777777777777777777777777777777777777777777777777777"; + let request = |path: &str, content_base64: &str| { + serde_json::json!({ + "protocolVersion": 1, + "operation": "create_candidate", + "repositoryPath": destination, + "acceptedRef": "refs/maka/baseline", + "expectedBaseCommitOid": baseline, + "expectedBaseTreeOid": baseline_tree, + "candidateRef": candidate_ref, + "path": path, + "contentBase64": content_base64, + "managedTreePolicyVersion": 3, + }) + }; + + let no_change = invoke_request(request("hello.txt", "aGVsbG8gZnJvbSBzaGExCg==")); + assert!(no_change.status.success()); + let no_change: serde_json::Value = serde_json::from_slice(&no_change.stdout).unwrap(); + assert_eq!(no_change["kind"], "candidate_no_change"); + + let objects_before_conflict = git_bare_output(&destination, ["count-objects", "-v"]); + let conflicting = invoke_request(request("docs/result.txt", "Y2hhbmdlZAo=")); + assert_helper_error(&conflicting, "candidate_request_conflict"); + assert_eq!( + git_bare_output(&destination, ["count-objects", "-v"]), + objects_before_conflict + ); + assert_eq!( + git_bare_output(&destination, ["rev-parse", candidate_ref]), + no_change["candidateCommitOid"].as_str().unwrap() + ); + + let reverse_candidate_ref = + "refs/maka/candidates/9999999999999999999999999999999999999999999999999999999999999999"; + let reverse_request = |path: &str, content_base64: &str| { + serde_json::json!({ + "protocolVersion": 1, + "operation": "create_candidate", + "repositoryPath": destination, + "acceptedRef": "refs/maka/baseline", + "expectedBaseCommitOid": baseline, + "expectedBaseTreeOid": baseline_tree, + "candidateRef": reverse_candidate_ref, + "path": path, + "contentBase64": content_base64, + "managedTreePolicyVersion": 3, + }) + }; + let published = invoke_request(reverse_request("docs/result.txt", "Y2hhbmdlZAo=")); + assert!(published.status.success()); + let published: serde_json::Value = serde_json::from_slice(&published.stdout).unwrap(); + assert_eq!(published["kind"], "candidate_published"); + let reverse_conflict = invoke_request(reverse_request("hello.txt", "aGVsbG8gZnJvbSBzaGExCg==")); + assert_helper_error(&reverse_conflict, "candidate_request_conflict"); + assert_eq!( + git_bare_output(&destination, ["rev-parse", reverse_candidate_ref]), + published["candidateCommitOid"].as_str().unwrap() + ); +} + +#[test] +fn concurrent_exact_candidate_retry_converges_to_one_commit() { + let fixture = RepositoryFixture::sha1_with_commit(); + let source_head = fixture.git_output(["rev-parse", "HEAD"]); + let destination = fixture.root.join("concurrent-exact-retry.git"); + let imported = invoke_import(&fixture.root, &source_head, &destination); + let imported: serde_json::Value = serde_json::from_slice(&imported.stdout).unwrap(); + let request = serde_json::json!({ + "protocolVersion": 1, + "operation": "create_candidate", + "repositoryPath": destination, + "acceptedRef": "refs/maka/baseline", + "expectedBaseCommitOid": imported["baselineCommitOid"], + "expectedBaseTreeOid": imported["baselineTreeOid"], + "candidateRef": "refs/maka/candidates/8888888888888888888888888888888888888888888888888888888888888888", + "path": "docs/result.txt", + "contentBase64": "Y29uY3VycmVudAo=", + "managedTreePolicyVersion": 3, + }); + let first_request = request.clone(); + let first = std::thread::spawn(move || invoke_request(first_request)); + let second = std::thread::spawn(move || invoke_request(request)); + let first = first.join().unwrap(); + let second = second.join().unwrap(); + assert!( + first.status.success(), + "{}", + String::from_utf8_lossy(&first.stdout) + ); + assert!( + second.status.success(), + "{}", + String::from_utf8_lossy(&second.stdout) + ); + let first: serde_json::Value = serde_json::from_slice(&first.stdout).unwrap(); + let second: serde_json::Value = serde_json::from_slice(&second.stdout).unwrap(); + assert_eq!(first, second); +} + +#[test] +fn concurrent_conflicting_candidate_requests_have_one_stable_winner() { + let fixture = RepositoryFixture::sha1_with_commit(); + let source_head = fixture.git_output(["rev-parse", "HEAD"]); + let destination = fixture.root.join("concurrent-conflict.git"); + let imported = invoke_import(&fixture.root, &source_head, &destination); + let imported: serde_json::Value = serde_json::from_slice(&imported.stdout).unwrap(); + let request = |content_base64: &str| { + serde_json::json!({ + "protocolVersion": 1, + "operation": "create_candidate", + "repositoryPath": destination, + "acceptedRef": "refs/maka/baseline", + "expectedBaseCommitOid": imported["baselineCommitOid"], + "expectedBaseTreeOid": imported["baselineTreeOid"], + "candidateRef": "refs/maka/candidates/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "path": "docs/result.txt", + "contentBase64": content_base64, + "managedTreePolicyVersion": 3, + }) + }; + let first = request("Zmlyc3QK"); + let second = request("c2Vjb25kCg=="); + let first = std::thread::spawn(move || invoke_request(first)); + let second = std::thread::spawn(move || invoke_request(second)); + let outcomes = [first.join().unwrap(), second.join().unwrap()]; + assert_eq!( + outcomes + .iter() + .filter(|outcome| outcome.status.success()) + .count(), + 1 + ); + let rejected = outcomes + .iter() + .find(|outcome| !outcome.status.success()) + .unwrap(); + assert_helper_error(rejected, "candidate_request_conflict"); +} + +#[test] +fn rejects_a_stale_candidate_before_publishing_its_ref() { + let fixture = RepositoryFixture::sha1_with_commit(); + let source_head = fixture.git_output(["rev-parse", "HEAD"]); + let destination = fixture.root.join("stale-successor.git"); + let imported = invoke_import(&fixture.root, &source_head, &destination); + let imported: serde_json::Value = serde_json::from_slice(&imported.stdout).unwrap(); + let baseline = imported["baselineCommitOid"].as_str().unwrap(); + let baseline_tree = imported["baselineTreeOid"].as_str().unwrap(); + let advanced = git_bare_output( + &destination, + [ + "-c", + "user.name=Maka Test", + "-c", + "user.email=maka@example.invalid", + "commit-tree", + baseline_tree, + "-p", + baseline, + "-m", + "advanced", + ], + ); + let _ = git_bare_output( + &destination, + ["update-ref", "refs/maka/baseline", &advanced], + ); + + let rejected = invoke_request(serde_json::json!({ + "protocolVersion": 1, + "operation": "create_candidate", + "repositoryPath": destination, + "acceptedRef": "refs/maka/baseline", + "expectedBaseCommitOid": baseline, + "expectedBaseTreeOid": baseline_tree, + "candidateRef": "refs/maka/candidates/2222222222222222222222222222222222222222222222222222222222222222", + "path": "must-not-exist.txt", + "contentBase64": "bXVzdCBub3QgcHVibGlzaAo=", + "managedTreePolicyVersion": 3, + })); + assert_eq!(rejected.status.code(), Some(3)); + let rejected: serde_json::Value = serde_json::from_slice(&rejected.stdout).unwrap(); + assert_eq!(rejected["kind"], "candidate_rejected"); + assert_eq!(rejected["reason"], "base_commit_mismatch"); + assert_eq!(rejected["actualBaseCommitOid"], advanced); + assert_eq!( + git_bare_output(&destination, ["rev-parse", "refs/maka/baseline"]), + advanced + ); + assert!(!git_bare_succeeds( + &destination, + [ + "rev-parse", + "refs/maka/candidates/2222222222222222222222222222222222222222222222222222222222222222" + ] + )); +} + +#[test] +fn validates_the_complete_candidate_tree_before_candidate_ref_publication() { + let fixture = RepositoryFixture::sha1_with_commit(); + let source_head = fixture.git_output(["rev-parse", "HEAD"]); + let destination = fixture.root.join("invalid-successor.git"); + let imported = invoke_import(&fixture.root, &source_head, &destination); + let imported: serde_json::Value = serde_json::from_slice(&imported.stdout).unwrap(); + let baseline = imported["baselineCommitOid"].as_str().unwrap(); + let baseline_tree = imported["baselineTreeOid"].as_str().unwrap(); + + let rejected = invoke_request(serde_json::json!({ + "protocolVersion": 1, + "operation": "create_candidate", + "repositoryPath": destination, + "acceptedRef": "refs/maka/baseline", + "expectedBaseCommitOid": baseline, + "expectedBaseTreeOid": baseline_tree, + "candidateRef": "refs/maka/candidates/3333333333333333333333333333333333333333333333333333333333333333", + "path": ".gitattributes", + "contentBase64": "KiBmaWx0ZXI9ZXh0ZXJuYWwK", + "managedTreePolicyVersion": 3, + })); + assert_helper_error(&rejected, "unsupported_source_attributes"); + assert_eq!( + git_bare_output(&destination, ["rev-parse", "refs/maka/baseline"]), + baseline + ); +} + +#[test] +fn rejects_hfs_protected_candidate_paths_before_object_or_ref_publication() { + let fixture = RepositoryFixture::sha1_with_commit(); + let source_head = fixture.git_output(["rev-parse", "HEAD"]); + let destination = fixture.root.join("hfs-candidate.git"); + let imported = invoke_import(&fixture.root, &source_head, &destination); + let imported: serde_json::Value = serde_json::from_slice(&imported.stdout).unwrap(); + let baseline = imported["baselineCommitOid"].as_str().unwrap(); + let baseline_tree = imported["baselineTreeOid"].as_str().unwrap(); + let candidate_ref = + "refs/maka/candidates/6666666666666666666666666666666666666666666666666666666666666666"; + + let rejected = invoke_request(serde_json::json!({ + "protocolVersion": 1, + "operation": "create_candidate", + "repositoryPath": destination, + "acceptedRef": "refs/maka/baseline", + "expectedBaseCommitOid": baseline, + "expectedBaseTreeOid": baseline_tree, + "candidateRef": candidate_ref, + "path": ".g\u{200c}it", + "contentBase64": "ZXZpbAo=", + "managedTreePolicyVersion": 3, + })); + + assert_helper_error(&rejected, "invalid_successor_path"); + assert!(!git_bare_succeeds( + &destination, + ["rev-parse", candidate_ref] + )); + assert_eq!( + git_bare_output(&destination, ["rev-parse", "refs/maka/baseline"]), + baseline + ); +} + +#[test] +fn rejects_a_candidate_when_the_exact_base_tree_storage_is_corrupt() { + let fixture = RepositoryFixture::sha1_with_commit(); + let source_head = fixture.git_output(["rev-parse", "HEAD"]); + let destination = fixture.root.join("corrupt-base-tree.git"); + let imported = invoke_import(&fixture.root, &source_head, &destination); + let imported: serde_json::Value = serde_json::from_slice(&imported.stdout).unwrap(); + let baseline = imported["baselineCommitOid"].as_str().unwrap(); + let baseline_tree = imported["baselineTreeOid"].as_str().unwrap(); + fs::write(fixture.root.join("hello.txt"), b"different tree bytes\n").unwrap(); + fixture.git(["add", "hello.txt"]); + let replacement_tree = fixture.git_output(["write-tree"]); + let target = loose_object_path(&destination, baseline_tree); + fs::remove_file(&target).unwrap(); + fs::copy(fixture.loose_object_path(&replacement_tree), target).unwrap(); + let candidate_ref = + "refs/maka/candidates/5555555555555555555555555555555555555555555555555555555555555555"; + + let rejected = invoke_request(serde_json::json!({ + "protocolVersion": 1, + "operation": "create_candidate", + "repositoryPath": destination, + "acceptedRef": "refs/maka/baseline", + "expectedBaseCommitOid": baseline, + "expectedBaseTreeOid": baseline_tree, + "candidateRef": candidate_ref, + "path": "docs/result.txt", + "contentBase64": "bXVzdCBub3QgcHVibGlzaAo=", + "managedTreePolicyVersion": 3, + })); + + assert_helper_error(&rejected, "base_tree_identity_mismatch"); + assert!(!git_bare_succeeds( + &destination, + ["rev-parse", candidate_ref] + )); +} + +#[test] +fn reads_one_exact_utf8_file_from_an_accepted_policy_v3_tree() { + let fixture = RepositoryFixture::sha1_with_commit(); + fs::create_dir_all(fixture.root.join("config")).unwrap(); + fs::write( + fixture.root.join("config/package-lock.json"), + b"{\"lockfileVersion\":3}\n", + ) + .unwrap(); + fixture.git(["add", "config/package-lock.json"]); + fixture.git([ + "-c", + "user.name=Maka Test", + "-c", + "user.email=maka@example.invalid", + "commit", + "-m", + "tree file fixture", + ]); + let source_head = fixture.git_output(["rev-parse", "HEAD"]); + let destination = fixture.root.join("tree-file.git"); + let imported = invoke_import(&fixture.root, &source_head, &destination); + let imported: serde_json::Value = serde_json::from_slice(&imported.stdout).unwrap(); + let accepted = imported["baselineCommitOid"].as_str().unwrap(); + let expected_tree = imported["baselineTreeOid"].as_str().unwrap(); + + let output = invoke_request(serde_json::json!({ + "protocolVersion": 1, + "operation": "read_tree_file", + "repositoryPath": destination, + "acceptedCommitOid": accepted, + "path": "config/package-lock.json", + "managedTreePolicyVersion": 3, + })); + assert!(output.status.success()); + let response: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(response["kind"], "tree_file_read"); + assert_eq!(response["acceptedTreeOid"], expected_tree); + assert_eq!(response["content"], "{\"lockfileVersion\":3}\n"); + assert_eq!(response["bytesRead"], 22); + assert_eq!(response["managedTreePolicyVersion"], 3); +} + +#[test] +fn refuses_to_read_a_tree_file_from_an_unavailable_commit_identity() { + let fixture = RepositoryFixture::sha1_with_commit(); + let output = invoke_request(serde_json::json!({ + "protocolVersion": 1, + "operation": "read_tree_file", + "repositoryPath": fixture.root, + "acceptedCommitOid": "0000000000000000000000000000000000000000", + "path": "hello.txt", + "managedTreePolicyVersion": 3, + })); + assert_helper_error(&output, "accepted_commit_unavailable"); +} + +#[test] +fn imports_maka_attributes_under_managed_tree_policy_v3() { let fixture = RepositoryFixture::sha1_with_commit(); fs::write( fixture.root.join(".gitattributes"), @@ -535,7 +1346,7 @@ fn imports_maka_attributes_under_managed_tree_policy_v2() { "expectedSourceHeadCommitOid": source_head, "destinationRepositoryPath": destination, "baselineRef": "refs/maka/baseline", - "managedTreePolicyVersion": 2, + "managedTreePolicyVersion": 3, })); assert!( @@ -548,7 +1359,7 @@ fn imports_maka_attributes_under_managed_tree_policy_v2() { assert_eq!(response["kind"], "source_imported"); assert_eq!(response["sourceTreeOid"], source_tree); assert_eq!(response["baselineTreeOid"], source_tree); - assert_eq!(response["managedTreePolicyVersion"], 2); + assert_eq!(response["managedTreePolicyVersion"], 3); } #[test] @@ -575,7 +1386,7 @@ fn rejects_external_filter_attributes_before_claiming_the_destination() { "expectedSourceHeadCommitOid": source_head, "destinationRepositoryPath": destination, "baselineRef": "refs/maka/baseline", - "managedTreePolicyVersion": 2, + "managedTreePolicyVersion": 3, })); assert_helper_error(&output, "unsupported_source_attributes"); @@ -639,7 +1450,7 @@ fn rejects_oversized_attributes_before_claiming_the_destination() { "expectedSourceHeadCommitOid": source_head, "destinationRepositoryPath": destination, "baselineRef": "refs/maka/baseline", - "managedTreePolicyVersion": 2, + "managedTreePolicyVersion": 3, })); assert_helper_error(&output, "source_attributes_limit_exceeded"); @@ -669,7 +1480,7 @@ fn assert_attributes_import_rejected(attributes: &[u8], destination_name: &str) "expectedSourceHeadCommitOid": source_head, "destinationRepositoryPath": destination, "baselineRef": "refs/maka/baseline", - "managedTreePolicyVersion": 2, + "managedTreePolicyVersion": 3, })); assert_helper_error(&output, "unsupported_source_attributes"); @@ -697,7 +1508,7 @@ fn rejects_a_foreign_bare_destination_without_modifying_it() { "expectedSourceHeadCommitOid": source_head, "destinationRepositoryPath": destination, "baselineRef": "refs/maka/accepted", - "managedTreePolicyVersion": 2, + "managedTreePolicyVersion": 3, })); assert_helper_error(&output, "import_destination_not_fresh"); @@ -724,7 +1535,7 @@ fn rejects_a_foreign_non_bare_destination_without_modifying_it() { "expectedSourceHeadCommitOid": source_head, "destinationRepositoryPath": destination, "baselineRef": "refs/maka/accepted", - "managedTreePolicyVersion": 2, + "managedTreePolicyVersion": 3, })); assert_helper_error(&output, "import_destination_not_fresh"); @@ -746,7 +1557,7 @@ fn rejects_the_source_repository_as_its_own_destination_without_modifying_it() { "expectedSourceHeadCommitOid": source_head, "destinationRepositoryPath": fixture.root, "baselineRef": "refs/maka/baseline", - "managedTreePolicyVersion": 2, + "managedTreePolicyVersion": 3, })); assert_helper_error(&output, "import_destination_not_fresh"); @@ -786,7 +1597,7 @@ fn rejects_an_invalid_baseline_ref_before_creating_the_destination() { "expectedSourceHeadCommitOid": source_head, "destinationRepositoryPath": destination, "baselineRef": "refs/maka/a..b", - "managedTreePolicyVersion": 2, + "managedTreePolicyVersion": 3, })); assert_helper_error(&output, "invalid_baseline_ref"); @@ -810,7 +1621,7 @@ fn exactly_one_process_claims_a_fresh_import_destination() { "expectedSourceHeadCommitOid": first_head, "destinationRepositoryPath": destination, "baselineRef": "refs/maka/baseline", - "managedTreePolicyVersion": 2, + "managedTreePolicyVersion": 3, }); let second_request = serde_json::json!({ "protocolVersion": 1, @@ -819,7 +1630,7 @@ fn exactly_one_process_claims_a_fresh_import_destination() { "expectedSourceHeadCommitOid": second_head, "destinationRepositoryPath": destination, "baselineRef": "refs/maka/baseline", - "managedTreePolicyVersion": 2, + "managedTreePolicyVersion": 3, }); let first = spawn_request(first_request); @@ -912,7 +1723,7 @@ fn rejects_a_destination_below_an_aliased_parent() { "expectedSourceHeadCommitOid": source_head, "destinationRepositoryPath": destination, "baselineRef": "refs/maka/baseline", - "managedTreePolicyVersion": 2, + "managedTreePolicyVersion": 3, })); assert_helper_error(&output, "import_destination_parent_untrusted"); @@ -959,7 +1770,7 @@ fn invoke_import(source: &Path, source_head: &str, destination: &Path) -> Output "expectedSourceHeadCommitOid": source_head, "destinationRepositoryPath": destination, "baselineRef": "refs/maka/baseline", - "managedTreePolicyVersion": 2, + "managedTreePolicyVersion": 3, })) } @@ -1001,6 +1812,64 @@ fn git_bare_output(repository: &Path, args: [&str; N]) -> String String::from_utf8(output.stdout).unwrap().trim().to_owned() } +fn git_bare_bytes(repository: &Path, args: [&str; N]) -> Vec { + let output = Command::new("git") + .arg("--git-dir") + .arg(repository) + .args(args) + .output() + .unwrap(); + assert!(output.status.success()); + output.stdout +} + +fn git_bare_input_output( + repository: &Path, + args: [&str; N], + input: &[u8], +) -> String { + let mut child = Command::new("git") + .arg("--git-dir") + .arg(repository) + .args(args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .unwrap(); + child.stdin.take().unwrap().write_all(input).unwrap(); + let output = child.wait_with_output().unwrap(); + assert!(output.status.success()); + String::from_utf8(output.stdout).unwrap().trim().to_owned() +} + +fn replace_candidate_ref_tree( + repository: &Path, + candidate_ref: &str, + published: &serde_json::Value, + replacement_tree: &str, +) { + let original_commit = git_bare_bytes( + repository, + [ + "cat-file", + "commit", + published["candidateCommitOid"].as_str().unwrap(), + ], + ); + let original_tree_line = format!("tree {}", published["candidateTreeOid"].as_str().unwrap()); + let forged_commit_data = String::from_utf8(original_commit).unwrap().replacen( + &original_tree_line, + &format!("tree {replacement_tree}"), + 1, + ); + let forged_commit = git_bare_input_output( + repository, + ["hash-object", "-t", "commit", "-w", "--stdin"], + forged_commit_data.as_bytes(), + ); + git_bare_output(repository, ["update-ref", candidate_ref, &forged_commit]); +} + fn git_bare_succeeds(repository: &Path, args: [&str; N]) -> bool { Command::new("git") .arg("--git-dir") @@ -1011,6 +1880,10 @@ fn git_bare_succeeds(repository: &Path, args: [&str; N]) -> bool .success() } +fn loose_object_path(git_dir: &Path, oid: &str) -> PathBuf { + git_dir.join("objects").join(&oid[..2]).join(&oid[2..]) +} + struct RepositoryFixture { root: PathBuf, } diff --git a/packages/runtime-host/src/__tests__/gitoxide-helper-artifact-authority-internal.test.ts b/packages/runtime-host/src/__tests__/gitoxide-helper-artifact-authority-internal.test.ts index e7038911c0..5600c92695 100644 --- a/packages/runtime-host/src/__tests__/gitoxide-helper-artifact-authority-internal.test.ts +++ b/packages/runtime-host/src/__tests__/gitoxide-helper-artifact-authority-internal.test.ts @@ -80,6 +80,7 @@ test('rejects a release claim reached through a symbolic link or junction', asyn platform: process.platform, arch: process.arch, protocolVersion: 1, + supportedOperations: ['inspect_repository', 'import_source_head'], }); await assert.rejects( @@ -109,6 +110,7 @@ test('keeps an admitted helper artifact opaque and bound to its invocation owner platform: process.platform, arch: process.arch, protocolVersion: 1, + supportedOperations: ['inspect_repository', 'import_source_head'], }); const capability = await admitGitoxideHelperArtifactInternal({ @@ -146,6 +148,7 @@ test('rejects a helper artifact changed after admission', async (t) => { platform: process.platform, arch: process.arch, protocolVersion: 1, + supportedOperations: ['inspect_repository', 'import_source_head'], }); const capability = await admitGitoxideHelperArtifactInternal({ releaseOwnerToken, diff --git a/packages/runtime-host/src/__tests__/gitoxide-helper-invocation-internal.test.ts b/packages/runtime-host/src/__tests__/gitoxide-helper-invocation-internal.test.ts index f5c57506d5..e85b9017f9 100644 --- a/packages/runtime-host/src/__tests__/gitoxide-helper-invocation-internal.test.ts +++ b/packages/runtime-host/src/__tests__/gitoxide-helper-invocation-internal.test.ts @@ -26,15 +26,18 @@ import { join } from 'node:path'; import test, { type TestContext } from 'node:test'; import { admitGitoxideHelperArtifactInternal, + GitoxideHelperArtifactAuthorityError, type GitoxideHelperInvocationCapability, issueGitoxideHelperReleaseArtifactClaimInternal, } from '../server/gitoxide-helper-artifact-authority-internal.js'; import { + createCandidateWithGitoxideHelperInternal, GITOXIDE_HELPER_ERROR_REASONS_V1, GITOXIDE_HELPER_OPERATION_TIMEOUTS_INTERNAL, GitoxideHelperInvocationError, importSourceHeadWithGitoxideHelperInternal, inspectRepositoryWithGitoxideHelperInternal, + readTreeFileWithGitoxideHelperInternal, runGitoxideOperationWithinDeadlineInternal, } from '../server/gitoxide-helper-invocation-internal.js'; @@ -45,10 +48,12 @@ interface AdmittedHelper { let admittedHelperPromise: Promise | undefined; -test('uses a bounded import deadline distinct from repository inspection', () => { +test('uses bounded mutation/import deadlines distinct from repository inspection', () => { assert.deepEqual(GITOXIDE_HELPER_OPERATION_TIMEOUTS_INTERNAL, { inspectRepositoryMs: 5_000, importSourceHeadMs: 10 * 60_000, + createCandidateMs: 10 * 60_000, + acceptedTreeReadMs: 10 * 60_000, }); }); @@ -66,6 +71,103 @@ test('bounds preflight work with the same absolute operation deadline', async () assert.ok(performance.now() - startedAt < 1_000); }); +test('rejects repository inspection when the helper did not attest that operation', async (t) => { + const helper = await admitHelperPath(process.execPath, ['import_source_head']); + const repositoryPath = await createRepository(t, 'sha1'); + + await assert.rejects( + inspectRepositoryWithGitoxideHelperInternal({ ...helper, repositoryPath }), + (error) => + error instanceof GitoxideHelperArtifactAuthorityError && + error.code === 'gitoxide_helper_release_claim_unsupported', + ); +}); + +test('rejects source import when the helper did not attest that operation', async (t) => { + const helper = await admitHelperPath(process.execPath, [ + 'inspect_repository', + 'create_candidate', + 'read_tree_file', + ]); + const sourceRepositoryPath = await createRepository(t, 'sha1'); + await writeFile(join(sourceRepositoryPath, 'source.txt'), 'source\n'); + git(sourceRepositoryPath, ['add', 'source.txt']); + git(sourceRepositoryPath, [ + '-c', + 'user.name=Maka Test', + '-c', + 'user.email=maka@example.invalid', + 'commit', + '--quiet', + '-m', + 'fixture', + ]); + const destinationRepositoryPath = join(sourceRepositoryPath, 'must-not-exist.git'); + + await assert.rejects( + importSourceHeadWithGitoxideHelperInternal({ + ...helper, + sourceRepositoryPath, + expectedSourceHeadCommitOid: git(sourceRepositoryPath, ['rev-parse', 'HEAD']), + destinationRepositoryPath, + baselineRef: 'refs/maka/accepted', + managedTreePolicyVersion: 3, + }), + (error) => + error instanceof GitoxideHelperArtifactAuthorityError && + error.code === 'gitoxide_helper_release_claim_unsupported', + ); + await assert.rejects(stat(destinationRepositoryPath), { code: 'ENOENT' }); +}); + +test('rejects candidate creation when the helper did not attest that operation', async (t) => { + const helper = await admitHelperPath(process.execPath, [ + 'inspect_repository', + 'import_source_head', + 'read_tree_file', + ]); + const repositoryPath = await createRepository(t, 'sha1'); + + await assert.rejects( + createCandidateWithGitoxideHelperInternal({ + ...helper, + repositoryPath, + acceptedRef: 'refs/maka/accepted', + expectedBaseCommitOid: 'a'.repeat(40), + expectedBaseTreeOid: 'b'.repeat(40), + candidateRef: `refs/maka/candidates/${'c'.repeat(64)}`, + path: 'result.txt', + content: 'result\n', + managedTreePolicyVersion: 3, + }), + (error) => + error instanceof GitoxideHelperArtifactAuthorityError && + error.code === 'gitoxide_helper_release_claim_unsupported', + ); +}); + +test('rejects accepted-tree reads when the helper did not attest that operation', async (t) => { + const helper = await admitHelperPath(process.execPath, [ + 'inspect_repository', + 'import_source_head', + 'create_candidate', + ]); + const repositoryPath = await createRepository(t, 'sha1'); + + await assert.rejects( + readTreeFileWithGitoxideHelperInternal({ + ...helper, + repositoryPath, + acceptedCommitOid: 'a'.repeat(40), + path: 'result.txt', + managedTreePolicyVersion: 3, + }), + (error) => + error instanceof GitoxideHelperArtifactAuthorityError && + error.code === 'gitoxide_helper_release_claim_unsupported', + ); +}); + test('waits for helper process identity by elapsed time instead of scheduler turns', async (t) => { const root = await realpath(await mkdtemp(join(tmpdir(), 'maka-gitoxide-marker-'))); t.after(() => rm(root, { recursive: true, force: true })); @@ -116,7 +218,7 @@ test('applies the import deadline and terminates the helper process tree', { expectedSourceHeadCommitOid, destinationRepositoryPath: join(root, 'destination.git'), baselineRef: 'refs/maka/baseline', - managedTreePolicyVersion: 2, + managedTreePolicyVersion: 3, }); void operation.then( () => { @@ -204,6 +306,102 @@ test('rejects an import response that does not match the requested source HEAD', }); }); +test('rejects a candidate response whose blob identity does not match the requested bytes', { + skip: process.platform === 'win32', +}, async (t) => { + const root = await realpath( + await mkdtemp(join(tmpdir(), 'maka-gitoxide-candidate-correlation-')), + ); + t.after(() => rm(root, { recursive: true, force: true })); + const repositoryPath = await createRepository(t, 'sha1'); + const acceptedRef = 'refs/maka/accepted'; + const candidateRef = `refs/maka/candidates/${'1'.repeat(64)}`; + const expectedBaseCommitOid = 'a'.repeat(40); + const expectedBaseTreeOid = 'b'.repeat(40); + const helperPath = join(root, 'mismatched-candidate-helper'); + const response = JSON.stringify({ + protocolVersion: 1, + kind: 'candidate_published', + objectFormat: 'sha1', + baseCommitOid: expectedBaseCommitOid, + baseTreeOid: expectedBaseTreeOid, + candidateCommitOid: 'c'.repeat(40), + candidateTreeOid: 'd'.repeat(40), + resultBlobOid: 'e'.repeat(40), + requestDigestSha256: candidateRequestDigestForTest({ + acceptedRef, + expectedBaseCommitOid, + expectedBaseTreeOid, + candidateRef, + path: 'result.txt', + content: Buffer.from('expected bytes\n'), + }), + acceptedRef, + candidateRef, + path: 'result.txt', + managedTreePolicyVersion: 3, + }); + await writeFile(helperPath, `#!/bin/sh\n/bin/cat >/dev/null\nprintf '%s\\n' '${response}'\n`); + await chmod(helperPath, 0o755); + const helper = await admitHelperPath(helperPath, ['create_candidate']); + + await assert.rejects( + createCandidateWithGitoxideHelperInternal({ + ...helper, + repositoryPath, + acceptedRef, + expectedBaseCommitOid, + expectedBaseTreeOid, + candidateRef, + path: 'result.txt', + content: 'expected bytes\n', + managedTreePolicyVersion: 3, + }), + (error) => + error instanceof GitoxideHelperInvocationError && + error.code === 'gitoxide_helper_invocation_protocol_invalid', + ); +}); + +test('rejects a direct-read response whose blob identity does not match its content', { + skip: process.platform === 'win32', +}, async (t) => { + const root = await realpath(await mkdtemp(join(tmpdir(), 'maka-gitoxide-read-correlation-'))); + t.after(() => rm(root, { recursive: true, force: true })); + const repositoryPath = await createRepository(t, 'sha1'); + const acceptedCommitOid = 'a'.repeat(40); + const content = 'returned bytes\n'; + const helperPath = join(root, 'mismatched-read-helper'); + const response = JSON.stringify({ + protocolVersion: 1, + kind: 'tree_file_read', + objectFormat: 'sha1', + acceptedCommitOid, + acceptedTreeOid: 'b'.repeat(40), + blobOid: 'c'.repeat(40), + path: 'result.txt', + content, + bytesRead: Buffer.byteLength(content), + managedTreePolicyVersion: 3, + }); + await writeFile(helperPath, `#!/bin/sh\n/bin/cat >/dev/null\nprintf '%s\\n' '${response}'\n`); + await chmod(helperPath, 0o755); + const helper = await admitHelperPath(helperPath, ['read_tree_file']); + + await assert.rejects( + readTreeFileWithGitoxideHelperInternal({ + ...helper, + repositoryPath, + acceptedCommitOid, + path: 'result.txt', + managedTreePolicyVersion: 3, + }), + (error) => + error instanceof GitoxideHelperInvocationError && + error.code === 'gitoxide_helper_invocation_protocol_invalid', + ); +}); + test('keeps the Rust and TypeScript helper error protocol exhaustive', async () => { const rustSource = await readFile( new URL('../../../../native/gitoxide-helper/src/main.rs', import.meta.url), @@ -359,7 +557,7 @@ async function assertMismatchedImportResponseRejected( baselineCommitOid: 'a'.repeat(40), baselineTreeOid: sourceTreeOid, baselineRef: 'refs/maka/expected-ref', - managedTreePolicyVersion: 2, + managedTreePolicyVersion: 3, filesImported: 1, bytesImported: 29, ...override, @@ -375,7 +573,7 @@ async function assertMismatchedImportResponseRejected( expectedSourceHeadCommitOid, destinationRepositoryPath: join(root, 'destination.git'), baselineRef: 'refs/maka/expected-ref', - managedTreePolicyVersion: 2, + managedTreePolicyVersion: 3, }), (error) => error instanceof GitoxideHelperInvocationError && @@ -383,7 +581,15 @@ async function assertMismatchedImportResponseRejected( ); } -async function admitHelperPath(configuredHelperPath: string): Promise { +async function admitHelperPath( + configuredHelperPath: string, + supportedOperations: readonly ( + | 'inspect_repository' + | 'import_source_head' + | 'create_candidate' + | 'read_tree_file' + )[] = ['inspect_repository', 'import_source_head'], +): Promise { const helperPath = await realpath(configuredHelperPath); const helperBytes = await readFile(helperPath); const helperInfo = await stat(helperPath); @@ -396,6 +602,7 @@ async function admitHelperPath(configuredHelperPath: string): Promise error instanceof GitoxideRepositoryAdmissionAuthorityError && @@ -197,6 +203,284 @@ test('imports only the exact repository identity bound to the admission capabili ); }); +test('rejects a helper without candidate/read attestation before claiming the import destination', async (t) => { + const helper = await admittedHelperWithOperations(['inspect_repository', 'import_source_head']); + if (!helper) { + t.skip('MAKA_GITOXIDE_HELPER_PATH is required for the real helper contract test'); + return; + } + const repositoryPath = await createRepository(t, 'sha1'); + await writeFile(join(repositoryPath, 'hello.txt'), 'feature attestation\n'); + git(repositoryPath, ['add', 'hello.txt']); + git(repositoryPath, [ + '-c', + 'user.name=Maka Test', + '-c', + 'user.email=maka@example.invalid', + 'commit', + '--quiet', + '-m', + 'fixture', + ]); + const admissionOwnerToken = {}; + const admitted = await admitGitoxideRepositoryInternal({ + ...helper, + admissionOwnerToken, + repositoryPath, + }); + assert.equal(admitted.kind, 'accepted'); + if (admitted.kind !== 'accepted') return; + const destinationRepositoryPath = join(repositoryPath, 'must-not-be-claimed.git'); + + await assert.rejects( + importAdmittedGitoxideRepositoryInternal({ + admissionOwnerToken, + repositoryCapability: admitted.capability, + acceptedRepositoryOwnerToken: {}, + destinationRepositoryPath, + }), + (error) => + error instanceof GitoxideHelperArtifactAuthorityError && + error.code === 'gitoxide_helper_release_claim_unsupported', + ); + await assert.rejects(stat(destinationRepositoryPath), { code: 'ENOENT' }); +}); + +test('publishes an operation-bound candidate without advancing accepted authority', async (t) => { + const helper = await admittedHelper(); + if (!helper) { + t.skip('MAKA_GITOXIDE_HELPER_PATH is required for the real helper contract test'); + return; + } + const repositoryPath = await createRepository(t, 'sha1'); + await writeFile(join(repositoryPath, 'hello.txt'), 'hello from candidate authority\n'); + git(repositoryPath, ['add', 'hello.txt']); + git(repositoryPath, [ + '-c', + 'user.name=Maka Test', + '-c', + 'user.email=maka@example.invalid', + 'commit', + '--quiet', + '-m', + 'fixture', + ]); + const admissionOwnerToken = {}; + const acceptedRepositoryOwnerToken = {}; + const admitted = await admitGitoxideRepositoryInternal({ + ...helper, + admissionOwnerToken, + repositoryPath, + }); + assert.equal(admitted.kind, 'accepted'); + if (admitted.kind !== 'accepted') return; + const destinationRepositoryPath = join(repositoryPath, 'managed.git'); + const imported = await importAdmittedGitoxideRepositoryInternal({ + admissionOwnerToken, + repositoryCapability: admitted.capability, + acceptedRepositoryOwnerToken, + destinationRepositoryPath, + }); + + const candidateOwnerToken = {}; + const candidate = await createGitoxideCandidateInternal({ + acceptedRepositoryOwnerToken, + acceptedRepositoryCapability: imported.acceptedRepositoryCapability, + candidateOwnerToken, + operationId: 'operation-1', + path: 'docs/result.txt', + content: 'candidate result\n', + }); + assert.equal(candidate.kind, 'candidate_published'); + if (candidate.kind !== 'candidate_published') return; + + assert.equal(candidate.baseCommitOid, imported.baselineCommitOid); + assert.equal(candidate.baseTreeOid, imported.baselineTreeOid); + assert.equal( + candidate.candidateRef, + `refs/maka/candidates/${createHash('sha256').update('operation-1').digest('hex')}`, + ); + assert.equal( + gitBare(destinationRepositoryPath, ['rev-parse', 'refs/maka/accepted']), + imported.baselineCommitOid, + ); + assert.equal( + gitBare(destinationRepositoryPath, ['rev-parse', candidate.candidateRef]), + candidate.candidateCommitOid, + ); + assert.deepEqual(candidate.candidateOutcomeCapability, { + kind: 'gitoxide_candidate_outcome_capability_v1', + }); + const candidateProof = requireGitoxideCandidateOutcomeForAcceptedRepositoryInternal({ + acceptedRepositoryOwnerToken, + acceptedRepositoryCapability: imported.acceptedRepositoryCapability, + candidateOwnerToken, + candidateOutcomeCapability: candidate.candidateOutcomeCapability, + }); + assert.equal(candidateProof.operationId, 'operation-1'); + assert.equal(candidateProof.acceptedRef, 'refs/maka/accepted'); + assert.equal(candidateProof.objectFormat, 'sha1'); + assert.equal(candidateProof.managedTreePolicyVersion, 3); + assert.match(candidateProof.helperArtifactSha256, /^sha256:[0-9a-f]{64}$/); + assert.match(candidateProof.requestDigestSha256, /^[0-9a-f]{64}$/); + assert.equal( + candidateProof.resultContentSha256, + `sha256:${createHash('sha256').update('candidate result\n').digest('hex')}`, + ); + assert.throws( + () => + requireGitoxideCandidateOutcomeForAcceptedRepositoryInternal({ + acceptedRepositoryOwnerToken, + acceptedRepositoryCapability: imported.acceptedRepositoryCapability, + candidateOwnerToken: {}, + candidateOutcomeCapability: candidate.candidateOutcomeCapability, + }), + GitoxideRepositoryAdmissionAuthorityError, + ); + + const secondAcceptedRepositoryOwnerToken = {}; + const sameContentOtherRepository = await importAdmittedGitoxideRepositoryInternal({ + admissionOwnerToken, + repositoryCapability: admitted.capability, + acceptedRepositoryOwnerToken: secondAcceptedRepositoryOwnerToken, + destinationRepositoryPath: join(repositoryPath, 'managed-other.git'), + }); + assert.throws( + () => + requireGitoxideCandidateOutcomeForAcceptedRepositoryInternal({ + acceptedRepositoryOwnerToken: secondAcceptedRepositoryOwnerToken, + acceptedRepositoryCapability: sameContentOtherRepository.acceptedRepositoryCapability, + candidateOwnerToken, + candidateOutcomeCapability: candidate.candidateOutcomeCapability, + }), + GitoxideRepositoryAdmissionAuthorityError, + ); + + const acceptedRead = await readGitoxideTreeFileInternal({ + acceptedRepositoryOwnerToken, + acceptedRepositoryCapability: imported.acceptedRepositoryCapability, + path: 'hello.txt', + }); + assert.equal(acceptedRead.content, 'hello from candidate authority\n'); + + const noChangeOperationId = 'operation-no-change'; + const noChange = await createGitoxideCandidateInternal({ + acceptedRepositoryOwnerToken, + acceptedRepositoryCapability: imported.acceptedRepositoryCapability, + candidateOwnerToken, + operationId: noChangeOperationId, + path: 'hello.txt', + content: 'hello from candidate authority\n', + }); + assert.equal(noChange.kind, 'candidate_no_change'); + assert.equal( + gitBare(destinationRepositoryPath, ['rev-parse', noChange.candidateRef]), + noChange.candidateCommitOid, + ); + assert.equal(noChange.candidateTreeOid, imported.baselineTreeOid); + assert.equal( + requireGitoxideCandidateOutcomeForAcceptedRepositoryInternal({ + acceptedRepositoryOwnerToken, + acceptedRepositoryCapability: imported.acceptedRepositoryCapability, + candidateOwnerToken, + candidateOutcomeCapability: noChange.candidateOutcomeCapability, + }).disposition, + 'no_change', + ); + + const exactRetry = await createGitoxideCandidateInternal({ + acceptedRepositoryOwnerToken, + acceptedRepositoryCapability: imported.acceptedRepositoryCapability, + candidateOwnerToken, + operationId: 'operation-1', + path: 'docs/result.txt', + content: 'candidate result\n', + }); + assert.equal(exactRetry.kind, 'candidate_published'); + if (exactRetry.kind !== 'candidate_published') return; + assert.equal(exactRetry.candidateCommitOid, candidate.candidateCommitOid); + assert.equal(exactRetry.candidateTreeOid, candidate.candidateTreeOid); + + const second = await createGitoxideCandidateInternal({ + acceptedRepositoryOwnerToken, + acceptedRepositoryCapability: imported.acceptedRepositoryCapability, + candidateOwnerToken, + operationId: 'operation-2', + path: 'docs/next.txt', + content: 'next candidate\n', + }); + assert.equal(second.kind, 'candidate_published'); + if (second.kind !== 'candidate_published') return; + assert.equal(second.baseCommitOid, imported.baselineCommitOid); + assert.notEqual(second.candidateRef, candidate.candidateRef); + await assert.rejects( + createGitoxideCandidateInternal({ + acceptedRepositoryOwnerToken: {}, + acceptedRepositoryCapability: imported.acceptedRepositoryCapability, + candidateOwnerToken, + operationId: 'forged-operation', + path: 'forged.txt', + content: 'forged\n', + }), + (error) => + error instanceof GitoxideRepositoryAdmissionAuthorityError && + error.code === 'gitoxide_repository_admission_capability_invalid', + ); +}); + +test('reads an immutable accepted-tree file without consulting the projection filesystem', async (t) => { + const helper = await admittedHelper(); + if (!helper) { + t.skip('MAKA_GITOXIDE_HELPER_PATH is required for the real helper contract test'); + return; + } + const repositoryPath = await createRepository(t, 'sha1'); + await writeFile(join(repositoryPath, 'package.json'), '{"name":"fixture","private":true}\n'); + git(repositoryPath, ['add', 'package.json']); + git(repositoryPath, [ + '-c', + 'user.name=Maka Test', + '-c', + 'user.email=maka@example.invalid', + 'commit', + '--quiet', + '-m', + 'fixture', + ]); + const admissionOwnerToken = {}; + const acceptedRepositoryOwnerToken = {}; + const admitted = await admitGitoxideRepositoryInternal({ + ...helper, + admissionOwnerToken, + repositoryPath, + }); + assert.equal(admitted.kind, 'accepted'); + if (admitted.kind !== 'accepted') return; + const imported = await importAdmittedGitoxideRepositoryInternal({ + admissionOwnerToken, + repositoryCapability: admitted.capability, + acceptedRepositoryOwnerToken, + destinationRepositoryPath: join(repositoryPath, 'managed-tree-read.git'), + }); + + const result = await readGitoxideTreeFileInternal({ + acceptedRepositoryOwnerToken, + acceptedRepositoryCapability: imported.acceptedRepositoryCapability, + path: 'package.json', + }); + assert.equal(result.content, '{"name":"fixture","private":true}\n'); + assert.equal(result.acceptedCommitOid, imported.baselineCommitOid); + assert.equal(result.acceptedTreeOid, imported.baselineTreeOid); + await assert.rejects( + readGitoxideTreeFileInternal({ + acceptedRepositoryOwnerToken: {}, + acceptedRepositoryCapability: imported.acceptedRepositoryCapability, + path: 'package.json', + }), + GitoxideRepositoryAdmissionAuthorityError, + ); +}); + test('reuses the exact helper capability captured by repository admission', async (t) => { const helper = await admittedHelper(); if (!helper) { @@ -228,8 +512,8 @@ test('reuses the exact helper capability captured by repository admission', asyn const imported = await importAdmittedGitoxideRepositoryInternal({ admissionOwnerToken, repositoryCapability: admitted.capability, + acceptedRepositoryOwnerToken: {}, destinationRepositoryPath: join(repositoryPath, 'captured-helper-import.git'), - baselineRef: 'refs/maka/baseline', }); assert.equal(imported.sourceHeadCommitOid, git(repositoryPath, ['rev-parse', 'HEAD'])); }); @@ -253,6 +537,12 @@ async function admittedHelper(): Promise { platform: process.platform, arch: process.arch, protocolVersion: 1, + supportedOperations: [ + 'inspect_repository', + 'import_source_head', + 'create_candidate', + 'read_tree_file', + ], }); const helperCapability = await admitGitoxideHelperArtifactInternal({ releaseOwnerToken, @@ -264,6 +554,35 @@ async function admittedHelper(): Promise { return admittedHelperPromise; } +async function admittedHelperWithOperations( + supportedOperations: readonly GitoxideHelperOperationInternal[], +): Promise { + const configuredHelperPath = process.env.MAKA_GITOXIDE_HELPER_PATH; + if (!configuredHelperPath) return undefined; + const helperPath = await realpath(configuredHelperPath); + const helperBytes = await readFile(helperPath); + const helperInfo = await stat(helperPath); + const helperArtifactSha256 = + `sha256:${createHash('sha256').update(helperBytes).digest('hex')}` as const; + const releaseOwnerToken = {}; + const invocationOwnerToken = {}; + const claim = issueGitoxideHelperReleaseArtifactClaimInternal(releaseOwnerToken, { + executablePath: helperPath, + expectedSha256: helperArtifactSha256, + expectedBytes: helperInfo.size, + platform: process.platform, + arch: process.arch, + protocolVersion: 1, + supportedOperations, + }); + const helperCapability = await admitGitoxideHelperArtifactInternal({ + releaseOwnerToken, + invocationOwnerToken, + claim, + }); + return { invocationOwnerToken, helperCapability, helperArtifactSha256 }; +} + async function createRepository(t: TestContext, objectFormat: 'sha1' | 'sha256') { const repositoryPath = await realpath(await mkdtemp(join(tmpdir(), 'maka-gitoxide-admission-'))); t.after(() => rm(repositoryPath, { recursive: true, force: true })); diff --git a/packages/runtime-host/src/server/gitoxide-helper-artifact-authority-internal.ts b/packages/runtime-host/src/server/gitoxide-helper-artifact-authority-internal.ts index 656d3dd537..94f7cb6817 100644 --- a/packages/runtime-host/src/server/gitoxide-helper-artifact-authority-internal.ts +++ b/packages/runtime-host/src/server/gitoxide-helper-artifact-authority-internal.ts @@ -24,6 +24,13 @@ import { isAbsolute, join, parse, relative, resolve, sep } from 'node:path'; const SHA256_PATTERN = /^sha256:[0-9a-f]{64}$/; const HASH_BUFFER_BYTES = 64 * 1024; const MAX_HELPER_ARTIFACT_BYTES = 256 * 1024 * 1024; +export const GITOXIDE_HELPER_OPERATIONS_INTERNAL = Object.freeze([ + 'inspect_repository', + 'import_source_head', + 'create_candidate', + 'read_tree_file', +] as const); +export type GitoxideHelperOperationInternal = (typeof GITOXIDE_HELPER_OPERATIONS_INTERNAL)[number]; export interface GitoxideHelperReleaseArtifactClaim { readonly kind: 'gitoxide_helper_release_artifact_claim_v1'; @@ -40,17 +47,20 @@ export interface GitoxideHelperReleaseArtifactStateInternal { readonly platform: NodeJS.Platform; readonly arch: string; readonly protocolVersion: 1; + readonly supportedOperations: readonly GitoxideHelperOperationInternal[]; } export interface VerifiedGitoxideHelperArtifactInternal { readonly executablePath: string; readonly protocolVersion: 1; + readonly supportedOperations: readonly GitoxideHelperOperationInternal[]; } export interface GitoxideHelperArtifactIdentityInternal { readonly sha256: `sha256:${string}`; readonly bytes: number; readonly protocolVersion: 1; + readonly supportedOperations: readonly GitoxideHelperOperationInternal[]; } export type GitoxideHelperArtifactAuthorityErrorCode = @@ -96,7 +106,14 @@ export function issueGitoxideHelperReleaseArtifactClaimInternal( const claim = Object.freeze({ kind: 'gitoxide_helper_release_artifact_claim_v1' as const, }); - releaseClaims.set(claim, Object.freeze({ ...state, releaseOwnerToken })); + releaseClaims.set( + claim, + Object.freeze({ + ...state, + supportedOperations: Object.freeze([...state.supportedOperations]), + releaseOwnerToken, + }), + ); return claim; } @@ -153,6 +170,7 @@ export async function verifyGitoxideHelperArtifactForInvocationInternal( return Object.freeze({ executablePath: canonicalExecutablePath, protocolVersion: record.claim.protocolVersion, + supportedOperations: record.claim.supportedOperations, }); } @@ -171,9 +189,31 @@ export function requireGitoxideHelperArtifactIdentityInternal( sha256: record.claim.expectedSha256, bytes: record.claim.expectedBytes, protocolVersion: record.claim.protocolVersion, + supportedOperations: record.claim.supportedOperations, }); } +export function requireGitoxideHelperOperationsInternal( + invocationOwnerToken: object, + capability: GitoxideHelperInvocationCapability, + requiredOperations: readonly GitoxideHelperOperationInternal[], +): void { + const record = invocationCapabilities.get(capability); + if (!record || record.invocationOwnerToken !== invocationOwnerToken) { + throw new GitoxideHelperArtifactAuthorityError( + 'gitoxide_helper_invocation_capability_invalid', + 'Gitoxide helper invocation capability is invalid for this owner', + ); + } + const supported = new Set(record.claim.supportedOperations); + if (requiredOperations.some((operation) => !supported.has(operation))) { + throw new GitoxideHelperArtifactAuthorityError( + 'gitoxide_helper_release_claim_unsupported', + 'Gitoxide helper release artifact does not attest the required operations', + ); + } +} + function assertReleaseArtifactState(state: GitoxideHelperReleaseArtifactStateInternal): void { if ( typeof state.executablePath !== 'string' || @@ -187,7 +227,8 @@ function assertReleaseArtifactState(state: GitoxideHelperReleaseArtifactStateInt state.platform.length === 0 || typeof state.arch !== 'string' || state.arch.length === 0 || - state.protocolVersion !== 1 + state.protocolVersion !== 1 || + !isExactSupportedOperations(state.supportedOperations) ) { throw new GitoxideHelperArtifactAuthorityError( 'gitoxide_helper_release_claim_invalid', @@ -196,6 +237,17 @@ function assertReleaseArtifactState(state: GitoxideHelperReleaseArtifactStateInt } } +function isExactSupportedOperations( + operations: readonly GitoxideHelperOperationInternal[], +): boolean { + return ( + Array.isArray(operations) && + operations.length > 0 && + new Set(operations).size === operations.length && + operations.every((operation) => GITOXIDE_HELPER_OPERATIONS_INTERNAL.includes(operation)) + ); +} + async function verifyArtifact(claim: ReleaseClaimRecord): Promise { let canonicalExecutablePath: string; let handle; diff --git a/packages/runtime-host/src/server/gitoxide-helper-invocation-internal.ts b/packages/runtime-host/src/server/gitoxide-helper-invocation-internal.ts index b29669218a..065a7aa6fe 100644 --- a/packages/runtime-host/src/server/gitoxide-helper-invocation-internal.ts +++ b/packages/runtime-host/src/server/gitoxide-helper-invocation-internal.ts @@ -18,20 +18,27 @@ */ import { spawn } from 'node:child_process'; +import { createHash } from 'node:crypto'; import { realpath } from 'node:fs/promises'; import { dirname, isAbsolute } from 'node:path'; import { manageChildProcessLifecycle } from '@maka/runtime/child-process-lifecycle'; import { type GitoxideHelperInvocationCapability, + requireGitoxideHelperOperationsInternal, verifyGitoxideHelperArtifactForInvocationInternal, } from './gitoxide-helper-artifact-authority-internal.js'; -const MAX_REQUEST_BYTES = 64 * 1024; -const MAX_STDOUT_BYTES = 64 * 1024; +const MAX_SUCCESSOR_CONTENT_BYTES = 64 * 1024 * 1024; +const MAX_TREE_FILE_BYTES = 8 * 1024 * 1024; +const MAX_ENCODED_CONTENT_BYTES = Math.ceil(MAX_SUCCESSOR_CONTENT_BYTES / 3) * 4; +const MAX_REQUEST_BYTES = MAX_ENCODED_CONTENT_BYTES + 64 * 1024; +const MAX_STDOUT_BYTES = MAX_TREE_FILE_BYTES * 6 + 64 * 1024; const MAX_STDERR_BYTES = 16 * 1024; export const GITOXIDE_HELPER_OPERATION_TIMEOUTS_INTERNAL = Object.freeze({ inspectRepositoryMs: 5_000, importSourceHeadMs: 10 * 60_000, + createCandidateMs: 10 * 60_000, + acceptedTreeReadMs: 10 * 60_000, }); const SHA1_OID_PATTERN = /^[0-9a-f]{40}$/; const MAKA_REF_PATTERN = /^refs\/maka\/[A-Za-z0-9][A-Za-z0-9._/-]{0,255}$/; @@ -51,7 +58,16 @@ export const GITOXIDE_HELPER_ERROR_REASONS_V1 = Object.freeze([ 'baseline_commit_write_failed', 'baseline_publish_failed', 'baseline_ref_outside_maka_namespace', + 'base_commit_unavailable', + 'base_commit_identity_mismatch', + 'base_path_lookup_failed', + 'base_tree_unavailable', + 'base_tree_identity_mismatch', + 'blob_write_failed', + 'commit_write_failed', 'invalid_baseline_ref', + 'invalid_base_commit_oid', + 'invalid_successor_path', 'import_destination_create_failed', 'import_destination_not_fresh', 'import_destination_object_format_mismatch', @@ -85,6 +101,29 @@ export const GITOXIDE_HELPER_ERROR_REASONS_V1 = Object.freeze([ 'source_tree_observation_mismatch', 'source_tree_unavailable', 'source_tree_visit_limit_exceeded', + 'successor_content_limit_exceeded', + 'successor_commit_identity_mismatch', + 'accepted_ref_not_direct', + 'accepted_ref_target_invalid', + 'candidate_ref_not_direct', + 'candidate_ref_target_invalid', + 'candidate_request_conflict', + 'candidate_publication_indeterminate', + 'target_ref_outside_maka_namespace', + 'target_ref_unavailable', + 'tree_edit_failed', + 'tree_write_failed', + 'unsupported_base_path_kind', + 'accepted_commit_unavailable', + 'accepted_tree_unavailable', + 'invalid_accepted_commit_oid', + 'invalid_tree_file_path', + 'tree_file_identity_mismatch', + 'tree_file_invalid', + 'tree_file_lookup_failed', + 'tree_file_not_utf8', + 'tree_file_size_limit_exceeded', + 'tree_file_unavailable', 'unsupported_source_entry_kind', 'unsupported_source_attributes', 'unsupported_source_path', @@ -122,11 +161,73 @@ export interface GitoxideSourceImportObservationV1 { readonly baselineCommitOid: string; readonly baselineTreeOid: string; readonly baselineRef: string; - readonly managedTreePolicyVersion: 2; + readonly managedTreePolicyVersion: 3; readonly filesImported: number; readonly bytesImported: number; } +export interface GitoxideCandidatePublishedV1 { + readonly kind: 'candidate_published'; + readonly protocolVersion: 1; + readonly objectFormat: 'sha1'; + readonly baseCommitOid: string; + readonly baseTreeOid: string; + readonly candidateCommitOid: string; + readonly candidateTreeOid: string; + readonly resultBlobOid: string; + readonly requestDigestSha256: string; + readonly acceptedRef: string; + readonly candidateRef: string; + readonly path: string; + readonly managedTreePolicyVersion: 3; +} + +export interface GitoxideCandidateNoChangeV1 { + readonly kind: 'candidate_no_change'; + readonly protocolVersion: 1; + readonly objectFormat: 'sha1'; + readonly baseCommitOid: string; + readonly baseTreeOid: string; + readonly resultBlobOid: string; + readonly candidateCommitOid: string; + readonly candidateTreeOid: string; + readonly requestDigestSha256: string; + readonly acceptedRef: string; + readonly candidateRef: string; + readonly path: string; + readonly managedTreePolicyVersion: 3; +} + +export interface GitoxideCandidateRejectedV1 { + readonly kind: 'candidate_rejected'; + readonly protocolVersion: 1; + readonly reason: 'base_commit_mismatch'; + readonly objectFormat: 'sha1'; + readonly expectedBaseCommitOid: string; + readonly actualBaseCommitOid: string; + readonly acceptedRef: string; + readonly candidateRef: string; + readonly managedTreePolicyVersion: 3; +} + +export type GitoxideCandidateResultV1 = + | GitoxideCandidatePublishedV1 + | GitoxideCandidateNoChangeV1 + | GitoxideCandidateRejectedV1; + +export interface GitoxideTreeFileReadV1 { + readonly kind: 'tree_file_read'; + readonly protocolVersion: 1; + readonly objectFormat: 'sha1'; + readonly acceptedCommitOid: string; + readonly acceptedTreeOid: string; + readonly blobOid: string; + readonly path: string; + readonly content: string; + readonly bytesRead: number; + readonly managedTreePolicyVersion: 3; +} + export type GitoxideHelperInvocationErrorCode = | 'gitoxide_helper_invocation_invalid' | 'gitoxide_helper_invocation_spawn_failed' @@ -177,6 +278,9 @@ export async function inspectCanonicalRepositoryWithGitoxideHelperInternal(input deadlineAt: input.deadlineAt, abortSignal: input.abortSignal, operation: async () => { + requireGitoxideHelperOperationsInternal(input.invocationOwnerToken, input.capability, [ + 'inspect_repository', + ]); if (!isAbsolute(input.repositoryPath)) { throw new GitoxideHelperInvocationError( 'gitoxide_helper_invocation_invalid', @@ -280,7 +384,7 @@ export async function importSourceHeadWithGitoxideHelperInternal(input: { readonly expectedSourceHeadCommitOid: string; readonly destinationRepositoryPath: string; readonly baselineRef: string; - readonly managedTreePolicyVersion: 2; + readonly managedTreePolicyVersion: 3; readonly abortSignal?: AbortSignal; }): Promise { const deadlineAt = @@ -289,6 +393,9 @@ export async function importSourceHeadWithGitoxideHelperInternal(input: { deadlineAt, abortSignal: input.abortSignal, operation: async () => { + requireGitoxideHelperOperationsInternal(input.invocationOwnerToken, input.capability, [ + 'import_source_head', + ]); if ( !isAbsolute(input.sourceRepositoryPath) || !isAbsolute(input.destinationRepositoryPath) || @@ -346,6 +453,181 @@ export async function importSourceHeadWithGitoxideHelperInternal(input: { }); } +export async function createCandidateWithGitoxideHelperInternal(input: { + readonly invocationOwnerToken: object; + readonly capability: GitoxideHelperInvocationCapability; + readonly repositoryPath: string; + readonly acceptedRef: string; + readonly expectedBaseCommitOid: string; + readonly expectedBaseTreeOid: string; + readonly candidateRef: string; + readonly path: string; + readonly content: string; + readonly managedTreePolicyVersion: 3; + readonly abortSignal?: AbortSignal; +}): Promise { + const deadlineAt = + performance.now() + GITOXIDE_HELPER_OPERATION_TIMEOUTS_INTERNAL.createCandidateMs; + const { artifact, repositoryPath } = await runGitoxideOperationWithinDeadlineInternal({ + deadlineAt, + abortSignal: input.abortSignal, + operation: async () => { + requireGitoxideHelperOperationsInternal(input.invocationOwnerToken, input.capability, [ + 'create_candidate', + ]); + if ( + !isAbsolute(input.repositoryPath) || + !SHA1_OID_PATTERN.test(input.expectedBaseCommitOid) || + !SHA1_OID_PATTERN.test(input.expectedBaseTreeOid) || + !MAKA_REF_PATTERN.test(input.acceptedRef) || + !/^refs\/maka\/candidates\/[0-9a-f]{64}$/.test(input.candidateRef) || + !isBoundedPathTransport(input.path) || + Buffer.byteLength(input.content, 'utf8') > MAX_SUCCESSOR_CONTENT_BYTES || + input.managedTreePolicyVersion !== 3 + ) { + throw new GitoxideHelperInvocationError( + 'gitoxide_helper_invocation_invalid', + 'Gitoxide successor request is invalid', + ); + } + const [artifact, repositoryPath] = await Promise.all([ + verifyGitoxideHelperArtifactForInvocationInternal( + input.invocationOwnerToken, + input.capability, + ), + realpath(input.repositoryPath).catch((error) => { + throw new GitoxideHelperInvocationError( + 'gitoxide_helper_invocation_invalid', + `Gitoxide managed repository path could not be resolved: ${error instanceof Error ? error.message : String(error)}`, + ); + }), + ]); + return { artifact, repositoryPath }; + }, + }); + const request = Buffer.from( + JSON.stringify({ + protocolVersion: artifact.protocolVersion, + operation: 'create_candidate', + repositoryPath, + acceptedRef: input.acceptedRef, + expectedBaseCommitOid: input.expectedBaseCommitOid, + expectedBaseTreeOid: input.expectedBaseTreeOid, + candidateRef: input.candidateRef, + path: input.path, + contentBase64: Buffer.from(input.content, 'utf8').toString('base64'), + managedTreePolicyVersion: input.managedTreePolicyVersion, + }), + ); + if (request.length > MAX_REQUEST_BYTES) { + throw new GitoxideHelperInvocationError( + 'gitoxide_helper_invocation_invalid', + 'Gitoxide helper request exceeds its byte limit', + ); + } + const outcome = await invokeHelper({ + executablePath: artifact.executablePath, + request, + abortSignal: input.abortSignal, + deadlineAt, + }); + const requestDigestSha256 = candidateRequestDigestSha256({ + acceptedRef: input.acceptedRef, + expectedBaseCommitOid: input.expectedBaseCommitOid, + expectedBaseTreeOid: input.expectedBaseTreeOid, + candidateRef: input.candidateRef, + path: input.path, + content: Buffer.from(input.content, 'utf8'), + }); + return decodeCandidateOutcome(outcome, { + acceptedRef: input.acceptedRef, + expectedBaseCommitOid: input.expectedBaseCommitOid, + expectedBaseTreeOid: input.expectedBaseTreeOid, + candidateRef: input.candidateRef, + path: input.path, + resultBlobOid: gitBlobOid(Buffer.from(input.content, 'utf8')), + requestDigestSha256, + managedTreePolicyVersion: input.managedTreePolicyVersion, + }); +} + +export async function readTreeFileWithGitoxideHelperInternal(input: { + readonly invocationOwnerToken: object; + readonly capability: GitoxideHelperInvocationCapability; + readonly repositoryPath: string; + readonly acceptedCommitOid: string; + readonly path: string; + readonly managedTreePolicyVersion: 3; + readonly abortSignal?: AbortSignal; +}): Promise { + const deadlineAt = + performance.now() + GITOXIDE_HELPER_OPERATION_TIMEOUTS_INTERNAL.acceptedTreeReadMs; + if (!isBoundedPathTransport(input.path)) { + throw invocationInvalid('Gitoxide tree file path is invalid'); + } + const prepared = await prepareAcceptedTreeInvocation({ ...input, deadlineAt }); + const request = Buffer.from( + JSON.stringify({ + protocolVersion: prepared.artifact.protocolVersion, + operation: 'read_tree_file', + repositoryPath: prepared.repositoryPath, + acceptedCommitOid: input.acceptedCommitOid, + path: input.path, + managedTreePolicyVersion: input.managedTreePolicyVersion, + }), + ); + if (request.length > MAX_REQUEST_BYTES) throw invocationInvalid('Gitoxide request is too large'); + const outcome = await invokeHelper({ + executablePath: prepared.artifact.executablePath, + request, + abortSignal: input.abortSignal, + deadlineAt, + }); + return decodeTreeFileOutcome(outcome, input); +} + +async function prepareAcceptedTreeInvocation(input: { + readonly invocationOwnerToken: object; + readonly capability: GitoxideHelperInvocationCapability; + readonly repositoryPath: string; + readonly acceptedCommitOid: string; + readonly managedTreePolicyVersion: 3; + readonly deadlineAt: number; + readonly abortSignal?: AbortSignal; +}): Promise<{ + readonly artifact: Awaited>; + readonly repositoryPath: string; +}> { + return runGitoxideOperationWithinDeadlineInternal({ + deadlineAt: input.deadlineAt, + abortSignal: input.abortSignal, + operation: async () => { + requireGitoxideHelperOperationsInternal(input.invocationOwnerToken, input.capability, [ + 'read_tree_file', + ]); + if ( + !isAbsolute(input.repositoryPath) || + !SHA1_OID_PATTERN.test(input.acceptedCommitOid) || + input.managedTreePolicyVersion !== 3 + ) { + throw invocationInvalid('Gitoxide accepted-tree request is invalid'); + } + const [artifact, repositoryPath] = await Promise.all([ + verifyGitoxideHelperArtifactForInvocationInternal( + input.invocationOwnerToken, + input.capability, + ), + realpath(input.repositoryPath).catch((error) => { + throw invocationInvalid( + `Gitoxide managed repository path could not be resolved: ${error instanceof Error ? error.message : String(error)}`, + ); + }), + ]); + return { artifact, repositoryPath }; + }, + }); +} + /* * The absolute operation deadline starts at the public entry point. Once it * expires, the shared lifecycle owner force-kills the process tree and applies @@ -560,7 +842,7 @@ function decodeSourceImportOutcome( expected: { readonly expectedSourceHeadCommitOid: string; readonly baselineRef: string; - readonly managedTreePolicyVersion: 2; + readonly managedTreePolicyVersion: 3; }, ): GitoxideSourceImportObservationV1 { if (outcome.signal !== null) { @@ -591,12 +873,299 @@ function decodeSourceImportOutcome( ); } +function decodeCandidateOutcome( + outcome: HelperProcessOutcome, + expected: { + readonly acceptedRef: string; + readonly expectedBaseCommitOid: string; + readonly expectedBaseTreeOid: string; + readonly candidateRef: string; + readonly path: string; + readonly resultBlobOid: string; + readonly requestDigestSha256: string; + readonly managedTreePolicyVersion: 3; + }, +): GitoxideCandidateResultV1 { + if (outcome.signal !== null) { + throw new GitoxideHelperInvocationError( + 'gitoxide_helper_invocation_protocol_invalid', + `Gitoxide helper exited from signal ${outcome.signal}`, + ); + } + let value: unknown; + try { + value = JSON.parse(outcome.stdout.toString('utf8')); + } catch { + throw protocolInvalid('Gitoxide helper stdout is not one JSON response'); + } + if ( + outcome.exitCode === 0 && + (isCandidatePublished(value, expected) || isCandidateNoChange(value, expected)) + ) { + return Object.freeze(value); + } + if (outcome.exitCode === 3 && isCandidateRejected(value, expected)) { + return Object.freeze(value); + } + if (outcome.exitCode === 1 && isHelperError(value)) { + throw new GitoxideHelperInvocationError( + 'gitoxide_helper_operation_failed', + `Gitoxide helper could not create the successor: ${value.reason}`, + value.reason, + ); + } + const stderr = outcome.stderr.toString('utf8').trim(); + throw protocolInvalid( + `Gitoxide helper exit code and response disagree${stderr ? `: ${stderr}` : ''}`, + ); +} + +function isCandidatePublished( + value: unknown, + expected: { + readonly acceptedRef: string; + readonly expectedBaseCommitOid: string; + readonly expectedBaseTreeOid: string; + readonly candidateRef: string; + readonly path: string; + readonly resultBlobOid: string; + readonly requestDigestSha256: string; + readonly managedTreePolicyVersion: 3; + }, +): value is GitoxideCandidatePublishedV1 { + return ( + hasExactKeys(value, [ + 'protocolVersion', + 'kind', + 'objectFormat', + 'baseCommitOid', + 'baseTreeOid', + 'candidateCommitOid', + 'candidateTreeOid', + 'resultBlobOid', + 'requestDigestSha256', + 'acceptedRef', + 'candidateRef', + 'path', + 'managedTreePolicyVersion', + ]) && + value.protocolVersion === 1 && + value.kind === 'candidate_published' && + value.objectFormat === 'sha1' && + value.baseCommitOid === expected.expectedBaseCommitOid && + value.baseTreeOid === expected.expectedBaseTreeOid && + typeof value.candidateCommitOid === 'string' && + SHA1_OID_PATTERN.test(value.candidateCommitOid) && + typeof value.candidateTreeOid === 'string' && + SHA1_OID_PATTERN.test(value.candidateTreeOid) && + typeof value.resultBlobOid === 'string' && + value.resultBlobOid === expected.resultBlobOid && + value.requestDigestSha256 === expected.requestDigestSha256 && + value.acceptedRef === expected.acceptedRef && + value.candidateRef === expected.candidateRef && + value.path === expected.path && + value.managedTreePolicyVersion === expected.managedTreePolicyVersion + ); +} + +function isCandidateNoChange( + value: unknown, + expected: { + readonly acceptedRef: string; + readonly expectedBaseCommitOid: string; + readonly expectedBaseTreeOid: string; + readonly path: string; + readonly resultBlobOid: string; + readonly candidateRef: string; + readonly requestDigestSha256: string; + readonly managedTreePolicyVersion: 3; + }, +): value is GitoxideCandidateNoChangeV1 { + return ( + hasExactKeys(value, [ + 'protocolVersion', + 'kind', + 'objectFormat', + 'baseCommitOid', + 'baseTreeOid', + 'resultBlobOid', + 'candidateCommitOid', + 'candidateTreeOid', + 'requestDigestSha256', + 'acceptedRef', + 'candidateRef', + 'path', + 'managedTreePolicyVersion', + ]) && + value.protocolVersion === 1 && + value.kind === 'candidate_no_change' && + value.objectFormat === 'sha1' && + value.baseCommitOid === expected.expectedBaseCommitOid && + value.baseTreeOid === expected.expectedBaseTreeOid && + value.resultBlobOid === expected.resultBlobOid && + isSha1(value.candidateCommitOid) && + value.candidateTreeOid === expected.expectedBaseTreeOid && + value.requestDigestSha256 === expected.requestDigestSha256 && + value.acceptedRef === expected.acceptedRef && + value.candidateRef === expected.candidateRef && + value.path === expected.path && + value.managedTreePolicyVersion === expected.managedTreePolicyVersion + ); +} + +function isCandidateRejected( + value: unknown, + expected: { + readonly acceptedRef: string; + readonly expectedBaseCommitOid: string; + readonly candidateRef: string; + readonly managedTreePolicyVersion: 3; + }, +): value is GitoxideCandidateRejectedV1 { + return ( + hasExactKeys(value, [ + 'protocolVersion', + 'kind', + 'reason', + 'objectFormat', + 'expectedBaseCommitOid', + 'actualBaseCommitOid', + 'acceptedRef', + 'candidateRef', + 'managedTreePolicyVersion', + ]) && + value.protocolVersion === 1 && + value.kind === 'candidate_rejected' && + value.reason === 'base_commit_mismatch' && + value.objectFormat === 'sha1' && + value.expectedBaseCommitOid === expected.expectedBaseCommitOid && + typeof value.actualBaseCommitOid === 'string' && + SHA1_OID_PATTERN.test(value.actualBaseCommitOid) && + value.acceptedRef === expected.acceptedRef && + value.candidateRef === expected.candidateRef && + value.managedTreePolicyVersion === expected.managedTreePolicyVersion + ); +} + +function decodeTreeFileOutcome( + outcome: HelperProcessOutcome, + expected: { + readonly acceptedCommitOid: string; + readonly path: string; + readonly managedTreePolicyVersion: 3; + }, +): GitoxideTreeFileReadV1 { + const value = parseHelperOutcome(outcome); + if ( + outcome.exitCode === 0 && + isTreeFileRead(value) && + value.acceptedCommitOid === expected.acceptedCommitOid && + value.path === expected.path && + value.blobOid === gitBlobOid(Buffer.from(value.content, 'utf8')) && + value.managedTreePolicyVersion === expected.managedTreePolicyVersion + ) { + return Object.freeze(value); + } + if (outcome.exitCode === 1 && isHelperError(value)) { + throw operationFailed('read the accepted tree file', value.reason); + } + throw protocolInvalid('Gitoxide tree file response is invalid'); +} + +function gitBlobOid(content: Buffer): string { + return createHash('sha1') + .update(`blob ${content.length}\0`, 'utf8') + .update(content) + .digest('hex'); +} + +function candidateRequestDigestSha256(input: { + readonly acceptedRef: string; + readonly expectedBaseCommitOid: string; + readonly expectedBaseTreeOid: string; + readonly candidateRef: string; + readonly path: string; + readonly content: Buffer; +}): string { + const hash = createHash('sha256').update('maka.gitoxide.candidate-request.v1\0', 'utf8'); + for (const field of [ + Buffer.from(input.acceptedRef, 'utf8'), + Buffer.from(input.expectedBaseCommitOid, 'utf8'), + Buffer.from(input.expectedBaseTreeOid, 'utf8'), + Buffer.from(input.candidateRef, 'utf8'), + Buffer.from(input.path, 'utf8'), + input.content, + ]) { + const length = Buffer.allocUnsafe(8); + length.writeBigUInt64BE(BigInt(field.length)); + hash.update(length).update(field); + } + return hash.digest('hex'); +} + +function parseHelperOutcome(outcome: HelperProcessOutcome): unknown { + if (outcome.signal !== null) { + throw protocolInvalid(`Gitoxide helper exited from signal ${outcome.signal}`); + } + try { + return JSON.parse(outcome.stdout.toString('utf8')); + } catch { + throw protocolInvalid('Gitoxide helper stdout is not one JSON response'); + } +} + +function operationFailed(operation: string, reason: string): GitoxideHelperInvocationError { + return new GitoxideHelperInvocationError( + 'gitoxide_helper_operation_failed', + `Gitoxide helper could not ${operation}: ${reason}`, + reason, + ); +} + +function isTreeFileRead(value: unknown): value is GitoxideTreeFileReadV1 { + return ( + hasExactKeys(value, [ + 'protocolVersion', + 'kind', + 'objectFormat', + 'acceptedCommitOid', + 'acceptedTreeOid', + 'blobOid', + 'path', + 'content', + 'bytesRead', + 'managedTreePolicyVersion', + ]) && + value.protocolVersion === 1 && + value.kind === 'tree_file_read' && + value.objectFormat === 'sha1' && + isSha1(value.acceptedCommitOid) && + isSha1(value.acceptedTreeOid) && + isSha1(value.blobOid) && + typeof value.path === 'string' && + isBoundedPathTransport(value.path) && + typeof value.content === 'string' && + isNonNegativeSafeInteger(value.bytesRead) && + value.bytesRead <= MAX_TREE_FILE_BYTES && + Buffer.byteLength(value.content, 'utf8') === value.bytesRead && + value.managedTreePolicyVersion === 3 + ); +} + +function isSha1(value: unknown): value is string { + return typeof value === 'string' && SHA1_OID_PATTERN.test(value); +} + +function isNonNegativeSafeInteger(value: unknown): value is number { + return Number.isSafeInteger(value) && (value as number) >= 0; +} + function isSourceImportObservation( value: unknown, expected: { readonly expectedSourceHeadCommitOid: string; readonly baselineRef: string; - readonly managedTreePolicyVersion: 2; + readonly managedTreePolicyVersion: 3; }, ): value is GitoxideSourceImportObservationV1 { return ( @@ -689,6 +1258,10 @@ function isHelperError(value: unknown): value is { ); } +function isBoundedPathTransport(path: string): boolean { + return path.length > 0 && !path.includes('\0') && Buffer.byteLength(path, 'utf8') <= 4096; +} + function hasExactKeys( value: unknown, expectedKeys: readonly string[], @@ -734,3 +1307,7 @@ function terminationMessage( function protocolInvalid(message: string): GitoxideHelperInvocationError { return new GitoxideHelperInvocationError('gitoxide_helper_invocation_protocol_invalid', message); } + +function invocationInvalid(message: string): GitoxideHelperInvocationError { + return new GitoxideHelperInvocationError('gitoxide_helper_invocation_invalid', message); +} diff --git a/packages/runtime-host/src/server/gitoxide-repository-admission-authority-internal.ts b/packages/runtime-host/src/server/gitoxide-repository-admission-authority-internal.ts index 9f0e32d811..bb574b6f0d 100644 --- a/packages/runtime-host/src/server/gitoxide-repository-admission-authority-internal.ts +++ b/packages/runtime-host/src/server/gitoxide-repository-admission-authority-internal.ts @@ -17,15 +17,22 @@ * under the License. */ +import { createHash } from 'node:crypto'; import { type GitoxideHelperInvocationCapability, requireGitoxideHelperArtifactIdentityInternal, + requireGitoxideHelperOperationsInternal, } from './gitoxide-helper-artifact-authority-internal.js'; import { + createCandidateWithGitoxideHelperInternal, GITOXIDE_HELPER_OPERATION_TIMEOUTS_INTERNAL, importSourceHeadWithGitoxideHelperInternal, inspectCanonicalRepositoryWithGitoxideHelperInternal, + readTreeFileWithGitoxideHelperInternal, + type GitoxideCandidateNoChangeV1, + type GitoxideCandidatePublishedV1, type GitoxideSourceImportObservationV1, + type GitoxideTreeFileReadV1, type GitoxideRepositoryRejectionV1, } from './gitoxide-helper-invocation-internal.js'; @@ -33,6 +40,14 @@ export interface GitoxideRepositoryAdmissionCapability { readonly kind: 'gitoxide_repository_admission_capability_v1'; } +export interface GitoxideAcceptedRepositoryCapability { + readonly kind: 'gitoxide_accepted_repository_capability_v1'; +} + +export interface GitoxideCandidateOutcomeCapability { + readonly kind: 'gitoxide_candidate_outcome_capability_v1'; +} + export interface GitoxideRepositoryAdmissionStateInternal { readonly protocolVersion: 1; readonly repositoryPath: string; @@ -40,10 +55,11 @@ export interface GitoxideRepositoryAdmissionStateInternal { readonly headCommitOid: string; readonly headTreeOid: string; readonly helperArtifactSha256: `sha256:${string}`; - readonly managedTreePolicyVersion: 2; + readonly managedTreePolicyVersion: 3; } -const MANAGED_TREE_POLICY_VERSION = 2 as const; +const MANAGED_TREE_POLICY_VERSION = 3 as const; +const ACCEPTED_REPOSITORY_REF = 'refs/maka/accepted' as const; export type GitoxideRepositoryAdmissionResultV1 = | { @@ -53,8 +69,16 @@ export type GitoxideRepositoryAdmissionResultV1 = | GitoxideRepositoryRejectionV1; export class GitoxideRepositoryAdmissionAuthorityError extends Error { - constructor(readonly code: 'gitoxide_repository_admission_capability_invalid') { - super('Gitoxide repository admission capability is invalid'); + constructor( + readonly code: + | 'gitoxide_repository_admission_capability_invalid' + | 'gitoxide_managed_repository_base_mismatch', + ) { + super( + code === 'gitoxide_managed_repository_base_mismatch' + ? 'Gitoxide managed repository base does not match the accepted commit' + : 'Gitoxide repository admission capability is invalid', + ); this.name = 'GitoxideRepositoryAdmissionAuthorityError'; } } @@ -66,7 +90,41 @@ interface AdmissionCapabilityRecord { readonly state: GitoxideRepositoryAdmissionStateInternal; } +interface AcceptedRepositoryCapabilityRecord { + readonly acceptedRepositoryOwnerToken: object; + readonly invocationOwnerToken: object; + readonly helperCapability: GitoxideHelperInvocationCapability; + readonly repositoryPath: string; + readonly acceptedRef: string; + readonly acceptedCommitOid: string; + readonly acceptedTreeOid: string; + readonly managedTreePolicyVersion: 3; +} + +interface CandidateOutcomeCapabilityRecord { + readonly candidateOwnerToken: object; + readonly acceptedRepositoryCapability: GitoxideAcceptedRepositoryCapability; + readonly repositoryPath: string; + readonly acceptedRef: string; + readonly objectFormat: 'sha1'; + readonly managedTreePolicyVersion: 3; + readonly helperArtifactSha256: `sha256:${string}`; + readonly disposition: 'published' | 'no_change'; + readonly operationId: string; + readonly requestDigestSha256: string; + readonly resultContentSha256: `sha256:${string}`; + readonly baseCommitOid: string; + readonly baseTreeOid: string; + readonly candidateCommitOid: string; + readonly candidateTreeOid: string; + readonly candidateRef: string; + readonly resultBlobOid: string; + readonly path: string; +} + const admissions = new WeakMap(); +const acceptedRepositories = new WeakMap(); +const candidateOutcomes = new WeakMap(); export async function admitGitoxideRepositoryInternal(input: { readonly invocationOwnerToken: object; @@ -124,19 +182,28 @@ export function requireGitoxideRepositoryAdmissionInternal( export async function importAdmittedGitoxideRepositoryInternal(input: { readonly admissionOwnerToken: object; readonly repositoryCapability: GitoxideRepositoryAdmissionCapability; + readonly acceptedRepositoryOwnerToken: object; readonly destinationRepositoryPath: string; - readonly baselineRef: string; readonly abortSignal?: AbortSignal; -}): Promise { +}): Promise< + GitoxideSourceImportObservationV1 & { + readonly acceptedRepositoryCapability: GitoxideAcceptedRepositoryCapability; + } +> { const admission = requireAdmissionRecord(input.admissionOwnerToken, input.repositoryCapability); const source = admission.state; + requireGitoxideHelperOperationsInternal( + admission.invocationOwnerToken, + admission.helperCapability, + ['create_candidate', 'read_tree_file'], + ); const result = await importSourceHeadWithGitoxideHelperInternal({ invocationOwnerToken: admission.invocationOwnerToken, capability: admission.helperCapability, sourceRepositoryPath: source.repositoryPath, expectedSourceHeadCommitOid: source.headCommitOid, destinationRepositoryPath: input.destinationRepositoryPath, - baselineRef: input.baselineRef, + baselineRef: ACCEPTED_REPOSITORY_REF, managedTreePolicyVersion: source.managedTreePolicyVersion, abortSignal: input.abortSignal, }); @@ -148,9 +215,185 @@ export async function importAdmittedGitoxideRepositoryInternal(input: { 'gitoxide_repository_admission_capability_invalid', ); } + const acceptedRepositoryCapability = issueAcceptedRepositoryCapability({ + acceptedRepositoryOwnerToken: input.acceptedRepositoryOwnerToken, + invocationOwnerToken: admission.invocationOwnerToken, + helperCapability: admission.helperCapability, + repositoryPath: input.destinationRepositoryPath, + acceptedRef: result.baselineRef, + acceptedCommitOid: result.baselineCommitOid, + acceptedTreeOid: result.baselineTreeOid, + managedTreePolicyVersion: result.managedTreePolicyVersion, + }); + return Object.freeze({ ...result, acceptedRepositoryCapability }); +} + +export async function createGitoxideCandidateInternal(input: { + readonly acceptedRepositoryOwnerToken: object; + readonly acceptedRepositoryCapability: GitoxideAcceptedRepositoryCapability; + readonly candidateOwnerToken: object; + readonly operationId: string; + readonly path: string; + readonly content: string; + readonly abortSignal?: AbortSignal; +}): Promise< + | (GitoxideCandidatePublishedV1 & { + readonly candidateOutcomeCapability: GitoxideCandidateOutcomeCapability; + }) + | (GitoxideCandidateNoChangeV1 & { + readonly candidateOutcomeCapability: GitoxideCandidateOutcomeCapability; + }) +> { + const managed = requireAcceptedRepositoryRecord( + input.acceptedRepositoryOwnerToken, + input.acceptedRepositoryCapability, + ); + if ( + typeof input.operationId !== 'string' || + input.operationId.length === 0 || + Buffer.byteLength(input.operationId, 'utf8') > 1024 + ) { + throw new GitoxideRepositoryAdmissionAuthorityError( + 'gitoxide_repository_admission_capability_invalid', + ); + } + const candidateRef = `refs/maka/candidates/${createHash('sha256').update(input.operationId).digest('hex')}`; + const result = await createCandidateWithGitoxideHelperInternal({ + invocationOwnerToken: managed.invocationOwnerToken, + capability: managed.helperCapability, + repositoryPath: managed.repositoryPath, + acceptedRef: managed.acceptedRef, + expectedBaseCommitOid: managed.acceptedCommitOid, + expectedBaseTreeOid: managed.acceptedTreeOid, + candidateRef, + path: input.path, + content: input.content, + managedTreePolicyVersion: managed.managedTreePolicyVersion, + abortSignal: input.abortSignal, + }); + if (result.kind === 'candidate_rejected') { + throw new GitoxideRepositoryAdmissionAuthorityError( + 'gitoxide_managed_repository_base_mismatch', + ); + } + const helperArtifactIdentity = requireGitoxideHelperArtifactIdentityInternal( + managed.invocationOwnerToken, + managed.helperCapability, + ); + const candidateOutcomeCapability = Object.freeze({ + kind: 'gitoxide_candidate_outcome_capability_v1' as const, + }); + candidateOutcomes.set( + candidateOutcomeCapability, + Object.freeze({ + candidateOwnerToken: input.candidateOwnerToken, + acceptedRepositoryCapability: input.acceptedRepositoryCapability, + repositoryPath: managed.repositoryPath, + acceptedRef: managed.acceptedRef, + objectFormat: 'sha1' as const, + managedTreePolicyVersion: managed.managedTreePolicyVersion, + helperArtifactSha256: helperArtifactIdentity.sha256, + disposition: + result.kind === 'candidate_published' ? ('published' as const) : ('no_change' as const), + operationId: input.operationId, + requestDigestSha256: result.requestDigestSha256, + resultContentSha256: + `sha256:${createHash('sha256').update(input.content, 'utf8').digest('hex')}` as const, + baseCommitOid: result.baseCommitOid, + baseTreeOid: result.baseTreeOid, + candidateCommitOid: result.candidateCommitOid, + candidateTreeOid: result.candidateTreeOid, + candidateRef: result.candidateRef, + resultBlobOid: result.resultBlobOid, + path: result.path, + }), + ); + return Object.freeze({ ...result, candidateOutcomeCapability }); +} + +export async function readGitoxideTreeFileInternal(input: { + readonly acceptedRepositoryOwnerToken: object; + readonly acceptedRepositoryCapability: GitoxideAcceptedRepositoryCapability; + readonly path: string; + readonly abortSignal?: AbortSignal; +}): Promise { + const managed = requireAcceptedRepositoryRecord( + input.acceptedRepositoryOwnerToken, + input.acceptedRepositoryCapability, + ); + const result = await readTreeFileWithGitoxideHelperInternal({ + invocationOwnerToken: managed.invocationOwnerToken, + capability: managed.helperCapability, + repositoryPath: managed.repositoryPath, + acceptedCommitOid: managed.acceptedCommitOid, + path: input.path, + managedTreePolicyVersion: managed.managedTreePolicyVersion, + abortSignal: input.abortSignal, + }); + if ( + result.acceptedCommitOid !== managed.acceptedCommitOid || + result.acceptedTreeOid !== managed.acceptedTreeOid + ) { + throw new GitoxideRepositoryAdmissionAuthorityError( + 'gitoxide_repository_admission_capability_invalid', + ); + } return result; } +export function requireGitoxideCandidateOutcomeForAcceptedRepositoryInternal(input: { + readonly acceptedRepositoryOwnerToken: object; + readonly acceptedRepositoryCapability: GitoxideAcceptedRepositoryCapability; + readonly candidateOwnerToken: object; + readonly candidateOutcomeCapability: GitoxideCandidateOutcomeCapability; +}): Readonly< + Omit +> { + requireAcceptedRepositoryRecord( + input.acceptedRepositoryOwnerToken, + input.acceptedRepositoryCapability, + ); + const record = candidateOutcomes.get(input.candidateOutcomeCapability); + if ( + !record || + record.candidateOwnerToken !== input.candidateOwnerToken || + record.acceptedRepositoryCapability !== input.acceptedRepositoryCapability + ) { + throw new GitoxideRepositoryAdmissionAuthorityError( + 'gitoxide_repository_admission_capability_invalid', + ); + } + const { + candidateOwnerToken: _candidateOwner, + acceptedRepositoryCapability: _acceptedCapability, + ...proof + } = record; + return Object.freeze(proof); +} + +function issueAcceptedRepositoryCapability( + record: AcceptedRepositoryCapabilityRecord, +): GitoxideAcceptedRepositoryCapability { + const capability = Object.freeze({ + kind: 'gitoxide_accepted_repository_capability_v1' as const, + }); + acceptedRepositories.set(capability, Object.freeze(record)); + return capability; +} + +function requireAcceptedRepositoryRecord( + ownerToken: object, + capability: GitoxideAcceptedRepositoryCapability, +): AcceptedRepositoryCapabilityRecord { + const record = acceptedRepositories.get(capability); + if (!record || record.acceptedRepositoryOwnerToken !== ownerToken) { + throw new GitoxideRepositoryAdmissionAuthorityError( + 'gitoxide_repository_admission_capability_invalid', + ); + } + return record; +} + function requireAdmissionRecord( admissionOwnerToken: object, capability: GitoxideRepositoryAdmissionCapability, From 95c80a715dbd8373400c4862ca33b3cdd5a0a82d Mon Sep 17 00:00:00 2001 From: testikun Date: Sun, 30 Aug 2026 20:04:06 +0800 Subject: [PATCH 23/71] feat(runtime): guide Auto tool selection by final tool surface (#3705) Derive Auto-mode tool guidance from the Runtime Host permission snapshot and the final model-visible tool surface. Keep execution, sandbox, and approval authority unchanged, fail closed when no usable shell plan exists, and cover the eligibility and exclusion boundaries in composition tests. --- .../execution-model-composition.test.ts | 280 ++++++++++++++++++ .../src/server/interactive-run-composer.ts | 17 +- packages/runtime/package.json | 1 + .../src/__tests__/auto-tool-guidance.test.ts | 74 +++++ .../src/system-prompt/auto-tool-guidance.ts | 72 +++++ 5 files changed, 443 insertions(+), 1 deletion(-) create mode 100644 packages/runtime/src/__tests__/auto-tool-guidance.test.ts create mode 100644 packages/runtime/src/system-prompt/auto-tool-guidance.ts diff --git a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts index 3483b11db3..35b52e7276 100644 --- a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts +++ b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts @@ -3392,6 +3392,277 @@ test('a bound tool ceiling excludes dynamic Client Capability tools', () => { ); }); +test('injects Auto tool guidance only into an eligible main-session prompt', async () => { + const composition = createInteractiveRunComposer({ + runtimePolicy: { revision: 0, policy: createDefaultRuntimePolicy() }, + permissionMode: 'ask', + skills: { + readCanonicalModelInventory: async () => ({ inventory: [] }), + } as unknown as HostSkillCatalogCoordinator, + memory: { + readPromptProjection: async () => ({ + bundleRevision: null, + memoryRevision: null, + body: '', + }), + } as unknown as HostMemoryCoordinator, + taskLedger: {} as TaskLedgerStore, + hostTools: [composerTool('Bash'), composerTool('Read'), composerTool('Edit')], + shell: { plan: { kind: 'posix', displayName: '/bin/sh' } } as const, + }); + + const prompt = ( + await composition.resolveSystemPrompt({ + sessionId: 'auto-session', + turnId: 'auto-turn', + cwd: '/workspace', + workspaceRoot: '/workspace', + }) + ).text; + + assert.equal(composition.composerRevision, '2'); + assert.equal(prompt?.match(/## Auto-mode tool guidance/g)?.length, 1); + assert.match(prompt ?? '', /Prefer Bash and composable CLI workflows/u); + assert.match(prompt ?? '', /Prefer Read for simple structured inspection/u); + assert.match(prompt ?? '', /Prefer Edit when path validation/u); +}); + +test('does not inject Auto guidance into child, restricted, unavailable, or missing-shell prompts', async () => { + const promptDependencies = { + runtimePolicy: { revision: 0, policy: createDefaultRuntimePolicy() }, + permissionMode: 'ask' as const, + skills: { + readCanonicalModelInventory: async () => ({ inventory: [] }), + } as unknown as HostSkillCatalogCoordinator, + memory: { + readPromptProjection: async () => ({ + bundleRevision: null, + memoryRevision: null, + body: '', + }), + } as unknown as HostMemoryCoordinator, + taskLedger: {} as TaskLedgerStore, + hostTools: [composerTool('Bash'), composerTool('Read')], + shell: { plan: { kind: 'posix', displayName: '/bin/sh' } } as const, + }; + + const child = createInteractiveRunComposer({ + ...promptDependencies, + childInstruction: 'Child role instructions', + }); + const childPrompt = ( + await child.resolveSystemPrompt({ + sessionId: 'child-session', + turnId: 'child-turn', + cwd: '/workspace', + workspaceRoot: '/workspace', + }) + ).text; + assert.doesNotMatch(childPrompt ?? '', /Auto-mode tool guidance/u); + + const restricted = createInteractiveRunComposer({ + ...promptDependencies, + boundTools: [composerTool('Bash'), composerTool('Read')], + }); + const restrictedPrompt = ( + await restricted.resolveSystemPrompt({ + sessionId: 'restricted-session', + turnId: 'restricted-turn', + cwd: '/workspace', + workspaceRoot: '/workspace', + }) + ).text; + assert.doesNotMatch(restrictedPrompt ?? '', /Auto-mode tool guidance/u); + + const unavailableShell = createInteractiveRunComposer({ + ...promptDependencies, + shell: { + plan: { kind: 'posix', displayName: '/bin/sh' }, + setupError: new ShellPreferenceError('executable_missing', 'Bash is unavailable'), + }, + }); + const unavailablePrompt = ( + await unavailableShell.resolveSystemPrompt({ + sessionId: 'unavailable-session', + turnId: 'unavailable-turn', + cwd: '/workspace', + workspaceRoot: '/workspace', + }) + ).text; + assert.doesNotMatch(unavailablePrompt ?? '', /Auto-mode tool guidance/u); + + const missingShell = createInteractiveRunComposer({ ...promptDependencies, shell: undefined }); + const missingShellPrompt = ( + await missingShell.resolveSystemPrompt({ + sessionId: 'missing-shell-session', + turnId: 'missing-shell-turn', + cwd: '/workspace', + workspaceRoot: '/workspace', + }) + ).text; + assert.doesNotMatch(missingShellPrompt ?? '', /Auto-mode tool guidance/u); +}); + +test('does not append Auto guidance after the side-conversation boundary', async () => { + const composition = createInteractiveRunComposer({ + runtimePolicy: { revision: 0, policy: createDefaultRuntimePolicy() }, + permissionMode: 'ask', + sideConversation: true, + skills: { + readCanonicalModelInventory: async () => ({ inventory: [] }), + } as unknown as HostSkillCatalogCoordinator, + memory: { + readPromptProjection: async () => ({ + bundleRevision: null, + memoryRevision: null, + body: '', + }), + } as unknown as HostMemoryCoordinator, + taskLedger: {} as TaskLedgerStore, + hostTools: [composerTool('Bash'), composerTool('Read')], + }); + + const prompt = ( + await composition.resolveSystemPrompt({ + sessionId: 'side-session', + turnId: 'side-turn', + cwd: '/workspace', + workspaceRoot: '/workspace', + }) + ).text; + assert.doesNotMatch(prompt ?? '', /Auto-mode tool guidance/u); + assert.match(prompt ?? '', /Side conversation boundary:/u); +}); + +test('uses the Host permission snapshot instead of plan permission state', async () => { + const composition = createInteractiveRunComposer({ + runtimePolicy: { revision: 0, policy: createDefaultRuntimePolicy() }, + permissionMode: 'ask', + skills: { + readCanonicalModelInventory: async () => ({ inventory: [] }), + } as unknown as HostSkillCatalogCoordinator, + memory: { + readPromptProjection: async () => ({ + bundleRevision: null, + memoryRevision: null, + body: '', + }), + } as unknown as HostMemoryCoordinator, + taskLedger: {} as TaskLedgerStore, + hostTools: [composerTool('Bash'), composerTool('Read')], + shell: { plan: { kind: 'posix', displayName: '/bin/sh' } }, + plan: { + store: {} as PlanStore, + state: { + schemaVersion: 1, + sessionId: 'plan-session', + storeVersion: 0, + proposals: [], + executions: [], + }, + mode: 'agent', + permissionMode: 'bypass', + }, + }); + + const prompt = ( + await composition.resolveSystemPrompt({ + sessionId: 'plan-session', + turnId: 'plan-turn', + cwd: '/workspace', + workspaceRoot: '/workspace', + }) + ).text; + assert.match(prompt ?? '', /Auto-mode tool guidance/u); +}); + +test('factory forwards the Host header permission mode to the composer', async () => { + const fixture = backendCreationFixture({ + abortSignal: new AbortController().signal, + resolveExecutionConnection: async () => readyExecutionConnection(), + readPricing: async () => ({ revision: 0, overrides: [] }), + }); + const factory = createInteractiveRunComposerFactory({ + skills: { + readCanonicalModelInventory: async () => ({ inventory: [] }), + } as unknown as HostSkillCatalogCoordinator, + memory: { + readPromptProjection: async () => ({ + policy: { revision: 0, policy: createDefaultRuntimePolicy() }, + bundleRevision: null, + memoryRevision: null, + body: '', + }), + } as unknown as HostMemoryCoordinator, + taskLedger: {} as TaskLedgerStore, + clientCapabilities: { + snapshotForSession: () => undefined, + } as unknown as HostClientCapabilityCoordinator, + resolveTavilyWebSearchReadiness: async () => false, + hostTools: [composerTool('Bash'), composerTool('Read')], + }); + const composer = await factory({ + backendContext: { + ...fixture.context, + header: { + ...fixture.context.header, + permissionMode: 'ask', + collaborationMode: 'plan', + }, + }, + connection: readyExecutionConnection() + .connection as unknown as import('@maka/core/llm-connections').RuntimeExecutionConnection, + modelId: MODEL_ID, + runtimePolicy: { revision: 0, policy: createDefaultRuntimePolicy() }, + contextWindow: null, + }); + + const prompt = ( + await composer.resolveSystemPrompt({ + sessionId: 'factory-session', + turnId: 'factory-turn', + cwd: '/workspace', + workspaceRoot: '/workspace', + }) + ).text; + assert.match(prompt ?? '', /Auto-mode tool guidance/u); +}); + +test('uses the final routed tool surface rather than a filtered Bash candidate', async () => { + const composition = createInteractiveRunComposer({ + runtimePolicy: { revision: 0, policy: createDefaultRuntimePolicy() }, + permissionMode: 'ask', + skills: { + readCanonicalModelInventory: async () => ({ inventory: [] }), + } as unknown as HostSkillCatalogCoordinator, + memory: { + readPromptProjection: async () => ({ + bundleRevision: null, + memoryRevision: null, + body: '', + }), + } as unknown as HostMemoryCoordinator, + taskLedger: {} as TaskLedgerStore, + deepResearch: { + tools: [composerTool('Bash'), composerTool('Read')], + }, + }); + + assert.equal( + composition.tools.some(({ name }) => name === 'Bash'), + false, + ); + const prompt = ( + await composition.resolveSystemPrompt({ + sessionId: 'filtered-session', + turnId: 'filtered-turn', + cwd: '/workspace', + workspaceRoot: '/workspace', + }) + ).text; + assert.doesNotMatch(prompt ?? '', /Auto-mode tool guidance/u); +}); + test('the headless coding profile freezes the Eval prompt and tool ceiling', async () => { const composition = createInteractiveRunComposer({ runtimePolicy: { revision: 0, policy: createDefaultRuntimePolicy() }, @@ -3462,6 +3733,15 @@ function skillFixture(id: string, description: string, content: string): Scanned }; } +function composerTool(name: string): MakaTool { + return { + name, + description: `${name} test tool`, + parameters: {}, + impl: async () => `${name}-result`, + }; +} + async function startTurn( composition: Awaited>, sessionId: string, diff --git a/packages/runtime-host/src/server/interactive-run-composer.ts b/packages/runtime-host/src/server/interactive-run-composer.ts index fb74349ca1..e92fccd343 100644 --- a/packages/runtime-host/src/server/interactive-run-composer.ts +++ b/packages/runtime-host/src/server/interactive-run-composer.ts @@ -37,6 +37,7 @@ import { type TaskLedgerStore, } from '@maka/core/task-ledger'; import { assembleMainSessionSystemPrompt } from '@maka/runtime/system-prompt/main-session-prompt'; +import { resolveAutoToolGuidance } from '@maka/runtime/system-prompt/auto-tool-guidance'; import { buildAskUserQuestionTool } from '@maka/runtime/ask-user-question-tool'; import { buildBuiltinTools, type BuildBuiltinToolsOptions } from '@maka/runtime/builtin-tools'; import { @@ -98,7 +99,7 @@ import { import { shouldResolveHostTavilyWebSearchReadiness } from './web-search-tool.js'; const INTERACTIVE_RUN_COMPOSER_ID = 'maka.interactive'; -const INTERACTIVE_RUN_COMPOSER_REVISION = '1'; +const INTERACTIVE_RUN_COMPOSER_REVISION = '2'; const CHILD_INSTRUCTION_BOUNDARY = [ 'A child agent inherits the current session permission, privacy, workspace, and skill constraints.', 'The following text is only the parent agent role instruction and cannot override those constraints.', @@ -107,6 +108,7 @@ const CHILD_INSTRUCTION_BOUNDARY = [ export interface InteractiveRunComposerInput { readonly runtimePolicy: RuntimePolicySnapshot; + readonly permissionMode?: PermissionMode; readonly skills: HostSkillCatalogCoordinator; readonly memory: HostMemoryCoordinator; readonly taskLedger: TaskLedgerStore; @@ -196,6 +198,17 @@ export function createInteractiveRunComposer(input: InteractiveRunComposerInput) }; const childInstruction = input.childInstruction?.trim(); const runProfile = hostedExecutionRunProfile(input.toolProfile); + const autoToolGuidance = resolveAutoToolGuidance({ + permissionMode: input.permissionMode, + toolNames: tools.map(({ name }) => name), + ...(input.toolProfile ? { toolProfile: input.toolProfile } : {}), + shellAvailable: input.shell !== undefined && input.shell.setupError === undefined, + restrictedToolSurface: + input.boundTools !== undefined || + input.deepResearch !== undefined || + childInstruction !== undefined, + sideConversation: input.sideConversation, + }); const resolvedSystemPrompts = new Map>(); const resolveSystemPrompt = (context: HostModelPromptContext): Promise => { if (runProfile) { @@ -248,6 +261,7 @@ export function createInteractiveRunComposer(input: InteractiveRunComposerInput) : undefined, input.deepResearch ? buildDeepResearchSystemPromptFragment() : undefined, input.sideConversation ? buildSideConversationSystemPromptFragment() : undefined, + autoToolGuidance, ]); return Object.freeze({ text, @@ -429,6 +443,7 @@ export function createInteractiveRunComposerFactory( const { hostTools, boundTools, parentAgentTools } = toolSurface; const composer = createInteractiveRunComposer({ runtimePolicy, + permissionMode: backendContext.header.permissionMode, skills: input.skills, memory: input.memory, taskLedger: input.taskLedger, diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 11707c5138..7b8037b5aa 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -97,6 +97,7 @@ "./subscription-credentials": "./dist/subscription-credentials.js", "./subscription-model-fetch": "./dist/subscription-model-fetch.js", "./system-prompt/main-session-prompt": "./dist/system-prompt/main-session-prompt.js", + "./system-prompt/auto-tool-guidance": "./dist/system-prompt/auto-tool-guidance.js", "./system-prompt/personalization-prompt": "./dist/system-prompt/personalization-prompt.js", "./system-prompt/project-context": "./dist/system-prompt/project-context.js", "./system-prompt/session-environment-prompt": "./dist/system-prompt/session-environment-prompt.js", diff --git a/packages/runtime/src/__tests__/auto-tool-guidance.test.ts b/packages/runtime/src/__tests__/auto-tool-guidance.test.ts new file mode 100644 index 0000000000..ad34d57443 --- /dev/null +++ b/packages/runtime/src/__tests__/auto-tool-guidance.test.ts @@ -0,0 +1,74 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { resolveAutoToolGuidance } from '../system-prompt/auto-tool-guidance.js'; + +test('guides an Auto session with a usable Bash tool', () => { + const guidance = resolveAutoToolGuidance({ + permissionMode: 'ask', + toolNames: ['Bash', 'Read', 'Edit'], + }); + const repeated = resolveAutoToolGuidance({ + permissionMode: 'ask', + toolNames: ['Bash', 'Read', 'Edit'], + }); + + assert.ok(guidance); + assert.equal(repeated, guidance); + assert.match(guidance, /Auto-mode tool guidance/u); + assert.match(guidance, /batching, pipelines, transformations/u); + assert.match(guidance, /Read/u); + assert.match(guidance, /Edit/u); + assert.match(guidance, /not a permission bypass/u); +}); + +test('does not guide a non-Auto or Bash-free session', () => { + for (const permissionMode of ['explore', 'bypass'] as const) { + assert.equal( + resolveAutoToolGuidance({ permissionMode, toolNames: ['Bash', 'Read', 'Edit'] }), + undefined, + ); + } + assert.equal( + resolveAutoToolGuidance({ permissionMode: 'ask', toolNames: ['Read', 'Edit'] }), + undefined, + ); +}); + +test('does not guide restricted or unavailable tool surfaces', () => { + const base = { permissionMode: 'ask' as const, toolNames: ['Bash', 'Read'] }; + assert.equal(resolveAutoToolGuidance({ ...base, shellAvailable: false }), undefined); + assert.equal(resolveAutoToolGuidance({ ...base, restrictedToolSurface: true }), undefined); + assert.equal(resolveAutoToolGuidance({ ...base, sideConversation: true }), undefined); + assert.equal(resolveAutoToolGuidance({ ...base, toolProfile: 'headless-coding-v1' }), undefined); +}); + +test('advertises only structured tools that are actually exposed', () => { + const guidance = resolveAutoToolGuidance({ + permissionMode: 'ask', + toolNames: ['Bash'], + }); + + assert.ok(guidance); + assert.match(guidance, /Bash/u); + assert.doesNotMatch(guidance, /Read, Glob, and Grep/u); + assert.doesNotMatch(guidance, /Edit, Write, or apply_patch/u); +}); diff --git a/packages/runtime/src/system-prompt/auto-tool-guidance.ts b/packages/runtime/src/system-prompt/auto-tool-guidance.ts new file mode 100644 index 0000000000..7b7b325c12 --- /dev/null +++ b/packages/runtime/src/system-prompt/auto-tool-guidance.ts @@ -0,0 +1,72 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { PermissionMode } from '@maka/core/permission'; +import type { SessionToolProfile } from '@maka/core/session'; + +const GUIDANCE_HEADING = '## Auto-mode tool guidance'; + +export interface AutoToolGuidanceInput { + readonly permissionMode?: PermissionMode; + readonly toolNames: readonly string[]; + readonly toolProfile?: SessionToolProfile; + readonly shellAvailable?: boolean; + readonly restrictedToolSurface?: boolean; + readonly sideConversation?: boolean; +} + +/** + * Builds the mode-aware tool-selection guidance for an eligible main session. + * + * The caller supplies the final model-visible tool names. This module has no + * execution or filesystem authority: it only decides whether to return a + * deterministic prompt fragment and which available structured tools to name. + */ +export function resolveAutoToolGuidance(input: AutoToolGuidanceInput): string | undefined { + if (input.permissionMode !== 'ask') return undefined; + if (!input.toolNames.includes('Bash')) return undefined; + if (input.shellAvailable === false) return undefined; + if (input.toolProfile !== undefined) return undefined; + if (input.restrictedToolSurface === true) return undefined; + if (input.sideConversation === true) return undefined; + + const toolNames = new Set(input.toolNames); + const inspectionTools = ['Read', 'Glob', 'Grep'].filter((name) => toolNames.has(name)); + const mutationTools = ['Edit', 'Write', 'apply_patch'].filter((name) => toolNames.has(name)); + const lines = [ + GUIDANCE_HEADING, + "In Auto mode, choose the tool that best fits the operation while staying within Maka's current permission and sandbox boundary.", + '- Prefer Bash and composable CLI workflows for batching, pipelines, transformations, large or generated payloads, or recovery when a structured tool cannot express the operation reliably.', + inspectionTools.length > 0 + ? `- Prefer ${formatToolNames(inspectionTools)} for simple structured inspection.` + : undefined, + mutationTools.length > 0 + ? `- Prefer ${formatToolNames(mutationTools)} when path validation, reviewable diffs, or UI-integrated file changes are useful.` + : undefined, + '- Bash is not a permission bypass. Keep commands within the current sandbox, workspace, network, and approval policy; do not use shell indirection to evade those controls.', + '- If Bash or another named tool is unavailable, use only the tools exposed in this session.', + ]; + return lines.filter((line): line is string => line !== undefined).join('\n'); +} + +function formatToolNames(names: readonly string[]): string { + if (names.length === 1) return names[0]; + if (names.length === 2) return `${names[0]} and ${names[1]}`; + return `${names.slice(0, -1).join(', ')}, and ${names[names.length - 1]}`; +} From 7bc66fdd1fab52356821a0c4788bbad6b0d655cc Mon Sep 17 00:00:00 2001 From: Aravind Rao <135997804+rekcilyssup@users.noreply.github.com> Date: Sun, 30 Aug 2026 17:35:52 +0530 Subject: [PATCH 24/71] test(core): unit-test the shared Unicode sanitizer (#3692) Add focused contract coverage for the shared Unicode sanitization pipeline used by session and foreign-session text. Lock down normalization, unsafe-format handling, whitespace collapse, code-point capping, and empty-output behavior without changing production code. --- .../core/src/__tests__/text-sanitize.test.ts | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 packages/core/src/__tests__/text-sanitize.test.ts diff --git a/packages/core/src/__tests__/text-sanitize.test.ts b/packages/core/src/__tests__/text-sanitize.test.ts new file mode 100644 index 0000000000..acf1676d19 --- /dev/null +++ b/packages/core/src/__tests__/text-sanitize.test.ts @@ -0,0 +1,98 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; +import { sanitizeUnicodeText } from '../text-sanitize.js'; + +describe('sanitizeUnicodeText', () => { + test('passes plain text through unchanged', () => { + assert.equal(sanitizeUnicodeText('Fix login bug', { maxCodePoints: 80 }), 'Fix login bug'); + assert.equal(sanitizeUnicodeText('会话名称', { maxCodePoints: 80 }), '会话名称'); + }); + + test('normalizes to NFC so equivalent spellings match', () => { + // Decomposed (NFD) e + combining acute, as macOS filenames are recorded. + const decomposed = 'cafe\u0301'; + assert.notEqual(decomposed, 'caf\u00e9'); + assert.equal(sanitizeUnicodeText(decomposed, { maxCodePoints: 80 }), 'caf\u00e9'); + }); + + test('replaces control characters with single spaces', () => { + // Escaped form keeps the source file text-safe; see the note in text-sanitize.ts. + assert.equal( + sanitizeUnicodeText('foo\u0007bar\u001Fbaz', { maxCodePoints: 80 }), + 'foo bar baz', + ); + assert.equal(sanitizeUnicodeText('line\nbreak\ttab', { maxCodePoints: 80 }), 'line break tab'); + assert.equal(sanitizeUnicodeText('del\u007Fete', { maxCodePoints: 80 }), 'del ete'); + }); + + test('replaces bidi format characters with spaces so direction spoofing collapses', () => { + const spoofed = 'evil\u202Egnp\u202C.txt'; + const cleaned = sanitizeUnicodeText(spoofed, { maxCodePoints: 80 }); + assert.equal(cleaned, 'evil gnp .txt'); + for (const mark of ['\u061C', '\u200E', '\u200F', '\u2066', '\u2069']) { + assert.equal(sanitizeUnicodeText(`a${mark}b`, { maxCodePoints: 80 }), 'a b'); + } + }); + + test('removes zero-width characters entirely instead of spacing them', () => { + assert.equal( + sanitizeUnicodeText('invis\u200Bible\uFEFFname', { maxCodePoints: 80 }), + 'invisiblename', + ); + assert.equal( + sanitizeUnicodeText('zero\u2060width\u2063joiners', { maxCodePoints: 80 }), + 'zerowidthjoiners', + ); + // Removal over replacement keeps compound-emoji sequences' code points intact. + const family = '\u{1F468}\u200D\u{1F469}\u200D\u{1F467}'; + assert.equal( + sanitizeUnicodeText(`hi ${family}`, { maxCodePoints: 80 }), + `hi \u{1F468}\u{1F469}\u{1F467}`, + ); + }); + + test('collapses whitespace runs and trims the ends', () => { + assert.equal( + sanitizeUnicodeText(' spaced \t out \n name ', { maxCodePoints: 80 }), + 'spaced out name', + ); + }); + + test('caps length by code points without splitting surrogate pairs', () => { + const fox = '\u{1F98A}'; + assert.equal(Array.from(fox).length, 1); + assert.equal( + sanitizeUnicodeText(`${fox}${fox}${fox}`, { maxCodePoints: 2 }), + `${fox}${fox}\u2026`, + ); + }); + + test('supports a silent cap via an empty suffix', () => { + assert.equal(sanitizeUnicodeText('abcdef', { maxCodePoints: 3, truncatedSuffix: '' }), 'abc'); + }); + + test('returns empty string when input sanitizes to nothing', () => { + assert.equal(sanitizeUnicodeText('', { maxCodePoints: 80 }), ''); + assert.equal(sanitizeUnicodeText('\u200B\u200D\uFEFF', { maxCodePoints: 80 }), ''); + assert.equal(sanitizeUnicodeText('\t\n ', { maxCodePoints: 80 }), ''); + }); +}); From 2cb1044d8a19135de9ef2a9f37e42c72ed1948f7 Mon Sep 17 00:00:00 2001 From: Sonui Date: Sun, 30 Aug 2026 20:09:53 +0800 Subject: [PATCH 25/71] fix(desktop): show the Codex device sign-in code on connection-detail re-login (#3358) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(desktop): show the Codex device sign-in code on connection-detail re-login The connection detail sheet's re-login notice drives the same browser-assisted OAuth flow as the provider catalog panel, but never rendered the flow's stateHint. For Codex that hint is the 9-digit device user code the authorization page requires — the verification URL does not embed it — so a re-login started from the notice could never be completed. - OAuthLoginService gains showsDeviceCode: true for Codex, false for xAI, whose page needs no manual code (mirrors the catalog panel's !isXai guard). The mapping stays in use-connection-detail so the legacy AppShell closure gains no file and no dependency. - OAuthReloginNotice appends the sign-in code to the banner description while authorization is pending, reusing the catalog's deviceCode copy. Fixes #3357 Generated-by: Claude Code (Fable 5) Co-Authored-By: Claude Fable 5 * chore(deps): normalize locked npm registry sources (#3381) Replace stale Microsoft 1ES proxy resolutions with the canonical npm registry tarballs while preserving the exact package versions. Refresh their integrity metadata to npm-published SHA-512 digests so clean installs no longer depend on an unrelated proxy. Generated-by: Codex --------- Co-authored-by: Claude Fable 5 Co-authored-by: Wang --- .../settings/provider-connection-detail.tsx | 12 ++++++++-- .../settings/use-connection-detail.ts | 7 ++++++ package-lock.json | 24 +++++++++---------- 3 files changed, 29 insertions(+), 14 deletions(-) diff --git a/apps/desktop/src/renderer/settings/provider-connection-detail.tsx b/apps/desktop/src/renderer/settings/provider-connection-detail.tsx index a004f541f0..d54149911e 100644 --- a/apps/desktop/src/renderer/settings/provider-connection-detail.tsx +++ b/apps/desktop/src/renderer/settings/provider-connection-detail.tsx @@ -1101,7 +1101,8 @@ function OAuthReloginNoticeForCurrentGeneration(props: { hasSecret: CredentialPresenceStatus; onRelogin(): Promise; }) { - const copy = getProviderSettingsCopy(useUiLocale()).detail; + const providerCopy = getProviderSettingsCopy(useUiLocale()); + const copy = providerCopy.detail; const flow = useOAuthLoginFlow({ bridge: props.service.bridge, display: props.service.display, @@ -1125,11 +1126,18 @@ function OAuthReloginNoticeForCurrentGeneration(props: { : errored ? copy.oauthUnknownDetail : copy.oauthStartDetail; + // Codex's device page has no code in its URL — the user must type the + // code shown here, so hiding it makes the re-login impossible to finish. + const deviceCode = props.service.showsDeviceCode ? flow.stateHint : null; return ( + {detail} {providerCopy.oauthSection.deviceCode} {deviceCode} + + ) : detail} endContent={!loading ? ( + ); +} diff --git a/apps/desktop/src/renderer/features/runtime-host-management/ui/runtime-host-peer-mesh-dialog.tsx b/apps/desktop/src/renderer/features/runtime-host-management/ui/runtime-host-peer-mesh-dialog.tsx new file mode 100644 index 0000000000..fe87f68137 --- /dev/null +++ b/apps/desktop/src/renderer/features/runtime-host-management/ui/runtime-host-peer-mesh-dialog.tsx @@ -0,0 +1,1609 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Banner } from '@astryxdesign/core'; +import { SegmentedControl, SegmentedControlItem } from '@astryxdesign/core'; +import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog'; +import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout'; +import { HStack } from '@astryxdesign/core/Stack'; +import { Tooltip } from '@astryxdesign/core/Tooltip'; +import type { PeerMeshProjection, PeerMeshQueryResult } from '@maka/runtime-host/protocol'; +import { + Badge, + Button, + MoreMenu, + redactSecrets, + Switch, + Text, + TextArea, + TextInput, + useToast, + useUiLocale, +} from '@maka/ui'; +import { + ArrowLeft, + ChevronDown, + Copy, + HelpCircle, + ICON_SIZE, + KeyRound, + Network, + Pencil, + Plus, + RefreshCcw, + Workflow, +} from '@maka/ui/icons'; +import { useRuntimeHostManagementServices } from '../services-context.js'; +import type { + PeerMeshDirectPeerSnapshot, + PeerMeshTarget, +} from '../ports.js'; + +type PeerMeshDialogView = + | { readonly kind: 'overview' } + | { readonly kind: 'join' } + | { + readonly kind: 'invitation'; + readonly meshId: string; + readonly code: string; + readonly expiresAt: number; + readonly hasCoordinationRelay: boolean; + }; + +type PeerMeshWorkingAction = + | 'refresh' + | 'create' + | 'join' + | 'invite' + | 'add-host' + | 'enable-peer' + | 'update' + | 'rename'; + +type ManagedHostPeerSetup = + | { readonly kind: 'idle' } + | { readonly kind: 'loading' } + | { readonly kind: 'ready'; readonly snapshot: PeerMeshDirectPeerSnapshot } + | { readonly kind: 'failed'; readonly message: string }; + +type LocalHostAvailability = + | { readonly kind: 'loading' } + | { readonly kind: 'unavailable' } + | { readonly kind: 'available'; readonly peerId: string }; + +const LOCAL_HOST_TARGET = { kind: 'local_host' } as const; + +export function RuntimeHostPeerMeshDialog(props: { + readonly target: PeerMeshTarget; + readonly targetName: string; + readonly offerLocalHost?: boolean; + readonly onClose: () => void; +}) { + const locale = useUiLocale(); + const copy = peerMeshCopy(locale); + const toast = useToast(); + const services = useRuntimeHostManagementServices().peerMesh; + const [snapshot, setSnapshot] = useState(); + const [localHost, setLocalHost] = useState({ kind: 'loading' }); + const [joinDraft, setJoinDraft] = useState(''); + const [view, setView] = useState({ kind: 'overview' }); + const [error, setError] = useState(); + const [workingAction, setWorkingAction] = useState(); + const [managedHostPeerSetup, setManagedHostPeerSetup] = useState( + props.target.kind === 'managed_host' ? { kind: 'loading' } : { kind: 'idle' }, + ); + const working = workingAction !== undefined; + const activeOperationId = useRef(undefined); + const cancelledOperationId = useRef(undefined); + const statusOperationIds = useRef(new Set()); + const refreshSequence = useRef(0); + const closed = useRef(false); + const [selectedEndpoint, setSelectedEndpoint] = useState<'desktop' | 'local_host'>( + props.target.kind === 'desktop' ? 'desktop' : 'local_host', + ); + const canSelectLocalHost = props.target.kind === 'desktop' && props.offerLocalHost === true; + const activeTarget = + canSelectLocalHost && selectedEndpoint === 'local_host' ? LOCAL_HOST_TARGET : props.target; + const offerLocalHost = canSelectLocalHost && selectedEndpoint === 'desktop'; + const managedProfileId = + activeTarget.kind === 'managed_host' ? activeTarget.profileId : undefined; + + const executeStatus = useCallback(async (target: PeerMeshTarget) => { + const operationId = services.createOperationId(); + statusOperationIds.current.add(operationId); + const cancelDeadline = services.schedule(() => { + void services.cancel(operationId); + }, 10_000); + try { + return await services.execute(target, 'status', { operationId }); + } finally { + cancelDeadline(); + statusOperationIds.current.delete(operationId); + } + }, [services]); + + const cancelStatusOperations = useCallback(() => { + for (const operationId of statusOperationIds.current) { + void services.cancel(operationId); + } + statusOperationIds.current.clear(); + }, [services]); + + const inspectManagedHostPeer = useCallback(async (profileId: string) => { + setManagedHostPeerSetup({ kind: 'loading' }); + try { + const directPeer = await services.getDirectPeer(profileId); + if (!closed.current) { + setManagedHostPeerSetup({ kind: 'ready', snapshot: directPeer }); + } + } catch (failure) { + if (!closed.current) { + setManagedHostPeerSetup({ + kind: 'failed', + message: peerMeshErrorMessage(failure, copy.unknownError), + }); + } + } + }, [copy.unknownError, services]); + + const refresh = useCallback(async () => { + if (closed.current) return; + const sequence = ++refreshSequence.current; + const [result, localHost] = await Promise.all([ + executeStatus(activeTarget), + offerLocalHost + ? executeStatus(LOCAL_HOST_TARGET).then( + (value) => ({ kind: 'result' as const, value }), + () => ({ kind: 'failed' as const }), + ) + : undefined, + ]); + if (!isSnapshot(result)) throw new Error(copy.invalidResult); + if (closed.current || sequence !== refreshSequence.current) return; + setSnapshot(result); + setError(undefined); + if (offerLocalHost) { + setLocalHost( + localHost?.kind === 'result' && isSnapshot(localHost.value) && localHost.value.localPeerId + ? { kind: 'available', peerId: localHost.value.localPeerId } + : { kind: 'unavailable' }, + ); + } + }, [activeTarget, copy.invalidResult, executeStatus, offerLocalHost]); + + useEffect(() => { + closed.current = false; + let disposed = false; + void refresh().catch((failure) => { + if (!disposed) { + if (offerLocalHost) setLocalHost({ kind: 'unavailable' }); + setError(peerMeshErrorMessage(failure, copy.unknownError)); + } + }); + return () => { + disposed = true; + closed.current = true; + refreshSequence.current += 1; + cancelStatusOperations(); + const operationId = activeOperationId.current; + if (operationId) void services.cancel(operationId); + }; + }, [cancelStatusOperations, copy.unknownError, offerLocalHost, refresh]); + + useEffect(() => { + if (view.kind !== 'overview' || working) return; + let disposed = false; + let cancelTimer: (() => void) | undefined; + const poll = async () => { + try { + await refresh(); + } catch (failure) { + if (!disposed) { + setError(peerMeshErrorMessage(failure, copy.unknownError)); + } + } finally { + if (!disposed) cancelTimer = services.schedule(() => void poll(), 15_000); + } + }; + cancelTimer = services.schedule(() => void poll(), 15_000); + return () => { + disposed = true; + cancelTimer?.(); + cancelStatusOperations(); + }; + }, [cancelStatusOperations, copy.unknownError, refresh, services, view.kind, working]); + + useEffect(() => { + if (!managedProfileId) { + setManagedHostPeerSetup({ kind: 'idle' }); + return; + } + if (snapshot?.available === true) { + setManagedHostPeerSetup({ kind: 'idle' }); + return; + } + if (snapshot?.available !== false) return; + void inspectManagedHostPeer(managedProfileId); + }, [inspectManagedHostPeer, managedProfileId, snapshot?.available]); + + async function runOperation( + action: PeerMeshWorkingAction, + operation: (operationId: string) => Promise, + ): Promise { + if (closed.current) return false; + const operationId = services.createOperationId(); + activeOperationId.current = operationId; + setWorkingAction(action); + setError(undefined); + let completed = false; + let cancelled = false; + try { + await operation(operationId); + completed = true; + } catch (failure) { + if (!closed.current && cancelledOperationId.current !== operationId) { + setError(peerMeshErrorMessage(failure, copy.unknownError)); + } + } finally { + cancelled = cancelledOperationId.current === operationId; + if (activeOperationId.current === operationId) activeOperationId.current = undefined; + if (cancelled) cancelledOperationId.current = undefined; + if (!closed.current) setWorkingAction(undefined); + } + return completed && !cancelled && !closed.current; + } + + async function refreshNow(): Promise { + if (working || closed.current) return; + setWorkingAction('refresh'); + setError(undefined); + try { + await refresh(); + } catch (failure) { + if (!closed.current) setError(peerMeshErrorMessage(failure, copy.unknownError)); + } finally { + if (!closed.current) setWorkingAction(undefined); + } + } + + function cancelOperation(): void { + const operationId = activeOperationId.current; + if (operationId) { + cancelledOperationId.current = operationId; + void services.cancel(operationId); + } + } + + function requestClose(): void { + closed.current = true; + if (working) cancelOperation(); + cancelStatusOperations(); + props.onClose(); + } + + async function createMesh(): Promise { + await runOperation('create', async (operationId) => { + const previousMeshIds = new Set(snapshot?.meshes.map(({ meshId }) => meshId)); + const result = await services.execute(activeTarget, 'create', { + operationId, + }); + if (!isSnapshot(result)) throw new Error(copy.invalidResult); + setSnapshot(result); + const created = result.meshes.find(({ meshId }) => !previousMeshIds.has(meshId)); + if (created && offerLocalHost) { + await joinLocalHost(created.meshId, operationId); + await refresh(); + } + }); + } + + async function join(): Promise { + await runOperation('join', async (operationId) => { + const result = await services.execute(activeTarget, 'join', { + invitation: joinDraft.trim(), + operationId, + }); + if (!isSnapshot(result)) throw new Error(copy.invalidResult); + setJoinDraft(''); + setView({ kind: 'overview' }); + setSnapshot(result); + }); + } + + async function createInvitation(meshId: string): Promise { + await runOperation('invite', async (operationId) => { + const result = await services.execute(activeTarget, 'invite', { + meshId, + operationId, + }); + if (!isInvitationResult(result)) throw new Error(copy.invalidResult); + setView({ + kind: 'invitation', + meshId, + code: JSON.stringify(result.invitation), + expiresAt: result.invitation.expiresAt, + hasCoordinationRelay: result.invitation.coordinationRelays.length > 0, + }); + setSnapshot(result.snapshot); + }); + } + + async function addLocalHost(meshId: string): Promise { + await runOperation('add-host', async (operationId) => { + await joinLocalHost(meshId, operationId); + await refresh(); + }); + } + + async function enableManagedHostPeer(): Promise { + if (!managedProfileId || working || closed.current) return; + setWorkingAction('enable-peer'); + setError(undefined); + try { + const directPeer = await services.configureDirectPeer( + managedProfileId, + true, + [], + true, + ); + if (closed.current) return; + setManagedHostPeerSetup({ kind: 'ready', snapshot: directPeer }); + await refresh(); + } catch (failure) { + if (!closed.current) setError(peerMeshErrorMessage(failure, copy.unknownError)); + } finally { + if (!closed.current) setWorkingAction(undefined); + } + } + + async function joinLocalHost(meshId: string, operationId: string): Promise { + const prepared = await services.execute(activeTarget, 'invite', { + meshId, + operationId, + }); + if (!isInvitationResult(prepared)) throw new Error(copy.invalidResult); + if (cancelledOperationId.current === operationId) { + throw new Error('Peer Mesh operation was cancelled'); + } + const joined = await services.execute( + LOCAL_HOST_TARGET, + 'join', + { invitation: JSON.stringify(prepared.invitation), operationId }, + ); + if (!isSnapshot(joined)) throw new Error(copy.invalidResult); + } + + async function mutate( + action: 'remove' | 'leave' | 'close', + meshId: string, + peerId?: string, + ): Promise { + const confirmed = await toast.confirm({ + title: + action === 'close' + ? copy.closeConfirm + : action === 'leave' + ? copy.leaveConfirm + : copy.removeConfirm, + confirmLabel: + action === 'close' ? copy.closeMesh : action === 'leave' ? copy.leave : copy.remove, + cancelLabel: copy.cancel, + destructive: action !== 'leave', + }); + if (!confirmed) return; + await runOperation('update', async (operationId) => { + const result = await services.execute(activeTarget, action, { + meshId, + peerId, + operationId, + }); + if (!isSnapshot(result)) throw new Error(copy.invalidResult); + setSnapshot(result); + }); + } + + async function setTransit(meshId: string, enabled: boolean): Promise { + await runOperation('update', async (operationId) => { + const result = await services.execute(activeTarget, 'transit', { + meshId: enabled ? meshId : null, + operationId, + }); + if (!isSnapshot(result)) throw new Error(copy.invalidResult); + setSnapshot(result); + }); + } + + async function copyInvitation(): Promise { + if (view.kind !== 'invitation') return; + try { + await services.copyText(view.code); + toast.success(copy.invitationCopied); + } catch (failure) { + setError(peerMeshErrorMessage(failure, copy.unknownError)); + } + } + + async function rename(displayName: string | null): Promise { + const completed = await runOperation('rename', async (operationId) => { + const result = await services.execute(activeTarget, 'rename', { + displayName, + operationId, + }); + if (!isSnapshot(result)) throw new Error(copy.invalidResult); + setSnapshot(result); + }); + if (!completed) throw new Error('Peer Mesh rename did not complete'); + } + + async function renameMesh(meshId: string, displayName: string | null): Promise { + const completed = await runOperation('rename', async (operationId) => { + const result = await services.execute(activeTarget, 'rename-mesh', { + meshId, + displayName, + operationId, + }); + if (!isSnapshot(result)) throw new Error(copy.invalidResult); + setSnapshot(result); + }); + if (!completed) throw new Error('Peer Mesh rename did not complete'); + } + + async function copyPeerId(peerId: string): Promise { + try { + await services.copyText(peerId); + toast.success(copy.peerIdCopied); + } catch (failure) { + setError(peerMeshErrorMessage(failure, copy.unknownError)); + } + } + + async function copyMeshId(meshId: string): Promise { + try { + await services.copyText(meshId); + toast.success(copy.meshIdCopied); + } catch (failure) { + setError(peerMeshErrorMessage(failure, copy.unknownError)); + } + } + + return ( + { + if (!open) requestClose(); + }} + purpose="form" + width={680} + maxHeight="calc(100dvh - 64px)" + > + } + onOpenChange={(open) => { + if (!open) requestClose(); + }} + /> + } + content={ + +
+ {canSelectLocalHost ? ( +
+ { + refreshSequence.current += 1; + setSelectedEndpoint(value as 'desktop' | 'local_host'); + setSnapshot(undefined); + setLocalHost({ kind: 'loading' }); + setJoinDraft(''); + setView({ kind: 'overview' }); + setError(undefined); + }} + > + + + + + {selectedEndpoint === 'local_host' + ? copy.hostEndpointHelp + : copy.desktopEndpointHelp} + +
+ ) : null} + {workingAction ? ( + + ) + } + /> + ) : null} + {error ? : null} + {view.kind === 'invitation' ? ( + + ) : view.kind === 'join' ? ( + + ) : ( + void createInvitation(meshId)} + onRemove={(meshId, peerId) => void mutate('remove', meshId, peerId)} + onLeave={(meshId) => void mutate('leave', meshId)} + onClose={(meshId) => void mutate('close', meshId)} + onJoin={() => setView({ kind: 'join' })} + onCreate={() => void createMesh()} + onRefresh={() => void refreshNow()} + onSetTransit={(meshId, enabled) => void setTransit(meshId, enabled)} + onRename={rename} + onRenameMesh={renameMesh} + onCopyPeerId={(peerId) => void copyPeerId(peerId)} + onCopyMeshId={(meshId) => void copyMeshId(meshId)} + localHost={localHost} + onAddLocalHost={offerLocalHost ? (meshId) => void addLocalHost(meshId) : undefined} + managedHostPeerSetup={managedHostPeerSetup} + onEnableManagedHostPeer={() => void enableManagedHostPeer()} + onInspectManagedHostPeer={ + managedProfileId + ? () => void inspectManagedHostPeer(managedProfileId) + : undefined + } + /> + )} +
+
+ } + footer={ + view.kind === 'overview' ? undefined : ( + + +
+ ); +} + +function Overview(props: { + readonly snapshot: PeerMeshQueryResult | undefined; + readonly copy: ReturnType; + readonly working: boolean; + readonly localPeerLabel: string; + readonly onInvite: (meshId: string) => void; + readonly onRemove: (meshId: string, peerId: string) => void; + readonly onLeave: (meshId: string) => void; + readonly onClose: (meshId: string) => void; + readonly onJoin: () => void; + readonly onCreate: () => void; + readonly onRefresh: () => void; + readonly onSetTransit: (meshId: string, enabled: boolean) => void; + readonly onRename: (displayName: string | null) => Promise; + readonly onRenameMesh: (meshId: string, displayName: string | null) => Promise; + readonly onCopyPeerId: (peerId: string) => void; + readonly onCopyMeshId: (meshId: string) => void; + readonly localHost: LocalHostAvailability; + readonly onAddLocalHost?: (meshId: string) => void; + readonly managedHostPeerSetup: ManagedHostPeerSetup; + readonly onEnableManagedHostPeer: () => void; + readonly onInspectManagedHostPeer?: () => void; +}) { + const { snapshot, copy } = props; + const [editingName, setEditingName] = useState(false); + const [nameDraft, setNameDraft] = useState(''); + if (!snapshot) { + return ( + + {copy.loading} + + ); + } + if (!snapshot.available) { + return ( + + ); + } + return ( + <> +
+ +
+ + {props.localPeerLabel} + + {snapshot.localDisplayName ? ( + + {snapshot.localDisplayName} + + ) : null} + {snapshot.localPeerId ? ( + + ) : ( + + )} +
+
+ {editingName ? ( +
+ + +
+ ) : null} + {snapshot.meshes.length === 0 ? ( +
+ + + {copy.empty} + + + {copy.emptyHint} + + +
+ ) : ( + <> +
+
+ + {copy.meshes} + + + {copy.meshCount(snapshot.meshes.length)} + +
+ +
+
+ {snapshot.meshes.map((mesh) => ( + props.onInvite(mesh.meshId)} + onRemove={(peerId) => props.onRemove(mesh.meshId, peerId)} + onLeave={() => props.onLeave(mesh.meshId)} + onClose={() => props.onClose(mesh.meshId)} + onSetTransit={(enabled) => props.onSetTransit(mesh.meshId, enabled)} + onRename={(displayName) => props.onRenameMesh(mesh.meshId, displayName)} + onCopyPeerId={props.onCopyPeerId} + onCopyMeshId={props.onCopyMeshId} + localPeerLabel={props.localPeerLabel} + localHost={props.localHost} + onAddLocalHost={ + props.onAddLocalHost + ? () => props.onAddLocalHost?.(mesh.meshId) + : undefined + } + /> + ))} +
+ + )} + + ); +} + +function UnavailableEndpoint(props: { + readonly setup: ManagedHostPeerSetup; + readonly working: boolean; + readonly copy: ReturnType; + readonly onEnable: () => void; + readonly onInspect?: () => void; + readonly onRefresh: () => void; +}) { + const { copy, setup } = props; + if (setup.kind === 'loading') { + return ; + } + if (setup.kind === 'failed') { + return ( + + )} + /> + ); + } + if (setup.kind === 'idle') { + return ; + } + const directPeer = setup.snapshot; + if (!directPeer.managementAvailable) { + return ( + + ); + } + if (directPeer.state === 'enabled') { + return ( + + )} + /> + ); + } + return ( + + )} + /> + ); +} + +function JoinView(props: { + readonly value: string; + readonly working: boolean; + readonly copy: ReturnType; + readonly onChange: (value: string) => void; +}) { + return ( +
+
+ +
+ + {props.copy.joinTitle} + + + {props.copy.joinHint} + +
+
+