@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.
- package/README.md +33 -6
- package/dist/api/host/fs.d.ts +8 -0
- package/dist/api/host/plugin.d.ts +3 -1
- package/dist/plugin/loader.js +16 -2
- package/dist/plugin/loader.js.map +1 -1
- package/dist/sandbox/backends/node-permission.d.ts +9 -0
- package/dist/sandbox/backends/node-permission.js +48 -0
- package/dist/sandbox/backends/node-permission.js.map +1 -0
- package/dist/sandbox/plugin-client.js +71 -10
- package/dist/sandbox/plugin-client.js.map +1 -1
- package/dist/sandbox/plugin-host.js +14 -3
- package/dist/sandbox/plugin-host.js.map +1 -1
- package/package.json +1 -1
- package/src/api/host/fs.ts +9 -0
- package/src/api/host/plugin.ts +3 -1
- package/src/plugin/loader.ts +18 -2
- package/src/sandbox/backends/node-permission.ts +62 -0
- package/src/sandbox/plugin-client.ts +79 -10
- package/src/sandbox/plugin-host.ts +22 -4
|
@@ -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
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
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
|
-
|
|
611
|
-
|
|
612
|
-
|
|
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
|
|
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
|
},
|