feat(m3): add consent-gated capability runtime
This commit is contained in:
+305
-5
@@ -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"; }
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user