@xyit/wellspring 1.1.1 → 1.2.0

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.
@@ -0,0 +1,62 @@
1
+ /*
2
+ * 非 Linux 平台的插件 sandbox:Node permission model。
3
+ *
4
+ * Windows / macOS 没有 bwrap,无法提供 mount / PID / network namespace;
5
+ * 这里用 Node 自带的 --permission 限制插件进程的文件读写,至少不让插件直接
6
+ * 读写宿主任意文件。它不隔离网络与进程,所以只用于插件,不用于任意 Agent 命令。
7
+ */
8
+
9
+ import type {
10
+ AgentLaunchInput,
11
+ LaunchSpec,
12
+ PluginLaunchInput,
13
+ SandboxBackend,
14
+ SandboxBackendStatus,
15
+ } from "../types.ts"
16
+
17
+ const BACKEND_NAME = "node-permission"
18
+
19
+ function permissionFlags(paths: readonly string[], flag: string): string[] {
20
+ return [...new Set(paths)].map((path) => `${flag}=${path}`)
21
+ }
22
+
23
+ /** 纯函数:插件 launch spec,方便单测 */
24
+ export function buildNodePermissionPluginLaunch(input: PluginLaunchInput): LaunchSpec {
25
+ const read = [input.pluginDir, input.hostSdkDir, input.pluginDataDir]
26
+ const write = [input.pluginDataDir]
27
+ return {
28
+ command: process.execPath,
29
+ args: [
30
+ "--permission",
31
+ ...permissionFlags(read, "--allow-fs-read"),
32
+ ...permissionFlags(write, "--allow-fs-write"),
33
+ input.runnerPath,
34
+ ],
35
+ cwd: input.pluginDir,
36
+ env: input.env,
37
+ }
38
+ }
39
+
40
+ export class NodePermissionBackend implements SandboxBackend {
41
+ readonly name = BACKEND_NAME
42
+
43
+ async probe(): Promise<SandboxBackendStatus> {
44
+ const supported = process.allowedNodeEnvironmentFlags.has("--permission")
45
+ return {
46
+ available: supported,
47
+ backend: this.name,
48
+ reason: supported
49
+ ? "Node permission model(无网络/进程隔离)"
50
+ : "当前 Node 不支持 --permission",
51
+ version: process.version,
52
+ }
53
+ }
54
+
55
+ buildAgentLaunch(_input: AgentLaunchInput): LaunchSpec {
56
+ throw new Error("node-permission 后端只用于插件,不支持任意 Agent 命令")
57
+ }
58
+
59
+ buildPluginLaunch(input: PluginLaunchInput): LaunchSpec {
60
+ return buildNodePermissionPluginLaunch(input)
61
+ }
62
+ }
@@ -8,7 +8,7 @@ import { createServer as createHttpsServer } from "node:https"
8
8
  import { WebSocketServer, type WebSocket } from "ws"
9
9
  import { randomUUID } from "node:crypto"
10
10
  import { cp, mkdir, rm, stat } from "node:fs/promises"
11
- import { basename, dirname, join, relative, resolve } from "node:path"
11
+ import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"
12
12
 
13
13
  import type { Logger } from "pino"
14
14
 
@@ -458,14 +458,47 @@ export class PluginSandboxClient {
458
458
  await this.#prepareHostSdk()
459
459
 
460
460
  const pluginEntryRel = relative(this.#options.pluginDir, this.#options.pluginEntry)
461
- const env: Record<string, string> = {
462
- PATH: "/usr/bin:/bin",
463
- LANG: "C.UTF-8",
464
- LC_ALL: "C.UTF-8",
465
- TERM: "dumb",
466
- HOME: "/tmp",
467
- TMPDIR: "/tmp",
468
- }
461
+ const pluginTempDir = join(this.#pluginDataDir, "tmp")
462
+ await mkdir(pluginTempDir, { recursive: true })
463
+ const env: Record<string, string> = isBwrap
464
+ ? {
465
+ PATH: "/usr/bin:/bin",
466
+ LANG: "C.UTF-8",
467
+ LC_ALL: "C.UTF-8",
468
+ TERM: "dumb",
469
+ HOME: "/tmp",
470
+ TMPDIR: "/tmp",
471
+ }
472
+ : {
473
+ /*
474
+ * Windows / macOS 的插件 sandbox(Node permission)使用宿主路径;
475
+ * 只透传系统定位变量,不把 API Key 等敏感环境变量带进插件进程。
476
+ */
477
+ PATH:
478
+ process.env["PATH"] ?? (process.platform === "win32" ? "" : "/usr/bin:/bin"),
479
+ TERM: "dumb",
480
+ HOME: this.#pluginDataDir,
481
+ TMP: pluginTempDir,
482
+ TEMP: pluginTempDir,
483
+ TMPDIR: pluginTempDir,
484
+ ...(process.platform === "win32"
485
+ ? {
486
+ USERPROFILE: process.env["USERPROFILE"] ?? this.#pluginDataDir,
487
+ APPDATA: process.env["APPDATA"] ?? this.#pluginDataDir,
488
+ LOCALAPPDATA: process.env["LOCALAPPDATA"] ?? this.#pluginDataDir,
489
+ SystemRoot: process.env["SystemRoot"] ?? "C:\\Windows",
490
+ SystemDrive: process.env["SystemDrive"] ?? "C:",
491
+ ComSpec: process.env["ComSpec"] ?? "C:\\Windows\\System32\\cmd.exe",
492
+ PATHEXT: process.env["PATHEXT"] ?? ".COM;.EXE;.BAT;.CMD",
493
+ NUMBER_OF_PROCESSORS: process.env["NUMBER_OF_PROCESSORS"] ?? "1",
494
+ PROCESSOR_ARCHITECTURE:
495
+ process.env["PROCESSOR_ARCHITECTURE"] ?? "AMD64",
496
+ }
497
+ : {
498
+ LANG: "C.UTF-8",
499
+ LC_ALL: "C.UTF-8",
500
+ }),
501
+ }
469
502
  const input: PluginLaunchInput = {
470
503
  pluginDir: this.#options.pluginDir,
471
504
  pluginEntryRel,
@@ -1528,6 +1561,13 @@ export class PluginSandboxClient {
1528
1561
  error instanceof Error ? error.message : String(error),
1529
1562
  )
1530
1563
  }
1564
+ } else if (request.method === "host.fileRoots.open") {
1565
+ this.#requireHostApi("fileRoots")
1566
+ const api = await this.#resolveFileRoot(params["root"])
1567
+ result = { root: api.root }
1568
+ } else if (request.method.startsWith("host.fileRoots.")) {
1569
+ this.#requireHostApi("fileRoots")
1570
+ result = await this.#handleFileRoot(request.method, params)
1531
1571
  } else if (request.method.startsWith("host.fs.")) {
1532
1572
  this.#requireHostApi("fs")
1533
1573
  result = await this.#handleFs(request.method, params, false)
@@ -1639,7 +1679,6 @@ export class PluginSandboxClient {
1639
1679
  const api: HostFileApi = workspace
1640
1680
  ? createPluginFileApi(process.cwd())
1641
1681
  : createPluginFileApi(this.#pluginDataDir)
1642
- const path = String(params["path"] ?? "")
1643
1682
  const action = method.replace(/^host\.(fs|workspaceFs)\./, "")
1644
1683
  if (
1645
1684
  workspace &&
@@ -1653,6 +1692,36 @@ export class PluginSandboxClient {
1653
1692
  throw new PluginSandboxError("当前 Agent 权限是 read-only,不能修改工作区文件")
1654
1693
  }
1655
1694
  }
1695
+ return await this.#dispatchFileApi(api, action, method, params)
1696
+ }
1697
+
1698
+ /** 校验绝对目录并创建以它为根的文件 API */
1699
+ async #resolveFileRoot(rootValue: unknown): Promise<HostFileApi> {
1700
+ const root = typeof rootValue === "string" ? rootValue.trim() : ""
1701
+ if (root === "" || root.includes("\0") || !isAbsolute(root)) {
1702
+ throw new PluginSandboxError("fileRoots.open 需要绝对目录路径")
1703
+ }
1704
+ const absolute = resolve(root)
1705
+ const info = await stat(absolute).catch(() => null)
1706
+ if (info === null || !info.isDirectory()) {
1707
+ throw new PluginSandboxError(`目录不存在或不是文件夹:${root}`)
1708
+ }
1709
+ return createPluginFileApi(absolute)
1710
+ }
1711
+
1712
+ async #handleFileRoot(method: string, params: Record<string, unknown>): Promise<unknown> {
1713
+ const api = await this.#resolveFileRoot(params["root"])
1714
+ const action = method.replace(/^host\.fileRoots\./, "")
1715
+ return await this.#dispatchFileApi(api, action, method, params)
1716
+ }
1717
+
1718
+ async #dispatchFileApi(
1719
+ api: HostFileApi,
1720
+ action: string,
1721
+ method: string,
1722
+ params: Record<string, unknown>,
1723
+ ): Promise<unknown> {
1724
+ const path = String(params["path"] ?? "")
1656
1725
  switch (action) {
1657
1726
  case "readText":
1658
1727
  return { text: await api.readText(path) }
@@ -607,11 +607,11 @@ const output: HostOutput = {
607
607
  print: (text = "") => bridgeNotify("host.output", { kind: "print", text }),
608
608
  }
609
609
 
610
- function createFileApi(scope: "fs" | "workspaceFs"): HostFileApi {
611
- const call = (method: string, params: Record<string, unknown> = {}) =>
612
- bridgeRequest(`host.${scope}.${method}`, params)
610
+ type FileApiCall = (method: string, params?: Record<string, unknown>) => Promise<unknown>
611
+
612
+ function fileApi(root: string, call: FileApiCall): HostFileApi {
613
613
  return {
614
- root: scope,
614
+ root,
615
615
  async readText(path) {
616
616
  const result = (await call("readText", { path })) as { text: string | null }
617
617
  return result.text
@@ -660,6 +660,16 @@ function createFileApi(scope: "fs" | "workspaceFs"): HostFileApi {
660
660
  }
661
661
  }
662
662
 
663
+ function createFileApi(scope: "fs" | "workspaceFs"): HostFileApi {
664
+ return fileApi(scope, (method, params = {}) => bridgeRequest(`host.${scope}.${method}`, params))
665
+ }
666
+
667
+ function createRootedFileApi(root: string): HostFileApi {
668
+ return fileApi(root, (method, params = {}) =>
669
+ bridgeRequest(`host.fileRoots.${method}`, { root, ...params }),
670
+ )
671
+ }
672
+
663
673
  function patchConsole(): void {
664
674
  const methods: Array<[string, string]> = [
665
675
  ["log", "info"],
@@ -785,6 +795,14 @@ async function bootstrap(params: ConfigurePayload): Promise<{
785
795
  pluginConfig: params.pluginConfig,
786
796
  fs: createFileApi("fs"),
787
797
  workspaceFs: createFileApi("workspaceFs"),
798
+ fileRoots: {
799
+ async open(root: string): Promise<HostFileApi> {
800
+ const result = (await bridgeRequest("host.fileRoots.open", { root })) as {
801
+ root: string
802
+ }
803
+ return createRootedFileApi(result.root)
804
+ },
805
+ },
788
806
  registerCommand: (command: PluginCommand): void => {
789
807
  commands.push(command)
790
808
  },