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