@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 +3 -1
- package/package.json +1 -1
- package/src/bwrap/core.ts +81 -8
- package/src/bwrap/runtime.ts +19 -4
package/README.md
CHANGED
|
@@ -52,7 +52,9 @@ bash 工具(opencode 风格 `bash`、Claude Code 风格 `Bash`)注册了 `da
|
|
|
52
52
|
|
|
53
53
|
### 保护目录
|
|
54
54
|
|
|
55
|
-
`.
|
|
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
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 = [".
|
|
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
|
-
|
|
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
|
-
|
|
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",
|
package/src/bwrap/runtime.ts
CHANGED
|
@@ -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,
|