@trim21/personal-pi-extensions 0.0.271 → 0.0.273
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/package.json +1 -1
- package/src/bwrap/runtime.ts +66 -18
- package/src/gh-readonly.ts +21 -24
- package/src/lib/seq-state.ts +34 -0
- package/src/lib/write-guard.ts +10 -0
- package/src/spawn-agent.ts +10 -0
package/package.json
CHANGED
package/src/bwrap/runtime.ts
CHANGED
|
@@ -224,6 +224,12 @@ export class BwrapRuntime {
|
|
|
224
224
|
this.sandboxDisabled = pi.getFlag("no-bwrap") === true && ctx.hasUI;
|
|
225
225
|
this.resolved = undefined;
|
|
226
226
|
this.bwrapUnavailable = false;
|
|
227
|
+
if (process.platform === "win32") {
|
|
228
|
+
// Windows 没有 bubblewrap:不做 bwrap 检测、不显示 bwrap 状态,
|
|
229
|
+
// 每条 bash 命令在 execute 时逐条人工审批,模型无需知道 bwrap 的存在。
|
|
230
|
+
ctx.ui.notify("Every bash command requires user approval before it runs.", "info");
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
227
233
|
const runtime = this.resolve(ctx);
|
|
228
234
|
if (runtime.bwrapEnabled) {
|
|
229
235
|
try {
|
|
@@ -254,12 +260,18 @@ export class BwrapRuntime {
|
|
|
254
260
|
|
|
255
261
|
pi.on("before_agent_start", (event, ctx) => {
|
|
256
262
|
const runtime = this.resolve(ctx);
|
|
263
|
+
const isWindows = process.platform === "win32";
|
|
257
264
|
const modeText = ctx.hasUI
|
|
258
|
-
?
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
265
|
+
? isWindows
|
|
266
|
+
? "Every bash command requires user approval before it runs."
|
|
267
|
+
: `Current bwrap mode: **${runtime.mode}**. The bwrap runtime selects sandboxing and, when requested, user approval for unsandboxed execution.`
|
|
268
|
+
: isWindows
|
|
269
|
+
? "This headless session cannot approve commands: bash commands are refused."
|
|
270
|
+
: "This headless session is forced into bwrap readonly mode. Unsandboxed execution cannot be approved.";
|
|
271
|
+
const unavailableText =
|
|
272
|
+
!isWindows && this.bwrapUnavailable
|
|
273
|
+
? " bwrap is unavailable (binary not found): bash commands are refused unless the user explicitly approves unsandboxed execution."
|
|
274
|
+
: "";
|
|
263
275
|
return {
|
|
264
276
|
systemPrompt:
|
|
265
277
|
event.systemPrompt + `\n\n## Command Execution\n${modeText}${unavailableText}\n`,
|
|
@@ -283,13 +295,24 @@ export class BwrapRuntime {
|
|
|
283
295
|
|
|
284
296
|
async execute(request: BwrapExecutionRequest): Promise<BwrapExecutionResult> {
|
|
285
297
|
const runtime = this.resolve(request.ctx);
|
|
286
|
-
|
|
298
|
+
const isWindows = process.platform === "win32";
|
|
299
|
+
// 非 Windows:bwrap 缺失时 fail closed,普通命令一律拒绝(除非显式 full-access 审批)。
|
|
300
|
+
// Windows:没有 bubblewrap,这是预期状态,降级为每条命令都走人工审核。
|
|
301
|
+
if (
|
|
302
|
+
!isWindows &&
|
|
303
|
+
this.bwrapUnavailable &&
|
|
304
|
+
runtime.bwrapEnabled &&
|
|
305
|
+
request.requestFullAccess !== true
|
|
306
|
+
) {
|
|
287
307
|
throw new Error(
|
|
288
308
|
"bwrap (bubblewrap) not found; refusing to execute commands without sandboxing. " +
|
|
289
309
|
"Install bubblewrap and restart the session, or pass --no-bwrap to disable the sandbox explicitly.",
|
|
290
310
|
);
|
|
291
311
|
}
|
|
292
|
-
|
|
312
|
+
// 需要人工审批:非 Windows 仅 requestFullAccess;Windows 上默认所有命令
|
|
313
|
+
// (allow-all 模式是显式 opt-out,仍直接执行)。
|
|
314
|
+
const needsApproval = request.requestFullAccess === true || (isWindows && runtime.bwrapEnabled);
|
|
315
|
+
if (needsApproval && runtime.bwrapEnabled) {
|
|
293
316
|
// 先按 approvalRules 自动判定:allow 直接放行,deny 直接拒绝,未命中才弹框
|
|
294
317
|
const decision = await evaluateBashApproval(request.command, runtime.approvalRules);
|
|
295
318
|
if (decision === "deny") {
|
|
@@ -300,9 +323,9 @@ export class BwrapRuntime {
|
|
|
300
323
|
}
|
|
301
324
|
}
|
|
302
325
|
const operations =
|
|
303
|
-
|
|
304
|
-
?
|
|
305
|
-
:
|
|
326
|
+
isWindows || needsApproval || !runtime.bwrapEnabled
|
|
327
|
+
? createLocalBashOperations()
|
|
328
|
+
: createBwrapBashOperations(runtime);
|
|
306
329
|
const output = new BashOutput();
|
|
307
330
|
const { onUpdate } = request;
|
|
308
331
|
|
|
@@ -389,15 +412,36 @@ export class BwrapRuntime {
|
|
|
389
412
|
): Promise<void> {
|
|
390
413
|
const policy = resolveEscalation({ hasUI: ctx.hasUI });
|
|
391
414
|
if (policy.kind === "deny") throw new Error(policy.reason);
|
|
415
|
+
// 弹框前解析命令的持久化规则(Allow forever 会写入),在弹框里预览给
|
|
416
|
+
// 用户:`echo 1` → `echo *`,避免用户对"永久允许"持久化什么一无所知。
|
|
417
|
+
const patterns = await commandPatternsFor(command);
|
|
392
418
|
// dcg 扫描建议是可选的参考文本:未安装时静默跳过;已安装但扫描失败
|
|
393
419
|
// 时 notify 提示,弹窗本身与无 dcg 时一致
|
|
394
420
|
const outcome = await dcgSuggestion(command);
|
|
395
|
-
const suggestionBlock =
|
|
396
|
-
outcome.kind === "suggestion" ? `\n${outcome.suggestion.text}\n---\n` : "";
|
|
397
421
|
if (outcome.kind === "failed") {
|
|
398
422
|
ctx.ui.notify(`dcg 扫描失败,本次无破坏性命令建议: ${outcome.detail}`, "warning");
|
|
399
423
|
}
|
|
400
|
-
|
|
424
|
+
// 弹框主体按行组织('\n' join),便于 review;suggestion 与 forever 预览
|
|
425
|
+
// 块带前导空行 + 尾部 "---" 分隔,输出与历史逐字符一致。
|
|
426
|
+
const lines: string[] = [
|
|
427
|
+
"Allow this command to run without sandbox?",
|
|
428
|
+
"---",
|
|
429
|
+
"",
|
|
430
|
+
`Reason: ${escapeHtml(reason ?? "(No reason provided by model)")}`,
|
|
431
|
+
"---",
|
|
432
|
+
];
|
|
433
|
+
if (outcome.kind === "suggestion") {
|
|
434
|
+
lines.push("", outcome.suggestion.text, "---");
|
|
435
|
+
}
|
|
436
|
+
if (patterns.length > 0) {
|
|
437
|
+
lines.push(
|
|
438
|
+
"",
|
|
439
|
+
`Allow forever 将持久化规则: ${patterns.map((pattern) => `\`${pattern}\``).join(", ")}`,
|
|
440
|
+
"---",
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
lines.push(fenceCodeBlock(command));
|
|
444
|
+
const description = lines.join("\n");
|
|
401
445
|
|
|
402
446
|
// 单选:允许一次 / 永久允许(写入规则)/ 拒绝 / 拒绝并附理由(弹输入框)
|
|
403
447
|
const verdict = await selectWithOptionalInput(description, FULL_ACCESS_CHOICES, ctx.ui, {
|
|
@@ -413,7 +457,7 @@ export class BwrapRuntime {
|
|
|
413
457
|
return;
|
|
414
458
|
}
|
|
415
459
|
case ALLOW_FOREVER: {
|
|
416
|
-
await this.persistAllowRule(ctx, command);
|
|
460
|
+
await this.persistAllowRule(ctx, command, patterns);
|
|
417
461
|
return;
|
|
418
462
|
}
|
|
419
463
|
case DENY: {
|
|
@@ -431,10 +475,14 @@ export class BwrapRuntime {
|
|
|
431
475
|
}
|
|
432
476
|
|
|
433
477
|
/** 把命令的权限模式写入项目 bwrap.json 的 approvalRules(allow forever)。 */
|
|
434
|
-
private async persistAllowRule(
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
478
|
+
private async persistAllowRule(
|
|
479
|
+
ctx: ExtensionContext,
|
|
480
|
+
command: string,
|
|
481
|
+
patterns?: string[],
|
|
482
|
+
): Promise<void> {
|
|
483
|
+
const rulePatterns = patterns ?? (await commandPatternsFor(command));
|
|
484
|
+
if (rulePatterns.length === 0) return; // 解析失败:本次放行,不写规则
|
|
485
|
+
const newRules: ApprovalRule[] = rulePatterns.map((pattern) => ({ action: "allow", pattern }));
|
|
438
486
|
const { project } = getBwrapConfigPaths(ctx.cwd);
|
|
439
487
|
let config: Record<string, unknown> = {};
|
|
440
488
|
if (existsSync(project)) {
|
package/src/gh-readonly.ts
CHANGED
|
@@ -34,10 +34,12 @@ import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
|
34
34
|
import { homedir } from "node:os";
|
|
35
35
|
import { delimiter, dirname, join, resolve } from "node:path";
|
|
36
36
|
|
|
37
|
-
import {
|
|
37
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
38
38
|
import { Type } from "typebox";
|
|
39
39
|
import { Value } from "typebox/value";
|
|
40
40
|
|
|
41
|
+
import { createSeqState } from "./lib/seq-state.js";
|
|
42
|
+
|
|
41
43
|
interface GhResult {
|
|
42
44
|
stdout: string;
|
|
43
45
|
stderr: string;
|
|
@@ -380,8 +382,9 @@ export function stepsDetail(
|
|
|
380
382
|
}));
|
|
381
383
|
}
|
|
382
384
|
|
|
383
|
-
|
|
384
|
-
|
|
385
|
+
// 模块级串行状态:同一资源(如 CI 日志)的请求排队执行,配合函数内部的
|
|
386
|
+
// 缓存检查避免重复网络请求。闭包状态不与其他扩展共享,key 无需全局前缀。
|
|
387
|
+
const seq = createSeqState();
|
|
385
388
|
|
|
386
389
|
async function getJobLog(
|
|
387
390
|
runId: string,
|
|
@@ -393,13 +396,10 @@ async function getJobLog(
|
|
|
393
396
|
): Promise<string> {
|
|
394
397
|
const cacheDir = join(homedir(), ".cache", "pi", "ci-logs", runId);
|
|
395
398
|
const cacheFile = join(cacheDir, `${jobId}.log`);
|
|
396
|
-
const key = `${runId}:${jobId}`;
|
|
397
|
-
|
|
398
|
-
// Check in-flight dedup map
|
|
399
|
-
const inflight = inflightLogs.get(key);
|
|
400
|
-
if (inflight) return inflight;
|
|
401
399
|
|
|
402
|
-
|
|
400
|
+
// 同一 runId:jobId 的请求串行执行:后一个进入时缓存已写入,直接命中缓存,
|
|
401
|
+
// 不会重复发网络请求;串行也保证不会有两个并发写同一 cache 文件。
|
|
402
|
+
return seq.execute(`${runId}:${jobId}`, async () => {
|
|
403
403
|
// Check file cache
|
|
404
404
|
try {
|
|
405
405
|
return await readFile(cacheFile, "utf8");
|
|
@@ -424,20 +424,10 @@ async function getJobLog(
|
|
|
424
424
|
|
|
425
425
|
// Write to cache
|
|
426
426
|
await mkdir(cacheDir, { recursive: true });
|
|
427
|
-
await
|
|
428
|
-
await writeFile(cacheFile, log);
|
|
429
|
-
});
|
|
427
|
+
await writeFile(cacheFile, log);
|
|
430
428
|
|
|
431
429
|
return log;
|
|
432
|
-
};
|
|
433
|
-
|
|
434
|
-
const promise = fetchAndCache();
|
|
435
|
-
inflightLogs.set(key, promise);
|
|
436
|
-
try {
|
|
437
|
-
return await promise;
|
|
438
|
-
} finally {
|
|
439
|
-
inflightLogs.delete(key);
|
|
440
|
-
}
|
|
430
|
+
});
|
|
441
431
|
}
|
|
442
432
|
|
|
443
433
|
async function resolveRepo(
|
|
@@ -1061,9 +1051,7 @@ export async function writeLogFile(
|
|
|
1061
1051
|
|
|
1062
1052
|
const target = resolve(cwd ?? process.cwd(), outputFile);
|
|
1063
1053
|
await mkdir(dirname(target), { recursive: true });
|
|
1064
|
-
await
|
|
1065
|
-
await writeFile(target, content);
|
|
1066
|
-
});
|
|
1054
|
+
await writeFile(target, content);
|
|
1067
1055
|
|
|
1068
1056
|
const lines = content.split("\n").length;
|
|
1069
1057
|
const bytes = Buffer.byteLength(content, "utf8");
|
|
@@ -1098,6 +1086,15 @@ export async function writeLogFile(
|
|
|
1098
1086
|
// ── tools ────────────────────────────────────────────────────────────────────
|
|
1099
1087
|
|
|
1100
1088
|
export default function ghReadonlyTools(pi: ExtensionAPI) {
|
|
1089
|
+
// Windows 上禁用:gh 可执行文件的探测(无扩展名 + POSIX 路径)与进程
|
|
1090
|
+
// 管理(SIGTERM 信号语义)都是 POSIX 假设,不做 Windows 适配。
|
|
1091
|
+
if (process.platform === "win32") {
|
|
1092
|
+
pi.on("session_start", (_event, ctx) => {
|
|
1093
|
+
ctx.ui.notify("gh-readonly tools are disabled on Windows.", "warning");
|
|
1094
|
+
});
|
|
1095
|
+
return;
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1101
1098
|
// Fail fast: the `gh` CLI is the only backend for these tools. Without it the
|
|
1102
1099
|
// extension registers nothing and reports the problem at session start, so
|
|
1103
1100
|
// the user gets one clear error instead of a dozen failing tool calls.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 按 key 串行化并发任务:同一 key 的调用排队执行,前一个 settle(无论成败)
|
|
3
|
+
* 后才执行下一个。key 由调用方自定义(如资源 id),每个 `createSeqState()`
|
|
4
|
+
* 返回独立的闭包状态,不同扩展之间不串扰。
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export interface SeqState {
|
|
8
|
+
execute<T>(key: string, fn: () => Promise<T>): Promise<T>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function createSeqState(): SeqState {
|
|
12
|
+
const tails = new Map<string, Promise<unknown>>();
|
|
13
|
+
|
|
14
|
+
return {
|
|
15
|
+
execute<T>(key: string, fn: () => Promise<T>): Promise<T> {
|
|
16
|
+
const previous = tails.get(key) ?? Promise.resolve();
|
|
17
|
+
|
|
18
|
+
// 等前一个任务 settle 后执行本任务。previous 恒为 resolve(tail 吞掉了错误),
|
|
19
|
+
// 失败不会阻塞后续任务。
|
|
20
|
+
const run = previous.then(() => fn());
|
|
21
|
+
|
|
22
|
+
// 本任务的完成占位:永远 resolve,作为下一个任务的等待点。
|
|
23
|
+
// eslint-disable-next-line unicorn/no-useless-undefined -- 显式返回 undefined:吞掉前序错误并归一化为成功占位
|
|
24
|
+
const tail = run.catch(() => undefined);
|
|
25
|
+
|
|
26
|
+
tails.set(key, tail);
|
|
27
|
+
void tail.finally(() => {
|
|
28
|
+
if (tails.get(key) === tail) tails.delete(key);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
return run;
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
package/src/lib/write-guard.ts
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
* - Paths outside require user approval via a confirmation dialog showing a
|
|
7
7
|
* `diff` code block preview of the pending change.
|
|
8
8
|
* - Headless sessions (no UI) reject outside writes outright.
|
|
9
|
+
* - Windows (no sandbox fallback): writes are restricted to the workspace;
|
|
10
|
+
* outside paths are rejected outright, with no approval path.
|
|
9
11
|
*
|
|
10
12
|
* Callers have already parsed their tool arguments, so the guard only takes the
|
|
11
13
|
* resolved pieces: the raw target path and the pending change (oldText/newText).
|
|
@@ -122,6 +124,14 @@ export async function guardWriteAccess(
|
|
|
122
124
|
const { absolutePath } = opts;
|
|
123
125
|
if (isPathAllowed(absolutePath, ctx.cwd)) return;
|
|
124
126
|
|
|
127
|
+
if (process.platform === "win32") {
|
|
128
|
+
// Windows 上退化为「只能写工作区」:无沙箱兜底,工作区外写入一律拒绝,
|
|
129
|
+
// 不提供审批路径。
|
|
130
|
+
throw new Error(
|
|
131
|
+
`Path "${absolutePath}" is outside workspace. Writes outside the workspace are not allowed on Windows.`,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
125
135
|
if (!ctx.hasUI || !ctx.ui) {
|
|
126
136
|
throw new Error(`Path "${absolutePath}" is outside workspace. No UI available for approval.`);
|
|
127
137
|
}
|
package/src/spawn-agent.ts
CHANGED
|
@@ -544,6 +544,16 @@ export function formatAgentListSection(agents: AgentConfig[]): string {
|
|
|
544
544
|
// ── extension ────────────────────────────────────────────────────────────────
|
|
545
545
|
|
|
546
546
|
export default function spawnAgent(pi: ExtensionAPI) {
|
|
547
|
+
// Windows 上禁用:子代理进程的派生(node/bun 运行时下回退到 `pi` 命令,
|
|
548
|
+
// 而 npm 安装的 pi 是 .cmd shim,spawn 无法直接启动)与信号管理都是
|
|
549
|
+
// POSIX 假设,不做 Windows 适配。
|
|
550
|
+
if (process.platform === "win32") {
|
|
551
|
+
pi.on("session_start", (_event, ctx) => {
|
|
552
|
+
ctx.ui.notify("spawn-agent is disabled on Windows.", "warning");
|
|
553
|
+
});
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
|
|
547
557
|
// Discover the available subagent types once at extension startup. The
|
|
548
558
|
// extension owns this discovery: the model never has to guess agent names
|
|
549
559
|
// or read the agent directory itself. Editing ~/.pi/agent/agents/*.md
|