本页目录
  1. 0. MVP 实现与验收状态
  2. 1. 范围与边界
  3. 2. 对象、名称与身份
  4. 2.1 对象
  5. 2.2 公共名称
  6. 2.3 内部身份与代际
  7. 2.4 终端状态
  8. 3. 入口、连接与输出约定
  9. 3.1 两个入口
  10. 3.2 连接选择
  11. 3.3 输出
  12. 3.4 退出码
  13. 4. 命令表
  14. 4.1 管理命令
  15. 4.2 终端命令
  16. 4.3 JSON 成功结构
  17. 4.4 交互管理界面(cli)
  18. 4.5 本地执行(exec)
  19. 5. 配置
  20. 6. 初始化与清理契约
  21. 6.1 工作目录与实例目录
  22. 6.2 初始化环境表
  23. 6.3 listener FD 与 CLOEXEC
  24. 6.4 就绪判定
  25. 6.5 清理程序
  26. 6.6 诊断
  27. 6.7 声明式 sandbox 策略(runner 与 hooks)
  28. 7. 运行目录、启动与生命周期
  29. 7.1 布局
  30. 7.2 serve 启动规则
  31. 7.3 关闭与重启
  32. 7.4 实例失效
  33. 8. 权限矩阵
  34. 9. 撤回与等待请求
  35. 10. 命令演练(纸面契约)
  36. 步骤 1:启动主 daemon(创建租户上下文)
  37. 步骤 2:租户创建实例
  38. 步骤 3:创建终端并运行命令
  39. 步骤 4:管理端创建高权限终端并只交付单终端
  40. 步骤 5:撤回访问
  41. 步骤 6:取消、清理与 GC
  42. 步骤 7:销毁实例
  43. 步骤 8:正常关闭与重启
  44. 步骤 9:失败与边界清单
  45. 11. 实现常量与边界

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 使用 typed capture-pane 渲染历史;transcript 使用 typed retained raw bytes,非 JSON 输出逐字节保留,JSON content 使用 lossy UTF-8。若读取中发生已知 Lag 或达到实现上限,JSON 额外带 truncated/missed_events,非 JSON 在 stderr 报告,不声称完整。cancel 先 typed C-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 服务。该探测只用于初始化就绪判定,普通运行期操作不再先做健康探测。满足以下条件才登记实例可用:

  1. 初始化进程仍存活(它通常已 exec 为 RMUX);
  2. 真实 endpoint 能完成一次 list 请求;
  3. 从启动到成功的耗时未超过 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/cleanup program 相同),因此不依赖进程 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。启动时:

  1. 解析项目根、配置和 runtime。未显式给 --config 时,只对缺省配置做自动初始化:创建 0700 根与 init/,写缺失的默认配置和 host/bwrap 脚本;已有文件不覆盖。显式 --config 不存在则 exit 1,不创建该路径。
  2. 读取并校验配置;失败 exit 1,不留下半初始化入口。
  3. 检查 runtime:
    • 若 admin.sock 可完成无副作用管理请求(tenant list),说明已有健康 daemon。后台 serve 幂等返回 already_running,不重复启动;前台 serve 仍以冲突失败。
    • 若存在不可连接的 socket 或其他明确运行残留(实例目录、私有状态等),明确拒绝并提示人工确认后清理;不得盲目覆盖、删除或杀进程。config.toml、daemon.log、init/ 等持久根文件不算运行残留;仅存在空的 tenants/、instances/ 目录也不算。
    • runtime 不存在、为空或仅含上述持久文件与空控制子目录时才继续。
  4. 绑定 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 会删除记录。