Documentation
¶
Overview ¶
Package config 负责 handoff 配置的加载、默认值填充与访问令牌生成。
职责:
- 读取 ~/.handoff/config.yaml(或指定路径)并解析为 Config
- 首次运行(文件不存在)时生成默认配置与随机 Token 并写盘
- 旧文件顶层 update 段先剥再严格解码,避免 KnownFields 拒启动
- 提供 DefaultPath 默认配置路径
边界:
- 不做网络请求,不校验 Target 可达性(由上层调用方负责)
- 不依赖 logx,仅使用 slog 默认 logger 输出关键节点日志
listenclass.go —— listen 地址的三档归类与 loopback 变体推导(B85)。
职责:
- 把 listen 的 host 归为 loopback / 通配 / 单点三档
- 对通配与单点给出 "127.0.0.1:<同端口>" 的 loopback 变体地址
边界:
- 纯函数,不做网络请求、不校验地址可绑性
- CLI(cmd/root.go 拨号改写)与 agentd(cmd/agentd.go 辅助监听)共用同一 口径——两处一旦发散就会出现「CLI 改写了、agentd 没绑」的连接拒绝,判定 必须唯一,这正是本文件存在的理由
- 与 cmd/init.go 的 listenKind 语义不同(那是 init 交互的预选口径,端口也 参与归类),刻意不合并(spec §3.1)
Index ¶
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
Types ¶
type ApproverConfig ¶
type ApproverConfig struct {
Executor string
Model string
Timeout time.Duration
Blacklist []string
}
ApproverConfig 描述审批链的廉价模型审批者。
参数语义:
- Executor:审批者执行者名(如 opencode/claude/grok);空=不启用审批链
- Model:审批者模型名;空=用执行者自身默认模型
- Timeout:单次裁决超时,超时按 escalate 处理(fail-closed)
- Blacklist:自定义黑名单正则;命中即跳过审批者直接升级人工协调者
type Config ¶
type Config struct {
Listen string
Token string
DataDir string
// Relay 是 executor 出站 relay 配置;nil 表示不启用 relay 出站。
Relay *RelayConfig `yaml:"relay,omitempty"`
// RepoRoot 是自动登记(B62)的 clone 落点根目录:首次派发到某台机器、而
// 那台机器上还没有该项目时,agentd 会把仓库 clone 到这里,实际落点为
// RepoRoot/<登记名>。空=未配置,Load 会补 <DataDir>/repos。
//
// 为什么放顶层而不是放进 Target:Target 是在**协调者本地**被读取的
//(见 cmd/pull.go 的 cfg.Targets[task.Target]),放那儿会让「仓库放哪」
// 变成协调者的本地状态,换一台协调者机接管就得重配。放顶层的语义是
// 「每台执行机自己决定它的仓库放在哪」。
// yaml:"repo_root":strict 解码器(KnownFields)按 tag 匹配键名,不加 tag 时
// yaml.v3 会把 RepoRoot 映射成 reporoot,与 README/设计文档里的 repo_root 不符。
RepoRoot string `yaml:"repo_root"`
// PathDirs 是本机额外的可执行文件搜索目录:agentd 启动时按序追加到 PATH 末尾
// (见 internal/pathenv)。内置已知目录表没覆盖到的安装位置写在这里。
//
// 为什么放顶层而不是放进 Executor:它描述的是「**这台机器**上工具装在哪」,
// 不是执行者的属性——与 RepoRoot 同一个道理。
//
// omitempty 是硬要求,不是风格:配置以 KnownFields(true) 严格解析,未知键让
// agentd **启动失败**。没有 omitempty 时,新版 Save 会把 path_dirs: [] 写进
// 每一台机器的 config.yaml,而一台还没换版的旧 agentd 读到它就再也起不来了
//(B59 spec D7 同款,方向相反)。
PathDirs []string `yaml:"path_dirs,omitempty"`
// Proxy 是 handoff **自身**出网时使用的代理地址,形如 http://host:port、
// https://host:port、socks5://host:port、socks5h://host:port。
// 空 = 不配,沿用 HTTPS_PROXY/HTTP_PROXY/NO_PROXY 环境变量(现行为不变)。
//
// 作用范围只有两处:更新链路的 HTTP 出网(查 release、下资产)与 agentd 的
// git clone/fetch。**不作用于协调者↔agentd 链路**——那是 LAN/loopback 地址,
// 代理化轻则每次请求多绕一跳,重则 socks5 代理解析不了 100.x.y.z 直接断链,
// 而这条链路的可达性是 handoff 的命根子。也**不作用于 executor**:executor
// 的出网归 env 段(B19),两者故障域不交叉——代理挂了只影响升级,不影响任务执行。
//
// 为什么放顶层而不是放进 Target:它描述的是「**这台机器**怎么出网」,
// 与 RepoRoot / PathDirs 同一个道理。
//
// omitempty 是硬要求,不是风格:配置以 KnownFields(true) 严格解析,未知键让
// agentd **启动失败**。没有 omitempty 时,新版 Save 会把 proxy: "" 写进
// 每一台机器的 config.yaml,而一台还没换版的旧 agentd 读到它就再也起不来了
//(PathDirs 同款)。
Proxy string `yaml:"proxy,omitempty"`
// EnvForward 是要转发进终端会话的环境变量名单(见 internal/ptyhost)。
//
// 它解决的是 PathDirs 解决不了的**另一类**问题:SSH_AUTH_SOCK 这类变量由
// launchd / ssh-agent **按会话注入**,不来自任何 dotfile,因此 login shell
// 的 rc 链**无法**像恢复 PATH 那样把它恢复出来。agentd 以服务形态托管时,
// 终端里的 ssh / git push 会因此全部失败。
//
// 三态语义(**不要**在 Load 里填默认值):
// nil → 用内置默认清单 ptyhost.DefaultEnvForward()(当前是 SSH_AUTH_SOCK)
// 非 nil → 完全以配置为准
// [](显式) → 一个都不转发
// 一旦 Load 把默认值填进结构体,下一次 Save 就会把 env_forward 落进
// config.yaml,omitempty 形同虚设,旧 agentd 照样被顶死。
//
// omitempty 是硬要求,理由同 PathDirs(B59 spec D7)。
EnvForward []string `yaml:"env_forward,omitempty"`
StallTimeout time.Duration
Targets map[string]Target
// Approver 是分级审批链的廉价模型审批者配置。Executor 空=不启用审批链
//(二期前的现行为:权限请求直接走人工协调者)。
Approver ApproverConfig
// Executor 是任务的缺省执行者选择配置。
Executor ExecutorConfig
// Terminal 是 dispatch 成功后是否弹终端实况的配置(Auto 默认 false,见 TerminalConfig)。
Terminal TerminalConfig
// Sync 是任务结束后自动同步远程任务分支到本地的配置。
Sync SyncConfig
// Env 是 agent(executor)名 → env 文件名的映射:该 agent 启动时注入该文件里的
// 环境变量。文件名必须是 <DataDir>/env/ 下的纯文件名(含路径分隔符会被拒绝)。
// 未配置的 agent 不注入。任务执行者与审批者共用同一份(见 B19 spec §4)。
Env map[string]string
// Discipline 是 executor 名 → 纪律块文件名的映射:派发该 executor 的任务时,
// 把该文件的内容作为「执行纪律」注入首回合 prompt。文件名必须是
// <DataDir>/discipline/ 下的纯文件名(含路径分隔符会被拒绝)。
//
// 三档语义(与 Env 刻意不同的是第三档):有非空值用该文件;显式空串关闭注入;
// 未出现该键用内置默认。Env 在未出现该键时是不注入。
//
// 为什么第三档不同:env 内容是机器特有的,猜错不如不猜;纪律块内容是 handoff
// 通用的,不给默认等于让用户退回人工粘贴到 plan 头部(见 B129 spec §2.4)。
Discipline map[string]string
// ProcFence 是 executor 进程围栏配置。默认启用、保留 10%。
ProcFence ProcFenceConfig `yaml:"proc_fence,omitempty"`
// Web 是浏览器控制台相关配置。
Web WebConfig
}
Config 是 handoff 的顶层配置。
Listen 为本地 agentd 监听地址;Token 为本机 agentd 的访问令牌; DataDir 为数据目录;StallTimeout 为卡住会话的判定超时; Targets 为可配对远端主机的地址与令牌表(供 --target 换算)。
func Defaults ¶ added in v0.3.0
func Defaults() *Config
Defaults 返回一份「出厂默认 + 随机 token」的配置,**不落盘**。
与 Load 的分工:Load 在文件不存在时会生成 token 并把默认配置写盘(firstRun), 这是给 CLI/agentd 用的。桌面壳的首次引导不能调 Load——向导问答中途崩溃、 被杀或取消时,磁盘上绝不允许出现会让 Resolve 判为「已配置」的 config.yaml (SIGKILL 实测原样复现过这个坑,且回滚法依赖进程还活着,封不死)。Defaults 只构造内存里的配置,落盘由调用方在问答成功后一次性 config.Save。
func Load ¶
Load 加载配置:文件不存在时返回带默认值的 Config 并自动生成随机 Token 写盘。
参数:
- path: 配置文件路径
返回:
- 解析后的配置;文件不存在时返回默认配置
- 错误信息:读/解析/校验失败时返回。剥 update 后回写失败不返回错误
注意:
- 首次运行生成的 Token 需要人工同步到配对主机的 Targets 中
- 旧文件的顶层 update 必须先剥再 KnownFields,否则 v0.1.x 机器升级即砖
与 Defaults 的分工:Load 在文件不存在时会生成 token 并把默认配置写盘(firstRun), 这是给 CLI/agentd 用的;桌面壳的首次引导走 Defaults(见其 doc 注释)。
type ExecutorConfig ¶
ExecutorConfig 描述 dispatch 未显式指定执行者时的缺省选择。
type ListenClass ¶
type ListenClass int
ListenClass 是 listen 地址的三档归类。
const ( // ListenLoopback:host 已是回环(127.x/::1/localhost),或 listen 解析失败—— // 错的 listen 让 net.Listen 自己去报,归类函数不抢这个错误。 ListenLoopback ListenClass = iota // ListenWildcard:通配(0.0.0.0/::/空 host),监听面已含 loopback。 ListenWildcard // ListenSingle:单网卡 IP 或主机名——需要辅助监听的档位。 ListenSingle )
func ClassifyListen ¶
func ClassifyListen(listen string) (cls ListenClass, loopback string)
ClassifyListen 把 listen 的 host 归为三档,并推导 loopback 变体地址。
参数:
- listen: 形如 "host:port" 的监听地址
返回:
- cls: 三档归类;解析失败归 ListenLoopback(即调用方什么都不做)
- loopback: 通配/单点档为 "127.0.0.1:<同端口>";loopback 档(含解析失败) 原样返回 listen,调用方可无条件使用返回值
type ProcFenceConfig ¶
type ProcFenceConfig struct {
Disabled bool `yaml:"disabled"`
ReserveRatio float64 `yaml:"reserve_ratio"`
// TaskBudget 是**单个任务**名下的进程数告警线,超过即发一次
// task_proc_pressure 事件唤醒审核者。0 = 关掉这一档。
//
// 为什么不是把围栏值调小:RLIMIT_NPROC 的内核判定是「该 uid 当前进程总数
// 是否超过调用者软限」,不是「这棵进程树的后代数」。给每个 shim 装 300 的
// 效果是「uid 总数一过 300 所有 shim 一起 fork 失败」,第二个任务会被第一个
// 饿死——表达不了每任务额度,只能换成 watchdog 按任务点名(B93 spec §2.2)
TaskBudget int `yaml:"task_budget"`
// TaskHardLimit 是单个任务的进程数硬上限,超过即强制清扫并落 failed。
// 0 = 关掉这一档。
//
// 两档的分工:TaskBudget 是「叫醒人」,TaskHardLimit 是「不等人了」。
// 只有一档要么太吵(每次都杀)要么太晚(人没醒机器就没了)。
TaskHardLimit int `yaml:"task_hard_limit"`
}
ProcFenceConfig 描述 executor 进程围栏(RLIMIT_NPROC)的策略。
字段说明:
- Disabled: true 时完全不装围栏。逃生开关,正常不该用——2026-08-12 的 整机 fork 瘫痪就是无围栏状态下发生的
- ReserveRatio: 保留给 agentd/sshd/登录 shell 的名额占系统上限的比例; 0 或越界时取默认 0.1。这是「救护车道」的宽度,不是给 executor 的节流 旋钮——调小它不增加安全性,只会让 executor 更早撞墙
注意:yaml tag 必须写全。strict 解码器(KnownFields)按 tag 匹配键名, 不加 tag 时 yaml.v3 会把 ReserveRatio 映射成 reserveratio,与 README 里的 reserve_ratio 对不上(RepoRoot 同款教训)。
type RelayConfig ¶ added in v0.3.2
type RelayConfig struct {
URL string `yaml:"url"`
Credential string `yaml:"credential"`
Node string `yaml:"node"`
}
RelayConfig describes the executor's outbound relay registration. Credential is only sent during the WSS control exchange; it is separate from the E2E key derived from Token.
func (*RelayConfig) Validate ¶ added in v0.3.2
func (r *RelayConfig) Validate() error
Validate validates the executor relay configuration. ws:// is accepted for local tests; production deployments should use wss://.
type SyncConfig ¶
type SyncConfig struct {
Auto bool
}
SyncConfig 描述任务结束(completed/failed)后 wait 是否自动把远程任务分支 同步到本地仓库。Auto 默认 true;关闭后仍可用 handoff pull 手动同步。
type Target ¶
type Target struct {
Addr string `yaml:"addr,omitempty"`
Token string `yaml:"token,omitempty"` // relay 形态下额外用作 E2E PSK 源(HKDF 派生),relay 不可见。
User string `yaml:"user,omitempty"`
Relay string `yaml:"relay,omitempty"` // relay WSS URL;与 Addr 互斥。
Credential string `yaml:"credential,omitempty"` // coordinator 的 CONNECT 凭证。
Node string `yaml:"node,omitempty"` // relay 上的 executor 节点名。
}
Target 描述一个可配对远端主机:Addr 为 agentd 地址,Token 为其访问令牌, User 为可选的 ssh 用户名(非空时 attach/pull 的 ssh 目标换算为 user@host, 空=保持历史行为只用 host)。
type TerminalConfig ¶
type TerminalConfig struct {
Auto bool
}
TerminalConfig 描述 dispatch 成功后的终端弹窗行为。
Auto 默认 **false**(不弹);仅当置 true 时才在 darwin 下用 osascript 弹 Terminal.app 进实况;其余平台无论配置如何都降级为打印 「实况: handoff attach <id>」提示行。
为什么默认不弹:dispatch 的 stdout 有「单行任务 JSON」契约,弹窗与提示行都 不该干扰它;且逐次开关由 --no-terminal 承担,默认不弹才不会让老脚本因为 多出一行提示而解析错乱。
type WebConfig ¶ added in v0.3.0
type WebConfig struct {
AllowedHosts []string `yaml:"allowed_hosts"`
}
WebConfig 是浏览器控制台相关配置。
AllowedHosts 是 Host 白名单的扩展项——回环地址(127.0.0.1 / localhost / ::1) 与 Listen 的 host 恒在白名单内,无需重复配置。它为将来的域名/中转场景预留: agentd 部署在 handoff.example.com 后面时,不配这一项所有请求都会被 403。
yaml:"allowed_hosts":strict 解码器(KnownFields)按 tag 匹配键名, 不加 tag 时 yaml.v3 会把它映射成 allowedhosts(同 RepoRoot 的处理)。