@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.
package/README.md CHANGED
@@ -1,4 +1,6 @@
1
- # pi-extensions
1
+ # Some of my personal pi extensions
2
+
3
+ 你可以参考本仓库的实现,但不要直接使用:这是我个人自用的扩展集,我会随意做 breaking change,不承诺向后兼容。
2
4
 
3
5
  [pi](https://github.com/earendil-works/pi-mono) coding-agent 自定义扩展集合。
4
6
 
@@ -241,7 +243,7 @@ opencode 风格的提问工具,参数与语义和 opencode 的 [`question`](ht
241
243
  ### 使用
242
244
 
243
245
  ```bash
244
- pi -e ./src/question.ts
246
+ pi -e ./src/opencode/question.ts
245
247
  ```
246
248
 
247
249
  ---
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trim21/personal-pi-extensions",
3
- "version": "0.0.217",
3
+ "version": "0.0.222",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
@@ -62,7 +62,6 @@
62
62
  "src/bash-default-timeout.ts",
63
63
  "src/gh-readonly.ts",
64
64
  "src/spawn-agent.ts",
65
- "src/question.ts",
66
65
  "src/talk/index.ts"
67
66
  ],
68
67
  "skills": [
@@ -0,0 +1,279 @@
1
+ import type { ChildProcess } from "node:child_process";
2
+ import { spawn } from "node:child_process";
3
+ import { constants } from "node:fs";
4
+ import { closeSync, existsSync, openSync, readFileSync } from "node:fs";
5
+ import { access as fsAccess } from "node:fs/promises";
6
+ import { delimiter, join } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+
9
+ import { StringEnum } from "@earendil-works/pi-ai";
10
+ import { type BashOperations, getAgentDir } from "@earendil-works/pi-coding-agent";
11
+ import { type Static, Type } from "typebox";
12
+ import { Value } from "typebox/value";
13
+
14
+ import { expandHome } from "../lib/path.js";
15
+
16
+ const PROTECTED_DIRS = [".git", ".pi", ".agent"];
17
+
18
+ export const BWRAP_MODES = ["allow-all", "workspace-write", "allow-net", "readonly"] as const;
19
+
20
+ export type BwrapMode = (typeof BWRAP_MODES)[number];
21
+
22
+ const bwrapConfigProperties = {
23
+ mode: StringEnum(BWRAP_MODES),
24
+ bwrapPath: Type.Optional(Type.String()),
25
+ writablePaths: Type.Array(Type.String()),
26
+ extraWritablePaths: Type.Array(Type.String()),
27
+ tmpfsPaths: Type.Array(Type.String()),
28
+ extraArgs: Type.Array(Type.String()),
29
+ };
30
+
31
+ export const bwrapConfigSchema = Type.Object(bwrapConfigProperties, {
32
+ additionalProperties: false,
33
+ });
34
+
35
+ export const bwrapConfigFileSchema = Type.Partial(bwrapConfigSchema, {
36
+ additionalProperties: false,
37
+ });
38
+
39
+ export type BwrapConfig = Static<typeof bwrapConfigSchema>;
40
+ export type BwrapConfigFile = Static<typeof bwrapConfigFileSchema>;
41
+
42
+ export interface ResolvedBwrap {
43
+ mode: BwrapMode;
44
+ bwrapEnabled: boolean;
45
+ network: boolean;
46
+ bwrapPath?: string;
47
+ writablePaths: string[];
48
+ extraWritablePaths: string[];
49
+ tmpfsPaths: string[];
50
+ extraArgs: string[];
51
+ }
52
+
53
+ const DEFAULT_CONFIG: BwrapConfig = {
54
+ mode: "workspace-write",
55
+ writablePaths: [".", "/tmp"],
56
+ extraWritablePaths: [],
57
+ tmpfsPaths: [],
58
+ extraArgs: [],
59
+ };
60
+
61
+ export function resolveBwrap(config: BwrapConfig): ResolvedBwrap {
62
+ const base = {
63
+ mode: config.mode,
64
+ bwrapPath: config.bwrapPath,
65
+ writablePaths: config.writablePaths ?? [".", "/tmp"],
66
+ extraWritablePaths: config.extraWritablePaths,
67
+ tmpfsPaths: config.tmpfsPaths ?? [],
68
+ extraArgs: config.extraArgs ?? [],
69
+ };
70
+ switch (config.mode) {
71
+ case "allow-all": {
72
+ return { ...base, bwrapEnabled: false, network: true };
73
+ }
74
+ case "workspace-write": {
75
+ return { ...base, bwrapEnabled: true, network: false };
76
+ }
77
+ case "allow-net": {
78
+ return { ...base, bwrapEnabled: true, network: true };
79
+ }
80
+ case "readonly": {
81
+ return { ...base, bwrapEnabled: true, network: false, writablePaths: [] };
82
+ }
83
+ }
84
+ }
85
+
86
+ export function resolveHeadlessBwrap(config: BwrapConfig): ResolvedBwrap {
87
+ return resolveBwrap({
88
+ ...config,
89
+ mode: "readonly",
90
+ writablePaths: [],
91
+ extraWritablePaths: [],
92
+ tmpfsPaths: [],
93
+ extraArgs: [],
94
+ });
95
+ }
96
+
97
+ function deepMerge(base: BwrapConfig, overrides: Partial<BwrapConfig>): BwrapConfig {
98
+ return {
99
+ mode: overrides.mode ?? base.mode,
100
+ bwrapPath: overrides.bwrapPath ?? base.bwrapPath,
101
+ writablePaths: overrides.writablePaths ?? base.writablePaths,
102
+ extraWritablePaths: [...base.extraWritablePaths, ...(overrides.extraWritablePaths ?? [])],
103
+ tmpfsPaths: overrides.tmpfsPaths ?? base.tmpfsPaths,
104
+ extraArgs: overrides.extraArgs ?? base.extraArgs,
105
+ };
106
+ }
107
+
108
+ function parseBwrapConfigFile(path: string): BwrapConfigFile {
109
+ let raw: unknown;
110
+ try {
111
+ raw = JSON.parse(readFileSync(path, "utf8"));
112
+ } catch (error) {
113
+ throw new Error(`Invalid bwrap configuration at ${path}: ${String(error)}`, { cause: error });
114
+ }
115
+ try {
116
+ return Value.Parse(bwrapConfigFileSchema, raw);
117
+ } catch (error) {
118
+ throw new Error(`Invalid bwrap configuration at ${path}: ${String(error)}`, { cause: error });
119
+ }
120
+ }
121
+
122
+ export interface BwrapConfigPaths {
123
+ global: string;
124
+ project: string;
125
+ }
126
+
127
+ export function getBwrapConfigPaths(cwd: string): BwrapConfigPaths {
128
+ return {
129
+ global: join(getAgentDir(), "extensions", "bwrap.json"),
130
+ project: join(cwd, ".pi", "bwrap.json"),
131
+ };
132
+ }
133
+
134
+ export function loadBwrapConfig(cwd: string, paths = getBwrapConfigPaths(cwd)): BwrapConfig {
135
+ let config = DEFAULT_CONFIG;
136
+ for (const path of [paths.global, paths.project]) {
137
+ if (!existsSync(path)) continue;
138
+ config = deepMerge(config, parseBwrapConfigFile(path));
139
+ }
140
+ return Value.Parse(bwrapConfigSchema, config);
141
+ }
142
+
143
+ export function resolveBwrapPath(path: string, cwd: string): string {
144
+ const expanded = expandHome(path);
145
+ return expanded === "." ? cwd : expanded;
146
+ }
147
+
148
+ function findDefaultBwrap(): string {
149
+ const pathEnv = process.env.PATH ?? "";
150
+ for (const directory of pathEnv.split(delimiter)) {
151
+ const candidate = join(directory, "bwrap");
152
+ if (existsSync(candidate)) return candidate;
153
+ }
154
+ for (const candidate of [
155
+ "/usr/bin/bwrap",
156
+ "/usr/local/bin/bwrap",
157
+ "/run/current-system/sw/bin/bwrap",
158
+ ]) {
159
+ if (existsSync(candidate)) return candidate;
160
+ }
161
+ throw new Error(
162
+ "bwrap (bubblewrap) not found in PATH. Install it:\n" +
163
+ " apt install bubblewrap (Debian/Ubuntu)\n" +
164
+ " pacman -S bubblewrap (Arch)\n" +
165
+ " dnf install bubblewrap (Fedora)",
166
+ );
167
+ }
168
+
169
+ export function findBwrap(override?: string): string {
170
+ return override ?? findDefaultBwrap();
171
+ }
172
+
173
+ export function buildBwrapArgs(resolved: ResolvedBwrap, cwd: string): string[] {
174
+ const args = ["--new-session", "--die-with-parent", "--unshare-user", "--unshare-pid"];
175
+ for (const path of resolved.writablePaths) {
176
+ const absolutePath = resolveBwrapPath(path, cwd);
177
+ args.push("--bind", absolutePath, absolutePath);
178
+ }
179
+ for (const path of resolved.extraWritablePaths) {
180
+ const absolutePath = resolveBwrapPath(path, cwd);
181
+ args.push("--bind", absolutePath, absolutePath);
182
+ }
183
+ for (const path of resolved.tmpfsPaths) {
184
+ args.push("--tmpfs", resolveBwrapPath(path, cwd));
185
+ }
186
+ if (!resolved.network) args.push("--unshare-net");
187
+ for (const name of PROTECTED_DIRS) {
188
+ const absolutePath = join(cwd, name);
189
+ if (existsSync(absolutePath)) args.push("--ro-bind", absolutePath, absolutePath);
190
+ }
191
+ args.push(...resolved.extraArgs);
192
+ return args;
193
+ }
194
+
195
+ const SECCOMP_BPF_FILE = (() => {
196
+ const directory = fileURLToPath(new URL(".", import.meta.url));
197
+ if (process.arch === "x64") return join(directory, "seccomp-x86_64.bpf");
198
+ if (process.arch === "arm64") return join(directory, "seccomp-aarch64.bpf");
199
+ return "";
200
+ })();
201
+
202
+ function getSeccompFd(): number | undefined {
203
+ if (!SECCOMP_BPF_FILE) return undefined;
204
+ try {
205
+ return openSync(SECCOMP_BPF_FILE, "r");
206
+ } catch {
207
+ return undefined;
208
+ }
209
+ }
210
+
211
+ function killChild(child: ChildProcess): void {
212
+ if (!child.pid) return;
213
+ try {
214
+ process.kill(-child.pid, "SIGKILL");
215
+ } catch {
216
+ child.kill("SIGKILL");
217
+ }
218
+ }
219
+
220
+ export function createBwrapBashOperations(resolved: ResolvedBwrap): BashOperations {
221
+ return {
222
+ async exec(command, cwd, { onData, signal, timeout }) {
223
+ await fsAccess(cwd, constants.F_OK).catch(() => {
224
+ throw new Error(`Working directory does not exist: ${cwd}\nCannot execute bash commands.`);
225
+ });
226
+ if (signal?.aborted) throw new Error("aborted");
227
+
228
+ const seccompFd = resolved.network ? undefined : getSeccompFd();
229
+ const baseArgs = [
230
+ "--ro-bind",
231
+ "/",
232
+ "/",
233
+ ...buildBwrapArgs(resolved, cwd),
234
+ "--dev",
235
+ "/dev",
236
+ "--proc",
237
+ "/proc",
238
+ ];
239
+ const child = spawn(
240
+ findBwrap(resolved.bwrapPath),
241
+ seccompFd === undefined
242
+ ? [...baseArgs, "--", "bash", "-c", command]
243
+ : [...baseArgs, "--seccomp", "3", "--", "bash", "-c", command],
244
+ {
245
+ cwd,
246
+ detached: true,
247
+ stdio:
248
+ seccompFd === undefined
249
+ ? ["ignore", "pipe", "pipe"]
250
+ : ["ignore", "pipe", "pipe", seccompFd],
251
+ env: process.env,
252
+ },
253
+ );
254
+
255
+ return new Promise<{ exitCode: number | null }>((resolve, reject) => {
256
+ let timedOut = false;
257
+ const timeoutHandle = timeout
258
+ ? setTimeout(() => {
259
+ timedOut = true;
260
+ killChild(child);
261
+ }, timeout * 1000)
262
+ : undefined;
263
+ const onAbort = () => killChild(child);
264
+ child.stdout?.on("data", onData);
265
+ child.stderr?.on("data", onData);
266
+ signal?.addEventListener("abort", onAbort, { once: true });
267
+ child.once("error", reject);
268
+ child.once("close", (exitCode) => {
269
+ if (timeoutHandle) clearTimeout(timeoutHandle);
270
+ signal?.removeEventListener("abort", onAbort);
271
+ if (seccompFd !== undefined) closeSync(seccompFd);
272
+ if (signal?.aborted) reject(new Error("aborted"));
273
+ else if (timedOut) reject(new Error(`timeout:${timeout}`));
274
+ else resolve({ exitCode });
275
+ });
276
+ });
277
+ },
278
+ };
279
+ }