@trim21/personal-pi-extensions 0.0.217 → 0.0.222

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,296 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionCommandContext,
4
+ ExtensionContext,
5
+ } from "@earendil-works/pi-coding-agent";
6
+ import { createBashTool } from "@earendil-works/pi-coding-agent";
7
+ import { type TObject, Type } from "typebox";
8
+
9
+ import { type CommandSpec, parseCommand } from "../lib/cli.js";
10
+ import {
11
+ type BwrapMode,
12
+ createBwrapBashOperations,
13
+ findBwrap,
14
+ loadBwrapConfig,
15
+ resolveBwrap,
16
+ resolveBwrapPath,
17
+ type ResolvedBwrap,
18
+ resolveHeadlessBwrap,
19
+ } from "./core.js";
20
+
21
+ export type EscalationDecision = { kind: "dialog" } | { kind: "deny"; reason: string };
22
+
23
+ export function resolveEscalation(opts: { hasUI: boolean }): EscalationDecision {
24
+ if (!opts.hasUI) {
25
+ return {
26
+ kind: "deny",
27
+ reason:
28
+ "request_full_access requires an interactive session with user approval; no UI is available in this session.",
29
+ };
30
+ }
31
+ return { kind: "dialog" };
32
+ }
33
+
34
+ export interface BwrapExecutionRequest {
35
+ toolCallId: string;
36
+ command: string;
37
+ timeout?: number;
38
+ requestFullAccess?: boolean;
39
+ requestFullAccessReason?: string;
40
+ signal?: AbortSignal;
41
+ onUpdate?: Parameters<ReturnType<typeof createBashTool>["execute"]>[3];
42
+ ctx: ExtensionContext;
43
+ }
44
+
45
+ function escapeHtml(text: string): string {
46
+ return text
47
+ .replaceAll("&", "&amp;")
48
+ .replaceAll("<", "&lt;")
49
+ .replaceAll(">", "&gt;")
50
+ .replaceAll('"', "&quot;");
51
+ }
52
+
53
+ function fenceCodeBlock(code: string): string {
54
+ const longestRun = Math.max(...(code.match(/`+/g)?.map((match) => match.length) ?? [0]));
55
+ const fence = "`".repeat(Math.max(3, longestRun + 1));
56
+ return `${fence}\n${code}\n${fence}`;
57
+ }
58
+
59
+ function notifyMode(
60
+ ctx: { ui: { notify: (message: string, type?: "info" | "warning" | "error") => void } },
61
+ mode: BwrapMode,
62
+ ): void {
63
+ const labels: Record<BwrapMode, string> = {
64
+ "allow-all": "allow-all: sandbox off, network on",
65
+ "workspace-write": "workspace-write: sandbox on, network off",
66
+ "allow-net": "allow-net: sandbox on, network on, workspace writable",
67
+ readonly: "readonly: sandbox on, network off, read-only fs",
68
+ };
69
+ ctx.ui.notify(labels[mode], "info");
70
+ }
71
+
72
+ export class BwrapRuntime {
73
+ private resolved: ResolvedBwrap | undefined;
74
+ private sandboxDisabled = false;
75
+
76
+ setup(pi: ExtensionAPI): void {
77
+ pi.registerFlag("no-bwrap", {
78
+ description: "Disable bwrap sandboxing for bash commands",
79
+ type: "boolean",
80
+ default: false,
81
+ });
82
+
83
+ pi.on("session_start", (_event, ctx) => {
84
+ this.sandboxDisabled = pi.getFlag("no-bwrap") === true && ctx.hasUI;
85
+ this.resolved = undefined;
86
+ const runtime = this.resolve(ctx);
87
+ if (runtime.bwrapEnabled) {
88
+ try {
89
+ findBwrap(runtime.bwrapPath);
90
+ } catch (error) {
91
+ this.sandboxDisabled = true;
92
+ this.resolved = undefined;
93
+ ctx.ui.notify(error instanceof Error ? error.message : "bwrap not found", "error");
94
+ return;
95
+ }
96
+ }
97
+ ctx.ui.setStatus("bwrap", ctx.ui.theme.fg("accent", `bwrap: ${runtime.mode}`));
98
+ ctx.ui.notify(
99
+ runtime.bwrapEnabled
100
+ ? `bwrap initialized (${runtime.mode})`
101
+ : `bwrap mode: ${runtime.mode}`,
102
+ "info",
103
+ );
104
+ });
105
+
106
+ pi.on("session_shutdown", () => {
107
+ this.reset();
108
+ });
109
+
110
+ pi.on("before_agent_start", (event, ctx) => {
111
+ const runtime = this.resolve(ctx);
112
+ const prompt = ctx.hasUI
113
+ ? `\n\n## Command Execution\nCurrent bwrap mode: **${runtime.mode}**. The bwrap runtime selects sandboxing and, when requested, user approval for unsandboxed execution.\n`
114
+ : "\n\n## Command Execution\nThis headless session is forced into bwrap readonly mode. Unsandboxed execution cannot be approved.\n";
115
+ return { systemPrompt: event.systemPrompt + prompt };
116
+ });
117
+
118
+ this.registerCommands(pi);
119
+ }
120
+
121
+ setMode(cwd: string, mode: BwrapMode): ResolvedBwrap {
122
+ this.resolved = resolveBwrap({ ...loadBwrapConfig(cwd), mode });
123
+ this.sandboxDisabled = false;
124
+ return this.resolved;
125
+ }
126
+
127
+ reset(): void {
128
+ this.resolved = undefined;
129
+ this.sandboxDisabled = false;
130
+ }
131
+
132
+ async execute(request: BwrapExecutionRequest) {
133
+ const runtime = this.resolve(request.ctx);
134
+ if (request.requestFullAccess === true && runtime.bwrapEnabled) {
135
+ await this.approveFullAccess(request.ctx, request.command, request.requestFullAccessReason);
136
+ }
137
+ const bash =
138
+ runtime.bwrapEnabled && request.requestFullAccess !== true
139
+ ? createBashTool(request.ctx.cwd, { operations: createBwrapBashOperations(runtime) })
140
+ : createBashTool(request.ctx.cwd);
141
+ return bash.execute(
142
+ request.toolCallId,
143
+ { command: request.command, timeout: request.timeout },
144
+ request.signal,
145
+ request.onUpdate,
146
+ );
147
+ }
148
+
149
+ private resolve(ctx: Pick<ExtensionContext, "cwd" | "hasUI">): ResolvedBwrap {
150
+ const config = loadBwrapConfig(ctx.cwd);
151
+ if (!ctx.hasUI) return resolveHeadlessBwrap(config);
152
+ if (this.sandboxDisabled) return resolveBwrap({ ...config, mode: "allow-all" });
153
+ if (!this.resolved) this.resolved = resolveBwrap(config);
154
+ return this.resolved;
155
+ }
156
+
157
+ private async approveFullAccess(
158
+ ctx: ExtensionContext,
159
+ command: string,
160
+ reason: string | undefined,
161
+ ): Promise<void> {
162
+ const policy = resolveEscalation({ hasUI: ctx.hasUI });
163
+ if (policy.kind === "deny") throw new Error(policy.reason);
164
+ const description = `Allow this command to run without sandbox?\n---\n\nReason: ${escapeHtml(reason ?? "(No reason provided by model)")}\n---\n${fenceCodeBlock(command)}`;
165
+ while (true) {
166
+ const choice = await ctx.ui.select(
167
+ description,
168
+ ["Approve once", "Block", "Block with reason"],
169
+ {
170
+ signal: ctx.signal,
171
+ },
172
+ );
173
+ if (choice === undefined) {
174
+ ctx.abort();
175
+ throw new Error("User denied the command execution.");
176
+ }
177
+ if (choice === "Approve once") return;
178
+ if (choice === "Block") throw new Error("User denied unsandboxed execution.");
179
+ const feedback = await ctx.ui.input("Why was this denied?", undefined, {
180
+ signal: ctx.signal,
181
+ });
182
+ if (feedback === undefined) continue;
183
+ throw new Error(
184
+ feedback
185
+ ? `User denied unsandboxed execution: ${feedback}`
186
+ : "User denied unsandboxed execution.",
187
+ );
188
+ }
189
+ }
190
+
191
+ private registerCommands(pi: ExtensionAPI): void {
192
+ const specs = {
193
+ bwrap: {
194
+ name: "bwrap",
195
+ usage: "",
196
+ description: "Show bwrap sandbox configuration",
197
+ flags: Type.Object({}),
198
+ },
199
+ "bwrap-allow-all": {
200
+ name: "bwrap-allow-all",
201
+ usage: "",
202
+ description: "Disable bwrap sandbox, full access",
203
+ flags: Type.Object({}),
204
+ },
205
+ "bwrap-workspace-write": {
206
+ name: "bwrap-workspace-write",
207
+ usage: "",
208
+ description: "Sandbox on, network off, workspace writable",
209
+ flags: Type.Object({}),
210
+ },
211
+ "bwrap-allow-net": {
212
+ name: "bwrap-allow-net",
213
+ usage: "",
214
+ description: "Sandbox on, network on, workspace writable",
215
+ flags: Type.Object({}),
216
+ },
217
+ "bwrap-readonly": {
218
+ name: "bwrap-readonly",
219
+ usage: "",
220
+ description: "Sandbox on, network off, no writes",
221
+ flags: Type.Object({}),
222
+ },
223
+ } as const satisfies Record<string, CommandSpec<TObject>>;
224
+
225
+ pi.registerCommand("bwrap", {
226
+ description: specs.bwrap.description,
227
+ handler: (args, ctx) =>
228
+ this.runCommand(pi, specs.bwrap, args, ctx, (commandCtx) => {
229
+ const runtime = this.resolve(commandCtx);
230
+ if (!runtime.bwrapEnabled) {
231
+ commandCtx.ui.notify(`bwrap disabled (mode: ${runtime.mode})`, "info");
232
+ return;
233
+ }
234
+ const writable = runtime.writablePaths.map((path) =>
235
+ resolveBwrapPath(path, commandCtx.cwd),
236
+ );
237
+ const tmpfs = runtime.tmpfsPaths.map((path) => resolveBwrapPath(path, commandCtx.cwd));
238
+ commandCtx.ui.notify(
239
+ `bwrap ${runtime.mode} ${runtime.network ? "net" : "no-net"} write:[${writable.join(", ")}] tmpfs:[${tmpfs.join(", ") || "-"}]`,
240
+ "info",
241
+ );
242
+ }),
243
+ });
244
+
245
+ for (const [name, mode] of [
246
+ ["bwrap-allow-all", "allow-all"],
247
+ ["bwrap-workspace-write", "workspace-write"],
248
+ ["bwrap-allow-net", "allow-net"],
249
+ ["bwrap-readonly", "readonly"],
250
+ ] as const) {
251
+ pi.registerCommand(name, {
252
+ description: specs[name].description,
253
+ handler: (args, ctx) =>
254
+ this.runCommand(pi, specs[name], args, ctx, (commandCtx) =>
255
+ this.switchMode(pi, mode, commandCtx),
256
+ ),
257
+ });
258
+ }
259
+ }
260
+
261
+ private switchMode(pi: ExtensionAPI, mode: BwrapMode, ctx: ExtensionCommandContext): void {
262
+ if (!ctx.hasUI) {
263
+ ctx.ui.notify("bwrap mode cannot be changed without an interactive UI", "warning");
264
+ return;
265
+ }
266
+ this.setMode(ctx.cwd, mode);
267
+ ctx.ui.setStatus("bwrap", ctx.ui.theme.fg("accent", `bwrap: ${mode}`));
268
+ notifyMode(ctx, mode);
269
+ pi.sendMessage({
270
+ customType: "info",
271
+ content: `Bwrap sandbox mode changed to "${mode}".`,
272
+ display: true,
273
+ });
274
+ }
275
+
276
+ private runCommand(
277
+ pi: ExtensionAPI,
278
+ spec: CommandSpec<TObject>,
279
+ args: string,
280
+ ctx: ExtensionCommandContext,
281
+ run: (ctx: ExtensionCommandContext) => void | Promise<void>,
282
+ ): Promise<void> {
283
+ const parsed = parseCommand(spec, args);
284
+ if (parsed.kind !== "ok") {
285
+ pi.sendMessage({ customType: "info", content: parsed.text, display: true });
286
+ return Promise.resolve();
287
+ }
288
+ return Promise.resolve(run(ctx));
289
+ }
290
+ }
291
+
292
+ export function createBwrapRuntime(): BwrapRuntime {
293
+ return new BwrapRuntime();
294
+ }
295
+
296
+ export const bwrapRuntime = createBwrapRuntime();
@@ -1,7 +1,8 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
- import { createBashTool } from "@earendil-works/pi-coding-agent";
3
2
  import { Type } from "typebox";
4
3
 
4
+ import { bwrapRuntime } from "../bwrap/runtime.js";
5
+
5
6
  const DEFAULT_TIMEOUT_MS = 120_000;
6
7
  const MAX_TIMEOUT_MS = 600_000;
7
8
 
@@ -47,6 +48,12 @@ export function registerShellTools(pi: ExtensionAPI): void {
47
48
  description: Type.Optional(
48
49
  Type.String({ description: "Clear, concise description of the command" }),
49
50
  ),
51
+ dangerouslyDisableSandbox: Type.Optional(
52
+ Type.Boolean({
53
+ description:
54
+ "Request one-time unsandboxed execution. The user must approve this request.",
55
+ }),
56
+ ),
50
57
  },
51
58
  { additionalProperties: false },
52
59
  ),
@@ -57,13 +64,16 @@ export function registerShellTools(pi: ExtensionAPI): void {
57
64
  }
58
65
 
59
66
  const marker = `__PI_CC_CWD_${id.replaceAll("-", "_")}_${Date.now()}__`;
60
- const bash = createBashTool(persistentCwd ?? ctx.cwd);
61
67
  try {
62
- const result = await bash.execute(
63
- id,
64
- { command: wrapCommand(params.command, marker), timeout: timeout / 1000 },
68
+ const result = await bwrapRuntime.execute({
69
+ ctx: { ...ctx, cwd: persistentCwd ?? ctx.cwd },
70
+ toolCallId: id,
71
+ command: wrapCommand(params.command, marker),
72
+ timeout: timeout / 1000,
73
+ requestFullAccess: params.dangerouslyDisableSandbox,
74
+ requestFullAccessReason: params.description,
65
75
  signal,
66
- onUpdate
76
+ onUpdate: onUpdate
67
77
  ? (update) => {
68
78
  const content = update.content.map((item) => {
69
79
  if (item.type !== "text") return item;
@@ -72,7 +82,7 @@ export function registerShellTools(pi: ExtensionAPI): void {
72
82
  onUpdate({ ...update, content });
73
83
  }
74
84
  : undefined,
75
- );
85
+ });
76
86
  const content = result.content.map((item) => {
77
87
  if (item.type !== "text") return item;
78
88
  const cleaned = stripCwdMarker(item.text, marker);
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * opencode —— 统一注册 opencode 风格工具扩展。
3
3
  *
4
- * 聚合 read / edit / write / todo 四个工具,一次加载全部注册;
4
+ * 聚合 read / edit / write / todo / question 五个工具,一次加载全部注册;
5
5
  * 各工具的公开 API(匹配引擎、纯函数等)也从这里重新导出,方便
6
6
  * 测试与其他模块(如 workspace-guard)引用。
7
7
  *
@@ -15,6 +15,7 @@
15
15
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
16
16
 
17
17
  import opencodeEdit from "./edit.js";
18
+ import opencodeQuestion from "./question.js";
18
19
  import opencodeRead from "./read.js";
19
20
  import opencodeTodo from "./todo.js";
20
21
  import opencodeWrite from "./write.js";
@@ -28,6 +29,7 @@ export {
28
29
  restoreLineEndings,
29
30
  stripBom,
30
31
  } from "./edit-engine.js";
32
+ export { default as opencodeQuestion } from "./question.js";
31
33
  export { default as opencodeRead, truncateHead, type TruncationResult } from "./read.js";
32
34
  export { default as opencodeTodo } from "./todo.js";
33
35
  export { default as opencodeWrite, resolveBom } from "./write.js";
@@ -37,4 +39,5 @@ export default function opencode(pi: ExtensionAPI) {
37
39
  opencodeEdit(pi);
38
40
  opencodeWrite(pi);
39
41
  opencodeTodo(pi);
42
+ opencodeQuestion(pi);
40
43
  }
File without changes