feat(runtime): enforce miniapp capability policy
This commit is contained in:
@@ -258,6 +258,25 @@ describe("LineUpRuntime", () => {
|
||||
expect("workspace" in sdk).toBe(false);
|
||||
});
|
||||
|
||||
it("requires bundled MiniApp capabilities to be declared and policy-valid before queuing an Agent request", async () => {
|
||||
const conversationID = "user_1:2:channel_1";
|
||||
const transport = new FakeTransport();
|
||||
const runtime = new LineUpRuntime({ storage: new MemoryStorage(), createTransport: () => transport, now: () => "2026-08-04T00:00:00.000Z" });
|
||||
runtime.apps.install({
|
||||
app_scope: "capability-demo", kind: "bundled", enabled: true, default_eligible: false, recovery: false,
|
||||
manifest: { app_scope: "capability-demo", version: "1.0.0", permissions: ["app.open_url"], tools: [] },
|
||||
});
|
||||
await runtime.login(login);
|
||||
const instance = runtime.lifecycle.start({ app_scope: "capability-demo", instance_id: "capability-demo:1", app_session_id: "capability-demo:session", conversation_id: conversationID }, "2026-08-04T00:00:00.000Z");
|
||||
runtime.lifecycle.foreground(instance.instance_id, "2026-08-04T00:00:00.000Z");
|
||||
const sdk = runtime.openBundledMiniApp("capability-demo", instance.instance_id);
|
||||
expect(await sdk.capabilities.request("clipboard.write", "复制文本", { text: "x" })).toEqual({ disposition: "rejected", code: "capability_not_declared" });
|
||||
expect(await sdk.capabilities.request("app.open_url", "打开文档", { url: "https://example.com" })).toEqual(expect.objectContaining({ disposition: "accepted" }));
|
||||
await runtime.flushOutbox();
|
||||
expect(transport.sent.filter(entry => entry.payload.includes("lineup.v1.app.call"))).toHaveLength(1);
|
||||
expect(transport.sent.at(-1)?.payload).toContain("app.open_url");
|
||||
});
|
||||
|
||||
it("keeps bundled Surface requests local and scopes them to the current instance", async () => {
|
||||
const conversationID = "user_1:2:channel_1";
|
||||
const launch = JSON.stringify({
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user