@runko/just-bash 0.1.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 +94 -0
- package/dist/index.cjs +372 -0
- package/dist/index.d.cts +19 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +371 -0
- package/package.json +52 -0
package/README.md
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# @runko/just-bash
|
|
2
|
+
|
|
3
|
+
`RunkoExec` 的全语法档实现:适配 [vercel-labs/just-bash](https://github.com/vercel-labs/just-bash)(Apache-2.0,纯 TS,专为 AI agent 场景设计),跑在任意 `RunkoFS` 之上——"模式 A 同源工作区"的另一种实证,语法面从 `@runko/mini-bash` 的六个只读命令升级到完整 `if`/`elif`/`for`/`while`/`until`/`case`/函数/`local`/变量与参数扩展/glob/管道/`&&`/`||`/重定向。定位:Claude 系模型高频产出的控制流脚本,mini-bash 的六命令撑不住时换这个档(一行代码:`exec: justBash(fs)` 换掉 `exec: miniBash(fs)`)。
|
|
4
|
+
|
|
5
|
+
> **本包不随 `@runko/sdk` 一起装**([core-sdk · 技术方案 §4.5b](../../docs/logic/engine/tech/core-sdk.md) "包关系"):`just-bash` 自身依赖树含 sql.js / quickjs-emscripten 等 wasm 大件,强制打包进门面违背 sdk 轻量默认。需要全语法档的宿主显式 `pnpm add @runko/just-bash`;`@runko/mini-bash` 仍是零依赖极简档,随 `@runko/sdk` 一起装,定位是安全默认与测试/演示载体。真实命令执行的第三个选项是 `@runko/core` 的 `localExec`,或宿主自己的沙盒实现(见 [core-sdk · 技术方案 §4.5a](../../docs/logic/engine/tech/core-sdk.md))。
|
|
6
|
+
|
|
7
|
+
## 安装
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
pnpm add @runko/just-bash
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
(`@runko/core`、`just-bash` 作为本包的 `dependencies` 一并装入,不需要单独 `pnpm add just-bash`。)
|
|
14
|
+
|
|
15
|
+
## 最小用例
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import { justBash } from "@runko/just-bash";
|
|
19
|
+
import { fromMemory } from "@runko/virtual-fs"; // 任何 RunkoFS 实现都行
|
|
20
|
+
|
|
21
|
+
const fs = fromMemory({});
|
|
22
|
+
const exec = justBash(fs);
|
|
23
|
+
|
|
24
|
+
const result = await exec.exec({
|
|
25
|
+
command: 'for n in 1 2 3; do echo "line-$n"; done',
|
|
26
|
+
cwd: "/",
|
|
27
|
+
signal: new AbortController().signal,
|
|
28
|
+
});
|
|
29
|
+
console.log(result); // { exitCode: 0, stdout: "line-1\nline-2\nline-3\n", stderr: "", durationMs: ... }
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
接进 session(同源工作区,`bash` 工具随 `exec` 注入自动出现;`fs`/`createSession`/`defineAgent` 仍从 `@runko/sdk` 拿,只有 `justBash` 本身来自本包):
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
import { createSession, defineAgent, RunkoFS } from "@runko/sdk";
|
|
36
|
+
import { justBash } from "@runko/just-bash";
|
|
37
|
+
|
|
38
|
+
const fs = RunkoFS.fromMemory({});
|
|
39
|
+
const session = createSession(defineAgent({ model: "anthropic/claude-sonnet-5" }), {
|
|
40
|
+
fs,
|
|
41
|
+
exec: justBash(fs), // 同一个 fs:write_file 写的文件,bash 立即可见,反之亦然
|
|
42
|
+
});
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
完整可跑示例见 [examples/08-just-bash.ts](../../examples/src/08-just-bash.ts)。
|
|
46
|
+
|
|
47
|
+
## API 面
|
|
48
|
+
|
|
49
|
+
本包公共 API 收紧到一个函数 + 它需要的选项/限额类型:
|
|
50
|
+
|
|
51
|
+
| 导出 | 说明 |
|
|
52
|
+
|---|---|
|
|
53
|
+
| `justBash(fs: RunkoFS, opts?: JustBashOptions): RunkoExec` | 返回跑在 `fs` 上的全语法档解释器。`defaultApproval: "never"`(重定向写面落在同一个注入的 `RunkoFS`,与文件工具同沙盒);`describe()` 声明全语法能力、无 symlink、无网络、输出非流式;`cwd` 在同一个 `justBash(fs)` 实例上跨 `exec()` 调用持久(同 `@runko/mini-bash` 语义) |
|
|
54
|
+
| `JustBashOptions` | `{ limits?: ExecutionLimits }`——与默认收紧值逐字段合并,未指定的字段沿用默认值 |
|
|
55
|
+
| `ExecutionLimits` | just-bash 的执行限额类型(`maxCommandCount`/`maxLoopIterations`/`maxOutputSize` 等,见下表) |
|
|
56
|
+
|
|
57
|
+
`createFsAdapter`/`IFileSystem` 等适配细节不导出(同 `@runko/mini-bash` 的 `parse()`/`COMMANDS` 先例)。
|
|
58
|
+
|
|
59
|
+
## 执行限额(默认值)
|
|
60
|
+
|
|
61
|
+
出厂默认(`just-bash` 自己的默认值是为通用 CLI 场景设的,对"一次 agent 工具调用里跑的一段脚本"明显过宽,这里整体收紧一个数量级左右;全部字段都能被 `opts.limits` 逐个覆盖):
|
|
62
|
+
|
|
63
|
+
| 字段 | 默认值 | 字段 | 默认值 |
|
|
64
|
+
|---|---|---|---|
|
|
65
|
+
| `maxCommandCount` | 2000 | `maxLoopIterations` | 2000 |
|
|
66
|
+
| `maxOutputSize` | 1MB(`1024 * 1024`) | `maxCallDepth` | 20 |
|
|
67
|
+
| `maxAwkIterations` | 2000 | `maxSedIterations` | 2000 |
|
|
68
|
+
| `maxJqIterations` | 2000 | `maxSqliteTimeoutMs` | 3000 |
|
|
69
|
+
| `maxGlobOperations` | 5000 | `maxStringLength` | 2MB(`2 * 1024 * 1024`) |
|
|
70
|
+
| `maxArrayElements` | 10,000 | `maxHeredocSize` | 1MB(`1024 * 1024`) |
|
|
71
|
+
| `maxSubstitutionDepth` | 20 | `maxBraceExpansionResults` | 2000 |
|
|
72
|
+
| `maxFileDescriptors` | 256 | `maxSourceDepth` | 20 |
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
justBash(fs, { limits: { maxCommandCount: 500 } }); // 只覆盖这一个字段,其余仍是上表默认值
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## 支持的语法
|
|
79
|
+
|
|
80
|
+
完整控制流:`if`/`elif`/`else`、`for`(list 形式与 C 式 `for ((i=0;i<n;i++))`)、`while`/`until`、`case`、函数(含 `local`)、变量/参数扩展、glob 展开、管道、`&&`/`||`、重定向(`>`/`>>`/`<`/`2>&1`)。命令集是 just-bash 内置的完整命令族(`cat`/`grep`/`sed`/`awk`/`find`/`sort`/`jq` 等数十个,详见 [just-bash 上游文档](https://github.com/vercel-labs/just-bash)),而非 mini-bash 那样的手写六命令子集。
|
|
81
|
+
|
|
82
|
+
## 已知限制(docs/logic/engine/tech/core-sdk.md §4.5b,如实照抄,不发明)
|
|
83
|
+
|
|
84
|
+
- **无 symlink**:`symlink`/`link`/`readlink` 始终抛不支持;`stat`/`lstat` 从不报告符号链接(与 v1 全线"无符号链接"立场一致)。
|
|
85
|
+
- **无网络**:curl/wget 不注册(未接入 fetch/network 配置)。
|
|
86
|
+
- **输出非流式**:just-bash 无增量回调,`onOutput` 每次 `exec()` 至多触发一次(脚本整体跑完后一次性回报 stdout/stderr),不是逐命令/逐行增量推送。
|
|
87
|
+
- **`**` 递归 glob 在同一次 `exec()` 内看不到该次脚本前面命令新写的文件**:`IFileSystem.getAllPaths()` 是同步签名而 `RunkoFS.glob` 是异步的,适配器折中为"每次顶层 `exec()` 开始时刷新一次同步缓存"——脚本运行期间新写入的文件要下一次 `exec()` 调用才对 `**` 展开可见;单层 glob(如 `*.txt`)走 `readdir` 不受影响,同一脚本内实时可见。
|
|
88
|
+
- **`cp`/`mv` 递归目录时空子目录不参与**:目录场景经 `glob` 枚举子树文件逐个搬运,继承"`RunkoFS.glob` 只报文件"的既有限制(§4.4),不额外物化空目录。
|
|
89
|
+
- `chmod`/`utimes` 是 no-op 成功(脚本常见惯用法,硬失败徒增纠错轮次),不是真实生效的权限/时间戳变更。
|
|
90
|
+
- `cwd` 的跨调用持久化由适配器自己维护(闭包变量 + 读回 `env.PWD`),不是 just-bash 引擎自带的免费特性——`Bash.exec()` 每次调用本身是无状态的。
|
|
91
|
+
|
|
92
|
+
## 退出码与取消契约(同 `@runko/mini-bash`,docs/logic/engine/tech/core-sdk.md §4.5a"实现契约")
|
|
93
|
+
|
|
94
|
+
`exec()` 的全部失败路径以 resolve 的 `ExecResult`(非零 `exitCode` + stderr)返回,从不 reject:超时 124、abort 130;just-bash 自身抛出的非正常异常兜底为 `exitCode: 1`。中止/超时不采信 just-bash 自己返回的退出码(实测不稳定)——适配器用独立的 `raceAbort` 竞速,信号一响就立即以合成结果返回,不等底层调用真正落定。
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
let just_bash = require("just-bash");
|
|
3
|
+
//#region src/path.ts
|
|
4
|
+
/**
|
|
5
|
+
* 自包含的极简 POSIX 虚拟路径解析——与 `@runko/mini-bash` 的 `src/path.ts`
|
|
6
|
+
* 同一份逻辑独立实现(不跨包依赖:src 侧只允许依赖 `@runko/core` + `just-bash`,
|
|
7
|
+
* 见 P6-4 mini-bash 先例头注释)。`..` 越界静默 clamp 到根而非抛错——这一层
|
|
8
|
+
* 不是安全边界(docs/tech/core-sdk.md §4.4:"安全边界在 FS 不在工具"),真正的边界校验
|
|
9
|
+
* 留给注入的 `RunkoFS` 实现自己那一层。
|
|
10
|
+
*/
|
|
11
|
+
/** 把任意输入(含 `.`、`..`、重复斜杠)规范化为以 `/` 开头的虚拟绝对路径。 */
|
|
12
|
+
function normalizePath(input) {
|
|
13
|
+
const segments = input.split("/");
|
|
14
|
+
const stack = [];
|
|
15
|
+
for (const segment of segments) {
|
|
16
|
+
if (segment === "" || segment === ".") continue;
|
|
17
|
+
if (segment === "..") {
|
|
18
|
+
if (stack.length > 0) stack.pop();
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
stack.push(segment);
|
|
22
|
+
}
|
|
23
|
+
return `/${stack.join("/")}`;
|
|
24
|
+
}
|
|
25
|
+
/** 绝对路径直接规范化;相对路径先拼上 base 再规范化——与 `IFileSystem.resolvePath(base, path)` 签名同形。 */
|
|
26
|
+
function resolvePath(base, input) {
|
|
27
|
+
if (input.startsWith("/")) return normalizePath(input);
|
|
28
|
+
return normalizePath(`${base}/${input}`);
|
|
29
|
+
}
|
|
30
|
+
/** 假定 path 已规范化。用于 `getAllPaths()` 从文件路径合成祖先目录集合(§4.5b)。 */
|
|
31
|
+
function dirnameOf(path) {
|
|
32
|
+
if (path === "/") return "/";
|
|
33
|
+
const idx = path.lastIndexOf("/");
|
|
34
|
+
if (idx <= 0) return "/";
|
|
35
|
+
return path.slice(0, idx);
|
|
36
|
+
}
|
|
37
|
+
//#endregion
|
|
38
|
+
//#region src/fs-adapter.ts
|
|
39
|
+
const textDecoder = new TextDecoder();
|
|
40
|
+
const textEncoder = new TextEncoder();
|
|
41
|
+
const FILE_MODE = 420;
|
|
42
|
+
const DIR_MODE = 493;
|
|
43
|
+
/** reference 条目 stat 视为 file(降级表原文)。 */
|
|
44
|
+
function toFsStat(stat) {
|
|
45
|
+
const isDirectory = stat.type === "dir";
|
|
46
|
+
return {
|
|
47
|
+
isFile: !isDirectory,
|
|
48
|
+
isDirectory,
|
|
49
|
+
isSymbolicLink: false,
|
|
50
|
+
mode: isDirectory ? DIR_MODE : FILE_MODE,
|
|
51
|
+
size: stat.size ?? 0,
|
|
52
|
+
mtime: new Date(stat.mtime ?? 0)
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
async function statImpl(fs, path) {
|
|
56
|
+
return toFsStat(await fs.stat(normalizePath(path)));
|
|
57
|
+
}
|
|
58
|
+
async function existsImpl(fs, path) {
|
|
59
|
+
try {
|
|
60
|
+
await fs.stat(normalizePath(path));
|
|
61
|
+
return true;
|
|
62
|
+
} catch {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/** `${dir}/**` 形式的 glob 模式,匹配 dir 子树下的全部文件(不含 dir 自身)。 */
|
|
67
|
+
function subtreeGlobPattern(dir) {
|
|
68
|
+
return dir === "/" ? "/**" : `${dir}/**`;
|
|
69
|
+
}
|
|
70
|
+
/** 把子树内某个文件的绝对路径,重定位到新的根 `destRoot` 下。 */
|
|
71
|
+
function relocate(srcRoot, destRoot, filePath) {
|
|
72
|
+
const prefix = srcRoot === "/" ? "/" : `${srcRoot}/`;
|
|
73
|
+
const rel = filePath.startsWith(prefix) ? filePath.slice(prefix.length) : filePath.slice(srcRoot.length);
|
|
74
|
+
return destRoot === "/" ? `/${rel}` : `${destRoot}/${rel}`;
|
|
75
|
+
}
|
|
76
|
+
/** cp -r / mv 目录场景的共用体:把 src 子树下每个文件 read 出来再 write 到重定位后的 dest 路径。空子目录不参与(RunkoFS.glob 只报文件)。 */
|
|
77
|
+
async function copySubtree(fs, src, dest) {
|
|
78
|
+
const files = await fs.glob(subtreeGlobPattern(src));
|
|
79
|
+
for (const filePath of files) {
|
|
80
|
+
const data = await fs.readFile(filePath);
|
|
81
|
+
await fs.writeFile(relocate(src, dest, filePath), data);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
async function cpImpl(fs, src, dest, options) {
|
|
85
|
+
const srcPath = normalizePath(src);
|
|
86
|
+
const destPath = normalizePath(dest);
|
|
87
|
+
if ((await fs.stat(srcPath)).type === "dir") {
|
|
88
|
+
if (options?.recursive !== true) throw new Error(`cp: -r not specified; omitting directory '${src}'`);
|
|
89
|
+
await copySubtree(fs, srcPath, destPath);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const data = await fs.readFile(srcPath);
|
|
93
|
+
await fs.writeFile(destPath, data);
|
|
94
|
+
}
|
|
95
|
+
async function mvImpl(fs, src, dest) {
|
|
96
|
+
const srcPath = normalizePath(src);
|
|
97
|
+
const destPath = normalizePath(dest);
|
|
98
|
+
if ((await fs.stat(srcPath)).type === "dir") {
|
|
99
|
+
await copySubtree(fs, srcPath, destPath);
|
|
100
|
+
await fs.rm(srcPath, { recursive: true });
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const data = await fs.readFile(srcPath);
|
|
104
|
+
await fs.writeFile(destPath, data);
|
|
105
|
+
await fs.rm(srcPath);
|
|
106
|
+
}
|
|
107
|
+
async function rmImpl(fs, path, options) {
|
|
108
|
+
try {
|
|
109
|
+
await fs.rm(normalizePath(path), { recursive: options?.recursive });
|
|
110
|
+
} catch (error) {
|
|
111
|
+
if (options?.force === true) return;
|
|
112
|
+
throw error;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
async function appendFileImpl(fs, path, addition) {
|
|
116
|
+
const normalized = normalizePath(path);
|
|
117
|
+
let existing;
|
|
118
|
+
try {
|
|
119
|
+
existing = await fs.readFile(normalized);
|
|
120
|
+
} catch {
|
|
121
|
+
existing = /* @__PURE__ */ new Uint8Array(0);
|
|
122
|
+
}
|
|
123
|
+
const combined = new Uint8Array(existing.length + addition.length);
|
|
124
|
+
combined.set(existing, 0);
|
|
125
|
+
combined.set(addition, existing.length);
|
|
126
|
+
await fs.writeFile(normalized, combined);
|
|
127
|
+
}
|
|
128
|
+
function createFsAdapter(fs) {
|
|
129
|
+
let allPathsCache = [];
|
|
130
|
+
async function refreshAllPaths() {
|
|
131
|
+
const files = await fs.glob("**");
|
|
132
|
+
const dirs = /* @__PURE__ */ new Set(["/"]);
|
|
133
|
+
for (const file of files) {
|
|
134
|
+
let dir = dirnameOf(file);
|
|
135
|
+
while (!dirs.has(dir)) {
|
|
136
|
+
dirs.add(dir);
|
|
137
|
+
const parent = dirnameOf(dir);
|
|
138
|
+
if (parent === dir) break;
|
|
139
|
+
dir = parent;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
allPathsCache = [...files, ...dirs];
|
|
143
|
+
}
|
|
144
|
+
return {
|
|
145
|
+
async readFile(path) {
|
|
146
|
+
const bytes = await fs.readFile(normalizePath(path));
|
|
147
|
+
return textDecoder.decode(bytes);
|
|
148
|
+
},
|
|
149
|
+
async readFileBytes(path) {
|
|
150
|
+
const bytes = await fs.readFile(normalizePath(path));
|
|
151
|
+
return (0, just_bash.unsafeBytesFromLatin1)(Buffer.from(bytes).toString("latin1"));
|
|
152
|
+
},
|
|
153
|
+
async readFileBuffer(path) {
|
|
154
|
+
return fs.readFile(normalizePath(path));
|
|
155
|
+
},
|
|
156
|
+
async writeFile(path, content) {
|
|
157
|
+
await fs.writeFile(normalizePath(path), content);
|
|
158
|
+
},
|
|
159
|
+
async appendFile(path, content) {
|
|
160
|
+
await appendFileImpl(fs, path, typeof content === "string" ? textEncoder.encode(content) : content);
|
|
161
|
+
},
|
|
162
|
+
async exists(path) {
|
|
163
|
+
return existsImpl(fs, path);
|
|
164
|
+
},
|
|
165
|
+
async stat(path) {
|
|
166
|
+
return statImpl(fs, path);
|
|
167
|
+
},
|
|
168
|
+
async mkdir(path) {
|
|
169
|
+
await fs.mkdir(normalizePath(path));
|
|
170
|
+
},
|
|
171
|
+
async readdir(path) {
|
|
172
|
+
return (await fs.readdir(normalizePath(path))).map((entry) => entry.name);
|
|
173
|
+
},
|
|
174
|
+
async readdirWithFileTypes(path) {
|
|
175
|
+
return (await fs.readdir(normalizePath(path))).map((entry) => ({
|
|
176
|
+
name: entry.name,
|
|
177
|
+
isFile: entry.type === "file" || entry.type === "reference",
|
|
178
|
+
isDirectory: entry.type === "dir",
|
|
179
|
+
isSymbolicLink: false
|
|
180
|
+
}));
|
|
181
|
+
},
|
|
182
|
+
async rm(path, options) {
|
|
183
|
+
await rmImpl(fs, path, options);
|
|
184
|
+
},
|
|
185
|
+
async cp(src, dest, options) {
|
|
186
|
+
await cpImpl(fs, src, dest, options);
|
|
187
|
+
},
|
|
188
|
+
async mv(src, dest) {
|
|
189
|
+
await mvImpl(fs, src, dest);
|
|
190
|
+
},
|
|
191
|
+
resolvePath(base, path) {
|
|
192
|
+
return resolvePath(base, path);
|
|
193
|
+
},
|
|
194
|
+
getAllPaths() {
|
|
195
|
+
return allPathsCache;
|
|
196
|
+
},
|
|
197
|
+
async chmod() {},
|
|
198
|
+
async symlink(target, linkPath) {
|
|
199
|
+
throw new Error(`just-bash adapter: symlink is not supported (no symlink concept in RunkoFS v1): '${linkPath}' -> '${target}'`);
|
|
200
|
+
},
|
|
201
|
+
async link(existingPath, newPath) {
|
|
202
|
+
throw new Error(`just-bash adapter: hard link is not supported (no symlink concept in RunkoFS v1): '${newPath}' -> '${existingPath}'`);
|
|
203
|
+
},
|
|
204
|
+
async readlink(path) {
|
|
205
|
+
throw new Error(`just-bash adapter: readlink is not supported (no symlink concept in RunkoFS v1): '${path}'`);
|
|
206
|
+
},
|
|
207
|
+
async lstat(path) {
|
|
208
|
+
return statImpl(fs, path);
|
|
209
|
+
},
|
|
210
|
+
async realpath(path) {
|
|
211
|
+
return normalizePath(path);
|
|
212
|
+
},
|
|
213
|
+
async utimes() {},
|
|
214
|
+
refreshAllPaths
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
//#endregion
|
|
218
|
+
//#region src/exec.ts
|
|
219
|
+
/**
|
|
220
|
+
* 默认收紧值(工单裁量:just-bash 自己的出厂默认——如 maxCommandCount 1万、
|
|
221
|
+
* maxLoopIterations 1万、maxOutputSize 10MB——是为通用 CLI 场景设的,对
|
|
222
|
+
* "一次 agent 工具调用里跑的一段脚本"明显过宽。这里按"典型 agent 脚本远小
|
|
223
|
+
* 于这些量级,但仍留足够余量不误伤正常用例"的原则整体收紧一个数量级左右;
|
|
224
|
+
* 全部字段都能被 `opts.limits` 逐个覆盖。)
|
|
225
|
+
*/
|
|
226
|
+
const DEFAULT_EXECUTION_LIMITS = {
|
|
227
|
+
maxCallDepth: 20,
|
|
228
|
+
maxCommandCount: 2e3,
|
|
229
|
+
maxLoopIterations: 2e3,
|
|
230
|
+
maxAwkIterations: 2e3,
|
|
231
|
+
maxSedIterations: 2e3,
|
|
232
|
+
maxJqIterations: 2e3,
|
|
233
|
+
maxSqliteTimeoutMs: 3e3,
|
|
234
|
+
maxGlobOperations: 5e3,
|
|
235
|
+
maxStringLength: 2 * 1024 * 1024,
|
|
236
|
+
maxArrayElements: 1e4,
|
|
237
|
+
maxHeredocSize: 1024 * 1024,
|
|
238
|
+
maxSubstitutionDepth: 20,
|
|
239
|
+
maxBraceExpansionResults: 2e3,
|
|
240
|
+
maxOutputSize: 1024 * 1024,
|
|
241
|
+
maxFileDescriptors: 256,
|
|
242
|
+
maxSourceDepth: 20
|
|
243
|
+
};
|
|
244
|
+
const DESCRIBE = [
|
|
245
|
+
"just-bash: full-syntax bash interpreter (vercel-labs/just-bash), running entirely on the injected RunkoFS",
|
|
246
|
+
"(mode A same-source workspace — bash and the file tools share one filesystem, nothing to reconcile).",
|
|
247
|
+
"Supports the full control-flow surface: if/elif/else, for (list and C-style `for ((i=0;i<n;i++))`),",
|
|
248
|
+
"while, until, case, functions with local variables, variable/parameter expansion, glob expansion,",
|
|
249
|
+
"pipes, `&&`/`||`, and redirections (`>`, `>>`, `<`, `2>&1`).",
|
|
250
|
+
"No symlinks: symlink/link/readlink always fail, and stat never reports a symbolic link.",
|
|
251
|
+
"No network: curl/wget are not registered (no fetch/network config is wired in).",
|
|
252
|
+
"No python/js sub-interpreters: disabled by default for reduced execution surface.",
|
|
253
|
+
"Output is NOT streamed: just-bash reports stdout/stderr only once the whole script has finished, so",
|
|
254
|
+
"onOutput fires at most once per stream, after the command completes (not incrementally as it runs).",
|
|
255
|
+
"cwd is persistent on this instance: a `cd` inside a script carries over to the next exec() call on the",
|
|
256
|
+
"same justBash(fs) instance (same semantics as @runko/mini-bash); an explicit req.cwd only overrides the",
|
|
257
|
+
"starting point for that one call and is not promoted to permanent memory unless a cd actually runs.",
|
|
258
|
+
"Execution limits are enforced (loop iterations, command count, call depth, output size, and more) to",
|
|
259
|
+
"bound runaway scripts; see justBash(fs, { limits }) to override the defaults."
|
|
260
|
+
].join(" ");
|
|
261
|
+
var JustBashAbortedError = class extends Error {
|
|
262
|
+
constructor(reason) {
|
|
263
|
+
super(reason === "timeout" ? "just-bash: command timed out" : "just-bash: aborted");
|
|
264
|
+
this.name = "JustBashAbortedError";
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
/**
|
|
268
|
+
* 同 `@runko/mini-bash` 的 `raceAbort`:`signal` 先触发就立刻 reject,不等
|
|
269
|
+
* `work` 真正落定(`work` 可能因为注入的 fs/glob 永久 pending,这正是需要
|
|
270
|
+
* 竞速的原因)。给 `work` 补一个空 catch,避免它在竞速结束后才 reject/resolve
|
|
271
|
+
* 时产生 unhandled rejection 噪音。
|
|
272
|
+
*/
|
|
273
|
+
function raceAbort(work, signal) {
|
|
274
|
+
return new Promise((resolve, reject) => {
|
|
275
|
+
const settleAborted = () => {
|
|
276
|
+
work.catch(() => {});
|
|
277
|
+
reject(new JustBashAbortedError("signal"));
|
|
278
|
+
};
|
|
279
|
+
if (signal.aborted) {
|
|
280
|
+
settleAborted();
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
const onAbort = () => settleAborted();
|
|
284
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
285
|
+
work.then((value) => {
|
|
286
|
+
signal.removeEventListener("abort", onAbort);
|
|
287
|
+
resolve(value);
|
|
288
|
+
}, (error) => {
|
|
289
|
+
signal.removeEventListener("abort", onAbort);
|
|
290
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
function describeError(error) {
|
|
295
|
+
return error instanceof Error ? error.message : String(error);
|
|
296
|
+
}
|
|
297
|
+
/** `result.env.PWD` 的类型安全读取——`noUncheckedIndexedAccess` 下 `Record<string,string>` 索引即 `string | undefined`,用 typeof 守卫而非断言。 */
|
|
298
|
+
function endingCwdOf(result, fallback) {
|
|
299
|
+
const pwd = result.env.PWD;
|
|
300
|
+
return typeof pwd === "string" ? pwd : fallback;
|
|
301
|
+
}
|
|
302
|
+
function justBash(fs, opts = {}) {
|
|
303
|
+
const adapter = createFsAdapter(fs);
|
|
304
|
+
const bash = new just_bash.Bash({
|
|
305
|
+
fs: adapter,
|
|
306
|
+
cwd: "/",
|
|
307
|
+
executionLimits: {
|
|
308
|
+
...DEFAULT_EXECUTION_LIMITS,
|
|
309
|
+
...opts.limits
|
|
310
|
+
},
|
|
311
|
+
python: false,
|
|
312
|
+
javascript: false
|
|
313
|
+
});
|
|
314
|
+
let instanceCwd = "/";
|
|
315
|
+
return {
|
|
316
|
+
defaultApproval: "allow",
|
|
317
|
+
describe() {
|
|
318
|
+
return DESCRIBE;
|
|
319
|
+
},
|
|
320
|
+
async exec(req, execOpts) {
|
|
321
|
+
const start = Date.now();
|
|
322
|
+
const startCwd = req.cwd !== void 0 ? resolvePath("/", req.cwd) : instanceCwd;
|
|
323
|
+
const timeoutController = new AbortController();
|
|
324
|
+
const timer = req.timeoutMs !== void 0 ? setTimeout(() => timeoutController.abort(), req.timeoutMs) : void 0;
|
|
325
|
+
const combined = AbortSignal.any([req.signal, timeoutController.signal]);
|
|
326
|
+
const work = (async () => {
|
|
327
|
+
await adapter.refreshAllPaths();
|
|
328
|
+
return bash.exec(req.command, {
|
|
329
|
+
cwd: startCwd,
|
|
330
|
+
signal: combined
|
|
331
|
+
});
|
|
332
|
+
})();
|
|
333
|
+
try {
|
|
334
|
+
const result = await raceAbort(work, combined);
|
|
335
|
+
const endCwd = endingCwdOf(result, startCwd);
|
|
336
|
+
if (endCwd !== startCwd) instanceCwd = endCwd;
|
|
337
|
+
if (result.stdout.length > 0) execOpts?.onOutput?.({
|
|
338
|
+
stream: "stdout",
|
|
339
|
+
data: result.stdout
|
|
340
|
+
});
|
|
341
|
+
if (result.stderr.length > 0) execOpts?.onOutput?.({
|
|
342
|
+
stream: "stderr",
|
|
343
|
+
data: result.stderr
|
|
344
|
+
});
|
|
345
|
+
return {
|
|
346
|
+
exitCode: result.exitCode,
|
|
347
|
+
stdout: result.stdout,
|
|
348
|
+
stderr: result.stderr,
|
|
349
|
+
durationMs: Date.now() - start
|
|
350
|
+
};
|
|
351
|
+
} catch (error) {
|
|
352
|
+
const timedOut = timeoutController.signal.aborted;
|
|
353
|
+
if (error instanceof JustBashAbortedError || timedOut) return {
|
|
354
|
+
exitCode: timedOut ? 124 : 130,
|
|
355
|
+
stdout: "",
|
|
356
|
+
stderr: timedOut ? `just-bash: command timed out after ${String(req.timeoutMs)}ms` : "just-bash: aborted",
|
|
357
|
+
durationMs: Date.now() - start
|
|
358
|
+
};
|
|
359
|
+
return {
|
|
360
|
+
exitCode: 1,
|
|
361
|
+
stdout: "",
|
|
362
|
+
stderr: `just-bash: internal error: ${describeError(error)}`,
|
|
363
|
+
durationMs: Date.now() - start
|
|
364
|
+
};
|
|
365
|
+
} finally {
|
|
366
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
//#endregion
|
|
372
|
+
exports.justBash = justBash;
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { RunkoExec, RunkoFS } from "@runko/core";
|
|
2
|
+
import { BashOptions } from "just-bash";
|
|
3
|
+
//#region src/exec.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* `just-bash` 不从包顶层导出 `ExecutionLimits` 这个类型名(`Bash.ts` 内部
|
|
6
|
+
* 有 `export type { ExecutionLimits } from "./limits.js"`,但顶层
|
|
7
|
+
* `index.ts` 的重导出列表里漏了它——实测 `tsc` 报 "has no exported member
|
|
8
|
+
* 'ExecutionLimits'" 确认)。改用索引访问类型从确实导出的 `BashOptions`
|
|
9
|
+
* 上派生同一个类型——结构上与直接导出完全等价,只是不依赖一个未导出的
|
|
10
|
+
* 名字;这不是类型逃逸(不是 `any`/断言),是用类型系统本身收窄出真实形状。
|
|
11
|
+
*/
|
|
12
|
+
type ExecutionLimits = NonNullable<BashOptions["executionLimits"]>;
|
|
13
|
+
interface JustBashOptions {
|
|
14
|
+
/** 执行限额覆盖——与默认收紧值逐字段合并(未指定的字段沿用默认值)。 */
|
|
15
|
+
limits?: ExecutionLimits;
|
|
16
|
+
}
|
|
17
|
+
declare function justBash(fs: RunkoFS, opts?: JustBashOptions): RunkoExec;
|
|
18
|
+
//#endregion
|
|
19
|
+
export { type ExecutionLimits, type JustBashOptions, justBash };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { BashOptions } from "just-bash";
|
|
2
|
+
import { RunkoExec, RunkoFS } from "@runko/core";
|
|
3
|
+
//#region src/exec.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* `just-bash` 不从包顶层导出 `ExecutionLimits` 这个类型名(`Bash.ts` 内部
|
|
6
|
+
* 有 `export type { ExecutionLimits } from "./limits.js"`,但顶层
|
|
7
|
+
* `index.ts` 的重导出列表里漏了它——实测 `tsc` 报 "has no exported member
|
|
8
|
+
* 'ExecutionLimits'" 确认)。改用索引访问类型从确实导出的 `BashOptions`
|
|
9
|
+
* 上派生同一个类型——结构上与直接导出完全等价,只是不依赖一个未导出的
|
|
10
|
+
* 名字;这不是类型逃逸(不是 `any`/断言),是用类型系统本身收窄出真实形状。
|
|
11
|
+
*/
|
|
12
|
+
type ExecutionLimits = NonNullable<BashOptions["executionLimits"]>;
|
|
13
|
+
interface JustBashOptions {
|
|
14
|
+
/** 执行限额覆盖——与默认收紧值逐字段合并(未指定的字段沿用默认值)。 */
|
|
15
|
+
limits?: ExecutionLimits;
|
|
16
|
+
}
|
|
17
|
+
declare function justBash(fs: RunkoFS, opts?: JustBashOptions): RunkoExec;
|
|
18
|
+
//#endregion
|
|
19
|
+
export { type ExecutionLimits, type JustBashOptions, justBash };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
import { Bash, unsafeBytesFromLatin1 } from "just-bash";
|
|
2
|
+
//#region src/path.ts
|
|
3
|
+
/**
|
|
4
|
+
* 自包含的极简 POSIX 虚拟路径解析——与 `@runko/mini-bash` 的 `src/path.ts`
|
|
5
|
+
* 同一份逻辑独立实现(不跨包依赖:src 侧只允许依赖 `@runko/core` + `just-bash`,
|
|
6
|
+
* 见 P6-4 mini-bash 先例头注释)。`..` 越界静默 clamp 到根而非抛错——这一层
|
|
7
|
+
* 不是安全边界(docs/tech/core-sdk.md §4.4:"安全边界在 FS 不在工具"),真正的边界校验
|
|
8
|
+
* 留给注入的 `RunkoFS` 实现自己那一层。
|
|
9
|
+
*/
|
|
10
|
+
/** 把任意输入(含 `.`、`..`、重复斜杠)规范化为以 `/` 开头的虚拟绝对路径。 */
|
|
11
|
+
function normalizePath(input) {
|
|
12
|
+
const segments = input.split("/");
|
|
13
|
+
const stack = [];
|
|
14
|
+
for (const segment of segments) {
|
|
15
|
+
if (segment === "" || segment === ".") continue;
|
|
16
|
+
if (segment === "..") {
|
|
17
|
+
if (stack.length > 0) stack.pop();
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
stack.push(segment);
|
|
21
|
+
}
|
|
22
|
+
return `/${stack.join("/")}`;
|
|
23
|
+
}
|
|
24
|
+
/** 绝对路径直接规范化;相对路径先拼上 base 再规范化——与 `IFileSystem.resolvePath(base, path)` 签名同形。 */
|
|
25
|
+
function resolvePath(base, input) {
|
|
26
|
+
if (input.startsWith("/")) return normalizePath(input);
|
|
27
|
+
return normalizePath(`${base}/${input}`);
|
|
28
|
+
}
|
|
29
|
+
/** 假定 path 已规范化。用于 `getAllPaths()` 从文件路径合成祖先目录集合(§4.5b)。 */
|
|
30
|
+
function dirnameOf(path) {
|
|
31
|
+
if (path === "/") return "/";
|
|
32
|
+
const idx = path.lastIndexOf("/");
|
|
33
|
+
if (idx <= 0) return "/";
|
|
34
|
+
return path.slice(0, idx);
|
|
35
|
+
}
|
|
36
|
+
//#endregion
|
|
37
|
+
//#region src/fs-adapter.ts
|
|
38
|
+
const textDecoder = new TextDecoder();
|
|
39
|
+
const textEncoder = new TextEncoder();
|
|
40
|
+
const FILE_MODE = 420;
|
|
41
|
+
const DIR_MODE = 493;
|
|
42
|
+
/** reference 条目 stat 视为 file(降级表原文)。 */
|
|
43
|
+
function toFsStat(stat) {
|
|
44
|
+
const isDirectory = stat.type === "dir";
|
|
45
|
+
return {
|
|
46
|
+
isFile: !isDirectory,
|
|
47
|
+
isDirectory,
|
|
48
|
+
isSymbolicLink: false,
|
|
49
|
+
mode: isDirectory ? DIR_MODE : FILE_MODE,
|
|
50
|
+
size: stat.size ?? 0,
|
|
51
|
+
mtime: new Date(stat.mtime ?? 0)
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
async function statImpl(fs, path) {
|
|
55
|
+
return toFsStat(await fs.stat(normalizePath(path)));
|
|
56
|
+
}
|
|
57
|
+
async function existsImpl(fs, path) {
|
|
58
|
+
try {
|
|
59
|
+
await fs.stat(normalizePath(path));
|
|
60
|
+
return true;
|
|
61
|
+
} catch {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/** `${dir}/**` 形式的 glob 模式,匹配 dir 子树下的全部文件(不含 dir 自身)。 */
|
|
66
|
+
function subtreeGlobPattern(dir) {
|
|
67
|
+
return dir === "/" ? "/**" : `${dir}/**`;
|
|
68
|
+
}
|
|
69
|
+
/** 把子树内某个文件的绝对路径,重定位到新的根 `destRoot` 下。 */
|
|
70
|
+
function relocate(srcRoot, destRoot, filePath) {
|
|
71
|
+
const prefix = srcRoot === "/" ? "/" : `${srcRoot}/`;
|
|
72
|
+
const rel = filePath.startsWith(prefix) ? filePath.slice(prefix.length) : filePath.slice(srcRoot.length);
|
|
73
|
+
return destRoot === "/" ? `/${rel}` : `${destRoot}/${rel}`;
|
|
74
|
+
}
|
|
75
|
+
/** cp -r / mv 目录场景的共用体:把 src 子树下每个文件 read 出来再 write 到重定位后的 dest 路径。空子目录不参与(RunkoFS.glob 只报文件)。 */
|
|
76
|
+
async function copySubtree(fs, src, dest) {
|
|
77
|
+
const files = await fs.glob(subtreeGlobPattern(src));
|
|
78
|
+
for (const filePath of files) {
|
|
79
|
+
const data = await fs.readFile(filePath);
|
|
80
|
+
await fs.writeFile(relocate(src, dest, filePath), data);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
async function cpImpl(fs, src, dest, options) {
|
|
84
|
+
const srcPath = normalizePath(src);
|
|
85
|
+
const destPath = normalizePath(dest);
|
|
86
|
+
if ((await fs.stat(srcPath)).type === "dir") {
|
|
87
|
+
if (options?.recursive !== true) throw new Error(`cp: -r not specified; omitting directory '${src}'`);
|
|
88
|
+
await copySubtree(fs, srcPath, destPath);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const data = await fs.readFile(srcPath);
|
|
92
|
+
await fs.writeFile(destPath, data);
|
|
93
|
+
}
|
|
94
|
+
async function mvImpl(fs, src, dest) {
|
|
95
|
+
const srcPath = normalizePath(src);
|
|
96
|
+
const destPath = normalizePath(dest);
|
|
97
|
+
if ((await fs.stat(srcPath)).type === "dir") {
|
|
98
|
+
await copySubtree(fs, srcPath, destPath);
|
|
99
|
+
await fs.rm(srcPath, { recursive: true });
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const data = await fs.readFile(srcPath);
|
|
103
|
+
await fs.writeFile(destPath, data);
|
|
104
|
+
await fs.rm(srcPath);
|
|
105
|
+
}
|
|
106
|
+
async function rmImpl(fs, path, options) {
|
|
107
|
+
try {
|
|
108
|
+
await fs.rm(normalizePath(path), { recursive: options?.recursive });
|
|
109
|
+
} catch (error) {
|
|
110
|
+
if (options?.force === true) return;
|
|
111
|
+
throw error;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
async function appendFileImpl(fs, path, addition) {
|
|
115
|
+
const normalized = normalizePath(path);
|
|
116
|
+
let existing;
|
|
117
|
+
try {
|
|
118
|
+
existing = await fs.readFile(normalized);
|
|
119
|
+
} catch {
|
|
120
|
+
existing = /* @__PURE__ */ new Uint8Array(0);
|
|
121
|
+
}
|
|
122
|
+
const combined = new Uint8Array(existing.length + addition.length);
|
|
123
|
+
combined.set(existing, 0);
|
|
124
|
+
combined.set(addition, existing.length);
|
|
125
|
+
await fs.writeFile(normalized, combined);
|
|
126
|
+
}
|
|
127
|
+
function createFsAdapter(fs) {
|
|
128
|
+
let allPathsCache = [];
|
|
129
|
+
async function refreshAllPaths() {
|
|
130
|
+
const files = await fs.glob("**");
|
|
131
|
+
const dirs = /* @__PURE__ */ new Set(["/"]);
|
|
132
|
+
for (const file of files) {
|
|
133
|
+
let dir = dirnameOf(file);
|
|
134
|
+
while (!dirs.has(dir)) {
|
|
135
|
+
dirs.add(dir);
|
|
136
|
+
const parent = dirnameOf(dir);
|
|
137
|
+
if (parent === dir) break;
|
|
138
|
+
dir = parent;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
allPathsCache = [...files, ...dirs];
|
|
142
|
+
}
|
|
143
|
+
return {
|
|
144
|
+
async readFile(path) {
|
|
145
|
+
const bytes = await fs.readFile(normalizePath(path));
|
|
146
|
+
return textDecoder.decode(bytes);
|
|
147
|
+
},
|
|
148
|
+
async readFileBytes(path) {
|
|
149
|
+
const bytes = await fs.readFile(normalizePath(path));
|
|
150
|
+
return unsafeBytesFromLatin1(Buffer.from(bytes).toString("latin1"));
|
|
151
|
+
},
|
|
152
|
+
async readFileBuffer(path) {
|
|
153
|
+
return fs.readFile(normalizePath(path));
|
|
154
|
+
},
|
|
155
|
+
async writeFile(path, content) {
|
|
156
|
+
await fs.writeFile(normalizePath(path), content);
|
|
157
|
+
},
|
|
158
|
+
async appendFile(path, content) {
|
|
159
|
+
await appendFileImpl(fs, path, typeof content === "string" ? textEncoder.encode(content) : content);
|
|
160
|
+
},
|
|
161
|
+
async exists(path) {
|
|
162
|
+
return existsImpl(fs, path);
|
|
163
|
+
},
|
|
164
|
+
async stat(path) {
|
|
165
|
+
return statImpl(fs, path);
|
|
166
|
+
},
|
|
167
|
+
async mkdir(path) {
|
|
168
|
+
await fs.mkdir(normalizePath(path));
|
|
169
|
+
},
|
|
170
|
+
async readdir(path) {
|
|
171
|
+
return (await fs.readdir(normalizePath(path))).map((entry) => entry.name);
|
|
172
|
+
},
|
|
173
|
+
async readdirWithFileTypes(path) {
|
|
174
|
+
return (await fs.readdir(normalizePath(path))).map((entry) => ({
|
|
175
|
+
name: entry.name,
|
|
176
|
+
isFile: entry.type === "file" || entry.type === "reference",
|
|
177
|
+
isDirectory: entry.type === "dir",
|
|
178
|
+
isSymbolicLink: false
|
|
179
|
+
}));
|
|
180
|
+
},
|
|
181
|
+
async rm(path, options) {
|
|
182
|
+
await rmImpl(fs, path, options);
|
|
183
|
+
},
|
|
184
|
+
async cp(src, dest, options) {
|
|
185
|
+
await cpImpl(fs, src, dest, options);
|
|
186
|
+
},
|
|
187
|
+
async mv(src, dest) {
|
|
188
|
+
await mvImpl(fs, src, dest);
|
|
189
|
+
},
|
|
190
|
+
resolvePath(base, path) {
|
|
191
|
+
return resolvePath(base, path);
|
|
192
|
+
},
|
|
193
|
+
getAllPaths() {
|
|
194
|
+
return allPathsCache;
|
|
195
|
+
},
|
|
196
|
+
async chmod() {},
|
|
197
|
+
async symlink(target, linkPath) {
|
|
198
|
+
throw new Error(`just-bash adapter: symlink is not supported (no symlink concept in RunkoFS v1): '${linkPath}' -> '${target}'`);
|
|
199
|
+
},
|
|
200
|
+
async link(existingPath, newPath) {
|
|
201
|
+
throw new Error(`just-bash adapter: hard link is not supported (no symlink concept in RunkoFS v1): '${newPath}' -> '${existingPath}'`);
|
|
202
|
+
},
|
|
203
|
+
async readlink(path) {
|
|
204
|
+
throw new Error(`just-bash adapter: readlink is not supported (no symlink concept in RunkoFS v1): '${path}'`);
|
|
205
|
+
},
|
|
206
|
+
async lstat(path) {
|
|
207
|
+
return statImpl(fs, path);
|
|
208
|
+
},
|
|
209
|
+
async realpath(path) {
|
|
210
|
+
return normalizePath(path);
|
|
211
|
+
},
|
|
212
|
+
async utimes() {},
|
|
213
|
+
refreshAllPaths
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
//#endregion
|
|
217
|
+
//#region src/exec.ts
|
|
218
|
+
/**
|
|
219
|
+
* 默认收紧值(工单裁量:just-bash 自己的出厂默认——如 maxCommandCount 1万、
|
|
220
|
+
* maxLoopIterations 1万、maxOutputSize 10MB——是为通用 CLI 场景设的,对
|
|
221
|
+
* "一次 agent 工具调用里跑的一段脚本"明显过宽。这里按"典型 agent 脚本远小
|
|
222
|
+
* 于这些量级,但仍留足够余量不误伤正常用例"的原则整体收紧一个数量级左右;
|
|
223
|
+
* 全部字段都能被 `opts.limits` 逐个覆盖。)
|
|
224
|
+
*/
|
|
225
|
+
const DEFAULT_EXECUTION_LIMITS = {
|
|
226
|
+
maxCallDepth: 20,
|
|
227
|
+
maxCommandCount: 2e3,
|
|
228
|
+
maxLoopIterations: 2e3,
|
|
229
|
+
maxAwkIterations: 2e3,
|
|
230
|
+
maxSedIterations: 2e3,
|
|
231
|
+
maxJqIterations: 2e3,
|
|
232
|
+
maxSqliteTimeoutMs: 3e3,
|
|
233
|
+
maxGlobOperations: 5e3,
|
|
234
|
+
maxStringLength: 2 * 1024 * 1024,
|
|
235
|
+
maxArrayElements: 1e4,
|
|
236
|
+
maxHeredocSize: 1024 * 1024,
|
|
237
|
+
maxSubstitutionDepth: 20,
|
|
238
|
+
maxBraceExpansionResults: 2e3,
|
|
239
|
+
maxOutputSize: 1024 * 1024,
|
|
240
|
+
maxFileDescriptors: 256,
|
|
241
|
+
maxSourceDepth: 20
|
|
242
|
+
};
|
|
243
|
+
const DESCRIBE = [
|
|
244
|
+
"just-bash: full-syntax bash interpreter (vercel-labs/just-bash), running entirely on the injected RunkoFS",
|
|
245
|
+
"(mode A same-source workspace — bash and the file tools share one filesystem, nothing to reconcile).",
|
|
246
|
+
"Supports the full control-flow surface: if/elif/else, for (list and C-style `for ((i=0;i<n;i++))`),",
|
|
247
|
+
"while, until, case, functions with local variables, variable/parameter expansion, glob expansion,",
|
|
248
|
+
"pipes, `&&`/`||`, and redirections (`>`, `>>`, `<`, `2>&1`).",
|
|
249
|
+
"No symlinks: symlink/link/readlink always fail, and stat never reports a symbolic link.",
|
|
250
|
+
"No network: curl/wget are not registered (no fetch/network config is wired in).",
|
|
251
|
+
"No python/js sub-interpreters: disabled by default for reduced execution surface.",
|
|
252
|
+
"Output is NOT streamed: just-bash reports stdout/stderr only once the whole script has finished, so",
|
|
253
|
+
"onOutput fires at most once per stream, after the command completes (not incrementally as it runs).",
|
|
254
|
+
"cwd is persistent on this instance: a `cd` inside a script carries over to the next exec() call on the",
|
|
255
|
+
"same justBash(fs) instance (same semantics as @runko/mini-bash); an explicit req.cwd only overrides the",
|
|
256
|
+
"starting point for that one call and is not promoted to permanent memory unless a cd actually runs.",
|
|
257
|
+
"Execution limits are enforced (loop iterations, command count, call depth, output size, and more) to",
|
|
258
|
+
"bound runaway scripts; see justBash(fs, { limits }) to override the defaults."
|
|
259
|
+
].join(" ");
|
|
260
|
+
var JustBashAbortedError = class extends Error {
|
|
261
|
+
constructor(reason) {
|
|
262
|
+
super(reason === "timeout" ? "just-bash: command timed out" : "just-bash: aborted");
|
|
263
|
+
this.name = "JustBashAbortedError";
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
/**
|
|
267
|
+
* 同 `@runko/mini-bash` 的 `raceAbort`:`signal` 先触发就立刻 reject,不等
|
|
268
|
+
* `work` 真正落定(`work` 可能因为注入的 fs/glob 永久 pending,这正是需要
|
|
269
|
+
* 竞速的原因)。给 `work` 补一个空 catch,避免它在竞速结束后才 reject/resolve
|
|
270
|
+
* 时产生 unhandled rejection 噪音。
|
|
271
|
+
*/
|
|
272
|
+
function raceAbort(work, signal) {
|
|
273
|
+
return new Promise((resolve, reject) => {
|
|
274
|
+
const settleAborted = () => {
|
|
275
|
+
work.catch(() => {});
|
|
276
|
+
reject(new JustBashAbortedError("signal"));
|
|
277
|
+
};
|
|
278
|
+
if (signal.aborted) {
|
|
279
|
+
settleAborted();
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
const onAbort = () => settleAborted();
|
|
283
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
284
|
+
work.then((value) => {
|
|
285
|
+
signal.removeEventListener("abort", onAbort);
|
|
286
|
+
resolve(value);
|
|
287
|
+
}, (error) => {
|
|
288
|
+
signal.removeEventListener("abort", onAbort);
|
|
289
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
function describeError(error) {
|
|
294
|
+
return error instanceof Error ? error.message : String(error);
|
|
295
|
+
}
|
|
296
|
+
/** `result.env.PWD` 的类型安全读取——`noUncheckedIndexedAccess` 下 `Record<string,string>` 索引即 `string | undefined`,用 typeof 守卫而非断言。 */
|
|
297
|
+
function endingCwdOf(result, fallback) {
|
|
298
|
+
const pwd = result.env.PWD;
|
|
299
|
+
return typeof pwd === "string" ? pwd : fallback;
|
|
300
|
+
}
|
|
301
|
+
function justBash(fs, opts = {}) {
|
|
302
|
+
const adapter = createFsAdapter(fs);
|
|
303
|
+
const bash = new Bash({
|
|
304
|
+
fs: adapter,
|
|
305
|
+
cwd: "/",
|
|
306
|
+
executionLimits: {
|
|
307
|
+
...DEFAULT_EXECUTION_LIMITS,
|
|
308
|
+
...opts.limits
|
|
309
|
+
},
|
|
310
|
+
python: false,
|
|
311
|
+
javascript: false
|
|
312
|
+
});
|
|
313
|
+
let instanceCwd = "/";
|
|
314
|
+
return {
|
|
315
|
+
defaultApproval: "allow",
|
|
316
|
+
describe() {
|
|
317
|
+
return DESCRIBE;
|
|
318
|
+
},
|
|
319
|
+
async exec(req, execOpts) {
|
|
320
|
+
const start = Date.now();
|
|
321
|
+
const startCwd = req.cwd !== void 0 ? resolvePath("/", req.cwd) : instanceCwd;
|
|
322
|
+
const timeoutController = new AbortController();
|
|
323
|
+
const timer = req.timeoutMs !== void 0 ? setTimeout(() => timeoutController.abort(), req.timeoutMs) : void 0;
|
|
324
|
+
const combined = AbortSignal.any([req.signal, timeoutController.signal]);
|
|
325
|
+
const work = (async () => {
|
|
326
|
+
await adapter.refreshAllPaths();
|
|
327
|
+
return bash.exec(req.command, {
|
|
328
|
+
cwd: startCwd,
|
|
329
|
+
signal: combined
|
|
330
|
+
});
|
|
331
|
+
})();
|
|
332
|
+
try {
|
|
333
|
+
const result = await raceAbort(work, combined);
|
|
334
|
+
const endCwd = endingCwdOf(result, startCwd);
|
|
335
|
+
if (endCwd !== startCwd) instanceCwd = endCwd;
|
|
336
|
+
if (result.stdout.length > 0) execOpts?.onOutput?.({
|
|
337
|
+
stream: "stdout",
|
|
338
|
+
data: result.stdout
|
|
339
|
+
});
|
|
340
|
+
if (result.stderr.length > 0) execOpts?.onOutput?.({
|
|
341
|
+
stream: "stderr",
|
|
342
|
+
data: result.stderr
|
|
343
|
+
});
|
|
344
|
+
return {
|
|
345
|
+
exitCode: result.exitCode,
|
|
346
|
+
stdout: result.stdout,
|
|
347
|
+
stderr: result.stderr,
|
|
348
|
+
durationMs: Date.now() - start
|
|
349
|
+
};
|
|
350
|
+
} catch (error) {
|
|
351
|
+
const timedOut = timeoutController.signal.aborted;
|
|
352
|
+
if (error instanceof JustBashAbortedError || timedOut) return {
|
|
353
|
+
exitCode: timedOut ? 124 : 130,
|
|
354
|
+
stdout: "",
|
|
355
|
+
stderr: timedOut ? `just-bash: command timed out after ${String(req.timeoutMs)}ms` : "just-bash: aborted",
|
|
356
|
+
durationMs: Date.now() - start
|
|
357
|
+
};
|
|
358
|
+
return {
|
|
359
|
+
exitCode: 1,
|
|
360
|
+
stdout: "",
|
|
361
|
+
stderr: `just-bash: internal error: ${describeError(error)}`,
|
|
362
|
+
durationMs: Date.now() - start
|
|
363
|
+
};
|
|
364
|
+
} finally {
|
|
365
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
//#endregion
|
|
371
|
+
export { justBash };
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@runko/just-bash",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "RunkoExec 实现:适配 vercel-labs/just-bash 的全语法档 bash(if/for/while/case/函数),跑在任意 RunkoFS 上",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "ISC",
|
|
7
|
+
"publishConfig": {
|
|
8
|
+
"access": "public",
|
|
9
|
+
"registry": "https://registry.npmjs.org/"
|
|
10
|
+
},
|
|
11
|
+
"engines": {
|
|
12
|
+
"node": ">=24"
|
|
13
|
+
},
|
|
14
|
+
"main": "./dist/index.cjs",
|
|
15
|
+
"module": "./dist/index.js",
|
|
16
|
+
"types": "./dist/index.d.ts",
|
|
17
|
+
"exports": {
|
|
18
|
+
".": {
|
|
19
|
+
"import": {
|
|
20
|
+
"types": "./dist/index.d.ts",
|
|
21
|
+
"default": "./dist/index.js"
|
|
22
|
+
},
|
|
23
|
+
"require": {
|
|
24
|
+
"types": "./dist/index.d.cts",
|
|
25
|
+
"default": "./dist/index.cjs"
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"./package.json": "./package.json"
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"dist"
|
|
32
|
+
],
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"just-bash": "^3.1.0",
|
|
35
|
+
"@runko/core": "^0.1.0"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@types/node": "^24.13.3",
|
|
39
|
+
"ai": "^7.0.20",
|
|
40
|
+
"tsdown": "^0.22.4",
|
|
41
|
+
"typescript": "^7.0.2",
|
|
42
|
+
"vitest": "^4.1.10",
|
|
43
|
+
"@runko/virtual-fs": "^0.1.0"
|
|
44
|
+
},
|
|
45
|
+
"scripts": {
|
|
46
|
+
"build": "tsdown",
|
|
47
|
+
"typecheck": "tsc --noEmit",
|
|
48
|
+
"lint": "eslint . --report-unused-disable-directives --max-warnings 0",
|
|
49
|
+
"lint:fix": "eslint . --fix",
|
|
50
|
+
"test": "vitest run"
|
|
51
|
+
}
|
|
52
|
+
}
|