Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { syncBuiltinESMExports } from "node:module";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { test } from "node:test";
import { createHash } from "node:crypto";
import { registerRuntimeHostArtifactsIpc } from "../runtime-host-artifacts-ipc-main.js";

type Handler = (event: unknown, ...args: any[]) => unknown;
Expand All @@ -32,6 +33,34 @@ type StreamArtifact = (
writeChunk: (chunk: Uint8Array) => Promise<void>,
) => Promise<number>;

test('routes archive reads to the Host and rejects inconsistent reference evidence', async () => {
const handlers = new Map<string, Handler>();
const text = JSON.stringify('中文 retained output '.repeat(4_000));
const bytes = Buffer.from(text);
const identity = { artifactId: 'archive-1', originalBytes: bytes.length,
bodySha256: createHash('sha256').update(bytes).digest('hex') };
registerRuntimeHostArtifactsIpc({
uiLocale: () => 'zh-CN' as const,
ipcMain: { handle: (channel, handler) => handlers.set(channel, handler as Handler) },
client: {
hostEpoch: 'host-1',
async readToolResult(sessionId: string, ref: string) {
assert.equal(sessionId, 'session-1');
assert.equal(ref, `maka://archive/archive-1/${identity.bodySha256}/${bytes.length}`);
return { ok: true, text };
},
} as never,
mainWindowController: {} as never,
showItemInFolder() {},
});
const read = handlers.get('artifacts:readToolResult');
assert.ok(read);
assert.deepEqual(await read({}, 'session-1', identity), { ok: true, text });
assert.deepEqual(await read({}, 'session-1', { ...identity,
resourceRef: `maka://archive/archive-1/${'0'.repeat(64)}/${bytes.length}` }),
{ ok: false, reason: 'not_allowed' });
});

// Exercise the public Save As result and destination bytes together. Faults
// use real temporary files; only the failing filesystem operation is mocked.
for (const [fault, reason] of [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -913,6 +913,21 @@ interface RecordedRequest {
input: unknown;
}

test('assembles archived UTF-8 output across Host chunks and preserves read failures', async () => {
const bytes = Buffer.from('中文 retained output '.repeat(2000));
const split = 32768;
const responses = [0, split].map(offset => ({ kind: 'archive_chunk', sessionId: 'session-1',
offset, totalBytes: bytes.length, chunkBase64: bytes.subarray(offset, offset ? undefined : split).toString('base64'),
nextOffset: offset ? null : split }));
const { client, requests } = clientWithResponses(responses);
assert.deepEqual(await client.readToolResult('session-1', 'archive-ref'), { ok: true, text: bytes.toString('utf8') });
assert.deepEqual(requests.map(request => request.input), [0, split].map(offset => ({
kind: 'read_archive_chunk', sessionId: 'session-1', ref: 'archive-ref', offset,
})));
const failure = clientWithResponses([{ kind: 'archive_unavailable', sessionId: 'session-1', reason: 'not_found' }]);
assert.deepEqual(await failure.client.readToolResult('session-1', 'archive-ref'), { ok: false, reason: 'not_found' });
});

function clientWithResponses(responses: unknown[]): {
client: DesktopRuntimeHostClient;
requests: RecordedRequest[];
Expand Down
26 changes: 23 additions & 3 deletions apps/desktop/src/main/runtime-host-artifacts-ipc-main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,10 @@ import {
normalizeArtifactImagePreviewMime,
resolveArtifactImagePreview,
type ArtifactSaveResult,
type ArtifactTextReadResult,
type ToolResultArchiveIdentity,
} from '@maka/core/artifacts';
import { buildToolResultArchiveResourceRef, parseToolResultArchiveResourceRef } from '@maka/runtime/tool-result-archive-resource';
import { sanitizeArtifactName } from "@maka/storage/artifact-stores";
import {
handleReconnectableRead,
Expand All @@ -50,7 +53,7 @@ type RuntimeHostAttachmentPreviewIpcDeps = Pick<
'ipcMain' | 'client'
>;

const ATTACHMENT_PREVIEW_LIMIT_EXCEEDED = Symbol("attachment-preview-limit-exceeded");
const ARTIFACT_READ_LIMIT_EXCEEDED = Symbol("artifact-read-limit-exceeded");

export function registerRuntimeHostArtifactsIpc(
deps: RuntimeHostArtifactsIpcDeps,
Expand All @@ -76,6 +79,23 @@ export function registerRuntimeHostArtifactsIpc(
(_event, sessionId: string, artifactId: string) =>
deps.client.readArtifactBinary(sessionId, artifactId),
);
handleReconnectableRead(
deps.ipcMain,
'artifacts:readToolResult',
async (_event, sessionId: string, identity: ToolResultArchiveIdentity): Promise<ArtifactTextReadResult> => {
if (!identity || (!identity.resourceRef && typeof identity.artifactId !== 'string')
|| !Number.isSafeInteger(identity.originalBytes) || identity.originalBytes < 0
|| typeof identity.bodySha256 !== 'string' || !/^[a-f0-9]{64}$/.test(identity.bodySha256)) return { ok: false, reason: 'not_allowed' };
const ref = identity.resourceRef ?? buildToolResultArchiveResourceRef({
artifactId: identity.artifactId!, originalBytes: identity.originalBytes, bodySha256: identity.bodySha256,
});
if (typeof ref !== 'string') return { ok: false, reason: 'not_allowed' };
const parsed = parseToolResultArchiveResourceRef(ref);
if (!parsed || parsed.originalBytes !== identity.originalBytes || parsed.bodySha256 !== identity.bodySha256)
return { ok: false, reason: 'not_allowed' };
return deps.client.readToolResult(sessionId, ref);
},
);
deps.ipcMain.handle(
"artifacts:delete",
(_event, sessionId: string, artifactId: string) =>
Expand Down Expand Up @@ -163,12 +183,12 @@ export function registerRuntimeHostAttachmentPreviewIpc(
await deps.client.streamArtifact(sessionId, artifactId, async (chunk) => {
received += chunk.byteLength;
if (received > ARTIFACT_IMAGE_PREVIEW_MAX_BYTES) {
throw ATTACHMENT_PREVIEW_LIMIT_EXCEEDED;
throw ARTIFACT_READ_LIMIT_EXCEEDED;
}
chunks.push(Buffer.from(chunk));
});
} catch (error) {
if (error === ATTACHMENT_PREVIEW_LIMIT_EXCEEDED) {
if (error === ARTIFACT_READ_LIMIT_EXCEEDED) {
return { ok: false as const, reason: "too_large" };
}
throw error;
Expand Down
20 changes: 20 additions & 0 deletions apps/desktop/src/main/runtime-host-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
*/

import { createHash, randomUUID } from "node:crypto";
import type { ArtifactTextReadResult } from '@maka/core/artifacts';
import { TOOL_RESULT_ARCHIVE_MAX_BYTES } from '@maka/runtime/tool-result-archive-resource';
import type { AttachmentRef, ShellRunUpdate } from "@maka/core/events";
import type { PlanSessionState, PlanUserControlInput } from "@maka/core/plan";
import {
Expand Down Expand Up @@ -888,6 +890,24 @@ export class DesktopRuntimeHostClient {
return result.preview;
}

async readToolResult(sessionId: string, ref: string): Promise<ArtifactTextReadResult> {
const chunks: Buffer[] = [];
let offset = 0;
let total: number | undefined;
while (true) {
const result = await this.request('artifact.query', { kind: 'read_archive_chunk', sessionId, ref, offset });
if (result.kind === 'archive_unavailable' && result.sessionId === sessionId) return { ok: false, reason: result.reason };
if (result.kind !== 'archive_chunk' || result.sessionId !== sessionId || result.offset !== offset
|| (total !== undefined && result.totalBytes !== total) || result.totalBytes > TOOL_RESULT_ARCHIVE_MAX_BYTES) {
throw invalidProjection('Archived output');
}
total = result.totalBytes;
chunks.push(Buffer.from(result.chunkBase64, 'base64'));
if (result.nextOffset === null) return { ok: true, text: Buffer.concat(chunks).toString('utf8') };
offset = result.nextOffset;
}
}

async readArtifactBinary(
sessionId: string,
artifactId: string,
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/preload/bridge-contract.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ import type {
ArtifactDescriptor,
ArtifactSaveResult,
ArtifactTextReadResult,
ToolResultArchiveIdentity,
} from '@maka/core/artifacts';
import type { CapabilitySnapshotCollection, PermissionSnapshot } from '@maka/core/capabilities';
import type { LocalMemoryState } from '@maka/core/local-memory';
Expand Down Expand Up @@ -1781,6 +1782,7 @@ export interface MakaBridge {
};
artifacts: {
list(sessionId: string): Promise<ArtifactDescriptor[]>;
readToolResult(sessionId: string, identity: ToolResultArchiveIdentity): Promise<ArtifactTextReadResult>;
readText(sessionId: string, artifactId: string): Promise<ArtifactTextReadResult>;
readBinary(sessionId: string, artifactId: string): Promise<ArtifactBinaryReadResult>;
delete(sessionId: string, artifactId: string): Promise<void>;
Expand Down
4 changes: 4 additions & 0 deletions apps/desktop/src/preload/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ import type {
ArtifactDescriptor,
ArtifactSaveResult,
ArtifactTextReadResult,
ToolResultArchiveIdentity,
} from '@maka/core/artifacts';
import type { CapabilitySnapshotCollection, PermissionSnapshot } from '@maka/core/capabilities';
import type { LocalMemoryState } from '@maka/core/local-memory';
Expand Down Expand Up @@ -3693,6 +3694,9 @@ const makaBridge = {
list(sessionId: string): Promise<ArtifactDescriptor[]> {
return invokeProjectedSessionRuntimeHost('artifacts:list', sessionId);
},
readToolResult(sessionId: string, identity: ToolResultArchiveIdentity): Promise<ArtifactTextReadResult> {
return invokeSessionRuntimeHost('artifacts:readToolResult', sessionId, identity);
},
readText(sessionId: string, artifactId: string): Promise<ArtifactTextReadResult> {
return invokeSessionRuntimeHost('artifacts:readText', sessionId, artifactId);
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import { SessionNavigationServicesProvider } from '../features/session-navigatio
import { WorkHubComposerServicesProvider } from '../features/workhub/index.js';
import { SessionSettingsServicesProvider } from '../features/session-settings';
import { TaskEntryServicesProvider } from '../features/task-entry';
import { WorkbarServicesProvider } from '../features/workbar';
import { WorkbarServicesProvider, ToolOutputPreviewProvider } from '../features/workbar';
import { createDesktopAppUpdateServices } from '../platform/desktop/create-app-update-services';
import { createDesktopGoalServices } from '../platform/desktop/create-goal-services';
import { createDesktopConnectionSettingsServices } from '../platform/desktop/create-connection-settings-services';
Expand Down Expand Up @@ -77,7 +77,9 @@ export function DesktopFeatureServicesProvider(props: {
<GoalServicesProvider services={props.services.goal}>
<WorkbarServicesProvider services={props.services.workbar}>
<ConversationServicesProvider services={props.services.conversation}>
{props.children}
<ToolOutputPreviewProvider>
{props.children}
</ToolOutputPreviewProvider>
</ConversationServicesProvider>
</WorkbarServicesProvider>
</GoalServicesProvider>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import { safeLocalStorageGet, safeLocalStorageSet } from '../../../browser-stora
import { getDesktopConversationCopy } from '../../../locales/conversation-copy.js';
import { getShellCopy, localizedShellErrorMessage } from '../../../locales/shell-copy.js';
import { sideChatTitleFromPrompt } from '../../../side-chat-command.js';
import { useToolOutputPreview } from '../tools/artifacts/tool-output-preview-context.js';
import { useWorkbarServices } from '../services-context.js';
import type { WorkbarHostModel } from '../ui/workbar-host.js';
import { SKIP_SIDE_CHAT_CLOSE_CONFIRMATION_KEY } from '../ui/side-chat-close-confirmation.js';
Expand Down Expand Up @@ -166,6 +167,8 @@ export function useWorkbarController(
input: UseWorkbarControllerInput,
): WorkbarController {
const locale = useUiLocale();
const toolOutput = useToolOutputPreview();
const openedOutput = useRef<unknown>(undefined);
const terminalCopy = getDesktopConversationCopy(locale).terminalPanel;
const { browser, sideChat, terminal } = useWorkbarServices();
const activeSessionId = input.activeSession?.id;
Expand Down Expand Up @@ -649,6 +652,15 @@ export function useWorkbarController(
return () => window.removeEventListener('keydown', handleShortcut, true);
}, [activeSessionId, input.available, input.shellObscured, openTool]);

useEffect(() => {
if (!toolOutput?.preview?.visible) { openedOutput.current = undefined; return; }
if (openedOutput.current === toolOutput.preview) return;
openedOutput.current = toolOutput.preview;
openTool('files');
}, [toolOutput?.preview, openTool]);
const closeToolOutput = toolOutput?.close;
useLayoutEffect(() => () => closeToolOutput?.(), [activeSessionId, closeToolOutput]);

const confirmPendingClose = useCallback(
(skipFutureConfirmations: boolean) => {
if (pendingSideChatClose.length === 0) return;
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/renderer/features/workbar/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,5 @@ export { WorkbarServicesProvider } from './services-context';
export { useWorkbarController } from './controller/use-workbar-controller';
export type { SessionWorkbarTabKind } from './model/workbar-tabs';
export type { WorkbarServices } from './ports';

export { ToolOutputPreviewProvider } from './tools/artifacts/tool-output-preview-context.js';
2 changes: 2 additions & 0 deletions apps/desktop/src/renderer/features/workbar/ports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
* under the License.
*/

import type { ToolResultArchiveIdentity } from '@maka/core/artifacts';
import type {
QuoteRef,
SessionEvent,
Expand Down Expand Up @@ -125,6 +126,7 @@ export type WorkbarOpenArtifactResult =
};

export interface WorkbarArtifactsService {
readToolResult?(sessionId: string, identity: ToolResultArchiveIdentity): Promise<ArtifactTextReadResult>;
list(sessionId: string): Promise<ArtifactDescriptor[]>;
readText(
sessionId: string,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ import { nextArtifactListAction } from './artifact-list-keyboard';
import { filterUserVisibleArtifacts } from './artifact-visibility';
import { openPathFailureCopy } from '../../../../open-path';
import { getArtifactCopy, type ArtifactCopy } from '../../../../locales/artifact-copy';
import { ToolOutputPreview } from './tool-output-preview.js';
import { useToolOutputPreview } from './tool-output-preview-context.js';
import { useWorkbarServices } from '../../services-context.js';

export function ArtifactPane(props: {
Expand All @@ -88,6 +90,7 @@ export function ArtifactPane(props: {
onDismiss?: () => void;
}) {
const { sessionId } = props;
const toolOutput = useToolOutputPreview();
const { artifacts } = useWorkbarServices();
const toast = useToast();
const locale = useUiLocale();
Expand Down Expand Up @@ -446,7 +449,10 @@ export function ArtifactPane(props: {
}
}

return (
return <>
{toolOutput?.preview?.visible && <ToolOutputPreview key={toolOutput.preview.id} request={toolOutput.preview.request}
onClose={() => { setView({ kind: 'list' }); toolOutput.hide(); }} />}
{!toolOutput?.preview?.visible && (
<div className="maka-artifact-pane" role="region" aria-label={copy.pane.panelAria} onKeyDown={handlePaneKeyDown}>
{activeListError && (
<Banner
Expand Down Expand Up @@ -589,8 +595,8 @@ export function ArtifactPane(props: {
</div>
</div>
) : null}
</div>
);
</div>)}
</>;
}

// ---- helpers ---------------------------------------------------------------
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,10 +107,10 @@ function FilePreview(props: { record: ArtifactDescriptor; copy: ArtifactCopy })
return <TextFilePreview name={props.record.name} text={result.value.text} copy={props.copy} />;
}

function TextFilePreview(props: { name: string; text: string; copy: ArtifactCopy }) {
export function TextFilePreview(props: { name: string; text: string; copy: ArtifactCopy; complete?: boolean }) {
const markdown = /\.(?:md|markdown)$/i.test(props.name);
const [mode, setMode] = useState<'rendered' | 'source'>(markdown ? 'rendered' : 'source');
const bounded = boundPreviewText(props.text);
const bounded = boundPreviewText(props.text, props.complete);

return (
<div className="maka-artifact-preview-text" data-mode={mode}>
Expand Down Expand Up @@ -255,10 +255,10 @@ type BoundedPreviewText = {
isDisplayTruncated: boolean;
};

function boundPreviewText(text: string): BoundedPreviewText {
function boundPreviewText(text: string, complete = false): BoundedPreviewText {
const bytes = new TextEncoder().encode(text);
const decoder = new TextDecoder();
const displayText = decoder.decode(utf8Prefix(bytes, TEXT_DISPLAY_LIMIT_BYTES));
const displayText = complete ? text : decoder.decode(utf8Prefix(bytes, TEXT_DISPLAY_LIMIT_BYTES));
const highlightCandidate = decoder.decode(utf8Prefix(bytes, TEXT_HIGHLIGHT_LIMIT_BYTES));
const lineBreak = highlightCandidate.lastIndexOf('\n');
let highlightedText = bytes.length > TEXT_HIGHLIGHT_LIMIT_BYTES && lineBreak > 0
Expand All @@ -274,7 +274,7 @@ function boundPreviewText(text: string): BoundedPreviewText {
highlightedText,
plainRemainder,
hasPlainRemainder: plainRemainder.length > 0,
isDisplayTruncated: bytes.length > TEXT_DISPLAY_LIMIT_BYTES,
isDisplayTruncated: !complete && bytes.length > TEXT_DISPLAY_LIMIT_BYTES,
};
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/*
* 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 { createContext, useCallback, useContext, useMemo, useRef, useState, type ReactNode } from 'react';
import { ToolResultHostProvider, type ToolOutputOpenRequest } from '@maka/ui';

const ToolOutputPreviewContext = createContext<{
preview?: { request: ToolOutputOpenRequest; id: number; visible: boolean };
close(): void;
hide(): void;
} | undefined>(undefined);

export const useToolOutputPreview = () => useContext(ToolOutputPreviewContext);

/** Retained tool output is a transient selection in the existing Files viewer. */
export function ToolOutputPreviewProvider(props: { children?: ReactNode }) {
const [preview, setPreview] = useState<{ request: ToolOutputOpenRequest; id: number; visible: boolean }>();
const opener = useRef<HTMLElement | null>(null);
const close = useCallback(() => { setPreview(undefined); opener.current = null; }, []);
const hide = useCallback(() => {
setPreview(current => current ? { ...current, visible: false } : current);
if (opener.current?.isConnected) opener.current.focus();
}, []);
const openOutput = useCallback((request: ToolOutputOpenRequest) => {
opener.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
setPreview(current => ({ request, id: (current?.id ?? 0) + 1, visible: true }));
}, []);
const value = useMemo(() => ({ preview, close, hide }), [preview, close, hide]);
return <ToolOutputPreviewContext.Provider value={value}>
<ToolResultHostProvider value={openOutput}>{props.children}</ToolResultHostProvider>
</ToolOutputPreviewContext.Provider>;
}
Loading