From 4fc83e5b13e03cdd8c952f379e3a48960ee6016b Mon Sep 17 00:00:00 2001 From: kyugao Date: Tue, 4 Aug 2026 01:09:20 +0800 Subject: [PATCH] feat(m3): add consent-gated capability runtime --- tauri/src/capability-card.css | 22 ++ tauri/src/main.ts | 310 +++++++++++++++++- tauri/src/protocol.ts | 11 +- .../runtime/artifact-content-cache.test.ts | 13 + tauri/src/runtime/artifact-content-cache.ts | 22 ++ tauri/src/runtime/artifact-state.test.ts | 13 + tauri/src/runtime/artifact-state.ts | 45 +++ tauri/src/runtime/capability-audit.test.ts | 15 + tauri/src/runtime/capability-audit.ts | 28 ++ .../src/runtime/capability-call-state.test.ts | 49 +++ tauri/src/runtime/capability-call-state.ts | 119 +++++++ tauri/src/runtime/capability-executor.test.ts | 42 +++ tauri/src/runtime/capability-executor.ts | 63 ++++ tauri/src/runtime/capability-registry.test.ts | 25 ++ tauri/src/runtime/capability-registry.ts | 105 ++++++ tauri/src/runtime/client-inventory.test.ts | 36 ++ tauri/src/runtime/client-inventory.ts | 49 +++ tauri/src/runtime/conversation-store.test.ts | 42 +++ tauri/src/runtime/conversation-store.ts | 88 ++++- tauri/src/runtime/interaction-kernel.test.ts | 31 ++ tauri/src/runtime/trusted-dom-renderers.ts | 138 +++++++- 21 files changed, 1246 insertions(+), 20 deletions(-) create mode 100644 tauri/src/capability-card.css create mode 100644 tauri/src/runtime/artifact-content-cache.test.ts create mode 100644 tauri/src/runtime/artifact-content-cache.ts create mode 100644 tauri/src/runtime/artifact-state.test.ts create mode 100644 tauri/src/runtime/artifact-state.ts create mode 100644 tauri/src/runtime/capability-audit.test.ts create mode 100644 tauri/src/runtime/capability-audit.ts create mode 100644 tauri/src/runtime/capability-call-state.test.ts create mode 100644 tauri/src/runtime/capability-call-state.ts create mode 100644 tauri/src/runtime/capability-executor.test.ts create mode 100644 tauri/src/runtime/capability-executor.ts create mode 100644 tauri/src/runtime/capability-registry.test.ts create mode 100644 tauri/src/runtime/capability-registry.ts create mode 100644 tauri/src/runtime/client-inventory.test.ts create mode 100644 tauri/src/runtime/client-inventory.ts diff --git a/tauri/src/capability-card.css b/tauri/src/capability-card.css new file mode 100644 index 0000000..f2c1871 --- /dev/null +++ b/tauri/src/capability-card.css @@ -0,0 +1,22 @@ +.capability-card { + display: grid; + justify-self: start; + gap: .7rem; + width: min(100%, 32rem); + padding: 1rem; + border: 1px solid #537362; + border-radius: .9rem; + background: #1c2c24; +} +.capability-card > p { margin: 0; color: #bfd1c6; line-height: 1.45; } +.capability-risk, .capability-status { color: #aac5b5; } +.capability-actions { display: flex; flex-wrap: wrap; gap: .5rem; } +.capability-actions button { + border: 0; border-radius: .65rem; padding: .8rem 1rem; + background: #a9dfc4; color: #102018; font-weight: 750; cursor: pointer; +} +.capability-actions .secondary-action { background: #395447; color: #e2f1e8; } +.capability-card[data-status="completed"] { border-color: #89c7a6; } +.capability-card[data-status="rejected"], .capability-card[data-status="expired"], +.capability-card[data-status="failed"], .capability-card[data-status="unsupported"] { border-color: #587064; } +.capability-card button:disabled { opacity: .5; cursor: not-allowed; } diff --git a/tauri/src/main.ts b/tauri/src/main.ts index 50b557d..a43fc89 100644 --- a/tauri/src/main.ts +++ b/tauri/src/main.ts @@ -1,4 +1,5 @@ import "./style.css"; +import "./capability-card.css"; import "./execution-progress.css"; import { type Actor, @@ -16,6 +17,13 @@ import { TrustedDOMRendererContext } from "./runtime/trusted-dom-renderers"; import { SurfaceRegistry } from "./runtime/surface-registry"; import { SurfaceInstanceManager } from "./runtime/surface-instance-manager"; import { IsolatedSurfaceHost } from "./runtime/isolated-surface-host"; +import { CapabilityRegistry } from "./runtime/capability-registry"; +import { ClientInventoryPublisher } from "./runtime/client-inventory"; +import { ArtifactState } from "./runtime/artifact-state"; +import { ArtifactContentCache } from "./runtime/artifact-content-cache"; +import { CapabilityCallStateMachine, type CapabilityCallRecord, parseCapabilityCall } from "./runtime/capability-call-state"; +import { CapabilityAuditLog } from "./runtime/capability-audit"; +import { CapabilityExecutor, type PickedFile } from "./runtime/capability-executor"; type Session = { api: string; @@ -37,6 +45,25 @@ const conversationStore = new ConversationStore(localStorage); const executionProgress = new ExecutionProgressState(); const surfaceRegistry = new SurfaceRegistry(); const surfaceInstances = new SurfaceInstanceManager(surfaceRegistry); +const capabilityRegistry = new CapabilityRegistry(); +const inventoryPublisher = new ClientInventoryPublisher(); +const artifactState = new ArtifactState(); +const artifactContentCache = new ArtifactContentCache(); +const capabilityCalls = new CapabilityCallStateMachine(); +const capabilityAudit = new CapabilityAuditLog(); +const capabilityExecutor = new CapabilityExecutor({ + async openURL(url) { + // With `noopener`, Chromium intentionally returns null even when it did + // create the tab. Treat a synchronous exception as failure, but not this + // security-preserving return value; otherwise a successful safe open is + // incorrectly reported as `handler_failed` to the Agent. + window.open(url, "_blank", "noopener,noreferrer"); + }, + writeClipboard: writeClipboardFromTrustedHost, + pickFile: pickFileFromTrustedHost, + async saveArtifact(artifactID) { saveCachedArtifact(artifactID); }, +}, artifactState); +let lastInventoryRevision = ""; const SURFACE_REGISTRY_STORAGE_KEY = "lineup.surface-registry.v1"; try { surfaceRegistry.restore(JSON.parse(localStorage.getItem(SURFACE_REGISTRY_STORAGE_KEY) ?? "null")); } catch { surfaceRegistry.restore(null); } @@ -146,6 +173,11 @@ const rendererContext = new TrustedDOMRendererContext(messages, presence, { return transition.disposition === "accepted"; }, read(operationID) { return interactionKernel.snapshot().tasks.find(task => task.operation_id === operationID); }, +}, { + async approve(callID) { return approveCapabilityCall(callID); }, + reject(callID) { return rejectCapabilityCall(callID); }, + expire(callID) { return expireCapabilityCall(callID); }, + read(callID) { return capabilityCalls.get(callID); }, }); const rendererRegistry = new RendererRegistry(); rendererRegistry.register("user-message", (item, context) => context.renderUserMessage(item)); @@ -162,6 +194,34 @@ rendererRegistry.register("app-call", (item, context) => context.renderAppCall(i rendererRegistry.register("fallback", (item, context) => context.renderFallback(item)); function renderIncoming(item: ConversationItem): void { + if (item.kind === "app-call") { + const now = new Date().toISOString(); + const parsed = parseCapabilityCall(item.envelope.payload, capabilityRegistry, now); + if (parsed.disposition === "unsupported") { + rendererContext.renderSystemMessage("此 App 能力当前未在本机启用,未执行任何操作。"); + return; + } + if (parsed.disposition !== "accepted") { + rendererContext.renderSystemMessage("收到无法安全确认的 App 能力请求。"); + return; + } + const transition = capabilityCalls.receive(parsed.request, item.envelope.conversation_id, now); + if (transition.disposition === "accepted" && transition.record) persistCapabilityCall(transition.record, now); + // Both an accepted call and an exact protocol duplicate render from the + // state machine. A duplicate cannot create a second actionable card. + rendererContext.renderAppCall(item); + return; + } + if (item.kind === "artifact-offer") { + const artifact = artifactState.offer(item.envelope.payload); + if (!artifact) { + rendererContext.renderSystemMessage("收到无法安全验证的 Artifact。 "); + return; + } + rendererContext.renderArtifact(artifact); + reconcileArtifactSaveAvailability(); + return; + } if (item.kind === "surface") { const now = new Date().toISOString(); if (item.envelope.type === "lineup.v1.ui.open") { @@ -205,12 +265,33 @@ function replayStoredSurfaceLifecycle(): void { } } +/** + * Inventory is a declarative host-to-Agent update, never an action request. + * Failed delivery is retried by syncLoop; a revision changes only when public + * availability changes, never because of a retry. + */ +async function publishInventory(): Promise { + if (!session.active || !session.uid || !transport) return; + const update = inventoryPublisher.sync(surfaceRegistry.snapshot(), capabilityRegistry.inventory()); + if (update.inventory.revision === lastInventoryRevision) return; + const envelope = makeProtocolEvent( + "lineup.v1.client.inventory", + update.inventory as unknown as JsonObject, + currentConversationKey(), + { kind: "human", id: session.uid }, + { kind: "agent", id: session.agentUID }, + ); + await transport.sendText({ from_uid: session.uid, channel_id: session.channelID, channel_type: session.channelType, payload: JSON.stringify(envelope) }); + lastInventoryRevision = update.inventory.revision; +} + $("#surface-demo").addEventListener("click", () => { const now = new Date().toISOString(); surfaceRegistry.enable("lineup.task-dashboard", "0.1.0", now); localStorage.setItem(SURFACE_REGISTRY_STORAGE_KEY, JSON.stringify(surfaceRegistry.snapshot())); replayStoredSurfaceLifecycle(); conversationStore.replaceSurfaces(surfaceInstances.snapshot()); + void publishInventory(); }); /** @@ -245,6 +326,221 @@ function currentConversationKey(): string { return `${session.uid}:${session.channelType}:${session.channelID}`; } +/** + * Only host-owned bytes can make artifact.save requestable. The protocol's + * local_ref is opaque and never dereferenced as a path/URL; if a host has no + * cache entry the capability is absent from the next client.inventory. + */ +function reconcileArtifactSaveAvailability(): void { + const available = artifactState.snapshot().some(artifact => artifact.integrity === "verified" && artifactContentCache.has(artifact.local_ref)); + const before = capabilityRegistry.resolve("artifact.save").disposition === "available"; + if (before === available) return; + capabilityRegistry.setVisible("artifact.save", available); + void publishInventory(); +} + +function saveCachedArtifact(artifactID: string): void { + const artifact = artifactState.get(artifactID); + const blob = artifact?.integrity === "verified" && artifact.local_ref ? artifactContentCache.get(artifact.local_ref) : undefined; + if (!artifact || !blob) throw new Error("artifact_cache_unavailable"); + const objectURL = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = objectURL; + anchor.download = artifact.name; + anchor.hidden = true; + document.body.append(anchor); + try { anchor.click(); } finally { + anchor.remove(); + window.setTimeout(() => URL.revokeObjectURL(objectURL), 0); + } +} + +/** + * HTTPS/Tauri hosts use the standard asynchronous Clipboard API. The HTTP + * development reference host cannot rely on that API being exposed, so it + * falls back to the browser's user-activation-gated copy command. Both paths + * are reached only after the native capability confirmation; the temporary + * textarea is removed synchronously and no clipboard value is persisted or + * reflected into a protocol result. + */ +async function writeClipboardFromTrustedHost(text: string): Promise { + if (navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(text); + return; + } catch { + // Development HTTP contexts often expose the property but reject the + // permission-gated call. Fall through to the user-activation fallback. + } + } + const input = document.createElement("textarea"); + input.value = text; + input.readOnly = true; + input.setAttribute("aria-hidden", "true"); + input.style.cssText = "position:fixed;left:-9999px;top:0;opacity:0;pointer-events:none"; + document.body.append(input); + try { + input.focus(); + input.select(); + if (!document.execCommand("copy")) throw new Error("clipboard_copy_unavailable"); + } finally { + input.remove(); + } +} + +/** + * An optional small binary body is a bounded delivery payload, not a URL or + * filesystem reference. It must bind exactly to the metadata size and opaque + * local ref before it enters the host-owned volatile cache. + */ +function cacheArtifactContent(payload: JsonObject, artifact: { size_bytes: number; local_ref?: string }): boolean { + const encoded = payload.content_base64; + if (encoded === undefined) return true; + if (typeof encoded !== "string" || !artifact.local_ref || encoded.length > 8 * 1024 * 1024) return false; + try { + const binary = atob(encoded); + if (binary.length !== artifact.size_bytes || binary.length > 6 * 1024 * 1024) return false; + const bytes = Uint8Array.from(binary, character => character.charCodeAt(0)); + return artifactContentCache.put(artifact.local_ref, new Blob([bytes])); + } catch { return false; } +} + +/** + * Cache optional bytes before persistence, then remove them from the durable + * transcript. A reload can show metadata but cannot retain volatile content + * or silently restore a save operation without a new host-owned offer. + */ +function prepareArtifactOffer(item: Extract): Extract | undefined { + const payload = item.envelope.payload; + const localRef = typeof payload.local_ref === "string" ? payload.local_ref : undefined; + const size = typeof payload.size_bytes === "number" ? payload.size_bytes : -1; + if (!cacheArtifactContent(payload, { size_bytes: size, ...(localRef ? { local_ref: localRef } : {}) })) return undefined; + if (!("content_base64" in payload)) return item; + const { content_base64: _content, ...metadata } = payload; + return { ...item, envelope: { ...item.envelope, payload: metadata } }; +} + +function persistCapabilityCall(record: CapabilityCallRecord, occurredAt: string): void { + conversationStore.replaceCapabilityCalls(capabilityCalls.snapshot()); + const resolution = capabilityRegistry.resolve(record.request.capability); + if (resolution.disposition === "available") capabilityAudit.record(record, resolution.definition.risk, occurredAt); +} + +function rejectCapabilityCall(callID: string): CapabilityCallRecord | undefined { + const now = new Date().toISOString(); + const transition = capabilityCalls.reject(callID, now); + if (transition.disposition !== "accepted" || !transition.record) return transition.record; + persistCapabilityCall(transition.record, now); + queueAppResult(transition.record); + return transition.record; +} + +function expireCapabilityCall(callID: string): CapabilityCallRecord | undefined { + const now = new Date().toISOString(); + const transition = capabilityCalls.expire(callID, now); + if (transition.disposition !== "accepted" || !transition.record) return transition.record; + persistCapabilityCall(transition.record, now); + queueAppResult(transition.record); + return transition.record; +} + +async function approveCapabilityCall(callID: string): Promise { + let now = new Date().toISOString(); + const approval = capabilityCalls.approve(callID, now); + if (approval.disposition !== "accepted" || !approval.record) return approval.record; + persistCapabilityCall(approval.record, now); + + // Re-check local policy at the moment authority is consumed. A stale + // inventory or a policy change between display and click cannot run a host + // handler merely because a confirmation card was previously visible. + const resolution = capabilityRegistry.resolve(approval.record.request.capability); + if (resolution.disposition !== "available") { + const unsupported = capabilityCalls.unsupported(callID, new Date().toISOString()); + if (unsupported.record) { + persistCapabilityCall(unsupported.record, new Date().toISOString()); + queueAppResult(unsupported.record); + } + return unsupported.record; + } + + now = new Date().toISOString(); + const beginning = capabilityCalls.begin(callID, now); + if (beginning.disposition !== "accepted" || !beginning.record) return beginning.record; + persistCapabilityCall(beginning.record, now); + const execution = await capabilityExecutor.execute(beginning.record); + now = new Date().toISOString(); + const terminal = execution.status === "cancelled" + ? capabilityCalls.cancel(callID, now) + : capabilityCalls.finish(callID, execution.status, execution.result, now); + if (terminal.record) { + persistCapabilityCall(terminal.record, now); + if (terminal.disposition === "accepted") queueAppResult(terminal.record); + } + return terminal.record; +} + +/** + * Sends a bounded outcome projection, never a capability argument, native + * exception, local file path or Artifact reference. This is also the durable + * idempotency boundary: only a newly accepted terminal transition queues it. + */ +function queueAppResult(record: CapabilityCallRecord): void { + if (!session.uid) return; + const payload = safeCapabilityResult(record); + const createdAt = new Date().toISOString(); + const envelope = makeProtocolEvent("lineup.v1.app.result", payload, currentConversationKey(), { kind: "human", id: session.uid }, { kind: "agent", id: session.agentUID }); + try { + conversationStore.enqueueToolAction(newLocalID("app-result"), "app-result", JSON.stringify(envelope), createdAt); + void flushOutbox(); + } catch (reason) { rendererContext.renderSystemMessage(storageFailureMessage(reason)); } +} + +function safeCapabilityResult(record: CapabilityCallRecord): JsonObject { + const outcome: JsonObject = { + call_id: record.call_id, + capability: record.request.capability, + status: record.status, + }; + if (record.status === "failed") outcome.code = typeof record.result?.code === "string" ? record.result.code : "failed"; + if (record.status === "completed") outcome.completed = true; + return outcome; +} + +function pickFileFromTrustedHost(): Promise { + return new Promise(resolve => { + const input = document.createElement("input"); + input.type = "file"; + input.hidden = true; + let settled = false; + let onFocus: () => void; + const finish = (value: PickedFile | undefined) => { + if (settled) return; + settled = true; + window.removeEventListener("focus", onFocus); + input.remove(); + resolve(value); + }; + input.addEventListener("change", () => { + const file = input.files?.item(0); + finish(file ? { name: file.name, mime_type: file.type || "application/octet-stream", size_bytes: file.size } : undefined); + }, { once: true }); + // Cancelling a native picker does not consistently dispatch `change`. + // Once focus returns to the trusted host, resolve a no-selection outcome; + // no input.value/path is ever read or retained. + onFocus = () => window.setTimeout(() => { + const file = input.files?.item(0); + if (!file) finish(undefined); + }, 0); + document.body.append(input); + // Register before triggering the native dialog. Some browser/desktop + // combinations return focus immediately on cancellation; registering in + // a later task can miss that only signal and leave the capability call + // permanently executing. + window.addEventListener("focus", onFocus, { once: true }); + input.click(); + }); +} + function queueToolAction(kind: "tool-result" | "tool-cancel", callID: string, payload: JsonObject): void { if (!session.uid) { rendererContext.renderSystemMessage("交互结果已保留,将在下次登录后发送。"); @@ -323,6 +619,7 @@ async function syncLoop(): Promise { polling = true; while (session.active) { try { + try { await publishInventory(); } catch { /* retried by the next loop */ } // WuKongIM's legacy sync endpoint can return a single retained message // per request even when `more` is 0. Drain until it is empty before // switching to the paced live-polling loop; otherwise a reopened chat @@ -352,7 +649,7 @@ async function syncLoop(): Promise { const payload = decodeBase64(message.payload); if (message.from_uid === session.uid) { const ownEnvelope = parseEnvelopeJSON(payload); - if (ownEnvelope.ok && (ownEnvelope.envelope.type === "lineup.v1.tool.result" || ownEnvelope.envelope.type === "lineup.v1.tool.cancel" || ownEnvelope.envelope.type === "lineup.v1.ui.event")) { + if (ownEnvelope.ok && (ownEnvelope.envelope.type === "lineup.v1.tool.result" || ownEnvelope.envelope.type === "lineup.v1.tool.cancel" || ownEnvelope.envelope.type === "lineup.v1.ui.event" || ownEnvelope.envelope.type === "lineup.v1.client.inventory" || ownEnvelope.envelope.type === "lineup.v1.app.result")) { // Tool action echoes advance the inclusive cursor but are never // rendered as raw JSON user messages or replayed as a new action. continue; @@ -373,9 +670,11 @@ async function syncLoop(): Promise { } for (const item of result.items) { const receivedAt = new Date().toISOString(); - if (conversationStore.recordIncoming(item, message.message_seq, receivedAt)) { - projectExecutionProgress(item, receivedAt); - renderIncoming(item); + const safeItem = item.kind === "artifact-offer" ? prepareArtifactOffer(item) : item; + if (!safeItem) continue; + if (conversationStore.recordIncoming(safeItem, message.message_seq, receivedAt)) { + projectExecutionProgress(safeItem, receivedAt); + renderIncoming(safeItem); } } } @@ -424,6 +723,7 @@ $("#login-form").addEventListener("submit", async event => { surfaceInstances.restore(snapshot.surfaces); interactionKernel.restoreToolCalls(snapshot.tool_calls, new Date().toISOString()); interactionKernel.restoreTasks(snapshot.tasks); + capabilityCalls.restore(snapshot.capability_calls); executionProgress.restore(snapshot.execution_summaries); conversationStore.replaceToolCalls(interactionKernel.snapshot().tool_calls); conversationStore.replaceTasks(interactionKernel.snapshot().tasks); @@ -438,7 +738,7 @@ $("#login-form").addEventListener("submit", async event => { } for (const instance of surfaceInstances.snapshot().instances) surfaceHost.mount(instance); rendererContext.restoreExecutionSummaries(snapshot.execution_summaries); - rendererContext.renderSystemMessage("已进入与 AI Agent 的对话"); rendererContext.setPresence("正在同步消息…"); void flushOutbox(); void syncLoop(); + rendererContext.renderSystemMessage("已进入与 AI Agent 的对话"); rendererContext.setPresence("正在同步消息…"); void publishInventory(); void flushOutbox(); void syncLoop(); } catch (reason) { error.textContent = loginFailureMessage(reason, endpoint); } finally { submit.disabled = false; submit.textContent = "进入 LineUp"; } }); diff --git a/tauri/src/protocol.ts b/tauri/src/protocol.ts index 5294c31..864bc8d 100644 --- a/tauri/src/protocol.ts +++ b/tauri/src/protocol.ts @@ -193,6 +193,7 @@ export type ConversationItem = | (ConversationItemBase & { kind: "tool-result"; result: ToolCallResult }) | (ConversationItemBase & { kind: "tool-cancel"; cancel: ToolCallCancel }) | (ConversationItemBase & { kind: "surface"; envelope: Envelope }) + | (ConversationItemBase & { kind: "artifact-offer"; envelope: Envelope }) | (ConversationItemBase & { kind: "app-call"; envelope: Envelope }) | (ConversationItemBase & { kind: "fallback"; reason: ProtocolFallbackCode; detail: string; envelope?: Envelope }); @@ -213,6 +214,7 @@ const SUPPORTED_TYPES = new Set([ "lineup.v1.ui.open", "lineup.v1.ui.patch", "lineup.v1.ui.close", + "lineup.v1.artifact.offer", "lineup.v1.app.call", ]); const ID_MAX_LENGTH = 256; @@ -335,7 +337,10 @@ function hasSurfaceBundleOverride(payload: JsonObject): boolean { } function validAppCallPayload(payload: JsonObject): boolean { - return Boolean(identifier(payload.call_id) && identifier(payload.capability) && object(payload.arguments)); + if (Object.keys(payload).some(key => key !== "call_id" && key !== "capability" && key !== "reason" && key !== "expires_at" && key !== "arguments")) return false; + const reason = text(payload.reason).trim(); + const expiry = optionalTimestamp(payload.expires_at); + return Boolean(identifier(payload.call_id) && identifier(payload.capability) && reason && reason.length <= 500 && expiry && object(payload.arguments)); } const TOOL_KINDS = new Set(["choice", "confirm", "input"]); @@ -570,6 +575,10 @@ export function decodeConversationItem(raw: string): ConversationItem { return validSurfacePayload(envelope.type, payload) ? { kind: "surface", ...base } : fallbackItem({ code: "invalid_payload", detail: "Surface payload does not match its lifecycle contract.", envelope }); + case "lineup.v1.artifact.offer": + return Object.keys(payload).every(key => ["artifact_id", "name", "mime_type", "size_bytes", "integrity", "created_at", "local_ref", "content_base64"].includes(key)) + ? { kind: "artifact-offer", ...base } + : fallbackItem({ code: "invalid_payload", detail: "Artifact offer contains unsupported fields.", envelope }); case "lineup.v1.app.call": return validAppCallPayload(payload) ? { kind: "app-call", ...base } diff --git a/tauri/src/runtime/artifact-content-cache.test.ts b/tauri/src/runtime/artifact-content-cache.test.ts new file mode 100644 index 0000000..7de0ebc --- /dev/null +++ b/tauri/src/runtime/artifact-content-cache.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vitest"; +import { ArtifactContentCache } from "./artifact-content-cache"; + +describe("ArtifactContentCache", () => { + it("accepts only host-owned opaque references and never exposes paths", () => { + const cache = new ArtifactContentCache(); + expect(cache.put("artifact-cache-001", new Blob(["verified bytes"]))).toBe(true); + expect(cache.has("artifact-cache-001")).toBe(true); + expect(cache.get("artifact-cache-001")?.size).toBe(14); + expect(cache.put("/tmp/report.pdf", new Blob(["x"]))).toBe(false); + expect(cache.has("/tmp/report.pdf")).toBe(false); + }); +}); diff --git a/tauri/src/runtime/artifact-content-cache.ts b/tauri/src/runtime/artifact-content-cache.ts new file mode 100644 index 0000000..b78437e --- /dev/null +++ b/tauri/src/runtime/artifact-content-cache.ts @@ -0,0 +1,22 @@ +/** + * Host-owned volatile bytes for Artifact metadata already accepted by + * ArtifactState. This cache has no network, persistence, DOM or protocol + * dependency: an Agent can name an opaque ref but cannot populate or read it. + */ +export class ArtifactContentCache { + private readonly blobs = new Map(); + + public put(localRef: string, content: Blob): boolean { + if (!opaqueRef(localRef) || content.size > 512 * 1024 * 1024) return false; + this.blobs.set(localRef, content); + return true; + } + + public get(localRef: string): Blob | undefined { return this.blobs.get(localRef); } + public has(localRef: string | undefined): boolean { return Boolean(localRef && this.blobs.has(localRef)); } + public clear(): void { this.blobs.clear(); } +} + +function opaqueRef(value: string): boolean { + return /^[A-Za-z0-9_.:-]{1,128}$/.test(value) && !/[\\/]/.test(value); +} diff --git a/tauri/src/runtime/artifact-state.test.ts b/tauri/src/runtime/artifact-state.test.ts new file mode 100644 index 0000000..29a8af3 --- /dev/null +++ b/tauri/src/runtime/artifact-state.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vitest"; +import { ArtifactState } from "./artifact-state"; + +describe("ArtifactState", () => { + it("keeps only bounded metadata and rejects paths or duplicate offers", () => { + const artifacts = new ArtifactState(); + const offered = { artifact_id: "report_001", name: "report.pdf", mime_type: "application/pdf", size_bytes: 42, integrity: "verified", created_at: "2026-08-03T00:00:00.000Z", local_ref: "artifact-cache-001" }; + expect(artifacts.offer(offered)).toEqual(expect.objectContaining({ artifact_id: "report_001", integrity: "verified" })); + expect(artifacts.getByLocalRef("artifact-cache-001")).toEqual(expect.objectContaining({ artifact_id: "report_001" })); + expect(artifacts.offer(offered)).toBeUndefined(); + expect(artifacts.offer({ ...offered, artifact_id: "report_002", local_ref: "/tmp/secret.pdf" })).toBeUndefined(); + }); +}); diff --git a/tauri/src/runtime/artifact-state.ts b/tauri/src/runtime/artifact-state.ts new file mode 100644 index 0000000..7210081 --- /dev/null +++ b/tauri/src/runtime/artifact-state.ts @@ -0,0 +1,45 @@ +export type ArtifactIntegrity = "verified" | "unverified" | "failed"; +export type ArtifactRecord = Readonly<{ + artifact_id: string; + name: string; + mime_type: string; + size_bytes: number; + integrity: ArtifactIntegrity; + created_at: string; + local_ref?: string; +}>; + +/** Metadata-only local Artifact registry. No download, file path, or DOM action. */ +export class ArtifactState { + private readonly records = new Map(); + + public offer(value: unknown): ArtifactRecord | undefined { + const record = parse(value); + if (!record || this.records.has(record.artifact_id)) return undefined; + this.records.set(record.artifact_id, record); + return { ...record }; + } + + public get(id: string): ArtifactRecord | undefined { const record = this.records.get(id); return record ? { ...record } : undefined; } + public getByLocalRef(localRef: string): ArtifactRecord | undefined { + const record = [...this.records.values()].find(candidate => candidate.local_ref === localRef); + return record ? { ...record } : undefined; + } + /** Removes a just-offered record when its separately validated host bytes fail to cache. */ + public discard(id: string): void { this.records.delete(id); } + public snapshot(): readonly ArtifactRecord[] { return [...this.records.values()].sort((left, right) => left.artifact_id.localeCompare(right.artifact_id)).map(record => ({ ...record })); } +} + +function parse(value: unknown): ArtifactRecord | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const item = value as Record; + const id = text(item.artifact_id); const name = text(item.name); const mime = text(item.mime_type); const integrity = item.integrity; + if (!/^[A-Za-z][A-Za-z0-9_.:-]{0,127}$/.test(id) || !name || name.length > 255 || !/^[a-z]+\/[a-z0-9.+-]+$/i.test(mime) + || typeof item.size_bytes !== "number" || !Number.isSafeInteger(item.size_bytes) || item.size_bytes < 0 || item.size_bytes > 512 * 1024 * 1024 + || (integrity !== "verified" && integrity !== "unverified" && integrity !== "failed") || typeof item.created_at !== "string" || Number.isNaN(Date.parse(item.created_at))) return undefined; + const localRef = item.local_ref === undefined ? undefined : text(item.local_ref); + // A reference is opaque, bounded, and must never be a filesystem path or URL. + if (localRef !== undefined && (!/^[A-Za-z0-9_.:-]{1,128}$/.test(localRef) || /[\\/]/.test(localRef))) return undefined; + return { artifact_id: id, name, mime_type: mime, size_bytes: item.size_bytes, integrity, created_at: item.created_at, ...(localRef ? { local_ref: localRef } : {}) }; +} +function text(value: unknown): string { return typeof value === "string" ? value.trim() : ""; } diff --git a/tauri/src/runtime/capability-audit.test.ts b/tauri/src/runtime/capability-audit.test.ts new file mode 100644 index 0000000..6f3cae7 --- /dev/null +++ b/tauri/src/runtime/capability-audit.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import { CapabilityAuditLog } from "./capability-audit"; + +describe("CapabilityAuditLog", () => { + it("records authority metadata but never arguments or result values", () => { + const audit = new CapabilityAuditLog(); + audit.record({ + call_id: "cap_001", conversation_id: "conversation", + request: { call_id: "cap_001", capability: "clipboard.write", reason: "复制内容", expires_at: "2026-08-03T01:00:00.000Z", arguments: { text: "super-secret" } }, + status: "rejected", created_at: "2026-08-03T00:00:00.000Z", updated_at: "2026-08-03T00:00:01.000Z", result: { echoed: "super-secret" }, + }, "user_confirmation", "2026-08-03T00:00:01.000Z"); + expect(audit.snapshot()).toEqual([expect.objectContaining({ call_id: "cap_001", capability: "clipboard.write", status: "rejected" })]); + expect(JSON.stringify(audit.snapshot())).not.toContain("super-secret"); + }); +}); diff --git a/tauri/src/runtime/capability-audit.ts b/tauri/src/runtime/capability-audit.ts new file mode 100644 index 0000000..692ad78 --- /dev/null +++ b/tauri/src/runtime/capability-audit.ts @@ -0,0 +1,28 @@ +import type { CapabilityCallRecord } from "./capability-call-state"; +import type { CapabilityRisk } from "./capability-registry"; + +export type CapabilityAuditEntry = Readonly<{ + call_id: string; + capability: string; + risk: CapabilityRisk; + status: CapabilityCallRecord["status"]; + occurred_at: string; +}>; + +/** + * Bounded audit projection. Parameters and results are intentionally absent: + * a URL, clipboard value, selected path, artifact bytes or native error must + * never become a durable UI audit field merely because a capability ran. + */ +export class CapabilityAuditLog { + private readonly entries: CapabilityAuditEntry[] = []; + + public record(record: CapabilityCallRecord, risk: CapabilityRisk, occurredAt: string): CapabilityAuditEntry { + const entry: CapabilityAuditEntry = { call_id: record.call_id, capability: record.request.capability, risk, status: record.status, occurred_at: occurredAt }; + this.entries.push(entry); + if (this.entries.length > 200) this.entries.splice(0, this.entries.length - 200); + return { ...entry }; + } + + public snapshot(): readonly CapabilityAuditEntry[] { return this.entries.map(entry => ({ ...entry })); } +} diff --git a/tauri/src/runtime/capability-call-state.test.ts b/tauri/src/runtime/capability-call-state.test.ts new file mode 100644 index 0000000..b7c5003 --- /dev/null +++ b/tauri/src/runtime/capability-call-state.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { CapabilityCallStateMachine, type CapabilityCallRequest } from "./capability-call-state"; +import { parseCapabilityCall } from "./capability-call-state"; +import { CapabilityRegistry } from "./capability-registry"; + +const now = "2026-08-03T00:00:00.000Z"; +const request: CapabilityCallRequest = { call_id: "cap_001", capability: "app.open_url", reason: "打开文档", expires_at: "2026-08-03T00:05:00.000Z", arguments: { url: "https://example.com" } }; + +describe("CapabilityCallStateMachine", () => { + it("requires explicit approval before a handler can start", () => { + const calls = new CapabilityCallStateMachine(); + calls.receive(request, "conversation", now); + expect(calls.begin("cap_001", now).disposition).toBe("invalid_transition"); + expect(calls.approve("cap_001", now).disposition).toBe("accepted"); + expect(calls.begin("cap_001", now).disposition).toBe("accepted"); + expect(calls.finish("cap_001", "completed", { opened: true }, now)).toMatchObject({ disposition: "accepted", record: { status: "completed", result: { opened: true } } }); + }); + + it("is idempotent and provides terminal results for denial and expiry", () => { + const calls = new CapabilityCallStateMachine(); + calls.receive(request, "conversation", now); + expect(calls.receive({ ...request, reason: "changed" }, "other", now).disposition).toBe("duplicate"); + expect(calls.reject("cap_001", now)).toMatchObject({ disposition: "accepted", record: { status: "rejected" } }); + expect(calls.reject("cap_001", now).disposition).toBe("invalid_transition"); + calls.receive({ ...request, call_id: "cap_002", expires_at: "2026-08-03T00:01:00.000Z" }, "conversation", now); + expect(calls.approve("cap_002", "2026-08-03T00:02:00.000Z")).toMatchObject({ record: { status: "expired" } }); + }); + + it("admits only current registry capabilities with bounded, unexpired arguments", () => { + const registry = new CapabilityRegistry({ visible: ["app.open_url"] }); + expect(parseCapabilityCall(request, registry, now)).toMatchObject({ disposition: "accepted", request: { capability: "app.open_url" } }); + expect(parseCapabilityCall({ ...request, capability: "clipboard.write", arguments: { text: "x" } }, registry, now)).toEqual({ disposition: "unsupported" }); + expect(parseCapabilityCall({ ...request, arguments: { url: "https://example.com", bypass: "yes" } }, registry, now)).toEqual({ disposition: "invalid" }); + expect(parseCapabilityCall({ ...request, expires_at: "2026-08-02T00:00:00.000Z" }, registry, now)).toEqual({ disposition: "invalid" }); + expect(parseCapabilityCall({ ...request, reason: "打开 https://private.example/path?q=secret" }, registry, now)).toMatchObject({ disposition: "accepted", request: { reason: "打开 链接" } }); + }); + + it("restores a durable call ledger without reopening terminal authority", () => { + const original = new CapabilityCallStateMachine(); + original.receive(request, "conversation", now); + original.approve("cap_001", now); + const restored = new CapabilityCallStateMachine(); + restored.restore(original.snapshot()); + expect(restored.get("cap_001")).toMatchObject({ status: "approved" }); + expect(restored.unsupported("cap_001", now)).toMatchObject({ disposition: "accepted", record: { status: "unsupported" } }); + expect(restored.begin("cap_001", now).disposition).toBe("invalid_transition"); + expect(restored.receive(request, "conversation", now).disposition).toBe("duplicate"); + }); +}); diff --git a/tauri/src/runtime/capability-call-state.ts b/tauri/src/runtime/capability-call-state.ts new file mode 100644 index 0000000..a877aa0 --- /dev/null +++ b/tauri/src/runtime/capability-call-state.ts @@ -0,0 +1,119 @@ +import type { JsonObject } from "../protocol"; +import type { CapabilityDefinition } from "./capability-registry"; +import { CapabilityRegistry } from "./capability-registry"; + +export type CapabilityCallStatus = "pending" | "approved" | "executing" | "completed" | "rejected" | "cancelled" | "expired" | "unsupported" | "failed"; + +export type CapabilityCallRequest = Readonly<{ + call_id: string; + capability: CapabilityDefinition["name"]; + reason: string; + expires_at: string; + arguments: JsonObject; +}>; + +export type CapabilityCallRecord = Readonly<{ + call_id: string; + conversation_id: string; + request: CapabilityCallRequest; + status: CapabilityCallStatus; + created_at: string; + updated_at: string; + result?: JsonObject; +}>; + +export type CapabilityCallTransition = Readonly<{ disposition: "accepted" | "duplicate" | "missing" | "invalid_transition"; record?: CapabilityCallRecord }>; + +export type CapabilityCallParseResult = + | Readonly<{ disposition: "accepted"; request: CapabilityCallRequest }> + | Readonly<{ disposition: "unsupported" | "invalid" }>; + +/** + * Narrows an untrusted wire payload before a trusted confirmation UI sees it. + * Registry availability is intentionally checked here as well as at execution + * time, so an old inventory revision cannot create a usable approval card. + */ +export function parseCapabilityCall(payload: unknown, registry: CapabilityRegistry, now: string): CapabilityCallParseResult { + if (!isObject(payload)) return { disposition: "invalid" }; + const callID = text(payload.call_id); + const capability = text(payload.capability) as CapabilityDefinition["name"]; + const reason = sanitizeCapabilityReason(text(payload.reason)); + const expiresAt = text(payload.expires_at); + if (!/^[A-Za-z][A-Za-z0-9_.:-]{0,127}$/.test(callID) || !reason || reason.length > 500 || !expiresAt || Number.isNaN(Date.parse(expiresAt))) return { disposition: "invalid" }; + const resolution = registry.resolve(capability); + if (resolution.disposition !== "available") return { disposition: "unsupported" }; + if (!registry.validateArguments(capability, payload.arguments) || Date.parse(expiresAt) <= Date.parse(now)) return { disposition: "invalid" }; + return { disposition: "accepted", request: { call_id: callID, capability, reason, expires_at: expiresAt, arguments: clone(payload.arguments as JsonObject) } }; +} + +/** Lifecycle gate only. No DOM, persistence, network, permission or handler exists here. */ +export class CapabilityCallStateMachine { + private readonly calls = new Map(); + + public receive(request: CapabilityCallRequest, conversationID: string, now: string): CapabilityCallTransition { + const existing = this.calls.get(request.call_id); + if (existing) return { disposition: "duplicate", record: copy(existing) }; + const expired = Date.parse(request.expires_at) <= Date.parse(now); + const record: CapabilityCallRecord = { call_id: request.call_id, conversation_id: conversationID, request: copyRequest(request), status: expired ? "expired" : "pending", created_at: now, updated_at: now }; + this.calls.set(record.call_id, record); + return { disposition: "accepted", record: copy(record) }; + } + + public approve(callID: string, now: string): CapabilityCallTransition { return this.transition(callID, now, ["pending"], "approved"); } + public reject(callID: string, now: string): CapabilityCallTransition { return this.transition(callID, now, ["pending"], "rejected"); } + public begin(callID: string, now: string): CapabilityCallTransition { return this.transition(callID, now, ["approved"], "executing"); } + public cancel(callID: string, now: string): CapabilityCallTransition { return this.transition(callID, now, ["pending", "approved", "executing"], "cancelled"); } + public unsupported(callID: string, now: string): CapabilityCallTransition { return this.transition(callID, now, ["pending", "approved"], "unsupported"); } + public finish(callID: string, status: "completed" | "failed", result: JsonObject, now: string): CapabilityCallTransition { + const transition = this.transition(callID, now, ["executing"], status); + if (transition.disposition !== "accepted" || !transition.record) return transition; + const record = this.calls.get(callID)! as CapabilityCallRecord & { result?: JsonObject }; + record.result = clone(result); + return { disposition: "accepted", record: copy(record) }; + } + public expire(callID: string, now: string): CapabilityCallTransition { return this.transition(callID, now, ["pending", "approved"], "expired"); } + public get(callID: string): CapabilityCallRecord | undefined { const record = this.calls.get(callID); return record ? copy(record) : undefined; } + public snapshot(): readonly CapabilityCallRecord[] { return [...this.calls.values()].map(copy); } + + /** Restores only records already normalized by the persistence boundary. */ + public restore(records: readonly CapabilityCallRecord[]): void { + this.calls.clear(); + for (const record of records) { + if (this.calls.has(record.call_id)) continue; + this.calls.set(record.call_id, copy(record)); + } + } + + private transition(callID: string, now: string, allowed: readonly CapabilityCallStatus[], next: CapabilityCallStatus): CapabilityCallTransition { + const record = this.calls.get(callID); + if (!record) return { disposition: "missing" }; + if (Date.parse(record.request.expires_at) <= Date.parse(now) && (record.status === "pending" || record.status === "approved")) { + (record as { status: CapabilityCallStatus; updated_at: string }).status = "expired"; + (record as { updated_at: string }).updated_at = now; + return { disposition: "invalid_transition", record: copy(record) }; + } + if (!allowed.includes(record.status)) return { disposition: "invalid_transition", record: copy(record) }; + (record as { status: CapabilityCallStatus; updated_at: string }).status = next; + (record as { updated_at: string }).updated_at = now; + return { disposition: "accepted", record: copy(record) }; + } +} + +function copyRequest(request: CapabilityCallRequest): CapabilityCallRequest { return { ...request, arguments: clone(request.arguments) }; } +function copy(record: CapabilityCallRecord): CapabilityCallRecord { return { ...record, request: copyRequest(record.request), ...(record.result ? { result: clone(record.result) } : {}) }; } +function clone(value: T): T { return JSON.parse(JSON.stringify(value)) as T; } +function text(value: unknown): string { return typeof value === "string" ? value : ""; } +/** A confirmation reason is an intent label, never a covert argument preview. */ +export function sanitizeCapabilityReason(value: string): string { + return value.trim() + .replace(/https?:\/\/\S+/giu, "链接") + .replace(/(?:[A-Za-z]:)?[\\/]\S+/gu, "本地资源") + .replace(/\s+/gu, " ") + .slice(0, 500) + .trim(); +} +function isObject(value: unknown): value is Record { + if (value === null || typeof value !== "object" || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} diff --git a/tauri/src/runtime/capability-executor.test.ts b/tauri/src/runtime/capability-executor.test.ts new file mode 100644 index 0000000..4d139bd --- /dev/null +++ b/tauri/src/runtime/capability-executor.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it, vi } from "vitest"; +import { ArtifactState } from "./artifact-state"; +import { CapabilityExecutor } from "./capability-executor"; +import type { CapabilityCallRecord } from "./capability-call-state"; + +const now = "2026-08-03T00:00:00.000Z"; +function call(capability: CapabilityCallRecord["request"]["capability"], argumentsValue: Record): CapabilityCallRecord { + return { call_id: "cap_001", conversation_id: "conversation", request: { call_id: "cap_001", capability, reason: "测试", expires_at: "2026-08-03T01:00:00.000Z", arguments: argumentsValue }, status: "executing", created_at: now, updated_at: now }; +} + +describe("CapabilityExecutor", () => { + it("uses injected host handlers only after execution has begun", async () => { + const handlers = { openURL: vi.fn(async () => {}), writeClipboard: vi.fn(async () => {}), pickFile: vi.fn(async () => undefined), saveArtifact: vi.fn(async () => {}) }; + const executor = new CapabilityExecutor(handlers, new ArtifactState()); + await expect(executor.execute({ ...call("app.open_url", { url: "https://example.com" }), status: "pending" })).resolves.toEqual({ status: "failed", result: { code: "not_executing" } }); + await expect(executor.execute(call("app.open_url", { url: "https://example.com" }))).resolves.toEqual({ status: "completed", result: { opened: true } }); + expect(handlers.openURL).toHaveBeenCalledWith("https://example.com"); + await expect(executor.execute(call("app.open_url", { url: "javascript:alert(1)" }))).resolves.toEqual({ status: "failed", result: { code: "invalid_url" } }); + handlers.openURL.mockRejectedValueOnce(new Error("popup blocked")); + await expect(executor.execute(call("app.open_url", { url: "https://example.com" }))).resolves.toEqual({ status: "failed", result: { code: "handler_failed" } }); + }); + + it("never saves an unknown or unverified artifact", async () => { + const handlers = { openURL: vi.fn(async () => {}), writeClipboard: vi.fn(async () => {}), pickFile: vi.fn(async () => undefined), saveArtifact: vi.fn(async () => {}) }; + const artifacts = new ArtifactState(); + const executor = new CapabilityExecutor(handlers, artifacts); + await expect(executor.execute(call("artifact.save", { artifact_id: "missing" }))).resolves.toEqual({ status: "failed", result: { code: "artifact_unavailable" } }); + artifacts.offer({ artifact_id: "unverified", name: "x.txt", mime_type: "text/plain", size_bytes: 1, integrity: "unverified", created_at: now }); + await expect(executor.execute(call("artifact.save", { artifact_id: "unverified" }))).resolves.toEqual({ status: "failed", result: { code: "artifact_unavailable" } }); + expect(handlers.saveArtifact).not.toHaveBeenCalled(); + artifacts.offer({ artifact_id: "verified", name: "x.txt", mime_type: "text/plain", size_bytes: 1, integrity: "verified", created_at: now }); + await executor.execute(call("artifact.save", { artifact_id: "verified" })); + expect(handlers.saveArtifact).toHaveBeenCalledWith("verified"); + }); + + it("turns a cancelled file selection into an argument-free terminal outcome", async () => { + const handlers = { openURL: vi.fn(async () => {}), writeClipboard: vi.fn(async () => {}), pickFile: vi.fn(async () => undefined), saveArtifact: vi.fn(async () => {}) }; + const executor = new CapabilityExecutor(handlers, new ArtifactState()); + await expect(executor.execute(call("device.pick_file", {}))).resolves.toEqual({ status: "cancelled", result: {} }); + expect(handlers.pickFile).toHaveBeenCalledOnce(); + }); +}); diff --git a/tauri/src/runtime/capability-executor.ts b/tauri/src/runtime/capability-executor.ts new file mode 100644 index 0000000..f7a248f --- /dev/null +++ b/tauri/src/runtime/capability-executor.ts @@ -0,0 +1,63 @@ +import type { JsonObject } from "../protocol"; +import type { ArtifactState } from "./artifact-state"; +import type { CapabilityCallRecord } from "./capability-call-state"; + +export type PickedFile = Readonly<{ name: string; mime_type: string; size_bytes: number }>; +export type CapabilityHostHandlers = Readonly<{ + openURL: (url: string) => Promise; + writeClipboard: (text: string) => Promise; + pickFile: () => Promise; + saveArtifact: (artifactID: string) => Promise; +}>; + +export type CapabilityExecution = Readonly<{ status: "completed" | "failed" | "cancelled"; result: JsonObject }>; + +/** + * Platform handler dispatcher. It contains no confirmation UI and must be + * invoked only after CapabilityCallStateMachine has moved the call to + * `executing`; callers own that state transition and audit recording. + */ +export class CapabilityExecutor { + public constructor(private readonly handlers: CapabilityHostHandlers, private readonly artifacts: ArtifactState) {} + + public async execute(record: CapabilityCallRecord): Promise { + if (record.status !== "executing") return { status: "failed", result: { code: "not_executing" } }; + try { + switch (record.request.capability) { + case "app.open_url": { + const url = requiredText(record.request.arguments, "url"); + if (!url || !isSafeExternalURL(url)) return { status: "failed", result: { code: "invalid_url" } }; + await this.handlers.openURL(url); + return { status: "completed", result: { opened: true } }; + } + case "clipboard.write": { + const text = requiredText(record.request.arguments, "text"); + if (!text) return { status: "failed", result: { code: "invalid_text" } }; + await this.handlers.writeClipboard(text); + return { status: "completed", result: { written: true } }; + } + case "device.pick_file": { + const file = await this.handlers.pickFile(); + return file ? { status: "completed", result: { selected: true, name: file.name, mime_type: file.mime_type, size_bytes: file.size_bytes } } : { status: "cancelled", result: {} }; + } + case "artifact.save": { + const artifactID = requiredText(record.request.arguments, "artifact_id"); + const artifact = artifactID ? this.artifacts.get(artifactID) : undefined; + if (!artifact || artifact.integrity !== "verified") return { status: "failed", result: { code: "artifact_unavailable" } }; + await this.handlers.saveArtifact(artifact.artifact_id); + return { status: "completed", result: { saved: true, artifact_id: artifact.artifact_id } }; + } + } + } catch { + return { status: "failed", result: { code: "handler_failed" } }; + } + } +} + +function requiredText(value: JsonObject, key: string): string | undefined { + const text = value[key]; + return typeof text === "string" && text.trim() ? text : undefined; +} +function isSafeExternalURL(value: string): boolean { + try { const parsed = new URL(value); return parsed.protocol === "https:" && Boolean(parsed.hostname); } catch { return false; } +} diff --git a/tauri/src/runtime/capability-registry.test.ts b/tauri/src/runtime/capability-registry.test.ts new file mode 100644 index 0000000..37fe09a --- /dev/null +++ b/tauri/src/runtime/capability-registry.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { CapabilityRegistry } from "./capability-registry"; + +describe("CapabilityRegistry", () => { + it("declares requestability without making a capability executable", () => { + const registry = new CapabilityRegistry(); + expect(registry.resolve("app.open_url")).toEqual(expect.objectContaining({ disposition: "available", definition: expect.objectContaining({ risk: "user_confirmation" }) })); + expect(registry.validateArguments("app.open_url", { url: "https://example.com" })).toBe(true); + expect(registry.validateArguments("app.open_url", { url: "https://example.com", bypass: "yes" })).toBe(false); + expect(registry.resolve("artifact.save")).toEqual({ disposition: "revoked" }); + }); + + it("treats unknown and revoked capabilities as unavailable", () => { + const registry = new CapabilityRegistry({ visible: ["artifact.save"] }); + expect(registry.resolve("clipboard.write")).toEqual({ disposition: "revoked" }); + expect(registry.resolve("camera.capture")).toEqual({ disposition: "unknown" }); + expect(registry.validateArguments("clipboard.write", { text: "secret" })).toBe(false); + }); + + it("restores only known capability names", () => { + const registry = new CapabilityRegistry({ visible: [] }); + registry.restore({ version: 1, policy: { visible: ["app.open_url", "camera.capture"] } }); + expect(registry.inventory().map(definition => definition.name)).toEqual(["app.open_url"]); + }); +}); diff --git a/tauri/src/runtime/capability-registry.ts b/tauri/src/runtime/capability-registry.ts new file mode 100644 index 0000000..88a8ef0 --- /dev/null +++ b/tauri/src/runtime/capability-registry.ts @@ -0,0 +1,105 @@ +import type { JsonObject } from "../protocol"; + +export type CapabilityRisk = "display_only" | "user_confirmation" | "system_permission" | "restricted"; + +export type CapabilityInputSchema = Readonly<{ + type: "object"; + required: readonly string[]; + properties: Readonly>; +}>; + +export type CapabilityDefinition = Readonly<{ + name: "app.open_url" | "clipboard.write" | "device.pick_file" | "artifact.save"; + version: "1.0"; + risk: CapabilityRisk; + input_schema: CapabilityInputSchema; + foreground_required: boolean; +}>; + +export type CapabilityPolicy = Readonly<{ + visible: readonly CapabilityDefinition["name"][]; +}>; + +export type CapabilityRegistrySnapshot = Readonly<{ + version: 1; + policy: CapabilityPolicy; +}>; + +export type CapabilityResolution = + | Readonly<{ disposition: "available"; definition: CapabilityDefinition }> + | Readonly<{ disposition: "unknown" | "revoked" }>; + +const DEFINITIONS: readonly CapabilityDefinition[] = [ + { name: "app.open_url", version: "1.0", risk: "user_confirmation", input_schema: { type: "object", required: ["url"], properties: { url: "string" } }, foreground_required: true }, + { name: "clipboard.write", version: "1.0", risk: "user_confirmation", input_schema: { type: "object", required: ["text"], properties: { text: "string" } }, foreground_required: true }, + { name: "device.pick_file", version: "1.0", risk: "system_permission", input_schema: { type: "object", required: [], properties: {} }, foreground_required: true }, + { name: "artifact.save", version: "1.0", risk: "user_confirmation", input_schema: { type: "object", required: ["artifact_id"], properties: { artifact_id: "string" } }, foreground_required: true }, +]; + +const NAMES = new Set(DEFINITIONS.map(definition => definition.name)); +// An Artifact offer in M3 is metadata-only; no host-owned bytes/cache exist +// yet. Do not advertise a `save` operation until M4 provides a verified local +// Artifact cache. Callers with such a cache may explicitly enable it. +const DEFAULT_VISIBLE = DEFINITIONS + .filter(definition => definition.name !== "artifact.save") + .map(definition => definition.name); + +/** + * M3's local authority boundary. This has no DOM, storage, transport, + * Surface bridge, browser permission, or handler side effect. It can say a + * capability is requestable, never that an Agent is authorized to execute it. + */ +export class CapabilityRegistry { + private readonly visible = new Set(); + + public constructor(policy: CapabilityPolicy = { visible: DEFAULT_VISIBLE }) { + this.restore({ version: 1, policy }); + } + + public resolve(name: unknown): CapabilityResolution { + if (typeof name !== "string" || !NAMES.has(name as CapabilityDefinition["name"])) return { disposition: "unknown" }; + const definition = DEFINITIONS.find(candidate => candidate.name === name)!; + return this.visible.has(definition.name) ? { disposition: "available", definition } : { disposition: "revoked" }; + } + + public setVisible(name: CapabilityDefinition["name"], visible: boolean): CapabilityResolution { + const result = this.resolveKnown(name); + if (!result) return { disposition: "unknown" }; + if (visible) this.visible.add(name); else this.visible.delete(name); + return this.resolve(name); + } + + public validateArguments(name: unknown, argumentsValue: unknown): boolean { + const resolution = this.resolve(name); + if (resolution.disposition !== "available" || !isPlainObject(argumentsValue)) return false; + const schema = resolution.definition.input_schema; + const keys = Object.keys(argumentsValue); + if (keys.some(key => !(key in schema.properties))) return false; + if (schema.required.some(key => typeof argumentsValue[key] !== "string" || !argumentsValue[key].trim())) return false; + return keys.every(key => typeof argumentsValue[key] === "string" && argumentsValue[key].length <= 8_192); + } + + public snapshot(): CapabilityRegistrySnapshot { + return { version: 1, policy: { visible: DEFINITIONS.map(definition => definition.name).filter(name => this.visible.has(name)) } }; + } + + public inventory(): readonly CapabilityDefinition[] { + return DEFINITIONS.filter(definition => this.visible.has(definition.name)); + } + + public restore(snapshot: unknown): void { + this.visible.clear(); + if (!isPlainObject(snapshot) || snapshot.version !== 1 || !isPlainObject(snapshot.policy) || !Array.isArray(snapshot.policy.visible)) return; + for (const name of snapshot.policy.visible) if (typeof name === "string" && NAMES.has(name as CapabilityDefinition["name"])) this.visible.add(name as CapabilityDefinition["name"]); + } + + private resolveKnown(name: CapabilityDefinition["name"]): CapabilityDefinition | undefined { + return DEFINITIONS.find(definition => definition.name === name); + } +} + +function isPlainObject(value: unknown): value is JsonObject { + if (value === null || typeof value !== "object" || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} diff --git a/tauri/src/runtime/client-inventory.test.ts b/tauri/src/runtime/client-inventory.test.ts new file mode 100644 index 0000000..c638296 --- /dev/null +++ b/tauri/src/runtime/client-inventory.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { CapabilityRegistry } from "./capability-registry"; +import { ClientInventoryPublisher } from "./client-inventory"; +import { SurfaceRegistry } from "./surface-registry"; + +describe("ClientInventoryPublisher", () => { + it("publishes only enabled apps and policy-visible capabilities", () => { + const surfaces = new SurfaceRegistry(); + const capabilities = new CapabilityRegistry({ visible: ["app.open_url"] }); + const publisher = new ClientInventoryPublisher(); + const first = publisher.sync(surfaces.snapshot(), capabilities.inventory()); + expect(first).toMatchObject({ changed: true, inventory: { revision: "catalog-1", applications: [], capabilities: [expect.objectContaining({ name: "app.open_url" })] } }); + expect(JSON.stringify(first.inventory)).not.toContain("bundle_id"); + expect(JSON.stringify(first.inventory)).not.toContain("enabled_at"); + + surfaces.enable("lineup.task-dashboard", "0.1.0", "2026-08-03T00:00:00.000Z"); + const second = publisher.sync(surfaces.snapshot(), capabilities.inventory()); + expect(second).toMatchObject({ changed: true, inventory: { revision: "catalog-2", applications: [{ id: "lineup.task-dashboard", surfaces: [{ events: ["cancel"] }] }] } }); + }); + + it("does not reuse a revision and does not republish unchanged state", () => { + const surfaces = new SurfaceRegistry(); + const capabilities = new CapabilityRegistry(); + const publisher = new ClientInventoryPublisher(); + const first = publisher.sync(surfaces.snapshot(), capabilities.inventory()); + const repeat = publisher.sync(surfaces.snapshot(), capabilities.inventory()); + expect(repeat).toEqual({ changed: false, inventory: first.inventory }); + capabilities.setVisible("clipboard.write", false); + expect(publisher.sync(surfaces.snapshot(), capabilities.inventory())).toMatchObject({ changed: true, inventory: { revision: "catalog-2" } }); + }); + + it("does not advertise artifact.save without a host verified-cache implementation", () => { + const inventory = new ClientInventoryPublisher().sync(new SurfaceRegistry().snapshot(), new CapabilityRegistry().inventory()).inventory; + expect(inventory.capabilities.map(capability => capability.name)).not.toContain("artifact.save"); + }); +}); diff --git a/tauri/src/runtime/client-inventory.ts b/tauri/src/runtime/client-inventory.ts new file mode 100644 index 0000000..6ef8b96 --- /dev/null +++ b/tauri/src/runtime/client-inventory.ts @@ -0,0 +1,49 @@ +import type { CapabilityDefinition } from "./capability-registry"; +import type { SurfaceRegistrySnapshot } from "./surface-registry"; + +export type ClientInventory = Readonly<{ + revision: string; + standard_components: readonly Readonly<{ id: "choice" | "confirm" | "input"; version: "1" }>[]; + applications: readonly Readonly<{ + id: string; + version: string; + surfaces: readonly Readonly<{ id: "task-dashboard"; events: readonly ["cancel"] }>[]; + }>[]; + capabilities: readonly CapabilityDefinition[]; +}>; + +/** + * Builds the only inventory the Agent may receive. It is an observation of + * host-local enablement and policy, not an install operation nor a grant of + * authority. The model deliberately has no access to bundle IDs, code, local + * paths, enabled timestamps, user content, cookies, or device identifiers. + */ +export class ClientInventoryPublisher { + private revision = 0; + private previousFingerprint = ""; + private current: ClientInventory | undefined; + + public sync(surfaces: SurfaceRegistrySnapshot, capabilities: readonly CapabilityDefinition[]): { changed: boolean; inventory: ClientInventory } { + const applications = surfaces.enabled + .map(enabled => ({ id: enabled.app_id, version: enabled.version, surfaces: [{ id: "task-dashboard" as const, events: ["cancel"] as ["cancel"] }] })) + .sort((left, right) => `${left.id}@${left.version}`.localeCompare(`${right.id}@${right.version}`)); + const allowedCapabilities = [...capabilities] + .sort((left, right) => left.name.localeCompare(right.name)) + .map(definition => ({ ...definition, input_schema: { ...definition.input_schema, required: [...definition.input_schema.required], properties: { ...definition.input_schema.properties } } })); + const fingerprint = JSON.stringify({ applications, capabilities: allowedCapabilities }); + if (this.current && fingerprint === this.previousFingerprint) return { changed: false, inventory: this.current }; + this.revision += 1; + this.previousFingerprint = fingerprint; + this.current = { + revision: `catalog-${this.revision}`, + standard_components: [ + { id: "choice", version: "1" }, + { id: "confirm", version: "1" }, + { id: "input", version: "1" }, + ], + applications, + capabilities: allowedCapabilities, + }; + return { changed: true, inventory: this.current }; + } +} diff --git a/tauri/src/runtime/conversation-store.test.ts b/tauri/src/runtime/conversation-store.test.ts index e3b5fa4..5188def 100644 --- a/tauri/src/runtime/conversation-store.test.ts +++ b/tauri/src/runtime/conversation-store.test.ts @@ -62,6 +62,32 @@ describe("ConversationStore", () => { })]); }); + it("removes legacy Artifact content bytes from memory and durable storage on open", () => { + const storage = new MemoryStorage(); + const key = "lineup.conversation.v1:user_test:2:agent_channel"; + storage.setItem(key, JSON.stringify({ + version: 10, cursor: 6, outbox: [], tool_calls: [], tool_drafts: {}, tasks: [], execution_summaries: [], surfaces: { version: 1, instances: [] }, capability_calls: [], + items: [{ + local_id: "remote_006", created_at: createdAt, message_seq: 6, + item: { + kind: "artifact-offer", id: "artifact_006", + envelope: { + v: 1, id: "artifact_006", type: "lineup.v1.artifact.offer", conversation_id: conversationID, + sender: { kind: "agent", id: "agent" }, + payload: { artifact_id: "report", name: "report.txt", mime_type: "text/plain", size_bytes: 5, integrity: "verified", created_at: createdAt, local_ref: "opaque-ref", content_base64: "aGVsbG8=" }, + }, + }, + }], + })); + + const snapshot = new ConversationStore(storage).open(conversationID); + const artifact = snapshot.items[0].item; + expect(artifact.kind).toBe("artifact-offer"); + expect(JSON.stringify(artifact)).not.toContain("content_base64"); + expect(storage.getItem(key)).not.toContain("content_base64"); + expect(storage.getItem(key)).not.toContain("aGVsbG8="); + }); + it("accepts one remote message per IM sequence and advances the cursor only from sync data", () => { const store = new ConversationStore(new MemoryStorage()); store.open(conversationID); @@ -129,6 +155,22 @@ describe("ConversationStore", () => { expect(current.snapshot().outbox).toEqual([]); }); + it("persists capability call state and a durable app result without a chat bubble", () => { + const storage = new MemoryStorage(); + const first = new ConversationStore(storage); + first.open(conversationID); + first.replaceCapabilityCalls([{ + call_id: "cap_001", conversation_id: conversationID, + request: { call_id: "cap_001", capability: "app.open_url", reason: "打开 https://private.example/doc", expires_at: "2026-08-03T01:00:00.000Z", arguments: { url: "https://example.com" } }, + status: "rejected", created_at: createdAt, updated_at: createdAt, + }]); + first.enqueueToolAction("app_001", "app-result", '{"type":"lineup.v1.app.result"}', createdAt); + const restored = new ConversationStore(storage).open(conversationID); + expect(restored.capability_calls).toEqual([expect.objectContaining({ call_id: "cap_001", status: "rejected", request: expect.objectContaining({ reason: "打开 链接" }) })]); + expect(restored.outbox).toEqual([expect.objectContaining({ local_id: "app_001", kind: "app-result" })]); + expect(restored.items).toEqual([]); + }); + it("persists only the already-sanitized execution progress summary projection", () => { const storage = new MemoryStorage(); const first = new ConversationStore(storage); diff --git a/tauri/src/runtime/conversation-store.ts b/tauri/src/runtime/conversation-store.ts index 103f26c..8152dee 100644 --- a/tauri/src/runtime/conversation-store.ts +++ b/tauri/src/runtime/conversation-store.ts @@ -3,6 +3,7 @@ import type { ToolCallRecord } from "./tool-call-state"; import type { TaskRecord } from "./task-state"; import type { ExecutionProgressSummary } from "./execution-progress-state"; import type { SurfaceInstanceSnapshot } from "./surface-instance-manager"; +import { sanitizeCapabilityReason, type CapabilityCallRecord } from "./capability-call-state"; export type StoredConversationItem = { local_id: string; @@ -13,7 +14,7 @@ export type StoredConversationItem = { export type OutboxEntry = { local_id: string; - kind: "text" | "tool-result" | "tool-cancel" | "surface-event"; + kind: "text" | "tool-result" | "tool-cancel" | "surface-event" | "app-result"; payload: string; created_at: string; }; @@ -29,10 +30,11 @@ export type ConversationSnapshot = { /** Allowlisted public operation summaries; never reasoning or raw tool data. */ execution_summaries: readonly ExecutionProgressSummary[]; surfaces: SurfaceInstanceSnapshot; + capability_calls: readonly CapabilityCallRecord[]; }; type PersistedConversation = { - version: 9; + version: 10; cursor: number; items: StoredConversationItem[]; outbox: OutboxEntry[]; @@ -41,6 +43,7 @@ type PersistedConversation = { tasks: TaskRecord[]; execution_summaries: ExecutionProgressSummary[]; surfaces: SurfaceInstanceSnapshot; + capability_calls: CapabilityCallRecord[]; }; const STORE_PREFIX = "lineup.conversation.v1"; @@ -62,6 +65,10 @@ export class ConversationStore { public open(conversationID: string): ConversationSnapshot { this.activeConversationID = conversationID; this.state = this.load(conversationID); + // `load` also removes content bodies from legacy Artifact offers. Persist + // immediately so a reload cannot leave those bytes in local storage until + // the next unrelated conversation mutation. + this.persist(); return this.snapshot(); } @@ -81,6 +88,7 @@ export class ConversationStore { tasks: clone(this.state.tasks), execution_summaries: clone(this.state.execution_summaries), surfaces: clone(this.state.surfaces), + capability_calls: clone(this.state.capability_calls), }; } @@ -122,6 +130,11 @@ export class ConversationStore { this.persist(); } + public replaceCapabilityCalls(records: readonly CapabilityCallRecord[]): void { + this.state.capability_calls = normalizeCapabilityCalls(records); + this.persist(); + } + public appendLocalText(localID: string, text: string, createdAt: string): StoredConversationItem { const delivery: DeliveryState = { status: "local_pending", updated_at: createdAt }; const stored: StoredConversationItem = { @@ -136,7 +149,7 @@ export class ConversationStore { } /** Queues a standard interaction response before any network attempt. */ - public enqueueToolAction(localID: string, kind: "tool-result" | "tool-cancel" | "surface-event", payload: string, createdAt: string): void { + public enqueueToolAction(localID: string, kind: "tool-result" | "tool-cancel" | "surface-event" | "app-result", payload: string, createdAt: string): void { if (this.state.outbox.some(entry => entry.local_id === localID)) return; this.state.outbox.push({ local_id: localID, kind, payload, created_at: createdAt }); this.persist(); @@ -233,21 +246,21 @@ export class ConversationStore { try { const parsed: unknown = JSON.parse(this.storage.getItem(this.key(conversationID)) ?? "null"); if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return emptyConversation(); - const candidate = parsed as { version?: unknown; cursor?: unknown; items?: unknown; outbox?: unknown; tool_calls?: unknown; tool_drafts?: unknown; tasks?: unknown; execution_summaries?: unknown; surfaces?: unknown }; - if ((candidate.version !== 1 && candidate.version !== 2 && candidate.version !== 3 && candidate.version !== 4 && candidate.version !== 5 && candidate.version !== 6 && candidate.version !== 7 && candidate.version !== 8 && candidate.version !== 9) || !Array.isArray(candidate.items) || !Array.isArray(candidate.outbox) || typeof candidate.cursor !== "number") { + const candidate = parsed as { version?: unknown; cursor?: unknown; items?: unknown; outbox?: unknown; tool_calls?: unknown; tool_drafts?: unknown; tasks?: unknown; execution_summaries?: unknown; surfaces?: unknown; capability_calls?: unknown }; + if ((candidate.version !== 1 && candidate.version !== 2 && candidate.version !== 3 && candidate.version !== 4 && candidate.version !== 5 && candidate.version !== 6 && candidate.version !== 7 && candidate.version !== 8 && candidate.version !== 9 && candidate.version !== 10) || !Array.isArray(candidate.items) || !Array.isArray(candidate.outbox) || typeof candidate.cursor !== "number") { return emptyConversation(); } - if (candidate.version !== 9) { + if (candidate.version !== 10) { // v1 advanced its cursor from a send acknowledgement. Some browsers // had already been upgraded to v2 before that correction, retaining a // high cursor with holes for their own echoes. Preserve every valid // local item but replay the server history once to repair those holes; // the next persist upgrades this record to v5. return { - version: 9, + version: 10, cursor: candidate.version === 4 || candidate.version === 5 || candidate.version === 6 || candidate.version === 7 ? Math.max(0, candidate.cursor) : 0, - items: candidate.items as StoredConversationItem[], + items: normalizeStoredItems(candidate.items), outbox: normalizeOutbox(candidate.outbox), tool_calls: (candidate.version === 4 || candidate.version === 5 || candidate.version === 6 || candidate.version === 7) && Array.isArray(candidate.tool_calls) ? candidate.tool_calls as ToolCallRecord[] : [], tool_drafts: (candidate.version === 4 || candidate.version === 5 || candidate.version === 6 || candidate.version === 7) && isDraftMap(candidate.tool_drafts) ? candidate.tool_drafts : {}, @@ -255,18 +268,20 @@ export class ConversationStore { // v7's fixed lifecycle placeholders are intentionally discarded. execution_summaries: [], surfaces: emptySurfaces(), + capability_calls: [], }; } return { - version: 9, + version: 10, cursor: Math.max(0, candidate.cursor), - items: candidate.items as StoredConversationItem[], + items: normalizeStoredItems(candidate.items), outbox: normalizeOutbox(candidate.outbox), tool_calls: Array.isArray(candidate.tool_calls) ? candidate.tool_calls as ToolCallRecord[] : [], tool_drafts: isDraftMap(candidate.tool_drafts) ? candidate.tool_drafts : {}, tasks: Array.isArray(candidate.tasks) ? candidate.tasks as TaskRecord[] : [], execution_summaries: normalizeExecutionSummaries(candidate.execution_summaries), surfaces: normalizeSurfaces(candidate.surfaces), + capability_calls: normalizeCapabilityCalls(candidate.capability_calls), }; } catch { return emptyConversation(); @@ -288,7 +303,7 @@ export class ConversationStore { } function emptyConversation(): PersistedConversation { - return { version: 9, cursor: 0, items: [], outbox: [], tool_calls: [], tool_drafts: {}, tasks: [], execution_summaries: [], surfaces: emptySurfaces() }; + return { version: 10, cursor: 0, items: [], outbox: [], tool_calls: [], tool_drafts: {}, tasks: [], execution_summaries: [], surfaces: emptySurfaces(), capability_calls: [] }; } function emptySurfaces(): SurfaceInstanceSnapshot { return { version: 1, instances: [] }; } @@ -300,11 +315,60 @@ function normalizeOutbox(value: unknown): OutboxEntry[] { if (!entry || typeof entry !== "object" || Array.isArray(entry)) return []; const candidate = entry as Partial; if (typeof candidate.local_id !== "string" || typeof candidate.payload !== "string" || typeof candidate.created_at !== "string") return []; - const kind = candidate.kind === "tool-result" || candidate.kind === "tool-cancel" || candidate.kind === "surface-event" ? candidate.kind : "text"; + const kind = candidate.kind === "tool-result" || candidate.kind === "tool-cancel" || candidate.kind === "surface-event" || candidate.kind === "app-result" ? candidate.kind : "text"; return [{ local_id: candidate.local_id, kind, payload: candidate.payload, created_at: candidate.created_at }]; }); } +/** + * Artifact content is delivery-only data for the host-owned volatile cache. + * It must never survive in the durable conversation transcript, including + * transcripts written by a pre-cache version of the client. + */ +function normalizeStoredItems(value: unknown): StoredConversationItem[] { + if (!Array.isArray(value)) return []; + return value.flatMap(raw => { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return []; + const stored = raw as Partial; + if (typeof stored.local_id !== "string" || !stored.item || typeof stored.item !== "object" || Array.isArray(stored.item) + || typeof stored.created_at !== "string") return []; + const item = stored.item as ConversationItem; + if (item.kind !== "artifact-offer" || !("envelope" in item) || !item.envelope?.payload || !("content_base64" in item.envelope.payload)) { + return [{ ...stored, item: clone(item) } as StoredConversationItem]; + } + const { content_base64: _content, ...metadata } = item.envelope.payload; + return [{ + ...stored, + item: { ...item, envelope: { ...item.envelope, payload: metadata } }, + } as StoredConversationItem]; + }); +} + +function normalizeCapabilityCalls(value: unknown): CapabilityCallRecord[] { + if (!Array.isArray(value)) return []; + const statuses = new Set(["pending", "approved", "executing", "completed", "rejected", "cancelled", "expired", "unsupported", "failed"]); + const names = new Set(["app.open_url", "clipboard.write", "device.pick_file", "artifact.save"]); + return value.flatMap(raw => { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return []; + const record = raw as Partial; + const request = record.request; + if (typeof record.call_id !== "string" || !record.call_id || typeof record.conversation_id !== "string" || !record.conversation_id + || !request || typeof request !== "object" || request.call_id !== record.call_id || !names.has(request.capability) + || typeof request.reason !== "string" || !request.reason.trim() || request.reason.length > 500 + || typeof request.expires_at !== "string" || Number.isNaN(Date.parse(request.expires_at)) + || !request.arguments || typeof request.arguments !== "object" || Array.isArray(request.arguments) + || typeof record.status !== "string" || !statuses.has(record.status) + || typeof record.created_at !== "string" || Number.isNaN(Date.parse(record.created_at)) + || typeof record.updated_at !== "string" || Number.isNaN(Date.parse(record.updated_at))) return []; + return [{ + call_id: record.call_id, conversation_id: record.conversation_id, + request: { call_id: request.call_id, capability: request.capability, reason: sanitizeCapabilityReason(request.reason), expires_at: request.expires_at, arguments: clone(request.arguments) }, + status: record.status as CapabilityCallRecord["status"], created_at: record.created_at, updated_at: record.updated_at, + ...(record.result && typeof record.result === "object" && !Array.isArray(record.result) ? { result: clone(record.result) } : {}), + }]; + }).slice(-100); +} + function isDraftMap(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value) && Object.values(value).every(draft => draft !== null && typeof draft === "object" && !Array.isArray(draft)); diff --git a/tauri/src/runtime/interaction-kernel.test.ts b/tauri/src/runtime/interaction-kernel.test.ts index 0d1fbdc..2c2b4b8 100644 --- a/tauri/src/runtime/interaction-kernel.test.ts +++ b/tauri/src/runtime/interaction-kernel.test.ts @@ -217,4 +217,35 @@ describe("InteractionKernel", () => { expect(injectedBundle).toEqual(expect.objectContaining({ kind: "fallback", reason: "invalid_payload" })); expect(extraAppField).toEqual(expect.objectContaining({ kind: "fallback", reason: "invalid_payload" })); }); + + it("requires a bounded reason, expiry and exact payload shape for app calls", () => { + const valid = decodeConversationItem(envelope({ + id: "evt_capability_valid", type: "lineup.v1.app.call", + payload: { call_id: "cap_001", capability: "app.open_url", reason: "打开文档", expires_at: "2026-08-04T00:00:00.000Z", arguments: { url: "https://example.com" } }, + })); + const missingExpiry = decodeConversationItem(envelope({ + id: "evt_capability_invalid", type: "lineup.v1.app.call", + payload: { call_id: "cap_002", capability: "app.open_url", reason: "打开文档", arguments: { url: "https://example.com" } }, + })); + expect(valid).toEqual(expect.objectContaining({ kind: "app-call" })); + expect(missingExpiry).toEqual(expect.objectContaining({ kind: "fallback", reason: "invalid_payload" })); + }); + + it("admits artifact offers as data but rejects undeclared control fields", () => { + const safe = decodeConversationItem(envelope({ + id: "evt_artifact_safe", type: "lineup.v1.artifact.offer", + payload: { artifact_id: "report_001", name: "report.pdf", mime_type: "application/pdf", size_bytes: 42, integrity: "verified", created_at: "2026-08-03T00:00:00.000Z" }, + })); + const injected = decodeConversationItem(envelope({ + id: "evt_artifact_injected", type: "lineup.v1.artifact.offer", + payload: { artifact_id: "report_002", name: "report.pdf", mime_type: "application/pdf", size_bytes: 42, integrity: "verified", created_at: "2026-08-03T00:00:00.000Z", url: "https://bad.example" }, + })); + expect(safe).toEqual(expect.objectContaining({ kind: "artifact-offer" })); + const content = decodeConversationItem(envelope({ + id: "evt_artifact_content", type: "lineup.v1.artifact.offer", + payload: { artifact_id: "report_003", name: "report.txt", mime_type: "text/plain", size_bytes: 5, integrity: "verified", created_at: "2026-08-03T00:00:00.000Z", local_ref: "artifact-cache-003", content_base64: "aGVsbG8=" }, + })); + expect(content).toEqual(expect.objectContaining({ kind: "artifact-offer" })); + expect(injected).toEqual(expect.objectContaining({ kind: "fallback", reason: "invalid_payload" })); + }); }); diff --git a/tauri/src/runtime/trusted-dom-renderers.ts b/tauri/src/runtime/trusted-dom-renderers.ts index ad5086d..d000338 100644 --- a/tauri/src/runtime/trusted-dom-renderers.ts +++ b/tauri/src/runtime/trusted-dom-renderers.ts @@ -12,6 +12,8 @@ import type { import type { ToolCallRecord } from "./tool-call-state"; import type { TaskRecord } from "./task-state"; import type { ExecutionProgressSummary } from "./execution-progress-state"; +import type { ArtifactRecord } from "./artifact-state"; +import type { CapabilityCallRecord } from "./capability-call-state"; export type ToolInteractionHandlers = { submit: (callID: string, submission: JsonObject) => boolean; @@ -28,6 +30,13 @@ export type TaskInteractionHandlers = { read: (operationID: string) => TaskRecord | undefined; }; +export type CapabilityInteractionHandlers = { + approve: (callID: string) => Promise; + reject: (callID: string) => CapabilityCallRecord | undefined; + expire: (callID: string) => CapabilityCallRecord | undefined; + read: (callID: string) => CapabilityCallRecord | undefined; +}; + /** * Trusted presentation context for the first-party chat shell. It owns only * DOM nodes and display-local state; it deliberately has no Store, Transport, @@ -37,6 +46,7 @@ export class TrustedDOMRendererContext { private readonly userRows = new Map(); private readonly toolCards = new Map(); private readonly taskCards = new Map(); + private readonly capabilityCards = new Map(); private readonly executionCards = new Map(); private readonly agentReplyGroups = new Map(); private readonly executionSummaries = new Map(); @@ -48,6 +58,7 @@ export class TrustedDOMRendererContext { private readonly presence: HTMLElement, private readonly toolInteractions?: ToolInteractionHandlers, private readonly taskInteractions?: TaskInteractionHandlers, + private readonly capabilityInteractions?: CapabilityInteractionHandlers, ) { // Status envelopes are sparse. Keep elapsed time honest between them // without persisting a ticking value or treating it as Agent progress. @@ -58,6 +69,7 @@ export class TrustedDOMRendererContext { this.userRows.clear(); this.toolCards.clear(); this.taskCards.clear(); + this.capabilityCards.clear(); this.executionCards.clear(); this.agentReplyGroups.clear(); this.executionSummaries.clear(); @@ -181,8 +193,85 @@ export class TrustedDOMRendererContext { this.renderSystemMessage(`收到扩展界面请求(${item.envelope.type});Mini Runtime Surface Host 将在下一步接入。`); } - public renderAppCall(_item: Extract): void { - this.renderSystemMessage("收到 App 能力调用请求;Capability 授权 UI 将在下一步接入。"); + /** + * A capability card is native chrome, never an Agent-provided form. The + * request arguments deliberately do not cross this presentation boundary: + * URL, clipboard text, local path and Artifact content cannot be displayed + * or copied from the confirmation UI. + */ + public renderAppCall(item: Extract): void { + const callID = typeof item.envelope.payload.call_id === "string" ? item.envelope.payload.call_id : ""; + const record = callID ? this.capabilityInteractions?.read(callID) : undefined; + if (!record) { + this.renderSystemMessage("收到无法安全确认的 App 能力请求。"); + return; + } + const existing = this.capabilityCards.get(record.call_id); + if (existing) { this.updateCapabilityCard(existing, record); return; } + + const card = document.createElement("article"); + card.className = "capability-card"; + card.dataset.callId = record.call_id; + const heading = document.createElement("strong"); + const reason = document.createElement("p"); + const risk = document.createElement("small"); + risk.className = "capability-risk"; + const status = document.createElement("small"); + status.className = "capability-status"; + const controls = document.createElement("div"); + controls.className = "capability-actions"; + const reject = document.createElement("button"); + reject.type = "button"; reject.className = "secondary-action"; reject.textContent = "拒绝"; + const approve = document.createElement("button"); + approve.type = "button"; approve.textContent = "允许一次"; + const entry = { card, status, approve, reject }; + reject.addEventListener("click", () => { + const next = this.capabilityInteractions?.reject(record.call_id); + if (next) this.updateCapabilityCard(entry, next); + }); + approve.addEventListener("click", () => { + approve.disabled = true; reject.disabled = true; + void this.capabilityInteractions?.approve(record.call_id).then(next => { + if (next) this.updateCapabilityCard(entry, next); + }); + }); + heading.textContent = capabilityLabel(record.request.capability); + reason.textContent = record.request.reason; + risk.textContent = capabilityRiskText(record.request.capability); + controls.append(reject, approve); + card.append(heading, reason, risk, status, controls); + this.capabilityCards.set(record.call_id, entry); + this.updateCapabilityCard(entry, record); + this.messages.append(card); + this.scrollLatest(); + + const delay = Date.parse(record.request.expires_at) - Date.now(); + if (delay > 0 && record.status === "pending") { + const timer = window.setTimeout(() => { + this.expirationTimers.delete(timer); + const next = this.capabilityInteractions?.expire(record.call_id); + if (next) this.updateCapabilityCard(entry, next); + }, delay); + this.expirationTimers.add(timer); + } + } + + /** Native Artifact display. It deliberately has no download URL or local reference. */ + public renderArtifact(record: ArtifactRecord): void { + const card = document.createElement("article"); + card.className = "artifact-card"; + card.dataset.artifactId = record.artifact_id; + const name = document.createElement("strong"); + name.textContent = record.name; + const metadata = document.createElement("small"); + metadata.textContent = `${record.mime_type} · ${formatBytes(record.size_bytes)}`; + const integrity = document.createElement("small"); + integrity.className = "artifact-integrity"; + integrity.dataset.integrity = record.integrity; + integrity.textContent = record.integrity === "verified" ? "完整性已验证" : record.integrity === "failed" ? "完整性校验失败" : "完整性未验证"; + card.append(name, metadata, integrity); + this.messages.append(card); + this.scrollLatest(); } public renderFallback(item: Extract): void { @@ -292,6 +381,16 @@ export class TrustedDOMRendererContext { entry.cancel.disabled = requested; } + private updateCapabilityCard(entry: { card: HTMLElement; status: HTMLElement; approve: HTMLButtonElement; reject: HTMLButtonElement }, record: CapabilityCallRecord): void { + entry.card.dataset.status = record.status; + const active = record.status === "pending"; + entry.approve.hidden = !active; + entry.reject.hidden = !active; + entry.approve.disabled = !active; + entry.reject.disabled = !active; + entry.status.textContent = capabilityStatusText(record.status); + } + private renderActionGroup(call: ToolCallRequest, group: ActionGroup, record?: ToolCallRecord): void { const card = document.createElement("article"); card.className = "action-group-card"; @@ -572,6 +671,41 @@ function deliveryLabel(delivery: DeliveryState): string { return "已发送"; } +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${Math.ceil(bytes / 1024)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +function capabilityLabel(capability: CapabilityCallRecord["request"]["capability"]): string { + switch (capability) { + case "app.open_url": return "打开外部网站"; + case "clipboard.write": return "写入剪贴板"; + case "device.pick_file": return "选择一个文件"; + case "artifact.save": return "保存已验证的文件"; + } +} + +function capabilityRiskText(capability: CapabilityCallRecord["request"]["capability"]): string { + return capability === "device.pick_file" + ? "这会打开系统文件选择器;只有你选择的文件元数据会返回给 Agent。" + : "此操作仅在你本次明确允许后执行一次。"; +} + +function capabilityStatusText(status: CapabilityCallRecord["status"]): string { + switch (status) { + case "pending": return "等待你的确认"; + case "approved": + case "executing": return "正在执行已允许的操作…"; + case "completed": return "已完成"; + case "rejected": return "你已拒绝此操作"; + case "cancelled": return "操作已取消"; + case "expired": return "确认已过期"; + case "unsupported": return "此客户端当前不支持该操作"; + case "failed": return "操作未能完成"; + } +} + function validateFormValues(form: InputForm, values: JsonObject): string | undefined { for (const field of form.fields) { const value = values[field.id];