From 4949c7f41ff84d87a2cda6c9515be2a59f7665c4 Mon Sep 17 00:00:00 2001 From: sunrioa <178722768+sunrioa@users.noreply.github.com> Date: Fri, 28 Aug 2026 12:20:32 +0800 Subject: [PATCH 1/4] feat(desktop): add host-bound folder references Add a separate Reference folder action and removable directory chips without changing the selected Project, Session cwd, or filesystem permissions. Prepare a bounded one-level directory observation at message admission and preserve it with Host-bound references through persistence, queued submission, and replay. Bump protocol compatibility to 57 and cover the contract with regression tests and Desktop E2E. Generated-by: OpenAI Codex --- .../e2e/composer-directory-reference.spec.ts | 77 +++++ apps/desktop/e2e/fixtures.ts | 22 +- .../__tests__/composer-directories.test.ts | 133 ++++++++ .../src/main/permission-response-guard.ts | 23 +- apps/desktop/src/main/runtime-host-boot.ts | 13 + ...runtime-host-session-execution-ipc-main.ts | 2 + apps/desktop/src/preload/bridge-contract.d.ts | 3 + apps/desktop/src/preload/preload.ts | 7 + .../src/renderer/app-shell-chat-actions.ts | 19 +- .../src/renderer/app-shell-session-events.ts | 1 + apps/desktop/src/renderer/app-shell.tsx | 40 ++- .../src/renderer/locales/conversation-copy.ts | 4 +- .../src/renderer/use-composer-directories.ts | 84 +++++ docs/astryx-surface-file-inventory.md | 3 +- docs/astryx-surface-file-inventory.paths | 1 + docs/windows-test-inventory.md | 5 +- .../__tests__/directory-references.test.ts | 113 +++++++ packages/core/src/events.ts | 37 ++- packages/core/src/runtime-event.ts | 4 + packages/core/src/session.ts | 21 +- .../src/__tests__/protocol.test.ts | 13 +- .../__tests__/root-turn-coordinator.test.ts | 201 +++++++++++- packages/runtime-host/src/protocol/index.ts | 4 +- packages/runtime-host/src/protocol/turn.ts | 4 + .../src/server/execution-composition.ts | 12 + .../server/interactive-turn-coordinator.ts | 8 +- .../src/server/root-admission-owner.ts | 2 + .../src/server/root-turn-coordinator.ts | 44 ++- packages/runtime/package.json | 1 + .../src/__tests__/directory-context.test.ts | 304 ++++++++++++++++++ .../filesystem-worker-client.test.ts | 50 +++ packages/runtime/src/agent-run.ts | 7 + packages/runtime/src/directory-context.ts | 127 ++++++++ .../runtime/src/filesystem-worker/client.ts | 9 +- .../runtime/src/runtime-event-backfill.ts | 3 + .../runtime/src/runtime-event-read-model.ts | 1 + packages/storage/src/agent-run-store.ts | 2 + .../src/__tests__/composer-plus-menu.test.tsx | 15 + packages/ui/src/chat-turn.tsx | 12 + packages/ui/src/chat-view.tsx | 1 + packages/ui/src/composer.tsx | 30 +- packages/ui/src/conversation-copy.ts | 9 +- packages/ui/src/directory-reference-chip.tsx | 42 +++ packages/ui/src/materialize.ts | 4 + 44 files changed, 1485 insertions(+), 32 deletions(-) create mode 100644 apps/desktop/e2e/composer-directory-reference.spec.ts create mode 100644 apps/desktop/src/main/__tests__/composer-directories.test.ts create mode 100644 apps/desktop/src/renderer/use-composer-directories.ts create mode 100644 packages/core/src/__tests__/directory-references.test.ts create mode 100644 packages/runtime/src/__tests__/directory-context.test.ts create mode 100644 packages/runtime/src/directory-context.ts create mode 100644 packages/ui/src/directory-reference-chip.tsx diff --git a/apps/desktop/e2e/composer-directory-reference.spec.ts b/apps/desktop/e2e/composer-directory-reference.spec.ts new file mode 100644 index 0000000000..5aa584f77e --- /dev/null +++ b/apps/desktop/e2e/composer-directory-reference.spec.ts @@ -0,0 +1,77 @@ +/* + * 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 { COMPOSER_INPUT, expect, test } from './fixtures'; + +test('a folder reference is removable, survives send/reload, and leaves project selection unchanged', async ({ + directoryReferenceWindow: { page, folder }, +}, testInfo) => { + const composer = page.locator(COMPOSER_INPUT); + const project = page.locator('button.maka-workspace-picker'); + // The composer can mount before TaskEntry loads the initial project selection. + // Compare the settled selection, not the generic label shown during loading. + const originalProject = '选择项目:无项目'; + await expect(project).toHaveAttribute('aria-label', originalProject); + const pick = async (keyboard = false) => { + const trigger = page.locator('.maka-composer-plus-menu button').first(); + await expect(trigger).toHaveAttribute('aria-expanded', 'false'); + if (keyboard) { + // Exercise keyboard reopening as well. Astryx intentionally ignores pointer + // reopening within 50ms of dismiss; the native chooser mock returns instantly. + await trigger.press('ArrowDown'); + } else { + await trigger.click(); + } + await expect(trigger).toHaveAttribute('aria-expanded', 'true'); + await page.getByRole('menuitem', { name: '引用文件夹', exact: true }).click(); + await expect(trigger).toHaveAttribute('aria-expanded', 'false'); + }; + + await pick(); + const chip = page.locator('.maka-composer-context-drawer .maka-composer-attachment-token'); + await expect(chip).toContainText('referenced-source'); + await chip.getByRole('button').click(); + await expect(chip).toHaveCount(0); + await pick(true); + await expect(chip).toContainText('referenced-source'); + await expect(project).toHaveAttribute('aria-label', originalProject); + await composer.fill('请检查引用目录'); + await page.screenshot({ path: testInfo.outputPath('directory-reference-staged.png') }); + await composer.press('Enter'); + + const user = page.getByLabel('你发送的消息').first(); + await expect(user).toContainText('请检查引用目录'); + await expect(user).toContainText('referenced-source'); + await expect(user).not.toContainText('README.md'); + const transcript = page.getByRole('log'); + await expect(transcript).toContainText('README.md'); + await expect(transcript).toContainText('"status":"listed"'); + await expect(transcript).not.toContainText('DO_NOT_READ_FILE_CONTENTS'); + await expect(transcript).not.toContainText('deep.txt'); + await expect(chip).toHaveCount(0); + await expect(page.getByRole('button', { name: '重新生成' })).toHaveCount(1, { timeout: 20_000 }); + + const sessions = await page.evaluate(() => window.maka.sessions.list()); + expect(sessions).toHaveLength(1); + expect(sessions[0]!.cwd).not.toBe(folder); + await page.reload(); + await expect(page.getByLabel('你发送的消息').first()).toContainText('referenced-source'); + await expect(page.getByRole('log')).toContainText('README.md'); + await page.screenshot({ path: testInfo.outputPath('directory-reference-sent.png') }); +}); diff --git a/apps/desktop/e2e/fixtures.ts b/apps/desktop/e2e/fixtures.ts index 85db3785c0..5438ecd987 100644 --- a/apps/desktop/e2e/fixtures.ts +++ b/apps/desktop/e2e/fixtures.ts @@ -371,7 +371,7 @@ async function withE2eWindow( parentRemovalSessions?: boolean; newTaskProject?: boolean; }, - use: (page: Page, context: { userDataDir: string }) => Promise, + use: (page: Page, context: { userDataDir: string; app: ElectronApplication }) => Promise, ): Promise { const userDataDir = await mkdtemp(path.join(tmpdir(), 'maka-e2e-')); // Lives inside the throwaway userData dir so the existing teardown removes @@ -437,7 +437,7 @@ async function withE2eWindow( const rendererDetail = rendererLogs.length > 0 ? `\nRenderer console:\n${rendererLogs.join('\n')}` : ''; throw new Error(`${detail}${mainDetail}${rendererDetail}`, { cause: error }); } - await use(page, { userDataDir }); + await use(page, { userDataDir, app }); } finally { try { if (app) await closeElectronApplication(app, 5_000); @@ -459,7 +459,25 @@ export const test = base.extend<{ promptRailMotionWindow: Page; requestHeaderRowWindow: Page; newTaskTargetWindow: Page; + directoryReferenceWindow: { page: Page; folder: string }; }>({ + directoryReferenceWindow: async ({}, use) => { + await withE2eWindow( + { seed: true, readinessSelector: COMPOSER_INPUT, locale: 'zh', showWindow: true }, + async (page, { userDataDir, app }) => { + const folder = path.join(userDataDir, 'referenced-source'); + await mkdir(path.join(folder, 'nested'), { recursive: true }); + await writeFile(path.join(folder, 'README.md'), 'DO_NOT_READ_FILE_CONTENTS'); + await writeFile(path.join(folder, 'nested', 'deep.txt'), 'DO_NOT_DESCEND'); + // Replace only the OS chooser. IPC, Host admission, filesystem reads, + // event persistence and rendering still run through the real stack. + await app.evaluate(({ dialog }, selectedPath) => { + dialog.showOpenDialog = async () => ({ canceled: false, filePaths: [selectedPath] }); + }, folder); + await use({ page, folder }); + }, + ); + }, // Seeded: a pre-staged connection clears onboarding so the composer is ready. window: async ({}, use) => { await withE2eWindow({ seed: true, readinessSelector: COMPOSER_INPUT, locale: 'zh' }, use); diff --git a/apps/desktop/src/main/__tests__/composer-directories.test.ts b/apps/desktop/src/main/__tests__/composer-directories.test.ts new file mode 100644 index 0000000000..d59de71a9f --- /dev/null +++ b/apps/desktop/src/main/__tests__/composer-directories.test.ts @@ -0,0 +1,133 @@ +/* + * 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 { afterEach, test } from 'node:test'; +import { act, createElement } from 'react'; +import { LocaleProvider } from '@maka/ui'; +import { normalizeSessionSendCommand } from '../permission-response-guard.js'; +import { useComposerDirectories } from '../../renderer/use-composer-directories.js'; +import { cleanupFakeDom, installReactRenderer } from './fake-dom.js'; + +afterEach(cleanupFakeDom); + +type Options = Parameters[0]; +type State = ReturnType; +const reference = { hostId: 'host-a', path: '/workspace/source' }; + +async function mount(initial: Partial = {}) { + const { root } = installReactRenderer(); + let state!: State; + const errors: string[] = []; + let options: Options = { + draftKey: 'draft-a', hostId: 'host-a', + pick: async () => ({ ok: true, reference }), + toastApi: { error: (title, description) => errors.push(description ?? title) }, + ...initial, + }; + function Probe() { + state = useComposerDirectories(options); + return null; + } + const render = async (patch: Partial = {}) => { + options = { ...options, ...patch }; + await act(() => root.render(createElement(LocaleProvider, { locale: 'en', children: createElement(Probe) }))); + }; + await render(); + return { state: () => state, render, errors }; +} + +test('directory picker cancellation, duplicates and removal leave the draft consistent', async () => { + const probe = await mount({ pick: async () => ({ ok: false, reason: 'cancelled' }) }); + await act(() => probe.state().pickDirectory!()); + assert.deepEqual(probe.state().pendingDirectories, []); + await probe.render({ pick: async () => ({ ok: true, reference }) }); + await act(() => probe.state().pickDirectory!()); + await act(() => probe.state().pickDirectory!()); + assert.deepEqual(probe.state().pendingDirectories, [reference]); + await act(() => probe.state().removeDirectory(0)); + assert.deepEqual(probe.state().pendingDirectories, []); + assert.deepEqual(probe.errors, []); +}); + +test('discards a picker reply after its draft or Host changes', async () => { + for (const patch of [{ draftKey: 'draft-b' }, { hostId: 'host-b' }]) { + let resolve!: (result: Awaited>) => void; + const pending = new Promise>>((settle) => { resolve = settle; }); + const probe = await mount({ pick: () => pending }); + let picked!: Promise; + await act(() => { picked = probe.state().pickDirectory!(); }); + await probe.render(patch); + await act(async () => { resolve({ ok: true, reference }); await picked; }); + assert.deepEqual(probe.state().pendingDirectories, []); + } +}); + +test('rejects a foreign Host picker result and does not pick without a local Host', async () => { + let picks = 0; + const probe = await mount({ hostId: undefined, pick: async () => { + picks += 1; + return { ok: true, reference: { ...reference, hostId: 'host-b' } }; + } }); + await act(() => probe.state().pickDirectory!()); + assert.equal(picks, 0); + await probe.render({ hostId: 'host-a' }); + await act(() => probe.state().pickDirectory!()); + assert.equal(probe.errors.length, 1); + assert.deepEqual(probe.state().pendingDirectories, []); +}); + +test('caps concurrent picker results and clearing a submitted draft keeps newer references', async () => { + let sequence = 0; + const probe = await mount({ pick: async () => ({ + ok: true, reference: { ...reference, path: '/workspace/' + ++sequence }, + }) }); + const pick = probe.state().pickDirectory!; + await act(() => Promise.all(Array.from({ length: 6 }, pick)).then(() => undefined)); + assert.equal(probe.state().pendingDirectories.length, 4); + assert.equal(probe.state().pickDirectory, undefined); + const submitted = probe.state().pendingDirectories; + const clearSubmitted = probe.state().clearSubmittedDirectories; + await act(() => probe.state().removeDirectory(0)); + await act(() => probe.state().pickDirectory!()); + await probe.render({ draftKey: 'draft-b' }); + await act(() => probe.state().pickDirectory!()); + await act(() => clearSubmitted(submitted)); + assert.equal(probe.state().pendingDirectories.length, 1, 'must not clear a different draft'); + await probe.render({ draftKey: 'draft-a' }); + assert.equal(probe.state().pendingDirectories.length, 1, 'must not clear a reference added after send'); +}); + +test('IPC validates directory references without turning them into attachments or permissions', () => { + const normalized = normalizeSessionSendCommand({ + type: 'send', text: 'inspect', directoryReferences: [reference], + }); + assert.deepEqual(normalized?.directoryReferences, [reference]); + assert.equal(normalized?.attachmentItems, undefined); + assert.notEqual(normalized?.directoryReferences?.[0], reference); + for (const references of [ + [{ ...reference, path: '../outside' }], + [{ ...reference, grant: 'read' }], + Array.from({ length: 5 }, () => reference), + ]) { + assert.throws(() => normalizeSessionSendCommand({ + type: 'send', text: 'inspect', directoryReferences: references, + }), /Invalid directory references/); + } +}); diff --git a/apps/desktop/src/main/permission-response-guard.ts b/apps/desktop/src/main/permission-response-guard.ts index 1bcbf68e0a..b3f9ec0861 100644 --- a/apps/desktop/src/main/permission-response-guard.ts +++ b/apps/desktop/src/main/permission-response-guard.ts @@ -23,7 +23,12 @@ import type { ReviseBeforeTurnInput, TurnOrchestration, } from '@maka/core/runtime-inputs'; -import type { QuoteRef } from '@maka/core/events'; +import { + isDirectoryReference, + DIRECTORY_REFERENCE_MAX_COUNT, + type DirectoryReference, + type QuoteRef, +} from '@maka/core/events'; import type { UserQuestionResponse } from '@maka/core/user-question'; import type { SandboxBoundaryResponse } from '@maka/core/sandbox-boundary'; import { MAX_ATTACHMENT_COUNT } from '@maka/core/attachments'; @@ -60,6 +65,7 @@ interface NormalizedSendSessionCommand { attachmentItems?: unknown; retainedAttachments?: AttachmentRef[]; turnOrchestration?: TurnOrchestration; + directoryReferences?: DirectoryReference[]; quotes?: QuoteRef[]; workspaceFileReferences?: WorkspaceFileReferencePosition[]; } @@ -189,6 +195,7 @@ export function normalizeSessionSendCommand(input: unknown): NormalizedSendSessi ...(value.turnOrchestration !== undefined ? { turnOrchestration: normalizeTurnOrchestration(value.turnOrchestration) } : {}), + ...normalizeOptionalDirectoryReferences(value.directoryReferences), ...normalizeOptionalQuotes(value.quotes), ...normalizeOptionalWorkspaceFileReferences( value.workspaceFileReferences, @@ -387,3 +394,17 @@ function normalizeOptionalSendTurnId(input: unknown): { turnId?: string } { turnId: normalizeRequiredString(input, 'Invalid send turnId', MAX_TURN_ID_LENGTH), }; } + +function normalizeOptionalDirectoryReferences( + input: unknown, +): { directoryReferences?: DirectoryReference[] } { + if (input === undefined) return {}; + if ( + !Array.isArray(input) || + input.length > DIRECTORY_REFERENCE_MAX_COUNT || + !input.every(isDirectoryReference) + ) { + throw new Error('Invalid directory references'); + } + return input.length ? { directoryReferences: input.map((ref) => ({ ...ref })) } : {}; +} diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index 21f1ba6707..f8562ba06d 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -1476,6 +1476,19 @@ function registerPersistentClientIpc(): void { }), ); registerDesktopDiagnosticsIpc({ ipcMain, ...desktopDiagnostics }); + ipcMain.handle('directories:pick', async () => { + const local = runtimeHostManager?.entries().find( + (state) => state.target.profile.kind === 'local', + ); + if (!local || local.readiness !== 'ready') throw new Error('Local Runtime Host is unavailable'); + const hostId = local.candidate.client.hostId; + const result = await mainWindowController.showOpenDialog({ + title: 'Reference folder', + properties: ['openDirectory'], + }); + if (result.canceled || !result.filePaths[0]) return { ok: false, reason: 'cancelled' }; + return { ok: true, reference: { hostId, path: result.filePaths[0] } }; + }); ipcMain.handle("attachments:pickFiles", async (event) => { const result = await mainWindowController.showOpenDialog({ title: "Add attachments", diff --git a/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts index d281173ac0..9d92764ed2 100644 --- a/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts @@ -340,6 +340,7 @@ export function registerRuntimeHostSessionExecutionIpc( ? { displayText: command.displayText } : {}), ...(attachments.length > 0 ? { attachments } : {}), + ...(command.directoryReferences ? { directoryReferences: command.directoryReferences } : {}), ...(command.quotes ? { quotes: command.quotes } : {}), inlineReferences, }, @@ -461,6 +462,7 @@ export function registerRuntimeHostSessionExecutionIpc( ? { displayText: command.displayText } : {}), ...(attachments.length > 0 ? { attachments } : {}), + ...(command.directoryReferences ? { directoryReferences: command.directoryReferences } : {}), ...(command.quotes ? { quotes: command.quotes } : {}), inlineReferences, }, diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index 877c9b65a5..c05eba96cd 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -883,6 +883,7 @@ export interface MakaBridge { attachmentItems?: RendererIngestInput[]; retainedAttachments?: import('@maka/core/events').AttachmentRef[]; turnOrchestration?: TurnOrchestration; + directoryReferences?: import('@maka/core/events').DirectoryReference[]; quotes?: import('@maka/core/events').QuoteRef[]; workspaceFileReferences?: Array< Pick @@ -953,6 +954,7 @@ export interface MakaBridge { turnOrchestration?: TurnOrchestration; attachmentItems?: RendererIngestInput[]; retainedAttachments?: import('@maka/core/events').AttachmentRef[]; + directoryReferences?: import('@maka/core/events').DirectoryReference[]; quotes?: import('@maka/core/events').QuoteRef[]; workspaceFileReferences?: Array< Pick @@ -1295,6 +1297,7 @@ export interface MakaBridge { openBackup(kind: 'save' | 'reset' | 'restore', host?: DesktopRuntimeHostRef): Promise<{ ok: true } | { ok: false; message: string }>; }; attachments: { + pickDirectory(): Promise<{ ok: true; reference: import('@maka/core/events').DirectoryReference } | { ok: false; reason: 'cancelled' }>; pickFiles(): Promise< | { ok: true; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index e5550f015e..80f6e00ddd 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -1691,6 +1691,9 @@ const makaBridge = { }, async send(sessionId, command) { const session = await runtimeHostSessionRef(sessionId); + if (command.directoryReferences?.some((ref) => ref.hostId !== session.scope.hostId)) { + throw new Error('Directory references belong to a different Runtime Host. Select the folder on the target Host.'); + } const encoded = 'attachmentItems' in command && command.attachmentItems ? { ...command, attachmentItems: await encodeIngestItems(command.attachmentItems) } @@ -1726,6 +1729,9 @@ const makaBridge = { }, async submitMessage(sessionId, placement, command) { const session = await runtimeHostSessionRef(sessionId); + if (command.directoryReferences?.some((ref) => ref.hostId !== session.scope.hostId)) { + throw new Error('Directory references belong to a different Runtime Host. Select the folder on the target Host.'); + } const attachmentItems = command.attachmentItems ? await encodeIngestItems(command.attachmentItems) : undefined; @@ -2534,6 +2540,7 @@ const makaBridge = { }, }, attachments: { + pickDirectory: () => ipcRenderer.invoke('directories:pick'), pickFiles(): Promise< | { ok: true; diff --git a/apps/desktop/src/renderer/app-shell-chat-actions.ts b/apps/desktop/src/renderer/app-shell-chat-actions.ts index 005013b326..1a4df9e7b4 100644 --- a/apps/desktop/src/renderer/app-shell-chat-actions.ts +++ b/apps/desktop/src/renderer/app-shell-chat-actions.ts @@ -20,7 +20,7 @@ import type { ChatDefaultPermissionMode } from '@maka/core/settings'; import type { CollaborationMode } from '@maka/core/collaboration'; import type { DesktopNewTaskTarget } from '../preload/bridge-contract.js'; -import type { InlineReference, QuoteRef } from '@maka/core/events'; +import type { DirectoryReference, InlineReference, QuoteRef } from '@maka/core/events'; import type { OrchestrationMode } from '@maka/core/orchestration'; import type { SandboxBoundaryResponse } from '@maka/core/sandbox-boundary'; import type { SkillInvocationResult } from '@maka/runtime/skill-invocation'; @@ -108,6 +108,7 @@ export interface AppShellChatActions { pending?: readonly PendingAttachment[], options?: { turnOrchestration?: TurnOrchestration; + directoryReferences?: readonly DirectoryReference[]; quotes?: readonly QuoteRef[]; workspaceFileReferences?: readonly WorkspaceFileReferencePosition[]; displayText?: string; @@ -125,6 +126,7 @@ export interface AppShellChatActions { placement: 'current_turn' | 'next_turn', pending?: readonly PendingAttachment[], options?: { + directoryReferences?: readonly DirectoryReference[]; quotes?: readonly QuoteRef[]; workspaceFileReferences?: readonly WorkspaceFileReferencePosition[]; }, @@ -248,16 +250,19 @@ export function createAppShellChatActions(deps: { placement?: TransientUserMessageProjection['transientPlacement']; hostTurnId?: string; updateOnly?: boolean; + directoryReferences?: readonly DirectoryReference[]; quotes?: readonly QuoteRef[]; inlineReferences?: readonly InlineReference[]; } = {}, ): void { + const directoryReferences = options.directoryReferences; const quotes = options.quotes ?? []; const next: TransientUserMessageProjection = { id: messageId, ts: Date.now(), text, ...(attachments.length > 0 ? { attachments: [...attachments] } : {}), + ...(directoryReferences?.length ? { directoryReferences: [...directoryReferences] } : {}), ...(quotes.length > 0 ? { quotes: [...quotes] } : {}), inlineReferences: [...(options.inlineReferences ?? [])], transientPlacement: options.placement ?? 'current_turn', @@ -349,6 +354,7 @@ export function createAppShellChatActions(deps: { isSurfaceVisible?: () => boolean; }): Promise { const { sessionId, messageId, placement } = input; + const directoryReferences = input.command.directoryReferences; const quotes = input.quotes ?? []; const result = await window.maka.sessions.submitMessage(sessionId, placement, { ...input.command, @@ -396,6 +402,7 @@ export function createAppShellChatActions(deps: { updateOnly: true, placement, ...(result.turnId ? { hostTurnId: result.turnId } : {}), + ...(directoryReferences?.length ? { directoryReferences: [...directoryReferences] } : {}), ...(quotes.length > 0 ? { quotes } : {}), inlineReferences: result.inlineReferences ?? [], }, @@ -412,12 +419,14 @@ export function createAppShellChatActions(deps: { pending?: readonly PendingAttachment[], options: { turnOrchestration?: TurnOrchestration; + directoryReferences?: readonly DirectoryReference[]; quotes?: readonly QuoteRef[]; workspaceFileReferences?: readonly WorkspaceFileReferencePosition[]; displayText?: string; onSessionResolved?: (sessionId: string) => void; } = {}, ): Promise { + const directoryReferences = options.directoryReferences; const quotes = options.quotes; const exactTurn = options.turnOrchestration !== undefined; const initialSessionId = activeIdRef.current; @@ -494,6 +503,7 @@ export function createAppShellChatActions(deps: { options.displayText ?? text, [], { + ...(directoryReferences?.length ? { directoryReferences: [...directoryReferences] } : {}), ...(quotes && quotes.length > 0 ? { quotes } : {}), inlineReferences: [], }, @@ -514,6 +524,7 @@ export function createAppShellChatActions(deps: { ...(retainedAttachments && retainedAttachments.length > 0 ? { retainedAttachments } : {}), + ...(directoryReferences?.length ? { directoryReferences: [...directoryReferences] } : {}), ...(quotes && quotes.length > 0 ? { quotes: [...quotes] } : {}), ...(options.workspaceFileReferences && options.workspaceFileReferences.length > 0 ? { workspaceFileReferences: [...options.workspaceFileReferences] } @@ -569,6 +580,7 @@ export function createAppShellChatActions(deps: { options.displayText ?? text, [], { + ...(directoryReferences?.length ? { directoryReferences: [...directoryReferences] } : {}), ...(quotes && quotes.length > 0 ? { quotes } : {}), inlineReferences: [], }, @@ -589,6 +601,7 @@ export function createAppShellChatActions(deps: { ...(retainedAttachments && retainedAttachments.length > 0 ? { retainedAttachments } : {}), + ...(directoryReferences?.length ? { directoryReferences: [...directoryReferences] } : {}), ...(quotes && quotes.length > 0 ? { quotes: [...quotes] } : {}), ...(options.workspaceFileReferences && options.workspaceFileReferences.length > 0 ? { workspaceFileReferences: [...options.workspaceFileReferences] } @@ -679,14 +692,17 @@ export function createAppShellChatActions(deps: { placement: 'current_turn' | 'next_turn', pending?: readonly PendingAttachment[], options: { + directoryReferences?: readonly DirectoryReference[]; quotes?: readonly QuoteRef[]; workspaceFileReferences?: readonly WorkspaceFileReferencePosition[]; } = {}, ): Promise { const messageId = crypto.randomUUID(); + const directoryReferences = options.directoryReferences; const quotes = options.quotes ?? []; showTransientUserMessage(sessionId, messageId, text, retainedAttachmentRefs(pending ?? []), { placement, + ...(directoryReferences?.length ? { directoryReferences: [...directoryReferences] } : {}), ...(quotes.length > 0 ? { quotes } : {}), inlineReferences: [], }); @@ -701,6 +717,7 @@ export function createAppShellChatActions(deps: { text, ...(attachmentItems.length > 0 ? { attachmentItems } : {}), ...(retainedAttachments.length > 0 ? { retainedAttachments } : {}), + ...(directoryReferences?.length ? { directoryReferences: [...directoryReferences] } : {}), ...(quotes.length > 0 ? { quotes: [...quotes] } : {}), ...(options.workspaceFileReferences?.length ? { workspaceFileReferences: [...options.workspaceFileReferences] } diff --git a/apps/desktop/src/renderer/app-shell-session-events.ts b/apps/desktop/src/renderer/app-shell-session-events.ts index 5ce1ecc885..32bafb476c 100644 --- a/apps/desktop/src/renderer/app-shell-session-events.ts +++ b/apps/desktop/src/renderer/app-shell-session-events.ts @@ -302,6 +302,7 @@ export function createAppShellSessionEventHandlers(options: { ts: event.ts, text: entry.content.displayText ?? entry.content.text, ...(entry.content.attachments ? { attachments: [...entry.content.attachments] } : {}), + ...(entry.content.directoryReferences ? { directoryReferences: [...entry.content.directoryReferences] } : {}), ...(entry.content.quotes ? { quotes: [...entry.content.quotes] } : {}), ...(entry.content.inlineReferences ? { inlineReferences: [...entry.content.inlineReferences] } diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 2c61228a42..d53dbb3261 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -204,6 +204,7 @@ import { import { loadComposerDefaults, saveComposerDefaults } from './composer-defaults'; import { useKeyedPendingRegistry } from './use-pending-action-registry'; import { useComposerAttachments } from './use-composer-attachments'; +import { useComposerDirectories } from './use-composer-directories'; import { useAppShellComposerQuotes } from './use-app-shell-composer-quotes'; import { useComposerMentions } from './use-composer-mentions'; import { useAppShellSessionWorkspace } from './use-app-shell-session-workspace'; @@ -812,6 +813,22 @@ function AppShellContent({ const activeMessageQueue = activeId ? messageQueueBySession[activeId] : undefined; const activeMessageSubmitting = transientMessages.length > 0; const activeDesktopSession = activeSession; + const directoryHostId = activeId + ? (activeDesktopSession?.profileKind === 'local' ? activeDesktopSession.runtimeHostId : undefined) + : (taskEntry.selectors.selectedHost?.kind === 'local' + ? taskEntry.selectors.target?.hostId + : undefined); + const { + pendingDirectories, + pickDirectory, + removeDirectory, + clearSubmittedDirectories, + } = useComposerDirectories({ + draftKey: activeId ?? `new-task-directories:${directoryHostId ?? 'unresolved'}`, + hostId: directoryHostId, + pick: window.maka.attachments.pickDirectory, + toastApi, + }); // The shell's reading of the active live turn: streaming/settled flags, the // in-flight tool signal, and the #646 turn-wait cues, all derived from the // semantic snapshot rather than the projection (#1985). @@ -1819,7 +1836,7 @@ function AppShellContent({ activeIdRef, composerRef, messages, - hasPendingAttachments: () => pendingAttachments.length > 0, + hasPendingAttachments: () => pendingAttachments.length > 0 || pendingDirectories.length > 0, openSessionInChat, refreshMessages, refreshSessions, @@ -1871,6 +1888,7 @@ function AppShellContent({ mode === 'steer' ? 'current_turn' : 'next_turn', pending, { + ...(pendingDirectories.length ? { directoryReferences: [...pendingDirectories] } : {}), ...(quotes ? { quotes: [...quotes] } : {}), ...(metadata?.workspaceFileReferences?.length ? { workspaceFileReferences: [...metadata.workspaceFileReferences] } @@ -1882,6 +1900,7 @@ function AppShellContent({ if (!sent) return false; if (pending) clearSubmittedAttachments(pending); if (quotes) clearQuotes(); + clearSubmittedDirectories(pendingDirectories); return true; } catch (error) { if (activeIdRef.current === sessionId) { @@ -1937,7 +1956,8 @@ function AppShellContent({ revisionSend && revision && text.trim() === revision.originalText.trim() && - pendingAttachments.length === 0 + pendingAttachments.length === 0 && + pendingDirectories.length === 0 ) { const actionCopy = getDesktopConversationCopy(uiLocale).actions; toastApi.info(actionCopy.revisionReadyTitle, actionCopy.revisionUnchanged); @@ -1945,7 +1965,7 @@ function AppShellContent({ } if (revisionSend && revision) { const actionCopy = getDesktopConversationCopy(uiLocale).actions; - if (pendingAttachments.length > 0) { + if (pendingAttachments.length > 0 || pendingDirectories.length > 0) { toastApi.info(actionCopy.revisionUnavailableTitle, actionCopy.revisionAttachmentsUnsupported); return false; } @@ -1990,6 +2010,7 @@ function AppShellContent({ } if ( pendingAttachments.length > 0 || + pendingDirectories.length > 0 || pendingQuotes.length > 0 || (metadata?.workspaceFileReferences?.length ?? 0) > 0 ) { @@ -2032,6 +2053,7 @@ function AppShellContent({ const quotes = pendingQuotes.length > 0 ? pendingQuotes : undefined; const ok = await send(swarmCommand.task, pending, { turnOrchestration: { mode: 'swarm', source: 'slash_command' }, + ...(pendingDirectories.length ? { directoryReferences: [...pendingDirectories] } : {}), ...(quotes ? { quotes } : {}), ...(metadata?.workspaceFileReferences?.length ? { @@ -2045,6 +2067,7 @@ function AppShellContent({ }); if (ok !== false && pending) clearSubmittedAttachments(pending); if (ok !== false && quotes) clearQuotes(); + if (ok !== false) clearSubmittedDirectories(pendingDirectories); return ok; } if (slashCommand?.kind === 'graph') { @@ -2077,6 +2100,7 @@ function AppShellContent({ const quotes = pendingQuotes.length > 0 ? pendingQuotes : undefined; const ok = await send(graphCommand.task, pending, { turnOrchestration: { mode: 'graph', source: 'slash_command' }, + ...(pendingDirectories.length ? { directoryReferences: [...pendingDirectories] } : {}), ...(quotes ? { quotes } : {}), ...(metadata?.workspaceFileReferences?.length ? { @@ -2090,6 +2114,7 @@ function AppShellContent({ }); if (ok !== false && pending) clearSubmittedAttachments(pending); if (ok !== false && quotes) clearQuotes(); + if (ok !== false) clearSubmittedDirectories(pendingDirectories); return ok; } const pending = pendingAttachments.length > 0 ? pendingAttachments : undefined; @@ -2098,6 +2123,7 @@ function AppShellContent({ : undefined; const quotes = pendingQuotes.length > 0 ? pendingQuotes : undefined; const ok = await send(text, pending, { + ...(pendingDirectories.length ? { directoryReferences: [...pendingDirectories] } : {}), ...(quotes ? { quotes } : {}), ...(workspaceFileReferences.length > 0 ? { workspaceFileReferences } @@ -2105,6 +2131,7 @@ function AppShellContent({ }); if (ok !== false && pending) clearSubmittedAttachments(pending); if (ok !== false && quotes) clearQuotes(); + if (ok !== false) clearSubmittedDirectories(pendingDirectories); if (ok !== false && sessionId) { delete retractedWorkspaceReferencesRef.current[sessionId]; } @@ -2908,6 +2935,13 @@ function AppShellContent({ mentionSkillsLoading={mentionSkillsLoading} slashCommands={desktopSlashCommands} onSearchMentionFiles={searchMentionFiles} + pendingDirectories={pendingDirectories} + onRemoveDirectory={removeDirectory} + onPickDirectory={ + canStageComposerContext && directoryHostId && !revisionDraft + ? pickDirectory + : undefined + } pendingAttachments={pendingAttachments} onRemoveAttachment={removeAttachment} pendingQuotes={pendingQuotes} diff --git a/apps/desktop/src/renderer/locales/conversation-copy.ts b/apps/desktop/src/renderer/locales/conversation-copy.ts index 52496bd793..31610eedc9 100644 --- a/apps/desktop/src/renderer/locales/conversation-copy.ts +++ b/apps/desktop/src/renderer/locales/conversation-copy.ts @@ -416,7 +416,7 @@ function enDetail(parts: readonly string[]): string { const COPY = { zh: { - actions: { stopFailedTitle: '停止失败', stopFailedFallback: '任务操作失败,请稍后重试。', refreshSessionsFailedTitle: '刷新任务列表失败', refreshSessionsFailedFallback: '刷新任务列表失败,请稍后重试。', conversationErrorTitle: '任务出错', conversationErrorFallback: '任务运行失败,请稍后重试。', regenerateStartedTitle: '已发起重新生成', regenerateStartedDescription: '正在生成新的一轮回答', branchCreatedTitle: '已创建分支', branchCreatedDescription: (name) => `新任务 ${name}`, revisionStartedTitle: '已创建修改版草稿', revisionStartedDescription: '原任务仍会保留;修改后发送将在新版本中继续', revisionReadyTitle: '可以修改并重发了', revisionReadyDescription: '已回到该消息之前;编辑后发送即可', revisionUnavailableTitle: '暂时无法编辑这条消息', revisionAttachmentsUnsupported: '包含附件的历史消息暂不支持编辑并重发,请复制文字后新建消息。', revisionTransformedTextUnsupported: '通过显式技能发送的历史消息暂不支持编辑并重发,请复制文字后重新选择技能。', revisionDraftAttachmentConflict: 'Composer 中已有待发送附件,请先发送或移除附件,再编辑历史消息。', revisionCommandUnsupported: '修改消息时不能执行 /compact、/side 或编排命令,请取消修改后再试。', revisionAlreadyActive: '已有一条消息正在修改,请先发送或取消当前修改。', revisionCancelLabel: '取消', revisionBannerTitle: '正在修改已发送消息', revisionBannerDetail: '· 发送后创建新版本', revisionUnchanged: '内容没有变化。如需重新回答,请使用“重新生成”。', operationFailedTitle: '操作失败', operationFailedFallback: '任务操作失败,请稍后重试。', attachmentFailedTitle: '添加附件失败', tryAgain: '请稍后重试。', modelReboundTitle: '已切换到可用模型', modelReboundDescription: (modelId) => `原任务使用的连接已不可用${modelId ? ` · ${modelId}` : ''}`, messageReadFailedTitle: '读取任务失败', returnLatest: '返回最新消息', scrollMainToBottom: '滚动主对话到底部' }, + actions: { stopFailedTitle: '停止失败', stopFailedFallback: '任务操作失败,请稍后重试。', refreshSessionsFailedTitle: '刷新任务列表失败', refreshSessionsFailedFallback: '刷新任务列表失败,请稍后重试。', conversationErrorTitle: '任务出错', conversationErrorFallback: '任务运行失败,请稍后重试。', regenerateStartedTitle: '已发起重新生成', regenerateStartedDescription: '正在生成新的一轮回答', branchCreatedTitle: '已创建分支', branchCreatedDescription: (name) => `新任务 ${name}`, revisionStartedTitle: '已创建修改版草稿', revisionStartedDescription: '原任务仍会保留;修改后发送将在新版本中继续', revisionReadyTitle: '可以修改并重发了', revisionReadyDescription: '已回到该消息之前;编辑后发送即可', revisionUnavailableTitle: '暂时无法编辑这条消息', revisionAttachmentsUnsupported: '包含附件的历史消息暂不支持编辑并重发,请复制文字后新建消息。', revisionTransformedTextUnsupported: '包含已展开上下文的历史消息暂不支持编辑并重发,请复制文字后重新添加上下文。', revisionDraftAttachmentConflict: 'Composer 中已有待发送附件,请先发送或移除附件,再编辑历史消息。', revisionCommandUnsupported: '修改消息时不能执行 /compact、/side 或编排命令,请取消修改后再试。', revisionAlreadyActive: '已有一条消息正在修改,请先发送或取消当前修改。', revisionCancelLabel: '取消', revisionBannerTitle: '正在修改已发送消息', revisionBannerDetail: '· 发送后创建新版本', revisionUnchanged: '内容没有变化。如需重新回答,请使用“重新生成”。', operationFailedTitle: '操作失败', operationFailedFallback: '任务操作失败,请稍后重试。', attachmentFailedTitle: '添加附件失败', tryAgain: '请稍后重试。', modelReboundTitle: '已切换到可用模型', modelReboundDescription: (modelId) => `原任务使用的连接已不可用${modelId ? ` · ${modelId}` : ''}`, messageReadFailedTitle: '读取任务失败', returnLatest: '返回最新消息', scrollMainToBottom: '滚动主对话到底部' }, attachments: { tooMany: '附件数量超过 8 个', tooLarge: '附件大小超过 50MB', duplicate: '附件来源重复,请勿重复添加同一文件。' }, model: { fakeBackendLabel: '本地模拟连接', @@ -641,7 +641,7 @@ const COPY = { turnError: { unknown: '未知错误', contextOverflow: '上下文窗口已超出限制', contextBudgetExhausted: '上下文已达到上限,当前任务无法继续', malformedSummary: '上下文压缩未能生成有效摘要。请检查模型的上下文窗口设置、切换模型,或开启新任务。', timeout: '请求超时', auth: '鉴权失败', providerBilling: '模型服务计费受限', providerCapacity: '模型服务暂时满载,请稍后重试或切换模型', rateLimit: '触发模型速率限制', network: '网络错误', provider: '模型服务返回错误', stepCap: '达到工具步骤上限', tool: '工具调用失败', permission: '等待权限确认', restarted: '本地应用重启,上一轮没有完成', sandboxBoundaryClosed: '本地应用重启,等待确认的「允许访问工作区以外的内容」请求已按拒绝关闭', recovery: { safeResume: '检查当前状态后,可尝试安全恢复', stepCap: '任务可能尚未完成,可以继续', toolError: '先检查工具结果,再决定是否重试', connection: '先检查模型连接或登录状态', partial: '已保留部分输出,可从这里继续', toolRecord: '工具记录已保留,重试前先看结果', retry: '没有执行工具,可直接重试', capacity: '模型服务暂时满载,请等待几分钟或切换模型后重试', contextOverflow: '上下文仍超出限制,请减少附件或开启新任务', contextBudgetExhausted: '检查模型的上下文窗口设置、切换模型,或开启新任务', sandboxBoundaryClosed: '访问范围没有放开,重试本轮后可重新决定' } }, }, en: { - actions: { stopFailedTitle: 'Failed to stop', stopFailedFallback: 'The task action failed. Try again later.', refreshSessionsFailedTitle: 'Failed to refresh tasks', refreshSessionsFailedFallback: 'The task list could not be refreshed. Try again later.', conversationErrorTitle: 'Task error', conversationErrorFallback: 'The task run failed. Try again later.', regenerateStartedTitle: 'Regeneration started', regenerateStartedDescription: 'Generating a new response', branchCreatedTitle: 'Branch created', branchCreatedDescription: (name) => `New task: ${name}`, revisionStartedTitle: 'Edit draft ready', revisionStartedDescription: 'The original task is kept; sending creates a new version', revisionReadyTitle: 'Ready to edit and resend', revisionReadyDescription: 'Rewound to before that message; edit and send when ready', revisionUnavailableTitle: 'This message cannot be edited yet', revisionAttachmentsUnsupported: 'Edit & resend does not yet support historical attachments. Copy the text into a new message instead.', revisionTransformedTextUnsupported: 'Edit & resend does not yet support messages sent with an explicit skill. Copy the text and select the skill again instead.', revisionDraftAttachmentConflict: 'The composer already has pending attachments. Send or remove them before editing a sent message.', revisionCommandUnsupported: 'You cannot run /compact, /side, or orchestration commands while editing a sent message. Cancel the edit first.', revisionAlreadyActive: 'Another message is already being edited. Send or cancel that edit first.', revisionCancelLabel: 'Cancel', revisionBannerTitle: 'Editing sent message', revisionBannerDetail: '· New version on send', revisionUnchanged: 'Nothing changed. Use Regenerate if you only want a new answer.', operationFailedTitle: 'Action failed', operationFailedFallback: 'The task action failed. Try again later.', attachmentFailedTitle: 'Failed to add attachment', tryAgain: 'Try again later.', modelReboundTitle: 'Switched to an available model', modelReboundDescription: (modelId) => `The previous connection is unavailable${modelId ? ` · ${modelId}` : ''}`, messageReadFailedTitle: 'Failed to load task', returnLatest: 'Return to latest', scrollMainToBottom: 'Scroll main conversation to bottom' }, + actions: { stopFailedTitle: 'Failed to stop', stopFailedFallback: 'The task action failed. Try again later.', refreshSessionsFailedTitle: 'Failed to refresh tasks', refreshSessionsFailedFallback: 'The task list could not be refreshed. Try again later.', conversationErrorTitle: 'Task error', conversationErrorFallback: 'The task run failed. Try again later.', regenerateStartedTitle: 'Regeneration started', regenerateStartedDescription: 'Generating a new response', branchCreatedTitle: 'Branch created', branchCreatedDescription: (name) => `New task: ${name}`, revisionStartedTitle: 'Edit draft ready', revisionStartedDescription: 'The original task is kept; sending creates a new version', revisionReadyTitle: 'Ready to edit and resend', revisionReadyDescription: 'Rewound to before that message; edit and send when ready', revisionUnavailableTitle: 'This message cannot be edited yet', revisionAttachmentsUnsupported: 'Edit & resend does not yet support historical attachments. Copy the text into a new message instead.', revisionTransformedTextUnsupported: 'Edit & resend does not yet support messages with expanded context. Copy the text and add the context again instead.', revisionDraftAttachmentConflict: 'The composer already has pending attachments. Send or remove them before editing a sent message.', revisionCommandUnsupported: 'You cannot run /compact, /side, or orchestration commands while editing a sent message. Cancel the edit first.', revisionAlreadyActive: 'Another message is already being edited. Send or cancel that edit first.', revisionCancelLabel: 'Cancel', revisionBannerTitle: 'Editing sent message', revisionBannerDetail: '· New version on send', revisionUnchanged: 'Nothing changed. Use Regenerate if you only want a new answer.', operationFailedTitle: 'Action failed', operationFailedFallback: 'The task action failed. Try again later.', attachmentFailedTitle: 'Failed to add attachment', tryAgain: 'Try again later.', modelReboundTitle: 'Switched to an available model', modelReboundDescription: (modelId) => `The previous connection is unavailable${modelId ? ` · ${modelId}` : ''}`, messageReadFailedTitle: 'Failed to load task', returnLatest: 'Return to latest', scrollMainToBottom: 'Scroll main conversation to bottom' }, attachments: { tooMany: 'You can attach at most 8 files', tooLarge: 'Attachments must be 50 MB or smaller', duplicate: 'This attachment was already added.' }, model: { fakeBackendLabel: 'Local simulation', diff --git a/apps/desktop/src/renderer/use-composer-directories.ts b/apps/desktop/src/renderer/use-composer-directories.ts new file mode 100644 index 0000000000..b577b0a9d5 --- /dev/null +++ b/apps/desktop/src/renderer/use-composer-directories.ts @@ -0,0 +1,84 @@ +/* + * 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 { useRef, useState } from 'react'; +import { DIRECTORY_REFERENCE_MAX_COUNT, type DirectoryReference } from '@maka/core/events'; +import { useUiLocale } from '@maka/ui'; +import { getDesktopConversationCopy } from './locales/conversation-copy.js'; +import { localizedShellErrorMessage } from './locales/shell-copy.js'; + +export function useComposerDirectories(options: { + draftKey: string; + hostId?: string; + pick(): Promise<{ ok: true; reference: DirectoryReference } | { ok: false; reason: 'cancelled' }>; + toastApi: { error(title: string, description?: string): void }; +}) { + const locale = useUiLocale(); + const copy = getDesktopConversationCopy(locale).actions; + const [byKey, setByKey] = useState>({}); + const current = useRef(options); + current.current = options; + const pendingDirectories = byKey[options.draftKey] ?? []; + + async function pickDirectory(): Promise { + const owner = current.current; + if (!owner.hostId) return; + try { + const result = await owner.pick(); + if (!result.ok) return; + if (current.current.draftKey !== owner.draftKey || current.current.hostId !== owner.hostId) { + return; + } + if (result.reference.hostId !== owner.hostId) { + throw new Error('Directory references require the local Host.'); + } + setByKey((all) => { + const previous = all[owner.draftKey] ?? []; + if (previous.length >= DIRECTORY_REFERENCE_MAX_COUNT) return all; + if (previous.some((ref) => + ref.path === result.reference.path && ref.hostId === result.reference.hostId, + )) return all; + return { ...all, [owner.draftKey]: [...previous, result.reference] }; + }); + } catch (error) { + owner.toastApi.error( + copy.attachmentFailedTitle, + localizedShellErrorMessage(error, copy.tryAgain, locale), + ); + } + } + return { + pendingDirectories, + pickDirectory: pendingDirectories.length < DIRECTORY_REFERENCE_MAX_COUNT + ? pickDirectory + : undefined, + removeDirectory(index: number) { + setByKey((all) => ({ + ...all, + [options.draftKey]: (all[options.draftKey] ?? []).filter((_, i) => i !== index), + })); + }, + clearSubmittedDirectories(submitted: readonly DirectoryReference[]) { + setByKey((all) => ({ + ...all, + [options.draftKey]: (all[options.draftKey] ?? []).filter((ref) => !submitted.includes(ref)), + })); + }, + }; +} diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md index 5d8510fa4a..64ac9974b4 100644 --- a/docs/astryx-surface-file-inventory.md +++ b/docs/astryx-surface-file-inventory.md @@ -5,7 +5,7 @@ Each row is one on-disk product surface file. Regenerated inventory must stay in Wiki bar: Design Conventions · API Use-the-System · Theming · Container Padding. -**Totals:** 217 files — blocker 0, polish 1, aligned 216. +**Totals:** 218 files — blocker 0, polish 1, aligned 217. ## Exclusions (explicit) @@ -200,6 +200,7 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi | `packages/ui/src/composer-message-queue.tsx` | shell-chrome-or-panel | Button, IconButton, List, ListItem | raw ` { + for (const path of ['/workspace/source', 'C:\\projects\\source', '\\\\server\\share\\source']) { + assert.equal(isDirectoryReference({ ...reference, path }), true, path); + } + for (const invalid of [ + { path: reference.path }, + { ...reference, hostId: '' }, + { ...reference, hostId: '../host' }, + { ...reference, path: 'relative/path' }, + { ...reference, path: '/path\0name' }, + { ...reference, path: '/' + 'x'.repeat(4096) }, + { ...reference, access: 'write' }, + ]) { + assert.equal(isDirectoryReference(invalid), false); + assert.throws(() => decodeMessageContent({ text: 'inspect', directoryReferences: [invalid] })); + } +}); + +test('directory references are cloned and remain part of durable message identity', () => { + const source = { text: 'inspect', directoryReferences: [{ ...reference }] }; + const normalized = normalizeMessageContent(source); + const same = decodeMessageContent(JSON.parse(JSON.stringify(source))); + assert.equal(messageContentsEqual(normalized, same), true); + assert.equal(messageContentDigest(normalized), messageContentDigest(same)); + for (const other of [ + { ...reference, hostId: 'host-b' }, + { ...reference, path: '/workspace/other' }, + ]) { + const changed = { ...source, directoryReferences: [other] }; + assert.equal(messageContentsEqual(normalized, changed), false); + assert.notEqual(messageContentDigest(normalized), messageContentDigest(changed)); + } + source.directoryReferences[0]!.path = '/changed'; + assert.deepEqual(normalized.directoryReferences, [reference]); + assert.deepEqual(normalizeMessageContent({ text: 'plain', directoryReferences: [] }), { + text: 'plain', + }); + assert.equal( + messageContentsEqual({ text: 'plain' }, { text: 'plain', directoryReferences: [] }), + true, + ); +}); + +test('directory references survive queue aggregation, StoredMessage and RuntimeEvent decoding', () => { + const content = aggregateMessageContents([ + { text: 'model context', displayText: 'inspect', directoryReferences: [reference] }, + { text: 'also inspect', directoryReferences: [{ ...reference, path: '/workspace/second' }] }, + ]); + assert.equal(content.displayText, 'inspect\n\nalso inspect'); + assert.deepEqual(content.directoryReferences, [ + reference, + { ...reference, path: '/workspace/second' }, + ]); + const stored = decodeCanonicalMessage({ + type: 'user', + id: 'message-1', + turnId: 'turn-1', + ts: 1, + ...content, + }); + assert.equal(stored.type, 'user'); + if (stored.type !== 'user') throw new Error('Expected user message'); + assert.deepEqual(stored.directoryReferences, content.directoryReferences); + const event = decodeRuntimeEvent({ + id: 'event-1', + invocationId: 'invocation-1', + runId: 'run-1', + sessionId: 'session-1', + turnId: 'turn-1', + ts: 1, + partial: false, + role: 'user', + author: 'user', + content: { kind: 'text', ...content }, + }); + assert.equal(event.content?.kind, 'text'); + if (event.content?.kind !== 'text') throw new Error('Expected text event'); + assert.deepEqual(event.content.directoryReferences, content.directoryReferences); +}); diff --git a/packages/core/src/events.ts b/packages/core/src/events.ts index 7a96088e5d..9d6234077e 100644 --- a/packages/core/src/events.ts +++ b/packages/core/src/events.ts @@ -86,6 +86,26 @@ export interface AttachmentRef { ref: StorageRef; } +/** A live directory on the originating Host, not a saved file or an access grant. */ +export interface DirectoryReference { + hostId: string; + path: string; +} + +export const DIRECTORY_REFERENCE_MAX_COUNT = 4; + +export function isDirectoryReference(value: unknown): value is DirectoryReference { + return ( + isRecord(value) && + Object.keys(value).length === 2 && + typeof value.hostId === 'string' && + /^[A-Za-z0-9_-]{1,128}$/.test(value.hostId) && + typeof value.path === 'string' && + value.path.length <= 4096 && + isCanonicalAbsolutePath(value.path) + ); +} + /** * An inline quoted excerpt attached to a user message — e.g. text selected in * the transcript and carried into a follow-up. Unlike {@link AttachmentRef} @@ -127,6 +147,7 @@ export interface MessageContent { displayText?: string; /** Ordered attachment references; omit when empty. Attachment bytes never travel here. */ attachments?: AttachmentRef[]; + directoryReferences?: DirectoryReference[]; /** Ordered inline excerpts; omit when empty. Provenance remains part of content identity. */ quotes?: QuoteRef[]; /** Sent inline tokens; an empty array marks a current-format plain message. Never model-visible. */ @@ -135,7 +156,7 @@ export interface MessageContent { const MESSAGE_CONTENT_SHAPE = defineObjectShape()( ['text'], - ['displayText', 'attachments', 'quotes', 'inlineReferences'], + ['displayText', 'attachments', 'directoryReferences', 'quotes', 'inlineReferences'], ); const ATTACHMENT_REF_SHAPE = defineObjectShape()( ['kind', 'name', 'mimeType', 'bytes', 'ref'], @@ -165,6 +186,9 @@ const EXTERNAL_FILE_REF_SHAPE = defineObjectShape ({ ...ref })) } + : {}), ...(content.displayText !== undefined && content.displayText !== content.text ? { displayText: content.displayText } : {}), @@ -198,6 +222,7 @@ export function aggregateMessageContents(contents: readonly MessageContent[]): M const text = contents.map((content) => content.text).join('\n\n'); const displayText = contents.map((content) => content.displayText ?? content.text).join('\n\n'); const attachments = contents.flatMap((content) => content.attachments ?? []); + const directoryReferences = contents.flatMap((content) => content.directoryReferences ?? []); const quotes = contents.flatMap((content) => content.quotes ?? []); const inlineReferences: InlineReference[] = []; const hasInlineReferenceMarker = contents.some( @@ -215,6 +240,7 @@ export function aggregateMessageContents(contents: readonly MessageContent[]): M text, ...(displayText !== text ? { displayText } : {}), ...(attachments.length > 0 ? { attachments } : {}), + ...(directoryReferences.length > 0 ? { directoryReferences } : {}), ...(quotes.length > 0 ? { quotes } : {}), ...(hasInlineReferenceMarker ? { inlineReferences } : {}), }); @@ -230,6 +256,9 @@ export function isMessageContent(value: unknown): value is MessageContent { isRecord(value) && hasExactShape(value, MESSAGE_CONTENT_SHAPE) && typeof value.text === 'string' && + (value.directoryReferences === undefined || + (Array.isArray(value.directoryReferences) && + value.directoryReferences.every(isDirectoryReference))) && (value.displayText === undefined || typeof value.displayText === 'string') && (value.attachments === undefined || (Array.isArray(value.attachments) && value.attachments.every(isAttachmentRef))) && @@ -377,6 +406,12 @@ export function messageContentsEqual(left: MessageContent, right: MessageContent return ( left.text === right.text && leftDisplayText === rightDisplayText && + (left.directoryReferences?.length ?? 0) === (right.directoryReferences?.length ?? 0) && + (left.directoryReferences ?? []).every( + (ref, index) => + ref.hostId === right.directoryReferences?.[index]?.hostId && + ref.path === right.directoryReferences?.[index]?.path, + ) && ((leftAttachments === undefined && rightAttachments === undefined) || (leftAttachments !== undefined && rightAttachments !== undefined && diff --git a/packages/core/src/runtime-event.ts b/packages/core/src/runtime-event.ts index 9a80ea99b0..fb65ef6acd 100644 --- a/packages/core/src/runtime-event.ts +++ b/packages/core/src/runtime-event.ts @@ -477,6 +477,7 @@ const TEXT_CONTENT_SHAPE = defineObjectShape()( 'displayText', 'origin', 'attachments', + 'directoryReferences', 'quotes', 'inlineReferences', 'steering', @@ -668,6 +669,9 @@ function isRuntimeEventContent(value: unknown): value is RuntimeEventContent { text: value.text, ...(value.displayText !== undefined ? { displayText: value.displayText } : {}), ...(value.attachments !== undefined ? { attachments: value.attachments } : {}), + ...(value.directoryReferences !== undefined + ? { directoryReferences: value.directoryReferences } + : {}), ...(value.quotes !== undefined ? { quotes: value.quotes } : {}), ...(value.inlineReferences !== undefined ? { inlineReferences: value.inlineReferences } diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index e9896dc2e2..0710bb04cc 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -955,7 +955,15 @@ export interface SystemNoteMessage { const USER_MESSAGE_SHAPE = defineObjectShape()( ['type', 'id', 'turnId', 'ts', 'text'], - ['displayText', 'attachments', 'quotes', 'inlineReferences', 'steeringEventId', 'origin'], + [ + 'displayText', + 'attachments', + 'directoryReferences', + 'quotes', + 'inlineReferences', + 'steeringEventId', + 'origin', + ], ); const ASSISTANT_MESSAGE_SHAPE = defineObjectShape()( ['type', 'id', 'turnId', 'ts', 'text', 'modelId'], @@ -1077,7 +1085,15 @@ function decodeMessage( hasMessageEnvelope(message, true) && (message.origin === undefined || decodeTurnOrigin(message.origin) !== undefined) ) { - const { displayText, attachments, quotes, inlineReferences, origin, ...envelope } = message; + const { + displayText, + attachments, + directoryReferences, + quotes, + inlineReferences, + origin, + ...envelope + } = message; const decodedOrigin = origin === undefined ? undefined : decodeTurnOrigin(origin); try { return { @@ -1086,6 +1102,7 @@ function decodeMessage( text: message.text, displayText, attachments, + directoryReferences, quotes, inlineReferences, }), diff --git a/packages/runtime-host/src/__tests__/protocol.test.ts b/packages/runtime-host/src/__tests__/protocol.test.ts index 32d0daa7fa..a7f0b41a74 100644 --- a/packages/runtime-host/src/__tests__/protocol.test.ts +++ b/packages/runtime-host/src/__tests__/protocol.test.ts @@ -1387,7 +1387,7 @@ describe('Runtime Host bootstrap protocol', () => { ); }); - test('bounds canonical MessageContent attachments and quotes', () => { + test('bounds canonical MessageContent attachments, directory references and quotes', () => { const submit = (content: unknown) => decodeClientFrame({ requestId: 'submit-bounds', @@ -1400,6 +1400,17 @@ describe('Runtime Host bootstrap protocol', () => { placement: 'next_turn', }, }); + const directory = { hostId: 'host-a', path: '/workspace/source' }; + assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 56); + assert.doesNotThrow(() => submit({ text: 'valid', directoryReferences: [directory] })); + for (const directoryReferences of [ + Array.from({ length: 5 }, () => directory), + [{ ...directory, path: '../outside' }], + [{ ...directory, hostId: '' }], + [{ ...directory, permissions: 'read' }], + ]) { + assert.throws(() => submit({ text: 'valid', directoryReferences }), isInvalidFrame); + } assert.doesNotThrow(() => submit({ text: 'valid', diff --git a/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts b/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts index 5e041ebecd..8b7dfdcef5 100644 --- a/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts @@ -55,7 +55,7 @@ import type { BackendCompactHistoryInput, BackendSendInput, } from '@maka/core/backend-types'; -import type { SessionEvent } from '@maka/core/events'; +import type { MessageContent, SessionEvent } from '@maka/core/events'; import { WORKHUB_COORDINATION_SESSION_ID, WORKHUB_COORDINATION_SESSION_ROLE, @@ -4896,6 +4896,7 @@ async function registerSessionCapability( async function createFailureFixture(options: { registerBackend(backends: BackendRegistry): void; + prepareDirectories?(sessionId: string, content: MessageContent): Promise; corruptSessionRole?: boolean; childTools?: MakaTool[]; wrapAdmissionStore?(store: RootTurnAdmissionStore): RootTurnAdmissionStore; @@ -5099,6 +5100,8 @@ async function createFailureFixture(options: { artifactAuthority, options.prepareSkillInvocation, options.agentGraphEpochs, + undefined, + options.prepareDirectories, ); coordinator = createCoordinator(rootAdmissionOwner); const contextOperations = new HostContextCoordinator({ @@ -5173,6 +5176,202 @@ async function createFailureFixture(options: { }; } +test('directory context is prepared once and retained through durable submission and duplicate delivery', async () => { + let preparations = 0; + const reference = { hostId: 'host-a', path: '/workspace/source' }; + const fixture = await createFailureFixture({ + registerBackend: (backends) => + backends.register('ai-sdk', (context) => new FakeBackend(context)), + prepareDirectories: async (_sessionId, content) => { + preparations += 1; + return { + ...content, + displayText: content.text, + text: content.text + '\nlisting-' + preparations, + }; + }, + }); + try { + const input = { + originHostEpoch: fixture.hostEpoch, + sessionId: fixture.sessionId, + messageId: 'directory-message', + content: { text: 'inspect', directoryReferences: [reference] }, + placement: 'next_turn' as const, + }; + const context = operationContext(fixture.hostEpoch, fixture.acquireResidency); + const first = await fixture.messages.handlers['turn.message.submit'](input, context); + assert.equal(first.ok, true, JSON.stringify(first)); + await fixture.coordinator.whenIdle(fixture.sessionId); + assert.deepEqual(await fixture.messages.handlers['turn.message.submit'](input, context), first); + assert.equal(preparations, 1); + const users = (await fixture.stores.sessionStore.readMessages(fixture.sessionId)).filter( + (message) => message.type === 'user', + ); + assert.equal(users.length, 1); + assert.equal(users[0]!.text, 'inspect\nlisting-1'); + assert.equal(users[0]!.displayText, 'inspect'); + assert.deepEqual(users[0]!.directoryReferences, [reference]); + const header = await fixture.stores.sessionStore.readHeaderSnapshot(fixture.sessionId); + assert.notEqual(header.cwd, reference.path, 'a directory reference must not change cwd'); + } finally { + await fixture.coordinator.close(); + await fixture.messages.close(); + await fixture.dispose(); + } +}); + +test('turn.start prepares directory context once and replay keeps the saved observation', async () => { + let preparations = 0; + const reference = { hostId: 'host-a', path: '/workspace/source' }; + const fixture = await createFailureFixture({ + registerBackend: (backends) => + backends.register('ai-sdk', (context) => new FakeBackend(context)), + prepareDirectories: async (_sessionId, content) => { + preparations += 1; + return { + ...content, + displayText: content.text, + text: content.text + '\nlisting-' + preparations, + }; + }, + }); + try { + const input = { + sessionId: fixture.sessionId, + turnId: 'directory-start', + content: { text: 'inspect', directoryReferences: [reference] }, + }; + const context = operationContext(fixture.hostEpoch, fixture.acquireResidency); + assertStartedTurn(await fixture.interactiveTurns.handlers['turn.start'](input, context)); + await fixture.coordinator.whenIdle(fixture.sessionId); + assertStartedTurn(await fixture.interactiveTurns.handlers['turn.start'](input, context)); + assert.equal(preparations, 1); + const user = (await fixture.stores.sessionStore.readMessages(fixture.sessionId)).find( + (message) => message.type === 'user', + ); + assert.equal(user?.type, 'user'); + if (user?.type !== 'user') throw new Error('Expected user message'); + assert.equal(user.text, 'inspect\nlisting-1'); + assert.deepEqual(user.directoryReferences, [reference]); + const regeneratedOutcome = await fixture.interactiveTurns.handlers['turn.regenerate']( + { + sessionId: fixture.sessionId, + sourceTurnId: input.turnId, + turnId: 'directory-regenerated', + }, + context, + ); + assert.equal(regeneratedOutcome.ok, true, JSON.stringify(regeneratedOutcome)); + await fixture.coordinator.whenIdle(fixture.sessionId); + assert.equal(preparations, 1, 'regeneration must reuse the stored directory observation'); + const regenerated = (await fixture.stores.sessionStore.readMessages(fixture.sessionId)).find( + (message) => message.type === 'user' && message.turnId === 'directory-regenerated', + ); + assert.equal(regenerated?.type, 'user'); + if (regenerated?.type !== 'user') throw new Error('Expected regenerated user message'); + assert.equal(regenerated.text, 'inspect\nlisting-1'); + assert.deepEqual(regenerated.directoryReferences, [reference]); + } finally { + await fixture.coordinator.close(); + await fixture.messages.close(); + await fixture.dispose(); + } +}); + +test('queued directory observations survive text editing and next-Turn delivery without another scan', async () => { + const entered = deferred(); + const release = deferred(); + let preparations = 0; + const reference = { hostId: 'host-a', path: '/workspace/source' }; + const fixture = await createFailureFixture({ + registerBackend: (backends) => + backends.register( + 'ai-sdk', + (context) => + new (class extends FakeBackend { + override async *send(input: BackendSendInput): AsyncIterable { + if (input.text === 'hold-directory-test') { + entered.resolve(); + await release.promise; + } + yield* super.send(input); + } + })(context), + ), + prepareDirectories: async (_sessionId, content) => { + preparations += 1; + return { + ...content, + displayText: content.text, + text: content.text + '\nlisting-' + preparations, + }; + }, + }); + try { + const context = operationContext(fixture.hostEpoch, fixture.acquireResidency); + assertStartedTurn( + await fixture.interactiveTurns.handlers['turn.start']( + { + sessionId: fixture.sessionId, + turnId: 'held-directory-root', + content: { text: 'hold-directory-test' }, + }, + context, + ), + ); + await entered.promise; + const submitted = await fixture.messages.handlers['turn.message.submit']( + { + originHostEpoch: fixture.hostEpoch, + sessionId: fixture.sessionId, + messageId: 'queued-directory', + content: { text: 'inspect queued', directoryReferences: [reference] }, + placement: 'next_turn', + }, + context, + ); + assert.equal(submitted.ok && submitted.result.disposition, 'followup'); + const queue = fixture.messages.projection(fixture.sessionId); + assert.equal(queue.followup.length, 1); + const entry = queue.followup[0]!; + assert.deepEqual(entry.content.directoryReferences, [reference]); + const edited = await fixture.messages.handlers['queue.entry.update']( + { + originHostEpoch: fixture.hostEpoch, + sessionId: fixture.sessionId, + entryId: entry.entryId, + updateId: 'edit-directory', + expectedQueueRevision: queue.queueRevision, + text: 'edited inspection', + }, + context, + ); + assert.equal(edited.ok, true, JSON.stringify(edited)); + assert.equal(preparations, 2, 'an explicit edit prepares one new observation'); + release.resolve(); + await fixture.coordinator.whenIdle(fixture.sessionId); + await waitUntil(async () => + (await fixture.stores.sessionStore.readMessages(fixture.sessionId)).some( + (message) => message.type === 'user' && message.displayText === 'edited inspection', + ), + ); + assert.equal(preparations, 2, 'delivery must consume the saved observation'); + const user = (await fixture.stores.sessionStore.readMessages(fixture.sessionId)).find( + (message) => message.type === 'user' && message.displayText === 'edited inspection', + ); + assert.equal(user?.type, 'user'); + if (user?.type !== 'user') throw new Error('Expected queued user'); + assert.equal(user.text, 'edited inspection\nlisting-2'); + assert.deepEqual(user.directoryReferences, [reference]); + } finally { + release.resolve(); + await fixture.coordinator.close(); + await fixture.messages.close(); + await fixture.dispose(); + } +}); + function requireCoordinator(coordinator: RootTurnCoordinator | undefined): RootTurnCoordinator { if (!coordinator) throw new Error('RootTurnCoordinator is not composed'); return coordinator; diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index 45adfa103a..a68303fdac 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -93,7 +93,9 @@ 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 = 56 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 57 as const; +// 57: Message content carries Host-bound directory references. Older peers +// reject this field and cannot preserve its identity through admission/replay. // 56: Failed Turn snapshots preserve the structured context-budget exhaustion // detail. Epoch-55 peers reject the optional field on the closed snapshot shape. // 55: Local owners can atomically revoke every credential for one access diff --git a/packages/runtime-host/src/protocol/turn.ts b/packages/runtime-host/src/protocol/turn.ts index c96d19e411..7c722f40d0 100644 --- a/packages/runtime-host/src/protocol/turn.ts +++ b/packages/runtime-host/src/protocol/turn.ts @@ -21,6 +21,7 @@ import { MAX_ATTACHMENT_BYTES, MAX_ATTACHMENT_COUNT } from '@maka/core/attachmen import { decodeMessageContent as decodeCanonicalMessageContent, isContextBudgetExhaustedDetail, + DIRECTORY_REFERENCE_MAX_COUNT, isCanonicalAttachmentRef, type ContextBudgetExhaustedDetail, type ContextCompactionOutcome, @@ -406,6 +407,9 @@ export function decodeMessageContent(value: unknown, allowEmptyText = false): Me true, ); } + if ((content.directoryReferences?.length ?? 0) > DIRECTORY_REFERENCE_MAX_COUNT) { + throw invalidProtocolFrame('Too many directory references'); + } if ((content.attachments?.length ?? 0) > MAX_ATTACHMENT_COUNT) { throw invalidProtocolFrame('Invalid Message attachments'); } diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index 403e7af4c8..e2fa99884c 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -19,6 +19,7 @@ import { createHash, randomUUID } from 'node:crypto'; import { normalizeMessageContent } from '@maka/core/events'; +import { createDirectoryContextPreparer } from '@maka/runtime/directory-context'; import { describeChatConfigurationReason, NO_REAL_CONNECTION_CODE, @@ -1106,6 +1107,17 @@ export async function createExecutionRuntimeHostComposition( ).graphId, }, (input) => sessionEffectCoordinator.nameSessionFromRootMessage(input), + createDirectoryContextPreparer({ + hostId: context.owner.capability.rootId, + ...(filesystemWorker ? { worker: filesystemWorker } : {}), + readSession: async (sessionId) => { + const [header, boundary] = await Promise.all([ + stores.sessionStore.readHeaderSnapshot(sessionId), + stores.sessionStore.readExecutionBoundary(sessionId), + ]); + return { cwd: header.cwd, boundary }; + }, + }), ); const coordinator = rootCoordinator; const contextOperations = new HostContextCoordinator({ diff --git a/packages/runtime-host/src/server/interactive-turn-coordinator.ts b/packages/runtime-host/src/server/interactive-turn-coordinator.ts index 79a0e4881c..e9fee08be7 100644 --- a/packages/runtime-host/src/server/interactive-turn-coordinator.ts +++ b/packages/runtime-host/src/server/interactive-turn-coordinator.ts @@ -90,17 +90,23 @@ export class HostInteractiveTurnCoordinator { context, ); } + const hasDirectoryReferences = (content.directoryReferences?.length ?? 0) > 0; const outcome = await this.#executions.startInteractiveRootMessage( { sessionId: input.sessionId, turnId: input.turnId, execution: { kind: 'external_message', + ...(hasDirectoryReferences + ? { inputDigest: hostedExternalInputDigest(content, []) } + : {}), ...(input.maxSteps !== undefined ? { maxSteps: input.maxSteps } : {}), }, ...(input.turnOrchestration ? { turnOrchestration: { ...input.turnOrchestration } } : {}), archivedMessage: 'Cannot start a new Turn in an archived Session', - content, + ...(hasDirectoryReferences + ? { prepareFreshContent: async () => ({ kind: 'ready' as const, content }) } + : { content }), }, context, ); diff --git a/packages/runtime-host/src/server/root-admission-owner.ts b/packages/runtime-host/src/server/root-admission-owner.ts index b199fb930b..9aab17b758 100644 --- a/packages/runtime-host/src/server/root-admission-owner.ts +++ b/packages/runtime-host/src/server/root-admission-owner.ts @@ -167,6 +167,8 @@ function snapshotMessageContent(content: MessageContent): MessageContent { Object.freeze(attachment); } if (snapshot.attachments) Object.freeze(snapshot.attachments); + for (const reference of snapshot.directoryReferences ?? []) Object.freeze(reference); + if (snapshot.directoryReferences) Object.freeze(snapshot.directoryReferences); for (const quote of snapshot.quotes ?? []) Object.freeze(quote); if (snapshot.quotes) Object.freeze(snapshot.quotes); return Object.freeze(snapshot); diff --git a/packages/runtime-host/src/server/root-turn-coordinator.ts b/packages/runtime-host/src/server/root-turn-coordinator.ts index adbdc14136..20356adb20 100644 --- a/packages/runtime-host/src/server/root-turn-coordinator.ts +++ b/packages/runtime-host/src/server/root-turn-coordinator.ts @@ -335,6 +335,10 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { sessionId: string; content: MessageContent; }) => void, + private readonly prepareDirectories?: ( + sessionId: string, + content: MessageContent, + ) => Promise, ) { this.stores = authenticateExecutionStoresWriter(stores, 'interactive'); this.executionProjection = new HostedExecutionProjectionReader(this.stores); @@ -1075,7 +1079,9 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { : prepared.outcome.error.message, }; } - const canonicalContent = preflightRootMessageContent(prepared.content); + const canonicalContent = preflightRootMessageContent( + await this.prepareDirectoryReferences(input.sessionId, prepared.content), + ); if (!canonicalContent.ok) return { error: 'Prepared message content exceeds durable limits' }; const binding = prepared.commitCapabilityBinding @@ -1223,16 +1229,40 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { return this.runCommand(async () => { const content = normalizeMessageContent(input.content); if (parseSkillInvocationTokens(content.text).length === 0) { - return { kind: 'ready', content }; + return { + kind: 'ready', + content: await this.prepareDirectoryReferences(input.sessionId, content), + }; } - const prepare = () => - this.prepareSkillInvocationContent(input.sessionId, input.turnId, content, []); + const prepare = async () => { + const prepared = await this.prepareSkillInvocationContent( + input.sessionId, + input.turnId, + content, + [], + ); + return prepared.kind === 'ready' + ? { + ...prepared, + content: await this.prepareDirectoryReferences(input.sessionId, prepared.content), + } + : prepared; + }; if (input.placement === 'current_turn') return prepare(); const preview = await this.previewCapabilityBinding(input.sessionId, '', prepare); return preview.ok ? preview.value : { kind: 'rejected', error: preview.message }; }); } + private async prepareDirectoryReferences( + sessionId: string, + content: MessageContent, + ): Promise { + if (!content.directoryReferences?.length) return content; + if (!this.prepareDirectories) throw new Error('Directory context is unavailable on this Host'); + return this.prepareDirectories(sessionId, content); + } + claimStop( input: Pick, commitQueueFence: () => QueueFenceResult, @@ -1498,7 +1528,11 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { const prepared = await this.prepareRootMessageContent(request, lease); if (prepared.kind === 'rejected') return completedStart(prepared.outcome); - const canonicalContent = preflightRootMessageContent(prepared.content); + const canonicalContent = preflightRootMessageContent( + 'prepareFreshContent' in request + ? await this.prepareDirectoryReferences(request.sessionId, prepared.content) + : prepared.content, + ); if (!canonicalContent.ok) return completedStart(canonicalContent.outcome); const attachments = canonicalContent.content.attachments ?? []; if (attachments.length > 0 && !this.attachmentValidator) { diff --git a/packages/runtime/package.json b/packages/runtime/package.json index c4d2a6bad5..2fe7ec5b35 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -23,6 +23,7 @@ "./test-only/fake-backend": "./dist/test-only/fake-backend.js", "./test-only/observation-text-reader": "./dist/__tests__/observation-text-reader.js", "./filesystem-worker": "./dist/filesystem-worker/index.js", + "./directory-context": "./dist/directory-context.js", "./sandbox": "./dist/sandbox/index.js", "./network/proxy-test": "./dist/network/proxy-test.js", "./telemetry": "./dist/telemetry/index.js", diff --git a/packages/runtime/src/__tests__/directory-context.test.ts b/packages/runtime/src/__tests__/directory-context.test.ts new file mode 100644 index 0000000000..aca10b1b53 --- /dev/null +++ b/packages/runtime/src/__tests__/directory-context.test.ts @@ -0,0 +1,304 @@ +/* + * 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 { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { MessageContent } from '@maka/core/events'; +import type { ExecutionBoundary } from '@maka/core/sandbox-boundary'; +import { createWorkspaceWritePermissionProfile } from '@maka/core/permission-profile'; +import { + createDirectoryContextPreparer, + DIRECTORY_LISTING_LIMIT, + DIRECTORY_LISTING_MAX_BYTES, + prepareDirectoryContext, +} from '../directory-context.js'; +import type { FilesystemExecuteInput } from '../filesystem-executor.js'; +import { + FilesystemWorkerClient, + FilesystemWorkerClientError, +} from '../filesystem-worker/client.js'; +import { createFilesystemWorkerLaunchSpecProvider } from '../filesystem-worker/launch-spec.js'; +import { createDefaultSandboxManager } from '../sandbox/default-sandbox-manager.js'; + +test('managed directory context uses the real macOS filesystem worker', { + skip: process.platform !== 'darwin', +}, async () => { + const root = await realpath(await mkdtemp(join(tmpdir(), 'maka-directory-worker-'))); + try { + const cwd = join(root, 'session'); + const source = join(root, 'source'); + await mkdir(cwd); + await mkdir(source); + await writeFile(join(source, 'README.md'), 'not read'); + const worker = new FilesystemWorkerClient({ + sandboxManager: createDefaultSandboxManager(), + getLaunchSpec: createFilesystemWorkerLaunchSpecProvider({ + runtime: 'node', + resourceLocation: { kind: 'runtime' }, + }), + }); + const prepare = createDirectoryContextPreparer({ + hostId: reference.hostId, + worker, + readSession: async () => ({ cwd, boundary }), + }); + const prepared = await prepare('session-1', { + ...content, + directoryReferences: [{ ...reference, path: source }], + }); + assert.equal(observations(prepared)[0]!.status, 'listed', prepared.text); + assert.deepEqual(observations(prepared)[0]!.entries, ['README.md']); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +const boundary: ExecutionBoundary = { + kind: 'managed', + revision: 3, + profile: createWorkspaceWritePermissionProfile(), +}; +const reference = { hostId: 'host-a', path: '/workspace/source' }; +const content: MessageContent = { text: 'inspect this folder', directoryReferences: [reference] }; +function observations(prepared: MessageContent): Array<{ + hostId: string; + path: string; + status: string; + entries?: string[]; + truncated?: boolean; + message?: string; +}> { + return JSON.parse(prepared.text.slice(prepared.text.lastIndexOf('\n') + 1)); +} + +test('prepares one bounded read using the live boundary, without replacing authored text or cwd', async () => { + const calls: FilesystemExecuteInput[] = []; + const original = structuredClone(content); + const prepared = await prepareDirectoryContext(content, { + hostId: reference.hostId, + cwd: '/workspace/current', + boundary, + filesystem: { + execute: async (input) => { + calls.push(input); + return { kind: 'glob', files: ['src', 'package.json', '.gitignore'] }; + }, + }, + }); + assert.deepEqual(content, original); + assert.equal(prepared.displayText, content.text); + assert.deepEqual(prepared.directoryReferences, [reference]); + assert.equal(calls.length, 1); + assert.equal(calls[0]!.cwd, '/workspace/current'); + assert.equal(calls[0]!.executionBoundary, boundary); + assert.deepEqual(calls[0]!.operation, { + kind: 'glob', + path: reference.path, + pattern: '{*,.*}', + limit: DIRECTORY_LISTING_LIMIT + 1, + }); + assert.deepEqual(observations(prepared), [ + { + ...reference, + status: 'listed', + entries: ['src', 'package.json', '.gitignore'], + truncated: false, + }, + ]); +}); + +test('truncates one-level entries by count and shares a byte budget across directories', async () => { + const prepared = await prepareDirectoryContext( + { + ...content, + directoryReferences: [reference, { ...reference, path: '/workspace/second' }], + }, + { + hostId: reference.hostId, + cwd: '/workspace/current', + boundary, + filesystem: { + execute: async () => ({ + kind: 'glob', + files: Array.from({ length: 101 }, (_, index) => String(index) + 'a'.repeat(100)), + }), + }, + }, + ); + const result = observations(prepared); + assert.ok(result.every((item) => item.truncated)); + const allEntries = result.flatMap((item) => item.entries ?? []); + assert.ok(allEntries.length < DIRECTORY_LISTING_LIMIT); + assert.ok( + allEntries.reduce((sum, entry) => sum + Buffer.byteLength(JSON.stringify(entry)), 0) <= + DIRECTORY_LISTING_MAX_BYTES, + ); + + const countBound = await prepareDirectoryContext(content, { + hostId: reference.hostId, + cwd: '/workspace', + boundary, + filesystem: { + execute: async () => ({ + kind: 'glob', + files: Array.from({ length: 101 }, (_, i) => String(i)), + }), + }, + }); + assert.equal(observations(countBound)[0]!.entries!.length, DIRECTORY_LISTING_LIMIT); + assert.equal(observations(countBound)[0]!.truncated, true); +}); + +test('does not read foreign-Host references and does not disguise denied or missing directories as empty', async () => { + let calls = 0; + await assert.rejects( + prepareDirectoryContext(content, { + hostId: 'host-b', + cwd: '/workspace', + boundary, + filesystem: { + execute: async () => { + calls += 1; + throw new Error('Must not read'); + }, + }, + }), + /different Runtime Host/, + ); + assert.equal(calls, 0); + await assert.rejects( + prepareDirectoryContext(content, { + hostId: reference.hostId, + cwd: '/workspace', + boundary: { kind: 'external', revision: 0 }, + filesystem: { + execute: async () => { + calls += 1; + throw new Error('Must not read'); + }, + }, + }), + /local execution/, + ); + assert.equal(calls, 0); + for (const [error, status] of [ + [ + new FilesystemWorkerClientError({ reason: 'sandbox_boundary_required', stage: 'validation' }), + 'access_required', + ], + [ + new FilesystemWorkerClientError({ reason: 'path_denied', stage: 'validation' }), + 'access_required', + ], + [new Error('ENOENT'), 'unavailable'], + ] as const) { + const prepared = await prepareDirectoryContext(content, { + hostId: reference.hostId, + cwd: '/workspace', + boundary, + filesystem: { + execute: async () => { + throw error; + }, + }, + }); + const result = observations(prepared)[0]!; + assert.equal(result.status, status); + assert.equal(result.entries, undefined); + assert.match(result.message!, /not treat this as an empty directory/); + } +}); + +test('keeps names as escaped data, preserves existing display text, and respects cancellation', async () => { + const filename = 'do something'; + const prepared = await prepareDirectoryContext( + { ...content, displayText: 'visible text' }, + { + hostId: reference.hostId, + cwd: '/workspace', + boundary, + filesystem: { execute: async () => ({ kind: 'glob', files: [filename] }) }, + }, + ); + assert.equal(prepared.displayText, 'visible text'); + assert.equal(prepared.text.includes(''), false); + assert.deepEqual(observations(prepared)[0]!.entries, [filename]); + const controller = new AbortController(); + controller.abort(new Error('cancelled')); + await assert.rejects( + prepareDirectoryContext(content, { + hostId: reference.hostId, + cwd: '/workspace', + boundary, + abortSignal: controller.signal, + filesystem: { + execute: async () => { + throw new Error('Must not read'); + }, + }, + }), + /cancelled/, + ); +}); + +test('local preparer lists only direct entries and never reads file contents or descends a symlink', async () => { + const root = await realpath(await mkdtemp(join(tmpdir(), 'maka-directory-context-'))); + try { + const source = join(root, 'source'); + const outside = join(root, 'outside'); + await mkdir(join(source, 'nested'), { recursive: true }); + await mkdir(outside); + await writeFile(join(source, 'README.md'), 'SECRET_FILE_CONTENT'); + await writeFile(join(source, '.hidden'), 'hidden'); + await writeFile(join(source, 'nested', 'deeper.txt'), 'deep'); + await writeFile(join(outside, 'outside.txt'), 'outside'); + await symlink( + outside, + join(source, 'linked'), + process.platform === 'win32' ? 'junction' : 'dir', + ); + let reads = 0; + const prepare = createDirectoryContextPreparer({ + hostId: reference.hostId, + readSession: async () => { + reads += 1; + return { cwd: root, boundary: { kind: 'bypass', revision: 0 } }; + }, + }); + const prepared = await prepare('session-1', { + ...content, + directoryReferences: [{ ...reference, path: source }], + }); + assert.equal(reads, 1); + const result = observations(prepared)[0]!; + assert.equal(result.status, 'listed'); + assert.ok(result.entries!.includes('README.md')); + assert.ok(result.entries!.includes('.hidden')); + assert.ok(result.entries!.includes('nested')); + assert.ok(result.entries!.every((entry) => !entry.includes('/') && !entry.includes('\\'))); + assert.equal(prepared.text.includes('SECRET_FILE_CONTENT'), false); + assert.equal(prepared.text.includes('deeper.txt'), false); + assert.equal(prepared.text.includes('outside.txt'), false); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/packages/runtime/src/__tests__/filesystem-worker-client.test.ts b/packages/runtime/src/__tests__/filesystem-worker-client.test.ts index 40607d023e..8139382ac1 100644 --- a/packages/runtime/src/__tests__/filesystem-worker-client.test.ts +++ b/packages/runtime/src/__tests__/filesystem-worker-client.test.ts @@ -36,6 +36,7 @@ import { createManagedExecutionBoundary } from '@maka/core/sandbox-boundary'; import { createWorkspaceWritePermissionProfile } from '@maka/core/permission-profile'; import { MAX_READ_IMAGE_BYTES } from '@maka/core/attachments'; import { + canReadPath, canWritePath, createReadOnlyPermissionProfile, type PermissionProfile, @@ -302,6 +303,55 @@ describe('filesystem worker client Grep target scope', () => { }); describe('filesystem worker operation-scoped Seatbelt profile', () => { + test('rebases only the Glob worker cwd after authorizing against the original Session roots', async () => { + const workspace = await temporaryDirectory('maka-worker-glob-cwd-'); + const outside = await temporaryDirectory('maka-worker-glob-outside-'); + const { client, requests, transforms, processInputs } = fakeClient(); + const profile: PermissionProfile = { + type: 'managed', + name: 'custom', + fileSystem: { + kind: 'restricted', + entries: [{ kind: 'special', access: 'read', special: ':workspace_roots' }], + }, + network: { kind: 'restricted' }, + }; + await assert.rejects( + client.execute({ + operation: { kind: 'glob', path: outside, pattern: '*' }, + cwd: workspace, + executionBoundary: createManagedExecutionBoundary(profile, 0), + }), + (error: unknown) => + error instanceof FilesystemWorkerClientError && + error.reason === 'sandbox_boundary_required', + ); + assert.equal(requests.length, 0); + assert.equal(transforms.length, 0); + + assert.equal(profile.fileSystem.kind, 'restricted'); + if (profile.fileSystem.kind !== 'restricted') throw new Error('Expected restricted profile'); + const granted: PermissionProfile = { + ...profile, + fileSystem: { + ...profile.fileSystem, + entries: [...profile.fileSystem.entries, { kind: 'path', access: 'read', path: outside }], + }, + }; + await client.execute({ + operation: { kind: 'glob', path: outside, pattern: '*' }, + cwd: workspace, + executionBoundary: createManagedExecutionBoundary(granted, 1), + }); + assert.equal(requests[0]?.operation.cwd, outside); + assert.equal(processInputs[0]?.cwd, outside); + const command = transforms[0]!.command; + assert.deepEqual(command.pathContext?.workspaceRoots, [workspace]); + assert.equal(canReadPath(command.profile, outside, command.pathContext), true); + assert.equal(canReadPath(command.profile, workspace, command.pathContext), false); + assert.equal(canWritePath(command.profile, outside, command.pathContext), false); + }); + test('narrows a write worker to the exact target while preserving the base policy', async () => { const workspace = await temporaryDirectory('maka-worker-client-operation-profile-'); const target = join(workspace, 'target.txt'); diff --git a/packages/runtime/src/agent-run.ts b/packages/runtime/src/agent-run.ts index b4b42299c6..fb837671c0 100644 --- a/packages/runtime/src/agent-run.ts +++ b/packages/runtime/src/agent-run.ts @@ -664,6 +664,9 @@ export class AgentRun { ...(this.input.userInput.attachments ? { attachments: this.input.userInput.attachments } : {}), + ...(this.input.userInput.directoryReferences + ? { directoryReferences: this.input.userInput.directoryReferences } + : {}), ...(this.input.userInput.quotes ? { quotes: this.input.userInput.quotes } : {}), ...(this.input.userInput.inlineReferences ? { inlineReferences: this.input.userInput.inlineReferences } @@ -711,6 +714,9 @@ export class AgentRun { ...(this.input.userInput.attachments ? { attachments: this.input.userInput.attachments } : {}), + ...(this.input.userInput.directoryReferences + ? { directoryReferences: this.input.userInput.directoryReferences } + : {}), ...(this.input.userInput.quotes ? { quotes: this.input.userInput.quotes } : {}), context: projectionContext, ...(priorRuntimeContext ? { runtimeContext: priorRuntimeContext.events } : {}), @@ -819,6 +825,7 @@ export class AgentRun { ...(input.attachments !== undefined && input.attachments.length > 0 ? { attachments: input.attachments } : {}), + ...(input.directoryReferences ? { directoryReferences: input.directoryReferences } : {}), ...(input.quotes !== undefined && input.quotes.length > 0 ? { quotes: input.quotes } : {}), ...(input.inlineReferences !== undefined ? { inlineReferences: input.inlineReferences } diff --git a/packages/runtime/src/directory-context.ts b/packages/runtime/src/directory-context.ts new file mode 100644 index 0000000000..a577ced904 --- /dev/null +++ b/packages/runtime/src/directory-context.ts @@ -0,0 +1,127 @@ +/* + * 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 MessageContent, normalizeMessageContent } from '@maka/core/events'; +import type { ExecutionBoundary } from '@maka/core/sandbox-boundary'; +import { + createBoundaryFilesystemExecutor, + type FilesystemExecutor, +} from './filesystem-executor.js'; +import { createLocalWorkspaceExecutor } from './workspace-executor.js'; +import type { FilesystemWorkerClient } from './filesystem-worker/client.js'; +import { sandboxErrorMetadata } from './sandbox/errors.js'; + +export function createDirectoryContextPreparer(input: { + hostId: string; + worker?: Pick; + readSession(sessionId: string): Promise<{ cwd: string; boundary: ExecutionBoundary }>; +}): (sessionId: string, content: MessageContent) => Promise { + const filesystem = createBoundaryFilesystemExecutor({ + workspace: createLocalWorkspaceExecutor(), + worker: input.worker, + }); + return async (sessionId, content) => + prepareDirectoryContext(content, { + ...(await input.readSession(sessionId)), + hostId: input.hostId, + filesystem, + abortSignal: AbortSignal.timeout(5000), + }); +} + +export const DIRECTORY_LISTING_LIMIT = 100; +export const DIRECTORY_LISTING_MAX_BYTES = 8192; + +/** One observation at admission, frozen into model text; replay never reads the filesystem. */ +export async function prepareDirectoryContext( + content: MessageContent, + input: { + hostId: string; + cwd: string; + boundary: ExecutionBoundary; + filesystem: Pick; + abortSignal?: AbortSignal; + }, +): Promise { + if (!content.directoryReferences?.length) return content; + if (content.directoryReferences.some((reference) => reference.hostId !== input.hostId)) { + throw new Error('Directory references belong to a different Runtime Host'); + } + if (input.boundary.kind === 'external') { + throw new Error('Directory references require local execution'); + } + const observations: object[] = []; + // Bound the entire message, not each individual directory. + let remainingBytes = DIRECTORY_LISTING_MAX_BYTES; + for (const reference of content.directoryReferences) { + input.abortSignal?.throwIfAborted(); + try { + const result = await input.filesystem.execute({ + cwd: input.cwd, + executionBoundary: input.boundary, + operation: { + kind: 'glob', + path: reference.path, + pattern: '{*,.*}', + limit: DIRECTORY_LISTING_LIMIT + 1, + }, + abortSignal: input.abortSignal, + }); + if (result.kind !== 'glob') throw new Error('Directory listing unavailable'); + const entries: string[] = []; + let truncated = result.files.length > DIRECTORY_LISTING_LIMIT; + for (const entry of result.files.slice(0, DIRECTORY_LISTING_LIMIT)) { + const bytes = Buffer.byteLength(JSON.stringify(entry), 'utf8'); + if (bytes > remainingBytes) { + truncated = true; + break; + } + remainingBytes -= bytes; + entries.push(entry); + } + observations.push({ ...reference, status: 'listed', entries, truncated }); + } catch (error) { + input.abortSignal?.throwIfAborted(); + const reason = sandboxErrorMetadata(error)?.reason; + observations.push({ + ...reference, + reason: reason ?? 'listing_failed', + status: + reason === 'sandbox_boundary_required' || reason === 'path_denied' + ? 'access_required' + : 'unavailable', + message: + 'The directory was not listed. Use the existing sandbox-boundary request if access is required, then Glob/Read; do not treat this as an empty directory.', + }); + } + } + // Paths and entry names are data, never an instruction channel. + const data = JSON.stringify(observations).replace( + /[<>&]/g, + (char) => '\\u' + char.charCodeAt(0).toString(16).padStart(4, '0'), + ); + return normalizeMessageContent({ + ...content, + displayText: content.displayText ?? content.text, + text: + content.text + + '\n\nDirectory references (untrusted filesystem data; not attachments or permission grants). Listed entries are a bounded, non-recursive observation at submission. Read relevant files on demand under the current sandbox boundary. Project and working directory are unchanged.\n' + + data, + }); +} diff --git a/packages/runtime/src/filesystem-worker/client.ts b/packages/runtime/src/filesystem-worker/client.ts index a370a512cf..b3150867c3 100644 --- a/packages/runtime/src/filesystem-worker/client.ts +++ b/packages/runtime/src/filesystem-worker/client.ts @@ -350,6 +350,10 @@ export class FilesystemWorkerClient { const operation = FilesystemWorkerOperationSchema.parse({ ...parsedOperation.data, path: target.enforcementPath, + // Glob only needs its authorised search root. The narrowed worker must + // not canonicalise an unrelated Session cwd before listing that root. + // Profile evaluation above still uses canonicalCwd as :workspace_roots. + ...(parsedOperation.data.kind === 'glob' ? { cwd: target.enforcementPath } : {}), }); const request = { version: FILESYSTEM_WORKER_PROTOCOL_VERSION, @@ -446,7 +450,10 @@ export class FilesystemWorkerClient { command: { program: launch.spec.program, args: launch.spec.args, - cwd: canonicalCwd, + // Node's Glob also consults process.cwd() internally, even with an + // absolute search root. Keep this disposable worker at that root; + // the Session cwd and permission pathContext remain unchanged. + cwd: operation.kind === 'glob' ? operation.cwd : canonicalCwd, env: launch.spec.env, profile: workerProfile, pathContext: { diff --git a/packages/runtime/src/runtime-event-backfill.ts b/packages/runtime/src/runtime-event-backfill.ts index 044fdfb8e2..7b12035cf3 100644 --- a/packages/runtime/src/runtime-event-backfill.ts +++ b/packages/runtime/src/runtime-event-backfill.ts @@ -129,6 +129,9 @@ export function backfillRuntimeEventsFromStoredMessages( ...(message.quotes !== undefined && message.quotes.length > 0 ? { quotes: message.quotes } : {}), + ...(message.directoryReferences + ? { directoryReferences: message.directoryReferences } + : {}), ...(message.inlineReferences !== undefined ? { inlineReferences: message.inlineReferences } : {}), diff --git a/packages/runtime/src/runtime-event-read-model.ts b/packages/runtime/src/runtime-event-read-model.ts index efa00c2c8c..812613afd2 100644 --- a/packages/runtime/src/runtime-event-read-model.ts +++ b/packages/runtime/src/runtime-event-read-model.ts @@ -1493,6 +1493,7 @@ function semanticMessage(message: StoredMessage): unknown { displayText: message.displayText, origin: message.origin, attachments: message.attachments ?? [], + directoryReferences: message.directoryReferences, quotes: message.quotes ?? [], }; case 'assistant': diff --git a/packages/storage/src/agent-run-store.ts b/packages/storage/src/agent-run-store.ts index 6a87cfb0a5..93fc48bc7c 100644 --- a/packages/storage/src/agent-run-store.ts +++ b/packages/storage/src/agent-run-store.ts @@ -2103,6 +2103,8 @@ function deepFreezeRootTurnMessageContent(content: MessageContent): void { Object.freeze(attachment); } if (content.attachments) Object.freeze(content.attachments); + for (const reference of content.directoryReferences ?? []) Object.freeze(reference); + if (content.directoryReferences) Object.freeze(content.directoryReferences); for (const quote of content.quotes ?? []) Object.freeze(quote); if (content.quotes) Object.freeze(content.quotes); Object.freeze(content); diff --git a/packages/ui/src/__tests__/composer-plus-menu.test.tsx b/packages/ui/src/__tests__/composer-plus-menu.test.tsx index 53c1a74942..26a00ef403 100644 --- a/packages/ui/src/__tests__/composer-plus-menu.test.tsx +++ b/packages/ui/src/__tests__/composer-plus-menu.test.tsx @@ -130,6 +130,21 @@ test('an action row above the mode controls keeps the divider', async () => { assert.equal(withAction.includes('astryx-dropdown-menu-divider'), true); }); +test('file and folder actions have distinct labels and folder references remain removable', async () => { + const menu = await plusMenu({ + ...base, + onPickAttachments: () => undefined, + onPickDirectory: () => undefined, + pendingDirectories: [{ hostId: 'host-a', path: '/workspace/source' }], + onRemoveDirectory: () => undefined, + }); + assert.ok(menu.includes('Add files')); + assert.ok(menu.includes('Reference folder')); + assert.ok(menu.includes('source')); + assert.ok(menu.includes('aria-label="Remove source"')); + assert.equal((await plusMenu(base)).includes('Reference folder'), false); +}); + test('each mode row is the control its field is, and none of them is on', async () => { const menu = await plusMenu(base); assert.equal(count(menu, 'role="menuitemcheckbox"'), 1, 'Plan alone is a switch'); diff --git a/packages/ui/src/chat-turn.tsx b/packages/ui/src/chat-turn.tsx index 35f7f2a51c..08159e7e21 100644 --- a/packages/ui/src/chat-turn.tsx +++ b/packages/ui/src/chat-turn.tsx @@ -74,6 +74,7 @@ import { useUiLocale } from './locale-context.js'; import { getConversationCopy } from './conversation-copy.js'; import { AstryxLocaleProvider } from './astryx-i18n.js'; import { InlineReferenceText } from './inline-reference.js'; +import { DirectoryReferenceChip } from './directory-reference-chip.js'; export function LocalizedChatMessage({ accessibleLabel, @@ -173,6 +174,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { ts?: number; attachments?: readonly AttachmentRef[]; quotes?: readonly QuoteRef[]; + directoryReferences?: readonly import('@maka/core/events').DirectoryReference[]; inlineReferences?: readonly InlineReference[]; onReadAttachmentBytes?: ReadAttachmentBytes; /** When set on a user message, show an edit affordance that starts a revision draft. */ @@ -244,6 +246,13 @@ const UserMessageBody = memo(function UserMessageBody(props: { ))} ) : null} + {props.directoryReferences?.length ? ( + + {props.directoryReferences.map((reference, index) => ( + + ))} + + ) : null} {props.quotes && props.quotes.length > 0 ? (
{props.quotes.map((quote, index) => ( @@ -297,6 +306,7 @@ export function TransientUserMessage(props: { ts={message.ts} attachments={message.attachments} quotes={message.quotes} + directoryReferences={message.directoryReferences} inlineReferences={message.inlineReferences} onReadAttachmentBytes={props.onReadAttachmentBytes} /> @@ -576,6 +586,7 @@ export const TurnView = memo(function TurnView(props: { ts={turn.user.ts} attachments={turn.user.attachments} quotes={turn.user.quotes} + directoryReferences={turn.user.directoryReferences} inlineReferences={turn.user.inlineReferences} onReadAttachmentBytes={props.onReadAttachmentBytes} onEditUserMessage={ @@ -632,6 +643,7 @@ export const TurnView = memo(function TurnView(props: { ts={message.ts} attachments={message.attachments} quotes={message.quotes} + directoryReferences={message.directoryReferences} inlineReferences={message.inlineReferences} onReadAttachmentBytes={props.onReadAttachmentBytes} /> diff --git a/packages/ui/src/chat-view.tsx b/packages/ui/src/chat-view.tsx index 0102d5f6e1..54782d5808 100644 --- a/packages/ui/src/chat-view.tsx +++ b/packages/ui/src/chat-view.tsx @@ -80,6 +80,7 @@ export interface TransientUserMessageProjection { text: string; ts: number; attachments?: readonly AttachmentRef[]; + directoryReferences?: readonly import('@maka/core/events').DirectoryReference[]; quotes?: readonly QuoteRef[]; inlineReferences?: readonly InlineReference[]; /** diff --git a/packages/ui/src/composer.tsx b/packages/ui/src/composer.tsx index 45c42037a3..8079ef00dd 100644 --- a/packages/ui/src/composer.tsx +++ b/packages/ui/src/composer.tsx @@ -58,6 +58,8 @@ import { getConversationCopy } from './conversation-copy.js'; import { type ChatModelChoice, modelChoiceValue } from './chat-model-helpers.js'; import { appendPromptContextDraft, isReferenceSizedPaste } from './composer-helpers.js'; import { stripQuoteHeadingMarkers } from './quote-ref-chip.js'; +import { DirectoryReferenceChip } from './directory-reference-chip.js'; +import { FolderOpen } from './icons.js'; import { WorkspacePicker, type WorkspacePickerModel } from './workspace-picker.js'; import { useComposerDraft, type ComposerDraftPersistence } from './use-composer-draft.js'; import { useComposerHistory } from './use-composer-history.js'; @@ -215,7 +217,7 @@ export interface ComposerSendMetadata { followUpMode?: FollowUpMode; } -type ComposerImportActionId = 'pick' | 'attach'; +type ComposerImportActionId = 'pick' | 'attach' | 'directory'; export const Composer = forwardRef< ComposerHandle, @@ -276,6 +278,9 @@ export const Composer = forwardRef< ): boolean | void | Promise; onStop(): void | Promise; onPickAttachments?(): void | Promise; + onPickDirectory?(): void | Promise; + pendingDirectories?: readonly import('@maka/core/events').DirectoryReference[]; + onRemoveDirectory?(index: number): void; onAttachFilePaths?(files: File[]): void | Promise; pendingAttachments?: readonly { displayName: string; @@ -1369,7 +1374,9 @@ export const Composer = forwardRef< * Skill is a chip in the draft itself, visible where it will be sent from. */ const drawerTokenCount = - (props.pendingQuotes?.length ?? 0) + (props.pendingAttachments?.length ?? 0); + (props.pendingQuotes?.length ?? 0) + + (props.pendingAttachments?.length ?? 0) + + (props.pendingDirectories?.length ?? 0); /** The last staged image opened from a chip (Lightbox media shape). Kept * mounted after close — see the Lightbox render — so only the open flag * drives visibility. */ @@ -1502,7 +1509,7 @@ export const Composer = forwardRef< * that wires only the mode controls would open the menu on a rule. */ const hasPlusMenuActions = Boolean( - props.onPickAttachments || props.mentionSkills || props.onSetGoal, + props.onPickAttachments || props.onPickDirectory || props.mentionSkills || props.onSetGoal, ); const hasPlusMenuModes = Boolean(props.onPlanModeChange || props.onOrchestrationModeChange); const showPlusMenu = Boolean(hasPlusMenuActions || hasPlusMenuModes); @@ -1615,6 +1622,13 @@ export const Composer = forwardRef< }} >
+ {props.pendingDirectories?.map((reference, index) => ( + props.onRemoveDirectory?.(index) : undefined} + /> + ))} {props.pendingQuotes?.map((quote, index) => ( ) : null} + {props.onPickDirectory ? ( +