sakura-agent 0.1.1 → 0.1.2
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 +75 -11
- package/README.zh-CN.md +75 -11
- package/dist/packages/env/src/connect-ssh.d.ts +14 -2
- package/dist/packages/env/src/connect-ssh.js +118 -8
- package/dist/packages/env/src/index.d.ts +1 -0
- package/dist/packages/env/src/remote-stream.d.ts +48 -0
- package/dist/packages/env/src/remote-stream.js +143 -0
- package/dist/packages/env/src/remote-types.d.ts +17 -0
- package/dist/packages/env/src/remote-types.js +10 -0
- package/dist/packages/env/src/remote.d.ts +10 -17
- package/dist/packages/env/src/remote.js +29 -16
- package/dist/packages/kernel/src/compaction.d.ts +5 -3
- package/dist/packages/kernel/src/compaction.js +11 -14
- package/dist/packages/llm/src/chat/errors.d.ts +11 -0
- package/dist/packages/llm/src/chat/errors.js +17 -0
- package/dist/packages/llm/src/chat/stream.js +2 -1
- package/dist/packages/plugins/review/src/chain.d.ts +8 -1
- package/dist/packages/plugins/review/src/chain.js +5 -3
- package/dist/packages/plugins/review/src/index.d.ts +8 -0
- package/dist/packages/plugins/review/src/index.js +34 -3
- package/dist/packages/plugins/review/src/reviewer.d.ts +32 -3
- package/dist/packages/plugins/review/src/reviewer.js +115 -27
- package/dist/packages/plugins/tools/src/builtin/image.d.ts +11 -0
- package/dist/packages/plugins/tools/src/builtin/image.js +84 -0
- package/dist/packages/plugins/tools/src/builtin/read.js +26 -12
- package/dist/packages/session/src/projection.js +21 -4
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -2,24 +2,74 @@
|
|
|
2
2
|
|
|
3
3
|
[English](README.md) · [简体中文](README.zh-CN.md)
|
|
4
4
|
|
|
5
|
-
**Sakura is
|
|
5
|
+
**Sakura is the Codex-style coding agent as a library.** The whole loop (tools, sessions, compaction, goals, review, sub-agents) lives in code you own, so defining an agent for one specific job, or starting a fleet of them, takes a few lines instead of a configuration project with plugin switches. It is written in TypeScript and published on npm as `sakura-agent`, with an SDK and a small `sakura` command-line tool; the terminal built on the same SDK is [`sakuracode`](code/README.md).
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { createAgent } from "sakura-agent";
|
|
9
|
+
|
|
10
|
+
const agent = await createAgent({ model }); // this one line
|
|
11
|
+
const run = agent.run("Add tests for parse() and make them pass.");
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
That one line already gives a complete Codex-style agent: the `read`, `write`, `edit` and `bash` tools, project instructions (`AGENTS.md`), automatic compaction when the context fills, automatic recovery from replies that were cut off or came back empty, and budgets you can set. Give it a `sessionFile` to resume, fork and rewind; give it a `goal` to have it keep working toward one.
|
|
6
15
|
|
|
7
16
|
## Why Sakura
|
|
8
17
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
18
|
+
**You own the complete inside of a Codex-style agent, instead of using a finished product you cannot change.** Everything else follows from that.
|
|
19
|
+
|
|
20
|
+
### 1. One line to start, and the default is complete
|
|
21
|
+
|
|
22
|
+
You do not pick plugins first. The default set is what a command-line agent needs; when you want more (review, redaction, sub-agents, MCP), each is one more line.
|
|
23
|
+
|
|
24
|
+
### 2. Specialising an agent means changing parameters, not flipping switches
|
|
25
|
+
|
|
26
|
+
A purpose is a few parameters: the directory the agent works in, the tools it gets, what it is told, and what it has to achieve.
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
// a read-only reviewer
|
|
30
|
+
await createAgent({ model, tools: ["read"], rules: ["Review only; do not change any file."] });
|
|
31
|
+
|
|
32
|
+
// an agent with a goal and a check: it may not stop while the check fails
|
|
33
|
+
await createAgent({ model, goal: { objective: "Make all tests pass", verify: "npm test" } });
|
|
34
|
+
|
|
35
|
+
// tools running on another machine, with automatic review of what runs there
|
|
36
|
+
const environment = await createSshEnvironment({ host: "agent@box", cwd: "/srv/job" });
|
|
37
|
+
await createAgent({ model, environment, plugins: [...corePlugins(), reviewPlugin({ llm: reviewerModel })] });
|
|
38
|
+
|
|
39
|
+
// an agent that can hand work to sub-agents (they inherit review and redaction)
|
|
40
|
+
await createAgent({ model, subAgents: {} });
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### 3. A fleet of agents is cheap
|
|
44
|
+
|
|
45
|
+
An agent is an object: its own working directory, its own `Environment`, its own session file, and nothing read from the process's current directory. So a fleet is an array and `Promise.all`:
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
const results = await Promise.all(
|
|
49
|
+
jobs.map(async (job) => {
|
|
50
|
+
const agent = await createAgent({ model, cwd: job.dir, confine: true, tools: job.tools, rules: job.rules, sessionFile: job.session });
|
|
51
|
+
const run = agent.run(job.task);
|
|
52
|
+
for await (const _event of run.events) {} // write the events to your own log here
|
|
53
|
+
return (await run.result).reason;
|
|
54
|
+
}),
|
|
55
|
+
);
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
A complete example you can run is [`examples/fleet.ts`](examples/fleet.ts) (a scripted model when there is no API key, so it works offline: `npm run example:fleet`): three agents for documentation, tests and review, each in its own directory and all running at once; the read-only review agent cannot have written a file.
|
|
59
|
+
|
|
60
|
+
### 4. Where it can reach, who reviews it and what it may spend are options in your code
|
|
61
|
+
|
|
62
|
+
- **Where it can reach:** `confine: true` keeps the file tools inside the directory however a path is written (absolute, `..`, a link); `createSshEnvironment` runs the tools on another machine over one SSH login; any function that can run a shell command can be an environment.
|
|
63
|
+
- **Who reviews it:** the `review` plugin judges risky tool calls with hard rules, then a reviewer model, and a circuit breaker stops a run that keeps being refused; every decision is recorded in the session log. Agents can run unattended. An agent with no shell (file tools plus a checker that answers true or false) needs nothing more than its confined directory, so no second model has to run.
|
|
64
|
+
- **What it may spend:** `limits` covers turns, tokens, cost and run time.
|
|
15
65
|
|
|
16
66
|
## How it compares
|
|
17
67
|
|
|
18
|
-
|
|
68
|
+
Tools such as Codex CLI and pi are finished products you use as they are; Sakura gives you the parts inside such a product, to take apart, recombine and put into your own product or a fleet of agents. The comparison is from their public documentation (November 2026).
|
|
19
69
|
|
|
20
70
|
| | Codex CLI | pi | Sakura |
|
|
21
71
|
|---|---|---|---|
|
|
22
|
-
| What it is | a terminal agent product; `exec --json` for automation; can run as an MCP server | a "minimal, extensible agent harness"; interactive, print/JSON and RPC modes, a TypeScript SDK | a library first
|
|
72
|
+
| What it is | a terminal agent product; `exec --json` for automation; can run as an MCP server | a "minimal, extensible agent harness"; interactive, print/JSON and RPC modes, a TypeScript SDK | a library first: the whole agent is in your code; with a minimal CLI and a separate terminal |
|
|
23
73
|
| Keeping actions safe | sandbox modes (read-only, workspace-write, full access) and approval policies | not described in its README | a reviewer with hard rules and a breaker, no person needed; `confine` for the file tools; no sandbox of its own |
|
|
24
74
|
| Sub-agents, plan mode | not checked | left out by design | sub-agents built in (opt-in) |
|
|
25
75
|
| Where the tools run | this machine, in a sandbox | not described in its README | this machine, SSH, or any function, chosen per agent |
|
|
@@ -29,11 +79,11 @@ Sources: [Codex CLI documentation](https://developers.openai.com/codex), [pi REA
|
|
|
29
79
|
|
|
30
80
|
## Who it is for
|
|
31
81
|
|
|
32
|
-
- You run agents **without watching them**: batch jobs, many agents, remote machines, or an agent inside your own product.
|
|
33
82
|
- You want to define an agent in code and control its model, session, tools and reach from there.
|
|
83
|
+
- You want many agents, each with a specific job: batch work, remote machines, or an agent inside your own product, running without anyone watching.
|
|
34
84
|
- You want review and secret redaction as separate, testable pieces rather than flags inside a monolith, and a loop you can read in one sitting.
|
|
35
85
|
|
|
36
|
-
It is not an all-in-one assistant for your own laptop, and it does not ship a sandbox: the default local environment uses the permissions of the calling process. `confine` is a boundary for the file tools, not for a shell: a shell command can still reach anything, so give an agent a shell only together with the review, or run untrusted work in an
|
|
86
|
+
It is not an all-in-one assistant for your own laptop (for that, use [`sakuracode`](code/README.md): `npm install -g sakuracode`), and it does not ship a sandbox: the default local environment uses the permissions of the calling process. `confine` is a boundary for the file tools, not for a shell: a shell command can still reach anything, so give an agent a shell only together with the review, or run untrusted work in an isolation you provide yourself.
|
|
37
87
|
|
|
38
88
|
## Where an agent can reach
|
|
39
89
|
|
|
@@ -61,6 +111,19 @@ const environment = await createSshEnvironment({ host: "agent@box", cwd: "/srv/j
|
|
|
61
111
|
const remote = await createAgent({ model, environment, plugins: [...corePlugins(), reviewPlugin({ llm: reviewerModel })] });
|
|
62
112
|
```
|
|
63
113
|
|
|
114
|
+
## What we measured
|
|
115
|
+
|
|
116
|
+
Numbers from real runs, each with the conditions it was taken under. They describe those runs; they are not guarantees, and provider behaviour can change.
|
|
117
|
+
|
|
118
|
+
| What | Result | Conditions |
|
|
119
|
+
|---|---|---|
|
|
120
|
+
| Prompt cache | The median request read **98.8 %** of its input from the provider's cache; 95.7 % of all input tokens (97.7 % without the first request after each compaction) | One real coding session on `deepseek-v4.1-flash` via OpenCode Go: 20.8 hours, 1,817 model requests. Compaction and review requests are not counted. Caching is done by the provider |
|
|
121
|
+
| Long unattended run | **20.8 hours**, 1,817 model requests, 1,766 tool calls, no provider error and no crash; the process stayed at about 160 MB | The same session, measured over those first 20.8 hours |
|
|
122
|
+
| Automatic review | **445** reviewed calls in the next 15 hours: **0** technical failures of the reviewer, 2 refusals (a cancelled run, and an `rm -rf` of the working directory) | The same session after the reviewer's conversation was bounded and a failed review is retried once. Before that, 9 reviews failed technically because the reviewer's own conversation had grown too long |
|
|
123
|
+
| Benchmark | No complete Terminal-Bench 2.1 score yet, so none is published | A partial run exists; it is not a score |
|
|
124
|
+
|
|
125
|
+
One limit to know: later in that same session, an error the provider sent inside a stream stopped the run once. Such errors are now classified like the same error as an HTTP status and retried.
|
|
126
|
+
|
|
64
127
|
## At a glance
|
|
65
128
|
|
|
66
129
|
- **What it is:** a library for TypeScript code, plus a minimal CLI (`sakura run`, `sakura resume`) and, in the repository, a terminal.
|
|
@@ -70,6 +133,7 @@ const remote = await createAgent({ model, environment, plugins: [...corePlugins(
|
|
|
70
133
|
- **Persistence:** an append-only, tree-shaped JSONL session file. A crashed run continues from the same file.
|
|
71
134
|
- **Plugins on by default:** `tools`, `context`, `resilience`, `limits`. **Opt in:** `review`, `redaction`, sub-agents, MCP client, tool search, code mode.
|
|
72
135
|
- **Safety plugins fail closed:** if the review or the redaction cannot do its job, the action or request does not go ahead.
|
|
136
|
+
- **Tests:** over 5,000 tests in the public tree, contract suites that run one test set against every implementation of an interface, and live tests against real models and a real SSH server.
|
|
73
137
|
- **License:** MIT.
|
|
74
138
|
|
|
75
139
|
## How it is built
|
package/README.zh-CN.md
CHANGED
|
@@ -2,24 +2,74 @@
|
|
|
2
2
|
|
|
3
3
|
[English](README.md) · [简体中文](README.zh-CN.md)
|
|
4
4
|
|
|
5
|
-
**Sakura
|
|
5
|
+
**Sakura 把 Codex 式的编程 Agent 做成了库。** 完整的循环(工具、会话、压缩、目标、审核、子 Agent)都在你自己的代码里:定义一个特定用途的 Agent,或者起一群 Agent,只要几行代码,不用折腾配置文件和插件开关。它是 TypeScript 写的,以 `sakura-agent` 这个 npm 包发布,包含 SDK 和一个小的 `sakura` 命令行工具;基于同一个 SDK 做出来的终端是 [`sakuracode`](code/README.md)。
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { createAgent } from "sakura-agent";
|
|
9
|
+
|
|
10
|
+
const agent = await createAgent({ model }); // 就这一行
|
|
11
|
+
const run = agent.run("给 parse() 补上测试,并让测试通过。");
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
这一行得到的已经是一个完整的 Codex 式 Agent:`read`、`write`、`edit`、`bash` 四个工具,项目说明(`AGENTS.md`),上下文快满时自动压缩,截断或为空的回复自动补救,可设置的预算;指定 `sessionFile` 就能续接、分叉和回退,设置 `goal` 就能让它朝一个目标一直做下去。
|
|
6
15
|
|
|
7
16
|
## 为什么用 Sakura
|
|
8
17
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
18
|
+
**你拥有 Codex 式 Agent 的完整内部,而不是一个只能用的成品。** 其余都是它的推论。
|
|
19
|
+
|
|
20
|
+
### 1. 一行起步,默认就是完整的
|
|
21
|
+
|
|
22
|
+
不需要先选一堆插件。默认的一套已经是命令行 Agent 该有的;需要更多(审核、脱敏、子 Agent、MCP)时,每一项都是再加一行。
|
|
23
|
+
|
|
24
|
+
### 2. 特化一个 Agent,是改参数,不是翻开关
|
|
25
|
+
|
|
26
|
+
一个用途就是几个参数:它在哪个目录、拿到哪些工具、被告知什么、要完成什么。
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
// 只读的审阅 Agent
|
|
30
|
+
await createAgent({ model, tools: ["read"], rules: ["只审阅,不修改任何文件。"] });
|
|
31
|
+
|
|
32
|
+
// 有目标和验证命令的 Agent:验证不通过就不允许停
|
|
33
|
+
await createAgent({ model, goal: { objective: "让所有测试通过", verify: "npm test" } });
|
|
34
|
+
|
|
35
|
+
// 工具跑在另一台机器上,并对在那里运行的命令开启自动审核
|
|
36
|
+
const environment = await createSshEnvironment({ host: "agent@box", cwd: "/srv/job" });
|
|
37
|
+
await createAgent({ model, environment, plugins: [...corePlugins(), reviewPlugin({ llm: reviewerModel })] });
|
|
38
|
+
|
|
39
|
+
// 能派子 Agent 的 Agent(子 Agent 继承审核和脱敏)
|
|
40
|
+
await createAgent({ model, subAgents: {} });
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### 3. 起一群 Agent 很便宜
|
|
44
|
+
|
|
45
|
+
一个 Agent 就是一个对象:自己的工作目录、自己的 `Environment`、自己的会话文件,不读进程的当前目录。所以一群 Agent 就是一个数组加 `Promise.all`:
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
const results = await Promise.all(
|
|
49
|
+
jobs.map(async (job) => {
|
|
50
|
+
const agent = await createAgent({ model, cwd: job.dir, confine: true, tools: job.tools, rules: job.rules, sessionFile: job.session });
|
|
51
|
+
const run = agent.run(job.task);
|
|
52
|
+
for await (const _event of run.events) {} // 在这里把事件写进你自己的日志
|
|
53
|
+
return (await run.result).reason;
|
|
54
|
+
}),
|
|
55
|
+
);
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
完整的、可以直接运行的例子在 [`examples/fleet.ts`](examples/fleet.ts)(没有 API 密钥时用脚本化的模型,离线就能跑:`npm run example:fleet`):三个 Agent,分别负责文档、测试、审阅,各在自己的目录里同时运行,只读的审阅 Agent 不可能写出文件。
|
|
59
|
+
|
|
60
|
+
### 4. 它能到哪里、谁来审、花多少,都是代码里的选项
|
|
61
|
+
|
|
62
|
+
- **能到哪里:** `confine: true` 让文件工具出不了这个目录,不管路径怎么写(绝对路径、`..`、链接);`createSshEnvironment` 用一次 SSH 登录在另一台机器上运行工具;任何能执行 shell 命令的函数都可以做环境。
|
|
63
|
+
- **谁来审:** `review` 插件先用硬规则、再用审核模型判断有风险的工具调用,熔断器会让一直被拒绝的运行停下来,每一次决定都记录在会话日志里。Agent 可以无人值守。没有 shell 的 Agent(只有文件工具和一个回答真或假的检查器)有了限定的目录就够了,不用再运行第二个模型。
|
|
64
|
+
- **花多少:** `limits` 管轮数、token、费用和运行时间。
|
|
15
65
|
|
|
16
66
|
## 和别的工具比
|
|
17
67
|
|
|
18
|
-
|
|
68
|
+
Codex CLI、pi 这样的工具是成品,直接拿来用;Sakura 给你的是成品里面的那些零件,你可以拆开、重新组合、放进自己的产品或者一群 Agent 里。下面依据它们公开的文档(2026 年 11 月)。
|
|
19
69
|
|
|
20
70
|
| | Codex CLI | pi | Sakura |
|
|
21
71
|
|---|---|---|---|
|
|
22
|
-
| 是什么 | 终端 Agent 产品;`exec --json` 用于自动化;可以作为 MCP 服务运行 | “极简、可扩展的 Agent 工具集”;交互、打印 / JSON、RPC 三种模式,有 TypeScript SDK |
|
|
72
|
+
| 是什么 | 终端 Agent 产品;`exec --json` 用于自动化;可以作为 MCP 服务运行 | “极简、可扩展的 Agent 工具集”;交互、打印 / JSON、RPC 三种模式,有 TypeScript SDK | 以库为主:整个 Agent 在你的代码里;带一个最小命令行和一个单独的终端 |
|
|
23
73
|
| 怎么保证动作安全 | 沙箱模式(只读、工作区可写、完全访问)加批准策略 | 其 README 没有说明 | 带硬规则和熔断器的审核,不需要人;文件工具有 `confine`;自己不带沙箱 |
|
|
24
74
|
| 子 Agent、计划模式 | 没有核对 | 有意不做 | 内置子 Agent(按需开启) |
|
|
25
75
|
| 工具在哪里运行 | 本机,在沙箱里 | 其 README 没有说明 | 本机、SSH,或任何函数,每个 Agent 各自选择 |
|
|
@@ -29,11 +79,11 @@
|
|
|
29
79
|
|
|
30
80
|
## 适合谁
|
|
31
81
|
|
|
32
|
-
- 你让 Agent **无人看着也能运行**:批处理、很多个 Agent、远程机器,或者放在你自己的产品里。
|
|
33
82
|
- 你想用代码定义 Agent,并从那里控制它的模型、会话、工具和能触到的范围。
|
|
83
|
+
- 你要起很多个 Agent,每个有特定用途:批处理、远程机器,或者放在你自己的产品里,无人看着也能运行。
|
|
34
84
|
- 你想要把审核和密钥脱敏做成独立、可测试的部件,而不是一个大程序里的开关,以及一个坐下来一次就能读完的循环。
|
|
35
85
|
|
|
36
|
-
|
|
86
|
+
它不是给你自己笔记本用的全能助手(那种需求直接用 [`sakuracode`](code/README.md):`npm install -g sakuracode`),也不带沙箱:默认的本机环境使用调用进程的权限。`confine` 是文件工具的边界,不是 shell 的边界:shell 命令仍然能触到任何地方,所以给 Agent 一个 shell 时要同时开启审核,或者把不可信的任务放在你自己的隔离环境里运行。
|
|
37
87
|
|
|
38
88
|
## Agent 能触到哪里
|
|
39
89
|
|
|
@@ -61,6 +111,19 @@ const environment = await createSshEnvironment({ host: "agent@box", cwd: "/srv/j
|
|
|
61
111
|
const remote = await createAgent({ model, environment, plugins: [...corePlugins(), reviewPlugin({ llm: reviewerModel })] });
|
|
62
112
|
```
|
|
63
113
|
|
|
114
|
+
## 我们测过什么
|
|
115
|
+
|
|
116
|
+
下面是真实运行里测到的数字,每一项都写明了条件。它们描述的是那几次运行,不是保证,供应商的行为也可能变化。
|
|
117
|
+
|
|
118
|
+
| 项目 | 结果 | 条件 |
|
|
119
|
+
|---|---|---|
|
|
120
|
+
| 提示缓存 | 请求的中位数有 **98.8%** 的输入来自供应商缓存;按 token 总量计是 95.7%(不计每次压缩后的第一个请求时是 97.7%) | 一次真实的编码会话,模型 `deepseek-v4.1-flash`,经 OpenCode Go:20.8 小时,1,817 次模型请求。压缩请求和审核请求不在统计内。缓存由供应商负责 |
|
|
121
|
+
| 长时间无人值守 | 连续 **20.8 小时**,1,817 次模型请求,1,766 次工具调用,没有供应商错误,没有崩溃;进程内存稳定在约 160 MB | 同一次会话,统计的是最初这 20.8 小时 |
|
|
122
|
+
| 自动审核 | 之后 15 小时里审核了 **445** 次调用:审核模型**技术失败 0 次**,拒绝 2 次(一次是被取消的运行,一次是 `rm -rf` 工作目录) | 同一次会话,在限制了审核模型对话长度、并且审核失败会重试一次之后。在此之前有 9 次审核因技术原因失败,原因是审核模型自己的对话变得太长 |
|
|
123
|
+
| 基准测试 | Terminal-Bench 2.1 还没有完整分数,所以不公布分数 | 有一次不完整的运行,它不是分数 |
|
|
124
|
+
|
|
125
|
+
需要知道的一个局限:在同一次会话的后段,供应商在流中途发来的一个错误让运行停了一次。这类错误现在会按同样的 HTTP 状态一样分类并重试。
|
|
126
|
+
|
|
64
127
|
## 一眼看懂
|
|
65
128
|
|
|
66
129
|
- **是什么:** 供 TypeScript 代码调用的库,外加一个最小的命令行(`sakura run`、`sakura resume`),仓库里还有一个终端。
|
|
@@ -70,6 +133,7 @@ const remote = await createAgent({ model, environment, plugins: [...corePlugins(
|
|
|
70
133
|
- **持久化:** 只追加的树形 JSONL 会话文件;崩溃的运行可以在同一个文件上继续。
|
|
71
134
|
- **默认启用的插件:** `tools`、`context`、`resilience`、`limits`。**按需开启:** `review`、`redaction`、子 Agent、MCP 客户端、工具搜索、代码模式。
|
|
72
135
|
- **安全类插件出错即拒绝:** 审核或脱敏没能完成工作时,动作或请求不会放行。
|
|
136
|
+
- **测试:** 公开仓库里有 5000 多个测试,有对每个接口的所有实现跑同一套用例的契约测试,还有对真实模型和真实 SSH 服务器的实测。
|
|
73
137
|
- **许可:** MIT。
|
|
74
138
|
|
|
75
139
|
## 怎么搭的
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { RemoteRunner } from "./remote.js";
|
|
2
|
+
import type { RemoteStreamer } from "./remote-stream.js";
|
|
2
3
|
export interface SshOptions {
|
|
3
4
|
/** What to connect to: a name from ~/.ssh/config, or user@host. Authentication, keys and host keys are whatever ssh is set up to do. */
|
|
4
5
|
host: string;
|
|
@@ -10,18 +11,29 @@ export interface SshOptions {
|
|
|
10
11
|
connectTimeoutSec?: number;
|
|
11
12
|
/** The ssh program to run (default "ssh"). */
|
|
12
13
|
ssh?: string;
|
|
14
|
+
/**
|
|
15
|
+
* How many commands may be running over the connection at once (default 8). A server accepts only so many sessions on one connection (sshd's
|
|
16
|
+
* MaxSessions is 10 unless changed) and refuses the rest, so the others wait here for a free place. A command that runs in the background keeps its place.
|
|
17
|
+
* Two of the places are kept for short jobs (reading a file, stopping a command), so at most this number minus two commands run at once.
|
|
18
|
+
*/
|
|
19
|
+
maxSessions?: number;
|
|
13
20
|
}
|
|
14
21
|
export interface SshConnection {
|
|
15
22
|
/** Runs a command on the remote machine over the connection. Rejects with RemoteConnectionError when ssh could not reach it. */
|
|
16
23
|
run: RemoteRunner;
|
|
24
|
+
/** Runs a command as a streamed child of the connection: its output arrives as it is produced and the child ends when the command does. */
|
|
25
|
+
stream: RemoteStreamer;
|
|
26
|
+
/** The directory on this machine that holds the socket of the connection (for diagnostics and tests). */
|
|
27
|
+
readonly socketDirectory: string;
|
|
17
28
|
/** Hangs up and removes what was made on this machine. Safe to call more than once. */
|
|
18
29
|
close(): Promise<void>;
|
|
19
30
|
}
|
|
20
31
|
/**
|
|
21
32
|
* Opens one connection to the machine and runs every command over it. The first ssh logs in (once) and stays in the background; each later ssh finds it
|
|
22
33
|
* through a socket in a directory of ours and only starts a new process on the other side, so a command costs milliseconds and not a login. If the
|
|
23
|
-
* connection dies
|
|
24
|
-
*
|
|
34
|
+
* connection dies it is NOT made again: commands are run with ControlMaster=no and ProxyCommand=false, so an ssh that finds no live master has nowhere to
|
|
35
|
+
* go and fails at once (by default, ssh_config(5) says, a client that cannot use the socket makes a connection of its own, silently and with a new login).
|
|
36
|
+
* The result is a RemoteConnectionError that tells the caller to stop and report it.
|
|
25
37
|
*/
|
|
26
38
|
export declare function connectSsh(options: SshOptions): Promise<SshConnection>;
|
|
27
39
|
/** What the model is told about where its tools run, over ssh. */
|
|
@@ -53,11 +53,83 @@ function runSsh(program, args, options) {
|
|
|
53
53
|
}, 100));
|
|
54
54
|
});
|
|
55
55
|
}
|
|
56
|
+
/** Runs ssh on this machine and passes on what it prints as it prints it. What ssh itself says (its stderr) is kept apart from the command's output. */
|
|
57
|
+
function streamSsh(program, args, options) {
|
|
58
|
+
return new Promise((resolve, reject) => {
|
|
59
|
+
const child = spawn(program, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
60
|
+
let stderr = "";
|
|
61
|
+
child.stdout.setEncoding("utf8");
|
|
62
|
+
child.stdout.on("data", (text) => options.onData(text));
|
|
63
|
+
child.stderr.on("data", (chunk) => {
|
|
64
|
+
stderr = (stderr + chunk.toString("utf8")).slice(-4000);
|
|
65
|
+
});
|
|
66
|
+
const stop = () => {
|
|
67
|
+
child.kill("SIGTERM");
|
|
68
|
+
setTimeout(() => child.kill("SIGKILL"), 1000).unref();
|
|
69
|
+
};
|
|
70
|
+
if (options.signal?.aborted)
|
|
71
|
+
stop();
|
|
72
|
+
options.signal?.addEventListener("abort", stop, { once: true });
|
|
73
|
+
child.on("error", (error) => reject(new RemoteConnectionError(`could not run ${program}: ${error.message}`)));
|
|
74
|
+
// "close" comes after the last byte of both pipes was read, so nothing the command printed is lost
|
|
75
|
+
child.on("close", (code) => {
|
|
76
|
+
options.signal?.removeEventListener("abort", stop);
|
|
77
|
+
resolve({ exitCode: code, stderr });
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
/** How long a command waits for a free place before it is given up with an error. */
|
|
82
|
+
const SESSION_WAIT_MS = 60_000;
|
|
83
|
+
/** A fixed number of places, handed out in the order they were asked for. `take` resolves with the function that gives the place back. */
|
|
84
|
+
function createPlaces(count) {
|
|
85
|
+
let free = count;
|
|
86
|
+
const waiting = [];
|
|
87
|
+
const giveBack = () => {
|
|
88
|
+
const next = waiting.shift();
|
|
89
|
+
if (next)
|
|
90
|
+
next.grant();
|
|
91
|
+
else
|
|
92
|
+
free++;
|
|
93
|
+
};
|
|
94
|
+
const release = () => {
|
|
95
|
+
let given = false;
|
|
96
|
+
return () => {
|
|
97
|
+
if (given)
|
|
98
|
+
return;
|
|
99
|
+
given = true;
|
|
100
|
+
giveBack();
|
|
101
|
+
};
|
|
102
|
+
};
|
|
103
|
+
return {
|
|
104
|
+
take(waitMs, tooLong) {
|
|
105
|
+
if (free > 0) {
|
|
106
|
+
free--;
|
|
107
|
+
return Promise.resolve(release());
|
|
108
|
+
}
|
|
109
|
+
return new Promise((resolve, reject) => {
|
|
110
|
+
const entry = {
|
|
111
|
+
grant: () => {
|
|
112
|
+
clearTimeout(timer);
|
|
113
|
+
resolve(release());
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
const timer = setTimeout(() => {
|
|
117
|
+
waiting.splice(waiting.indexOf(entry), 1);
|
|
118
|
+
reject(tooLong());
|
|
119
|
+
}, waitMs);
|
|
120
|
+
waiting.push(entry);
|
|
121
|
+
});
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
/** How often an idle connection is touched, so that it is not given up by ControlPersist while the session is still there (the master is meant to live as long as we do). */
|
|
126
|
+
const KEEP_ALIVE_MS = 4 * 60_000;
|
|
56
127
|
/**
|
|
57
128
|
* Opens one connection to the machine and runs every command over it. The first ssh logs in (once) and stays in the background; each later ssh finds it
|
|
58
129
|
* through a socket in a directory of ours and only starts a new process on the other side, so a command costs milliseconds and not a login. If the
|
|
59
|
-
* connection dies
|
|
60
|
-
*
|
|
130
|
+
* connection dies it is NOT made again: commands are run with ControlMaster=no and ProxyCommand=false, so an ssh that finds no live master has nowhere to
|
|
131
|
+
* go and fails at once (by default, ssh_config(5) says, a client that cannot use the socket makes a connection of its own, silently and with a new login).
|
|
132
|
+
* The result is a RemoteConnectionError that tells the caller to stop and report it.
|
|
61
133
|
*/
|
|
62
134
|
export async function connectSsh(options) {
|
|
63
135
|
const program = options.ssh ?? "ssh";
|
|
@@ -91,22 +163,58 @@ export async function connectSsh(options) {
|
|
|
91
163
|
await cleanup();
|
|
92
164
|
throw error;
|
|
93
165
|
}
|
|
166
|
+
/** Arguments for one command: through the master or nowhere. Ours come first because with ssh the first value of an option wins over later ones. */
|
|
167
|
+
const throughMaster = (remote) => ["-o", "ControlMaster=no", "-o", "ProxyCommand=false", ...common, options.host, remote];
|
|
168
|
+
const goneBecause = (detail) => new RemoteConnectionError(`the connection to ${options.host} is gone and is not made again by itself: ${detail || "ssh exited with code 255"}`);
|
|
169
|
+
const remoteCommand = (command, cwd) => `${cwd !== undefined ? `cd ${quote(cwd)} && ` : ""}bash -c ${quote(command)}`;
|
|
170
|
+
// Two places stay for short jobs (files, stopping a command), so that commands that run for long cannot keep a command from being stopped.
|
|
171
|
+
const SHORT_PLACES = 2;
|
|
172
|
+
const maxSessions = Math.max(SHORT_PLACES + 1, options.maxSessions ?? 8);
|
|
173
|
+
const commandPlaces = createPlaces(maxSessions - SHORT_PLACES);
|
|
174
|
+
const shortPlaces = createPlaces(SHORT_PLACES);
|
|
175
|
+
const waitForPlace = (places, limit) => places.take(SESSION_WAIT_MS, () => new RemoteConnectionError(`too many commands are running at once over the connection to ${options.host} (at most ${limit}); none ended within ${SESSION_WAIT_MS / 1000} seconds. Wait for one to finish or stop one, then try again.`));
|
|
94
176
|
const run = async (command, runOptions) => {
|
|
95
177
|
if (closed)
|
|
96
178
|
throw new RemoteConnectionError("the connection was closed");
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
179
|
+
const giveBack = await waitForPlace(shortPlaces, SHORT_PLACES);
|
|
180
|
+
try {
|
|
181
|
+
const result = await runSsh(program, throughMaster(remoteCommand(command, runOptions.cwd)), { timeoutMs: runOptions.timeoutMs, signal: runOptions.signal });
|
|
182
|
+
// ssh itself exits with 255 when it could not reach the machine; a command that exits with 255 on its own is taken for that too
|
|
183
|
+
if (result.exitCode === 255 && !result.timedOut && !runOptions.signal?.aborted) {
|
|
184
|
+
throw new RemoteConnectionError(`${goneBecause(result.stderr.trim()).message}. The command may or may not have run. Stop here and tell the user that the connection was lost.`);
|
|
185
|
+
}
|
|
186
|
+
return { exitCode: result.exitCode, output: result.output, timedOut: result.timedOut };
|
|
187
|
+
}
|
|
188
|
+
finally {
|
|
189
|
+
giveBack();
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
const stream = async (command, streamOptions) => {
|
|
193
|
+
if (closed)
|
|
194
|
+
throw new RemoteConnectionError("the connection was closed");
|
|
195
|
+
const giveBack = await waitForPlace(commandPlaces, maxSessions - SHORT_PLACES);
|
|
196
|
+
try {
|
|
197
|
+
const outcome = await streamSsh(program, throughMaster(remoteCommand(command, streamOptions.cwd)), { signal: streamOptions.signal, onData: streamOptions.onData });
|
|
198
|
+
// the command's own code travels in its output; the child itself ends with 255 only when ssh could not do its job
|
|
199
|
+
if (outcome.exitCode === 255 && !streamOptions.signal?.aborted)
|
|
200
|
+
throw goneBecause(outcome.stderr.trim());
|
|
201
|
+
return outcome;
|
|
202
|
+
}
|
|
203
|
+
finally {
|
|
204
|
+
giveBack();
|
|
205
|
+
}
|
|
103
206
|
};
|
|
207
|
+
const keepAlive = setInterval(() => void run("true", {}).catch(() => undefined), KEEP_ALIVE_MS);
|
|
208
|
+
keepAlive.unref();
|
|
104
209
|
return {
|
|
105
210
|
run,
|
|
211
|
+
stream,
|
|
212
|
+
socketDirectory: directory,
|
|
106
213
|
async close() {
|
|
107
214
|
if (closed)
|
|
108
215
|
return;
|
|
109
216
|
closed = true;
|
|
217
|
+
clearInterval(keepAlive);
|
|
110
218
|
await runSsh(program, [...common, "-O", "exit", options.host], {}).catch(() => undefined);
|
|
111
219
|
await cleanup();
|
|
112
220
|
},
|
|
@@ -117,6 +225,7 @@ export function describeSsh(host, cwd) {
|
|
|
117
225
|
return (`a remote machine (${host}), not the computer that runs you. Every command is sent over an SSH connection and starts fresh in ${cwd}: cd and export do not carry over to the next command. ` +
|
|
118
226
|
"The connection can be slow or drop, and a command can fail for reasons that have nothing to do with it, so results are not guaranteed to be repeatable. " +
|
|
119
227
|
"If a tool says the connection failed, the command may or may not have run: look at the current state (list the files, read the output file) before you repeat anything that changes something. " +
|
|
228
|
+
"A lost connection is not made again by itself: when a tool says it was lost, stop and tell the user. " +
|
|
120
229
|
"Files are on the remote machine.");
|
|
121
230
|
}
|
|
122
231
|
/** An environment whose tools run on another machine over ssh. Close it (or dispose of the agent) to hang up. */
|
|
@@ -125,6 +234,7 @@ export async function createSshEnvironment(options) {
|
|
|
125
234
|
return createRemoteEnvironment({
|
|
126
235
|
cwd: options.cwd,
|
|
127
236
|
run: connection.run,
|
|
237
|
+
stream: connection.stream,
|
|
128
238
|
close: connection.close,
|
|
129
239
|
description: describeSsh(options.host, options.cwd),
|
|
130
240
|
...(options.workDir !== undefined ? { workDir: options.workDir } : {}),
|
|
@@ -3,5 +3,6 @@ export type { LocalOptions } from "./local.js";
|
|
|
3
3
|
export { OutsideDirectoryError } from "./confine.js";
|
|
4
4
|
export { createRemoteEnvironment, RemoteConnectionError } from "./remote.js";
|
|
5
5
|
export type { RemoteRunner } from "./remote.js";
|
|
6
|
+
export type { RemoteStreamer } from "./remote-stream.js";
|
|
6
7
|
export { connectSsh, createSshEnvironment } from "./connect-ssh.js";
|
|
7
8
|
export type { SshConnection, SshOptions } from "./connect-ssh.js";
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { ProcessHandle } from "../../kernel/src/index.js";
|
|
2
|
+
import type { RemoteRunner } from "./remote-types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Runs one command on the other side as a child that lives as long as the command is watched, and hands over what it prints as it is printed. Resolves
|
|
5
|
+
* with the exit code of that child (null when it was stopped by a signal) and what it wrote to its own error stream, which is the transport's talk (ssh
|
|
6
|
+
* says why it could not connect) and never the command's. Rejects with a RemoteConnectionError when the child could not be started at all.
|
|
7
|
+
*/
|
|
8
|
+
export type RemoteStreamer = (command: string, options: {
|
|
9
|
+
cwd?: string;
|
|
10
|
+
signal?: AbortSignal;
|
|
11
|
+
onData(text: string): void;
|
|
12
|
+
}) => Promise<{
|
|
13
|
+
exitCode: number | null;
|
|
14
|
+
stderr: string;
|
|
15
|
+
}>;
|
|
16
|
+
export interface StreamedSpawnDependencies {
|
|
17
|
+
stream: RemoteStreamer;
|
|
18
|
+
/** One short command over the same connection (used to stop a command). */
|
|
19
|
+
run: RemoteRunner;
|
|
20
|
+
root: string;
|
|
21
|
+
workDir: string;
|
|
22
|
+
resolvePath(path: string): string;
|
|
23
|
+
nextId(): string;
|
|
24
|
+
running: Set<ProcessHandle>;
|
|
25
|
+
killing: Map<ProcessHandle, Promise<void>>;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* The shell text one streamed command is sent as. It writes the script, starts it detached (own session, output into a log file, so it survives the
|
|
29
|
+
* connection and the log can be read afterwards), prints the process group id, then copies the log to its own output as the log grows. When the command
|
|
30
|
+
* has ended and the log is copied to the last byte, it prints the exit code after a marker. `wait` gives the exit code without anyone looking for it.
|
|
31
|
+
*/
|
|
32
|
+
export declare function launcherFor(options: {
|
|
33
|
+
id: string;
|
|
34
|
+
command: string;
|
|
35
|
+
cwd: string;
|
|
36
|
+
workDir: string;
|
|
37
|
+
}): string;
|
|
38
|
+
/** `kill` text for the group of a command: ask nicely, then insist. */
|
|
39
|
+
export declare const stopCommand: (pid: number) => string;
|
|
40
|
+
/**
|
|
41
|
+
* `spawn` for an environment that has a streamer. Each command is one child of the connection; the exit code is the end of that child's script, no
|
|
42
|
+
* file is looked at to find it; stopping a command stops its process group on the other side. When the connection is lost the command is NOT followed:
|
|
43
|
+
* it keeps running there (it is detached), and the error says that, where its output is, and that the model must stop and report it.
|
|
44
|
+
*/
|
|
45
|
+
export declare function createStreamedSpawn(deps: StreamedSpawnDependencies): (command: string, options: {
|
|
46
|
+
cwd?: string;
|
|
47
|
+
signal?: AbortSignal;
|
|
48
|
+
}) => ProcessHandle;
|