feat(m3): add consent-gated capability runtime

This commit is contained in:
2026-08-04 01:09:20 +08:00
parent ccff57b2a1
commit 4fc83e5b13
21 changed files with 1246 additions and 20 deletions
+22
View File
@@ -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; }
+305 -5
View File
@@ -1,4 +1,5 @@
import "./style.css"; import "./style.css";
import "./capability-card.css";
import "./execution-progress.css"; import "./execution-progress.css";
import { import {
type Actor, type Actor,
@@ -16,6 +17,13 @@ import { TrustedDOMRendererContext } from "./runtime/trusted-dom-renderers";
import { SurfaceRegistry } from "./runtime/surface-registry"; import { SurfaceRegistry } from "./runtime/surface-registry";
import { SurfaceInstanceManager } from "./runtime/surface-instance-manager"; import { SurfaceInstanceManager } from "./runtime/surface-instance-manager";
import { IsolatedSurfaceHost } from "./runtime/isolated-surface-host"; 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 = { type Session = {
api: string; api: string;
@@ -37,6 +45,25 @@ const conversationStore = new ConversationStore(localStorage);
const executionProgress = new ExecutionProgressState(); const executionProgress = new ExecutionProgressState();
const surfaceRegistry = new SurfaceRegistry(); const surfaceRegistry = new SurfaceRegistry();
const surfaceInstances = new SurfaceInstanceManager(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"; 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); } 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"; return transition.disposition === "accepted";
}, },
read(operationID) { return interactionKernel.snapshot().tasks.find(task => task.operation_id === operationID); }, 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<TrustedDOMRendererContext>(); const rendererRegistry = new RendererRegistry<TrustedDOMRendererContext>();
rendererRegistry.register("user-message", (item, context) => context.renderUserMessage(item)); 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)); rendererRegistry.register("fallback", (item, context) => context.renderFallback(item));
function renderIncoming(item: ConversationItem): void { 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") { if (item.kind === "surface") {
const now = new Date().toISOString(); const now = new Date().toISOString();
if (item.envelope.type === "lineup.v1.ui.open") { 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<void> {
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;
}
$<HTMLButtonElement>("#surface-demo").addEventListener("click", () => { $<HTMLButtonElement>("#surface-demo").addEventListener("click", () => {
const now = new Date().toISOString(); const now = new Date().toISOString();
surfaceRegistry.enable("lineup.task-dashboard", "0.1.0", now); surfaceRegistry.enable("lineup.task-dashboard", "0.1.0", now);
localStorage.setItem(SURFACE_REGISTRY_STORAGE_KEY, JSON.stringify(surfaceRegistry.snapshot())); localStorage.setItem(SURFACE_REGISTRY_STORAGE_KEY, JSON.stringify(surfaceRegistry.snapshot()));
replayStoredSurfaceLifecycle(); replayStoredSurfaceLifecycle();
conversationStore.replaceSurfaces(surfaceInstances.snapshot()); conversationStore.replaceSurfaces(surfaceInstances.snapshot());
void publishInventory();
}); });
/** /**
@@ -245,6 +326,221 @@ function currentConversationKey(): string {
return `${session.uid}:${session.channelType}:${session.channelID}`; 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<void> {
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<ConversationItem, { kind: "artifact-offer" }>): Extract<ConversationItem, { kind: "artifact-offer" }> | 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<CapabilityCallRecord | undefined> {
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<PickedFile | undefined> {
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 { function queueToolAction(kind: "tool-result" | "tool-cancel", callID: string, payload: JsonObject): void {
if (!session.uid) { if (!session.uid) {
rendererContext.renderSystemMessage("交互结果已保留,将在下次登录后发送。"); rendererContext.renderSystemMessage("交互结果已保留,将在下次登录后发送。");
@@ -323,6 +619,7 @@ async function syncLoop(): Promise<void> {
polling = true; polling = true;
while (session.active) { while (session.active) {
try { try {
try { await publishInventory(); } catch { /* retried by the next loop */ }
// WuKongIM's legacy sync endpoint can return a single retained message // WuKongIM's legacy sync endpoint can return a single retained message
// per request even when `more` is 0. Drain until it is empty before // per request even when `more` is 0. Drain until it is empty before
// switching to the paced live-polling loop; otherwise a reopened chat // switching to the paced live-polling loop; otherwise a reopened chat
@@ -352,7 +649,7 @@ async function syncLoop(): Promise<void> {
const payload = decodeBase64(message.payload); const payload = decodeBase64(message.payload);
if (message.from_uid === session.uid) { if (message.from_uid === session.uid) {
const ownEnvelope = parseEnvelopeJSON(payload); 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 // Tool action echoes advance the inclusive cursor but are never
// rendered as raw JSON user messages or replayed as a new action. // rendered as raw JSON user messages or replayed as a new action.
continue; continue;
@@ -373,9 +670,11 @@ async function syncLoop(): Promise<void> {
} }
for (const item of result.items) { for (const item of result.items) {
const receivedAt = new Date().toISOString(); const receivedAt = new Date().toISOString();
if (conversationStore.recordIncoming(item, message.message_seq, receivedAt)) { const safeItem = item.kind === "artifact-offer" ? prepareArtifactOffer(item) : item;
projectExecutionProgress(item, receivedAt); if (!safeItem) continue;
renderIncoming(item); if (conversationStore.recordIncoming(safeItem, message.message_seq, receivedAt)) {
projectExecutionProgress(safeItem, receivedAt);
renderIncoming(safeItem);
} }
} }
} }
@@ -424,6 +723,7 @@ $<HTMLFormElement>("#login-form").addEventListener("submit", async event => {
surfaceInstances.restore(snapshot.surfaces); surfaceInstances.restore(snapshot.surfaces);
interactionKernel.restoreToolCalls(snapshot.tool_calls, new Date().toISOString()); interactionKernel.restoreToolCalls(snapshot.tool_calls, new Date().toISOString());
interactionKernel.restoreTasks(snapshot.tasks); interactionKernel.restoreTasks(snapshot.tasks);
capabilityCalls.restore(snapshot.capability_calls);
executionProgress.restore(snapshot.execution_summaries); executionProgress.restore(snapshot.execution_summaries);
conversationStore.replaceToolCalls(interactionKernel.snapshot().tool_calls); conversationStore.replaceToolCalls(interactionKernel.snapshot().tool_calls);
conversationStore.replaceTasks(interactionKernel.snapshot().tasks); conversationStore.replaceTasks(interactionKernel.snapshot().tasks);
@@ -438,7 +738,7 @@ $<HTMLFormElement>("#login-form").addEventListener("submit", async event => {
} }
for (const instance of surfaceInstances.snapshot().instances) surfaceHost.mount(instance); for (const instance of surfaceInstances.snapshot().instances) surfaceHost.mount(instance);
rendererContext.restoreExecutionSummaries(snapshot.execution_summaries); 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); } } catch (reason) { error.textContent = loginFailureMessage(reason, endpoint); }
finally { submit.disabled = false; submit.textContent = "进入 LineUp"; } finally { submit.disabled = false; submit.textContent = "进入 LineUp"; }
}); });
+10 -1
View File
@@ -193,6 +193,7 @@ export type ConversationItem =
| (ConversationItemBase & { kind: "tool-result"; result: ToolCallResult }) | (ConversationItemBase & { kind: "tool-result"; result: ToolCallResult })
| (ConversationItemBase & { kind: "tool-cancel"; cancel: ToolCallCancel }) | (ConversationItemBase & { kind: "tool-cancel"; cancel: ToolCallCancel })
| (ConversationItemBase & { kind: "surface"; envelope: Envelope }) | (ConversationItemBase & { kind: "surface"; envelope: Envelope })
| (ConversationItemBase & { kind: "artifact-offer"; envelope: Envelope })
| (ConversationItemBase & { kind: "app-call"; envelope: Envelope }) | (ConversationItemBase & { kind: "app-call"; envelope: Envelope })
| (ConversationItemBase & { kind: "fallback"; reason: ProtocolFallbackCode; detail: string; 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.open",
"lineup.v1.ui.patch", "lineup.v1.ui.patch",
"lineup.v1.ui.close", "lineup.v1.ui.close",
"lineup.v1.artifact.offer",
"lineup.v1.app.call", "lineup.v1.app.call",
]); ]);
const ID_MAX_LENGTH = 256; const ID_MAX_LENGTH = 256;
@@ -335,7 +337,10 @@ function hasSurfaceBundleOverride(payload: JsonObject): boolean {
} }
function validAppCallPayload(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<ToolCallKind>(["choice", "confirm", "input"]); const TOOL_KINDS = new Set<ToolCallKind>(["choice", "confirm", "input"]);
@@ -570,6 +575,10 @@ export function decodeConversationItem(raw: string): ConversationItem {
return validSurfacePayload(envelope.type, payload) return validSurfacePayload(envelope.type, payload)
? { kind: "surface", ...base } ? { kind: "surface", ...base }
: fallbackItem({ code: "invalid_payload", detail: "Surface payload does not match its lifecycle contract.", envelope }); : 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": case "lineup.v1.app.call":
return validAppCallPayload(payload) return validAppCallPayload(payload)
? { kind: "app-call", ...base } ? { kind: "app-call", ...base }
@@ -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);
});
});
@@ -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<string, Blob>();
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);
}
+13
View File
@@ -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();
});
});
+45
View File
@@ -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<string, ArtifactRecord>();
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<string, unknown>;
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() : ""; }
@@ -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");
});
});
+28
View File
@@ -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 })); }
}
@@ -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");
});
});
+119
View File
@@ -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<string, CapabilityCallRecord>();
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<T>(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<string, unknown> {
if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
const prototype = Object.getPrototypeOf(value);
return prototype === Object.prototype || prototype === null;
}
@@ -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<string, string>): 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();
});
});
+63
View File
@@ -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<void>;
writeClipboard: (text: string) => Promise<void>;
pickFile: () => Promise<PickedFile | undefined>;
saveArtifact: (artifactID: string) => Promise<void>;
}>;
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<CapabilityExecution> {
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; }
}
@@ -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"]);
});
});
+105
View File
@@ -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<Record<string, "string">>;
}>;
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<CapabilityDefinition["name"]>();
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;
}
@@ -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");
});
});
+49
View File
@@ -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 };
}
}
@@ -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", () => { it("accepts one remote message per IM sequence and advances the cursor only from sync data", () => {
const store = new ConversationStore(new MemoryStorage()); const store = new ConversationStore(new MemoryStorage());
store.open(conversationID); store.open(conversationID);
@@ -129,6 +155,22 @@ describe("ConversationStore", () => {
expect(current.snapshot().outbox).toEqual([]); 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", () => { it("persists only the already-sanitized execution progress summary projection", () => {
const storage = new MemoryStorage(); const storage = new MemoryStorage();
const first = new ConversationStore(storage); const first = new ConversationStore(storage);
+76 -12
View File
@@ -3,6 +3,7 @@ import type { ToolCallRecord } from "./tool-call-state";
import type { TaskRecord } from "./task-state"; import type { TaskRecord } from "./task-state";
import type { ExecutionProgressSummary } from "./execution-progress-state"; import type { ExecutionProgressSummary } from "./execution-progress-state";
import type { SurfaceInstanceSnapshot } from "./surface-instance-manager"; import type { SurfaceInstanceSnapshot } from "./surface-instance-manager";
import { sanitizeCapabilityReason, type CapabilityCallRecord } from "./capability-call-state";
export type StoredConversationItem = { export type StoredConversationItem = {
local_id: string; local_id: string;
@@ -13,7 +14,7 @@ export type StoredConversationItem = {
export type OutboxEntry = { export type OutboxEntry = {
local_id: string; local_id: string;
kind: "text" | "tool-result" | "tool-cancel" | "surface-event"; kind: "text" | "tool-result" | "tool-cancel" | "surface-event" | "app-result";
payload: string; payload: string;
created_at: string; created_at: string;
}; };
@@ -29,10 +30,11 @@ export type ConversationSnapshot = {
/** Allowlisted public operation summaries; never reasoning or raw tool data. */ /** Allowlisted public operation summaries; never reasoning or raw tool data. */
execution_summaries: readonly ExecutionProgressSummary[]; execution_summaries: readonly ExecutionProgressSummary[];
surfaces: SurfaceInstanceSnapshot; surfaces: SurfaceInstanceSnapshot;
capability_calls: readonly CapabilityCallRecord[];
}; };
type PersistedConversation = { type PersistedConversation = {
version: 9; version: 10;
cursor: number; cursor: number;
items: StoredConversationItem[]; items: StoredConversationItem[];
outbox: OutboxEntry[]; outbox: OutboxEntry[];
@@ -41,6 +43,7 @@ type PersistedConversation = {
tasks: TaskRecord[]; tasks: TaskRecord[];
execution_summaries: ExecutionProgressSummary[]; execution_summaries: ExecutionProgressSummary[];
surfaces: SurfaceInstanceSnapshot; surfaces: SurfaceInstanceSnapshot;
capability_calls: CapabilityCallRecord[];
}; };
const STORE_PREFIX = "lineup.conversation.v1"; const STORE_PREFIX = "lineup.conversation.v1";
@@ -62,6 +65,10 @@ export class ConversationStore {
public open(conversationID: string): ConversationSnapshot { public open(conversationID: string): ConversationSnapshot {
this.activeConversationID = conversationID; this.activeConversationID = conversationID;
this.state = this.load(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(); return this.snapshot();
} }
@@ -81,6 +88,7 @@ export class ConversationStore {
tasks: clone(this.state.tasks), tasks: clone(this.state.tasks),
execution_summaries: clone(this.state.execution_summaries), execution_summaries: clone(this.state.execution_summaries),
surfaces: clone(this.state.surfaces), surfaces: clone(this.state.surfaces),
capability_calls: clone(this.state.capability_calls),
}; };
} }
@@ -122,6 +130,11 @@ export class ConversationStore {
this.persist(); 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 { public appendLocalText(localID: string, text: string, createdAt: string): StoredConversationItem {
const delivery: DeliveryState = { status: "local_pending", updated_at: createdAt }; const delivery: DeliveryState = { status: "local_pending", updated_at: createdAt };
const stored: StoredConversationItem = { const stored: StoredConversationItem = {
@@ -136,7 +149,7 @@ export class ConversationStore {
} }
/** Queues a standard interaction response before any network attempt. */ /** 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; if (this.state.outbox.some(entry => entry.local_id === localID)) return;
this.state.outbox.push({ local_id: localID, kind, payload, created_at: createdAt }); this.state.outbox.push({ local_id: localID, kind, payload, created_at: createdAt });
this.persist(); this.persist();
@@ -233,21 +246,21 @@ export class ConversationStore {
try { try {
const parsed: unknown = JSON.parse(this.storage.getItem(this.key(conversationID)) ?? "null"); const parsed: unknown = JSON.parse(this.storage.getItem(this.key(conversationID)) ?? "null");
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return emptyConversation(); 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 }; 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) || !Array.isArray(candidate.items) || !Array.isArray(candidate.outbox) || typeof candidate.cursor !== "number") { 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(); return emptyConversation();
} }
if (candidate.version !== 9) { if (candidate.version !== 10) {
// v1 advanced its cursor from a send acknowledgement. Some browsers // v1 advanced its cursor from a send acknowledgement. Some browsers
// had already been upgraded to v2 before that correction, retaining a // had already been upgraded to v2 before that correction, retaining a
// high cursor with holes for their own echoes. Preserve every valid // high cursor with holes for their own echoes. Preserve every valid
// local item but replay the server history once to repair those holes; // local item but replay the server history once to repair those holes;
// the next persist upgrades this record to v5. // the next persist upgrades this record to v5.
return { return {
version: 9, version: 10,
cursor: candidate.version === 4 || candidate.version === 5 || candidate.version === 6 || candidate.version === 7 ? Math.max(0, candidate.cursor) : 0, 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), 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_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 : {}, 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. // v7's fixed lifecycle placeholders are intentionally discarded.
execution_summaries: [], execution_summaries: [],
surfaces: emptySurfaces(), surfaces: emptySurfaces(),
capability_calls: [],
}; };
} }
return { return {
version: 9, version: 10,
cursor: Math.max(0, candidate.cursor), cursor: Math.max(0, candidate.cursor),
items: candidate.items as StoredConversationItem[], items: normalizeStoredItems(candidate.items),
outbox: normalizeOutbox(candidate.outbox), outbox: normalizeOutbox(candidate.outbox),
tool_calls: Array.isArray(candidate.tool_calls) ? candidate.tool_calls as ToolCallRecord[] : [], tool_calls: Array.isArray(candidate.tool_calls) ? candidate.tool_calls as ToolCallRecord[] : [],
tool_drafts: isDraftMap(candidate.tool_drafts) ? candidate.tool_drafts : {}, tool_drafts: isDraftMap(candidate.tool_drafts) ? candidate.tool_drafts : {},
tasks: Array.isArray(candidate.tasks) ? candidate.tasks as TaskRecord[] : [], tasks: Array.isArray(candidate.tasks) ? candidate.tasks as TaskRecord[] : [],
execution_summaries: normalizeExecutionSummaries(candidate.execution_summaries), execution_summaries: normalizeExecutionSummaries(candidate.execution_summaries),
surfaces: normalizeSurfaces(candidate.surfaces), surfaces: normalizeSurfaces(candidate.surfaces),
capability_calls: normalizeCapabilityCalls(candidate.capability_calls),
}; };
} catch { } catch {
return emptyConversation(); return emptyConversation();
@@ -288,7 +303,7 @@ export class ConversationStore {
} }
function emptyConversation(): PersistedConversation { 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: [] }; } 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 []; if (!entry || typeof entry !== "object" || Array.isArray(entry)) return [];
const candidate = entry as Partial<OutboxEntry>; const candidate = entry as Partial<OutboxEntry>;
if (typeof candidate.local_id !== "string" || typeof candidate.payload !== "string" || typeof candidate.created_at !== "string") return []; 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 }]; 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<StoredConversationItem>;
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<CapabilityCallRecord>;
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<string, JsonObject> { function isDraftMap(value: unknown): value is Record<string, JsonObject> {
return value !== null && typeof value === "object" && !Array.isArray(value) return value !== null && typeof value === "object" && !Array.isArray(value)
&& Object.values(value).every(draft => draft !== null && typeof draft === "object" && !Array.isArray(draft)); && Object.values(value).every(draft => draft !== null && typeof draft === "object" && !Array.isArray(draft));
@@ -217,4 +217,35 @@ describe("InteractionKernel", () => {
expect(injectedBundle).toEqual(expect.objectContaining({ kind: "fallback", reason: "invalid_payload" })); expect(injectedBundle).toEqual(expect.objectContaining({ kind: "fallback", reason: "invalid_payload" }));
expect(extraAppField).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" }));
});
}); });
+136 -2
View File
@@ -12,6 +12,8 @@ import type {
import type { ToolCallRecord } from "./tool-call-state"; import type { ToolCallRecord } from "./tool-call-state";
import type { TaskRecord } from "./task-state"; import type { TaskRecord } from "./task-state";
import type { ExecutionProgressSummary } from "./execution-progress-state"; import type { ExecutionProgressSummary } from "./execution-progress-state";
import type { ArtifactRecord } from "./artifact-state";
import type { CapabilityCallRecord } from "./capability-call-state";
export type ToolInteractionHandlers = { export type ToolInteractionHandlers = {
submit: (callID: string, submission: JsonObject) => boolean; submit: (callID: string, submission: JsonObject) => boolean;
@@ -28,6 +30,13 @@ export type TaskInteractionHandlers = {
read: (operationID: string) => TaskRecord | undefined; read: (operationID: string) => TaskRecord | undefined;
}; };
export type CapabilityInteractionHandlers = {
approve: (callID: string) => Promise<CapabilityCallRecord | undefined>;
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 * Trusted presentation context for the first-party chat shell. It owns only
* DOM nodes and display-local state; it deliberately has no Store, Transport, * DOM nodes and display-local state; it deliberately has no Store, Transport,
@@ -37,6 +46,7 @@ export class TrustedDOMRendererContext {
private readonly userRows = new Map<string, HTMLElement>(); private readonly userRows = new Map<string, HTMLElement>();
private readonly toolCards = new Map<string, { card: HTMLElement; status: HTMLElement; controls: readonly (HTMLButtonElement | HTMLInputElement | HTMLTextAreaElement)[] }>(); private readonly toolCards = new Map<string, { card: HTMLElement; status: HTMLElement; controls: readonly (HTMLButtonElement | HTMLInputElement | HTMLTextAreaElement)[] }>();
private readonly taskCards = new Map<string, { card: HTMLElement; title: HTMLElement; progress: HTMLProgressElement; status: HTMLElement; cancel: HTMLButtonElement }>(); private readonly taskCards = new Map<string, { card: HTMLElement; title: HTMLElement; progress: HTMLProgressElement; status: HTMLElement; cancel: HTMLButtonElement }>();
private readonly capabilityCards = new Map<string, { card: HTMLElement; status: HTMLElement; approve: HTMLButtonElement; reject: HTMLButtonElement }>();
private readonly executionCards = new Map<string, HTMLDetailsElement>(); private readonly executionCards = new Map<string, HTMLDetailsElement>();
private readonly agentReplyGroups = new Map<string, HTMLElement>(); private readonly agentReplyGroups = new Map<string, HTMLElement>();
private readonly executionSummaries = new Map<string, ExecutionProgressSummary>(); private readonly executionSummaries = new Map<string, ExecutionProgressSummary>();
@@ -48,6 +58,7 @@ export class TrustedDOMRendererContext {
private readonly presence: HTMLElement, private readonly presence: HTMLElement,
private readonly toolInteractions?: ToolInteractionHandlers, private readonly toolInteractions?: ToolInteractionHandlers,
private readonly taskInteractions?: TaskInteractionHandlers, private readonly taskInteractions?: TaskInteractionHandlers,
private readonly capabilityInteractions?: CapabilityInteractionHandlers,
) { ) {
// Status envelopes are sparse. Keep elapsed time honest between them // Status envelopes are sparse. Keep elapsed time honest between them
// without persisting a ticking value or treating it as Agent progress. // without persisting a ticking value or treating it as Agent progress.
@@ -58,6 +69,7 @@ export class TrustedDOMRendererContext {
this.userRows.clear(); this.userRows.clear();
this.toolCards.clear(); this.toolCards.clear();
this.taskCards.clear(); this.taskCards.clear();
this.capabilityCards.clear();
this.executionCards.clear(); this.executionCards.clear();
this.agentReplyGroups.clear(); this.agentReplyGroups.clear();
this.executionSummaries.clear(); this.executionSummaries.clear();
@@ -181,8 +193,85 @@ export class TrustedDOMRendererContext {
this.renderSystemMessage(`收到扩展界面请求(${item.envelope.type});Mini Runtime Surface Host 将在下一步接入。`); this.renderSystemMessage(`收到扩展界面请求(${item.envelope.type});Mini Runtime Surface Host 将在下一步接入。`);
} }
public renderAppCall(_item: Extract<ConversationItem, { kind: "app-call" }>): 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<ConversationItem, { kind: "app-call" }>): 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<ConversationItem, { kind: "fallback" }>): void { public renderFallback(item: Extract<ConversationItem, { kind: "fallback" }>): void {
@@ -292,6 +381,16 @@ export class TrustedDOMRendererContext {
entry.cancel.disabled = requested; 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 { private renderActionGroup(call: ToolCallRequest, group: ActionGroup, record?: ToolCallRecord): void {
const card = document.createElement("article"); const card = document.createElement("article");
card.className = "action-group-card"; card.className = "action-group-card";
@@ -572,6 +671,41 @@ function deliveryLabel(delivery: DeliveryState): string {
return "已发送"; 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 { function validateFormValues(form: InputForm, values: JsonObject): string | undefined {
for (const field of form.fields) { for (const field of form.fields) {
const value = values[field.id]; const value = values[field.id];