Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apps/desktop/electron/main/plugin-host-process.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,12 @@ function buildApi() {
rename: (input) => call("session.rename", [input ?? {}]),
delete: (input) => call("session.delete", [input ?? {}]),
},
// Read-only usage facts (`usage.read`). The main-process dispatch owns
// the permission check and parameter bounds; the host returns per-turn
// counters and identifiers only, so no message body crosses this bridge.
usage: {
listTurns: (input) => call("usage.listTurns", [input ?? {}]),
},
/**
* Resident background workers (spec 07 §3). Registration is local: the
* manifest already declared the service, and the broker starts it only when
Expand Down
91 changes: 91 additions & 0 deletions apps/desktop/electron/main/plugin-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -435,6 +435,10 @@ export type PluginHostServices = {
project?: {
create: (pluginId: string, input: Record<string, unknown>) => Promise<unknown>;
};
/** Read-only completed-turn facts served by host-core's usage domain. */
usage?: {
listTurns: (pluginId: string, input: Record<string, unknown>) => Promise<unknown>;
};
};

/** Host APIs a plugin process may reach. Anything else does not exist (spec 04 §2). */
Expand Down Expand Up @@ -514,6 +518,7 @@ const HOST_API_ALLOWLIST = new Set([
"session.importBatch",
"session.rename",
"session.delete",
"usage.listTurns",
"agent.complete",
"keyboard.registerGlobalShortcut",
"keyboard.unregisterGlobalShortcut",
Expand Down Expand Up @@ -888,6 +893,81 @@ function normalizePluginSessionInput(
return { ...(input as Record<string, unknown>) };
}

/**
* Bounds for the read-only usage fact listing. The host RPC re-checks the
* same windows, so a caller that skips this main-process side still cannot
* widen the scan (spec 07-plugins/03 §usage).
*/
const PLUGIN_USAGE_MAX_WINDOW_MS = 365 * 24 * 60 * 60 * 1000;
const PLUGIN_USAGE_DEFAULT_WINDOW_MS = 30 * 24 * 60 * 60 * 1000;

/** Absent/null keeps the host default; anything else must be an integer. */
function pluginUsageProjectId(value: Record<string, unknown>): number | undefined {
const raw = value.projectId;
if (raw === undefined || raw === null) return undefined;
if (typeof raw !== "number" || !Number.isInteger(raw)) {
throw apiError("INVALID_PARAMS", "projectId must be an integer");
}
return raw;
}

/**
* Mirrors the host-side validation for `usage.listTurns`: absent/null fields
* stay absent (the host applies the 30-day default window and 200-row page),
* and anything out of range is rejected here so a plugin sees a plain
* INVALID_PARAMS instead of a host round-trip. Implied bounds (now / now-30d)
* are used only to check order and the 365-day cap.
*/
function normalizePluginUsageListTurnsInput(input: unknown): Record<string, unknown> {
if (input === undefined || input === null) return {};
if (typeof input !== "object" || Array.isArray(input)) {
throw apiError("INVALID_PARAMS", "usage input must be an object");
}
const value = input as Record<string, unknown>;
const normalized: Record<string, unknown> = {};
const intField = (key: string): number | undefined => {
const raw = value[key];
if (raw === undefined || raw === null) return undefined;
if (typeof raw !== "number" || !Number.isInteger(raw) || raw < 0) {
throw apiError("INVALID_PARAMS", `${key} must be a non-negative integer`);
}
normalized[key] = raw;
return raw;
};
const fromMs = intField("fromMs");
const toMs = intField("toMs");
const resolvedTo = toMs ?? Date.now();
const resolvedFrom = fromMs ?? resolvedTo - PLUGIN_USAGE_DEFAULT_WINDOW_MS;
if (resolvedTo < resolvedFrom) {
throw apiError("INVALID_PARAMS", "toMs must be >= fromMs");
}
if (resolvedTo - resolvedFrom > PLUGIN_USAGE_MAX_WINDOW_MS) {
throw apiError("INVALID_PARAMS", "usage window must span at most 365 days");
}
if (value.sessionId !== undefined && value.sessionId !== null) {
if (typeof value.sessionId !== "string" || !value.sessionId.trim()) {
throw apiError("INVALID_PARAMS", "sessionId must be a non-empty string");
}
normalized.sessionId = value.sessionId;
}
const projectId = pluginUsageProjectId(value);
if (projectId !== undefined) normalized.projectId = projectId;
if (value.cursor !== undefined && value.cursor !== null) {
if (typeof value.cursor !== "string") {
throw apiError("INVALID_PARAMS", "cursor must be a string");
}
if (value.cursor) normalized.cursor = value.cursor;
}
if (value.limit !== undefined && value.limit !== null) {
const limit = value.limit;
if (typeof limit !== "number" || !Number.isInteger(limit) || limit < 1 || limit > 500) {
throw apiError("INVALID_PARAMS", "limit must be an integer between 1 and 500");
}
normalized.limit = limit;
}
return normalized;
}

/** Key for the per-service supervision map. */
function serviceStateKey(pluginId: string, serviceId: string): string {
return `${pluginId}:${serviceId}`;
Expand Down Expand Up @@ -2615,6 +2695,17 @@ export class PluginRuntime {
}
return this.services.session.delete(loaded.manifest.id, input);
}
case "usage.listTurns": {
// Read-only completed-turn facts (spec 07-plugins/03 §usage): flat
// counters and identifiers, no message body, no write path. Every
// dashboard shape stays the plugin's own computation.
this.assertPermission(loaded, "usage.read");
const input = normalizePluginUsageListTurnsInput(args[0]);
if (!this.services.usage?.listTurns) {
throw apiError("UNSUPPORTED", "host api not available: usage.listTurns");
}
return this.services.usage.listTurns(loaded.manifest.id, input);
}
case "agent.complete": {
return this.runAgentComplete(loaded, (args[0] ?? {}) as PluginCompleteInput);
}
Expand Down
7 changes: 7 additions & 0 deletions apps/desktop/electron/main/services/plugin-services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,13 @@ export function createPluginServices({
project: {
create: (pluginId, input) => callPluginProjectHost(pluginId, input),
},
// Read-only usage facts: the same host-owned session transport, no
// mutation, so no `sessionsChanged` fan-out (callPluginSessionHost only
// announces the mutating methods).
usage: {
listTurns: (pluginId, input) =>
callPluginSessionHost("plugin.usage.listTurns", pluginId, input),
},
complete: async (input): Promise<PluginCompleteResult> => {
if (!getHost()) {
throw Object.assign(new Error("host unavailable"), { code: "UNSUPPORTED" });
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/resources/skills/plugin-development.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ user.
`agent.tool.register`, `agent.complete`, `session.read`, `mcp.server.local`,
`mcp.server.remote`
- Medium: `fs.read`, `clipboard.read`, `clipboard.write`, `shell.openExternal`,
`background.service`, `bus.publish`, `bus.subscribe`, `models.list`
`background.service`, `bus.publish`, `bus.subscribe`, `models.list`, `usage.read`
- Low: `ui.panel`, `ui.theme`, `notify` (Toast and best-effort native notifications)

### File and network range
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/features/plugins/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ export const PERMISSION_RISK: Record<string, RiskTier> = {
"mcp.server.local": "high",
"mcp.server.remote": "high",
"background.service": "high",
// Per-turn counters and session titles only, per the usage.read matrix row.
"usage.read": "medium",
// Two capabilities that reach outside PI-Desktop's own window or read its
// live audio stream sit at the top tier with the other outbound paths.
"net.websocket": "high",
Expand Down
99 changes: 99 additions & 0 deletions apps/desktop/test/plugin-session-api.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -311,3 +311,102 @@ test("plugin session read, update, and delete permissions are independent", asyn
"delete:PERMISSION_DENIED",
]);
});

test("plugin usage listTurns requires usage.read and forwards the plugin id", async (t) => {
const calls = [];
const runtime = new PluginRuntime({
hostEntry: hostProcessEntry,
spawnProcess: forkPluginProcess,
usage: {
listTurns: async (pluginId, input) => {
calls.push(["listTurns", pluginId, input]);
return { turns: [{ turnId: "t2", inputTokens: 100 }], nextCursor: null };
},
},
});
t.after(async () => {
for (const loaded of runtime.listLoaded()) await runtime.unload(loaded.manifest.id);
});
const dir = writePlugin({
id: "demo.usage",
permissions: ["usage.read"],
main: `
module.exports = {
async onLoad() {
await pi.commands.register({
id: "read-usage",
title: "Read usage",
run: async () => {
const page = await pi.usage.listTurns({ fromMs: 1, toMs: 2, limit: 7 });
await pi.ui.showToast("rows:" + page.turns.length);
await pi.usage.listTurns({ limit: 5, projectId: 3, sessionId: "s2", cursor: "abc" });
for (const [name, call] of [
["badWindow", () => pi.usage.listTurns({ fromMs: 0, toMs: 1 + 365 * 86400000 })],
["badOrder", () => pi.usage.listTurns({ fromMs: 10, toMs: 1 })],
["badFromOnly", () => pi.usage.listTurns({ fromMs: 0 })],
["badLimit", () => pi.usage.listTurns({ limit: 0 })],
["badProject", () => pi.usage.listTurns({ projectId: "seven" })],
["badFrom", () => pi.usage.listTurns({ fromMs: -1 })]
]) {
try { await call(); }
catch (error) { await pi.ui.showToast(name + ":" + error.code); }
}
}
});
}
};
`,
});
await runtime.loadFromPath(dir, ["usage.read"]);
await runCommand(runtime, "read-usage");
// Authorized calls forward with the plugin id and only the normalized fields.
assert.deepEqual(calls, [
["listTurns", "demo.usage", { fromMs: 1, toMs: 2, limit: 7 }],
["listTurns", "demo.usage", { limit: 5, projectId: 3, sessionId: "s2", cursor: "abc" }],
]);
assert.deepEqual(runtime.drainToasts(), [
"rows:1",
"badWindow:INVALID_PARAMS",
"badOrder:INVALID_PARAMS",
"badFromOnly:INVALID_PARAMS",
"badLimit:INVALID_PARAMS",
"badProject:INVALID_PARAMS",
"badFrom:INVALID_PARAMS",
]);
});

test("plugin usage listTurns is refused without the usage.read permission", async (t) => {
const calls = [];
const runtime = new PluginRuntime({
hostEntry: hostProcessEntry,
spawnProcess: forkPluginProcess,
usage: {
listTurns: async () => calls.push("listTurns"),
},
});
t.after(async () => {
for (const loaded of runtime.listLoaded()) await runtime.unload(loaded.manifest.id);
});
const dir = writePlugin({
id: "demo.usage-denied",
permissions: ["session.read.own"],
main: `
module.exports = {
async onLoad() {
await pi.commands.register({
id: "peek",
title: "Peek",
run: async () => {
try { await pi.usage.listTurns(); }
catch (error) { await pi.ui.showToast("listTurns:" + error.code); }
}
});
}
};
`,
});
await runtime.loadFromPath(dir, ["session.read.own"]);
await runCommand(runtime, "peek");
assert.deepEqual(calls, []);
assert.deepEqual(runtime.drainToasts(), ["listTurns:PERMISSION_DENIED"]);
});
1 change: 1 addition & 0 deletions crates/host-core/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ mod notifications;
mod permissions;
mod plans;
mod plugin_sessions;
mod plugin_usage;
mod plugins;
mod providers;
mod review;
Expand Down
Loading
Loading