feat(runtime): enforce miniapp capability policy

This commit is contained in:
2026-08-06 00:23:11 +08:00
parent 58e77462ad
commit c77a0cf69b
5 changed files with 84 additions and 17 deletions
@@ -52,6 +52,8 @@ import type { StandardInteractionRequest } from "@/runtime/coordination/standard
import type { LineUpMiniAppSDK, MiniAppToolFailure, MiniAppToolProgress } from "@/runtime/app-management/miniapp-sdk";
import { MiniAppToolStateMachine, type MiniAppToolCallRecord } from "@/runtime/coordination/miniapp-tool-state";
import { validateMiniAppToolSchema } from "@/runtime/coordination/miniapp-tool-schema";
import { CapabilityRegistry } from "@/runtime/capabilities/capability-registry";
import { CapabilityAuditLog } from "@/runtime/capabilities/capability-audit";
export type RuntimeSession = {
api: string;
@@ -115,6 +117,8 @@ export class LineUpRuntime {
private readonly prepareIncoming: (item: ConversationItem) => ConversationItem | undefined;
private readonly orchestrator: AppOrchestrator;
private readonly toolRouter: ToolRouter;
private readonly capabilityRegistry = new CapabilityRegistry();
private readonly capabilityAudit = new CapabilityAuditLog();
/** SDK v1 inventory revision advertised by this Runtime instance. */
private readonly inventoryRevision = "catalog-1";
@@ -166,6 +170,8 @@ export class LineUpRuntime {
return this.miniappTools.list();
}
public capabilityAuditEntries() { return this.capabilityAudit.snapshot(); }
/** Binds an ordinary MiniApp Tool to its Runtime instance before delivery. */
public bindToolCallToInstance(callID: string, instanceID: string): boolean {
const instance = this.lifecycle.instances.get(instanceID);
@@ -381,7 +387,7 @@ export class LineUpRuntime {
request: async (event, data) => this.requestBundledSurface(appScope, instanceID, event, data),
},
capabilities: {
request: (name, reason, input = {}) => this.queueProtocolAction("app-result", "lineup.v1.app.call", { instance_id: instanceID, app_scope: appScope, capability: name, reason, arguments: input, expires_at: new Date(Date.parse(this.now()) + 15 * 60 * 1000).toISOString() }),
request: (name, reason, input = {}) => this.requestMiniAppCapability(instanceID, name, reason, input),
},
};
}
@@ -456,10 +462,7 @@ export class LineUpRuntime {
const name = text("name");
const reason = text("reason");
if (!name || !reason || !isJsonObject(payload.input)) throw new Error("invalid_capability_request");
return this.bridgeReceipt(await this.queueProtocolAction("app-result", "lineup.v1.app.call", {
instance_id: instanceID, app_scope: instance.app_scope, capability: name, reason, arguments: payload.input,
expires_at: new Date(Date.parse(this.now()) + 15 * 60 * 1000).toISOString(),
}));
return this.bridgeReceipt(await this.requestMiniAppCapability(instanceID, name, reason, payload.input));
}
throw new Error("unsupported_bridge_method");
}
@@ -468,6 +471,44 @@ export class LineUpRuntime {
return receipt.disposition === "accepted" ? { disposition: "accepted", ...(receipt.local_id ? { local_id: receipt.local_id } : {}) } : { disposition: "rejected", code: receipt.code };
}
/**
* Capability requests are proposals, not direct Host handles. Runtime checks
* the installed Manifest, local Registry/Policy, argument schema and lifecycle
* before putting a request on the normal Agent confirmation path.
*/
private requestMiniAppCapability(instanceID: string, name: string, reason: string, input: JsonObject): Promise<CommandReceipt> {
const instance = this.lifecycle.instances.get(instanceID);
const app = instance ? this.apps.get(instance.app_scope) : undefined;
const resolution = this.capabilityRegistry.resolve(name);
const callID = this.newID("capability");
if (!instance || !app || app.kind !== "bundled" || !app.manifest.permissions.includes(name)) {
this.capabilityAudit.recordDecision(callID, name, "restricted", "rejected", this.now());
return Promise.resolve({ disposition: "rejected", code: "capability_not_declared" });
}
if (resolution.disposition !== "available") {
this.capabilityAudit.recordDecision(callID, name, "restricted", "rejected", this.now());
return Promise.resolve({ disposition: "rejected", code: "capability_unavailable" });
}
if (!reason.trim() || reason.length > 500 || !this.capabilityRegistry.validateArguments(name, input)) {
this.capabilityAudit.recordDecision(callID, name, resolution.definition.risk, "rejected", this.now());
return Promise.resolve({ disposition: "rejected", code: "invalid_capability_request" });
}
if (resolution.definition.foreground_required && instance.state !== "foreground") {
this.capabilityAudit.recordDecision(callID, name, resolution.definition.risk, "rejected", this.now());
return Promise.resolve({ disposition: "rejected", code: "capability_requires_foreground" });
}
this.capabilityAudit.recordDecision(callID, name, resolution.definition.risk, "pending", this.now());
return this.queueProtocolAction("app-result", "lineup.v1.app.call", {
call_id: callID,
instance_id: instanceID,
app_scope: instance.app_scope,
capability: name,
reason: reason.trim().replace(/\s+/gu, " ").slice(0, 500),
arguments: input,
expires_at: new Date(Date.parse(this.now()) + 15 * 60 * 1000).toISOString(),
});
}
/** @deprecated Use openApp("chat") so App selection remains registry-driven. */
public openChatApp(): ChatRuntimeSDK {
return this.openApp("chat");