chore: directory migration - gateway→server, web→client/electron
This commit is contained in:
168
server/internal/models/address.go
Normal file
168
server/internal/models/address.go
Normal file
@ -0,0 +1,168 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Address 是三维寻址 name@path.session 的解析结果
|
||||
type Address struct {
|
||||
Name string `json:"name"` // Agent 实例名(或人类用户名)
|
||||
Path string `json:"path"` // 工作区路径(可含 /,可为空)
|
||||
Session string `json:"session"` // 会话别名;"new" = 新建;"" = 默认会话
|
||||
Raw string `json:"raw"` // 原始字符串
|
||||
}
|
||||
|
||||
// SessionMode 是 session 位的三种语义
|
||||
type SessionMode int
|
||||
|
||||
const (
|
||||
// SessionDefault:session 位省略 → 投递到 name@path 的默认会话(不存在则建立)
|
||||
SessionDefault SessionMode = iota
|
||||
// SessionNew:session 位为 new → 强制新建一个会话
|
||||
SessionNew
|
||||
// SessionNamed:session 位为具体别名 → 必须已存在,否则无法送达
|
||||
SessionNamed
|
||||
)
|
||||
|
||||
// Mode 返回该地址 session 位的语义
|
||||
func (a Address) Mode() SessionMode {
|
||||
switch a.Session {
|
||||
case "":
|
||||
return SessionDefault
|
||||
case "new":
|
||||
return SessionNew
|
||||
default:
|
||||
return SessionNamed
|
||||
}
|
||||
}
|
||||
|
||||
// IsNewSession 表示该地址要求新建会话(仅 session == "new")。
|
||||
// 注意:session 位省略不等于 new,那是「默认会话」,见 Mode()。
|
||||
func (a Address) IsNewSession() bool {
|
||||
return a.Mode() == SessionNew
|
||||
}
|
||||
|
||||
// IsDefaultSession 表示该地址省略了 session 位,走默认会话
|
||||
func (a Address) IsDefaultSession() bool {
|
||||
return a.Mode() == SessionDefault
|
||||
}
|
||||
|
||||
func (a Address) String() string {
|
||||
return a.Raw
|
||||
}
|
||||
|
||||
// ParseAddress 解析 name@path.session 三维地址。
|
||||
//
|
||||
// 支持形态:
|
||||
//
|
||||
// deepseekharness@/program.updatefeature → name=deepseekharness path=/program session=updatefeature
|
||||
// pi@root.new → name=pi path=root session=new(新建)
|
||||
// builder@ModelRouter.fix-leak → name=builder path=ModelRouter session=fix-leak
|
||||
// human@.new → name=human path="" session=new
|
||||
// human → name=human path="" session=""(默认会话)
|
||||
//
|
||||
// 规则:
|
||||
// - 第一个 @ 之前是 name(必填)
|
||||
// - @ 之后按【最后一个 .】切成 path 与 session,因此 path 内可以包含 . 与 /
|
||||
// - 没有 . 时,整段视为 path,session 为空(默认会话)
|
||||
//
|
||||
// session 位三态语义见 Address.Mode():省略=默认会话,new=新建,其他=必须已存在。
|
||||
func ParseAddress(s string) (Address, error) {
|
||||
raw := strings.TrimSpace(s)
|
||||
if raw == "" {
|
||||
return Address{}, fmt.Errorf("empty address")
|
||||
}
|
||||
|
||||
// 只有形如 "@name@path.session" 时才剥掉前导 @;
|
||||
// "@ModelRouter.new" 缺少 name,应当报错而不是被当成名字。
|
||||
trimmed := raw
|
||||
if strings.HasPrefix(raw, "@") && strings.Contains(raw[1:], "@") {
|
||||
trimmed = raw[1:]
|
||||
}
|
||||
|
||||
at := strings.Index(trimmed, "@")
|
||||
if at < 0 {
|
||||
// 只有名字:human / builder
|
||||
name := strings.TrimSpace(trimmed)
|
||||
if name == "" {
|
||||
return Address{}, fmt.Errorf("missing agent name in %q", raw)
|
||||
}
|
||||
return Address{Name: name, Raw: raw}, nil
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(trimmed[:at])
|
||||
if name == "" {
|
||||
return Address{}, fmt.Errorf("missing agent name in %q", raw)
|
||||
}
|
||||
|
||||
rest := trimmed[at+1:]
|
||||
|
||||
// 按最后一个 . 切 path / session;path 内允许 / 与 .
|
||||
var path, session string
|
||||
if dot := strings.LastIndex(rest, "."); dot >= 0 {
|
||||
path = rest[:dot]
|
||||
session = rest[dot+1:]
|
||||
} else {
|
||||
path = rest
|
||||
}
|
||||
|
||||
return Address{
|
||||
Name: name,
|
||||
Path: strings.TrimSpace(path),
|
||||
Session: strings.TrimSpace(session),
|
||||
Raw: raw,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// FormatAddress 把三段拼回可寻址的 name@path.session。
|
||||
//
|
||||
// **必须走这个函数而不是自己拼字符串**:path 为空时(人类用户没有工作区)
|
||||
// 朴素拼接得到 "admin.silent-harbor",而它没有 @,ParseAddress 会把整串当成
|
||||
// 名字,session 位丢失,地址静默失效。空 path 也必须留下那个 @ 与 . ——
|
||||
// "admin@.silent-harbor" 才解析成 name=admin path="" session=silent-harbor。
|
||||
//
|
||||
// session 传空则省略该位(默认会话语义)。
|
||||
func FormatAddress(name, path, session string) string {
|
||||
name = strings.TrimSpace(name)
|
||||
path = strings.TrimSpace(path)
|
||||
session = strings.TrimSpace(session)
|
||||
if name == "" {
|
||||
return ""
|
||||
}
|
||||
if session == "" {
|
||||
if path == "" {
|
||||
return name
|
||||
}
|
||||
return name + "@" + path
|
||||
}
|
||||
return name + "@" + path + "." + session
|
||||
}
|
||||
|
||||
// WithSession 返回同一收件方在指定会话下的地址。
|
||||
// 用于把 .new 换成刚建出来的会话别名 —— 参与方拿到的地址必须是能再次投递的那个。
|
||||
func (a Address) WithSession(session string) string {
|
||||
return FormatAddress(a.Name, a.Path, session)
|
||||
}
|
||||
|
||||
// ParseAddressList 解析逗号/分号/空白分隔的多个地址(用于 CC)
|
||||
func ParseAddressList(s string) ([]Address, error) {
|
||||
raw := strings.TrimSpace(s)
|
||||
if raw == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
fields := strings.FieldsFunc(raw, func(r rune) bool {
|
||||
return r == ',' || r == ';' || r == '\n' || r == '\t' || r == ' '
|
||||
})
|
||||
|
||||
out := make([]Address, 0, len(fields))
|
||||
for _, f := range fields {
|
||||
addr, err := ParseAddress(f)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, addr)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
94
server/internal/models/address_test.go
Normal file
94
server/internal/models/address_test.go
Normal file
@ -0,0 +1,94 @@
|
||||
package models
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseAddress(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
name string
|
||||
path string
|
||||
session string
|
||||
mode SessionMode
|
||||
}{
|
||||
// 用户给出的两个例子
|
||||
{"deepseekharness@/program.upadtefeature", "deepseekharness", "/program", "upadtefeature", SessionNamed},
|
||||
{"pi@root.new", "pi", "root", "new", SessionNew},
|
||||
|
||||
// 常规形态
|
||||
{"builder@ModelRouter.fix-memory-leak", "builder", "ModelRouter", "fix-memory-leak", SessionNamed},
|
||||
{"@builder@ModelRouter.new", "builder", "ModelRouter", "new", SessionNew},
|
||||
{"human@.new", "human", "", "new", SessionNew},
|
||||
|
||||
// 省略 session 位 = 默认会话(不等于 new)
|
||||
{"human", "human", "", "", SessionDefault},
|
||||
{"ops@prod", "ops", "prod", "", SessionDefault},
|
||||
|
||||
// path 内含 . 与 /(按最后一个 . 切)
|
||||
{"agent@/home/a.b/c.deploy", "agent", "/home/a.b/c", "deploy", SessionNamed},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
got, err := ParseAddress(c.in)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseAddress(%q) unexpected error: %v", c.in, err)
|
||||
}
|
||||
if got.Name != c.name || got.Path != c.path || got.Session != c.session {
|
||||
t.Errorf("ParseAddress(%q) = {name:%q path:%q session:%q}, want {name:%q path:%q session:%q}",
|
||||
c.in, got.Name, got.Path, got.Session, c.name, c.path, c.session)
|
||||
}
|
||||
if got.Mode() != c.mode {
|
||||
t.Errorf("ParseAddress(%q).Mode() = %v, want %v", c.in, got.Mode(), c.mode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// session 位省略与 new 必须是两种不同语义:
|
||||
// 省略 → 默认会话;new → 强制新建;其他 → 必须已存在。
|
||||
func TestSessionModeSemantics(t *testing.T) {
|
||||
def, _ := ParseAddress("pi@root")
|
||||
if def.Mode() != SessionDefault || def.IsNewSession() || !def.IsDefaultSession() {
|
||||
t.Errorf("pi@root 应为默认会话,得到 mode=%v isNew=%v", def.Mode(), def.IsNewSession())
|
||||
}
|
||||
|
||||
new_, _ := ParseAddress("pi@root.new")
|
||||
if new_.Mode() != SessionNew || !new_.IsNewSession() || new_.IsDefaultSession() {
|
||||
t.Errorf("pi@root.new 应为新建,得到 mode=%v", new_.Mode())
|
||||
}
|
||||
|
||||
named, _ := ParseAddress("pi@root.fix-leak")
|
||||
if named.Mode() != SessionNamed || named.IsNewSession() || named.IsDefaultSession() {
|
||||
t.Errorf("pi@root.fix-leak 应为具名会话,得到 mode=%v", named.Mode())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAddressErrors(t *testing.T) {
|
||||
for _, in := range []string{"", " ", "@", "@ModelRouter.new"} {
|
||||
if _, err := ParseAddress(in); err == nil {
|
||||
t.Errorf("ParseAddress(%q) expected error, got nil", in)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAddressList(t *testing.T) {
|
||||
list, err := ParseAddressList("pi@root.new, deepseekharness@/program.upadtefeature;ops@prod")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(list) != 3 {
|
||||
t.Fatalf("got %d addresses, want 3", len(list))
|
||||
}
|
||||
if list[0].Name != "pi" || !list[0].IsNewSession() {
|
||||
t.Errorf("addr[0] = %+v", list[0])
|
||||
}
|
||||
if list[1].Path != "/program" || list[1].Session != "upadtefeature" {
|
||||
t.Errorf("addr[1] = %+v", list[1])
|
||||
}
|
||||
if list[2].Name != "ops" || list[2].Path != "prod" || list[2].Mode() != SessionDefault {
|
||||
t.Errorf("addr[2] = %+v", list[2])
|
||||
}
|
||||
|
||||
empty, err := ParseAddressList(" ")
|
||||
if err != nil || empty != nil {
|
||||
t.Errorf("empty list = %v, %v; want nil, nil", empty, err)
|
||||
}
|
||||
}
|
||||
176
server/internal/models/calendar.go
Normal file
176
server/internal/models/calendar.go
Normal file
@ -0,0 +1,176 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CalendarEvent 日历事件。
|
||||
//
|
||||
// 设计参照 Outlook:事件有时间、提醒、收件人,触发时产生一封邮件。
|
||||
// 事件本身是日历实体,提醒是触发器,邮件是投递通道 —— 三者分离。
|
||||
type CalendarEvent struct {
|
||||
EventID string `json:"event_id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
ReminderText string `json:"reminder_text"`
|
||||
|
||||
// AgentName / ToAddress 是**单收件人时代的字段**,保留作兼容与兜底:
|
||||
// Recipients 为空时用它们。新代码一律读 EffectiveRecipients()。
|
||||
AgentName string `json:"agent_name"`
|
||||
ToAddress string `json:"to_address"`
|
||||
|
||||
// Recipients 是完整的收件人列表(每项是完整三维地址串)。
|
||||
//
|
||||
// 为什么不用 []Address 而用 []string:地址的三段语义(尤其 session 位的
|
||||
// new/别名三态)在**触发那一刻**才该被解析 —— 存结构化的话,
|
||||
// 「.new」这种一次性语义在建事件时就被固化,而重复事件每次触发都该
|
||||
// 重新决定落到哪条会话。存原始串让 ParseAddress 在投递时做这个决定。
|
||||
Recipients []string `json:"recipients"`
|
||||
|
||||
// DeliveryMode 决定多收件人怎么投:
|
||||
// "separate"(默认)—— 每人各发一封,落在各自的会话里,互相看不到
|
||||
// "together" —— 第一个是主收件人,其余进 cc_list,共享同一条线索
|
||||
//
|
||||
// 两种语义都需要而不是二选一:「让三个 Agent 各自独立汇报」与
|
||||
// 「让 pi 主办、dsh 知情」是完全不同的任务形态,用错会让协作失败 ——
|
||||
// 前者用 together 会让三个 Agent 互相看到对方的回复而趋同,
|
||||
// 后者用 separate 会让 dsh 完全不知道 pi 在做什么。
|
||||
DeliveryMode string `json:"delivery_mode"`
|
||||
|
||||
EventTime time.Time `json:"event_time"`
|
||||
RemindBefore int `json:"remind_before"` // 提前多少分钟
|
||||
|
||||
// Recurrence:公历 none/daily/weekly/monthly/yearly + 农历两种
|
||||
// lunar_monthly —— 每农历月同一日(如每月十五)
|
||||
// lunar_yearly —— 每农历年同月同日(过农历生日/祭日)
|
||||
//
|
||||
// lunar_daily 不存在:农历的「日」与公历同长,那就是 daily。
|
||||
// lunar_weekly 也不存在:农历没有「周」这个单位。
|
||||
Recurrence string `json:"recurrence"`
|
||||
RecurrenceEnd *time.Time `json:"recurrence_end,omitempty"`
|
||||
|
||||
Status string `json:"status"` // active/paused/cancelled
|
||||
LastFiredAt *time.Time `json:"last_fired_at,omitempty"`
|
||||
|
||||
// PermissionMode 是事件触发时新建会话应采用的档位(plan / workspace / full)。
|
||||
// 空 = workspace(默认)。
|
||||
// 复用已有会话时不能直接搬用:要 ModeAtMost(会话现档, 事件档) ——
|
||||
// 事件档表示「这件事允许到什么程度」,而会话现档可能更严(plan 档派出的
|
||||
// 任务不该因为日程触发就偷偷升到 workspace)。
|
||||
PermissionMode string `json:"permission_mode"`
|
||||
|
||||
// FiredFor 是已触发的那个 occurrence(值 = 当时的 EventTime)。
|
||||
// 去重靠它与 EventTime 相等判断,不是拿 LastFiredAt 比大小 ——
|
||||
// DueEvents 有 60 秒 lookahead,后者在窗口内恒为真会导致每 tick 重发。
|
||||
FiredFor *time.Time `json:"fired_for,omitempty"`
|
||||
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
CreatedBy string `json:"created_by"`
|
||||
}
|
||||
|
||||
// 重复规则常量。农历规则单独一组:它们的推进要经过 internal/lunar,
|
||||
// 不能像公历那样 AddDate 固定天数(农历月 29~30 天、闰年 13 个月)。
|
||||
const (
|
||||
RecurNone = "none"
|
||||
RecurDaily = "daily"
|
||||
RecurWeekly = "weekly"
|
||||
RecurMonthly = "monthly"
|
||||
RecurYearly = "yearly"
|
||||
RecurLunarMonthly = "lunar_monthly"
|
||||
RecurLunarYearly = "lunar_yearly"
|
||||
)
|
||||
|
||||
// IsLunarRecurrence 判断一条重复规则是否按农历推进。
|
||||
func IsLunarRecurrence(r string) bool {
|
||||
return r == RecurLunarMonthly || r == RecurLunarYearly
|
||||
}
|
||||
|
||||
// 事件状态常量。
|
||||
//
|
||||
// 此前这三个值只以裸字符串形式散落在 handler、scheduler 与前端里,而更新端点
|
||||
// 把 `status` 原样写进库 —— 于是一个拼错的值(比如 "pause")会变成一个
|
||||
// **调度器不认识的状态**:DueEvents 只查 status='active',那条提醒于是静默失效。
|
||||
// 人以为自己只是暂停了它,实际上再也恢复不了(界面的下拉框里没有这个选项)。
|
||||
//
|
||||
// 提成常量后,handler.validEventStatus 能对着这一份清单校验。
|
||||
const (
|
||||
// EventActive 生效中:到点会触发提醒。
|
||||
EventActive = "active"
|
||||
// EventPaused 暂停:保留事件与重复规则,但不触发。
|
||||
EventPaused = "paused"
|
||||
// EventCancelled 已取消:保留历史记录,不再触发也不再推进重复。
|
||||
EventCancelled = "cancelled"
|
||||
)
|
||||
|
||||
// ValidEventStatus 判断状态取值是否合法。
|
||||
func ValidEventStatus(s string) bool {
|
||||
switch s {
|
||||
case EventActive, EventPaused, EventCancelled:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 投递模式常量。
|
||||
const (
|
||||
DeliverSeparate = "separate"
|
||||
DeliverTogether = "together"
|
||||
)
|
||||
|
||||
// EffectiveRecipients 返回真正要投的收件人列表。
|
||||
//
|
||||
// Recipients 优先;为空时退回 ToAddress,再退回 AgentName。
|
||||
// 这个兜底链让旧数据(只有 agent_name 的事件)继续工作 ——
|
||||
// 历史事件不迁移,读的时候归一化。
|
||||
func (e *CalendarEvent) EffectiveRecipients() []string {
|
||||
if len(e.Recipients) > 0 {
|
||||
out := make([]string, 0, len(e.Recipients))
|
||||
for _, r := range e.Recipients {
|
||||
if r = strings.TrimSpace(r); r != "" {
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
if len(out) > 0 {
|
||||
return out
|
||||
}
|
||||
}
|
||||
if a := strings.TrimSpace(e.ToAddress); a != "" {
|
||||
return []string{a}
|
||||
}
|
||||
if a := strings.TrimSpace(e.AgentName); a != "" {
|
||||
return []string{a}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EffectiveDeliveryMode 归一化投递模式,未知值按 separate 处理。
|
||||
//
|
||||
// 默认 separate 而不是 together:separate 的失败是「Agent 各干各的」,
|
||||
// together 的失败是「本该独立的 Agent 互相污染了上下文」——
|
||||
// 后者更难发现也更难挽回。
|
||||
func (e *CalendarEvent) EffectiveDeliveryMode() string {
|
||||
if e.DeliveryMode == DeliverTogether {
|
||||
return DeliverTogether
|
||||
}
|
||||
return DeliverSeparate
|
||||
}
|
||||
|
||||
// CalendarAttachment 事件附件。
|
||||
type CalendarAttachment struct {
|
||||
AttachmentID string `json:"attachment_id"`
|
||||
EventID string `json:"event_id"`
|
||||
Filename string `json:"filename"`
|
||||
SHA256 string `json:"sha256"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// CalendarView 日历视图(月/周/日)。
|
||||
type CalendarView struct {
|
||||
Events []CalendarEvent `json:"events"`
|
||||
// 当前时间线上的事件数(用于统计徽章)
|
||||
UpcomingCount int `json:"upcoming_count"`
|
||||
TodayCount int `json:"today_count"`
|
||||
}
|
||||
309
server/internal/models/models.go
Normal file
309
server/internal/models/models.go
Normal file
@ -0,0 +1,309 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Agent 代表一个已注册的 Agent 实例
|
||||
type Agent struct {
|
||||
ID uuid.UUID `json:"agent_id"`
|
||||
Name string `json:"agent_name"`
|
||||
Secret string `json:"-"`
|
||||
HostURL string `json:"host_url"`
|
||||
Workspaces []Workspace `json:"workspaces"`
|
||||
Platform string `json:"platform"`
|
||||
Status string `json:"status"`
|
||||
// DefaultRounds 是派给该 Agent 的新任务默认多少个来回(0 = 不限)。
|
||||
// 真正的额度在每条会话上(sessions.max_rounds),这里只是默认值。
|
||||
DefaultRounds int `json:"default_rounds"`
|
||||
// UsedRounds 是累计发信数,纯统计,不拦请求。
|
||||
// 它原本是「终身额度」——那种额度跑满要人工重置才能再干活,
|
||||
// 而 Agent 是长期在线的,所以已降级为观测数据。
|
||||
UsedRounds int `json:"used_rounds"`
|
||||
LastSeen *time.Time `json:"last_seen"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
|
||||
// ModeEnforcement 是该平台插件自报的权限档位强制能力(native / advisory),
|
||||
// 随心跳上报(与模型目录同一条通道,见 I-1:平台自己说的才算)。
|
||||
//
|
||||
// 为什么要存:发件人在派活前得知道 plan 档在对方那儿到底算不算。
|
||||
// homeagent 的核心没有工具调用拦截点,档位只能写进提示词 ——
|
||||
// 把这个事实藏起来比做不到本身更危险。
|
||||
ModeEnforcement string `json:"mode_enforcement"`
|
||||
}
|
||||
|
||||
// Workspace 是 Agent 管理的项目工作区
|
||||
type Workspace struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
// Session 是有明确边界的任务会话
|
||||
type Session struct {
|
||||
ID uuid.UUID `json:"session_id"`
|
||||
Alias *string `json:"session_alias"`
|
||||
FromAgent string `json:"from_agent"`
|
||||
Subject string `json:"subject"`
|
||||
Status string `json:"status"`
|
||||
OwnerUserID *uuid.UUID `json:"owner_user_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
MailCount int `json:"mail_count,omitempty"`
|
||||
|
||||
// RenameDismissed 是用户驳回过的改名提议。
|
||||
// 记下来才能让提示条不再反复弹同一个建议。
|
||||
RenameDismissed string `json:"rename_dismissed,omitempty"`
|
||||
|
||||
// AliasSource 记录别名是谁定的:
|
||||
// platform = Agent 平台自动同步来的,后续同步可以覆盖
|
||||
// manual = 人显式指定(手工改名或接受了 Agent 的提议),平台同步不得覆盖
|
||||
// 没有这个区分,平台下一次 session.updated 会把人刚定的名字冲掉。
|
||||
AliasSource string `json:"alias_source,omitempty"`
|
||||
|
||||
// MaxRounds/UsedRounds 是本任务的往返预算(0 = 本会话不限)。
|
||||
// 配额的语义是「这件事值得多少个来回」,那是任务的属性而非 Agent 的属性,
|
||||
// 所以在写信时给、在对话页里随时调。
|
||||
MaxRounds int `json:"max_rounds"`
|
||||
UsedRounds int `json:"used_rounds"`
|
||||
|
||||
// PermissionMode 声明本任务允许 Agent 动手到什么程度:
|
||||
// plan / workspace / full。空值按 DefaultPermissionMode 处理。
|
||||
PermissionMode string `json:"permission_mode"`
|
||||
|
||||
// PermissionEnforcement 记录接收平台是否真正强制了权限档位:
|
||||
// native / advisory。它描述执行事实,不与 PermissionMode 混为一谈。
|
||||
PermissionEnforcement string `json:"permission_enforcement"`
|
||||
}
|
||||
|
||||
// User 是人类用户(多用户账号体系)
|
||||
type User struct {
|
||||
ID uuid.UUID `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"display_name"`
|
||||
PasswordHash string `json:"-"`
|
||||
Role string `json:"role"` // admin / user
|
||||
Status string `json:"status"` // active / disabled
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
LastLogin *time.Time `json:"last_login"`
|
||||
|
||||
// 权限边界:空切片 = 不限
|
||||
AllowedAgents []string `json:"allowed_agents"`
|
||||
AllowedPaths []string `json:"allowed_paths"`
|
||||
}
|
||||
|
||||
// IsAdmin 判断是否管理员
|
||||
func (u User) IsAdmin() bool { return u.Role == "admin" }
|
||||
|
||||
// CanUseAgent 判断用户是否可向指定 Agent 发信
|
||||
// 空白名单(或为空) = 不限;管理员不受限;收件方是人类用户时不走此限制
|
||||
func (u User) CanUseAgent(agentName string) bool {
|
||||
if u.IsAdmin() || len(u.AllowedAgents) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, a := range u.AllowedAgents {
|
||||
if a == agentName {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// CanUsePath 判断用户是否可访问指定工作区。
|
||||
// 空白名单 = 不限;管理员不受限;空 path(人类地址)总是允许。
|
||||
// 匹配规则:完全相等,或白名单项作为目录前缀(/program 允许 /program/sub)。
|
||||
func (u User) CanUsePath(path string) bool {
|
||||
if u.IsAdmin() || len(u.AllowedPaths) == 0 || path == "" {
|
||||
return true
|
||||
}
|
||||
for _, p := range u.AllowedPaths {
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
if path == p {
|
||||
return true
|
||||
}
|
||||
prefix := p
|
||||
if !strings.HasSuffix(prefix, "/") {
|
||||
prefix += "/"
|
||||
}
|
||||
if strings.HasPrefix(path, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Mail 是会话中的一封邮件
|
||||
type Mail struct {
|
||||
ID uuid.UUID `json:"mail_id"`
|
||||
SessionID uuid.UUID `json:"session_id"`
|
||||
ParentMailID *uuid.UUID `json:"parent_mail_id"`
|
||||
FromName string `json:"from_name"`
|
||||
FromWorkspace string `json:"from_workspace"`
|
||||
ToName string `json:"to_name"`
|
||||
ToWorkspace string `json:"to_workspace"`
|
||||
CCList []Address `json:"cc_list"`
|
||||
Subject string `json:"subject"`
|
||||
Body string `json:"body"`
|
||||
MailType string `json:"mail_type"`
|
||||
PermOptions []string `json:"permission_options,omitempty"`
|
||||
PermResult string `json:"permission_result,omitempty"`
|
||||
PermissionKind string `json:"permission_kind,omitempty"`
|
||||
PermissionMulti bool `json:"permission_multi_select,omitempty"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
HopLimit int `json:"hop_limit"`
|
||||
|
||||
SessionAlias string `json:"session_alias,omitempty"`
|
||||
|
||||
// SessionWorkspace 是**这条会话**的工作目录(sessions.workspace)。
|
||||
//
|
||||
// 为什么不能用 FromWorkspace / ToWorkspace 代替:
|
||||
// - 人 → Agent:to_workspace 是真路径,from_workspace 为空(人没有工作目录)
|
||||
// - Agent → 人:to_workspace 为空,而 **from_workspace 存的是 Agent 名
|
||||
// 而不是路径**(历史遗留,见 db/migrate.go 的 sessions.workspace 注释)
|
||||
//
|
||||
// 于是「Agent 发来的这封信,那个 Agent 在哪个目录干活」只能从会话上取 ——
|
||||
// 前端要靠它拼出 `name@path.alias` 这个可投递地址(界面上曾显示成
|
||||
// `dsh@dsh`,就是拿 from_workspace 当路径拼出来的)。
|
||||
SessionWorkspace string `json:"session_workspace,omitempty"`
|
||||
|
||||
BodyPreview string `json:"body_preview,omitempty"`
|
||||
|
||||
// Attachments 仅在读取单封邮件/会话线程时填充;列表接口为省带宽留空
|
||||
Attachments []Attachment `json:"attachments,omitempty"`
|
||||
|
||||
// RenameAlias / RenameReason 是 Agent 在本封正文里提议的新会话别名。
|
||||
// 存在邮件上而非会话上:邮件是不可篡改的历史记录,
|
||||
// 「谁在哪一封里提了什么」应当留痕。
|
||||
RenameAlias string `json:"rename_alias,omitempty"`
|
||||
RenameReason string `json:"rename_reason,omitempty"`
|
||||
|
||||
// FromHuman 表示发件方是人类用户而不是 Agent。
|
||||
//
|
||||
// 插件靠它判「要不要自动转发本轮结论」:Agent 之间不自动回,
|
||||
// 否则两边都以为对方的插件会代它开口,持续互相唤醒(生产实测 6 轮)。
|
||||
//
|
||||
// **补拉路径必须有它**:SSE 事件里叫 `from_human`,而插件重启后走
|
||||
// `GET /mail/inbox` 补投 —— 那条路径上没有这个字段的话,补投的邮件会被
|
||||
// 保守当成 Agent 来信,于是人发的那封失去自动回信。
|
||||
FromHuman bool `json:"from_human"`
|
||||
|
||||
// ToHuman 表示收件方是人类用户而不是 Agent(判据与 FromHuman 同源:
|
||||
// to_name 是否存在于 users 表)。
|
||||
//
|
||||
// 前端拼地址时靠它决定「要不要带 path 与会话位」:人只写名字,
|
||||
// Agent 才拼 `name@path.session`。此前靠 `to_workspace` 是否为空的启发式 ——
|
||||
// 但对 Agent 而言 to_workspace 存的是 Agent 名而不是路径(历史遗留),
|
||||
// 那条启发式在「Agent 名恰好为空」时会猜错。显式布尔胜过猜。
|
||||
ToHuman bool `json:"to_human"`
|
||||
|
||||
// PermissionMode / PermissionEnforcement 是所属会话的权限档位与实际强制力。
|
||||
//
|
||||
// **补拉路径必须有它们**(与 FromHuman 同一个理由):SSE 事件里叫
|
||||
// `permission_mode` / `permission_enforcement`,而插件重启后走
|
||||
// `GET /mail/inbox` 补投 —— 那条路径上没有这两个字段的话,补投的邮件
|
||||
// 会拿不到档位,插件只能回落默认档 —— 于是一条 plan 档的任务在重启后
|
||||
// 惄惄变成了 workspace 档。
|
||||
PermissionMode string `json:"permission_mode"`
|
||||
PermissionEnforcement string `json:"permission_enforcement"`
|
||||
}
|
||||
|
||||
// PermissionRequest 是 Agent 向人类发起的权限请求
|
||||
type PermissionRequest struct {
|
||||
ID uuid.UUID `json:"request_id"`
|
||||
MailID uuid.UUID `json:"mail_id"`
|
||||
SessionID uuid.UUID `json:"session_id"`
|
||||
AgentName string `json:"agent_name"`
|
||||
Question string `json:"question"`
|
||||
Options []string `json:"options"`
|
||||
Context string `json:"context"`
|
||||
Kind string `json:"kind"` // permission | question
|
||||
MultiSelect bool `json:"multi_select"` // 仅 question 使用
|
||||
Result *string `json:"result"`
|
||||
DecidedAt *time.Time `json:"decided_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// SSE 事件类型
|
||||
const (
|
||||
EventNewMail = "new_mail"
|
||||
EventPermissionDecision = "permission_decision"
|
||||
EventSessionUpdate = "session_update"
|
||||
EventAgentOnline = "agent_online"
|
||||
)
|
||||
|
||||
// ---------- 密钥认证 ----------
|
||||
|
||||
// 密钥类型:签发时决定其生命周期
|
||||
const (
|
||||
// KeyPermanent 永不过期,可重复使用(正式部署的 Agent 用这个)
|
||||
KeyPermanent = "permanent"
|
||||
// KeyOneTime 首次验证后即失效(用于把 Agent 首次接入的窗口压到最小)
|
||||
KeyOneTime = "one_time"
|
||||
// KeyTimed 到 ExpiresAt 之后失效
|
||||
KeyTimed = "timed"
|
||||
)
|
||||
|
||||
// ValidKeyType 判断密钥类型是否受支持
|
||||
func ValidKeyType(t string) bool {
|
||||
return t == KeyPermanent || t == KeyOneTime || t == KeyTimed
|
||||
}
|
||||
|
||||
// AgentKey 是管理员签发的 Agent 接入密钥。
|
||||
// AgentName 为空表示「待绑定」——密钥有效但还没指定属于哪个 Agent,
|
||||
// 首次注册时由注册请求里的 name 落定。
|
||||
type AgentKey struct {
|
||||
ID uuid.UUID `json:"key_id"`
|
||||
Token string `json:"key_token,omitempty"` // 仅创建时回显一次
|
||||
TokenHint string `json:"token_hint"` // 前 8 位 + 省略号,用于列表展示
|
||||
AgentName *string `json:"agent_name"`
|
||||
KeyType string `json:"key_type"`
|
||||
Label string `json:"label"`
|
||||
ExpiresAt *time.Time `json:"expires_at"`
|
||||
UsedAt *time.Time `json:"used_at"`
|
||||
CreatedBy *uuid.UUID `json:"created_by"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// UserKey 是用户自助签发的客户端连接密钥,只能用于 /me/* 人类邮箱接口。
|
||||
type UserKey struct {
|
||||
ID uuid.UUID `json:"key_id"`
|
||||
Token string `json:"key_token,omitempty"` // 仅创建时回显一次
|
||||
TokenHint string `json:"token_hint"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
Label string `json:"label"`
|
||||
KeyType string `json:"key_type"`
|
||||
ExpiresAt *time.Time `json:"expires_at"`
|
||||
UsedAt *time.Time `json:"used_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// TokenHint 返回密钥的展示形式:只露前 8 位。
|
||||
// 密钥全文仅在创建响应里出现一次,之后任何列表接口都只给 hint。
|
||||
func TokenHint(token string) string {
|
||||
if len(token) <= 8 {
|
||||
return token
|
||||
}
|
||||
return token[:8] + "…"
|
||||
}
|
||||
|
||||
// ---------- 附件 ----------
|
||||
|
||||
// Attachment 是一封邮件的附件元数据。文件内容存磁盘,按 sha256 内容寻址。
|
||||
//
|
||||
// MailID 为空表示「已上传、尚未挂到邮件上」:上传与发信是两步操作
|
||||
// (Agent 侧工具走 JSON,无法在发信请求里带 multipart),中间态必须允许存在。
|
||||
type Attachment struct {
|
||||
ID uuid.UUID `json:"attachment_id"`
|
||||
MailID *uuid.UUID `json:"mail_id"`
|
||||
Uploader string `json:"uploader"`
|
||||
Filename string `json:"filename"`
|
||||
ContentType string `json:"content_type"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
SHA256 string `json:"sha256"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
142
server/internal/models/permission_mode.go
Normal file
142
server/internal/models/permission_mode.go
Normal file
@ -0,0 +1,142 @@
|
||||
package models
|
||||
|
||||
// ─── 权限档位 ───
|
||||
//
|
||||
// 三档描述「这条任务允许 Agent 动手到什么程度」。**AgentMail 声明,平台执行,
|
||||
// 插件只做翻译** —— 不能让插件按工具名自己猜着拦,那会同时违反 I-1(平台原生
|
||||
// 信号是唯一真相来源)与 I-4(插件只搬运不决策),而且四个插件对「workspace
|
||||
// 到底管什么」必然各猜一套。
|
||||
//
|
||||
// 档位与 DSH 原生的三档沙箱一一对应(read-only / workspace-write /
|
||||
// danger-full-access,见 @deepseek-ai/dsh-sandbox-policy)—— 那不是巧合,
|
||||
// 是同一个问题的同一个答案。
|
||||
const (
|
||||
// ModePlan 只读:查资料、读代码、出方案,一个字都不许写。
|
||||
//
|
||||
// 危险操作**直接拒绝**,不产生权限邮件 —— plan 档的语义就是「这轮不动手」,
|
||||
// 没什么可问人的。模型该做的是把方案写在回信里。
|
||||
ModePlan = "plan"
|
||||
|
||||
// ModeWorkspace 本目录内可动手,越界要问人。默认档。
|
||||
//
|
||||
// 「本目录」= 会话的 workspace(三维地址的 path 位)。越界的定义是
|
||||
// 写到那个目录之外,或跑一条无法判定影响范围的命令。
|
||||
ModeWorkspace = "workspace"
|
||||
|
||||
// ModeFull 自动放行,不问人。
|
||||
//
|
||||
// 不产生权限邮件:既然已经声明了全权,再问一遍只是噪音。
|
||||
ModeFull = "full"
|
||||
)
|
||||
|
||||
// DefaultPermissionMode 是没有显式指定时的档位。
|
||||
//
|
||||
// 选 workspace 而不是 full:默认值应当是「多数任务够用且出错代价可控」的那一档。
|
||||
// 一个默认全权的系统里,「我忘了收紧」与「我确实需要全权」在数据上无法区分。
|
||||
const DefaultPermissionMode = ModeWorkspace
|
||||
|
||||
// PermissionModes 是全部合法档位,按宽松程度递增排列。
|
||||
//
|
||||
// 顺序有意义:ModeAtMost 靠它做「向更严取整」。
|
||||
var PermissionModes = []string{ModePlan, ModeWorkspace, ModeFull}
|
||||
|
||||
// ValidPermissionMode 判断是不是合法档位。
|
||||
func ValidPermissionMode(m string) bool {
|
||||
for _, v := range PermissionModes {
|
||||
if v == m {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// NormalizePermissionMode 把外部输入收敛成合法档位。
|
||||
//
|
||||
// 空串 → 默认档;非法值 → 默认档(**不是** ModeFull)。
|
||||
// 拼错一个档位名不该换来比预期更大的权限。
|
||||
func NormalizePermissionMode(m string) string {
|
||||
if ValidPermissionMode(m) {
|
||||
return m
|
||||
}
|
||||
return DefaultPermissionMode
|
||||
}
|
||||
|
||||
// modeRank 是档位的宽松程度序号,越大越宽松。
|
||||
//
|
||||
// 只接已经归一化过的档位 —— 调用方负责先跑 NormalizePermissionMode。
|
||||
// 让它自己处理非法值会造出两套语义:曾经这里把未知值当 rank 0(plan),
|
||||
// 而 NormalizePermissionMode 把它归到 workspace,于是同一个脏值在不同函数里
|
||||
// 含义不同,ModeAtMost 也因此不可交换(单元测试当场抓到)。
|
||||
func modeRank(m string) int {
|
||||
for i, v := range PermissionModes {
|
||||
if v == m {
|
||||
return i
|
||||
}
|
||||
}
|
||||
// 归一化后不可能走到这里;防御性地返回默认档的序号。
|
||||
return modeRank(DefaultPermissionMode)
|
||||
}
|
||||
|
||||
// ModeAtMost 返回 a 与 b 里更严的那一档。
|
||||
//
|
||||
// 两个用途:
|
||||
// - 子会话继承:Agent 派活时子会话不得比父会话宽松(plan 档派不出 full 档子任务)
|
||||
// - 平台取整:平台表达不出精确档位时向更严的方向取整
|
||||
//
|
||||
// 为什么必须是同一个函数:这两处若各写一遍,早晚有一处会写成「取更宽松」。
|
||||
//
|
||||
// **先归一化再比较**:两个脏值都变成默认档,于是结果与参数顺序无关(可交换),
|
||||
// 也与 NormalizePermissionMode / ModeNeedsHuman 对同一个脏值的理解一致。
|
||||
func ModeAtMost(a, b string) string {
|
||||
na := NormalizePermissionMode(a)
|
||||
nb := NormalizePermissionMode(b)
|
||||
if modeRank(na) <= modeRank(nb) {
|
||||
return na
|
||||
}
|
||||
return nb
|
||||
}
|
||||
|
||||
// ModeNeedsHuman 这一档会不会产生权限邮件(即需不需要人来点头)。
|
||||
//
|
||||
// 只有 workspace 档需要人。这一点直接决定了「找不到人类时怎么办」:
|
||||
// plan 档当场拒绝、full 档自动放行,两者都不问人,所以**只有 workspace 档
|
||||
// 会走到「这条链上有没有人类」这个问题**,找不到就是 409。
|
||||
//
|
||||
// 这也是为什么 permission.go 里那段「退回第一个管理员」的兜底必须删掉:
|
||||
// 它让 409 分支永远不可达(实测:pi 给自己派活跑 bash,权限邮件发给了 jianf),
|
||||
// 而那段 409 的注释本身就在论证兜底是错的 —— 管理员对这条 Agent 链一无所知。
|
||||
func ModeNeedsHuman(m string) bool {
|
||||
return NormalizePermissionMode(m) == ModeWorkspace
|
||||
}
|
||||
|
||||
// ─── 强制力 ───
|
||||
//
|
||||
// 档位是「要求什么」,强制力是「平台实际做到了什么」。两者必须分开记录并且
|
||||
// 都对人可见(I-5:失败必须可见)—— 否则发件人以为 plan 档管住了 homeagent,
|
||||
// 而 homeagent 的核心根本没有工具调用拦截点。
|
||||
const (
|
||||
// EnforcementNative 平台有原生拦截点,档位被真正执行。
|
||||
EnforcementNative = "native"
|
||||
|
||||
// EnforcementAdvisory 平台没有拦截点,档位只写进提示词。
|
||||
//
|
||||
// 模型至少知道「这活只让你看不让你动」,但没有任何机制阻止它动手。
|
||||
// 这不是缺陷掩饰 —— 是把「做不到」如实标出来,让发件人自己决定要不要派。
|
||||
EnforcementAdvisory = "advisory"
|
||||
)
|
||||
|
||||
// ValidEnforcement 判断强制力取值是否合法。
|
||||
func ValidEnforcement(e string) bool {
|
||||
return e == EnforcementNative || e == EnforcementAdvisory
|
||||
}
|
||||
|
||||
// NormalizeEnforcement 收敛强制力取值。
|
||||
//
|
||||
// 空串或非法值 → advisory。**保守方向是 advisory 而不是 native**:
|
||||
// 没自报过的插件,我们不能替它宣称「档位在这里是被强制的」。
|
||||
func NormalizeEnforcement(e string) string {
|
||||
if ValidEnforcement(e) {
|
||||
return e
|
||||
}
|
||||
return EnforcementAdvisory
|
||||
}
|
||||
160
server/internal/models/permission_mode_test.go
Normal file
160
server/internal/models/permission_mode_test.go
Normal file
@ -0,0 +1,160 @@
|
||||
package models
|
||||
|
||||
// 权限档位的判据测试。
|
||||
//
|
||||
// 为什么值得单独一组测试:`ModeAtMost` 被两处调用(子会话继承 / 平台向更严取整),
|
||||
// 两处若各写一遍必有一处写成「取更宽松」。而 `NormalizePermissionMode` 的保守
|
||||
// 取向(非法值 → workspace 而非 full)是安全属性,拼错一个档位名不该换来更大权限。
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidPermissionMode(t *testing.T) {
|
||||
for _, m := range []string{ModePlan, ModeWorkspace, ModeFull} {
|
||||
if !ValidPermissionMode(m) {
|
||||
t.Fatalf("%q 应当合法", m)
|
||||
}
|
||||
}
|
||||
for _, m := range []string{"", "PLAN", "readonly", "danger-full-access", "workspace-write"} {
|
||||
if ValidPermissionMode(m) {
|
||||
t.Fatalf("%q 不该合法", m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 非法值必须落到 workspace,不能落到 full。
|
||||
// 拼错一个档位名换来全权是最不该有的失败方向。
|
||||
func TestNormalizePermissionMode_FailsClosed(t *testing.T) {
|
||||
for _, in := range []string{"", "full-access", "plan ", "FULL", "无", "workspace-write"} {
|
||||
got := NormalizePermissionMode(in)
|
||||
if got != DefaultPermissionMode {
|
||||
t.Fatalf("NormalizePermissionMode(%q) = %q,应当是默认档 %q", in, got, DefaultPermissionMode)
|
||||
}
|
||||
}
|
||||
if DefaultPermissionMode == ModeFull {
|
||||
t.Fatal("默认档不能是 full —— 「我忘了收紧」与「我确实需要全权」会无法区分")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizePermissionMode_KeepsValid(t *testing.T) {
|
||||
for _, m := range []string{ModePlan, ModeWorkspace, ModeFull} {
|
||||
if got := NormalizePermissionMode(m); got != m {
|
||||
t.Fatalf("合法档位应原样返回:%q → %q", m, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ModeAtMost 取更严的一档 —— 子会话继承与平台取整共用这一个判据。
|
||||
func TestModeAtMost(t *testing.T) {
|
||||
cases := []struct{ a, b, want string }{
|
||||
{ModePlan, ModeFull, ModePlan},
|
||||
{ModeFull, ModePlan, ModePlan},
|
||||
{ModeWorkspace, ModeFull, ModeWorkspace},
|
||||
{ModeFull, ModeWorkspace, ModeWorkspace},
|
||||
{ModePlan, ModeWorkspace, ModePlan},
|
||||
{ModeWorkspace, ModePlan, ModePlan},
|
||||
{ModeFull, ModeFull, ModeFull},
|
||||
{ModePlan, ModePlan, ModePlan},
|
||||
{ModeWorkspace, ModeWorkspace, ModeWorkspace},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := ModeAtMost(c.a, c.b); got != c.want {
|
||||
t.Fatalf("ModeAtMost(%q,%q) = %q,want %q", c.a, c.b, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 未知值归到默认档(workspace),而不是最严的 plan。
|
||||
//
|
||||
// 为什么不是 plan:脏数据的含义应该在整个包里只有一个 ——
|
||||
// NormalizePermissionMode / ModeNeedsHuman 都把它当默认档,ModeAtMost
|
||||
// 若单独把它当 plan,同一个脏值就有两种语义,且 ModeAtMost 不可交换
|
||||
// (单元测试当场抓到过)。一致比“局部更严”重要:默认档本身已经是安全的。
|
||||
func TestModeAtMost_UnknownFallsToDefault(t *testing.T) {
|
||||
if got := ModeAtMost("garbage", ModeFull); got != DefaultPermissionMode {
|
||||
t.Fatalf("未知档位应归默认档,得到 %q", got)
|
||||
}
|
||||
if got := ModeAtMost(ModeFull, "garbage"); got != DefaultPermissionMode {
|
||||
t.Fatalf("未知档位应归默认档,得到 %q", got)
|
||||
}
|
||||
// 脏值不得抬升权限:与 plan 相遇时仍然是 plan 胜出。
|
||||
if got := ModeAtMost("garbage", ModePlan); got != ModePlan {
|
||||
t.Fatalf("脏值不该把 plan 抬成更宽松的档,得到 %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ModeAtMost 必须可交换:两处调用点传参顺序不同,结果不能不同。
|
||||
func TestModeAtMost_Commutative(t *testing.T) {
|
||||
all := append([]string{"garbage", ""}, PermissionModes...)
|
||||
for _, a := range all {
|
||||
for _, b := range all {
|
||||
if ModeAtMost(a, b) != ModeAtMost(b, a) {
|
||||
t.Fatalf("ModeAtMost 不可交换:(%q,%q)=%q 但 (%q,%q)=%q",
|
||||
a, b, ModeAtMost(a, b), b, a, ModeAtMost(b, a))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 只有 workspace 档需要人 —— 这一条直接决定「找不到人类时怎么办」。
|
||||
//
|
||||
// plan 档当场拒绝、full 档自动放行,两者都不问人,所以只有 workspace 档会
|
||||
// 走到「这条链上有没有人类」这个问题,找不到就是 409。permission.go 里那段
|
||||
// 「退回第一个管理员」的兜底正因此必须删掉:它让 409 分支永远不可达。
|
||||
func TestModeNeedsHuman(t *testing.T) {
|
||||
if ModeNeedsHuman(ModePlan) {
|
||||
t.Fatal("plan 档不该问人:语义就是这轮不动手,直接拒绝即可")
|
||||
}
|
||||
if !ModeNeedsHuman(ModeWorkspace) {
|
||||
t.Fatal("workspace 档必须问人:越界时需要人点头")
|
||||
}
|
||||
if ModeNeedsHuman(ModeFull) {
|
||||
t.Fatal("full 档不该问人:已声明全权,再问一遍只是噪音")
|
||||
}
|
||||
}
|
||||
|
||||
func TestModeNeedsHuman_NormalizesInput(t *testing.T) {
|
||||
// 脏数据走默认档(workspace)→ 需要人。宁可多问一次,不可静默放行。
|
||||
if !ModeNeedsHuman("garbage") {
|
||||
t.Fatal("认不出的档位应当按默认档处理,即需要人")
|
||||
}
|
||||
if !ModeNeedsHuman("") {
|
||||
t.Fatal("空档位应当按默认档处理,即需要人")
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 强制力 ───
|
||||
|
||||
func TestValidEnforcement(t *testing.T) {
|
||||
if !ValidEnforcement(EnforcementNative) || !ValidEnforcement(EnforcementAdvisory) {
|
||||
t.Fatal("native / advisory 都应合法")
|
||||
}
|
||||
for _, e := range []string{"", "NATIVE", "none", "enforced"} {
|
||||
if ValidEnforcement(e) {
|
||||
t.Fatalf("%q 不该合法", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 保守方向是 advisory:没自报过的插件,不能替它宣称档位在那里是被强制的。
|
||||
func TestNormalizeEnforcement_FailsClosed(t *testing.T) {
|
||||
for _, in := range []string{"", "garbage", "NATIVE", "native "} {
|
||||
if got := NormalizeEnforcement(in); got != EnforcementAdvisory {
|
||||
t.Fatalf("NormalizeEnforcement(%q) = %q,应当是 advisory", in, got)
|
||||
}
|
||||
}
|
||||
if got := NormalizeEnforcement(EnforcementNative); got != EnforcementNative {
|
||||
t.Fatalf("显式 native 应原样保留,得到 %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// PermissionModes 的顺序是 ModeAtMost 的依据,不能被随手改动。
|
||||
func TestPermissionModesOrder(t *testing.T) {
|
||||
if len(PermissionModes) != 3 {
|
||||
t.Fatalf("档位应当是三个,得到 %d 个", len(PermissionModes))
|
||||
}
|
||||
if PermissionModes[0] != ModePlan ||
|
||||
PermissionModes[1] != ModeWorkspace ||
|
||||
PermissionModes[2] != ModeFull {
|
||||
t.Fatalf("PermissionModes 必须按宽松程度递增排列(plan < workspace < full),得到 %v", PermissionModes)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user