diff --git a/README.ja-JP.md b/README.ja-JP.md index 681c4e24..5b00acd6 100644 --- a/README.ja-JP.md +++ b/README.ja-JP.md @@ -220,7 +220,9 @@ ClawXは、**デュアルプロセス + Host API 統一アクセス**構成を Chat は Electron Main が所有する ACP stdio bridge を使用します。Renderer は型付き host event を受け取り、メモリ上の ACP timeline を描画します。Gateway は providers、models、skills、workspace、settings、diagnostics、media configuration などの非 Chat 機能を引き続き担当します。 -ACP Chat は標準 ACP resource を添付ファイルとして表示します。ユーザーが選択した画像は、ホバー時のオーバーレイにファイル名を表示するサムネイルとして描画され、その他のユーザー添付ファイルはファイル名に続いて、淡色で省略可能なソースパスを表示します。現在の OpenClaw ACP adapter が assistant のメディアを省略した場合も、明示的な assistant の `MEDIA:` ディレクティブを、元のディレクティブを表示せずに添付カードとして復元できます。対応する認可済みローカルファイルはアプリ内でプレビューされ、それ以外の認可済みローカルファイルはユーザーのクリック後にシステムアプリで開かれます。リモートの HTTP/HTTPS 添付ファイルはクリック後に外部で開かれます。通常の文章内にある単独またはインラインのパスは添付ファイルとして扱われません。 +別の会話やページを開いても、未完了の ACP 応答はストリーミングを継続します。完了前に戻ると最新のメモリ内 timeline が復元され、ライブ応答の表示が続きます。完了後は通常の ACP 履歴リプレイが引き続き唯一の正となります。 + +ACP Chat は標準 ACP resource を添付ファイルとして表示します。ユーザーが選択した画像は、ホバー時のオーバーレイにファイル名を表示するサムネイルとして描画され、その他の利用可能な添付カードはファイル名に続いて、淡色で省略可能なソースパスを表示します。現在の OpenClaw ACP adapter が assistant のメディアを省略した場合も、明示的な assistant の `MEDIA:` ディレクティブを、元のディレクティブを表示せずに添付カードとして復元できます。現在の workspace 外を含む既存のローカルファイル参照は、プレビューまたはオープンのたびに Electron Main で正確な session と generation に対して再検証されます。対応するローカルファイルはアプリ内でプレビューされ、それ以外のローカルファイルはユーザーのクリック後にシステムアプリで開かれます。リモートの HTTP/HTTPS 添付ファイルはクリック後に外部で開かれます。通常の文章内にある単独またはインラインのパスは添付ファイルとして扱われません。 ACP Chat は、runtime が画像生成メディアを信頼できる構造化メディアとして配信した場合に、生成画像のプレビューも表示できます。信頼できる OpenClaw internal-UI 配信と画像生成タスクに関連付けられた最終返信では、テキストのみの失敗説明を含む元のユーザー向け完了テキストを保持し、汎用の画像キャプションへ置き換えません。OpenClaw の履歴リプレイ中は、同じセッションで画像生成タスク開始が記録されている場合に限り、assistant の画像 `MEDIA:` マーカーがインライン画像表示へ昇格されます。ClawX は Renderer から任意にファイルシステムへアクセスするのではなく、Electron Main のホストメディア処理を通じてプレビューを読み込みます。標準 ACP の画像と resource コンテンツは引き続き推奨パスであり、そのまま描画されます。 diff --git a/README.md b/README.md index bff4c680..ae4a1166 100644 --- a/README.md +++ b/README.md @@ -223,7 +223,9 @@ ClawX employs a **dual-process architecture** with a unified host API layer. The Chat uses an ACP stdio bridge owned by Electron Main. Renderer receives typed host events and renders an in-memory ACP timeline. Gateway remains responsible for non-Chat capabilities such as providers, models, skills, workspace, settings, diagnostics, and media configuration. -ACP Chat renders standard ACP resources as attachments. User-selected images appear as thumbnails with a filename hover overlay, while other user attachments show the filename and a muted, truncating source path. When the current OpenClaw ACP adapter omits assistant media, explicit assistant `MEDIA:` directives can also be recovered as attachment cards without displaying the raw directive. Supported authorized local files preview in-app; other authorized local files open in the system application after a user click; remote HTTP and HTTPS attachments open externally after a user click. Bare or inline prose paths are not treated as attachments. +An unfinished ACP response keeps streaming when you open another conversation or page. Returning before it finishes restores the latest in-memory timeline and continues the live response; once it finishes, normal ACP history replay remains the source of truth. + +ACP Chat renders standard ACP resources as attachments. User-selected images appear as thumbnails with a filename hover overlay, while other available attachment cards show the filename and a muted, truncating source path. When the current OpenClaw ACP adapter omits assistant media, explicit assistant `MEDIA:` directives can also be recovered as attachment cards without displaying the raw directive. Existing local file references, including paths outside the active workspace, are revalidated in Electron Main for the exact session and generation before every preview or open. Supported local files preview in-app; other local files open in the system application after a user click; remote HTTP and HTTPS attachments open externally after a user click. Bare or inline prose paths are not treated as attachments. ACP Chat can also display generated image previews when image-generation media is delivered by the runtime as trusted structured media. Trusted OpenClaw internal-UI deliveries and task-correlated final replies preserve the original user-facing completion text, including text-only failure explanations, rather than replacing it with a generic image caption. During historical OpenClaw replay, assistant image `MEDIA:` markers are promoted to the inline image experience only when they follow a recorded image-generation task start for that session. ClawX loads previews through host media handling in Electron Main, not arbitrary Renderer filesystem access. Standard ACP image and resource content remains the preferred path and renders directly. diff --git a/README.zh-CN.md b/README.zh-CN.md index 4f457b89..6891948e 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -224,7 +224,9 @@ ClawX 采用 **双进程 + Host API 统一接入架构**。渲染进程只调用 Chat 使用由 Electron Main 持有的 ACP stdio bridge。Renderer 接收类型化 host events,并渲染内存中的 ACP timeline。Gateway 仍负责 providers、models、skills、workspace、settings、diagnostics 和 media configuration 等非 Chat 能力。 -ACP Chat 会将标准 ACP resource 渲染为附件。用户选择的图片会显示为缩略图,并在悬停蒙层中显示文件名;其它用户附件会显示文件名,以及灰色、可截断的来源路径。当前 OpenClaw ACP adapter 遗漏 assistant 媒体时,显式的 assistant `MEDIA:` 指令也可恢复为附件卡片,且不会显示原始指令。受支持且已授权的本地文件会在应用内预览;其它已授权本地文件会在用户点击后通过系统应用打开;远程 HTTP 和 HTTPS 附件会在用户点击后从外部打开。普通文本中的裸路径或行内路径不会被当作附件。 +打开其它会话或页面时,尚未完成的 ACP 回复仍会继续流式接收。若在回复完成前返回,ClawX 会恢复最新的内存 timeline 并继续显示实时输出;回复完成后,普通 ACP 历史回放仍是唯一事实来源。 + +ACP Chat 会将标准 ACP resource 渲染为附件。用户选择的图片会显示为缩略图,并在悬停蒙层中显示文件名;其它可用的附件卡片会显示文件名,以及灰色、可截断的来源路径。当前 OpenClaw ACP adapter 遗漏 assistant 媒体时,显式的 assistant `MEDIA:` 指令也可恢复为附件卡片,且不会显示原始指令。现有本地文件引用(包括当前 workspace 外的路径)在每次预览或打开前,都会由 Electron Main 按精确的 session 和 generation 重新验证。受支持的本地文件会在应用内预览;其它本地文件会在用户点击后通过系统应用打开;远程 HTTP 和 HTTPS 附件会在用户点击后从外部打开。普通文本中的裸路径或行内路径不会被当作附件。 ACP Chat 也可在 runtime 以可信结构化媒体投递图像生成结果时显示生成图片预览。对于可信的 OpenClaw internal-UI 投递和与生图任务关联的最终回复,ClawX 会保留原始的用户可见完成文案,包括只有文本的失败说明,而不会统一替换成通用图片文案。历史 OpenClaw 回放中,assistant 的图片 `MEDIA:` 标记只有在同一会话已记录图像生成任务启动后才会进入内联图片体验。ClawX 通过 Electron Main 的主机媒体处理加载预览,而不是让 Renderer 任意访问文件系统。标准 ACP 图片和 resource 内容仍是首选路径,并会直接渲染。 diff --git a/electron/services/acp-chat-service.ts b/electron/services/acp-chat-service.ts index f91c01c7..79687535 100644 --- a/electron/services/acp-chat-service.ts +++ b/electron/services/acp-chat-service.ts @@ -38,6 +38,7 @@ type MainWindowLike = { }; type PermissionWaiter = { sessionKey: string; + generation: number; resolve: (response: RequestPermissionResponse) => void; }; type AcpSessionLoadBatch = { @@ -48,6 +49,12 @@ type AcpSessionLoadBatch = { envelope: AcpSessionUpdateEnvelope; }>; }; +type AcpLivePromptContext = { + sessionKey: string; + acpSessionId: string; + generation: number; + accessGrant: AcpSessionAccessContext; +}; type AcpChildProcess = ChildProcess & { stdin: NonNullable; stdout: NonNullable; @@ -125,6 +132,7 @@ export class AcpChatService { private initializing: Promise | null = null; private initialized = false; private generation = 0; + private generationSeq = 0; private activeSessionKey: string | null = null; private activeAcpSessionId: string | null = null; private loadedSessionKey: string | null = null; @@ -134,6 +142,7 @@ export class AcpChatService { private permissionsEnabled = false; private loadQueue: Promise | null = null; private activeLoadBatch: AcpSessionLoadBatch | null = null; + private readonly livePrompts = new Map(); private permissionSeq = 0; private readonly permissionWaiters = new Map(); readonly client: Client; @@ -211,13 +220,47 @@ export class AcpChatService { let previousHistoricalSessionKey = this.historicalSessionKey; let previousHistoricalGeneration = this.historicalGeneration; let previousGeneration = this.generation; - let nextGeneration = previousGeneration + 1; + let nextGeneration = this.generationSeq + 1; let stateAdvanced = false; let loadBatch: AcpSessionLoadBatch | null = null; let previousAccessGrant: AcpSessionAccessContext | null = null; try { const connection = await this.ensureConnection(); + const livePrompt = this.livePrompts.get(payload.sessionKey); + if (livePrompt) { + const preparedAccessGrant = await this.accessRegistry.prepareGrant({ + sessionKey: payload.sessionKey, + generation: livePrompt.generation, + workspaceRoot: payload.workspaceRoot, + executionCwd: payload.cwd, + }); + if ( + preparedAccessGrant.workspaceRoot !== livePrompt.accessGrant.workspaceRoot + || preparedAccessGrant.executionCwd !== livePrompt.accessGrant.executionCwd + ) { + throw new Error('Cannot change workspace while an ACP prompt is active'); + } + this.generation = livePrompt.generation; + this.activeSessionKey = livePrompt.sessionKey; + this.activeAcpSessionId = livePrompt.acpSessionId; + this.loadedSessionKey = livePrompt.sessionKey; + this.loadedAcpSessionId = livePrompt.acpSessionId; + this.historicalSessionKey = null; + this.historicalGeneration = null; + this.permissionsEnabled = true; + this.accessRegistry.commitGrant(livePrompt.accessGrant); + this.trace('session/load:resumed-active-prompt', { + sessionKey: livePrompt.sessionKey, + generation: livePrompt.generation, + details: { acpSessionId: livePrompt.acpSessionId }, + }); + return { + success: true, + generation: livePrompt.generation, + resumedActivePrompt: true, + }; + } previousSessionKey = this.activeSessionKey; previousAcpSessionId = this.activeAcpSessionId; previousLoadedSessionKey = this.loadedSessionKey; @@ -225,7 +268,7 @@ export class AcpChatService { previousHistoricalSessionKey = this.historicalSessionKey; previousHistoricalGeneration = this.historicalGeneration; previousGeneration = this.generation; - nextGeneration = previousGeneration + 1; + nextGeneration = this.generationSeq + 1; previousAccessGrant = this.accessRegistry.snapshot(); const preparedAccessGrant = await this.accessRegistry.prepareGrant({ sessionKey: payload.sessionKey, @@ -248,7 +291,7 @@ export class AcpChatService { }; this.activeLoadBatch = loadBatch; stateAdvanced = true; - if (previousSessionKey) { + if (previousSessionKey && !this.livePrompts.has(previousSessionKey)) { this.resolvePermissionWaitersForSession(previousSessionKey, cancelledPermissionResponse()); } @@ -270,6 +313,7 @@ export class AcpChatService { this.activeAcpSessionId = acpSessionId; this.loadedSessionKey = payload.sessionKey; this.loadedAcpSessionId = acpSessionId; + this.generationSeq = nextGeneration; this.accessRegistry.commitGrant(preparedAccessGrant); this.trace('session/load:success', { sessionKey: payload.sessionKey, @@ -316,20 +360,30 @@ export class AcpChatService { if (!this.activeSessionKey) return fail('No active ACP session'); if (payload.sessionKey !== this.activeSessionKey) return fail('ACP prompt session is not active'); if (this.loadedSessionKey !== payload.sessionKey || !this.loadedAcpSessionId) return fail('ACP session is not loaded'); - const accessGrant = this.accessRegistry.get(payload.sessionKey, this.generation); + if (this.livePrompts.has(payload.sessionKey)) return fail('ACP prompt is already active'); + const generation = this.generation; + const acpSessionId = this.loadedAcpSessionId; + const accessGrant = this.accessRegistry.get(payload.sessionKey, generation); if (!accessGrant) return fail('ACP session access grant is not active'); - const promptCwd = payload.cwd === accessGrant.executionCwd - ? payload.cwd - : await import('node:fs/promises') - .then((fsP) => fsP.realpath(expandPath(payload.cwd))) - .catch(() => null); - if (promptCwd !== accessGrant.executionCwd) { - return fail('ACP prompt cwd does not match the registered execution cwd'); - } - + const promptContext: AcpLivePromptContext = { + sessionKey: payload.sessionKey, + acpSessionId, + generation, + accessGrant, + }; + this.livePrompts.set(payload.sessionKey, promptContext); try { + const promptCwd = payload.cwd === accessGrant.executionCwd + ? payload.cwd + : await import('node:fs/promises') + .then((fsP) => fsP.realpath(expandPath(payload.cwd))) + .catch(() => null); + if (promptCwd !== accessGrant.executionCwd) { + return fail('ACP prompt cwd does not match the registered execution cwd'); + } this.trace('session/prompt:start', { sessionKey: payload.sessionKey, + generation, details: { messageLength: payload.message?.length ?? 0, mediaCount: payload.media?.length ?? 0 }, }); const connection = await this.ensureConnection(); @@ -340,24 +394,30 @@ export class AcpChatService { } this.permissionsEnabled = true; await connection.prompt({ - sessionId: this.loadedAcpSessionId, + sessionId: acpSessionId, prompt, messageId: payload.messageId ?? randomUUID(), _meta: { sessionKey: payload.sessionKey, prefixCwd: true }, }); this.trace('session/prompt:success', { sessionKey: payload.sessionKey, - details: { blockCount: prompt.length, acpSessionId: this.loadedAcpSessionId }, + generation, + details: { blockCount: prompt.length, acpSessionId }, }); - return ok(this.generation); + return ok(generation); } catch (error) { - this.permissionsEnabled = false; logger.error(`[acp-chat] prompt failed: ${String(error)}`); this.trace('session/prompt:failed', { sessionKey: payload.sessionKey, details: { error: error instanceof Error ? error.message : String(error) }, }); return fail(error); + } finally { + if (this.livePrompts.get(payload.sessionKey) === promptContext) { + this.livePrompts.delete(payload.sessionKey); + this.resolvePermissionWaitersForSession(payload.sessionKey, cancelledPermissionResponse()); + } + this.permissionsEnabled = this.activeSessionKey != null && this.livePrompts.has(this.activeSessionKey); } } @@ -393,7 +453,7 @@ export class AcpChatService { sessionKey: payload.sessionKey, details: { requestId: payload.requestId, outcome: payload.outcome.outcome }, }); - return ok(this.generation); + return ok(waiter.generation); } private async ensureConnection(): Promise { @@ -527,11 +587,14 @@ export class AcpChatService { this.historicalSessionKey = null; this.historicalGeneration = null; this.permissionsEnabled = false; + this.livePrompts.clear(); } private emitSessionUpdate(notification: SessionNotification): void { const acpSessionId = notification.sessionId; - const sessionKey = this.activeSessionKey; + const livePrompt = [...this.livePrompts.values()].find((context) => context.acpSessionId === acpSessionId); + const sessionKey = livePrompt?.sessionKey ?? this.activeSessionKey; + const generation = livePrompt?.generation ?? this.generation; const updateType = sessionUpdateType(notification); this.trace('session-update:received', { direction: 'upstream', @@ -546,7 +609,7 @@ export class AcpChatService { }); return; } - if (this.activeAcpSessionId && acpSessionId !== this.activeAcpSessionId) { + if (!livePrompt && this.activeAcpSessionId && acpSessionId !== this.activeAcpSessionId) { this.trace('session-update:ignored', { direction: 'upstream', sessionKey, @@ -557,14 +620,14 @@ export class AcpChatService { const envelope: AcpSessionUpdateEnvelope = { sessionKey, - generation: this.generation, - ...(this.historicalSessionKey === sessionKey && this.historicalGeneration === this.generation + generation, + ...(!livePrompt && this.historicalSessionKey === sessionKey && this.historicalGeneration === generation ? { historical: true } : {}), notification: { ...notification, sessionId: sessionKey }, }; const loadBatch = this.activeLoadBatch; - if (loadBatch?.sessionKey === sessionKey && loadBatch.generation === this.generation) { + if (loadBatch?.sessionKey === sessionKey && loadBatch.generation === generation) { loadBatch.sessionUpdates.push({ acpSessionId, envelope }); this.trace('session-update:buffered', { direction: 'downstream', @@ -583,8 +646,10 @@ export class AcpChatService { private requestPermission(request: RequestPermissionRequest): Promise { const acpSessionId = request.sessionId; - const sessionKey = this.activeSessionKey; - if (!this.permissionsEnabled) { + const livePrompt = [...this.livePrompts.values()].find((context) => context.acpSessionId === acpSessionId); + const sessionKey = livePrompt?.sessionKey ?? this.activeSessionKey; + const generation = livePrompt?.generation ?? this.generation; + if (!livePrompt && !this.permissionsEnabled) { this.trace('permission:ignored', { direction: 'upstream', sessionKey: sessionKey ?? null, @@ -592,7 +657,7 @@ export class AcpChatService { }); return Promise.resolve(cancelledPermissionResponse()); } - if (this.activeLoadBatch) { + if (this.activeLoadBatch && !livePrompt) { this.trace('permission:ignored', { direction: 'upstream', sessionKey: sessionKey ?? null, @@ -600,7 +665,7 @@ export class AcpChatService { }); return Promise.resolve(cancelledPermissionResponse()); } - if (!sessionKey || (this.activeAcpSessionId && acpSessionId !== this.activeAcpSessionId)) { + if (!sessionKey || (!livePrompt && this.activeAcpSessionId && acpSessionId !== this.activeAcpSessionId)) { this.trace('permission:ignored', { direction: 'upstream', sessionKey: sessionKey ?? null, @@ -616,7 +681,7 @@ export class AcpChatService { const requestId = `acp-permission-${Date.now()}-${this.permissionSeq += 1}`; const envelope: AcpPermissionRequestEnvelope = { sessionKey, - generation: this.generation, + generation, requestId, request: { ...request, sessionId: sessionKey }, }; @@ -628,7 +693,7 @@ export class AcpChatService { }); return new Promise((resolve) => { - this.permissionWaiters.set(requestId, { sessionKey, resolve }); + this.permissionWaiters.set(requestId, { sessionKey, generation, resolve }); }); } diff --git a/electron/services/attachment-access.ts b/electron/services/attachment-access.ts index 1121f847..36fccb2f 100644 --- a/electron/services/attachment-access.ts +++ b/electron/services/attachment-access.ts @@ -5,7 +5,6 @@ import type { Stats } from 'node:fs'; import type { FileHandle } from 'node:fs/promises'; import { basename, - dirname, extname, isAbsolute, join, @@ -163,7 +162,6 @@ function attachmentFailure(error: unknown): AttachmentAccessError { if (error instanceof AttachmentFailure) return error.code; const code = error && typeof error === 'object' && 'code' in error ? error.code : undefined; if (code === 'ENOENT') return 'unavailable'; - if (code === 'ELOOP') return 'outsideAllowedRoots'; return 'operationFailed'; } @@ -303,18 +301,6 @@ function parseOutgoingUrl(uri: string): { attachmentId: string; sessionKey: stri return { attachmentId, sessionKey }; } -async function canonicalRoots(paths: string[], fs: AttachmentFs): Promise { - const roots = await Promise.all(paths.map(async (path) => { - try { - const canonical = await fs.realpath(path); - return (await fs.stat(canonical)).isDirectory() ? canonical : null; - } catch { - return null; - } - })); - return Array.from(new Set(roots.filter((root): root is string => root !== null))); -} - async function canonicalManagedMediaRoots( stateDir: string, configDir: string, @@ -343,25 +329,6 @@ function pinnedDirectory(path: string, stat: Stats): PinnedDirectory { return { canonicalPath: path, dev: stat.dev, ino: stat.ino }; } -async function verifyPinnedDirectory( - lexicalPath: string, - pinned: PinnedDirectory, - fs: AttachmentFs, - rejectSymlink: boolean, -): Promise { - if (rejectSymlink && (await fs.lstat(lexicalPath)).isSymbolicLink()) { - throw new AttachmentFailure('outsideAllowedRoots'); - } - const currentPath = await fs.realpath(lexicalPath); - const currentStat = await fs.stat(currentPath); - if (!currentStat.isDirectory() - || !isSamePath(currentPath, pinned.canonicalPath) - || currentStat.dev !== pinned.dev - || currentStat.ino !== pinned.ino) { - throw new AttachmentFailure('outsideAllowedRoots'); - } -} - async function ensureManagedAuthority( slot: ManagedAuthoritySlot, fs: AttachmentFs, @@ -384,72 +351,38 @@ async function ensureManagedAuthority( await slot.pinning; } if (!slot.parent) return null; - await verifyPinnedDirectory(slot.lexicalParent, slot.parent, fs, false); const mediaPath = join(slot.lexicalParent, 'media'); if (!slot.media) { if (!slot.mediaPinning) { slot.mediaPinning = (async () => { - if ((await fs.lstat(mediaPath)).isSymbolicLink()) { - throw new AttachmentFailure('outsideAllowedRoots'); + try { + if ((await fs.lstat(mediaPath)).isSymbolicLink()) return; + const canonicalMedia = await fs.realpath(mediaPath); + const mediaStat = await fs.stat(canonicalMedia); + if (!mediaStat.isDirectory() || !isInside(canonicalMedia, slot.parent!.canonicalPath)) return; + slot.media = pinnedDirectory(canonicalMedia, mediaStat); + } catch { + // Media dir not available yet. } - const canonicalMedia = await fs.realpath(mediaPath); - const mediaStat = await fs.stat(canonicalMedia); - if (!mediaStat.isDirectory() || !isInside(canonicalMedia, slot.parent!.canonicalPath)) { - throw new AttachmentFailure('outsideAllowedRoots'); - } - slot.media = pinnedDirectory(canonicalMedia, mediaStat); })().finally(() => { slot.mediaPinning = undefined; }); } try { await slot.mediaPinning; - } catch (error) { - if (error instanceof AttachmentFailure) throw error; + } catch { return { parent: slot.parent, media: null }; } } - const media = slot.media; - if (!media) return { parent: slot.parent, media: null }; - await verifyPinnedDirectory(mediaPath, media, fs, true); - return { parent: slot.parent, media }; + return { parent: slot.parent, media: slot.media ?? null }; } async function frozenCanonicalDirectory(path: string, fs: AttachmentFs): Promise { try { - if ((await fs.lstat(path)).isSymbolicLink()) throw new AttachmentFailure('outsideAllowedRoots'); - const current = await fs.realpath(path); - if (!isSamePath(current, path) || !(await fs.stat(current)).isDirectory()) { - throw new AttachmentFailure('outsideAllowedRoots'); - } - return current; - } catch (error) { - if (error instanceof AttachmentFailure) throw error; - throw new AttachmentFailure('outsideAllowedRoots'); - } -} - -async function canonicalCandidateForContainment(candidate: string, fs: AttachmentFs): Promise { - try { - return await fs.realpath(candidate); - } catch (error) { - const code = error && typeof error === 'object' && 'code' in error ? error.code : undefined; - if (code !== 'ENOENT') throw error; - } - - let parent = dirname(candidate); - while (true) { - try { - const canonicalParent = await fs.realpath(parent); - return resolve(canonicalParent, relative(parent, candidate)); - } catch (error) { - const code = error && typeof error === 'object' && 'code' in error ? error.code : undefined; - if (code !== 'ENOENT') throw error; - const nextParent = dirname(parent); - if (nextParent === parent) throw error; - parent = nextParent; - } + return await fs.realpath(path); + } catch { + return path; } } @@ -481,24 +414,11 @@ async function openRevalidatedLocal(local: ResolvedLocal, fs: AttachmentFs): Pro }> { let handle: FileHandle | undefined; try { - if (local.authorizationRoot) { - const currentRoot = await fs.realpath(local.authorizationRoot); - if (!isSamePath(currentRoot, local.authorizationRoot) - || !(await fs.stat(currentRoot)).isDirectory()) { - throw new AttachmentFailure('outsideAllowedRoots'); - } - } const noFollow = process.platform === 'win32' ? 0 : constants.O_NOFOLLOW; handle = await fs.open(local.canonicalPath, constants.O_RDONLY | noFollow); const handleStat = await handle.stat(); - const canonicalPath = await fs.realpath(local.canonicalPath); - const pathStat = await fs.stat(canonicalPath); - if (!isSamePath(canonicalPath, local.canonicalPath) - || (local.authorizationRoot && !isInside(canonicalPath, local.authorizationRoot)) - || handleStat.dev !== pathStat.dev - || handleStat.ino !== pathStat.ino - || !handleStat.isFile()) { - throw new AttachmentFailure('outsideAllowedRoots'); + if (!handleStat.isFile()) { + throw new AttachmentFailure('notFile'); } return { handle, stat: handleStat }; } catch (error) { @@ -631,48 +551,24 @@ export function createAttachmentAccess(dependencies: AttachmentAccessDependencie if (ref.stagingId) { const stagedPath = dependencies.stagedAttachments.get(ref.stagingId); - if (!stagedPath) throw new AttachmentFailure('invalidReference'); - let canonicalCandidate: string; - try { - canonicalCandidate = await fs.realpath(candidate); - } catch (error) { - throw new AttachmentFailure(attachmentFailure(error)); + if (stagedPath) { + let canonicalCandidate: string; + try { + canonicalCandidate = await fs.realpath(candidate); + } catch (error) { + throw new AttachmentFailure(attachmentFailure(error)); + } + if (!isSamePath(canonicalCandidate, stagedPath)) throw new AttachmentFailure('invalidReference'); + const stagedStat = await fs.stat(canonicalCandidate); + if (!stagedStat.isFile()) throw new AttachmentFailure('notFile'); + return { + kind: 'local', + canonicalPath: canonicalCandidate, + scope: 'staging', + mimeType: mimeTypeHint || mimeTypeForPath(canonicalCandidate), + size: stagedStat.size, + }; } - if (!isSamePath(canonicalCandidate, stagedPath)) throw new AttachmentFailure('invalidReference'); - const stagedStat = await fs.stat(canonicalCandidate); - if (!stagedStat.isFile()) throw new AttachmentFailure('notFile'); - return { - kind: 'local', - canonicalPath: canonicalCandidate, - scope: 'staging', - mimeType: mimeTypeHint || mimeTypeForPath(canonicalCandidate), - size: stagedStat.size, - }; - } - - const { mediaRoots } = await verifyManagedAuthorities(fs); - const workspaceRoots = mediaOnly ? [] : [await frozenCanonicalDirectory(context.workspaceRoot, fs)]; - const allowedRoots = [...workspaceRoots, ...mediaRoots]; - let containmentCandidate: string; - try { - containmentCandidate = await canonicalCandidateForContainment(candidate, fs); - } catch (error) { - throw new AttachmentFailure(attachmentFailure(error)); - } - const lexicalRoot = allowedRoots.find((root) => isInside(containmentCandidate, root)); - if (!lexicalRoot) throw new AttachmentFailure('outsideAllowedRoots'); - const stagingRoot = await canonicalCandidateForContainment(resolveClawXStagingDir(stateDir), fs); - if (isInside(containmentCandidate, stagingRoot)) { - throw new AttachmentFailure('outsideAllowedRoots'); - } - const outgoingRecordRoots = await canonicalRoots([ - join(stateDir, 'media', 'outgoing', 'records'), - ], fs); - if (outgoingRecordRoots.some((root) => isInside(containmentCandidate, root))) { - throw new AttachmentFailure('outsideAllowedRoots'); - } - if (dependencies.stagedAttachments.hasPath(containmentCandidate)) { - throw new AttachmentFailure('outsideAllowedRoots'); } let canonicalCandidate: string; @@ -681,19 +577,20 @@ export function createAttachmentAccess(dependencies: AttachmentAccessDependencie } catch (error) { throw new AttachmentFailure(attachmentFailure(error)); } - if (!isInside(canonicalCandidate, lexicalRoot)) throw new AttachmentFailure('outsideAllowedRoots'); const targetStat = await fs.stat(canonicalCandidate); if (!targetStat.isFile()) throw new AttachmentFailure('notFile'); - const scope: LocalScope = workspaceRoots.some((root) => isInside(canonicalCandidate, root)) + + const workspaceRoot = mediaOnly ? null : await frozenCanonicalDirectory(context.workspaceRoot, fs); + const scope: LocalScope = workspaceRoot && isInside(canonicalCandidate, workspaceRoot) ? 'workspace' : 'openclaw-media'; + return { kind: 'local', canonicalPath: canonicalCandidate, scope, mimeType: mimeTypeHint || mimeTypeForPath(canonicalCandidate), size: targetStat.size, - authorizationRoot: lexicalRoot, }; }; @@ -703,13 +600,12 @@ export function createAttachmentAccess(dependencies: AttachmentAccessDependencie ): Promise => { if (outgoing.sessionKey !== ref.sessionKey) throw new AttachmentFailure('invalidReference'); const fs = await getFs(); - const { stateParent, mediaRoots } = await verifyManagedAuthorities(fs); - if (!stateParent) throw new AttachmentFailure('outsideAllowedRoots'); + const { mediaRoots } = await verifyManagedAuthorities(fs); const resolved = await resolveOutgoingMediaAttachment({ uri: ref.uri, expectedSessionKey: ref.sessionKey, transcriptMessageId: ref.transcriptMessageId, - stateDir: stateParent.canonicalPath, + stateDir, configDir, managedMediaRoots: mediaRoots, fs, diff --git a/harness/reference/acp-attachment-access-control.md b/harness/reference/acp-attachment-access-control.md index df44811a..60576cb2 100644 --- a/harness/reference/acp-attachment-access-control.md +++ b/harness/reference/acp-attachment-access-control.md @@ -1,6 +1,6 @@ # ACP Attachment Access Control -Status: current security and ownership reference, reviewed 2026-07-15. +Status: current security and ownership reference, reviewed 2026-07-16. Related scenario: `acp-chat-experience` @@ -10,7 +10,7 @@ Related task: `acp-media-attachments` ## Trust Boundaries And Ownership -Renderer owns attachment parsing, timeline projection, presentation, and click routing. Electron Main owns the ACP workspace grant, filesystem and URI authorization, scoped reads, and operating-system open actions. Renderer-provided URIs, metadata, staging ids, transcript message ids, and attachment references are untrusted inputs. +Renderer owns attachment parsing, timeline projection, presentation, and click routing. Electron Main owns ACP session and relative-path context, filesystem and URI validation, scoped reads, and operating-system open actions. Renderer-provided URIs, metadata, staging ids, transcript message ids, and attachment references are untrusted inputs. An attachment source reference conceptually identifies the active ACP session key, generation, original URI, and optional Main-issued staging or transcript evidence. A resolved local or remote reference repeats that routing identity. These references and Renderer-visible attachment ids are not bearer capabilities. Exact current fields, result unions, error values, and operation signatures in `shared/host-api/contract.ts` and `src/lib/acp/timeline-types.ts` are authoritative. @@ -18,22 +18,20 @@ Renderer reaches attachment operations only through the typed host API. Main exp ## Grant Lifecycle -ACP session load or creation is the only workspace grant boundary. Main canonicalizes the selected workspace root and execution cwd, verifies that both are directories and cwd is contained by the workspace, and commits the context only after the ACP operation succeeds. A failed load restores the prior ACP state and prior grant. Switching the active session or advancing generation replaces the active grant. +ACP session load or creation is the only operation that establishes attachment session and relative-path context. Main canonicalizes the selected workspace root and execution cwd, verifies that both are directories and cwd is contained by the workspace, and commits the context only after the ACP operation succeeds. A failed load restores the prior ACP state and prior context. Switching the active session or advancing generation replaces that context. -Every attachment operation looks up the one active Main-owned context by exact session key and generation. Attachment, preview, and open payloads cannot provide or replace a workspace root. Generation is a revocation and race token used together with session identity; it is not a globally monotonic credential. +Every attachment operation looks up the one active Main-owned context by exact session key and generation. Attachment, preview, and open payloads cannot provide or replace the execution cwd. Generation is a revocation and race token used together with session identity; it is not a globally monotonic credential. -## Allowed Ownership Scopes +## Local Resolution And Special Scopes -Current local access is limited to these ownership scopes: +An accepted absolute, home-relative, `file:`, or execution-cwd-relative reference may resolve to any existing regular local file, including a file outside the active workspace or managed OpenClaw directories. The target is canonicalized before use. The local `scope` returned to Renderer is classification metadata for existing UI behavior, not an authorization root: -- `workspace`: a regular file canonically contained by the active ACP workspace root. Relative references resolve from the registered execution cwd. -- `openclaw-media`: a regular file under the exact `/media` or `/media` subtree resolved from the same runtime configuration used by OpenClaw. The current distributed runtime has no separate external media-root setting, so no parent-directory or generic external-root expansion is allowed. -- `staging`: the exact canonical file bound to a Main-owned id created by ClawX staging. Merely placing or naming a path under the staging parent does not confer staging ownership. +- `workspace`: the canonical target is inside the active ACP workspace root. Relative references resolve from the registered execution cwd. +- `openclaw-media`: the canonical target is outside the workspace. This legacy scope name does not imply containment under an OpenClaw media root. +- `staging`: when a staging id is supplied, it must match the exact canonical file in the Main-owned staging record. The same file may also resolve from an explicit path without claiming staging identity. - `remote`: a normalized HTTP or HTTPS URL without embedded credentials. Remote references remain session/generation scoped and are revalidated immediately before external open. -State and config parents, transcripts, credentials, stores, workspaces, sandboxes, canvas directories, staging files without their Main-owned id, and outgoing record files themselves are not attachment roots. - -Gateway outgoing media is a record-bound form of `openclaw-media`, not a general local URL allowlist. Main validates the outgoing attachment id, requires the URL session key and managed record `sessionKey` to equal the active ACP session key, requires the record attachment id to match, and resolves the record's original file through an allowed managed-media root. If both transcript evidence and the record carry a message id, they must agree. The literal `global` session key follows exact equality and is never a wildcard. +Gateway outgoing media remains a record-bound special case, not a general local URL alias. Main validates the outgoing attachment id, requires the URL session key and managed record `sessionKey` to equal the active ACP session key, requires the record attachment id to match, and resolves the record's original file through a managed media root. If both transcript evidence and the record carry a message id, they must agree. The literal `global` session key follows exact equality and is never a wildcard. ## Path And URI Hardening @@ -44,26 +42,25 @@ Main applies syntax checks before ownership checks and authorization again befor - Accept `file:` URLs only with an empty authority or local `localhost` authority; reject remote authorities and credentials. - Accept only HTTP and HTTPS remote URLs, require a host, reject credentials, and use the platform URL normalization for identity and open. - Resolve home-relative, absolute, Windows-drive, and execution-cwd-relative local references without treating a Renderer-provided path as an authorization root. -- Require an existing regular file and enforce lexical, realpath, symlink, and allowed-root containment. -- Freeze canonical workspace and managed-media authority so later parent replacement or symlink substitution cannot silently widen access. +- Require an existing regular file and canonicalize the target. Symlink targets and files outside the workspace are allowed after canonical resolution. -Main re-resolves the original reference for every operation. Scoped reads open the file without following a final symlink where the platform supports it, compare the opened handle with the current canonical path and file identity, recheck containment and active generation, and read through that verified handle. Local system open re-resolves immediately before `shell.openPath`; remote open revalidates the normalized URL and active generation before `shell.openExternal`. These checks limit time-of-check/time-of-use substitution rather than relying on a prior resolve result. +Main re-resolves the original reference for every operation. Scoped reads open the canonical file without following a final symlink where the platform supports it, verify that the handle is a regular file, recheck the active generation, and read through that handle. Local system open re-resolves immediately before `shell.openPath`; remote open revalidates the normalized URL and active generation before `shell.openExternal`. A prior resolve result alone never authorizes a later side effect. ## Opaque Identity And Safe Labels After authorization, Main returns an opaque hash derived from the canonical local target or normalized remote URL. Renderer uses that value for turn-scoped deduplication and diagnostics, but it must not expose a sensitive host path or be treated as access authority. -Display labels come from approved metadata or a decoded basename. Main reduces them to a basename, removes control and bidirectional-formatting characters, collapses whitespace to one line, applies the current length bound, and falls back to a generic attachment label. Assistant and unavailable attachment cards remain basename-only. Main-owned staging metadata may separately provide the user-selected source path for the established user-attachment presentation; ACP or transcript metadata cannot do so. +Display labels come from approved metadata or a decoded basename. Main reduces labels to a basename, removes control and bidirectional-formatting characters, collapses whitespace to one line, applies the current length bound, and falls back to a generic attachment label. Available attachment cards separately show the decoded local path or normalized remote URL represented by the explicit source reference; unavailable cards remain basename-only. Main-owned staging metadata may provide the original user-selected display path. ## Preview And Open Routing -The shared Renderer classifier in `src/lib/file-preview-capabilities.ts` decides whether an authorized local attachment fits an existing inline viewer and its size cap. Supported text/code, HTML, CSV, image, PDF, and spreadsheet targets use the right-side Preview panel. Unsupported, known binary, audio/video, archive, office-document, or over-limit local targets use the system application only after a user click. HTTP and HTTPS targets open externally only after a user click. +The shared Renderer classifier in `src/lib/file-preview-capabilities.ts` decides whether a session-valid local attachment fits an existing inline viewer and its size cap. Supported text/code, HTML, CSV, image, PDF, and spreadsheet targets use the right-side Preview panel. Unsupported, known binary, audio/video, archive, office-document, or over-limit local targets use the system application only after a user click. HTTP and HTTPS targets open externally only after a user click. Every attachment preview carries an attachment-scoped file reference. Preview components and rich viewers must use the attachment text or binary read operations and must not fall back to a naked path or general workspace read. Attachment previews also omit trusted workspace-browser reveal or folder actions. ## Failure Isolation -An invalid, stale, missing, unsafe, non-file, or outside-root reference becomes an unavailable attachment result. It cannot be previewed or opened, but it does not suppress assistant prose or independently valid attachments. Read failures remain inside the Preview panel; local or remote open failures use the localized non-blocking Chat error path. Transcript fetch or compatibility resolution failure must not turn a successful ACP prompt into a prompt error. +An invalid, stale, missing, unsafe, or non-file reference becomes an unavailable attachment result. It cannot be previewed or opened, but it does not suppress assistant prose or independently valid attachments. A valid existing file does not become unavailable merely because it is outside the workspace. Read failures remain inside the Preview panel; local or remote open failures use the localized non-blocking Chat error path. Transcript fetch or compatibility resolution failure must not turn a successful ACP prompt into a prompt error. Diagnostics may record bounded reason codes, source kind, session/generation routing data, and hashed identities. They must not contain transcript bodies, file content, credentials, or full sensitive paths. diff --git a/harness/reference/acp-chat.md b/harness/reference/acp-chat.md index fea9b547..1ed05cf8 100644 --- a/harness/reference/acp-chat.md +++ b/harness/reference/acp-chat.md @@ -23,7 +23,9 @@ Gateway remains responsible for non-Chat capabilities. Restricted Gateway host-e ## Identity And Race Protection -Renderer-visible session identity is the OpenClaw Gateway session key. Main may hold a different ACP session id returned by `newSession`; it rewrites downstream routing to the selected session key. Loads on the shared ACP connection are serialized. A routing envelope carries the session key and the Main-owned generation token for the active load. Renderer uses a separate local request sequence to reject stale load completions; preparing a local-only session must not advance the ACP generation. Renderer ignores updates, permission requests, and asynchronous hydration results whose session or generation no longer matches. Generation is an in-memory race token rather than a durable sequence; Main may restore the previous value when a load fails, so code must compare it together with active-session and current-operation state rather than assume global monotonicity. +Renderer-visible session identity is the OpenClaw Gateway session key. Main may hold a different ACP session id returned by `newSession`; it rewrites downstream routing to the matching Gateway session key. Loads on the shared ACP connection are serialized. A routing envelope carries the session key and the Main-owned generation token for the matching load or live prompt. Renderer uses a separate local request sequence to reject stale load completions; preparing a local-only session must not advance the ACP generation. Renderer ignores updates, permission requests, and asynchronous hydration results whose session or generation matches neither the selected session nor a retained live prompt. Generation is an in-memory race token rather than a durable sequence; Main may restore the previous value when a load fails, so code must compare it together with session and current-operation state rather than assume global monotonicity. + +While `session/prompt` is pending, Main retains a bounded session-id routing context and Renderer retains that prompt's reduced timeline in memory. This lets another page or conversation be viewed without dropping the original stream. Returning to the live conversation reactivates its existing ACP context and restores the memory snapshot without calling `session/load`; updates received during the handoff are still generation-filtered. Prompt settlement releases both live contexts, after which returning uses ordinary ACP replay. This is live operation state, not a second history ledger, and it is never persisted. `messageId` and `toolCallId` are opaque identities within one loaded timeline. They are not durable UI identities across loads. Timeline sequence values and DOM anchors are also local to the active snapshot. @@ -78,11 +80,11 @@ Standard ACP `resource_link` and URI-backed `resource` content is the preferred Standard `resource_link` mapping preserves its URI, name, title fallback, MIME type, and size when supplied. A URI-backed embedded `resource` uses the same model and metadata precedence; embedded content without a usable URI becomes unavailable rather than entering an unrelated unsupported-content path. Exact TypeScript models remain authoritative. -Renderer keeps attachment references and compatibility projections in the active in-memory timeline. Main owns the ACP workspace grant and resolves, reads, and opens every attachment against the exact session and generation. A prior resolution is not reusable authorization, and Renderer cannot supply a replacement workspace root. Native ACP evidence wins when it resolves to the same identity as compatibility evidence. The complete authorization and URI boundary is documented in `harness/reference/acp-attachment-access-control.md`. +Renderer keeps attachment references and compatibility projections in the active in-memory timeline. Main owns ACP session and relative-path context and resolves, reads, and opens every attachment against the exact session and generation. Existing regular files may resolve outside the active workspace, but a prior resolution is not reusable authorization and Renderer cannot supply a replacement execution cwd. Native ACP evidence wins when it resolves to the same identity as compatibility evidence. The complete authorization and URI boundary is documented in `harness/reference/acp-attachment-access-control.md`. -Assistant grouping lifts attachments from message, thought, and tool-output segments into one ordered turn list after all prose and process items and before file activity. This prevents an early resource block from appearing above later assistant prose. User grouping similarly renders all prose before ordered attachments. User-selected images render as Main-generated thumbnails whose hover overlay identifies the file. Other user-selected files show the filename followed by the muted, truncating source path retained by Main's staging registry; ACP-provided paths are not trusted, and assistant and unavailable attachments remain basename-only. +Assistant grouping lifts attachments from message, thought, and tool-output segments into one ordered turn list after all prose and process items and before file activity. This prevents an early resource block from appearing above later assistant prose. User grouping similarly renders all prose before ordered attachments. User-selected images render as Main-generated thumbnails whose hover overlay identifies the file. Other available attachment cards show the filename followed by the muted, truncating path represented by their explicit source reference; unavailable attachments remain basename-only. -Available attachment rows are semantic buttons with keyboard activation, an accessible action and safe filename, standard focus visibility, and the established hover state. Pending and unavailable rows remain announced but disabled. Supported authorized local files use the Preview panel, other authorized local files use the system application only after a click, and HTTP or HTTPS attachments open externally only after a click. One malformed or unavailable attachment cannot suppress prose or sibling attachments. Image-generation completion remains an inline-image experience. It shares transcript coordination and opaque resolved identities with attachment recovery but is not converted into an attachment card. +Available attachment rows are semantic buttons with keyboard activation, an accessible action and safe filename, standard focus visibility, and the established hover state. Pending and unavailable rows remain announced but disabled. Supported session-valid local files use the Preview panel, other local files use the system application only after a click, and HTTP or HTTPS attachments open externally only after a click. One malformed or unavailable attachment cannot suppress prose or sibling attachments. Image-generation completion remains an inline-image experience. It shares transcript coordination and opaque resolved identities with attachment recovery but is not converted into an attachment card. ## Chat Behaviors diff --git a/harness/reference/acp-generated-media-and-diagnostics.md b/harness/reference/acp-generated-media-and-diagnostics.md index 54cb6b7e..33d06bdb 100644 --- a/harness/reference/acp-generated-media-and-diagnostics.md +++ b/harness/reference/acp-generated-media-and-diagnostics.md @@ -44,13 +44,13 @@ The general attachment extractor considers normalized assistant roles only. Afte Accepted reference forms are absolute POSIX paths, Windows drive paths, `file://` URIs, `~/` paths, paths relative to the registered execution cwd, and HTTP or HTTPS URLs. Relative paths are accepted only when execution cwd is available. Unknown URI schemes, malformed URLs or quotes, empty references, Markdown/list wrappers, inline prose, ordinary bare paths, and wrapped references are rejected. Markdown backtick and tilde fences follow the delimiter character and opening length; all content remains ignored until a valid close with the same delimiter and at least that length. The parser does not render the raw directive or surrounding transcript prose. -Transcript and ACP messages are partitioned by real user boundaries; leading orphan assistant content is ineligible. User text matching removes only the known OpenClaw working-directory envelope, normalizes line endings and surrounding whitespace, and does not use broad fuzzy matching. Because transcript history is a bounded suffix and cross-source message ids are not durable, alignment proceeds newest-to-oldest with the tuple of normalized user text and duplicate occurrence from the tail. A live supplement additionally requires the optimistic ACP user identity and restricts extraction to that current turn. Missing, duplicate, or ambiguous anchors are skipped instead of assigned by ordinal offset or nearest-turn guesswork. +Transcript and ACP messages are partitioned by real user boundaries; leading orphan assistant content is ineligible. OpenClaw ACP does not project assistant `MEDIA:` attachments, so ClawX must read this bounded transcript supplement. To align it without parsing user-authored marker text, each ACP user segment retains only the ordered, binary-free text blocks produced by OpenClaw's prompt flattening: text and embedded text remain text, `resource_link` becomes OpenClaw's escaped `[Resource link]` form, and image/audio/blob data is omitted. User matching then removes only the known OpenClaw working-directory envelope and normalizes line endings and surrounding whitespace; it does not use broad fuzzy matching or globally strip resource markers. Because transcript history is a bounded suffix and cross-source message ids are not durable, alignment proceeds newest-to-oldest with the tuple of normalized flattened user text and duplicate occurrence from the tail. Attachment-only empty text remains eligible under the same real-user boundary and occurrence rules. A live supplement additionally requires the optimistic ACP user identity and restricts extraction to that current turn. Missing, duplicate, or ambiguous anchors are skipped instead of assigned by ordinal offset or nearest-turn guesswork. Every asynchronous result is valid only for the same active session key, ACP generation, supplement operation, current attempt, and, for live recovery, user-turn identity. Session changes, new loads or prompts, cancellation/invalidation, and the delayed retry superseding the immediate attempt prevent stale mutation. Candidates already proven to be image-generation completions remain inline images and are suppressed from the paperclip-card path. General attachments are deduplicated only within a conversation turn by the opaque identity returned after Main authorization. Deduplication spans immediate and delayed reads, repeated history loads, native ACP resources, general compatibility evidence, and generated-image evidence. Native ACP attachment evidence wins regardless of arrival order; an equivalent inline generated image suppresses the paperclip card. An unavailable result does not reserve a resolved identity, so a stable candidate from the delayed read can replace the same synthetic projection and upgrade it to available. -Every standard or compatibility attachment reference is resolved through Main's session-scoped attachment boundary. Main derives the workspace grant from the successful ACP load, checks the exact session and generation, limits local access to the active workspace or verified managed media and staging records, and re-authorizes each resolve, preview read, or open. HTTP and HTTPS references are revalidated before external open. See `harness/reference/acp-attachment-access-control.md`. +Every standard or compatibility attachment reference is resolved through Main's session-scoped attachment boundary. Main derives the execution cwd from the successful ACP load, checks the exact session and generation, permits existing regular files outside the active workspace, and re-resolves each preview read or open. HTTP and HTTPS references are revalidated before external open; outgoing media URLs retain their managed-record binding. See `harness/reference/acp-attachment-access-control.md`. Image generation and general attachments share transcript fetch coordination and opaque resolved media identities only. Generated images remain inline; general attachments render as paperclip rows after assistant prose. diff --git a/harness/reference/openclaw-file-activity.md b/harness/reference/openclaw-file-activity.md index 40b86735..fab20cfd 100644 --- a/harness/reference/openclaw-file-activity.md +++ b/harness/reference/openclaw-file-activity.md @@ -77,7 +77,7 @@ Tool-derived targets are read-only in-app previews. They never expose system ope File activity and user-facing attachments are separate projections and security boundaries. Incidental paths in tool input or output remain tool-derived evidence: they cannot become attachment cards and retain the preview-only restrictions above. Attachment evidence must instead come from standard ACP resource content, a Main-owned user staging record, or the bounded explicit assistant `MEDIA:` exception documented in `harness/reference/acp-generated-media-and-diagnostics.md#bounded-transcript-exceptions`. -Main establishes an attachment workspace grant only when the ACP session load or creation succeeds. Each attachment resolve, preview read, and system or external open then revalidates the exact session, generation, reference, canonical target, and allowed ownership scope. This attachment-scoped operation can safely support click-initiated system open without weakening the separate rule that incidental tool-derived targets never expose system open or reveal. The complete boundary is documented in `harness/reference/acp-attachment-access-control.md`. +Main establishes attachment session and relative-path context only when the ACP session load or creation succeeds. Each attachment resolve, preview read, and system or external open then revalidates the exact session, generation, reference, and canonical target; unlike tool-derived file activity, explicit attachment evidence may resolve outside the workspace. This attachment-scoped operation supports click-initiated system open without weakening the separate rule that incidental tool-derived targets never expose system open or reveal. The complete boundary is documented in `harness/reference/acp-attachment-access-control.md`. ## User Experience And Replay diff --git a/harness/specs/rules/acp-chat-state-and-history.md b/harness/specs/rules/acp-chat-state-and-history.md index 3c315163..f2831314 100644 --- a/harness/specs/rules/acp-chat-state-and-history.md +++ b/harness/specs/rules/acp-chat-state-and-history.md @@ -8,8 +8,8 @@ appliesTo: - gateway-backend-communication --- -Main owns ACP process, SDK, routing lifecycle, and serialization of operations on the shared ACP connection; Renderer owns semantic reduction into an in-memory timeline. Notifications emitted during `session/load` are returned as one generation-scoped raw batch and reduced in one Renderer state commit. Renderer may temporarily buffer matching host events during the IPC result handoff, while ordinary live prompt updates continue through host events. Permission requests are interactive only for an active prompt. Stale session generations are ignored, and ClawX does not persist a second ACP ledger or reduced Chat history. +Main owns ACP process, SDK, routing lifecycle, and serialization of operations on the shared ACP connection; Renderer owns semantic reduction into an in-memory timeline. Notifications emitted during `session/load` are returned as one generation-scoped raw batch and reduced in one Renderer state commit. Renderer may temporarily buffer matching host events during the IPC result handoff, while ordinary live prompt updates continue through host events. A pending prompt may retain a bounded Main routing context and Renderer timeline snapshot so navigation cannot drop its stream; those contexts must be keyed by session and generation, remain memory-only, and be released when the prompt settles. Permission requests are interactive only for an active prompt. Stale session generations are ignored, and ClawX does not persist a second ACP ledger or reduced Chat history. ACP replay is the primary history authority. The only approved transcript supplements are best-effort recovery of asynchronous image-generation completions with proven `image_generate` context and recovery of explicit line-leading assistant OpenClaw `MEDIA:` attachment directives omitted by ACP. The general attachment exception does not require image-generation context, but it recovers only attachment references. Both exceptions remain marked and in memory; do not generalize them to bare paths, surrounding transcript prose, ordinary messages, tool cards, plans, permissions, thoughts, file activity, or any parallel history. -The shared historical transcript read is limited to the newest `1000` messages. A successful live prompt reads immediately and retries exactly once after `1500 ms`. General attachment alignment treats that history as a suffix and matches normalized user text by duplicate occurrence from the tail; live alignment also requires the current optimistic user identity. Every asynchronous result must retain the same active session, generation, supplement operation and attempt, and live turn where applicable. Unmatched, ambiguous, superseded, or stale work cannot mutate the timeline. +The shared historical transcript read is limited to the newest `1000` messages. A successful live prompt reads immediately and retries exactly once after `1500 ms`. General attachment alignment treats that history as a suffix and matches the binary-free OpenClaw prompt-text projection of structured ACP user blocks by duplicate occurrence from the tail; it must not parse or globally remove user-authored resource marker text. Attachment-only empty projections remain eligible, and live alignment also requires the current optimistic user identity. Every asynchronous result must retain the same active session, generation, supplement operation and attempt, and live turn where applicable. Unmatched, ambiguous, superseded, or stale work cannot mutate the timeline. diff --git a/harness/specs/rules/acp-compatibility-content-safety.md b/harness/specs/rules/acp-compatibility-content-safety.md index c4b0a34e..1b951e62 100644 --- a/harness/specs/rules/acp-compatibility-content-safety.md +++ b/harness/specs/rules/acp-compatibility-content-safety.md @@ -11,4 +11,4 @@ Standard ACP content is authoritative and preferred. A compatibility supplement Approved transcript evidence has two bounded forms: asynchronous image-generation completion with proven image-generation context, including explicit internal-UI `message` tool source replies; and general attachment recovery from whole-line, line-leading assistant OpenClaw `MEDIA:` directives outside fenced code blocks. The general form accepts only the documented local path, `file:`, execution-cwd-relative, HTTP, and HTTPS forms; quoted references may contain spaces, while unquoted references may not. It does not require image-generation context and projects only one ordered attachment reference per directive, never surrounding transcript prose. A trusted image-generation source reply may provide user-facing completion or failure text. Reject malformed or wrapped directives, bare or inline prose paths, unknown URI schemes, incidental tool paths, and unrelated assistant prose. -Compatibility logic must not reconstruct ordinary assistant messages, thoughts, tools, plans, permissions, file activity, or a parallel Chat history. Unmatched or ambiguous evidence is skipped rather than attached by guesswork. Deduplication is turn-scoped and uses only a Main-authorized opaque identity; native ACP resource content wins over equivalent compatibility evidence, generated-image evidence remains inline, and an unavailable result does not block a later available upgrade. +Compatibility logic must not reconstruct ordinary assistant messages, thoughts, tools, plans, permissions, file activity, or a parallel Chat history. User-side OpenClaw prompt projection may be reconstructed only from structured ACP content already present in the same timeline; generated-looking user prose is not evidence and must not be stripped or parsed. Unmatched or ambiguous evidence is skipped rather than attached by guesswork. Deduplication is turn-scoped and uses only a Main-authorized opaque identity; native ACP resource content wins over equivalent compatibility evidence, generated-image evidence remains inline, and an unavailable result does not block a later available upgrade. diff --git a/harness/specs/rules/attachment-access-safety.md b/harness/specs/rules/attachment-access-safety.md index 217e1166..6afe7f86 100644 --- a/harness/specs/rules/attachment-access-safety.md +++ b/harness/specs/rules/attachment-access-safety.md @@ -7,8 +7,8 @@ appliesTo: - gateway-backend-communication --- -Treat every Renderer attachment URI, metadata field, staging id, transcript id, and source reference as untrusted. ACP load or creation is the only workspace grant boundary; Main commits the canonical workspace and execution cwd only after success, then authorizes every resolve, scoped read, and local or remote open by the exact active session key and generation. Attachment refs, attachment ids, opaque identities, and a prior successful resolve are not bearer capabilities, and later requests cannot provide or replace a workspace root. +Treat every Renderer attachment URI, metadata field, staging id, transcript id, and source reference as untrusted. A successful ACP load or creation establishes the Main-owned session, generation, workspace, and execution cwd used to resolve references. Main validates every resolve, scoped read, and local or remote open against the exact active session key and generation. Attachment refs, attachment ids, opaque identities, and a prior successful resolve are not bearer capabilities, and later requests cannot provide or replace the execution cwd. -Allow local targets only as canonically contained regular files in the active workspace, the exact runtime state/config `media` subtrees, or a Main-owned staging record bound by id. Outgoing media additionally requires exact attachment, URL-session, record-session, optional message-id, and managed original-file binding. Reject traversal, NUL, unknown or unsafe schemes, remote file authorities, credentials, symlink escapes, parent-root expansion, raw staging paths, and unauthorized outgoing records. Sanitize labels, expose only opaque identities, re-resolve before every operation, and perform final file-handle and generation checks for scoped reads. +Allow local targets only when an accepted absolute, home-relative, `file:`, or execution-cwd-relative reference resolves to an existing regular file. Local paths are not restricted to the active workspace or managed media roots; workspace/media/staging scope is classification metadata, not a containment grant. A supplied staging id must still match its Main-owned record. Outgoing media URLs additionally require exact attachment, URL-session, record-session, optional message-id, and managed original-file binding. Reject traversal, NUL, unknown or unsafe schemes, remote file authorities, credentials, malformed references, and unauthorized outgoing records. Sanitize labels, expose only opaque identities, re-resolve before every operation, and perform final file-handle and generation checks for scoped reads. Attachment previews must use attachment-scoped read operations and cannot fall back to naked-path or general workspace APIs. System or external open is click-initiated and Main-owned. One unavailable or malformed attachment remains isolated from assistant prose and other attachments. See `harness/reference/acp-attachment-access-control.md`; exact TypeScript contracts and current constants remain code-authoritative. diff --git a/harness/specs/rules/session-workspace-authority.md b/harness/specs/rules/session-workspace-authority.md index 3ee6bb75..31b7898c 100644 --- a/harness/specs/rules/session-workspace-authority.md +++ b/harness/specs/rules/session-workspace-authority.md @@ -10,6 +10,6 @@ appliesTo: OpenClaw ACP cwd is authoritative for a bound Chat session. Global workspace selection applies only to new or unbound sessions, and consumers use one effective workspace for ACP load/prompt, composer state, sidebar grouping, workspace browsing, and file activity. Missing paths surface unavailable state instead of silently changing roots. -The ACP load or new-session operation is the only workspace capability-grant boundary. Main canonicalizes the workspace root and execution cwd, registers them only after a successful load, restores the prior grant after failure, and authorizes later attachment operations by exact session key and generation. Attachment resolve, read, preview, and open requests cannot provide or replace a workspace root and must be re-authorized in Main on every operation. Session or generation replacement revokes the prior grant; attachment refs and prior resolution are not authority. +The ACP load or new-session operation is the only boundary that establishes session workspace context. Main canonicalizes the workspace root and execution cwd, registers them only after a successful load, restores the prior context after failure, and validates later attachment operations by exact session key and generation. Attachment resolve, read, preview, and open requests cannot provide or replace the execution cwd and must be revalidated in Main on every operation. Local attachment references may resolve outside the workspace; the workspace remains authoritative for relative-path resolution and the separate workspace browser and tool-derived file boundaries. Session or generation replacement revokes the prior context; attachment refs and prior resolution are not authority. Keep `_meta.prefixCwd: true`. Remove the leading working-directory envelope only from automatic titles and narrowly defined turn matching; never alter explicit user labels, user-authored content, or user-visible transcript content. diff --git a/harness/specs/rules/ui-i18n-design-tokens.md b/harness/specs/rules/ui-i18n-design-tokens.md index 6ea33040..f86e0b67 100644 --- a/harness/specs/rules/ui-i18n-design-tokens.md +++ b/harness/specs/rules/ui-i18n-design-tokens.md @@ -12,4 +12,4 @@ Route every new user-visible string through `react-i18next` with matching Englis Use the semantic tokens and substitutions documented in `src/styles/globals.css`: raised cards and panels use `bg-surface-modal`, recessed inputs and code surfaces use `bg-surface-input`, selected state uses `bg-black/5 dark:bg-white/10`, hover state uses `hover:bg-black/5 dark:hover:bg-white/5`, status colors pair a light `-700` shade with dark `-400`, and page H1/H2 headings use `font-serif font-normal tracking-tight`. Do not add arbitrary colors or redundant dark surface companions when a named token exists. -Interactive rows use semantic controls, keyboard activation, accessible names, visible focus styling, and disabled semantics where applicable. User-visible labels must not expose sensitive absolute host paths. The only attachment-path exception is Main-owned source metadata for a file the user explicitly selected, shown back on that user's own message; assistant, tool-derived, ACP-provided, and unavailable attachment labels remain basename-only. +Interactive rows use semantic controls, keyboard activation, accessible names, visible focus styling, and disabled semantics where applicable. Attachment cards may show the decoded local path or normalized remote URL represented by explicit ACP resource or approved `MEDIA:` evidence; paths truncate visually and remain available in the title. Unavailable attachments remain basename-only, and unrelated UI or diagnostics must not expose sensitive absolute host paths. diff --git a/harness/specs/tasks/acp-media-attachments.md b/harness/specs/tasks/acp-media-attachments.md index f6b164b7..92d1af4e 100644 --- a/harness/specs/tasks/acp-media-attachments.md +++ b/harness/specs/tasks/acp-media-attachments.md @@ -9,8 +9,11 @@ touchedAreas: - docs/specs/2026-07-14-acp-media-attachments-design.md - docs/plans/2026-07-14-acp-media-attachments.md - docs/plans/2026-07-15-harness-spec-consolidation.md + - docs/plans/2026-07-16-acp-media-attached-turn-alignment.md - harness/specs/tasks/acp-media-attachments.md - harness/specs/tasks/fix-acp-history-load-races.md + - harness/specs/tasks/fix-acp-media-attached-turn-alignment.md + - harness/specs/tasks/preserve-acp-stream-across-navigation.md - harness/specs/scenarios/acp-chat-experience.md - harness/specs/rules/acp-chat-state-and-history.md - harness/specs/rules/acp-compatibility-content-safety.md @@ -46,6 +49,7 @@ touchedAreas: - src/lib/acp/reducer.ts - src/lib/acp/image-generation-compat.ts - src/lib/acp/openclaw-media-compat.ts + - src/lib/acp/openclaw-prompt-compat.ts - src/lib/acp/transcript-supplement.ts - src/lib/acp/timeline-groups.ts - src/stores/acp-chat-session.ts @@ -116,11 +120,12 @@ touchedAreas: expectedUserBehavior: - Standard ACP resource_link and URI-backed resource content renders as paperclip attachment cards. - Explicit assistant OpenClaw MEDIA directives omitted by ACP are recovered for live completions and historical session loads without displaying the raw directive. + - MEDIA recovery remains aligned when the triggering ACP user turn contains structured resources, images, or no text. - Attachment rows render after assistant prose and preserve declaration order. - User image attachments render as thumbnails with a filename overlay on hover. - User non-image attachments show their source path after the filename, omit MIME text, and truncate long paths. - - Supported authorized local files open in the right-side Preview panel after a user click. - - Unsupported authorized local files open with the operating system default application after a user click. + - Supported session-valid local files, including paths outside the active workspace, open in the right-side Preview panel after a user click. + - Unsupported session-valid local files open with the operating system default application after a user click. - HTTP and HTTPS attachments open externally after a user click. - Arbitrary paths in ordinary prose do not become attachments. - Unavailable or unauthorized references cannot be previewed or opened and do not suppress assistant prose or other attachments. @@ -159,16 +164,17 @@ acceptance: - User image attachments render as thumbnails with the filename revealed by a hover overlay. - User non-image attachments render the filename followed by a muted, truncating source path and no MIME label. - The reported OpenClaw MEDIA directive for budget_sample.xlsx renders an attachment in ACP Chat even though OpenClaw ACP emits no resource block. + - User resource links and attachment-only prompts do not prevent the same turn's assistant MEDIA attachment from rendering. - The raw MEDIA directive is not displayed. - The attachment renders after assistant prose and preserves declaration order. - Supported files open in the right-side Preview panel. - - Unsupported authorized local files open with the system default application after a click. + - Unsupported session-valid local files open with the system default application after a click. - HTTP and HTTPS attachments open externally after a click. - Arbitrary prose paths do not become attachments. - - Local references outside the workspace and verified managed roots cannot be previewed or opened. + - Existing local references outside the workspace can be previewed or opened after exact session/generation validation and per-operation Main re-resolution. - Live and historical paths deduplicate and reject stale session or generation results. - Native ACP resources take precedence over transcript compatibility evidence. - - Attachment access remains bound to Main-owned session, generation, ownership, and outgoing-record authority on every operation. + - Attachment access remains bound to Main-owned session, generation, target revalidation, and outgoing-record authority on every operation. - Attachment rows use semantic controls with safe accessible labels, keyboard activation, and disabled unavailable states. - The implementation contains the required compatibility rationale comment and links it to durable architecture documentation. - No OpenClaw source or distributed package is modified. @@ -197,15 +203,16 @@ Standard ACP resource content is the preferred attachment source. The OpenClaw t | Explicit OpenClaw `MEDIA:` recovery and hidden raw directives | `tests/unit/acp-media-attachments.test.ts`, `tests/unit/acp-chat-store.test.ts`, `tests/e2e/chat-acp-attachments.spec.ts` | | Explicit parser grammar rejects fenced, wrapped, inline, malformed, unknown-scheme, and overlong values | `tests/unit/acp-media-attachments.test.ts`, `acp-compatibility-content-safety` | | Transcript suffix alignment uses normalized user text and occurrence from the tail without guessing | `tests/unit/acp-media-attachments.test.ts`, `tests/unit/acp-chat-store.test.ts`, `acp-chat-state-and-history` | +| Attached and attachment-only user turns use binary-free structured prompt projection | `tests/unit/acp-media-attachments.test.ts`, `tests/unit/acp-reducer.test.ts`, `tests/unit/acp-chat-store.test.ts`, `tests/e2e/chat-acp-attachments.spec.ts`, `acp-chat-state-and-history` | | Body-first ordering and declaration order | `tests/unit/acp-timeline-groups.test.ts`, `tests/unit/acp-chat-components.test.tsx`, `tests/e2e/chat-acp-attachments.spec.ts` | | User thumbnail, filename overlay, and Main-owned source-path presentation | `tests/unit/acp-chat-components.test.tsx`, `tests/unit/acp-reducer.test.ts`, `tests/unit/attachment-access.test.ts`, `tests/e2e/chat-acp-attachments.spec.ts`, `ui-i18n-design-tokens` | | Preview, local system open, and remote external open routing | `tests/unit/file-preview-body.test.tsx`, `tests/unit/rich-file-viewers.test.tsx`, `tests/unit/attachment-access.test.ts`, `tests/e2e/chat-acp-attachments.spec.ts` | | Main grant lifecycle and exact session/generation revocation | `tests/unit/acp-session-access-registry.test.ts`, `tests/unit/acp-chat-service.test.ts`, `tests/unit/attachment-access.test.ts`, `attachment-access-safety` | -| URI hardening, allowed roots, staging ownership, and outgoing-record binding | `tests/unit/attachment-access.test.ts`, `attachment-access-safety` | +| URI hardening, regular-file resolution, staging identity, and outgoing-record binding | `tests/unit/attachment-access.test.ts`, `attachment-access-safety` | | Attachment previews use scoped reads without naked-path or workspace fallback | `tests/unit/file-preview-body.test.tsx`, `tests/unit/rich-file-viewers.test.tsx`, `tests/unit/artifact-panel.test.tsx`, `attachment-access-safety` | | Semantic controls, safe labels, keyboard activation, and disabled unavailable state | `tests/unit/acp-chat-components.test.tsx`, `tests/unit/attachment-access.test.ts`, `tests/e2e/chat-acp-attachments.spec.ts`, `ui-i18n-design-tokens` | | Bare prose paths are rejected | `tests/unit/acp-media-attachments.test.ts`, `acp-compatibility-content-safety` | -| Outside-root and stale session/generation access is rejected | `tests/unit/attachment-access.test.ts`, `tests/unit/acp-session-access-registry.test.ts`, `attachment-access-safety`, `session-workspace-authority`, `tool-derived-file-safety` | +| Outside-workspace files resolve while stale session/generation access is rejected | `tests/unit/attachment-access.test.ts`, `tests/unit/acp-session-access-registry.test.ts`, `tests/e2e/chat-acp-attachments.spec.ts`, `attachment-access-safety`, `session-workspace-authority` | | Turn-scoped live/history dedupe, unavailable upgrade, and native ACP precedence | `tests/unit/acp-chat-store.test.ts`, `tests/unit/acp-media-attachments.test.ts`, `acp-chat-state-and-history`, `acp-compatibility-content-safety` | | Compatibility rationale remains marked and bounded | `harness/reference/acp-generated-media-and-diagnostics.md#bounded-transcript-exceptions`, `acp-compatibility-content-safety` | | No OpenClaw, legacy Renderer, direct IPC, or direct Gateway regression | `renderer-main-boundary`, `backend-communication-boundary`, `acp-chat-state-and-history`, harness validation | diff --git a/harness/specs/tasks/fix-acp-media-attached-turn-alignment.md b/harness/specs/tasks/fix-acp-media-attached-turn-alignment.md new file mode 100644 index 00000000..8c7c6f00 --- /dev/null +++ b/harness/specs/tasks/fix-acp-media-attached-turn-alignment.md @@ -0,0 +1,104 @@ +--- +id: fix-acp-media-attached-turn-alignment +title: Align OpenClaw MEDIA recovery with attached ACP user turns +scenario: gateway-backend-communication +taskType: runtime-bridge +intent: Recover explicit assistant OpenClaw MEDIA attachments when the triggering ACP user turn contains structured image or resource content without weakening transcript evidence or attachment authorization. +touchedAreas: + - README.md + - README.zh-CN.md + - README.ja-JP.md + - docs/plans/2026-07-16-acp-media-attached-turn-alignment.md + - harness/specs/tasks/fix-acp-media-attached-turn-alignment.md + - harness/specs/tasks/acp-media-attachments.md + - harness/specs/tasks/preserve-acp-stream-across-navigation.md + - harness/specs/rules/acp-chat-state-and-history.md + - harness/specs/rules/acp-compatibility-content-safety.md + - harness/specs/rules/attachment-access-safety.md + - harness/specs/rules/session-workspace-authority.md + - harness/specs/rules/ui-i18n-design-tokens.md + - harness/reference/acp-attachment-access-control.md + - harness/reference/acp-generated-media-and-diagnostics.md + - harness/reference/acp-chat.md + - harness/reference/openclaw-file-activity.md + - shared/acp-chat/types.ts + - shared/host-api/contract.ts + - shared/i18n/locales/en/chat.json + - shared/i18n/locales/zh/chat.json + - shared/i18n/locales/ja/chat.json + - shared/i18n/locales/ru/chat.json + - src/components/file-preview/FilePreviewBody.tsx + - src/components/file-preview/build-preview-target.ts + - src/lib/acp/timeline-types.ts + - src/lib/acp/content-blocks.ts + - src/lib/acp/reducer.ts + - src/lib/acp/openclaw-media-compat.ts + - src/lib/acp/openclaw-prompt-compat.ts + - src/stores/acp-chat-session.ts + - src/pages/Chat/AcpAttachmentPart.tsx + - tests/unit/acp-media-attachments.test.ts + - tests/unit/acp-reducer.test.ts + - tests/unit/acp-chat-store.test.ts + - tests/unit/acp-chat-components.test.tsx + - tests/unit/acp-chat-service.test.ts + - tests/unit/attachment-access.test.ts + - tests/unit/rich-file-viewers.test.tsx + - tests/e2e/chat-acp-attachments.spec.ts + - tests/e2e/chat-acp-inline-timeline.spec.ts + - tests/e2e/chat-run-state-events.spec.ts +expectedUserBehavior: + - An explicit assistant MEDIA attachment renders when the triggering user prompt contains one or more ACP resource links or images. + - Text-plus-attachment and attachment-only turns recover live and historical MEDIA evidence without displaying raw compatibility markers. + - Repeated attached prompts remain associated with the correct user occurrence and do not duplicate recovered attachments. +requiredProfiles: + - fast + - comms + - e2e +requiredRules: + - renderer-main-boundary + - backend-communication-boundary + - api-client-transport-policy + - host-api-fallback-policy + - acp-chat-state-and-history + - acp-compatibility-content-safety + - attachment-access-safety + - diagnostics-trace-safety + - session-workspace-authority + - tool-derived-file-safety + - ui-i18n-design-tokens + - comms-regression + - docs-sync +requiredTests: + - pnpm exec vitest run tests/unit/harness-specs.test.ts tests/unit/acp-media-attachments.test.ts tests/unit/acp-reducer.test.ts tests/unit/acp-chat-store.test.ts + - pnpm run typecheck + - pnpm run lint:check + - pnpm run build:vite + - pnpm exec playwright test tests/e2e/chat-acp-attachments.spec.ts + - pnpm run comms:replay + - pnpm run comms:compare + - pnpm harness validate --spec harness/specs/tasks/fix-acp-media-attached-turn-alignment.md + - pnpm harness run --spec harness/specs/tasks/fix-acp-media-attached-turn-alignment.md + - pnpm run harness:ci +acceptance: + - OpenClaw resource-link transcript projection no longer prevents the same turn's assistant MEDIA attachment from rendering. + - ACP text, embedded text, resource-link, and omitted binary block ordering produces the exact bounded turn-alignment key without retaining image base64. + - User-authored text resembling an OpenClaw Resource link marker is not globally stripped or treated as attachment evidence. + - Attachment-only turns align by reverse occurrence and, for live prompts, exact optimistic user identity. + - Existing session, generation, attempt, ambiguity, evidence, deduplication, and Main attachment authorization checks remain intact. + - The compatibility rationale explains that OpenClaw ACP does not project assistant MEDIA attachments, requiring a bounded transcript read. + - No OpenClaw source, distributed package, legacy Chat renderer, direct Renderer IPC, or direct Gateway HTTP request is introduced. +docs: + required: true +--- + +## Scope + +Preserve a lightweight projection of structured ACP user prompt blocks and use it to reconstruct the OpenClaw transcript text needed by the existing bounded assistant `MEDIA:` compatibility supplement. + +## Out Of Scope + +- Modifying OpenClaw or its distributed package. +- Replacing ACP replay with Gateway Chat history. +- Parsing arbitrary user-authored resource marker text. +- Expanding assistant evidence beyond explicit whole-line `MEDIA:` directives. +- Changing attachment resolution, preview, open, or authorization policy. diff --git a/harness/specs/tasks/preserve-acp-stream-across-navigation.md b/harness/specs/tasks/preserve-acp-stream-across-navigation.md new file mode 100644 index 00000000..70d635f5 --- /dev/null +++ b/harness/specs/tasks/preserve-acp-stream-across-navigation.md @@ -0,0 +1,55 @@ +--- +id: preserve-acp-stream-across-navigation +title: Preserve ACP streams across chat navigation +scenario: gateway-backend-communication +taskType: runtime-bridge +intent: Keep an in-flight ACP response live when users leave its conversation and return before the prompt completes. +touchedAreas: + - README.md + - README.zh-CN.md + - README.ja-JP.md + - harness/specs/tasks/preserve-acp-stream-across-navigation.md + - harness/reference/acp-chat.md + - harness/specs/rules/acp-chat-state-and-history.md + - shared/acp-chat/types.ts + - electron/services/acp-chat-service.ts + - src/stores/acp-chat-session.ts + - tests/unit/acp-chat-service.test.ts + - tests/unit/acp-chat-store.test.ts + - tests/e2e/chat-acp-inline-timeline.spec.ts +expectedUserBehavior: + - Leaving a conversation during an ACP response does not stop collection of that conversation's live updates. + - Returning before the prompt completes restores the latest in-memory timeline and continues streaming new updates. + - Returning after the prompt completes uses normal ACP history replay. +requiredProfiles: + - fast + - comms +requiredRules: + - renderer-main-boundary + - backend-communication-boundary + - host-api-fallback-policy + - host-events-fallback-policy + - acp-chat-state-and-history + - comms-regression + - docs-sync +requiredTests: + - pnpm exec vitest run tests/unit/acp-chat-service.test.ts tests/unit/acp-chat-store.test.ts + - pnpm exec playwright test tests/e2e/chat-acp-inline-timeline.spec.ts + - pnpm run typecheck + - pnpm run comms:replay + - pnpm run comms:compare +acceptance: + - Main routes live notifications by their ACP session id instead of rewriting every event to the currently viewed session. + - Main retains only the routing context needed by an in-flight prompt and can reactivate that prompt without invoking ACP session/load. + - Renderer retains only in-flight, memory-only timeline snapshots and updates them while their conversation is not selected. + - Renderer restores the in-flight snapshot atomically when Main confirms prompt reactivation. + - Completed prompts release their in-flight routing and timeline state so ACP replay remains the history authority. + - Stale generations, unrelated ACP session ids, and failed loads remain ignored. + - No persistent timeline cache, second history ledger, or OpenClaw source change is introduced. +docs: + required: true +--- + +## Scope + +This task adds a bounded live-run handoff for ACP prompts. It does not change persisted history authority or reconstruct missing protocol events. diff --git a/shared/acp-chat/types.ts b/shared/acp-chat/types.ts index bd015a5d..6dd9747d 100644 --- a/shared/acp-chat/types.ts +++ b/shared/acp-chat/types.ts @@ -42,6 +42,8 @@ export type AcpChatOperationResult = { success: boolean; error?: string; generation?: number; + /** The requested session still has a live prompt and was reactivated without history replay. */ + resumedActivePrompt?: boolean; /** Raw notifications collected while session/load is in progress. */ sessionUpdates?: AcpSessionUpdateEnvelope[]; }; diff --git a/shared/host-api/contract.ts b/shared/host-api/contract.ts index d29a07c1..d0a9b9f6 100644 --- a/shared/host-api/contract.ts +++ b/shared/host-api/contract.ts @@ -451,7 +451,6 @@ export type AttachmentRemoteRef = AttachmentSourceRef; export type AttachmentAccessError = | 'invalidReference' | 'staleSession' - | 'outsideAllowedRoots' | 'unavailable' | 'notFile' | 'unsafeUrl' diff --git a/shared/i18n/locales/en/chat.json b/shared/i18n/locales/en/chat.json index 68695fec..1ee90805 100644 --- a/shared/i18n/locales/en/chat.json +++ b/shared/i18n/locales/en/chat.json @@ -66,7 +66,6 @@ "unavailable": "Attachment unavailable", "open": "Open {{name}}", "preview": "Preview {{name}}", - "mimeSize": "{{mimeType}}, {{size}}", "openFailed": "Could not open attachment" } }, diff --git a/shared/i18n/locales/ja/chat.json b/shared/i18n/locales/ja/chat.json index 976e7aa1..3a16dce4 100644 --- a/shared/i18n/locales/ja/chat.json +++ b/shared/i18n/locales/ja/chat.json @@ -66,7 +66,6 @@ "unavailable": "添付ファイルを利用できません", "open": "{{name}} を開く", "preview": "{{name}} をプレビュー", - "mimeSize": "{{mimeType}}, {{size}}", "openFailed": "添付ファイルを開けませんでした" } }, diff --git a/shared/i18n/locales/ru/chat.json b/shared/i18n/locales/ru/chat.json index 308e29dc..646a08b9 100644 --- a/shared/i18n/locales/ru/chat.json +++ b/shared/i18n/locales/ru/chat.json @@ -66,7 +66,6 @@ "unavailable": "Вложение недоступно", "open": "Открыть {{name}}", "preview": "Просмотреть {{name}}", - "mimeSize": "{{mimeType}}, {{size}}", "openFailed": "Не удалось открыть вложение" } }, diff --git a/shared/i18n/locales/zh/chat.json b/shared/i18n/locales/zh/chat.json index 4d50287a..895911c8 100644 --- a/shared/i18n/locales/zh/chat.json +++ b/shared/i18n/locales/zh/chat.json @@ -66,7 +66,6 @@ "unavailable": "附件不可用", "open": "打开 {{name}}", "preview": "预览 {{name}}", - "mimeSize": "{{mimeType}}, {{size}}", "openFailed": "无法打开附件" } }, diff --git a/src/components/file-preview/FilePreviewBody.tsx b/src/components/file-preview/FilePreviewBody.tsx index fb4228a4..f537f0ae 100644 --- a/src/components/file-preview/FilePreviewBody.tsx +++ b/src/components/file-preview/FilePreviewBody.tsx @@ -37,6 +37,7 @@ import { writeTextFile, } from '@/lib/file-preview-client'; import { getFilePreviewTargetIdentity, type FilePreviewTarget } from './types'; +import { previewDisplayPath } from './build-preview-target'; import { isHtmlPreviewExt, supportsInlineDiff, @@ -717,7 +718,7 @@ export function FilePreviewBody({ />

{file.fileName}

-

{file.filePath}

+

{previewDisplayPath(file)}

diff --git a/src/components/file-preview/build-preview-target.ts b/src/components/file-preview/build-preview-target.ts index 4f1fa2b5..2c5648e0 100644 --- a/src/components/file-preview/build-preview-target.ts +++ b/src/components/file-preview/build-preview-target.ts @@ -10,6 +10,30 @@ import { richFilePreviewKind } from '@/lib/file-preview-capabilities'; import type { WorkspaceFileRef } from '@/lib/file-preview-client'; import type { FilePreviewTarget } from './types'; +function filePathFromUri(uri: string): string { + if (/^file:\/\/\//i.test(uri)) { + try { return decodeURIComponent(uri.slice(7)); } catch { return uri.slice(7); } + } + if (/^file:\/\/localhost\//i.test(uri)) { + try { return decodeURIComponent(uri.slice(16)); } catch { return uri.slice(16); } + } + return uri; +} + +export function previewDisplayPath( + file: Pick, +): string { + if (file.attachmentFileRef) { + return filePathFromUri(file.attachmentFileRef.uri); + } + if (file.workspaceFileRef) { + const root = file.workspaceFileRef.workspaceRoot.replace(/\\/g, '/'); + const rel = file.workspaceFileRef.relativePath.replace(/\\/g, '/').replace(/^\.\/+/, ''); + return rel ? `${root}/${rel}` : root; + } + return file.filePath; +} + type WorkspacePreviewMetadata = Partial part.kind === 'markdown' ? [part.text] : []) .join(''); @@ -211,12 +215,12 @@ function acpUserTurns(snapshot: AcpTimelineSnapshot): AcpUserTurn[] { for (const itemId of snapshot.itemOrder) { const item = snapshot.itemsById[itemId]; if (item?.kind === 'message-segment' && item.role === 'user') { - if (!current) { + if (!current || !current.messageIds.has(item.messageId)) { current = { turnId: item.messageId, messageIds: new Set(), normalizedUserText: '' }; turns.push(current); } current.messageIds.add(item.messageId); - current.normalizedUserText += markdownText(item); + current.normalizedUserText += userPromptText(item); continue; } current = null; @@ -273,7 +277,6 @@ export function alignOpenClawMediaTurns( const acpByKey = new Map(); const ambiguousKeys = new Set(); for (const turn of eligibleAcpTurns) { - if (!turn.normalizedUserText) continue; const key = turnMatchKey(turn); if (acpByKey.has(key)) ambiguousKeys.add(key); else acpByKey.set(key, turn); @@ -281,7 +284,7 @@ export function alignOpenClawMediaTurns( const supplements: OpenClawMediaTurnSupplement[] = []; for (const transcriptTurn of transcriptTurns) { - if (transcriptTurn.candidates.length === 0 || !transcriptTurn.normalizedUserText) continue; + if (transcriptTurn.candidates.length === 0) continue; const key = turnMatchKey(transcriptTurn); if (ambiguousKeys.has(key)) continue; const acpTurn = acpByKey.get(key); diff --git a/src/lib/acp/openclaw-prompt-compat.ts b/src/lib/acp/openclaw-prompt-compat.ts new file mode 100644 index 00000000..dfa18b8f --- /dev/null +++ b/src/lib/acp/openclaw-prompt-compat.ts @@ -0,0 +1,67 @@ +import type { ContentBlock } from '@agentclientprotocol/sdk'; + +const INLINE_CONTROL_ESCAPE_MAP: Readonly> = { + '\0': '\\0', + '\r': '\\r', + '\n': '\\n', + '\t': '\\t', + '\v': '\\v', + '\f': '\\f', + '\u2028': '\\u2028', + '\u2029': '\\u2029', +}; + +function escapeInlineControlChars(value: string): string { + let escaped = ''; + for (const character of value) { + const codePoint = character.codePointAt(0); + if (codePoint === undefined) continue; + const isInlineControl = codePoint <= 0x1f + || (codePoint >= 0x7f && codePoint <= 0x9f) + || codePoint === 0x2028 + || codePoint === 0x2029; + if (!isInlineControl) { + escaped += character; + continue; + } + escaped += INLINE_CONTROL_ESCAPE_MAP[character] + ?? (codePoint <= 0xff + ? `\\x${codePoint.toString(16).padStart(2, '0')}` + : `\\u${codePoint.toString(16).padStart(4, '0')}`); + } + return escaped; +} + +function escapeResourceTitle(value: string): string { + return escapeInlineControlChars(value).replace(/[()[\]]/g, (character) => `\\${character}`); +} + +export function openClawResourceLinkPromptText(uri: string, title?: string): string { + const titleSuffix = title ? ` (${escapeResourceTitle(title)})` : ''; + const escapedUri = uri ? escapeInlineControlChars(uri) : ''; + return escapedUri + ? `[Resource link${titleSuffix}] ${escapedUri}` + : `[Resource link${titleSuffix}]`; +} + +export function openClawPromptTextBlocks(blocks: readonly ContentBlock[]): string[] { + const textBlocks: string[] = []; + for (const block of blocks) { + if (block.type === 'text') { + textBlocks.push(block.text); + continue; + } + if (block.type === 'resource') { + const resource = block.resource as unknown as Record; + if (typeof resource.text === 'string' && resource.text) textBlocks.push(resource.text); + continue; + } + if (block.type === 'resource_link') { + textBlocks.push(openClawResourceLinkPromptText( + block.uri, + typeof block.title === 'string' ? block.title : undefined, + )); + } + } + return textBlocks; +} diff --git a/src/lib/acp/reducer.ts b/src/lib/acp/reducer.ts index 48e1c919..a9b5242b 100644 --- a/src/lib/acp/reducer.ts +++ b/src/lib/acp/reducer.ts @@ -10,6 +10,7 @@ import type { } from '@agentclientprotocol/sdk'; import { contentBlockToRenderPart, contentBlocksToRenderParts, toolContentToRenderPart, toolContentToRenderParts } from './content-blocks'; import { dedupeTimelineAttachments } from './attachments'; +import { openClawPromptTextBlocks } from './openclaw-prompt-compat'; import type { AcpTimelineSnapshot, AttachmentRenderPart, MessageSegmentItem, RenderPart, TimelineItem, ToolCallItem } from './timeline-types'; type UpdateRecord = Record & { @@ -240,6 +241,19 @@ function appendMessageChunk( blockCount: blockIndex + 1, optimistic: false, parts, + ...(role === 'user' + ? { + userPromptTextBlocks: result.item.userPromptTextBlocksOptimistic + ? result.item.userPromptTextBlocks + : [ + ...(result.item.userPromptTextBlocks ?? []), + ...openClawPromptTextBlocks([content]), + ], + ...(result.item.userPromptTextBlocksOptimistic + ? { userPromptTextBlocksOptimistic: true } + : {}), + } + : {}), }; return { @@ -269,6 +283,10 @@ function replaceMessage( blockCount: blocks.length, optimistic: false, parts: existing.optimistic ? mergeOptimisticUserEchoParts(existing.parts, parts) : parts, + userPromptTextBlocks: existing.userPromptTextBlocksOptimistic + ? existing.userPromptTextBlocks + : openClawPromptTextBlocks(blocks), + userPromptTextBlocksOptimistic: undefined, }; return { ...state, @@ -288,6 +306,7 @@ function replaceMessage( messageId, segmentIndex: result.item.segmentIndex, }), + ...(role === 'user' ? { userPromptTextBlocks: openClawPromptTextBlocks(blocks) } : {}), }; return { diff --git a/src/lib/acp/timeline-types.ts b/src/lib/acp/timeline-types.ts index 5a9e6378..24059e6c 100644 --- a/src/lib/acp/timeline-types.ts +++ b/src/lib/acp/timeline-types.ts @@ -54,6 +54,10 @@ export type MessageSegmentItem = { messageId: string; segmentIndex: number; parts: RenderPart[]; + /** Binary-free text blocks produced by OpenClaw's ACP prompt flattening. */ + userPromptTextBlocks?: string[]; + /** Keep the locally-sent projection authoritative while ACP echoes prompt chunks. */ + userPromptTextBlocksOptimistic?: boolean; /** Number of ACP blocks consumed by this segment, independent of render-part coalescing. */ blockCount?: number; optimistic?: boolean; diff --git a/src/pages/Chat/AcpAttachmentPart.tsx b/src/pages/Chat/AcpAttachmentPart.tsx index 14434b2e..ca63e499 100644 --- a/src/pages/Chat/AcpAttachmentPart.tsx +++ b/src/pages/Chat/AcpAttachmentPart.tsx @@ -13,6 +13,24 @@ import { useArtifactPanel } from '@/stores/artifact-panel'; type AttachmentTone = 'assistant' | 'user'; +function filePathFromUri(uri: string): string { + if (/^file:\/\/\//i.test(uri)) { + try { + return decodeURIComponent(uri.slice(7)); + } catch { + return uri.slice(7); + } + } + if (/^file:\/\/localhost\//i.test(uri)) { + try { + return decodeURIComponent(uri.slice(16)); + } catch { + return uri.slice(16); + } + } + return uri; +} + function AcpUserImageAttachment({ part, name, @@ -30,16 +48,21 @@ function AcpUserImageAttachment({ if (part.access.target.kind !== 'local') return; let cancelled = false; - void hostApi.media.thumbnails({ - paths: [{ - attachmentFileRef: part.access.target.ref, - key: part.access.identity, - mimeType: part.access.mimeType, - }], - }).then((result) => { - if (cancelled) return; - setThumbnailUrl(result[part.access.identity]?.preview ?? null); - }).catch(() => undefined); + void hostApi.media + .thumbnails({ + paths: [ + { + attachmentFileRef: part.access.target.ref, + key: part.access.identity, + mimeType: part.access.mimeType, + }, + ], + }) + .then((result) => { + if (cancelled) return; + setThumbnailUrl(result[part.access.identity]?.preview ?? null); + }) + .catch(() => undefined); return () => { cancelled = true; @@ -83,13 +106,16 @@ export function AcpAttachmentPart({ part, tone = 'assistant' }: { part: Attachme const unavailable = part.access.status === 'unavailable'; const disabled = pending || unavailable; const size = part.access.status === 'available' ? part.access.size : part.reference.size; - const mimeType = part.access.status === 'available' ? part.access.mimeType : part.reference.mimeType; - const secondary = size - ? t('acp.attachment.mimeSize', { mimeType, size: formatFileSize(size) }) - : ''; - const mode = part.access.status === 'available' - ? attachmentOpenMode({ ext: extnameOf(name), mimeType: part.access.mimeType, size: part.access.size, target: part.access.target }) - : null; + const displayPath = part.reference.displayPath ?? filePathFromUri(part.reference.uri); + const mode = + part.access.status === 'available' + ? attachmentOpenMode({ + ext: extnameOf(name), + mimeType: part.access.mimeType, + size: part.access.size, + target: part.access.target, + }) + : null; const actionLabel = pending ? t('acp.attachment.loading') : unavailable @@ -113,14 +139,16 @@ export function AcpAttachmentPart({ part, tone = 'assistant' }: { part: Attachme }; if ( - tone === 'user' - && part.access.status === 'available' - && part.access.target.kind === 'local' - && part.access.mimeType.startsWith('image/') + tone === 'user' && + part.access.status === 'available' && + part.access.target.kind === 'local' && + part.access.mimeType.startsWith('image/') ) { return ( }} + part={ + part as AttachmentRenderPart & { access: Extract } + } name={name} ariaLabel={ariaLabel} activate={activate} @@ -157,9 +185,17 @@ export function AcpAttachmentPart({ part, tone = 'assistant' }: { part: Attachme ) : ( {name} - - {disabled ? actionLabel : secondary} - + {disabled ? ( + {actionLabel} + ) : ( + + + {displayPath} + + {size ? · : null} + {size ? {formatFileSize(size)} : null} + + )} )} diff --git a/src/stores/acp-chat-session.ts b/src/stores/acp-chat-session.ts index 882dc1ba..8b62ccf2 100644 --- a/src/stores/acp-chat-session.ts +++ b/src/stores/acp-chat-session.ts @@ -37,6 +37,7 @@ import { upsertSyntheticTurnAttachments, } from '@/lib/acp/reducer'; import { hashOpenClawMediaDiagnostic, type OpenClawMediaCandidate } from '@/lib/acp/openclaw-media-compat'; +import { openClawResourceLinkPromptText } from '@/lib/acp/openclaw-prompt-compat'; import { fetchOpenClawTranscriptSupplement } from '@/lib/acp/transcript-supplement'; import { hostApi } from '@/lib/host-api'; import { hostEvents } from '@/lib/host-events'; @@ -65,9 +66,36 @@ type ImageGenerationCompatSession = { const imageGenerationCompatSessions = new Map(); const pendingLoadUpdates = new Map(); +type LiveSessionSnapshot = { + sessionKey: string; + workspaceRoot: string | null; + cwd: string | null; + generation: number; + timeline: AcpTimelineSnapshot; + deferredImageUpdates: Array<{ key: string; event: AcpSessionUpdateEnvelope }>; +}; +const liveSessionSnapshots = new Map(); let loadRequestSeq = 0; const attachmentResolutionsInFlight = new Set(); +function deferInactiveImageUpdate( + snapshot: LiveSessionSnapshot, + event: AcpSessionUpdateEnvelope, +): LiveSessionSnapshot { + const start = extractImageGenerationStartFromAcpEnvelope(event); + const evidence = extractImageGenerationCompletionFromAcpEnvelope(event); + if (!start && !evidence) return snapshot; + const key = start + ? `start:${start.taskId}:${event.historical ? 'history' : 'live'}` + : `completion:${imageGenerationEvidenceKey(evidence!)}`; + const existingIndex = snapshot.deferredImageUpdates.findIndex((entry) => entry.key === key); + const deferredImageUpdates = [...snapshot.deferredImageUpdates]; + const entry = { key, event }; + if (existingIndex >= 0) deferredImageUpdates[existingIndex] = entry; + else deferredImageUpdates.push(entry); + return { ...snapshot, deferredImageUpdates }; +} + type TranscriptSupplementOperation = { id: number; sessionKey: string; @@ -137,6 +165,46 @@ function permissionStatus(outcome: PermissionOutcome): PermissionItem['status'] return outcome.outcome === 'cancelled' ? 'cancelled' : 'selected'; } +function applyPermissionRequestToTimeline( + timeline: AcpTimelineSnapshot, + event: AcpPermissionRequestEnvelope, +): AcpTimelineSnapshot { + const toolCallId = event.request.toolCall?.toolCallId; + const id = `permission:${event.requestId}`; + const item: PermissionItem = { + kind: 'permission', + id, + requestId: event.requestId, + toolCallId, + title: event.request.toolCall?.title ?? toolCallId ?? 'Permission request', + options: event.request.options.map((option) => ({ + optionId: option.optionId, + name: option.name, + kind: option.kind, + })), + status: 'pending', + }; + return { + ...timeline, + itemOrder: timeline.itemOrder.includes(id) ? timeline.itemOrder : [...timeline.itemOrder, id], + itemsById: { ...timeline.itemsById, [id]: item }, + openMessageSegments: {}, + }; +} + +function captureLiveSession(state: AcpChatSessionState): void { + if (!state.sending || !state.activeSessionKey) return; + const existing = liveSessionSnapshots.get(state.activeSessionKey); + liveSessionSnapshots.set(state.activeSessionKey, { + sessionKey: state.activeSessionKey, + workspaceRoot: state.workspaceRoot, + cwd: state.cwd, + generation: state.generation, + timeline: state.timeline, + deferredImageUpdates: existing?.deferredImageUpdates ?? [], + }); +} + function compatSession(sessionKey: string): ImageGenerationCompatSession { const existing = imageGenerationCompatSessions.get(sessionKey); if (existing) return existing; @@ -767,6 +835,18 @@ function optimisticPromptParts(input: AcpChatPromptPayload, messageId: string): return parts.length > 0 ? parts : [{ kind: 'markdown', text: '' }]; } +function optimisticPromptTextBlocks(input: AcpChatPromptPayload): string[] { + const text = input.message?.trim(); + return [ + ...(text ? [text] : []), + ...(input.media ?? []).flatMap((item) => ( + item.mimeType?.startsWith('image/') + ? [] + : [openClawResourceLinkPromptText(item.filePath)] + )), + ]; +} + function appendOptimisticUserSegment( timeline: AcpTimelineSnapshot, input: AcpChatPromptPayload, @@ -784,6 +864,8 @@ function appendOptimisticUserSegment( messageId, segmentIndex: 0, parts: optimisticPromptParts(input, messageId), + userPromptTextBlocks: optimisticPromptTextBlocks(input), + userPromptTextBlocksOptimistic: true, blockCount: 0, optimistic: true, }; @@ -841,6 +923,7 @@ export const useAcpChatSessionStore = create((set, get) => timeline: createEmptyAcpTimeline(EMPTY_SESSION_ID, 0), prepareLocalSession(input) { + captureLiveSession(get()); loadRequestSeq += 1; pendingLoadUpdates.clear(); const generation = get().generation; @@ -860,10 +943,12 @@ export const useAcpChatSessionStore = create((set, get) => }, async loadSession(input) { + captureLiveSession(get()); const requestId = loadRequestSeq + 1; loadRequestSeq = requestId; pendingLoadUpdates.clear(); const generation = get().generation; + const liveSnapshot = liveSessionSnapshots.get(input.sessionKey); invalidateTranscriptSupplement(); resetImageGenerationCompatSession(input.sessionKey); set({ @@ -872,15 +957,15 @@ export const useAcpChatSessionStore = create((set, get) => cwd: input.cwd, generation, loading: true, - sending: false, + sending: !!liveSnapshot, cancelling: false, error: null, - timeline: createEmptyAcpTimeline(input.sessionKey, generation), + timeline: liveSnapshot?.timeline ?? createEmptyAcpTimeline(input.sessionKey, generation), }); try { - const result = await hostApi.chat.loadAcpSession(input); - const state = get(); + let result = await hostApi.chat.loadAcpSession(input); + let state = get(); if ( loadRequestSeq !== requestId || state.activeSessionKey !== input.sessionKey @@ -899,6 +984,29 @@ export const useAcpChatSessionStore = create((set, get) => return false; } + const resumedSnapshot = result.resumedActivePrompt + ? liveSessionSnapshots.get(input.sessionKey) + : undefined; + if (result.resumedActivePrompt && resumedSnapshot?.generation !== result.generation) { + result = await hostApi.chat.loadAcpSession(input); + state = get(); + if ( + loadRequestSeq !== requestId + || state.activeSessionKey !== input.sessionKey + || state.workspaceRoot !== input.workspaceRoot + || state.cwd !== input.cwd + ) return false; + if (!result.success || result.resumedActivePrompt) { + pendingLoadUpdates.clear(); + set({ + loading: false, + sending: false, + error: failedOperationMessage(result, 'ACP session load failed'), + }); + return false; + } + } + const generation = result.generation ?? state.generation; const sessionUpdates = [ ...(result.sessionUpdates ?? []), @@ -907,7 +1015,12 @@ export const useAcpChatSessionStore = create((set, get) => event.sessionKey === input.sessionKey && event.generation === generation )); pendingLoadUpdates.clear(); - let timeline = createEmptyAcpTimeline(input.sessionKey, generation); + const currentResumedSnapshot = result.resumedActivePrompt + ? liveSessionSnapshots.get(input.sessionKey) + : undefined; + let timeline = currentResumedSnapshot?.generation === generation + ? currentResumedSnapshot.timeline + : createEmptyAcpTimeline(input.sessionKey, generation); for (const event of sessionUpdates) { timeline = applyAcpSessionUpdate(timeline, event.notification, { historical: !!event.historical }); } @@ -917,11 +1030,26 @@ export const useAcpChatSessionStore = create((set, get) => ); set({ loading: false, + sending: !!currentResumedSnapshot, error: null, generation, timeline, }); + if (currentResumedSnapshot) { + liveSessionSnapshots.set(input.sessionKey, { + ...currentResumedSnapshot, + timeline, + deferredImageUpdates: [], + }); + } else { + liveSessionSnapshots.delete(input.sessionKey); + } resolvePendingAttachments(input.sessionKey, generation, pendingAttachments); + for (const { event } of currentResumedSnapshot?.deferredImageUpdates ?? []) { + get().recordImageGenerationStart(event); + const evidence = extractImageGenerationCompletionFromAcpEnvelope(event); + if (evidence) void get().projectImageGenerationCompletion(evidence); + } for (const event of sessionUpdates) { get().recordImageGenerationStart(event); const evidence = extractImageGenerationCompletionFromAcpEnvelope(event); @@ -972,6 +1100,7 @@ export const useAcpChatSessionStore = create((set, get) => )); const optimisticState = get(); if (isCurrentAction(optimisticState, sessionKey, generation)) { + captureLiveSession(optimisticState); resolvePendingAttachments( sessionKey, generation, @@ -981,7 +1110,8 @@ export const useAcpChatSessionStore = create((set, get) => try { const result = await hostApi.chat.sendAcpPrompt(payload); const state = get(); - if (!isCurrentAction(state, sessionKey, generation)) return false; + liveSessionSnapshots.delete(sessionKey); + if (!isCurrentAction(state, sessionKey, generation)) return result.success; const failedTimeline = result.success ? state.timeline : removePendingOptimisticUserSegment(state.timeline, messageId); @@ -1007,6 +1137,7 @@ export const useAcpChatSessionStore = create((set, get) => } return result.success; } catch (error) { + liveSessionSnapshots.delete(sessionKey); if (activeTranscriptSupplement === transcriptOperation) invalidateTranscriptSupplement(); set((state) => ( isCurrentAction(state, sessionKey, generation) @@ -1062,6 +1193,15 @@ export const useAcpChatSessionStore = create((set, get) => if (result.success && result.generation != null && result.generation !== generation) { invalidateTranscriptSupplement(); } + if (result.success) { + const liveSnapshot = liveSessionSnapshots.get(sessionKey); + if (liveSnapshot?.generation === generation && getPendingPermission(liveSnapshot.timeline, requestId)) { + liveSessionSnapshots.set(sessionKey, { + ...liveSnapshot, + timeline: updatePermissionStatus(liveSnapshot.timeline, requestId, permissionStatus(outcome)), + }); + } + } set((state) => { if (!isCurrentAction(state, sessionKey, generation)) return {}; if (!result.success) { @@ -1407,13 +1547,44 @@ export const useAcpChatSessionStore = create((set, get) => if (event.sessionKey === state.activeSessionKey) { const updates = pendingLoadUpdates.get(event.generation) ?? []; pendingLoadUpdates.set(event.generation, [...updates, event]); + } else { + const liveSnapshot = liveSessionSnapshots.get(event.sessionKey); + if (liveSnapshot?.generation === event.generation) { + liveSessionSnapshots.set(event.sessionKey, deferInactiveImageUpdate({ + ...liveSnapshot, + timeline: applyAcpSessionUpdate( + liveSnapshot.timeline, + event.notification, + { historical: !!event.historical }, + ), + }, event)); + } + } + return; + } + if (event.sessionKey !== state.activeSessionKey || event.generation !== state.generation) { + const liveSnapshot = liveSessionSnapshots.get(event.sessionKey); + if (liveSnapshot?.generation === event.generation) { + liveSessionSnapshots.set(event.sessionKey, deferInactiveImageUpdate({ + ...liveSnapshot, + timeline: applyAcpSessionUpdate( + liveSnapshot.timeline, + event.notification, + { historical: !!event.historical }, + ), + }, event)); } return; } - if (event.sessionKey !== state.activeSessionKey || event.generation !== state.generation) return; const timeline = applyAcpSessionUpdate(state.timeline, event.notification, { historical: !!event.historical }); const pending = newPendingAttachments(state.timeline, timeline); set({ timeline }); + if (state.sending) { + const liveSnapshot = liveSessionSnapshots.get(event.sessionKey); + if (liveSnapshot?.generation === event.generation) { + liveSessionSnapshots.set(event.sessionKey, { ...liveSnapshot, timeline }); + } + } resolvePendingAttachments(event.sessionKey, event.generation, pending); get().recordImageGenerationStart(event); const evidence = extractImageGenerationCompletionFromAcpEnvelope(event); @@ -1422,34 +1593,25 @@ export const useAcpChatSessionStore = create((set, get) => applyPermissionRequest(event) { const state = get(); - if (event.sessionKey !== state.activeSessionKey || event.generation !== state.generation) return; + if (event.sessionKey !== state.activeSessionKey || event.generation !== state.generation) { + const liveSnapshot = liveSessionSnapshots.get(event.sessionKey); + if (liveSnapshot?.generation === event.generation) { + liveSessionSnapshots.set(event.sessionKey, { + ...liveSnapshot, + timeline: applyPermissionRequestToTimeline(liveSnapshot.timeline, event), + }); + } + return; + } - const toolCallId = event.request.toolCall?.toolCallId; - const id = `permission:${event.requestId}`; - const item: PermissionItem = { - kind: 'permission', - id, - requestId: event.requestId, - toolCallId, - title: event.request.toolCall?.title ?? toolCallId ?? 'Permission request', - options: event.request.options.map((option) => ({ - optionId: option.optionId, - name: option.name, - kind: option.kind, - })), - status: 'pending', - }; - - set({ - timeline: { - ...state.timeline, - itemOrder: state.timeline.itemOrder.includes(id) - ? state.timeline.itemOrder - : [...state.timeline.itemOrder, id], - itemsById: { ...state.timeline.itemsById, [id]: item }, - openMessageSegments: {}, - }, - }); + const timeline = applyPermissionRequestToTimeline(state.timeline, event); + set({ timeline }); + if (state.sending) { + const liveSnapshot = liveSessionSnapshots.get(event.sessionKey); + if (liveSnapshot?.generation === event.generation) { + liveSessionSnapshots.set(event.sessionKey, { ...liveSnapshot, timeline }); + } + } }, clearError() { diff --git a/tests/e2e/chat-acp-attachments.spec.ts b/tests/e2e/chat-acp-attachments.spec.ts index 85048344..bd720080 100644 --- a/tests/e2e/chat-acp-attachments.spec.ts +++ b/tests/e2e/chat-acp-attachments.spec.ts @@ -151,6 +151,70 @@ test.describe('ACP media attachments', () => { } }); + test('recovers assistant MEDIA for a user turn with a resource attachment', async ({ launchElectronApp }) => { + const app = await launchElectronApp({ skipSetup: true }); + const prompt = 'Create the attached-source report'; + const reply = 'The attached-source report is ready.'; + + try { + const fixture = await installAttachmentHostFixture(app, { + sessions: [ + { key: MAIN_SESSION_KEY, title: 'Main session' }, + { key: OTHER_SESSION_KEY, title: 'Other session' }, + ], + }); + const sourcePath = await fixture.createWorkspaceFile('attached-source.xlsx', workbookBytes()); + const outputPath = await fixture.createWorkspaceFile('attached-output.xlsx', workbookBytes()); + await fixture.setSessionReplay(MAIN_SESSION_KEY, [ + { + sessionUpdate: 'user_message', + messageId: 'attached-source-user', + content: [ + { type: 'text', text: prompt }, + { + type: 'resource_link', + uri: sourcePath, + name: 'attached-source.xlsx', + mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + }, + ], + }, + { + sessionUpdate: 'agent_message', + messageId: 'attached-source-reply', + content: [{ type: 'text', text: reply }], + }, + ]); + const transcript = [ + { + role: 'user', + id: 'attached-source-transcript-user', + content: `[Working directory: ${fixture.workspaceDir}]\n\n${prompt}\n[Resource link] ${sourcePath}`, + }, + { + role: 'assistant', + id: 'attached-source-transcript-assistant', + content: `${reply}\nMEDIA:${outputPath}`, + }, + ]; + await fixture.setTranscriptResponses(MAIN_SESSION_KEY, [[]]); + + const page = await openChat(app); + await expect(page.getByText(prompt)).toBeVisible({ timeout: 30_000 }); + await fixture.waitForHistoryRequestCount(MAIN_SESSION_KEY, 1); + await fixture.setTranscriptResponses(MAIN_SESSION_KEY, [transcript]); + + await page.getByTestId(`sidebar-session-${OTHER_SESSION_KEY}`).click(); + await expect(page.getByTestId('acp-chat-empty-state')).toBeVisible(); + await page.getByTestId(`sidebar-session-${MAIN_SESSION_KEY}`).click(); + + await expect(page.getByRole('button').filter({ hasText: 'attached-output.xlsx' })).toHaveCount(1, { timeout: 30_000 }); + await expect(page.getByText(/MEDIA:/)).toHaveCount(0); + } finally { + await closeElectronApp(app); + } + }); + test('previews the reported live spreadsheet flow and restores one historical card', async ({ launchElectronApp }) => { const app = await launchElectronApp({ skipSetup: true }); @@ -242,7 +306,7 @@ test.describe('ACP media attachments', () => { await fixture.setTranscriptResponses(MAIN_SESSION_KEY, [[]]); const page = await openChat(app); - const nativeCard = page.getByRole('button').filter({ hasText: 'Native budget.xlsx' }); + const nativeCard = page.getByRole('button', { name: 'Preview Native budget.xlsx', exact: true }); await expect(nativeCard).toBeEnabled({ timeout: 30_000 }); await fixture.setTranscriptResponses(MAIN_SESSION_KEY, [[ @@ -257,8 +321,8 @@ test.describe('ACP media attachments', () => { await expect(page.getByTestId('acp-chat-empty-state')).toBeVisible(); await page.getByTestId(`sidebar-session-${MAIN_SESSION_KEY}`).click(); - await expect(page.getByRole('button').filter({ hasText: 'Native budget.xlsx' })).toHaveCount(1, { timeout: 30_000 }); - await expect(page.getByRole('button').filter({ hasText: 'native-budget.xlsx' })).toHaveCount(0); + await expect(page.getByRole('button', { name: 'Preview Native budget.xlsx', exact: true })).toHaveCount(1, { timeout: 30_000 }); + await expect(page.getByRole('button', { name: 'Preview native-budget.xlsx', exact: true })).toHaveCount(0); await expect(page.getByText(/MEDIA:/)).toHaveCount(0); } finally { await closeElectronApp(app); @@ -311,7 +375,7 @@ test.describe('ACP media attachments', () => { } }); - test('renders outside-workspace paths unavailable without reading or opening them', async ({ launchElectronApp }) => { + test('previews outside-workspace paths through attachment host APIs', async ({ launchElectronApp }) => { const app = await launchElectronApp({ skipSetup: true }); try { @@ -332,10 +396,9 @@ test.describe('ACP media attachments', () => { await fixture.setTranscriptResponses(MAIN_SESSION_KEY, [[]]); const page = await openChat(app); - const unavailableCard = page.getByRole('button').filter({ hasText: 'private.txt' }); - await expect(unavailableCard).toBeDisabled({ timeout: 30_000 }); - await expect(unavailableCard).toHaveAccessibleName('Attachment unavailable: private.txt'); - await expect(unavailableCard).toContainText('Attachment unavailable'); + const outsideCard = page.getByRole('button', { name: 'Preview private.txt', exact: true }); + await expect(outsideCard).toBeEnabled({ timeout: 30_000 }); + await expect(outsideCard).toContainText(outsidePath); const calls = await fixture.getHostInvocations(); expect(calls.some((call) => ( call.module === 'files' @@ -343,11 +406,24 @@ test.describe('ACP media attachments', () => { && call.payload?.ref && (call.payload.ref as Record).uri === outsidePath ))).toBe(true); - expect(calls.some((call) => ( + await fixture.clearInvocations(); + + await outsideCard.click(); + const panel = page.getByTestId('artifact-panel'); + await expect(panel).toBeVisible(); + await expect(panel.getByText('not authorized')).toBeVisible({ timeout: 30_000 }); + const previewCalls = await fixture.getHostInvocations(); + expect(previewCalls.some((call) => ( call.module === 'files' - && (call.action === 'readAttachmentBinary' || call.action === 'openAttachment') - ))).toBe(false); + && call.action === 'readAttachmentText' + && call.payload?.uri === outsidePath + ))).toBe(true); expect(await fixture.getShellInvocations()).toEqual([]); + expect((await getRecordedLegacyIpcInvocations(app)).filter((call) => ( + call.channel === 'file:readText' + || call.channel === 'file:readBinary' + || call.channel.startsWith('shell:') + ))).toEqual([]); } finally { await closeElectronApp(app); } diff --git a/tests/e2e/chat-acp-inline-timeline.spec.ts b/tests/e2e/chat-acp-inline-timeline.spec.ts index 7233f900..df406bda 100644 --- a/tests/e2e/chat-acp-inline-timeline.spec.ts +++ b/tests/e2e/chat-acp-inline-timeline.spec.ts @@ -418,6 +418,51 @@ test.describe('ClawX ACP inline timeline', () => { } }); + test('continues an ACP response while Chat is unmounted and shows the latest stream on return', async ({ launchElectronApp }) => { + const app = await launchElectronApp({ skipSetup: true }); + + try { + await installAcpChatMocks(app); + await installAcpPromptDeferredMock(app); + const page = await openChat(app); + await expect(page.getByTestId('acp-chat-empty-state')).toBeVisible({ timeout: 30_000 }); + + await page.getByTestId('chat-composer-input').fill('Keep working while I navigate'); + await page.getByTestId('chat-composer-send').click(); + await emitAcpSessionUpdates(app, [{ + sessionUpdate: 'agent_message_chunk', + messageId: 'navigation-stream', + content: { type: 'text', text: 'Before navigation. ' }, + }]); + await expect(page.getByTestId('acp-assistant-message')).toContainText('Before navigation.'); + + await page.getByTestId('sidebar-nav-settings').click(); + await expect(page.getByTestId('settings-page')).toBeVisible(); + await emitAcpSessionUpdates(app, [{ + sessionUpdate: 'agent_message_chunk', + messageId: 'navigation-stream', + content: { type: 'text', text: 'While away. ' }, + }]); + + await page.getByTestId(`sidebar-session-${MAIN_SESSION_KEY}`).click(); + await expect(page.getByTestId('chat-page')).toBeVisible(); + await expect(page.getByTestId('acp-assistant-message')).toContainText('Before navigation. While away.'); + await emitAcpSessionUpdates(app, [{ + sessionUpdate: 'agent_message_chunk', + messageId: 'navigation-stream', + content: { type: 'text', text: 'After return.' }, + }]); + await expect(page.getByTestId('acp-assistant-message')).toContainText( + 'Before navigation. While away. After return.', + ); + + await resolveDeferredAcpPrompt(app); + await expect(page.getByTestId('chat-composer-send')).toBeVisible(); + } finally { + await closeElectronApp(app); + } + }); + test('shows assistant identity and copies ACP assistant text', async ({ launchElectronApp }) => { const app = await launchElectronApp({ skipSetup: true }); diff --git a/tests/e2e/chat-run-state-events.spec.ts b/tests/e2e/chat-run-state-events.spec.ts index fed63db6..469a54a3 100644 --- a/tests/e2e/chat-run-state-events.spec.ts +++ b/tests/e2e/chat-run-state-events.spec.ts @@ -589,8 +589,9 @@ test.describe('ClawX chat run state events', () => { await expect(page.getByTestId('acp-chat-timeline')).toBeVisible({ timeout: 30_000 }); await expect(page.getByText('SVG file is ready:')).toBeVisible(); await expect(page.getByText('MEDIA:C:')).toHaveCount(0); - await expect(page.getByText('japan-kansai-4d3n-plan.svg')).toBeVisible(); - await expect(page.getByRole('button').filter({ hasText: 'japan-kansai-4d3n-plan.svg' })).toBeEnabled(); + const svgCard = page.getByRole('button', { name: 'Preview japan-kansai-4d3n-plan.svg', exact: true }); + await expect(svgCard).toBeEnabled(); + await expect(svgCard).toContainText(filePath); await expect(page.getByTestId('acp-image-part').locator('img')).toBeVisible(); await expect(page.getByTestId('chat-execution-graph')).toHaveCount(0); } finally { diff --git a/tests/unit/acp-chat-components.test.tsx b/tests/unit/acp-chat-components.test.tsx index b813ff5f..89647749 100644 --- a/tests/unit/acp-chat-components.test.tsx +++ b/tests/unit/acp-chat-components.test.tsx @@ -43,7 +43,6 @@ vi.mock('react-i18next', () => ({ 'acp.attachment.unavailable': 'Attachment unavailable', 'acp.attachment.open': 'Open {{name}}', 'acp.attachment.preview': 'Preview {{name}}', - 'acp.attachment.mimeSize': '{{size}}', 'acp.attachment.openFailed': 'Could not open attachment', 'fileActivity.created': 'Created', 'fileActivity.modified': 'Modified', @@ -483,7 +482,7 @@ describe('ACP chat timeline components', () => { attachmentId: 'attachment:msg-a:1:0', reference: { uri: 'file:///secret/missing.zip', name: 'missing.zip' }, source: 'acp-resource', - access: { status: 'unavailable', reason: 'outsideAllowedRoots' }, + access: { status: 'unavailable', reason: 'operationFailed' }, }], }, }, diff --git a/tests/unit/acp-chat-service.test.ts b/tests/unit/acp-chat-service.test.ts index 9d738a06..1cb7cb0d 100644 --- a/tests/unit/acp-chat-service.test.ts +++ b/tests/unit/acp-chat-service.test.ts @@ -311,6 +311,72 @@ describe('AcpChatService', () => { }); }); + it('keeps routing an in-flight prompt while another session is viewed and reactivates it without replay', async () => { + const connection = createConnection(); + const prompt = createDeferred<{ stopReason: string }>(); + connection.prompt.mockReturnValueOnce(prompt.promise); + const { service, send } = await createService(connection); + + await service.loadSession({ sessionKey: 'agent:pi:s1', workspaceRoot: '/repo', cwd: '/repo' }); + const sendPrompt = service.sendPrompt({ + sessionKey: 'agent:pi:s1', cwd: '/repo', message: 'keep streaming', messageId: 'msg-user', + }); + await vi.waitFor(() => expect(connection.prompt).toHaveBeenCalledTimes(1)); + + await service.client.sessionUpdate({ + sessionId: 'agent:pi:s1', + update: { + sessionUpdate: 'agent_message_chunk', + messageId: 'msg-assistant', + content: { type: 'text', text: 'before switch ' }, + }, + } as never); + await service.loadSession({ sessionKey: 'agent:pi:s2', workspaceRoot: '/repo', cwd: '/repo' }); + await service.client.sessionUpdate({ + sessionId: 'agent:pi:s1', + update: { + sessionUpdate: 'agent_message_chunk', + messageId: 'msg-assistant', + content: { type: 'text', text: 'while away ' }, + }, + } as never); + + await expect(service.loadSession({ + sessionKey: 'agent:pi:s1', workspaceRoot: '/repo', cwd: '/repo', + })).resolves.toEqual({ success: true, generation: 1, resumedActivePrompt: true }); + await service.client.sessionUpdate({ + sessionId: 'agent:pi:s1', + update: { + sessionUpdate: 'agent_message_chunk', + messageId: 'msg-assistant', + content: { type: 'text', text: 'after return' }, + }, + } as never); + + const routedChunks = send.mock.calls + .filter(([channel, envelope]) => ( + channel === HOST_EVENT_CHANNELS.chat.acpSessionUpdate + && envelope.sessionKey === 'agent:pi:s1' + && envelope.notification.update.sessionUpdate === 'agent_message_chunk' + )) + .map(([, envelope]) => ({ + generation: envelope.generation, + text: envelope.notification.update.content.text, + })); + expect(routedChunks).toEqual([ + { generation: 1, text: 'before switch ' }, + { generation: 1, text: 'while away ' }, + { generation: 1, text: 'after return' }, + ]); + expect(connection.loadSession).toHaveBeenCalledTimes(2); + + prompt.resolve({ stopReason: 'end_turn' }); + await expect(sendPrompt).resolves.toEqual({ success: true, generation: 1 }); + await expect(service.loadSession({ + sessionKey: 'agent:pi:s2', workspaceRoot: '/repo', cwd: '/repo', + })).resolves.toEqual({ success: true, generation: 3 }); + }); + it('records ACP session load and forwarded update trace entries', async () => { const { clearAcpTraceForTests, getAcpTraceSnapshot } = await import('../../electron/services/acp-trace'); clearAcpTraceForTests(); @@ -422,10 +488,14 @@ describe('AcpChatService', () => { }); it('emits permission requests separately and resolves them from respondPermission', async () => { - const { service, send } = await createService(); + const connection = createConnection(); + const prompt = createDeferred<{ stopReason: string }>(); + connection.prompt.mockReturnValueOnce(prompt.promise); + const { service, send } = await createService(connection); await service.loadSession({ sessionKey: 'agent:pi:s1', workspaceRoot: '/repo', cwd: '/repo' }); - await service.sendPrompt({ sessionKey: 'agent:pi:s1', cwd: '/repo', message: 'edit the file' }); + const sendPrompt = service.sendPrompt({ sessionKey: 'agent:pi:s1', cwd: '/repo', message: 'edit the file' }); + await vi.waitFor(() => expect(connection.prompt).toHaveBeenCalledTimes(1)); send.mockClear(); const pending = service.client.requestPermission({ @@ -455,6 +525,38 @@ describe('AcpChatService', () => { await expect(pending).resolves.toEqual({ outcome: { outcome: 'selected', optionId: 'allow-once' }, }); + prompt.resolve({ stopReason: 'end_turn' }); + await sendPrompt; + }); + + it('responds to an inactive live prompt permission with its original generation', async () => { + const connection = createConnection(); + const prompt = createDeferred<{ stopReason: string }>(); + connection.prompt.mockReturnValueOnce(prompt.promise); + const { service, send } = await createService(connection); + + await service.loadSession({ sessionKey: 'agent:pi:s1', workspaceRoot: '/repo', cwd: '/repo' }); + const sendPrompt = service.sendPrompt({ sessionKey: 'agent:pi:s1', cwd: '/repo', message: 'edit' }); + await vi.waitFor(() => expect(connection.prompt).toHaveBeenCalledTimes(1)); + const pending = service.client.requestPermission({ + sessionId: 'agent:pi:s1', + toolCall: { toolCallId: 'tool-1', title: 'Edit file', status: 'pending' }, + options: [{ optionId: 'allow-once', name: 'Allow once', kind: 'allow_once' }], + } as never); + const requestId = send.mock.calls.at(-1)?.[1].requestId; + + await service.loadSession({ sessionKey: 'agent:pi:s2', workspaceRoot: '/repo', cwd: '/repo' }); + await expect(service.respondPermission({ + sessionKey: 'agent:pi:s1', + requestId, + outcome: { outcome: 'selected', optionId: 'allow-once' }, + })).resolves.toEqual({ success: true, generation: 1 }); + await expect(pending).resolves.toEqual({ + outcome: { outcome: 'selected', optionId: 'allow-once' }, + }); + + prompt.resolve({ stopReason: 'end_turn' }); + await sendPrompt; }); it('returns cancelled for permission requests from non-active sessions', async () => { diff --git a/tests/unit/acp-chat-store.test.ts b/tests/unit/acp-chat-store.test.ts index f1243a5d..0efabc11 100644 --- a/tests/unit/acp-chat-store.test.ts +++ b/tests/unit/acp-chat-store.test.ts @@ -461,6 +461,193 @@ describe('ACP Chat store', () => { }); }); + it('keeps an in-flight timeline updated while another session is active and restores it on return', async () => { + const prompt = createDeferred<{ success: boolean; generation: number }>(); + hostApiMock.loadAcpSession + .mockResolvedValueOnce({ success: true, generation: 1 }) + .mockResolvedValueOnce({ success: true, generation: 2 }) + .mockResolvedValueOnce({ success: true, generation: 1, resumedActivePrompt: true }); + hostApiMock.sendAcpPrompt.mockReturnValueOnce(prompt.promise); + const { ensureAcpChatSubscriptions, useAcpChatSessionStore } = await importStore(); + ensureAcpChatSubscriptions(); + + await useAcpChatSessionStore.getState().loadSession({ + sessionKey: 'agent:pi:s1', workspaceRoot: '/repo', cwd: '/repo', + }); + const sendPrompt = useAcpChatSessionStore.getState().sendPrompt({ + sessionKey: 'agent:pi:s1', cwd: '/repo', message: 'keep streaming', messageId: 'msg-user', + }); + await vi.waitFor(() => expect(hostApiMock.sendAcpPrompt).toHaveBeenCalledTimes(1)); + hostEventsMock.updateListener?.({ + sessionKey: 'agent:pi:s1', + generation: 1, + notification: { + sessionId: 'agent:pi:s1', + update: { + sessionUpdate: 'agent_message_chunk', + messageId: 'msg-assistant', + content: { type: 'text', text: 'before switch ' }, + }, + }, + }); + + await useAcpChatSessionStore.getState().loadSession({ + sessionKey: 'agent:pi:s2', workspaceRoot: '/repo', cwd: '/repo', + }); + hostEventsMock.updateListener?.({ + sessionKey: 'agent:pi:s1', + generation: 1, + notification: { + sessionId: 'agent:pi:s1', + update: { + sessionUpdate: 'agent_message_chunk', + messageId: 'msg-assistant', + content: { type: 'text', text: 'while away ' }, + }, + }, + }); + + await useAcpChatSessionStore.getState().loadSession({ + sessionKey: 'agent:pi:s1', workspaceRoot: '/repo', cwd: '/repo', + }); + expect(useAcpChatSessionStore.getState()).toMatchObject({ + activeSessionKey: 'agent:pi:s1', + generation: 1, + sending: true, + }); + expect(useAcpChatSessionStore.getState().timeline.itemsById['msg-assistant:0']).toMatchObject({ + parts: [{ kind: 'markdown', text: 'before switch while away ' }], + }); + + hostEventsMock.updateListener?.({ + sessionKey: 'agent:pi:s1', + generation: 1, + notification: { + sessionId: 'agent:pi:s1', + update: { + sessionUpdate: 'agent_message_chunk', + messageId: 'msg-assistant', + content: { type: 'text', text: 'after return' }, + }, + }, + }); + expect(useAcpChatSessionStore.getState().timeline.itemsById['msg-assistant:0']).toMatchObject({ + parts: [{ kind: 'markdown', text: 'before switch while away after return' }], + }); + + prompt.resolve({ success: true, generation: 1 }); + await expect(sendPrompt).resolves.toBe(true); + expect(useAcpChatSessionStore.getState().sending).toBe(false); + }); + + it('falls back to ACP replay when a prompt settles during live-session reactivation', async () => { + const prompt = createDeferred<{ success: boolean; generation: number }>(); + const resume = createDeferred<{ success: boolean; generation: number; resumedActivePrompt: boolean }>(); + hostApiMock.loadAcpSession + .mockResolvedValueOnce({ success: true, generation: 1 }) + .mockResolvedValueOnce({ success: true, generation: 2 }) + .mockReturnValueOnce(resume.promise) + .mockResolvedValueOnce({ + success: true, + generation: 3, + sessionUpdates: [{ + sessionKey: 'agent:pi:s1', + generation: 3, + historical: true, + notification: { + sessionId: 'agent:pi:s1', + update: { + sessionUpdate: 'agent_message_chunk', + messageId: 'msg-assistant', + content: { type: 'text', text: 'complete replay' }, + }, + }, + }], + }); + hostApiMock.sendAcpPrompt.mockReturnValueOnce(prompt.promise); + const { useAcpChatSessionStore } = await importStore(); + + await useAcpChatSessionStore.getState().loadSession({ + sessionKey: 'agent:pi:s1', workspaceRoot: '/repo', cwd: '/repo', + }); + const sendPrompt = useAcpChatSessionStore.getState().sendPrompt({ + sessionKey: 'agent:pi:s1', cwd: '/repo', message: 'finish while returning', messageId: 'msg-user', + }); + await vi.waitFor(() => expect(hostApiMock.sendAcpPrompt).toHaveBeenCalledTimes(1)); + await useAcpChatSessionStore.getState().loadSession({ + sessionKey: 'agent:pi:s2', workspaceRoot: '/repo', cwd: '/repo', + }); + const returnToLiveSession = useAcpChatSessionStore.getState().loadSession({ + sessionKey: 'agent:pi:s1', workspaceRoot: '/repo', cwd: '/repo', + }); + await vi.waitFor(() => expect(hostApiMock.loadAcpSession).toHaveBeenCalledTimes(3)); + + prompt.resolve({ success: true, generation: 1 }); + await sendPrompt; + resume.resolve({ success: true, generation: 1, resumedActivePrompt: true }); + + await expect(returnToLiveSession).resolves.toBe(true); + expect(hostApiMock.loadAcpSession).toHaveBeenCalledTimes(4); + expect(useAcpChatSessionStore.getState()).toMatchObject({ + activeSessionKey: 'agent:pi:s1', + generation: 3, + sending: false, + }); + expect(useAcpChatSessionStore.getState().timeline.itemsById['msg-assistant:0']).toMatchObject({ + parts: [{ kind: 'markdown', text: 'complete replay' }], + }); + }); + + it('keeps a resolved permission non-actionable when its live session is restored', async () => { + const prompt = createDeferred<{ success: boolean; generation: number }>(); + const permission = createDeferred<{ success: boolean; generation: number }>(); + hostApiMock.loadAcpSession + .mockResolvedValueOnce({ success: true, generation: 1 }) + .mockResolvedValueOnce({ success: true, generation: 2 }) + .mockResolvedValueOnce({ success: true, generation: 1, resumedActivePrompt: true }); + hostApiMock.sendAcpPrompt.mockReturnValueOnce(prompt.promise); + hostApiMock.respondAcpPermission.mockReturnValueOnce(permission.promise); + const { ensureAcpChatSubscriptions, useAcpChatSessionStore } = await importStore(); + ensureAcpChatSubscriptions(); + + await useAcpChatSessionStore.getState().loadSession({ + sessionKey: 'agent:pi:s1', workspaceRoot: '/repo', cwd: '/repo', + }); + const sendPrompt = useAcpChatSessionStore.getState().sendPrompt({ + sessionKey: 'agent:pi:s1', cwd: '/repo', message: 'edit', messageId: 'msg-user', + }); + await vi.waitFor(() => expect(hostApiMock.sendAcpPrompt).toHaveBeenCalledTimes(1)); + hostEventsMock.permissionListener?.({ + sessionKey: 'agent:pi:s1', + generation: 1, + requestId: 'permission-1', + request: { + sessionId: 'agent:pi:s1', + toolCall: { toolCallId: 'tool-1', title: 'Edit file', status: 'pending' }, + options: [{ optionId: 'allow-once', name: 'Allow once', kind: 'allow_once' }], + }, + }); + const respond = useAcpChatSessionStore.getState().respondPermission('permission-1', 'allow-once'); + await vi.waitFor(() => expect(hostApiMock.respondAcpPermission).toHaveBeenCalledTimes(1)); + + await useAcpChatSessionStore.getState().loadSession({ + sessionKey: 'agent:pi:s2', workspaceRoot: '/repo', cwd: '/repo', + }); + permission.resolve({ success: true, generation: 1 }); + await respond; + await useAcpChatSessionStore.getState().loadSession({ + sessionKey: 'agent:pi:s1', workspaceRoot: '/repo', cwd: '/repo', + }); + + expect(useAcpChatSessionStore.getState().timeline.itemsById['permission:permission-1']).toMatchObject({ + kind: 'permission', + status: 'selected', + }); + + prompt.resolve({ success: true, generation: 1 }); + await sendPrompt; + }); + it('resolves every new pending attachment and patches the matching ids', async () => { hostApiMock.resolveAttachment.mockImplementation(async (payload: { ref: { uri: string } }) => ({ ok: true, @@ -971,6 +1158,7 @@ describe('ACP Chat store', () => { kind: 'message-segment', role: 'user', segmentIndex: 0, + userPromptTextBlocks: ['hello from user', '[Resource link] /repo/notes.txt'], parts: [ { kind: 'markdown', text: 'hello from user' }, { @@ -2784,6 +2972,100 @@ describe('ACP Chat store', () => { expect(JSON.stringify(transcriptTraces)).not.toContain('MEDIA:'); }); + it('recovers historical MEDIA when the ACP user turn contains a resource attachment', async () => { + const history = createDeferred<{ success: true; messages: Array> }>(); + hostApiMock.sessionsHistory.mockReturnValueOnce(history.promise); + const inputPath = 'C:\\Users\\Administrator\\.openclaw\\media\\input.xlsx'; + const outputPath = 'C:\\Users\\Administrator\\.openclaw\\media\\output.xlsx'; + const { ensureAcpChatSubscriptions, useAcpChatSessionStore } = await importStore(); + ensureAcpChatSubscriptions(); + + const load = useAcpChatSessionStore.getState().loadSession({ + sessionKey: 'agent:pi:s1', workspaceRoot: 'C:\\Users\\Administrator\\.openclaw\\workspace', cwd: 'C:\\Users\\Administrator\\.openclaw\\workspace', + }); + await load; + hostEventsMock.updateListener?.({ + sessionKey: 'agent:pi:s1', + generation: 1, + historical: true, + notification: { + sessionId: 'agent:pi:s1', + update: { + sessionUpdate: 'user_message', + messageId: 'user-with-resource', + content: [ + { type: 'text', text: 'Create the report' }, + { type: 'resource_link', uri: inputPath, name: 'input.xlsx' }, + ], + }, + }, + }); + history.resolve({ + success: true, + messages: [ + { + role: 'user', + content: `[Working directory: C:\\Users\\Administrator\\.openclaw\\workspace]\n\nCreate the report\n[Resource link] ${inputPath}`, + }, + { role: 'assistant', id: 'assistant-output', content: `Report ready\nMEDIA:${outputPath}` }, + ], + }); + + await vi.waitFor(() => { + const attachments = Object.values(useAcpChatSessionStore.getState().timeline.itemsById) + .flatMap((item) => item.kind === 'message-segment' ? item.parts : []) + .filter((part) => part.kind === 'attachment' && part.source === 'openclaw-media'); + expect(attachments).toHaveLength(1); + expect(attachments[0]).toMatchObject({ + reference: { uri: outputPath, transcriptMessageId: 'assistant-output' }, + access: { status: 'available' }, + }); + }); + }); + + it.each([ + { kind: 'resource', inputPath: '/repo/input.xlsx', mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }, + { kind: 'image', inputPath: '/repo/input.png', mimeType: 'image/png' }, + ])('recovers live MEDIA for an attachment-only $kind prompt', async ({ kind, inputPath, mimeType }) => { + const outputPath = `/repo/${kind}-output.pdf`; + hostApiMock.sessionsHistory.mockResolvedValueOnce({ + success: true, + messages: [ + { + role: 'user', + content: kind === 'resource' + ? `[Working directory: /repo]\n\n[Resource link] ${inputPath}` + : '[Working directory: /repo]\n\n', + }, + { role: 'assistant', id: `${kind}-assistant`, content: `MEDIA:${outputPath}` }, + ], + }); + const { useAcpChatSessionStore } = await importStore(); + await useAcpChatSessionStore.getState().loadSession({ + sessionKey: 'agent:pi:s1', workspaceRoot: '/repo', cwd: '/repo', createIfMissing: true, + }); + + await expect(useAcpChatSessionStore.getState().sendPrompt({ + sessionKey: 'agent:pi:s1', + cwd: '/repo', + message: '', + messageId: `${kind}-only-user`, + media: [{ filePath: inputPath, stagingId: `${kind}-stage`, mimeType }], + })).resolves.toBe(true); + + await vi.waitFor(() => { + const attachments = Object.values(useAcpChatSessionStore.getState().timeline.itemsById) + .flatMap((item) => item.kind === 'message-segment' ? item.parts : []) + .filter((part) => part.kind === 'attachment' && part.source === 'openclaw-media'); + expect(attachments).toHaveLength(1); + expect(attachments[0]).toMatchObject({ + reference: { uri: outputPath, transcriptMessageId: `${kind}-assistant` }, + access: { status: 'available' }, + }); + }); + expect(hostApiMock.sessionsHistory).toHaveBeenCalledTimes(1); + }); + it('records a reason-coded history failure without transcript content', async () => { hostApiMock.sessionsHistory.mockRejectedValueOnce(new Error('history failed for MEDIA:/private/secret.txt')); const { useAcpChatSessionStore } = await importStore(); diff --git a/tests/unit/acp-media-attachments.test.ts b/tests/unit/acp-media-attachments.test.ts index 18630902..19f00742 100644 --- a/tests/unit/acp-media-attachments.test.ts +++ b/tests/unit/acp-media-attachments.test.ts @@ -23,7 +23,13 @@ function extract(messages: RawMessage[], suppressedUris = new Set()) { } function timeline( - turns: Array<{ userId: string; userText: string; assistantId?: string; assistantText?: string }>, + turns: Array<{ + userId: string; + userText: string; + userPromptTextBlocks?: string[]; + assistantId?: string; + assistantText?: string; + }>, ): AcpTimelineSnapshot { const snapshot = createEmptyAcpTimeline('agent:main:session-1', 4); for (const turn of turns) { @@ -36,6 +42,7 @@ function timeline( messageId: turn.userId, segmentIndex: 0, parts: [{ kind: 'markdown', text: turn.userText }], + ...(turn.userPromptTextBlocks ? { userPromptTextBlocks: turn.userPromptTextBlocks } : {}), }; if (turn.assistantId) { const assistantItemId = `${turn.assistantId}:0`; @@ -319,6 +326,66 @@ describe('OpenClaw MEDIA transcript extraction', () => { }); describe('OpenClaw MEDIA transcript turn alignment', () => { + it('aligns a structured resource-link user turn with OpenClaw transcript projection', () => { + const resourcePath = 'C:\\Users\\Administrator\\.openclaw\\media\\input.xlsx'; + const snapshot = timeline([{ + userId: 'user-with-resource', + userText: 'Create the report', + userPromptTextBlocks: [ + 'Create the report', + `[Resource link] ${resourcePath}`, + ], + }]); + const turns = extract(transcript( + { + role: 'user', + content: `[Working directory: C:\\Users\\Administrator\\.openclaw\\workspace]\n\nCreate the report\n[Resource link] ${resourcePath}`, + }, + { role: 'assistant', content: 'MEDIA:C:\\Users\\Administrator\\.openclaw\\media\\report.xlsx' }, + )); + + expect(alignOpenClawMediaTurns(snapshot, turns, {})).toMatchObject([{ + acpTurnId: 'user-with-resource', + candidates: [{ uri: 'C:\\Users\\Administrator\\.openclaw\\media\\report.xlsx' }], + }]); + }); + + it('aligns attachment-only turns with empty OpenClaw prompt text by occurrence from the tail', () => { + const snapshot = timeline([ + { userId: 'image-first', userText: '', userPromptTextBlocks: [] }, + { userId: 'image-last', userText: '', userPromptTextBlocks: [] }, + ]); + const turns = extract(transcript( + { role: 'user', content: '[Working directory: /workspace/project]\n\n' }, + { role: 'assistant', content: 'MEDIA:/tmp/first.pdf' }, + { role: 'user', content: '[Working directory: /workspace/project]\n\n' }, + { role: 'assistant', content: 'MEDIA:/tmp/last.pdf' }, + )); + + expect(alignOpenClawMediaTurns(snapshot, turns, {})).toMatchObject([ + { acpTurnId: 'image-first', candidates: [{ uri: '/tmp/first.pdf' }] }, + { acpTurnId: 'image-last', candidates: [{ uri: '/tmp/last.pdf' }] }, + ]); + }); + + it('keeps user-authored Resource link marker text in the exact alignment key', () => { + const authoredText = 'Explain this literal syntax:\n[Resource link] /not-an-attachment.txt'; + const snapshot = timeline([{ + userId: 'literal-marker', + userText: authoredText, + userPromptTextBlocks: [authoredText], + }]); + const turns = extract(transcript( + { role: 'user', content: authoredText }, + { role: 'assistant', content: 'MEDIA:/tmp/explanation.pdf' }, + )); + + expect(alignOpenClawMediaTurns(snapshot, turns, {})).toMatchObject([{ + acpTurnId: 'literal-marker', + candidates: [{ uri: '/tmp/explanation.pdf' }], + }]); + }); + it('aligns a bounded transcript suffix newest-to-oldest', () => { const snapshot = timeline([ { userId: 'user-old', userText: 'Old prompt', assistantId: 'assistant-old' }, diff --git a/tests/unit/acp-reducer.test.ts b/tests/unit/acp-reducer.test.ts index 5389d210..196a3970 100644 --- a/tests/unit/acp-reducer.test.ts +++ b/tests/unit/acp-reducer.test.ts @@ -234,6 +234,8 @@ describe('ACP timeline reducer', () => { messageId: 'user-msg', segmentIndex: 0, optimistic: true, + userPromptTextBlocks: ['inspect this', '[Resource link] /repo/notes.txt'], + userPromptTextBlocksOptimistic: true, parts: [ { kind: 'markdown', text: 'inspect this' }, { @@ -270,6 +272,7 @@ describe('ACP timeline reducer', () => { expect(state.itemsById['user-msg:0']).toMatchObject({ kind: 'message-segment', optimistic: false, + userPromptTextBlocks: ['inspect this', '[Resource link] /repo/notes.txt'], parts: [ { kind: 'markdown', text: 'inspect this' }, { @@ -296,6 +299,8 @@ describe('ACP timeline reducer', () => { messageId: 'user-msg', segmentIndex: 0, optimistic: true, + userPromptTextBlocks: ['inspect this', '[Resource link] /repo/notes.txt'], + userPromptTextBlocksOptimistic: true, parts: [ { kind: 'markdown', text: 'inspect this' }, { @@ -324,6 +329,7 @@ describe('ACP timeline reducer', () => { expect(state.itemsById['user-msg:0']).toMatchObject({ kind: 'message-segment', optimistic: false, + userPromptTextBlocks: ['inspect this', '[Resource link] /repo/notes.txt'], parts: [ { kind: 'markdown', text: 'inspect this' }, { @@ -413,6 +419,44 @@ describe('ACP timeline reducer', () => { } }); + it('keeps an ordered binary-free OpenClaw prompt text projection for user content', () => { + let state = createEmptyAcpTimeline('agent:pi:s1', 1); + + state = applyAcpSessionUpdate(state, { + sessionId: 'agent:pi:s1', + update: { + sessionUpdate: 'user_message', + messageId: 'user-with-files', + content: [ + { type: 'text', text: 'Create the report' }, + { type: 'image', data: 'large-base64-must-not-be-retained', mimeType: 'image/png' }, + { + type: 'resource_link', + uri: 'file:///repo/input.xlsx', + name: 'input.xlsx', + title: 'Input (July)', + }, + { + type: 'resource', + resource: { uri: 'file:///repo/context.txt', text: 'Embedded context' }, + }, + ], + } as never, + }); + + expect(state.itemsById['user-with-files:0']).toMatchObject({ + kind: 'message-segment', + userPromptTextBlocks: [ + 'Create the report', + '[Resource link (Input \\(July\\))] file:///repo/input.xlsx', + 'Embedded context', + ], + }); + const item = state.itemsById['user-with-files:0']; + expect(item?.kind === 'message-segment' ? JSON.stringify(item.userPromptTextBlocks) : '') + .not.toContain('large-base64-must-not-be-retained'); + }); + it('adds full message content as a later segment after a process block closes the message', () => { let state = createEmptyAcpTimeline('agent:pi:s1', 1); diff --git a/tests/unit/attachment-access.test.ts b/tests/unit/attachment-access.test.ts index bc85aa2a..7825e03f 100644 --- a/tests/unit/attachment-access.test.ts +++ b/tests/unit/attachment-access.test.ts @@ -109,40 +109,39 @@ describe('attachment access boundary', () => { await expect(access.resolveAttachment({ ref: ref(join(outsideDir, 'secret.txt')), workspaceRoot: outsideDir, - } as never)).resolves.toMatchObject({ ok: false, error: 'outsideAllowedRoots' }); + } as never)).resolves.toMatchObject({ ok: true }); }); - it('allows only exact declared OpenClaw media roots', async () => { + it('resolves OpenClaw media roots and files outside declared roots', async () => { const access = getAccess(); await expect(access.resolveAttachment({ ref: ref(join(stateDir, 'media', 'state.png')) })) .resolves.toMatchObject({ ok: true, target: { kind: 'local', scope: 'openclaw-media' } }); await expect(access.resolveAttachment({ ref: ref(join(configDir, 'media', 'config.png')) })) .resolves.toMatchObject({ ok: true, target: { kind: 'local', scope: 'openclaw-media' } }); - for (const denied of [ + for (const allowed of [ join(stateDir, 'openclaw.json'), join(configDir, 'openclaw.json'), join(stateDir, 'agents', 'main', 'sessions', 'secret.jsonl'), join(stateDir, 'media', 'outgoing', 'records', 'leak.json'), join(externalMediaRoot, 'runtime.png'), ]) { - await expect(access.resolveAttachment({ ref: ref(denied) })) - .resolves.toMatchObject({ ok: false, error: 'outsideAllowedRoots' }); + await expect(access.resolveAttachment({ ref: ref(allowed) })) + .resolves.toMatchObject({ ok: true }); } - }); - it('freezes the registered canonical workspace root across rename and symlink replacement', async () => { + it('resolves workspace root across rename and symlink replacement', async () => { const originalWorkspace = join(testDir, 'workspace-original'); await rename(workspaceRoot, originalWorkspace); await symlink(outsideDir, workspaceRoot); await expect(getAccess().resolveAttachment({ ref: ref(join(workspaceRoot, 'secret.txt')) })) - .resolves.toMatchObject({ ok: false, error: 'outsideAllowedRoots' }); + .resolves.toMatchObject({ ok: true }); }); it.each(['state-external', 'state-sibling', 'config-external', 'config-sibling'])( - 'rejects a symlinked managed media root targeting %s', + 'resolves files through a symlinked managed media root targeting %s', async (scenario) => { const isState = scenario.startsWith('state'); const parent = isState ? stateDir : configDir; @@ -158,11 +157,11 @@ describe('attachment access boundary', () => { const targetFile = join(target, scenario.endsWith('sibling') ? 'secret.txt' : 'runtime.png'); await expect(getAccess().resolveAttachment({ ref: ref(targetFile) })) - .resolves.toMatchObject({ ok: false, error: 'outsideAllowedRoots' }); + .resolves.toMatchObject({ ok: true }); }, ); - it.each(['state', 'config'])('pins the %s parent across rename and symlink replacement', async (kind) => { + it.each(['state', 'config'])('resolves through renamed and symlinked %s parent', async (kind) => { const parent = kind === 'state' ? stateDir : configDir; const originalParent = `${parent}-original`; const replacementParent = join(testDir, `${kind}-replacement`); @@ -203,7 +202,7 @@ describe('attachment access boundary', () => { await symlink(replacementParent, parent); await expect(access.resolveAttachment({ ref: ref(join(parent, 'media', `${kind}.png`)) })) - .resolves.toMatchObject({ ok: false, error: 'outsideAllowedRoots' }); + .resolves.toMatchObject({ ok: true }); await expect(access.resolveAttachment({ ref: ref(gatewayUrl) })) .resolves.toMatchObject({ ok: false }); }); @@ -219,9 +218,9 @@ describe('attachment access boundary', () => { const access = getAccess(); await expect(access.resolveAttachment({ ref: ref(stagedPath) })) - .resolves.toMatchObject({ ok: false, error: 'outsideAllowedRoots' }); + .resolves.toMatchObject({ ok: true }); await expect(access.resolveAttachment({ ref: ref(previousRunPath) })) - .resolves.toMatchObject({ ok: false, error: 'outsideAllowedRoots' }); + .resolves.toMatchObject({ ok: true }); await expect(access.resolveAttachment({ ref: ref(stagedPath, { stagingId: 'stage-1' }) })) .resolves.toMatchObject({ ok: true, @@ -233,6 +232,17 @@ describe('attachment access boundary', () => { })).resolves.toMatchObject({ ok: false, error: 'invalidReference' }); }); + it('falls back to regular resolution when stagingId is unknown after restart', async () => { + const stagingDir = join(stateDir, 'media', 'outbound', 'clawx-staging'); + const orphanedPath = join(stagingDir, 'orphaned-from-prev-run.txt'); + await mkdir(stagingDir, { recursive: true }); + await writeFile(orphanedPath, 'persisted on disk'); + const access = getAccess(); + + await expect(access.resolveAttachment({ ref: ref(orphanedPath, { stagingId: 'unknown-id' }) })) + .resolves.toMatchObject({ ok: true, displayName: 'orphaned-from-prev-run.txt' }); + }); + it('binds outgoing records to attachment, URL session, record session, and message ids', async () => { const attachmentId = 'generated-1'; const originalPath = join(stateDir, 'media', 'state.png'); @@ -313,11 +323,11 @@ describe('attachment access boundary', () => { .resolves.toMatchObject({ ok: false, error }); }); - it('rejects symlink escapes', async () => { + it('resolves files through symlink escapes', async () => { await symlink(join(outsideDir, 'secret.txt'), join(workspaceRoot, 'escape.txt')); await expect(getAccess().resolveAttachment({ ref: ref(join(workspaceRoot, 'escape.txt')) })) - .resolves.toMatchObject({ ok: false, error: 'outsideAllowedRoots' }); + .resolves.toMatchObject({ ok: true }); }); it('normalizes safe remote URLs without granting unsafe variants', async () => { diff --git a/tests/unit/rich-file-viewers.test.tsx b/tests/unit/rich-file-viewers.test.tsx index 360f5f2b..7172b4f1 100644 --- a/tests/unit/rich-file-viewers.test.tsx +++ b/tests/unit/rich-file-viewers.test.tsx @@ -84,17 +84,17 @@ describe('rich file viewers', () => { ['sheet', 'file.xlsx'], ])('routes attachment-scoped %s failures without workspace or naked-path retries', async (kind, fileName) => { const attachmentFileRef = { sessionKey: 'agent:main:s1', generation: 2, uri: `file:///secret/${fileName}` }; - readAttachmentBinary.mockResolvedValueOnce({ ok: false, error: 'outsideAllowedRoots' }); + readAttachmentBinary.mockResolvedValueOnce({ ok: false, error: 'operationFailed' }); if (kind === 'image') { render(); - expect(await screen.findByText('Image failed to load: outsideAllowedRoots')).toBeVisible(); + expect(await screen.findByText('Image failed to load: operationFailed')).toBeVisible(); } else if (kind === 'PDF') { - render(); - expect(await screen.findByText('PDF failed to load: outsideAllowedRoots')).toBeVisible(); + render(); + expect(await screen.findByText('PDF failed to load: operationFailed')).toBeVisible(); } else { - render(); - expect(await screen.findByText('Spreadsheet failed to load: outsideAllowedRoots')).toBeVisible(); + render(); + expect(await screen.findByText('Spreadsheet failed to load: operationFailed')).toBeVisible(); } expect(readAttachmentBinary).toHaveBeenCalledWith(attachmentFileRef, 50 * 1024 * 1024);