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 CHANGED
@@ -2,24 +2,74 @@
2
2
 
3
3
  [English](README.md) · [简体中文](README.zh-CN.md)
4
4
 
5
- **Sakura is an agent loop you can embed, review and put anywhere.** A small TypeScript kernel (about 1,600 lines, eight hooks) runs the loop; everything else is an optional plugin. You define an agent in code the way you define any object, give it tools that are plain objects, and decide exactly where it may reach: this directory, another machine over SSH, or any place a function can run a command. It ships as the `sakura-agent` npm package with an SDK and a small `sakura` command-line tool, and the repository also contains a Codex-style terminal built on the same SDK.
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
- - **An agent is a few lines of code.** `createAgent({ model, tools, rules, plugins })`; tools are plain objects (a name, a JSON schema, an `execute` function); a Python or shell tool is a command the `bash` tool or the environment runs. Nothing to install, no config files to generate.
10
- - **What you expect from a CLI agent, assembled from code.** Resumable sessions (fork, rewind, crash recovery), conversation compaction, goals, steer / queue / cancel while it works, project instructions (`AGENTS.md`), sub-agents, MCP, tool search and a code mode. Each is a plugin or an option; leave out what you do not need.
11
- - **You decide where it can reach.** Every agent has its own working directory and its own `Environment`; nothing is read from the process's current directory. `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 and tells the model that it is there. Any function that runs a shell command can be an environment, so the loop can run where your key is and the hands where the work is.
12
- - **A reviewer in the loop instead of a person.** 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. Agents can run unattended. And when an agent has no shell (file tools plus a checker that answers true or false, say), a confined directory is all the safety it needs, and no second model has to run.
13
- - **A terminal when you want one.** `terminal/` is a Codex-style terminal (Markdown output, slash commands, resume, fork, `exec --json`) written only against the public SDK. It is in the repository, not in the npm package.
14
- - **Small enough to read, tested like a product.** 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.
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
- Two tools I compared it with, from their public documentation (November 2026). They are products for a person at a keyboard; Sakura is the part you can take apart and embed.
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, with a minimal CLI and a separate terminal |
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 isolated environment of your own.
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 是一个能嵌入、能审核、能放在任何地方的 Agent 循环。** 一个小小的 TypeScript 内核(约 1600 行、8 个钩子)负责循环,其余的一切都是可以不装的插件。你像定义一个对象那样用代码定义 Agent,工具就是普通对象,并且精确决定它能触到哪里:这个目录、通过 SSH 连上的另一台机器,或者任何能执行命令的函数。它以 `sakura-agent` 这个 npm 包发布,包含 SDK 和一个小的 `sakura` 命令行工具;仓库里还有一个基于同一个 SDK 的、类似 Codex 的终端。
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
- - **一个 Agent 就是几行代码。** `createAgent({ model, tools, rules, plugins })`;工具是普通对象(名字、JSON Schema、一个 `execute` 函数);Python 或 shell 工具就是让 `bash` 工具或环境执行的一条命令。不用安装什么,也不用生成配置文件。
10
- - **命令行 Agent 该有的,用代码拼出来。** 可恢复的会话(分叉、回退、崩溃恢复)、对话压缩、目标、运行中引导 / 排队 / 取消、项目说明(`AGENTS.md`)、子 Agent、MCP、工具搜索和代码模式。每一项都是插件或选项,不需要的就不要。
11
- - **你决定它能触到哪里。** 每个 Agent 有自己的工作目录和自己的 `Environment`,不读进程的当前目录。`confine: true` 让文件工具出不了这个目录,不管路径怎么写(绝对路径、`..`、链接)。`createSshEnvironment` 用一次 SSH 登录在另一台机器上运行工具,并告诉模型它在那里。任何能执行 shell 命令的函数都可以做环境,所以循环可以跑在放密钥的地方,工具跑在干活的地方。
12
- - **用审核代替人在旁边。** `review` 插件先用硬规则、再用审核模型判断有风险的工具调用,熔断器会让一直被拒绝的运行停下来。Agent 可以无人值守。而且当一个 Agent 没有 shell(比如只有文件工具和一个回答真或假的检查器)时,限定的目录就是它需要的全部安全措施,不用再运行第二个模型。
13
- - **想要终端也有。** `terminal/` 是一个类似 Codex 的终端(Markdown 输出、斜杠命令、续接、分叉、`exec --json`),只用公开的 SDK 写成。它在仓库里,不在 npm 包里。
14
- - **小到能读完,按产品标准测试。** 公开仓库里有 5000 多个测试,有对每个接口的所有实现跑同一套用例的契约测试,还有对真实模型和真实 SSH 服务器的实测。
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
- 我对比过的两个工具,依据它们公开的文档(2026 年 11 月)。它们是给坐在键盘前的人用的产品;Sakura 是可以拆开、嵌进去的那一部分。
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
- 它不是给你自己笔记本用的全能助手,也不带沙箱:默认的本机环境使用调用进程的权限。`confine` 是文件工具的边界,不是 shell 的边界:shell 命令仍然能触到任何地方,所以给 Agent 一个 shell 时要同时开启审核,或者把不可信的任务放在你自己的隔离环境里运行。
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, the next command makes a new one by itself. Nothing is promised about the connection: when it cannot be reached, the command's
24
- * result is a RemoteConnectionError and the caller says so.
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, the next command makes a new one by itself. Nothing is promised about the connection: when it cannot be reached, the command's
60
- * result is a RemoteConnectionError and the caller says so.
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 remote = `${runOptions.cwd !== undefined ? `cd ${quote(runOptions.cwd)} && ` : ""}bash -c ${quote(command)}`;
98
- const result = await runSsh(program, [...common, "-o", "ControlMaster=auto", "-o", "ControlPersist=10m", options.host, remote], { timeoutMs: runOptions.timeoutMs, signal: runOptions.signal });
99
- // 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
100
- if (result.exitCode === 255 && !result.timedOut && !runOptions.signal?.aborted)
101
- throw new RemoteConnectionError(result.stderr.trim() || `could not reach ${options.host}`);
102
- return { exitCode: result.exitCode, output: result.output, timedOut: result.timedOut };
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;