mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-07-09 17:28:45 +00:00
feat(coding-agent): allow project trust extensions to defer
This commit is contained in:
parent
085a08582f
commit
d8aef0feff
10 changed files with 72 additions and 19 deletions
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
### Added
|
||||
|
||||
- Added a `project_trust` extension event so global and CLI extensions can decide project trust during startup and runtime cwd switches.
|
||||
- Added a `project_trust` extension event so global and CLI extensions can decide or defer project trust during startup and runtime cwd switches.
|
||||
- Added project trust gating for project-local settings, resources, instructions, and packages ([#5332](https://github.com/earendil-works/pi/pull/5332)).
|
||||
- Added the latest prompt cache hit rate to the interactive footer.
|
||||
- Exported RPC extension UI request and response types from the public API ([#5455](https://github.com/earendil-works/pi/issues/5455)).
|
||||
|
|
|
|||
|
|
@ -346,12 +346,13 @@ pi.on("project_trust", async (event, ctx) => {
|
|||
// event.cwd - current working directory
|
||||
// ctx has a limited trust context: cwd, mode, hasUI, and select/confirm/input/notify UI helpers
|
||||
if (await ctx.ui.confirm("Trust project?", event.cwd)) {
|
||||
return { trusted: true, remember: true };
|
||||
return { trusted: "yes", remember: true };
|
||||
}
|
||||
return { trusted: "undecided" };
|
||||
});
|
||||
```
|
||||
|
||||
A `project_trust` handler must return `{ trusted }`; if a user/global or CLI extension registers this handler, it owns the decision. The first returned decision wins and suppresses the built-in trust prompt. Use `remember: true` to persist the decision; otherwise it applies only to the current process. Check `ctx.hasUI` before prompting. If no extension handles `project_trust`, normal trust resolution continues, including the built-in trust prompt when UI is available.
|
||||
A `project_trust` handler must return `{ trusted: "yes" | "no" | "undecided" }`. A user/global or CLI extension that returns `"yes"` or `"no"` owns the decision; the first yes/no decision wins and suppresses the built-in trust prompt. Use `remember: true` to persist a yes/no decision; otherwise it applies only to the current process. Return `"undecided"` to let later handlers or the built-in trust flow decide. Check `ctx.hasUI` before prompting. If no handler returns yes/no, normal trust resolution continues, including the built-in trust prompt when UI is available.
|
||||
|
||||
### Resource Events
|
||||
|
||||
|
|
@ -2586,7 +2587,7 @@ All examples in [examples/extensions/](../examples/extensions/).
|
|||
| `shutdown-command.ts` | Graceful shutdown command | `registerCommand`, `shutdown()` |
|
||||
| **Events & Gates** |||
|
||||
| `permission-gate.ts` | Block dangerous commands | `on("tool_call")`, `ui.confirm` |
|
||||
| `project-trust.ts` | Decide project trust from a user/global or CLI extension | `on("project_trust")`, trust UI, required trust result |
|
||||
| `project-trust.ts` | Decide or defer project trust from a user/global or CLI extension | `on("project_trust")`, trust UI, required trust result |
|
||||
| `protected-paths.ts` | Block writes to specific paths | `on("tool_call")` |
|
||||
| `confirm-destructive.ts` | Confirm session changes | `on("session_before_switch")`, `on("session_before_fork")` |
|
||||
| `dirty-repo-guard.ts` | Warn on dirty git repo | `on("session_before_*")`, `exec` |
|
||||
|
|
|
|||
|
|
@ -20,13 +20,14 @@ export default function (pi: ExtensionAPI) {
|
|||
loadCount++;
|
||||
|
||||
// Multiple handlers in one extension are allowed. The first handler that returns
|
||||
// { trusted } wins and suppresses the built-in trust prompt. A project_trust
|
||||
// handler must return a decision.
|
||||
// { trusted: "yes" } or { trusted: "no" } wins and suppresses the built-in
|
||||
// trust prompt. Return { trusted: "undecided" } to let another handler or the
|
||||
// built-in flow decide.
|
||||
pi.on("project_trust", async (event, ctx): Promise<ProjectTrustEventResult> => {
|
||||
ctx.ui.notify(`project_trust fired for ${event.cwd} (mode: ${ctx.mode}, load: ${loadCount})`, "info");
|
||||
|
||||
if (!ctx.hasUI) {
|
||||
return { trusted: false };
|
||||
return { trusted: "undecided" };
|
||||
}
|
||||
|
||||
const choice = await ctx.ui.select(`Project trust for:\n${event.cwd}`, [
|
||||
|
|
@ -34,23 +35,27 @@ export default function (pi: ExtensionAPI) {
|
|||
"Trust with note and remember",
|
||||
"Trust this session",
|
||||
"Do not trust this session",
|
||||
"Let built-in prompt decide",
|
||||
]);
|
||||
|
||||
if (choice === "Trust with note and remember") {
|
||||
const note = await ctx.ui.input("Project trust note", "Optional note for this demo");
|
||||
ctx.ui.notify(note ? `Recorded demo note: ${note}` : "No demo note entered", "info");
|
||||
return { trusted: true, remember: true };
|
||||
return { trusted: "yes", remember: true };
|
||||
}
|
||||
if (choice === "Trust and remember") {
|
||||
return { trusted: true, remember: true };
|
||||
return { trusted: "yes", remember: true };
|
||||
}
|
||||
if (choice === "Trust this session") {
|
||||
return { trusted: true };
|
||||
return { trusted: "yes" };
|
||||
}
|
||||
if (choice === "Do not trust this session") {
|
||||
return { trusted: false };
|
||||
return { trusted: "no" };
|
||||
}
|
||||
return { trusted: false };
|
||||
if (choice === "Let built-in prompt decide") {
|
||||
return { trusted: "undecided" };
|
||||
}
|
||||
return { trusted: "undecided" };
|
||||
});
|
||||
|
||||
pi.on("session_start", (_event, ctx) => {
|
||||
|
|
|
|||
|
|
@ -100,6 +100,7 @@ export type {
|
|||
ModelSelectSource,
|
||||
ProjectTrustContext,
|
||||
ProjectTrustEvent,
|
||||
ProjectTrustEventDecision,
|
||||
ProjectTrustEventResult,
|
||||
ProjectTrustHandler,
|
||||
// Provider Registration
|
||||
|
|
|
|||
|
|
@ -202,13 +202,16 @@ export async function emitProjectTrustEvent(
|
|||
const errors: ExtensionError[] = [];
|
||||
for (const ext of extensionsResult.extensions) {
|
||||
// A single extension may register multiple handlers for the same event.
|
||||
// The first project_trust handler that returns a decision wins.
|
||||
// The first project_trust handler that returns yes/no wins; undecided falls through.
|
||||
const handlers = ext.handlers.get("project_trust");
|
||||
if (!handlers || handlers.length === 0) continue;
|
||||
|
||||
for (const handler of handlers) {
|
||||
try {
|
||||
const handlerResult = (await handler(event, ctx)) as ProjectTrustEventResult;
|
||||
if (handlerResult.trusted === "undecided") {
|
||||
continue;
|
||||
}
|
||||
return { result: handlerResult, errors };
|
||||
} catch (error) {
|
||||
errors.push({
|
||||
|
|
|
|||
|
|
@ -503,8 +503,10 @@ export interface ProjectTrustEvent {
|
|||
cwd: string;
|
||||
}
|
||||
|
||||
export type ProjectTrustEventDecision = "yes" | "no" | "undecided";
|
||||
|
||||
export interface ProjectTrustEventResult {
|
||||
trusted: boolean;
|
||||
trusted: ProjectTrustEventDecision;
|
||||
remember?: boolean;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -100,6 +100,7 @@ export type {
|
|||
MessageRenderOptions,
|
||||
ProjectTrustContext,
|
||||
ProjectTrustEvent,
|
||||
ProjectTrustEventDecision,
|
||||
ProjectTrustEventResult,
|
||||
ProjectTrustHandler,
|
||||
ProviderConfig,
|
||||
|
|
|
|||
|
|
@ -648,10 +648,11 @@ async function resolveProjectTrusted(options: {
|
|||
options.onExtensionError?.(`Extension "${error.extensionPath}" project_trust error: ${error.error}`);
|
||||
}
|
||||
if (result) {
|
||||
const trusted = result.trusted === "yes";
|
||||
if (result.remember === true) {
|
||||
options.trustStore.set(options.cwd, result.trusted);
|
||||
options.trustStore.set(options.cwd, trusted);
|
||||
}
|
||||
return result.trusted;
|
||||
return trusted;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@ import * as os from "node:os";
|
|||
import * as path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { AuthStorage } from "../src/core/auth-storage.ts";
|
||||
import { createExtensionRuntime, discoverAndLoadExtensions } from "../src/core/extensions/loader.ts";
|
||||
import { ExtensionRunner } from "../src/core/extensions/runner.ts";
|
||||
import { createExtensionRuntime, discoverAndLoadExtensions, loadExtensions } from "../src/core/extensions/loader.ts";
|
||||
import { ExtensionRunner, emitProjectTrustEvent } from "../src/core/extensions/runner.ts";
|
||||
import type {
|
||||
ExtensionActions,
|
||||
ExtensionContextActions,
|
||||
|
|
@ -85,6 +85,45 @@ describe("ExtensionRunner", () => {
|
|||
getSystemPrompt: () => "",
|
||||
};
|
||||
|
||||
describe("project_trust", () => {
|
||||
it("continues past undecided handlers and returns the first yes/no decision", async () => {
|
||||
const undecidedPath = path.join(extensionsDir, "undecided.ts");
|
||||
const decidedPath = path.join(extensionsDir, "decided.ts");
|
||||
fs.writeFileSync(
|
||||
undecidedPath,
|
||||
`export default function(pi) {
|
||||
pi.on("project_trust", () => ({ trusted: "undecided", remember: true }));
|
||||
}`,
|
||||
);
|
||||
fs.writeFileSync(
|
||||
decidedPath,
|
||||
`export default function(pi) {
|
||||
pi.on("project_trust", () => ({ trusted: "no", remember: true }));
|
||||
}`,
|
||||
);
|
||||
|
||||
const extensionsResult = await loadExtensions([undecidedPath, decidedPath], tempDir);
|
||||
const result = await emitProjectTrustEvent(
|
||||
extensionsResult,
|
||||
{ type: "project_trust", cwd: tempDir },
|
||||
{
|
||||
cwd: tempDir,
|
||||
mode: "tui",
|
||||
hasUI: false,
|
||||
ui: {
|
||||
select: async () => undefined,
|
||||
confirm: async () => false,
|
||||
input: async () => undefined,
|
||||
notify: () => {},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.result).toEqual({ trusted: "no", remember: true });
|
||||
expect(result.errors).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shortcut conflicts", () => {
|
||||
it("warns when extension shortcut conflicts with built-in", async () => {
|
||||
const extCode = `
|
||||
|
|
|
|||
|
|
@ -199,7 +199,7 @@ Project skill`,
|
|||
join(userExtDir, "user.ts"),
|
||||
`globalThis[${JSON.stringify(loadCountKey)}] = (globalThis[${JSON.stringify(loadCountKey)}] ?? 0) + 1;
|
||||
export default function(pi) {
|
||||
pi.on("project_trust", () => ({ trusted: true }));
|
||||
pi.on("project_trust", () => ({ trusted: "yes" }));
|
||||
pi.registerCommand("user-trust", {
|
||||
description: "user trust",
|
||||
handler: async () => {},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue