Kuruwa 最小接口契约(M0)
状态:契约;M0–M6 MVP 已验收完成。 本文冻结 M0 需要的最小产品语义、命令语法、配置字段、初始化契约、身份规则和权限矩阵。除本文明确标为“实现常量”的数值外,不引入新配置项。文中命令均已落地并通过 M2–M6 实际验收。
术语以 CONTEXT.md 为准,功能背景见 第一版设计,施工顺序见 PLAN.md。
0. MVP 实现与验收状态
M2 已实现实例与终端最小闭环;M3 已实现多入口身份、实例所有权、终端 grant、输入、有限等待和立即撤回;M4 已补齐日常终端操作与宿主原生 attach;M5 已完成部署说明、完整流程演练、cleanup/重启/实例失效检查;M6 已完成统一验收入口、干净 checkout 构建和最终复跑。M0–M6 MVP 已验收完成:
- 可用二进制:
kuruwa,配置样例examples/kuruwa.toml,初始化样例examples/init-host.sh与examples/init-bwrap.sh。 - M2 命令:
serve --config --runtime(M2 当时为前台)、shutdown、instance create/list/destroy [--force]、start、status、ls、screen、clean。 - M3 命令:
tenant list、grant、revoke、send [--key]、resume NAME [--interval]。 - M4 命令:
run、tail、transcript、cancel、gc、resize、attach、skill。 - 声明式 sandbox 与本地
exec:exec --definition NAME [--config FILE] -- PROGRAM ARG...不连接管理 daemon、不进入已有实例,直接把定义选出的 bubblewrap 策略用于本次进程;定义用旧的init或新的[definitions.sandbox](二选一)声明,terminal_passthrough是实例级 opt-in。 - 入口:
runtime/admin.sock为管理主体;每个配置租户绑定runtime/tenants/<name>.sock。Principal只由 accept 的监听入口决定,请求 JSON 不包含也不能伪造租户、principal、endpoint 或 session。所有请求在反序列化前后都会拒绝未知字段。 - 授权:实例记录创建者 owner;管理创建的实例 owner 为管理主体,租户创建的实例 owner 为该租户,且租户只能使用配置获准的定义。终端 grant 保存内部 session 身份;管理入口唯一可以
tenant list、grant、revoke和shutdown,attach还允许目标实例 owner。实例 owner 与 grant 的可见/可操作范围按第 8 节执行。 - 撤回:
revoke删除 grant 并广播权限变更;status、screen、send、resume、tail、transcript、resize、run等在等待后端时也会立即取消相关请求,返回前再次校验授权。授权快照与关闭通知保留到响应写出完成;客户端不读造成写出阻塞时,撤回或 shutdown 会关闭连接,已进入 socket 缓冲区的字节不追回,部分响应后不追加错误 JSON。grant 带内部序号,撤销后重新 grant 不会复活旧等待连接或继续其输入;owner 权限不受其 grant 被撤销影响。 - 实例创建使用预绑定 listener + 可信 init 契约;就绪判定、10 秒上限、失败清理和 typed SDK 终端创建已按本文实现。后端固定启用
remain-on-exit与exit-empty off,清理最后一个终端不会隐式销毁实例。 - M4 输出:
tail使用 typedcapture-pane渲染历史;transcript使用 typed retained raw bytes,非 JSON 输出逐字节保留,JSONcontent使用 lossy UTF-8。若读取中发生已知 Lag 或达到实现上限,JSON 额外带truncated/missed_events,非 JSON 在 stderr 报告,不声称完整。cancel先 typedC-c,2 秒后仍运行则通过正确实例后端杀 session;若未观察退出且强制 kill 失败,则返回backend,若后端已失效则按实例失效机制转为orphaned,绝不虚假声称cancelled。取消时实际读取到的 screen/transcript 保存在该终端记录内,仅在 session 已删除时作为真实诊断输出返回。gc只回收 owner/admin 范围内达到年龄的终态记录,running 永不回收;resize通过 typed SDK 调整单 pane 所在窗口;attach在管理入口或目标实例 owner 下返回真实 endpoint/session/pinned rmux 绝对路径并 exec 原生客户端;skill本地执行。 - 行为验收入口:
python3 scripts/test-m2.py、python3 scripts/test-m3.py、python3 scripts/test-m3-blocked.py、python3 scripts/test-m3-write.py、python3 scripts/test-m4.py与python3 scripts/test-m5.py(真实 CLI/UnixStream/PTY +target/rmux/bin/rmux,不使用 mock);python3 scripts/test-exec.py覆盖本地exec、声明式 sandbox、hook 失败阻断和实例terminal_passthrough;python3 scripts/test-long-socket.py对打包产物覆盖 >108 字节 socket 路径的 serve/实例/终端/关闭流程。阻塞回归覆盖后端等待和响应写出背压,只暂停本测试启动的 RMUX,验证后恢复并清理。 - M5 部署与实测:见 部署与 M5 演练。
- M6 统一入口:
scripts/check.sh顺序运行固定 RMUX 构建/回归、cargo 格式/构建/单测/clippy、M2–M5 真实套件和继承 FD 验收;干净 checkout 日志/tmp/opencode/kuruwa-m6-clean.log,主 agent 原仓库复跑日志/tmp/opencode/kuruwa-final-primary.log,均 exit 0 并打印CHECK_PASS。验收与限制见 M6 交付验收。
1. 范围与边界
本契约只覆盖以下内容:
- 产品 CLI 是 Rust 二进制,主命令为
kuruwa;本仓库通过cargo build生成该二进制。 - 一个项目级主 daemon;
serve默认在<root>后台启动,--foreground才前台常驻。项目根、配置与日志跨关闭保留,实例/终端/授权不跨重启恢复。 - 管理入口与每个配置租户一个受限 Unix socket 入口。
- 配置中的沙盒定义、租户及其获准定义。
- 实例创建、终端创建与单终端操作、访问授予与撤回、实例与终端清理。
- Linux 上通过继承监听 FD 启动实例内 RMUX 的初始化契约。
不包含:网络监听、协议版本协商、流式订阅、跨主 daemon 协调、崩溃恢复、持久化运行对象、可写租户数据库、独立权限服务、任务实体、agent 框架集成、原始 RMUX 透传。
一个终端就是某个实例内 RMUX session 的单 pane。命令任务与长期 shell 使用同一对象模型,不另设任务实体;Kuruwa 不向用户提供任意 rmux 子命令透传,只有管理端或实例 owner 的 attach 会 exec 原生 RMUX 客户端。
2. 对象、名称与身份
2.1 对象
| 对象 | 说明 |
|---|---|
| 主 daemon | 本次运行中管理配置、租户入口、实例、终端和授权的常驻进程。 |
| 沙盒定义 | 持久配置中的可信初始化规格:name、旧的 init argv 或新的 [definitions.sandbox](原生 bwrap args + 可信 hooks)、可选 cleanup argv、可选 terminal_passthrough。 |
| 沙盒实例 | 一次成功初始化后由 RMUX(实例 daemon)承载的执行环境。 |
| 终端 | 某实例内 RMUX session 的单 pane;有全局唯一名称,但不是授权凭据。 |
| 租户 | 持久配置中的受限授权上下文;一个租户对应一个 runtime/tenants/<name>.sock。 |
主机租户是内建的管理主体,通过管理入口操作,不需要出现在配置租户列表中,也不作为 grant/revoke 的目标。主机实例只是 init 为 noop 的普通定义实例,不会在启动时自动创建。
2.2 公共名称
定义名、租户名、实例名、终端名都必须是:
^[A-Za-z0-9_-]+$
即非空 ASCII 字母、数字、下划线或连字符;区分大小写。禁止 /、\、.、空白、Unicode 和其他路径成分。该规则同时用于阻止路径穿越和 socket 路径注入。
- 定义名与租户名由配置声明;实例名由
instance create --name指定;终端名由start/run --name指定。 - 实例名在一个主 daemon 运行内跨所有租户全局唯一,销毁后也不得重用。
- 终端名在一个主 daemon 运行内跨所有租户与实例全局唯一;
clean、gc或实例销毁后也不得重用。 - 名称只用于寻址。权限绑定内部身份,不绑定名称。
- 名称冲突对无权限调用者只返回通用冲突/不可用,不暴露已有对象的归属、实例或状态。
2.3 内部身份与代际
不使用哈希、摘要、nonce 或加密标识。主 daemon 启动时生成运行代际:
generation = <Unix 纳秒>-<主 daemon PID>
instance-id = <generation>-i<进程内实例计数>
terminal-id = <generation>-t<进程内终端计数>
计数器在本次运行内单调递增且不复用。内部 ID 至少满足:
- 同一次运行内实例、终端 ID 唯一;
- 代际用于诊断和本次运行的内部路径;授权和连接仅属于创建它们的主 daemon 进程,不跨重启导入,也不接受客户端自报内部 ID 作为权限凭据;
- 授权记录保存终端内部 ID,不保存终端名作为最终判定依据。
跨重启的公共名称属于新代际。正常运行关闭并重新启动后,配置中的定义和租户仍在,但实例、终端、输出和临时授权全部消失;旧 socket 已删除,旧连接不可能继续使用。
2.4 终端状态
终端记录在退出后仍保留,直到显式 clean 或 gc:
| 状态 | 含义 | 可输入/attach | 可读状态与输出 |
|---|---|---|---|
running |
终端进程仍存活,实例可用。 | 是 | 是 |
unknown |
创建请求已发出,但其成功或回滚结果无法确认;不代表进程正在运行。 | 否 | 状态可读;由 status/ls/clean 核对真实 session。 |
exited |
进程自行结束;exit_code 保留(可能非零)。 |
否 | 是 |
cancelled |
通过 cancel/清理流程终止;exit_code 已知时保留。 |
否 | 是 |
orphaned |
实例已失效或已销毁,终端后端不再可用;诊断记录保留。 | 否 | 状态可读;输出仅限失效前已取得的内容,没有可用内容时明确报后端不可用。 |
ended_at 在进入已确认的终态(exited/cancelled/orphaned)时写入;unknown 不写 ended_at,因为创建是否成功尚未确认。实例失效时仍在运行或 unknown 的终端转为 orphaned;在失效前已经结束的终端保留原状态,但同样不再能输入。
创建/初次检查/回滚尚在执行时,临时记录保持 unknown,查询不会提前提交状态,clean/gc 不会移除它。创建请求超时后,单次 session 不存在不能排除该请求随后执行:记录继续保持 unknown,直到观察到实际 session,成功终止已观察到的 session,或确认所属实例终止。下面“session 不存在可移除”的规则只适用于已确认创建完成的终端。
3. 入口、连接与输出约定
3.1 两个入口
| 入口 | 路径 | 权限上下文 |
|---|---|---|
| 管理入口 | runtime/admin.sock |
管理主体,全部权限。 |
| 租户入口 | runtime/tenants/<name>.sock |
该配置租户的受限上下文。 |
租户身份只来自连接进入了哪个 socket。协议请求没有、也不接受自报租户、--tenant 身份参数或可伪造的授权字段。grant --tenant 中的 --tenant 只指定管理操作的目标,不改变调用者身份。
runtime 控制目录必须私有。实现创建目录使用 0700,创建 socket 使用 0600。是否把租户 socket 挂载进沙盒由调用方/定义脚本控制,Kuruwa 不自动挂载。
3.2 连接选择
客户端命令使用:
--socket <path> > 环境变量 KURUWA_SOCKET > <root>/admin.sock
项目根由 KURUWA_ROOT 决定:未设置时为 $PWD/.kuruwa;相对值相对当前目录解析成绝对路径;不向父目录搜索。普通客户端只解析路径,不创建 root、不自动启动主 daemon 或 RMUX。
显式入口失败不回退:非空 --socket 或 KURUWA_SOCKET 连接失败直接报错;空的 --socket 是用法错误,空的 KURUWA_SOCKET 是入口错误。两者都不会改用默认 root 入口。
serve 不使用 --socket;管理入口为 <runtime>/admin.sock。默认 <runtime> 是项目根,也可用显式 --runtime 覆盖。
3.3 输出
- 成功数据写 stdout;诊断、日志和错误写 stderr。
screen、tail、transcript默认输出终端正文的纯文本,不添加标题、状态行或命令回显说明。- 管理/状态/列举命令默认输出简短可读文本;其文本格式不承诺机器稳定。脚本应使用
--json。 --json是客户端命令的全局选项。成功时 stdout 恰好一个 JSON 对象(末尾换行),不得混入日志。- 错误在
--json下向 stderr 输出一个 JSON 对象;普通模式下为可读文本。 - Clap 在解析阶段产生的 usage 错误不保证 JSON 包装,固定写 stderr 并 exit 2。
exec不包装输出:stdout/stderr 完全属于被执行的命令,成功时也没有 Kuruwa 对象;因此它拒绝--json。
错误 JSON 结构:
{"error":{"code":"denied","message":"..."}}
code 取 config、invalid、denied、not_found、conflict、backend、runtime、cleanup。错误信息不得包含未授权对象的实例归属、输出、endpoint 或 session。
3.4 退出码
| 退出码 | 含义 |
|---|---|
0 |
命令成功完成。终端自身退出非零不使 run/resume/status 失败;退出码作为数据保留。等待窗口到期但终端仍 running 也是成功。 |
1 |
配置/语义参数校验失败、权限拒绝、对象缺失、名称冲突、运行目录错误、后端失败、清理失败、连接失败。 |
2 |
Clap 语法/用法错误:未知命令或选项、缺少必需选项、无法解析的参数。 |
解析成功后的数值范围错误(例如 --cols 0、负 --interval)属于语义校验,exit 1。run 遇到终端退出码 7 时仍 exit 0;--json 中 exit_code 为 7。
例外:
attach在成功取得 endpoint 后exec原生 RMUX 客户端,进程退出码属于该客户端(detach 正常通常为 0);只有解析、授权、解析 endpoint 或 exec 失败时使用上表。serve是常驻进程:配置/启动失败 exit 1;正常关闭清理成功 exit 0;清理有失败 exit 1。shutdown即使清理有失败也停止 daemon,但 CLI exit 1 并报告失败,不宣称清理成功。exec在配置/策略/解析失败时使用上表(exit 1);成功启动后 Kuruwa 进程已被 runner 与 bwrap 替换,Kuruwa 不再转写或折叠状态:退出状态沿用 bubblewrap 的执行边界(被信号终止的 payload 通常报128 + signal,例如 SIGTERM 得 143),143 也可能只是程序的普通退出码。Kuruwa 不引入 supervisor、不猜测信号、也不把 143 解释成 signal。
4. 命令表
全局语法:
kuruwa [--socket <path>] [--json] <command> [args...]
kuruwa [--socket <path>] cli
kuruwa serve [--config <toml>] [--runtime <dir>] [--foreground] [--json]
kuruwa exec --definition NAME [--config <toml>] -- PROGRAM [ARG...]
全局选项放在子命令之前。--socket 与 --json 适用于客户端命令;skill 是本地命令,不需要 socket。cli 需要 stdin/stdout 都是交互式 TTY,且明确拒绝 --json。serve 的 --config 默认为 <root>/config.toml,--runtime 默认为 <root>;显式参数保持原有含义。
4.1 管理命令
| 命令 | 可用入口 | 语义 |
|---|---|---|
shutdown |
仅管理 | 正常关闭主 daemon,清理受管实例/终端,删除入口 socket。 |
tenant list |
仅管理 | 列出持久配置中的租户及其获准定义;没有创建/删除租户命令。 |
instance create --definition NAME --name NAME |
管理可指定全部定义;租户只能指定配置获准定义 | 创建、等待就绪并登记实例;返回时实例必须可用,否则失败且不留半成品。 |
instance list |
管理全部;租户仅自己创建的实例;仅终端授权者得到空列表 | 只读,按可访问对象过滤。 |
instance destroy NAME [--force] |
管理任意;实例 owner 仅自己的实例 | 非空实例默认拒绝;--force 终止其终端并销毁实例,保留终端诊断记录。 |
grant --tenant NAME --terminal NAME |
仅管理 | 给配置租户授予指定终端的访问权;授予到内部终端 ID。 |
revoke --tenant NAME --terminal NAME |
仅管理 | 删除该终端授权;立即断开相关等待请求,不终止终端。 |
instance create 的成功结果是实例 owner 为调用者:管理创建的实例 owner 为管理主体;租户创建的实例 owner 为该租户。管理创建的实例不会自动给任何租户实例控制权,只能通过终端 grant 交付单个终端。
instance destroy 中的“非空”指该实例仍存在任何终端记录,包括 unknown/exited/cancelled/orphaned;不只是 running。非空且未给 --force 时返回 conflict,不产生任何副作用。同一实例已在 Destroying 时再次 destroy 返回 conflict,不会并发执行第二次 cleanup。--force 通过实例 RMUX 终止活动终端、结束本次启动的实例进程并调用一次 cleanup;销毁时仍在运行或 unknown 的终端转为 orphaned,已经结束的终端保留原状态,供 status/输出诊断或后续 clean/gc。cleanup 失败时实例已不可用,操作 exit 1 并报告 cleanup 失败,不宣称清理成功;失败目录保留为可识别残留,且不会自动重试用户 hook。之后 shutdown 会报告仍存在的残留,而不会宣称完全清理成功。
grant/revoke 的目标终端必须是当前代际中仍存在、未 clean 的终端记录。grant 幂等;revoke 在 grant 已不存在时也幂等成功。clean/gc 已使相关 grant 失效;对已 cleaned 终端执行 grant/revoke 返回 not_found。grant --tenant 只能指向配置中存在的租户,不能指向管理主体。
4.2 终端命令
start --instance NAME --name NAME [--cwd PATH] [--cols N] [--rows N] -- <argv...>
run --instance NAME --name NAME [--cwd PATH] [--cols N] [--rows N] [--interval SECONDS] -- <argv...>
resume NAME [--interval SECONDS]
status NAME
ls
send NAME [--key] -- <values...>
screen NAME
tail NAME [--lines N]
transcript NAME
cancel NAME
clean NAME
gc [--older-than SECONDS]
attach NAME
resize NAME --cols N --rows N
skill
| 命令 | 语义 |
|---|---|
start |
在指定实例创建终端并启动 argv;返回观察到的状态,可能是 running 或已 exited。 |
run |
start + 有界等待;等待窗口到期仍运行则返回 running;完成则返回终态与退出码;不隐式清理。 |
resume |
对已有终端执行同样的有界等待;不创建终端。 |
status |
读取单个终端的名称、实例、状态、尺寸、退出码和时间戳。 |
ls |
列出调用者可访问的终端;仅终端授权者只看到被授予的终端。 |
send |
默认把 argv 用单个 ASCII 空格连接后发送,不追加换行;--key 时每个 value 是一个按键名(如 Enter、C-c、Up),逐个发送。 |
screen |
当前视口文本。 |
tail |
渲染历史最后 N 行;N 缺省 50,必须为正。 |
transcript |
读取后端当前保留的原始 PTY 输出,不渲染、不加状态头;受后端输出环容量限制,不承诺自创建以来的完整持久记录。 |
cancel |
向终端发送 C-c,短宽限后仍存活则由实例内 RMUX 终止;只能管理 owner/管理调用。 |
clean |
显式销毁终端记录及其实例内 session;活动进程也终止;名称仍不可复用;相关授权失效。 |
gc |
只回收已结束且达到年龄的终端记录;绝不回收 running,也绝不杀活动进程。 |
attach |
管理入口或目标实例 owner;取得真实 endpoint/session 后 exec 原生 RMUX attach;终端 grant 不暴露 endpoint。宿主 PATH 必须提供兼容的 rmux 客户端。 |
resize |
调整单 pane 尺寸;cols/rows 必须为正。 |
skill |
打印与实际契约一致的实用命令说明;本地执行,不连接 daemon。 |
run 的 --interval 是本次等待窗口(秒),不是终端寿命、TTL 或自动清理阈值。实现在该窗口内轮询,到期返回 wait: "timeout" 与 state: "running"。缺省 1 秒,0 合法(只检查一次后立即返回),负值 exit 1。需要继续等待时重复调用 resume。resume 对已经 exited/cancelled/orphaned 的终端立即返回,不报错。
start/run 缺省尺寸 80x24;--cols 与 --rows 可分别给出,未给出的使用缺省。尺寸必须为正整数。--cwd 原样交给终端后端;绝对路径按实例环境解释,相对路径相对于实例 RMUX 继承的工作目录解析,省略时使用该工作目录。路径不存在等错误由终端创建失败返回,不登记半成品终端。
send 的文本模式不做 shell 拼接、不做引号解释;send NAME -- echo hi 发送的字节是 echo hi,没有换行。要执行 shell 命令须自行包含换行按键,例如:
kuruwa --socket "$SOCK" send build -- printf 'HELLO\n'
kuruwa --socket "$SOCK" send build --key -- Enter
send --key 不透传任意 RMUX 命令;只接受 terminal 后端支持的按键名。
cancel 对已经结束的 exited/cancelled 终端是幂等成功,只返回当前状态;对 unknown/orphaned 终端返回 backend 失败,因为尚无法确认运行状态或后端已不可用,不能声称已终止或做过任何操作。clean 接受任意未 cleaned 状态,但只有确认 session 不存在、kill 成功或实例资源确已终止时才移除记录和相关 grant:running/unknown 通过实例内 RMUX 核对并终止 session;会话不存在可移除;通信失败或实例销毁尚未完成时保留记录并 exit 1。send/resize/attach 只对 running 且实例 ready 的终端成功,否则返回 backend 失败。gc 先核对可控的 running/unknown 记录,再只处理真实 exited/cancelled/orphaned 且 ended_at 早于或等于 now - older_than 的记录;执行中的终端永远跳过;刷新或回收失败时保留该记录并报告,其他合格记录仍可继续回收,最终 exit 1。上述操作都不得用 namespace 内 PID 猜主机 PID,也不得杀不相关进程。
start/run 的 -- 后必须至少有一个 argv 元素;send 的 -- 后必须至少有一个 value。缺失属于 Clap 用法错误 exit 2。文本 send 的 value 可以是空字符串,Kuruwa 只做空格连接,不添加引号或换行。
4.3 JSON 成功结构
--json 下成功结果使用以下稳定结构(时间均为 Unix 秒):
{
"name": "build",
"instance": "alice-dev",
"state": "running",
"exit_code": null,
"cols": 120,
"rows": 32,
"created_at": 1760000000,
"ended_at": null
}
上例是 TerminalView。字段含义:
| 字段 | 类型 | 说明 |
|---|---|---|
name |
string | 公共终端名。 |
instance |
string | 所属实例名。终端 grant 者可看到该字段以使用终端。 |
state |
string | running、unknown、exited、cancelled、orphaned。 |
exit_code |
integer/null | 后端能确定时保留真实退出码(含非零);无法确定时为 null。 |
cols / rows |
integer | 当前尺寸。 |
created_at |
integer | 创建时间。 |
ended_at |
integer/null | 进入非 running 状态的时间;running 时为 null。 |
InstanceView:
{"name":"alice-dev","definition":"bwrap","state":"ready"}
列出调用者可控制的实例及其真实状态:ready、stopped(自有实例进程已退出)或 destroying。一次后端通信超时不会把实例改成 stopped;只有持有的实例子进程确已退出才进入 stopped,其终端转为 orphaned。已销毁实例不再出现。
各命令成功对象:
| 命令 | JSON |
|---|---|
start / status / send / resize / cancel |
{"terminal": TerminalView} |
run / resume |
{"wait":"done" 或 "timeout","terminal": TerminalView} |
ls |
{"terminals":[TerminalView...]} |
screen |
{"name":"...","content":"..."} |
tail |
{"name":"...","lines":N,"content":"..."} |
transcript |
{"name":"...","content":"..."} |
clean |
{"name":"...","cleaned":true} |
gc |
{"removed":["name"...]} |
instance create |
{"instance": InstanceView} |
instance list |
{"instances":[InstanceView...]} |
instance destroy |
{"instance":"...","terminals_orphaned":["..."],"cleanup":"ok"} |
tenant list |
{"tenants":[{"name":"...","definitions":["..."]}]} |
grant / revoke |
{"tenant":"...","terminal":"...","granted":true 或 false} |
serve(后台) |
{"root":"...","runtime":"...","socket":"...","log":"...","status":"started" 或 "already_running"} |
shutdown |
{"stopped":true} |
skill |
{"text":"..."} |
wait 只在终态或等待窗口到期时取值:done 表示终端处于 exited/cancelled/orphaned,timeout 表示窗口到期仍 running。unknown 表示创建结果未确认,不是终态;resume 刷新后若仍为 unknown,返回 backend 错误并 exit 1,不返回 wait=done 或伪造 running。run/resume 成功返回等待结果时退出码为 0;终端退出码在 terminal.exit_code 中。
screen/tail 的 content 是 UTF-8 渲染文本。transcript 默认非 JSON 输出原始字节;JSON 的 content 对非 UTF-8 序列使用替换字符。原始输出读取受 RMUX 保留环限制,发生已知丢失时必须报告,不能声称完整;不为 MVP 增加持续输出落盘器。attach 成功时 exec 原生 RMUX,不产生 Kuruwa JSON 成功对象;解析阶段失败仍按错误约定处理。skill 是本地命令,不需要 socket。
4.4 交互管理界面(cli)
kuruwa cli
kuruwa --socket /path/to/admin.sock cli
cli 使用与普通客户端相同的入口解析规则,不创建项目根、不自动启动 serve/RMUX、不读取配置冒充授权。非 TTY 直接 exit 1;--json cli 明确拒绝,界面不会向 JSON 管道写任何内容。普通 attach 仍是 exec 原生客户端,cli 内的 attach 是独立的界面行为。
界面从当前入口取得身份:管理入口显示 ADMIN,租户入口显示 TENANT <name>,租户无法显示其他身份。它通过只读 ui_context 请求取得服务端派生的可创建 definition 名、可访问终端名和可控终端名;请求不接受任何调用者提供的身份、租户、endpoint 或 session 字段。实例列表继续使用 instance list(租户仅自己创建的实例,管理全部可控实例),终端列表继续使用 ls,租户列表仍仅管理专用。
ui_context 成功对象示例(租户身份访问被授予的终端;管理身份省略 tenant):
{
"is_admin": false,
"tenant": "alice",
"definitions": ["bwrap"],
"terminals": ["build"],
"controllable_terminals": []
}
terminals 与 controllable_terminals 都由接受连接的 Principal 过滤;后者只用于界面提示,真正的 cancel/clean/destroy 仍由对应 handler 授权。
固定按键:
| 按键 | 行为 |
|---|---|
Tab / Shift-Tab |
Instances/Terminals 焦点切换;表单字段前后移动。 |
Up / Down / j / k |
焦点列表选择;表单定义/实例选择。 |
Space |
暂停/恢复预览内容刷新;授权检查继续运行,撤回会清空旧预览。 |
i |
创建实例(允许的 definition + 名称)。 |
n |
创建终端(可控实例 + 名称 + argv JSON + 可选 cwd)。 |
g / r |
对选中终端选择租户 grant/revoke(仅管理入口)。 |
c / x / d |
cancel 终端 / clean 终端 / destroy 实例,均需确认。 |
Enter |
原生 attach 选中终端(管理入口或目标实例 owner);detach 后返回并刷新界面。 |
? |
帮助。 |
q |
退出;表单文字输入中的 q 不退出。 |
表单以 Tab/Shift-Tab 移动、Ctrl-U 清空文本、Enter 前进或在最后字段提交、Esc 取消。argv 必须是 JSON 字符串数组(例如 ["bash"]),不做 shell 字符串拼接。确认框 Enter 确认、Esc 取消;destroy 默认非 force,按 f 显式切换 force 并显示终止终端/留下 orphaned 记录等影响。确认取消不产生后端副作用。
预览约每 400 ms 刷新选中终端,列表约每 1 s 刷新,上下文约每 500 ms 刷新;请求异步执行,不阻塞键盘和退出。切换选择先清空旧画面再获取。暂停只停止预览内容轮询,不停止授权检查;grant 撤回、清理或 not_found/denied 会立即清空旧预览。预览不 resize 后端、不转发键盘、不直接回放 ANSI。退出、attach、绘制错误和 panic 都恢复 raw mode、alternate screen 与光标。
详细操作说明见 交互管理界面。
4.5 本地执行(exec)
kuruwa exec --definition NAME [--config FILE] -- PROGRAM [ARG...]
exec 是本地命令:不解析 --socket/KURUWA_SOCKET,不连接管理 daemon,不创建或进入实例,也不创建项目根或配置文件。配置路径缺省为 <KURUWA_ROOT>/config.toml(KURUWA_ROOT 缺省 $PWD/.kuruwa),显式 --config 与缺省路径都只读;文件缺失即 exit 1,不自动初始化。--json 被拒绝,因为 stdout 属于用户命令;-- 后至少需要一个 argv 元素。
只有带 [definitions.sandbox] 的定义可用于 exec。旧 init 定义明确报错(definition "x" uses the legacy init form; exec requires a definition with a [definitions.sandbox] table),未知定义返回 config 错误。
exec 把 Kuruwa 进程替换为固定 runner,再由 runner exec 解析出的 bubblewrap;因此 stdin、stdout、stderr、argv(含空格与空字符串)以及调用 cwd 都原样属于用户命令(定义仍可用 --chdir 覆盖 cwd)。退出状态不在 Kuruwa 这一层转写,而是沿用 bubblewrap 的执行边界:Kuruwa 自身用 exec 替换进程、不添加 supervisor、不解析输出;被信号终止的 payload 通常表现为 128 + signal(如 SIGTERM → 143),普通退出码 143 与它不可区分,调用者不应据 143 反推信号。不经过 PTY、不写 transcript、不产生后台状态,也不读写实例目录。
5. 配置
启动时读取一次 TOML,不支持热更新。grant/revoke 只改内存,不写回配置文件;租户只能来自持久配置,没有运行时创建租户的命令或数据库。
未显式给出 --config 时,首次 serve 会在项目根创建默认 config.toml 与 init/init-host.sh、init/init-bwrap.sh,只写缺失文件,绝不覆盖已有配置或脚本。默认租户为 alice(获准 bwrap)与 bob(空);host 定义只供管理端使用。初始化不创建实例、终端或 grant。显式 --config 不存在时直接失败,不替用户创建任意指定配置。
[[definitions]]
name = "host"
init = ["bash", "examples/init-host.sh"]
[[definitions]]
name = "bwrap"
init = ["bash", "examples/init-bwrap.sh"]
[[tenants]]
name = "alice"
definitions = ["bwrap"]
[[tenants]]
name = "bob"
definitions = []
字段:
| 表 | 字段 | 必需 | 说明 |
|---|---|---|---|
[[definitions]] |
name |
是 | 定义名,满足 §2.2。 |
init |
与 sandbox 二选一 |
旧格式:非空 argv [program, args...],原样 exec,不经过 shell。 |
|
[definitions.sandbox].args |
与 init 二选一 |
非空的原生 bubblewrap 参数数组;不含 bwrap 程序本身(由 KURUWA_BWRAP_BIN 解析)。只做 ${NAME} 替换,空字符串元素保留。 |
|
[definitions.sandbox].hooks |
否(缺省空) | 可信 Bash 文件数组,按顺序 source;相对路径相对配置目录;只做 ${NAME} 替换。 |
|
cleanup |
否 | 非空 argv;实例正常/强制销毁及创建失败后 best effort 调用一次。 | |
terminal_passthrough |
否(缺省 false) |
true 时实例在既有 remain-on-exit/exit-empty 之后追加 prefix None、prefix2 None、status off;false 保持 Kuruwa 的交互默认。 |
|
[[tenants]] |
name |
是 | 租户名,满足 §2.2,也是 socket 文件名。 |
definitions |
是 | 该租户可创建实例的定义名数组;可为空;每项必须引用已声明定义。 |
[definitions.sandbox] 示例(完整示例见 examples/kuruwa-sandbox.toml):
[[definitions]]
name = "sandbox"
# 可选:Hub 需要把 Ctrl+B 原样交给终端且不显示 RMUX 状态栏
# terminal_passthrough = true
[definitions.sandbox]
args = ["--die-with-parent", "--ro-bind", "/", "/", "--proc", "/proc", "--dev", "/dev"]
hooks = ["sandbox-hooks/instance-work-dir.sh"]
校验规则:
- 定义名、租户名唯一;租户引用的定义必须存在。
- 每个定义必须且只能声明
init或[definitions.sandbox]之一;两者都给或都不给都是配置错误。 definitions为空只表示该租户不能创建实例,不阻止管理端向它 grant 指定终端。init/cleanup的 program 不得为空;argv 元素原样执行,不做 shell 展开。sandbox.args不得为空;terminal_passthrough只影响实例初始化选项,不影响exec。${NAME}是sandbox.args/sandbox.hooks唯一的替换语法:$NAME、${NAME:-default}、$(...)等一律按配置错误拒绝;未设置(或非 UTF-8)变量报错且不回显任何值;替换结果为空时 argv 元素仍然保留。- 配置目录是
init/cleanup的工作目录。相对 program 路径按该 cwd 解析;裸 program 名按PATH查找。脚本中的相对路径也以该 cwd 为基准。sandbox hook 的相对路径相对配置目录解析,规则见 §6.7。 - 不使用 shell 拼接。需要 shell 语义时必须显式写
["sh", "-c", "..."]或["bash", "-c", "..."]。 - 配置错误使
serve/exec以 exit 1 失败,不创建任何可用入口;exec不创建配置文件。 examples/init-host.sh、examples/init-bwrap.sh是 M2 落地的可信初始化样例;主机定义就是普通 noop 初始化定义,bwrap 定义也是普通定义,没有内置策略类型。examples/kuruwa-sandbox.toml与examples/sandbox-hooks/instance-work-dir.sh是声明式 sandbox 样例。- 示例配置假定放在仓库根目录,因此
examples/...相对路径成立;若配置文件放在别处,脚本路径相应调整。
6. 初始化与清理契约
6.1 工作目录与实例目录
主 daemon 在 runtime/instances/<internal-instance-id>/ 创建实例私有目录(0700)。init 与 cleanup 的 cwd 都是配置文件所在目录。相对 program 与脚本相对路径按该 cwd 执行。
主 daemon 启动时通过 KURUWA_RMUX_BIN 指定适配后的 RMUX 路径;未设置时从 PATH 查找 rmux。解析得到的绝对路径用于初始化与宿主 attach,不自动下载或替换后端。
6.2 初始化环境表
| 变量 | init |
cleanup |
值 |
|---|---|---|---|
KURUWA_LISTENER_FD |
有 | 无 | 已预绑定 Unix listener 的十进制 FD 编号;实例内唯一额外继承的 FD。 |
KURUWA_RMUX_BIN |
有 | 有 | 主 daemon 选择的 patched RMUX 可执行文件路径;定义负责确保最终环境可执行它(bwrap 可重新映射路径)。 |
KURUWA_INSTANCE_NAME |
有 | 有 | 公共实例名。 |
KURUWA_INSTANCE_DIR |
有 | 有 | 外侧实例私有目录;沙盒可选择是否暴露。 |
KURUWA_INTERNAL_SOCKET |
有 | 有 | RMUX 的 logical endpoint 参数值;不等于外侧真实 socket 路径,可以不存在,RMUX 不得按它 bind/unlink。 |
初始化程序最终必须以最终环境中的可执行路径执行:
exec "$KURUWA_RMUX_BIN" --__internal-daemon "$KURUWA_INTERNAL_SOCKET" \
--listener-fd "$KURUWA_LISTENER_FD"
主机脚本只做环境准备并 exec。沙盒脚本先完成挂载、权限、环境清理等准备,再 exec。Kuruwa 不要求链接某种库,也不规定初始化程序的实现语言。除上表列出的 KURUWA_* 变量外,主 daemon 不向定义保证特定环境内容;定义脚本负责建立最终环境(bwrap 示例应在进入沙盒前清理不需要的主机环境变量)。
6.3 listener FD 与 CLOEXEC
- 外侧主 daemon 绑定真实 listener(AF_UNIX、
SOCK_STREAM、监听态),路径私有、权限0600;外侧管理端掌握该真实路径。 - 传递前把 listener 复制到已知 FD,并只对该 FD 清除
FD_CLOEXEC。 - 所有不必要的继承 FD(管理/租户 listener、已接受连接、配置/日志文件等)必须带
CLOEXEC;初始化程序不得把 listener 或已接受连接泄漏给终端子进程。 - RMUX 导入 listener 后立即重新设置
CLOEXEC,校验其为监听中的 Unix stream socket;导入后不按 logical endpoint 重绑定、不 unlink 他人 socket。 - 真实外侧 socket 路径不进入
KURUWA_*环境,也不作为--__internal-daemon参数传入沙盒。KURUWA_INTERNAL_SOCKET是 logical 值。
6.4 就绪判定
创建阶段使用真实外侧 endpoint 做 RMUX connect + list sessions 探测;禁止自动启动或替换 RMUX 服务。该探测只用于初始化就绪判定,普通运行期操作不再先做健康探测。满足以下条件才登记实例可用:
- 初始化进程仍存活(它通常已 exec 为 RMUX);
- 真实 endpoint 能完成一次 list 请求;
- 从启动到成功的耗时未超过 readiness 常量。
readiness 超时为 10 秒(实现常量,不提供配置)。初始化进程在就绪前退出(无论退出码)立即判定失败;超过 10 秒仍不可连接也判定失败。失败时:
- 不登记实例,不返回可用对象;
- 终止本次创建所启动的受管进程(只限自己启动的进程,不使用 namespace PID 猜主机 PID);
- best effort 调用一次
cleanup(如果定义提供); - 清理本次创建的 socket/实例目录,报告初始化退出状态或超时原因;
- 若 cleanup 或目录删除失败,保留实例目录并把错误传播给调用者,不删除现场;之后 shutdown 也报告仍存在的残留,不重复执行 cleanup。
6.5 清理程序
cleanup 是可信 argv,cwd 同上,接收与 init 相同的元信息环境,但不接收 KURUWA_LISTENER_FD,也不接收 listener/已接受连接 FD。正常销毁、--force 销毁、shutdown 和创建失败都 best effort 调用一次;没有 cleanup 字段则视为 no-op。清理失败必须报告,不能宣称清理成功,也不会为同一次销毁自动重试可能非幂等的 hook。清理程序只能处理该实例的受管资源,Kuruwa 及其示例不得杀不相关的主机服务。
6.6 诊断
初始化/清理的 stdout/stderr 由主 daemon 持续读取,以 tracing 事件写入 serve 的 stderr,并标注已有内部实例 ID、实例名、阶段(init/cleanup)和 stdout/stderr;非 UTF-8 只影响该行,不终止整条流。转发任务在受管子进程结束后用既有清理等待预算排空,避免 hook 后代持有输出 FD 无限阻塞结束。instance create 失败时,错误信息至少包含实例名、失败类别(进程退出/就绪超时/后端错误)和退出状态(若已知)。就绪前不得向任何客户端返回可用实例。
6.7 声明式 sandbox 策略(runner 与 hooks)
[definitions.sandbox] 由固定 runner 执行:runner 先构造 args Bash 数组,按顺序 source 每个 hook,最后执行
exec "$KURUWA_BWRAP_BIN" "${args[@]}" -- COMMAND...
KURUWA_BWRAP_BIN/KURUWA_BASH_BIN 与 §6.1 的 KURUWA_RMUX_BIN 同一解析规则:显式环境变量优先,未设置时在 PATH 查找 bwrap/bash;结果必须是绝对可执行文件,显式但无效的路径直接失败,不回退。
- 替换:
args与hooks只做${NAME}替换,值来自启动该定义的进程环境(exec是调用者环境;实例创建是主 daemon 的环境)。实例专属的KURUWA_*元信息在 hook 执行时可读,不参与模板替换。不做 shell 求值,不展开其他语法;未设置或非 UTF-8 变量报错且不输出值;替换为空的 argv 元素仍然保留。 - hooks:可信 Bash 文件,在同一个 shell 中按顺序
source,并启用set -euo pipefail。普通命令失败或return非零会阻止后续 hook 与 bwrap 启动,runner 以该失败状态退出。hook 可读继承环境(实例创建时可读KURUWA_INSTANCE_NAME、KURUWA_INSTANCE_DIR等)并追加/改写args;bwrap、hooks、cmd是 runner 内部变量,hook 不得覆盖。 - 路径与 cwd:hook 相对路径相对配置文件目录解析(与
init/cleanupprogram 相同),因此不依赖进程 cwd。runner 不改写 cwd:exec保持调用者 cwd(定义仍可用--chdir覆盖),实例创建保持配置目录(与旧 init 契约一致),实例内最终 cwd 由 bwrap--chdir决定。 - 顺序:hook 参数追加在静态
args之后。若静态参数已经隐藏某个宿主路径(例如--tmpfs /tmp),hook 不能再从该路径--bind;先 bind,后隐藏。 - 实例创建:runner 的最终命令就是既有后端命令
$KURUWA_RMUX_BIN --__internal-daemon "$KURUWA_INTERNAL_SOCKET" --listener-fd "$KURUWA_LISTENER_FD",参数由 Kuruwa 以已解析值给出。沙盒参数必须让该路径在沙盒内可见且可执行(例如--ro-bind / /)。listener FD 继承契约不变:exec 前只对该 FD 清除FD_CLOEXEC;runner、hook 与 bwrap 都不得关闭、复制或泄漏给终端子进程。 exec不使用 listener FD,也不提供实例元信息环境变量(除非调用者自己设置)。- 诊断:runner/hook 的 stderr 就是命令的 stderr;实例创建时按 §6.6 收集。Kuruwa 不把 argv 或环境值拼进 shell 源码,也不把它们写进日志。
terminal_passthrough 只影响实例初始化:在固定的 remain-on-exit on、exit-empty off 之后,再执行原生 set-option -g prefix None、prefix2 None、status off,让客户端(Harness Hub)把 Ctrl+B 原样送给终端且不显示 RMUX 状态栏。缺省 false 时不设置任何 prefix/status 选项,保持既有交互行为;该设置只作用于声明它的实例,不是全局无条件关闭。
7. 运行目录、启动与生命周期
7.1 布局
<root>/
config.toml # 默认配置(首次 serve 生成,跨关闭保留)
daemon.log # 后台 serve 的追加日志(跨关闭保留)
init/ # 默认 host/bwrap 初始化脚本
admin.sock
tenants/<tenant>.sock
instances/<internal-instance-id>/
项目根由 KURUWA_ROOT 决定(未设置时 $PWD/.kuruwa,相对值相对当前目录,不向上搜索)。<runtime> 默认为项目根,也可由 serve --runtime 显式覆盖;显式 runtime 中仍有 admin.sock、tenants/、instances/。主 daemon 创建 runtime、tenants、instances 目录为 0700,socket 为 0600。租户 socket 只为配置中的租户创建;没有租户创建命令。
socket 路径允许超过 sockaddr_un.sun_path 的 108 字节上限(例如项目根嵌得很深):超过时 bind/connect 使用父目录的 /proc/self/fd/<fd>/<basename> 形式,真实 socket 路径、0600 权限、诊断与清理仍使用原路径。父目录 fd 只在该 bind/connect 期间存活并带 O_CLOEXEC,不会传给实例子进程;因此长路径仍然不产生新的全局状态、哈希文件名或临时 socket 目录。
7.2 serve 启动规则
serve 默认后台启动,--foreground 时前台常驻。后台模式不写 pid 文件、不加锁、不引入 token 或恢复系统;它用当前可执行文件启动一个 --foreground 子进程,stdin 为 null,stdout/stderr 追加到 <root>/daemon.log,并处于新 session。启动时:
- 解析项目根、配置和 runtime。未显式给
--config时,只对缺省配置做自动初始化:创建0700根与init/,写缺失的默认配置和 host/bwrap 脚本;已有文件不覆盖。显式--config不存在则 exit 1,不创建该路径。 - 读取并校验配置;失败 exit 1,不留下半初始化入口。
- 检查 runtime:
- 若
admin.sock可完成无副作用管理请求(tenant list),说明已有健康 daemon。后台serve幂等返回already_running,不重复启动;前台serve仍以冲突失败。 - 若存在不可连接的 socket 或其他明确运行残留(实例目录、私有状态等),明确拒绝并提示人工确认后清理;不得盲目覆盖、删除或杀进程。
config.toml、daemon.log、init/等持久根文件不算运行残留;仅存在空的tenants/、instances/目录也不算。 - runtime 不存在、为空或仅含上述持久文件与空控制子目录时才继续。
- 若
- 绑定
admin.sock和全部租户 socket;任一失败则清理本次已创建的入口并以 exit 1 失败。
后台父进程只在管理入口真正可用后返回;它观察自己启动的子进程是否提前退出,并有有限启动超时。失败时只终止本次 spawn 的子进程,不触碰其他 daemon。成功 JSON 为 {root,runtime,socket,log,status},status 为 started 或 already_running;纯文本输出去掉品牌装饰的简短路径。--foreground --json 保持日志/错误约定,不伪造成功对象。
serve 就绪后的运行日志写 stderr;后台模式该 stderr 由 daemon.log 收集。日志由 tracing subscriber 集中初始化,事件包含时间、级别和模块;过滤使用标准 RUST_LOG,未设置时默认 info,不提供 Kuruwa 专用日志配置。生命周期开始/完成用低频 INFO,后端异常用 WARN,未完成的创建回滚/清理/关闭用 ERROR。结构化字段只包含已有实例/终端 ID、公共名、操作、阶段、耗时、结果类别、安全 I/O kind/errno、退出码/信号和清理结果;绝不记录完整请求、argv、环境变量、PTY screen/transcript、凭据或完整 SDK 对象。SDK Transport 只保留 I/O kind 与 errno,其他 SDK 错误只记录稳定类别,不输出 SDK 自由文本 operation/feature/hint。客户端错误仍为脱敏摘要。SIGINT/SIGTERM 按正常关闭路径尽量清理后退出;SIGKILL 不保证清理。
7.3 关闭与重启
shutdown 与管理端信号触发的正常关闭都会:
- 停止接受新请求,关闭等待中的请求;
- 通过各实例 RMUX 终止仍活动的终端;
- 销毁受管实例,best effort 调用
cleanup; - 删除本次运行创建的 admin/租户 socket、实例目录和私有状态,不删除
config.toml、daemon.log、init/或其他不属于本次运行的文件。
清理失败要报告并使 shutdown exit 1,同时在 runtime 留下可识别的残留;下一次 serve 必须按 §7.2 拒绝并提示人工清理。
不实现崩溃恢复。重启后:
- 配置中的定义和租户重新加载;
- 实例、终端、输出、授权全部不恢复;
- 新运行使用新代际内部 ID;旧连接已经随 socket 关闭而失效;
- 不允许旧授权/旧请求命中新对象。
7.4 实例失效
主 daemon 持有的实例子进程实际退出后,实例进入 stopped 并仍出现在 instance list,不自动重建;其仍运行或 unknown 的终端标记 orphaned,保留状态与输出用于诊断。单次 RMUX 连接、list 或 pane 超时只是本次操作失败,不会把实例置为 stopped 或把终端置为 orphaned;只有目标操作明确确认 session 不存在时才收敛为 orphaned。这些终端不能再 send/resize/cancel/attach,可由 owner/管理执行 clean 或 gc。实例失效或销毁不自动撤销既有终端 grant:grant 仍绑定原内部 ID,按原授权可读诊断,但不能输入/attach;只有 revoke、clean 或 gc 才移除 grant。实例对象本身没有崩溃恢复命令。
8. 权限矩阵
三个授权来源,取并集:
- 管理:管理 socket 的调用者;全部对象和操作。
- 实例 owner:创建该实例的租户;拥有该实例及其内全部终端的控制权。管理创建的实例没有租户 owner,只能通过终端 grant 交付。
- 终端 grant:管理通过
grant授予某配置租户对某终端的访问;不包含实例控制权。
| 操作 | 管理 | 实例 owner | 终端 grant |
|---|---|---|---|
tenant list |
是 | 否 | 否 |
instance create |
全部定义 | 仅配置获准定义 | 否 |
instance list |
全部 | 仅自己创建的实例 | 空列表 |
instance destroy |
任意实例 | 仅自己的实例 | 否 |
start / run(创建终端) |
任意实例 | 自己的实例 | 否 |
resume(等待) |
是 | 自己的终端 | 被授予终端 |
status / ls |
全部 | 自己实例内终端 | 仅被授予终端 |
send |
是 | 自己的终端 | 被授予终端 |
screen / tail / transcript |
是 | 自己的终端 | 被授予终端 |
resize |
是 | 自己的终端 | 被授予终端 |
cancel |
是 | 自己的终端 | 否 |
clean |
是 | 自己的终端 | 否 |
gc |
全部 | 自己实例/终端的范围 | 否 |
attach |
是 | 自己的实例 | 否 |
cli 内 attach |
是 | 自己的实例 | 否 |
grant / revoke |
是 | 否 | 否 |
shutdown |
是 | 否 | 否 |
skill |
本地可用 | 本地可用 | 本地可用 |
补充:
run含创建,因此终端 grant 不能run;需要等待时用resume。attach要求实例控制权;终端 grant 不返回 endpoint/session,也不影响该 grant 的 send/read 能力。- 终端 grant 可以
send --key -- C-c,这是输入;cancel是生命周期操作,仍仅 owner/管理可用。 - owner 对自己实例中的所有终端有控制权,包括管理在该实例中创建的终端。高权限终端应放在管理拥有、只 grant 单终端的实例中。
gc对 owner 只处理其有控制权的终端;管理处理全部。gc不触碰running。- 不可见对象的查询返回
not_found;可见但不允许的操作返回denied。两者都不得泄漏实例归属、其他终端、endpoint 或 session。 instance list对仅终端授权者返回空列表而不是报错,避免泄漏实例存在性;ls只列可访问终端。- 所有入口共用同一授权语义;猜中全局名称不能绕过检查。
9. 撤回与等待请求
主协议是本地、一请求一响应,不设计网络协议版本。等待类请求(run/resume)在等待期间保持连接;不需要流式订阅 API。
revoke 必须:
- 删除对应终端的 grant;
- 立即关闭/拒绝该租户针对该终端正在等待的请求,使连接断开或得到明确拒绝;
- 拒绝后续请求;
- 不终止终端,不杀进程,不回滚已执行的 shell 副作用;
- 不影响 owner/管理
attach或其他入口。
若同一租户同时通过实例 owner 身份拥有该终端,撤回 grant 只撤销 grant,owner 权限仍然有效。已交付的字节和已产生的进程副作用不在撤回范围。实现以后若增加流式输出,撤回必须在同一时刻切断相关流;MVP 不要求实现流。
授权绑定终端内部 ID。即使将来出现名称重用,旧 grant/旧连接也不能命中新对象;本契约还要求同一运行内名称根本不可重用,作为第二道保证。
10. 命令演练(纸面契约)
以下演练是契约,用于检查 M0 的命令、权限、成功与失败结果是否闭合。M2–M5 的真实脚本已覆盖这些路径,M6 的
scripts/check.sh顺序执行全部验收;本段保留纸面语义,实际命令以当前契约和脚本为准。M5 当时的 serve 只有前台模式,历史命令记录见 部署与 M5 演练;现行 serve 默认后台,显式--foreground保持前台。假设kuruwa已在PATH,rmux是兼容的 patched 客户端/服务端。
准备配置 kuruwa.toml(租户只来自配置;没有 tenant create):
[[definitions]]
name = "host"
init = ["bash", "examples/init-host.sh"]
[[definitions]]
name = "bwrap"
init = ["bash", "examples/init-bwrap.sh"]
[[tenants]]
name = "alice"
definitions = ["bwrap"]
[[tenants]]
name = "bob"
definitions = []
R=/srv/kuruwa/run
ADMIN="--socket $R/admin.sock"
ALICE="--socket $R/tenants/alice.sock"
BOB="--socket $R/tenants/bob.sock"
步骤 1:启动主 daemon(创建租户上下文)
- 入口/权限:本地管理用户,
serve不需要 socket。 - 目标:runtime 目录、admin socket、alice/bob 租户 socket;缺省配置时还会创建项目根和默认脚本。
- 成功:后台命令确认管理入口可用后返回;
admin.sock、tenants/alice.sock、tenants/bob.sock存在;尚无实例。前台模式使用--foreground保持常驻。
kuruwa serve --config ./kuruwa.toml --runtime "$R" # 显式路径,默认后台
kuruwa serve --foreground --config ./kuruwa.toml --runtime "$R"
- 失败:配置非法、显式
--config不存在、runtime 已有 live daemon 或未清理残留 → exit 1,不创建可用入口。已有健康后台 daemon 时后台命令幂等返回already_running;前台重复启动仍失败。残留只能人工确认后清理,不自动杀进程。
管理端验证:
kuruwa $ADMIN tenant list
成功列出 alice(definitions bwrap)与 bob(空)。租户执行同一命令应得到 denied,exit 1。
步骤 2:租户创建实例
- 入口/权限:alice 入口;
bwrap在配置获准列表中。 - 目标:新实例
alice-dev,owner 为 alice。 - 成功:等待初始化与 RMUX 就绪后返回 ready;此后才可用。
kuruwa $ALICE instance create --definition bwrap --name alice-dev
- 失败:
--definition host(未获准)→denied,不产生进程/实例;- 名称已占用 →
conflict,不覆盖、不重用; - 初始化退出或 10 秒未就绪 →
backend,exit 1,无半成品实例。
管理端可创建全部定义;租户只能创建配置获准定义:
kuruwa $ADMIN instance create --definition host --name ops-host
kuruwa $ADMIN instance list
管理端看到 ops-host 与 alice-dev。alice 的 instance list 只看到 alice-dev;bob 的列表为空。
步骤 3:创建终端并运行命令
- 入口/权限:alice 是
alice-dev的 owner。 - 目标:
alice-dev内两个终端build和hello。 - 成功:终端登记、输出/状态可读;客户端断开不影响终端。
kuruwa $ALICE start --instance alice-dev --name build --cols 120 --rows 32 -- \
sh -c 'printf "BUILD_READY\n"; sleep 300'
kuruwa $ALICE run --instance alice-dev --name hello --interval 1 -- \
sh -c 'printf "HELLO\n"; exit 7'
kuruwa $ALICE status hello
kuruwa $ALICE --json status hello
run 成功退出 0;hello 的 state 为 exited,exit_code 为 7。这不是传输错误,也不是 CLI exit 7。终端与输出保留,不会自动清理。
run 等待窗口到期而命令仍运行时:
kuruwa $ALICE resume build --interval 0
返回 state 为 running、wait 为 timeout,exit 0;build 继续运行。
- 失败:重复终端名 →
conflict;无实例/无权限 →not_found/denied;--cols 0→ exit 1;start的 argv 为空 → usage exit 2。
步骤 4:管理端创建高权限终端并只交付单终端
- 入口/权限:管理端创建
ops-host(host/noop 普通定义)中的终端ops,然后 grant 给 alice。 - 成功:alice 能读写该终端,但不能管理实例或其他终端。
kuruwa $ADMIN start --instance ops-host --name ops -- bash
kuruwa $ADMIN grant --tenant alice --terminal ops
kuruwa $ALICE status ops
kuruwa $ALICE send ops -- printf 'FROM_ALICE\n'
kuruwa $ALICE send ops --key -- Enter
kuruwa $ALICE screen ops
kuruwa $ALICE resume ops --interval 1
- alice 的
ls显示build、hello、ops;bob 的ls不显示ops。 - alice 尝试
start --instance ops-host --name other ...:她不是 owner,返回not_found,不创建终端。 - alice 尝试
cancel ops/clean ops:终端 grant 不允许生命周期操作 →denied。 - alice 尝试
attach ops:alice 只有终端 grant、不是ops-host的 owner →denied,不返回 endpoint/session。 - bob 猜
ops:status ops返回not_found;ls不暴露。
步骤 5:撤回访问
- 入口/权限:管理端。
- 目标:删除 alice 对
ops的 grant。 - 成功:alice 已有等待请求立刻断开/拒绝;后续访问失败;
ops仍运行。
kuruwa $ADMIN revoke --tenant alice --terminal ops
kuruwa $ALICE status ops # not_found(grant 已移除,且 alice 不是该实例 owner)
kuruwa $ADMIN status ops # 仍可见、仍 running
kuruwa $ADMIN attach ops # 管理端仍可原生 attach
attach 成功时 exec:
rmux -N -S <daemon 返回的真实 endpoint> attach-session -t <daemon 返回的 session>
人工 detach(默认 C-b d)后 ops 继续运行。attach 不属于终端 grant 撤回范围。
- 失败:
revoke对不存在租户/终端 →not_found;租户调用revoke→denied。
步骤 6:取消、清理与 GC
- 入口/权限:owner/管理。
- 目标:结束并清理 alice 自己的终端,并验证 GC 的年龄阈值。
kuruwa $ALICE cancel build
# cancel 先发 C-c,2 秒宽限后仍存活则由实例内 RMUX 终止;不按 namespace PID kill。
kuruwa $ALICE clean hello
kuruwa $ALICE gc --older-than 3600
cancel build成功:build进入cancelled,记录保留,grant 仍指向原内部 ID。clean hello成功:记录、输出和 session 销毁,相关 grant 失效;hello名称仍不可复用。gc --older-than 3600不回收刚刚结束的build(未达到年龄),也不碰running;这里用于确认年龄阈值而不是立即删除。- 失败:终端 grant 调用
clean/gc→denied;实例不可用而无法终止活动进程 → 保留记录并 exit 1,不宣称清理成功。
步骤 7:销毁实例
- 入口/权限:owner/管理。
- 目标:
alice-dev。 - 成功(非空拒绝):存在
build等终端记录时,instance destroy alice-dev返回conflict,exit 1,不销毁任何东西。
kuruwa $ALICE instance destroy alice-dev # 非空,失败
kuruwa $ALICE instance destroy alice-dev --force # 显式强制
--force 成功:通过实例 RMUX 终止活动终端,结束本次启动的实例进程,best effort 调一次 cleanup;实例从列表消失。销毁时仍在运行的终端记录转为 orphaned,已经结束的记录保留原状态;两者都仍可 status/读输出,但不能 send/attach。随后可:
kuruwa $ALICE ls
kuruwa $ALICE clean build
kuruwa $ADMIN gc --older-than 0
instance create --name alice-dev 在同一运行内仍然 conflict:实例名称不复用。
- 失败:清理程序失败 → 报告
cleanup失败并 exit 1;不得把失败描述为已清理。
步骤 8:正常关闭与重启
- 入口/权限:管理端。
- 目标:主 daemon 与全部 runtime 资源。
- 成功:终端/实例结束,cleanup 调用,admin/租户 socket 删除,serve 退出;配置保留。
kuruwa $ADMIN shutdown
# 后台或前台 serve 都退出;$R 中不应再有 live socket 或实例残留
# config.toml、daemon.log 与 init/ 保留
随后用同一配置与 runtime 重新启动:
kuruwa serve --config ./kuruwa.toml --runtime "$R"
# 默认路径时也可直接:kuruwa serve
- 新运行只有配置中的定义/租户,没有旧实例、终端、输出或 grant。
- 旧 socket 与旧连接不能命中新对象;内部身份使用新代际。
- 若上次关闭清理失败、
$R有残留,重启必须 exit 1 并提示人工清理,不自动覆盖或杀进程。 - 若
admin.sock仍有 live daemon,重启拒绝,不启动第二个。
步骤 9:失败与边界清单
| 场景 | 期望 |
|---|---|
未给 --socket/KURUWA_SOCKET,且默认 <root>/admin.sock 不存在 |
连接前按 root 入口尝试,exit 1;不创建 root、不自动启动。 |
显式 --socket/KURUWA_SOCKET 失效 |
exit 1;不回退默认 root 入口、不创建替代 daemon。 |
租户调用 shutdown/tenant list/grant/revoke/attach |
denied,exit 1;不泄漏 endpoint。 |
| 租户猜其他租户的终端名 | not_found,无对象详情;ls 不出现。 |
| 重复实例名/终端名(含销毁后) | conflict,不覆盖、不复用。 |
配置名含 /、.、空白或未知定义引用 |
serve exit 1,无入口。 |
| 初始化进程提前退出 | instance create exit 1;无可用实例。 |
| 初始化 10 秒未就绪 | instance create exit 1;清理受管进程并报告。 |
run 的终端退出码非零 |
CLI exit 0;JSON exit_code 保留非零值。 |
run/resume 等待窗口到期 |
CLI exit 0;state: running、wait: timeout;终端保留。 |
| 非空实例非强制销毁 | exit 1;终端与实例不变。 |
实例失效后 send/attach |
exit 1;终端记录与输出仍可诊断。 |
gc 遇到 running 终端 |
跳过,不杀进程。 |
| RMUX 连接/list/pane 超时 | 仅本次操作 exit 1;实例与终端状态不变。 |
clean/gc 无法确认 session 已消失 |
保留记录与 grant,exit 1;不宣称清理成功。 |
| 并发第二次 destroy | conflict;cleanup 最多执行一次。 |
| shutdown 发起连接在响应前断开 | 关闭流程照常完成并退出,不依赖 ACK 写成功。 |
| 清理程序失败 | 报告失败,exit 1;不宣称清理成功,保留目录,shutdown 继续报告残留。 |
11. 实现常量与边界
以下值是实现常量,不新增配置项;实现测试必须覆盖各数值的 0/1、负值、边界和超大值:
| 常量 | 值 | 说明 |
|---|---|---|
| readiness 超时 | 10 秒 | 初始化到 RMUX 可 list 的上限。 |
| 后台启动就绪超时 | 10 秒 | 后台父进程等待管理入口可用的上限;子进程提前退出立即报错。 |
| cancel 宽限 | 2 秒 | 发 C-c 后等待自行退出的时间;之后由实例 RMUX 终止。 |
--interval 缺省 |
1 秒 | run/resume 本次等待窗口;最小 0。 |
start/run 缺省尺寸 |
80x24 | --cols/--rows 均为正整数。 |
tail --lines 缺省 |
50 | 正整数;0/负值拒绝。 |
gc --older-than 缺省 |
0 秒 | 非负;按 ended_at 判断。 |
| runtime/tenant 目录 | 0700 |
控制目录私有。 |
| socket 权限 | 0600 |
admin 与租户入口。 |
“等待窗口”与“终端寿命”不是同一个概念:任何窗口到期、CLI 断开或客户端退出都不会自动 clean;只有显式 clean 或满足条件的 gc 会删除记录。