@trim21/personal-pi-extensions 0.0.398 → 0.0.400

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
@@ -52,7 +52,9 @@ bash 工具(opencode 风格 `bash`、Claude Code 风格 `Bash`)注册了 `da
52
52
 
53
53
  ### 保护目录
54
54
 
55
- `.git`、`.pi`、`.agent` 即使在 `workspace-write` 模式下也始终只读。
55
+ `.pi`、`.agent` 即使在 `workspace-write` 模式下也始终只读;`.git` 同样只读:工作区根是 git 仓库时保护根 `.git`,根不是 git 仓库时才递归扫描嵌套仓库(monorepo 子仓库,跳过 `node_modules`、`.venv` 等包目录)。
56
+
57
+ 可写与保护路径均使用 bwrap 的 `--*-bind-try` 变体:路径不存在时自动忽略该项,而不是让整条命令失败。
56
58
 
57
59
  ### 运行时命令
58
60
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trim21/personal-pi-extensions",
3
- "version": "0.0.398",
3
+ "version": "0.0.400",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
package/src/bwrap/core.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { type ChildProcess, spawn } from "node:child_process";
2
- import { closeSync, constants, existsSync, openSync, readFileSync } from "node:fs";
3
- import { access as fsAccess } from "node:fs/promises";
2
+ import { closeSync, constants, type Dirent, existsSync, openSync, readFileSync } from "node:fs";
3
+ import { access as fsAccess, readdir, stat } from "node:fs/promises";
4
4
  import { delimiter, join } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
 
@@ -12,7 +12,7 @@ import { Value } from "typebox/value";
12
12
  import { expandHome } from "../lib/path.js";
13
13
  import { type ApprovalRule } from "./approval-rules.js";
14
14
 
15
- const PROTECTED_DIRS = [".git", ".pi", ".agent"];
15
+ const PROTECTED_DIRS = [".pi", ".agent"];
16
16
 
17
17
  export const BWRAP_MODES = ["allow-all", "workspace-write", "allow-net", "readonly"] as const;
18
18
 
@@ -190,23 +190,96 @@ export function findBwrap(override?: string): string {
190
190
  return findDefaultBwrap();
191
191
  }
192
192
 
193
- export function buildBwrapArgs(resolved: ResolvedBwrap, cwd: string): string[] {
193
+ /** 扫描 .git 时跳过的目录:包/依赖/构建产物,嵌套 git 仓库几乎不会出现在这里。 */
194
+ const GIT_DIR_SCAN_SKIP = new Set([
195
+ "node_modules",
196
+ ".venv",
197
+ "venv",
198
+ ".venvs",
199
+ "dist",
200
+ "build",
201
+ "target",
202
+ "__pycache__",
203
+ ".next",
204
+ ".cache",
205
+ ".turbo",
206
+ ".pytest_cache",
207
+ ".mypy_cache",
208
+ ".hg",
209
+ ".svn",
210
+ ]);
211
+
212
+ /** 最多收集的 .git 数量与扫描深度,防止异常大的工作区拖慢每条命令。 */
213
+ const MAX_GIT_DIRS = 64;
214
+ const MAX_GIT_SCAN_DEPTH = 24;
215
+
216
+ class ScanLimitError extends Error {}
217
+
218
+ /**
219
+ * 递归扫描工作区,收集所有 `.git` 目录的绝对路径(monorepo / 嵌套仓库)。
220
+ * 达到数量上限立即停止整棵遍历;跳过 symlink(防循环)与无法读取的目录。
221
+ */
222
+ export async function findGitDirs(root: string): Promise<string[]> {
223
+ const found: string[] = [];
224
+ const walk = async (dir: string, depth: number): Promise<void> => {
225
+ if (depth > MAX_GIT_SCAN_DEPTH) return;
226
+ let entries: Dirent[];
227
+ try {
228
+ entries = await readdir(dir, { withFileTypes: true });
229
+ } catch {
230
+ return;
231
+ }
232
+ for (const entry of entries) {
233
+ if (found.length >= MAX_GIT_DIRS) throw new ScanLimitError();
234
+ if (entry.name === ".git" && entry.isDirectory()) {
235
+ found.push(join(dir, ".git"));
236
+ continue;
237
+ }
238
+ if (!entry.isDirectory() || GIT_DIR_SCAN_SKIP.has(entry.name)) continue;
239
+ await walk(join(dir, entry.name), depth + 1);
240
+ }
241
+ };
242
+ try {
243
+ await walk(root, 0);
244
+ } catch (error) {
245
+ if (!(error instanceof ScanLimitError)) throw error;
246
+ }
247
+ return found;
248
+ }
249
+
250
+ export async function buildBwrapArgs(resolved: ResolvedBwrap, cwd: string): Promise<string[]> {
194
251
  const args = ["--new-session", "--die-with-parent", "--unshare-user", "--unshare-pid"];
252
+ // --*-bind-try:配置的路径不存在时忽略该项而不是让整条命令失败
195
253
  for (const path of resolved.writablePaths) {
196
254
  const absolutePath = resolveBwrapPath(path, cwd);
197
- args.push("--bind", absolutePath, absolutePath);
255
+ args.push("--bind-try", absolutePath, absolutePath);
198
256
  }
199
257
  for (const path of resolved.extraWritablePaths) {
200
258
  const absolutePath = resolveBwrapPath(path, cwd);
201
- args.push("--bind", absolutePath, absolutePath);
259
+ args.push("--bind-try", absolutePath, absolutePath);
202
260
  }
203
261
  for (const path of resolved.tmpfsPaths) {
204
262
  args.push("--tmpfs", resolveBwrapPath(path, cwd));
205
263
  }
206
264
  if (!resolved.network) args.push("--unshare-net");
265
+ // --ro-bind-try:目录不存在(或已被删除)时自动忽略
207
266
  for (const name of PROTECTED_DIRS) {
208
267
  const absolutePath = join(cwd, name);
209
- if (existsSync(absolutePath)) args.push("--ro-bind", absolutePath, absolutePath);
268
+ args.push("--ro-bind-try", absolutePath, absolutePath);
269
+ }
270
+ // 工作区下所有 .git 一律只读:可写 bind 之上的覆盖绑定,防止命令篡改仓库元数据。
271
+ // 根目录本身是 git 仓库时只保护根 .git(递归扫描有成本,绝大多数情况根即唯一仓库);
272
+ // 根不是 git 仓库时才递归扫描嵌套仓库(如 monorepo 子仓库)。
273
+ const rootGit = join(cwd, ".git");
274
+ let gitDirs: string[];
275
+ try {
276
+ await stat(rootGit);
277
+ gitDirs = [rootGit];
278
+ } catch {
279
+ gitDirs = await findGitDirs(cwd);
280
+ }
281
+ for (const gitDir of gitDirs) {
282
+ args.push("--ro-bind-try", gitDir, gitDir);
210
283
  }
211
284
  args.push(...resolved.extraArgs);
212
285
  return args;
@@ -265,7 +338,7 @@ export function createBwrapBashOperations(
265
338
  "--ro-bind",
266
339
  "/",
267
340
  "/",
268
- ...buildBwrapArgs(resolved, workspace),
341
+ ...(await buildBwrapArgs(resolved, workspace)),
269
342
  "--dev",
270
343
  "/dev",
271
344
  "--proc",
@@ -1,6 +1,6 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { createWriteStream, existsSync, mkdirSync, readFileSync, type WriteStream } from "node:fs";
3
- import { mkdir, writeFile } from "node:fs/promises";
3
+ import { mkdir, unlink, writeFile } from "node:fs/promises";
4
4
  import { dirname, join } from "node:path";
5
5
 
6
6
  import {
@@ -141,7 +141,7 @@ function countNewlines(data: Buffer): number {
141
141
 
142
142
  /**
143
143
  * 合并 stdout/stderr 的流式输出累积器:输出在运行时就直接写入
144
- * agent-dir/tmp/{uuid}.txt(完整内容),内存只保留尾部缓冲。
144
+ * agent-dir/tmp/{sessionId}/{uuid}.txt(完整内容),内存只保留尾部缓冲。
145
145
  * 大输出不会撑爆内存;最终结果只返回截断后的文本。
146
146
  */
147
147
  class BashOutput {
@@ -151,13 +151,18 @@ class BashOutput {
151
151
  private tailBytes = 0;
152
152
  private totalBytes = 0;
153
153
  private totalLines = 0;
154
+ private readonly sessionId: string;
154
155
  filePath: string | undefined;
155
156
 
157
+ constructor(sessionId: string) {
158
+ this.sessionId = sessionId;
159
+ }
160
+
156
161
  append(data: Buffer): void {
157
162
  this.totalBytes += data.length;
158
163
  this.totalLines += countNewlines(data);
159
164
  if (!this.stream) {
160
- const dir = join(getAgentDir(), "tmp");
165
+ const dir = join(getAgentDir(), "tmp", this.sessionId);
161
166
  mkdirSync(dir, { recursive: true });
162
167
  this.filePath = join(dir, `${randomUUID()}.txt`);
163
168
  this.stream = createWriteStream(this.filePath, { flags: "w" });
@@ -361,7 +366,7 @@ export class BwrapRuntime {
361
366
  isWindows || needsApproval || !runtime.bwrapEnabled
362
367
  ? createLocalBashOperations()
363
368
  : createBwrapBashOperations(runtime, workspace);
364
- const output = new BashOutput();
369
+ const output = new BashOutput(request.ctx.sessionManager.getSessionId());
365
370
  const { onUpdate } = request;
366
371
 
367
372
  // 流式进度:节流推送尾部快照(对齐 pi 内置 bash 的实时输出体验)
@@ -406,6 +411,16 @@ export class BwrapRuntime {
406
411
  });
407
412
  await output.close();
408
413
  const truncation = truncateTail(output.tailText());
414
+ // 未截断:完整输出已直接返回给模型,临时文件没有用途,删掉避免
415
+ // agent-dir/tmp 堆积无主文件(删除失败只残留文件,不影响命令结果)
416
+ if (!truncation.truncated && output.filePath) {
417
+ try {
418
+ await unlink(output.filePath);
419
+ } catch {
420
+ // 删除失败(如沙箱只读):best-effort,命令结果不受影响
421
+ }
422
+ output.filePath = undefined;
423
+ }
409
424
  return {
410
425
  exitCode,
411
426
  output: truncation.content,