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
+305 -5
View File
@@ -1,4 +1,5 @@
import "./style.css";
import "./capability-card.css";
import "./execution-progress.css";
import {
type Actor,
@@ -16,6 +17,13 @@ import { TrustedDOMRendererContext } from "./runtime/trusted-dom-renderers";
import { SurfaceRegistry } from "./runtime/surface-registry";
import { SurfaceInstanceManager } from "./runtime/surface-instance-manager";
import { IsolatedSurfaceHost } from "./runtime/isolated-surface-host";
import { CapabilityRegistry } from "./runtime/capability-registry";
import { ClientInventoryPublisher } from "./runtime/client-inventory";
import { ArtifactState } from "./runtime/artifact-state";
import { ArtifactContentCache } from "./runtime/artifact-content-cache";
import { CapabilityCallStateMachine, type CapabilityCallRecord, parseCapabilityCall } from "./runtime/capability-call-state";
import { CapabilityAuditLog } from "./runtime/capability-audit";
import { CapabilityExecutor, type PickedFile } from "./runtime/capability-executor";
type Session = {
api: string;
@@ -37,6 +45,25 @@ const conversationStore = new ConversationStore(localStorage);
const executionProgress = new ExecutionProgressState();
const surfaceRegistry = new SurfaceRegistry();
const surfaceInstances = new SurfaceInstanceManager(surfaceRegistry);
const capabilityRegistry = new CapabilityRegistry();
const inventoryPublisher = new ClientInventoryPublisher();
const artifactState = new ArtifactState();
const artifactContentCache = new ArtifactContentCache();
const capabilityCalls = new CapabilityCallStateMachine();
const capabilityAudit = new CapabilityAuditLog();
const capabilityExecutor = new CapabilityExecutor({
async openURL(url) {
// With `noopener`, Chromium intentionally returns null even when it did
// create the tab. Treat a synchronous exception as failure, but not this
// security-preserving return value; otherwise a successful safe open is
// incorrectly reported as `handler_failed` to the Agent.
window.open(url, "_blank", "noopener,noreferrer");
},
writeClipboard: writeClipboardFromTrustedHost,
pickFile: pickFileFromTrustedHost,
async saveArtifact(artifactID) { saveCachedArtifact(artifactID); },
}, artifactState);
let lastInventoryRevision = "";
const SURFACE_REGISTRY_STORAGE_KEY = "lineup.surface-registry.v1";
try { surfaceRegistry.restore(JSON.parse(localStorage.getItem(SURFACE_REGISTRY_STORAGE_KEY) ?? "null")); } catch { surfaceRegistry.restore(null); }
@@ -146,6 +173,11 @@ const rendererContext = new TrustedDOMRendererContext(messages, presence, {
return transition.disposition === "accepted";
},
read(operationID) { return interactionKernel.snapshot().tasks.find(task => task.operation_id === operationID); },
}, {
async approve(callID) { return approveCapabilityCall(callID); },
reject(callID) { return rejectCapabilityCall(callID); },
expire(callID) { return expireCapabilityCall(callID); },
read(callID) { return capabilityCalls.get(callID); },
});
const rendererRegistry = new RendererRegistry<TrustedDOMRendererContext>();
rendererRegistry.register("user-message", (item, context) => context.renderUserMessage(item));
@@ -162,6 +194,34 @@ rendererRegistry.register("app-call", (item, context) => context.renderAppCall(i
rendererRegistry.register("fallback", (item, context) => context.renderFallback(item));
function renderIncoming(item: ConversationItem): void {
if (item.kind === "app-call") {
const now = new Date().toISOString();
const parsed = parseCapabilityCall(item.envelope.payload, capabilityRegistry, now);
if (parsed.disposition === "unsupported") {
rendererContext.renderSystemMessage("此 App 能力当前未在本机启用,未执行任何操作。");
return;
}
if (parsed.disposition !== "accepted") {
rendererContext.renderSystemMessage("收到无法安全确认的 App 能力请求。");
return;
}
const transition = capabilityCalls.receive(parsed.request, item.envelope.conversation_id, now);
if (transition.disposition === "accepted" && transition.record) persistCapabilityCall(transition.record, now);
// Both an accepted call and an exact protocol duplicate render from the
// state machine. A duplicate cannot create a second actionable card.
rendererContext.renderAppCall(item);
return;
}
if (item.kind === "artifact-offer") {
const artifact = artifactState.offer(item.envelope.payload);
if (!artifact) {
rendererContext.renderSystemMessage("收到无法安全验证的 Artifact。 ");
return;
}
rendererContext.renderArtifact(artifact);
reconcileArtifactSaveAvailability();
return;
}
if (item.kind === "surface") {
const now = new Date().toISOString();
if (item.envelope.type === "lineup.v1.ui.open") {
@@ -205,12 +265,33 @@ function replayStoredSurfaceLifecycle(): void {
}
}
/**
* Inventory is a declarative host-to-Agent update, never an action request.
* Failed delivery is retried by syncLoop; a revision changes only when public
* availability changes, never because of a retry.
*/
async function publishInventory(): Promise<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", () => {
const now = new Date().toISOString();
surfaceRegistry.enable("lineup.task-dashboard", "0.1.0", now);
localStorage.setItem(SURFACE_REGISTRY_STORAGE_KEY, JSON.stringify(surfaceRegistry.snapshot()));
replayStoredSurfaceLifecycle();
conversationStore.replaceSurfaces(surfaceInstances.snapshot());
void publishInventory();
});
/**
@@ -245,6 +326,221 @@ function currentConversationKey(): string {
return `${session.uid}:${session.channelType}:${session.channelID}`;
}
/**
* Only host-owned bytes can make artifact.save requestable. The protocol's
* local_ref is opaque and never dereferenced as a path/URL; if a host has no
* cache entry the capability is absent from the next client.inventory.
*/
function reconcileArtifactSaveAvailability(): void {
const available = artifactState.snapshot().some(artifact => artifact.integrity === "verified" && artifactContentCache.has(artifact.local_ref));
const before = capabilityRegistry.resolve("artifact.save").disposition === "available";
if (before === available) return;
capabilityRegistry.setVisible("artifact.save", available);
void publishInventory();
}
function saveCachedArtifact(artifactID: string): void {
const artifact = artifactState.get(artifactID);
const blob = artifact?.integrity === "verified" && artifact.local_ref ? artifactContentCache.get(artifact.local_ref) : undefined;
if (!artifact || !blob) throw new Error("artifact_cache_unavailable");
const objectURL = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = objectURL;
anchor.download = artifact.name;
anchor.hidden = true;
document.body.append(anchor);
try { anchor.click(); } finally {
anchor.remove();
window.setTimeout(() => URL.revokeObjectURL(objectURL), 0);
}
}
/**
* HTTPS/Tauri hosts use the standard asynchronous Clipboard API. The HTTP
* development reference host cannot rely on that API being exposed, so it
* falls back to the browser's user-activation-gated copy command. Both paths
* are reached only after the native capability confirmation; the temporary
* textarea is removed synchronously and no clipboard value is persisted or
* reflected into a protocol result.
*/
async function writeClipboardFromTrustedHost(text: string): Promise<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 {
if (!session.uid) {
rendererContext.renderSystemMessage("交互结果已保留,将在下次登录后发送。");
@@ -323,6 +619,7 @@ async function syncLoop(): Promise<void> {
polling = true;
while (session.active) {
try {
try { await publishInventory(); } catch { /* retried by the next loop */ }
// WuKongIM's legacy sync endpoint can return a single retained message
// per request even when `more` is 0. Drain until it is empty before
// switching to the paced live-polling loop; otherwise a reopened chat
@@ -352,7 +649,7 @@ async function syncLoop(): Promise<void> {
const payload = decodeBase64(message.payload);
if (message.from_uid === session.uid) {
const ownEnvelope = parseEnvelopeJSON(payload);
if (ownEnvelope.ok && (ownEnvelope.envelope.type === "lineup.v1.tool.result" || ownEnvelope.envelope.type === "lineup.v1.tool.cancel" || ownEnvelope.envelope.type === "lineup.v1.ui.event")) {
if (ownEnvelope.ok && (ownEnvelope.envelope.type === "lineup.v1.tool.result" || ownEnvelope.envelope.type === "lineup.v1.tool.cancel" || ownEnvelope.envelope.type === "lineup.v1.ui.event" || ownEnvelope.envelope.type === "lineup.v1.client.inventory" || ownEnvelope.envelope.type === "lineup.v1.app.result")) {
// Tool action echoes advance the inclusive cursor but are never
// rendered as raw JSON user messages or replayed as a new action.
continue;
@@ -373,9 +670,11 @@ async function syncLoop(): Promise<void> {
}
for (const item of result.items) {
const receivedAt = new Date().toISOString();
if (conversationStore.recordIncoming(item, message.message_seq, receivedAt)) {
projectExecutionProgress(item, receivedAt);
renderIncoming(item);
const safeItem = item.kind === "artifact-offer" ? prepareArtifactOffer(item) : item;
if (!safeItem) continue;
if (conversationStore.recordIncoming(safeItem, message.message_seq, receivedAt)) {
projectExecutionProgress(safeItem, receivedAt);
renderIncoming(safeItem);
}
}
}
@@ -424,6 +723,7 @@ $<HTMLFormElement>("#login-form").addEventListener("submit", async event => {
surfaceInstances.restore(snapshot.surfaces);
interactionKernel.restoreToolCalls(snapshot.tool_calls, new Date().toISOString());
interactionKernel.restoreTasks(snapshot.tasks);
capabilityCalls.restore(snapshot.capability_calls);
executionProgress.restore(snapshot.execution_summaries);
conversationStore.replaceToolCalls(interactionKernel.snapshot().tool_calls);
conversationStore.replaceTasks(interactionKernel.snapshot().tasks);
@@ -438,7 +738,7 @@ $<HTMLFormElement>("#login-form").addEventListener("submit", async event => {
}
for (const instance of surfaceInstances.snapshot().instances) surfaceHost.mount(instance);
rendererContext.restoreExecutionSummaries(snapshot.execution_summaries);
rendererContext.renderSystemMessage("已进入与 AI Agent 的对话"); rendererContext.setPresence("正在同步消息…"); void flushOutbox(); void syncLoop();
rendererContext.renderSystemMessage("已进入与 AI Agent 的对话"); rendererContext.setPresence("正在同步消息…"); void publishInventory(); void flushOutbox(); void syncLoop();
} catch (reason) { error.textContent = loginFailureMessage(reason, endpoint); }
finally { submit.disabled = false; submit.textContent = "进入 LineUp"; }
});