@trim21/personal-pi-extensions 0.0.424 → 0.0.426

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trim21/personal-pi-extensions",
3
- "version": "0.0.424",
3
+ "version": "0.0.426",
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
@@ -9,6 +9,7 @@ import { type BashOperations, getAgentDir, getShellConfig } from "@earendil-work
9
9
  import { type Static, Type } from "typebox";
10
10
  import { Value } from "typebox/value";
11
11
 
12
+ import { parseWithSchema } from "../lib/parse-with-schema.js";
12
13
  import { expandHome } from "../lib/path.js";
13
14
  import { type ApprovalRule } from "./approval-rules.js";
14
15
  import { type NetworkStack, resolveDnsServers, startNetworkStack } from "./network-stack.js";
@@ -32,7 +33,9 @@ const bwrapConfigProperties = {
32
33
  extraWritablePaths: Type.Array(Type.String()),
33
34
  tmpfsPaths: Type.Array(Type.String()),
34
35
  extraArgs: Type.Array(Type.String()),
35
- networkAllowlist: Type.Array(Type.String()),
36
+ networkAllowlist: Type.Array(
37
+ Type.String({ description: "允许直连的域名 / IP / CIDR,可带 :port" }),
38
+ ),
36
39
  singBoxPath: Type.Optional(Type.String()),
37
40
  slirp4netnsPath: Type.Optional(Type.String()),
38
41
  approvalRules: Type.Optional(
@@ -42,18 +45,20 @@ const bwrapConfigProperties = {
42
45
  action: StringEnum(["allow", "deny"] as const),
43
46
  pattern: Type.String({ description: '命令模式,如 "git push *"、"npm install *"' }),
44
47
  },
45
- { additionalProperties: false },
48
+ { additionalProperties: true },
46
49
  ),
47
50
  ),
48
51
  ),
49
52
  };
50
53
 
54
+ // 配置文件容忍未知字段:schema 之外的字段(如新版本扩展新增的配置)会被忽略,
55
+ // 避免整个 bwrap 配置因单个未知字段失效;已声明字段仍做类型/取值校验。
51
56
  export const bwrapConfigSchema = Type.Object(bwrapConfigProperties, {
52
- additionalProperties: false,
57
+ additionalProperties: true,
53
58
  });
54
59
 
55
60
  export const bwrapConfigFileSchema = Type.Partial(bwrapConfigSchema, {
56
- additionalProperties: false,
61
+ additionalProperties: true,
57
62
  });
58
63
 
59
64
  export type BwrapConfig = Static<typeof bwrapConfigSchema>;
@@ -68,7 +73,7 @@ export interface ResolvedBwrap {
68
73
  extraWritablePaths: string[];
69
74
  tmpfsPaths: string[];
70
75
  extraArgs: string[];
71
- /** per-domain 过滤的允许域名(非空 = 启用 sing-box 网络过滤)。 */
76
+ /** 允许直连的域名 / IP / IP:port 白名单(非空 = 启用 sing-box 网络过滤)。 */
72
77
  networkAllowlist: string[];
73
78
  singBoxPath?: string;
74
79
  slirp4netnsPath?: string;
@@ -151,9 +156,10 @@ function parseBwrapConfigFile(path: string): BwrapConfigFile {
151
156
  throw new Error(`Invalid bwrap configuration at ${path}: ${String(error)}`, { cause: error });
152
157
  }
153
158
  try {
154
- return Value.Parse(bwrapConfigFileSchema, raw);
159
+ return parseWithSchema(bwrapConfigFileSchema, raw);
155
160
  } catch (error) {
156
- throw new Error(`Invalid bwrap configuration at ${path}: ${String(error)}`, { cause: error });
161
+ const detail = error instanceof Error ? error.message : String(error);
162
+ throw new Error(`Invalid bwrap configuration at ${path}: ${detail}`, { cause: error });
157
163
  }
158
164
  }
159
165
 
@@ -49,7 +49,6 @@ async function main() {
49
49
  return false;
50
50
  }
51
51
  });
52
- await sleep(4e3);
53
52
  await writeFile(readyFile, "");
54
53
  await new Promise((resolve) => singbox.once("exit", () => resolve()));
55
54
  }
@@ -55,8 +55,7 @@ async function main(): Promise<void> {
55
55
  process.exit(0);
56
56
  });
57
57
 
58
- // tun0 设备在 sing-box 启动早期就创建,但 gvisor 栈初始化仍需时间;
59
- // 以 "sing-box started" 日志作为进程就绪标志,再等待栈初始化完成
58
+ // 以 "sing-box started" 日志作为就绪标志(TUN 与 gvisor 数据面此时已可用)
60
59
  await waitFor(200, 100, async () => {
61
60
  try {
62
61
  const log = await readFile(logPath, "utf8");
@@ -65,7 +64,6 @@ async function main(): Promise<void> {
65
64
  return false;
66
65
  }
67
66
  });
68
- await sleep(4000);
69
67
  await writeFile(readyFile, "");
70
68
 
71
69
  await new Promise<void>((resolve) => singbox.once("exit", () => resolve()));
@@ -7,7 +7,7 @@ import { fileURLToPath } from "node:url";
7
7
  import { generateSingboxConfig } from "./singbox-config.js";
8
8
 
9
9
  export interface NetworkStackOptions {
10
- /** 允许直连的域名列表(session 启动时固定,reload 才会重新加载)。 */
10
+ /** 允许直连的域名 / IP:port 列表(session 启动时固定,reload 才会重新加载)。 */
11
11
  readonly allowlist: readonly string[];
12
12
  /** 真实 DNS 服务器列表,按顺序 fallback。 */
13
13
  readonly dnsServers: readonly string[];
@@ -232,7 +232,7 @@ function notifyMode(
232
232
  "allow-all": "allow-all: sandbox off, network on",
233
233
  "workspace-write": "workspace-write: sandbox on, network off",
234
234
  "allow-net": "allow-net: sandbox on, network on, workspace writable",
235
- "net-allowlist": "net-allowlist: sandbox on, network filtered by domain allowlist",
235
+ "net-allowlist": "net-allowlist: sandbox on, network filtered by allowlist",
236
236
  readonly: "readonly: sandbox on, network off, read-only fs",
237
237
  };
238
238
  ctx.ui.notify(labels[mode], "info");
@@ -672,7 +672,7 @@ export class BwrapRuntime {
672
672
  "bwrap-net-allowlist": {
673
673
  name: "bwrap-net-allowlist",
674
674
  usage: "",
675
- description: "Sandbox on, network filtered by domain allowlist, workspace writable",
675
+ description: "Sandbox on, network filtered by allowlist, workspace writable",
676
676
  flags: Type.Object({}),
677
677
  },
678
678
  "bwrap-readonly": {
@@ -3,12 +3,99 @@ const TUN_ADDRESS = "198.18.0.1/16";
3
3
  const EGRESS_INTERFACE = "tap0";
4
4
 
5
5
  export interface SingboxConfigOptions {
6
- /** 允许直连的域名列表(空列表 = 默认拒绝一切出网)。 */
6
+ /** 允许直连的条目列表(域名 / IP / CIDR,可带 :port;空列表 = 默认拒绝一切出网)。 */
7
7
  readonly allowlist: readonly string[];
8
8
  /** 真实 DNS 服务器地址列表(IPv4,UDP 53),按顺序 fallback,至少一个。 */
9
9
  readonly dnsServers: readonly string[];
10
10
  }
11
11
 
12
+ interface AllowlistEntry {
13
+ readonly host: string;
14
+ readonly port?: number;
15
+ }
16
+
17
+ interface AllowlistRule {
18
+ readonly domain?: readonly string[];
19
+ readonly ip_cidr?: readonly string[];
20
+ readonly port?: readonly number[];
21
+ readonly outbound: "direct";
22
+ }
23
+
24
+ const IPV4_PATTERN = /^\d{1,3}(?:\.\d{1,3}){3}(?:\/\d{1,2})?$/;
25
+
26
+ function isIp(host: string): boolean {
27
+ return IPV4_PATTERN.test(host) || host.includes(":");
28
+ }
29
+
30
+ /** 单 IP 补掩码为 CIDR(IPv4 /32、IPv6 /128),带掩码原样返回。 */
31
+ function toCidr(host: string): string {
32
+ if (host.includes("/")) return host;
33
+ return host.includes(":") ? `${host}/128` : `${host}/32`;
34
+ }
35
+
36
+ function parsePort(value: string): number {
37
+ const port = Number(value);
38
+ if (!Number.isSafeInteger(port) || port < 1 || port > 65535) {
39
+ throw new Error(`Invalid port "${value}"`);
40
+ }
41
+ return port;
42
+ }
43
+
44
+ /**
45
+ * 解析 allowlist 条目:域名 / IPv4 / CIDR,可带 :port;IPv6 必须用 [] 包裹
46
+ * (如 `[::1]:80`),裸 IPv6 会报错提示补方括号。
47
+ */
48
+ function parseAllowlistEntry(entry: string): AllowlistEntry {
49
+ if (entry.startsWith("[")) {
50
+ const match = /^\[(.+)\](?::(\d+))?$/.exec(entry);
51
+ if (match?.[1] === undefined) throw new Error(`Invalid allowlist entry "${entry}"`);
52
+ return { host: match[1], port: match[2] === undefined ? undefined : parsePort(match[2]) };
53
+ }
54
+ const colon = entry.lastIndexOf(":");
55
+ if (colon === -1) {
56
+ if (entry.length === 0) throw new Error(`Invalid allowlist entry ""`);
57
+ return { host: entry };
58
+ }
59
+ const portPart = entry.slice(colon + 1);
60
+ if (!/^\d+$/.test(portPart)) throw new Error(`Invalid allowlist entry "${entry}"`);
61
+ const host = entry.slice(0, colon);
62
+ if (host.length === 0) throw new Error(`Invalid allowlist entry "${entry}"`);
63
+ if (host.includes(":")) {
64
+ throw new Error(`IPv6 addresses must be wrapped in brackets, e.g. "[${host}]:${portPart}"`);
65
+ }
66
+ return { host, port: parsePort(portPart) };
67
+ }
68
+
69
+ function buildAllowlistRules(allowlist: readonly string[]): AllowlistRule[] {
70
+ const noPortDomains: string[] = [];
71
+ const noPortIps: string[] = [];
72
+ const byPort = new Map<number, { domains: string[]; ips: string[] }>();
73
+ for (const entry of allowlist) {
74
+ const { host, port } = parseAllowlistEntry(entry);
75
+ if (port === undefined) {
76
+ (isIp(host) ? noPortIps : noPortDomains).push(host);
77
+ } else {
78
+ const bucket = byPort.get(port) ?? { domains: [], ips: [] };
79
+ (isIp(host) ? bucket.ips : bucket.domains).push(host);
80
+ byPort.set(port, bucket);
81
+ }
82
+ }
83
+ const rules: AllowlistRule[] = [];
84
+ if (noPortDomains.length > 0) rules.push({ domain: noPortDomains, outbound: "direct" });
85
+ if (noPortIps.length > 0) {
86
+ rules.push({ ip_cidr: noPortIps.map((host) => toCidr(host)), outbound: "direct" });
87
+ }
88
+ for (const [port, bucket] of byPort) {
89
+ rules.push({
90
+ outbound: "direct",
91
+ port: [port],
92
+ ...(bucket.domains.length > 0 && { domain: bucket.domains }),
93
+ ...(bucket.ips.length > 0 && { ip_cidr: bucket.ips.map((host) => toCidr(host)) }),
94
+ });
95
+ }
96
+ return rules;
97
+ }
98
+
12
99
  /**
13
100
  * 生成 sing-box 1.13 配置:TUN + fakeip + deny-by-default allowlist。
14
101
  *
@@ -69,7 +156,7 @@ export function generateSingboxConfig(options: SingboxConfigOptions): string {
69
156
  rules: [
70
157
  { action: "sniff" },
71
158
  { protocol: "dns", action: "hijack-dns" },
72
- ...(allowlist.length > 0 ? [{ domain: [...allowlist], outbound: "direct" }] : []),
159
+ ...buildAllowlistRules(allowlist),
73
160
  ],
74
161
  },
75
162
  };
@@ -0,0 +1,21 @@
1
+ import type { Static, TSchema } from "typebox";
2
+ import Value, { ParseError } from "typebox/value";
3
+
4
+ /**
5
+ * 用 typebox schema 解析 value。成功返回解析结果;失败抛带字段路径的 Error
6
+ * (Value.Parse 的 ParseError 错误详情在 cause.errors,默认 message 只有
7
+ * "Parse",这里拼成可读信息,供调用方包装上下文如配置文件路径)。
8
+ */
9
+ export function parseWithSchema<T extends TSchema>(schema: T, value: unknown): Static<T> {
10
+ try {
11
+ return Value.Parse(schema, value);
12
+ } catch (error) {
13
+ if (error instanceof ParseError) {
14
+ const details = error.cause.errors
15
+ .map((e) => `${e.instancePath || "(root)"}: ${e.message}`)
16
+ .join("; ");
17
+ throw new Error(details, { cause: error });
18
+ }
19
+ throw error;
20
+ }
21
+ }