Why: Owner-only command audit cannot prove shared business commits, and button dispatch omitted its authenticated actor. Evidence: Local go test ./... passed; isolated PostgreSQL cutoff, link, and SQLite-to-PostgreSQL migration receipts are recorded as SELF in the task card. Impact: Add an optional domain-verified metadata link, exact authorized cross-member read, idempotent storage, actor propagation, and honest PostgreSQL cutoff coverage. Broad audit pagination and other sources remain pending. Refs: Stage 5 W9; approved Stage 6 audit-link design adjustment r1
281 lines
14 KiB
Go
281 lines
14 KiB
Go
// Package extension 是基座对下游仓的公开扩展面。
|
||
// 下游只能依赖本包(以及 agent-system-core 的类型),不能导入 internal。
|
||
package extension
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"sync"
|
||
|
||
core "agent-system-core"
|
||
)
|
||
|
||
// SurfaceVersion 是本包导出面的实验版本。变更导出符号必须同时更新 surface.txt。
|
||
// 2.1.0-experimental:EXT-8 新增 ResourceAuthorizer 与 Registry.RegisterResourceAuthorizer。
|
||
// 2.2.0-experimental:EXT-9 新增 Host.KnowledgePublish/KnowledgeDelete 与
|
||
// Options.DisableUploadIndexing。
|
||
// 2.3.0-experimental:EXT-12 最小开关 Options.DisableConvCardMirror(会话卡不镜像
|
||
// 进项目公共流)。
|
||
// 2.4.0-experimental:EXT-11 新增 SetScopedHostFactory/NewScopedHost(按 owner+pluginID
|
||
// 重建受限宿主句柄,供下游恢复路径)。
|
||
// 2.5.0-experimental:Options.DisableExecTools(执行原语启动级硬关闭)。
|
||
// 2.6.0-experimental:Options.ExecBackend(EXT-16 执行后端 direct|sandbox)。
|
||
// 2.7.0-experimental:Registry.AddIntent 与 IntentSpec(G2-2 意图声明面)。
|
||
// 2.8.0-experimental:EXT-17 新增可选能力接口 SurfaceHost(SurfaceSave/Get/List/
|
||
// Activation;Generative UI U2)。实现由基座 hostcaps 注入;未装配时类型断言
|
||
// 不成立即明确拒绝,不给必实现 Host 接口加方法(不破坏既有消费者)。
|
||
// 2.11.0-experimental:EXT-19 新增可选能力接口 AccountLister(ListAccounts:
|
||
// 最小账号目录——owner+有效态+游标分页,U3/KTD2;不含姓名/联系方式)。
|
||
// 2.10.0-experimental:EXT-8b 新增 ResourceAuthzQuery/ResourceAuthorizerV2 与
|
||
// Registry.RegisterResourceAuthorizerV2(资源级授权,U9 首切片)。
|
||
// 2.9.0-experimental:Registry.AddAdminZone 与 AdminZoneSpec(WP3-I3/KTD4:编译
|
||
// 插件向基座管理区声明展示分区;分区入口不做基座级权限判定,数据能力由
|
||
// 分区组件自身的命令面 fail-closed)。
|
||
// 2.12.0-experimental: optional ProjectHost/ProjectLifecycleRegistry and trusted
|
||
// ResourceAuthzQuery.Source for per-document publication authorization.
|
||
// 2.13.0-experimental: optional ProjectAuditHost and ProjectAuditRegistry (X2
|
||
// project-bound command-audit read slice and domain audit.read callback).
|
||
// 2.14.0-experimental: X2 optional committed project-audit link publisher,
|
||
// verifier, disclosure callback, and exact linked receipt read.
|
||
const SurfaceVersion = "2.14.0-experimental"
|
||
|
||
// ErrNotLinked 表示进程没有空白导入 link 包,Main 无法启动服务。
|
||
var ErrNotLinked = errors.New("extension: runner not linked; blank-import agent-system-backend/pkg/extension/link")
|
||
|
||
// ResourceAuthorizer 是 EXT-8 资源授权适配器:基座在 owner 自有资源判定未命中时,
|
||
// 回调询问「owner 对 projectID 是否有共享读取授权」。只回答布尔,不接触基座内部
|
||
// 状态;返回 error 视为拒绝(fail-closed)。仅作用于读取路径,写路径一律 owner 自有。
|
||
type ResourceAuthorizer func(owner string, projectID core.ID) (bool, error)
|
||
|
||
// ResourceAuthzQuery 是 EXT-8b 资源级授权查询(U9):字段由执行端从受信上下文
|
||
// (认证会话、受信存储)构造,不采信请求自报的归属;下游只回答布尔,不接触基座
|
||
// 内部状态;返回 error 视为拒绝(fail-closed)。读取路径专用;写路径一律 owner 自有。
|
||
type ResourceAuthzQuery struct {
|
||
Actor string `json:"actor"` // 认证主体(发起读取的会话)
|
||
Action string `json:"action"` // read|download|list|search|stream|model-context
|
||
ResourceType string `json:"resourceType"` // 基座资源类型(artifact/attachment/...)
|
||
ResourceID core.ID `json:"resourceId"` // 资源稳定 ID
|
||
Owner string `json:"owner"` // 资源属主(受信存储解析,非请求自报)
|
||
// Source is optional trusted resource provenance, populated from stored knowledge rows.
|
||
Source string `json:"source,omitempty"`
|
||
ProjectID core.ID `json:"projectId,omitempty"` // 所属项目;空=无项目私域
|
||
}
|
||
|
||
// ResourceAuthorizerV2 EXT-8b 资源级授权适配器:V1(owner×project 布尔)之上的
|
||
// 细粒度形态——注册后对域内读路径权威(含 owner 自有命中与共享读取)。
|
||
type ResourceAuthorizerV2 func(q ResourceAuthzQuery) (bool, error)
|
||
|
||
// Registry 是进程内扩展的注册口。实现在基座内部,对下游只暴露这些方法。
|
||
type Registry interface {
|
||
AddSkill(name, markdown string) error
|
||
AddCommand(spec json.RawMessage) error
|
||
// AddCommandHandler 注册插件命令规格并绑定进程内处理器(EXT-1):
|
||
// 基座在分发时按请求 Owner 构造 Host 句柄调用 h。
|
||
AddCommandHandler(spec json.RawMessage, h Handler) error
|
||
Note(kind, name string, payload json.RawMessage) error
|
||
// RegisterResourceAuthorizer 注册 EXT-8 资源授权适配器(进程内扩展);
|
||
// 首个注册者生效。声明式扩展不支持(回调需宿主进程内代码)。
|
||
RegisterResourceAuthorizer(authz ResourceAuthorizer) error
|
||
// RegisterResourceAuthorizerV2 注册 EXT-8b 资源级授权适配器(进程内扩展);
|
||
// 首个注册者生效;注册后读路径按资源粒度咨询(V1 为未注册时的兼容回落)。
|
||
RegisterResourceAuthorizerV2(authz ResourceAuthorizerV2) error
|
||
// AddIntent 声明自然语言意图(G2-2):短语命中后路由到已注册命令——
|
||
// 经同一命令总线分发(同审计信封与 HITL 收口)。规格见 IntentSpec。
|
||
AddIntent(spec json.RawMessage) error
|
||
// AddAdminZone 声明基座管理区展示分区(WP3-I3/KTD4):编译插件把自己的
|
||
// 领域工作台以分区形式挂进管理区;声明只携带展示元数据,分区入口不做
|
||
// 基座级权限判定——数据能力由分区组件自身的命令面 fail-closed。
|
||
// 规格见 AdminZoneSpec。
|
||
AddAdminZone(spec json.RawMessage) error
|
||
}
|
||
|
||
// AccountEntry 最小账号目录行(U3/KTD2):owner+有效态——授权管理所需的最小
|
||
// 身份。2026-10-13 P3 扩展(提案 rdm-settings-members-duties-perms-increment):
|
||
// Name 向授权管理读面下发(同名区分与按姓名查找是成员管理必需;扩展申请四要素
|
||
// 见 Dev 任务卡 feature/rdm-increment-settings-duties)。邮箱/联系方式仍一律
|
||
// 不下发(隐私边界不变)。
|
||
type AccountEntry struct {
|
||
Owner string `json:"owner"`
|
||
Name string `json:"name,omitempty"`
|
||
Valid bool `json:"valid"`
|
||
}
|
||
|
||
// AccountLister EXT-19 可选能力接口(实现由宿主注入):游标分页列举系统账号。
|
||
// 未实现时下游回落现行行为(成员添加不做账号有效性校验);实现后仅用于
|
||
// 授权管理读面——不授予任何写权。
|
||
type AccountLister interface {
|
||
ListAccounts(cursor string, limit int) (entries []AccountEntry, next string, err error)
|
||
}
|
||
|
||
// AccountSearcher EXT-19 扩展可选能力(P3,2026-10-13):按账号或姓名检索账号
|
||
// 目录,供人员选择器的输入搜索。query 匹配账号子串或姓名子串(大小写不敏感;
|
||
// 空串=不限,返回首页候选)。仅授权管理读面使用;未实现时下游回落
|
||
// AccountLister 分页浏览。
|
||
type AccountSearcher interface {
|
||
SearchAccounts(query string, limit int) (entries []AccountEntry, err error)
|
||
}
|
||
|
||
// AdminZoneSpec 管理区分区声明(WP3-I3):ID 是分区身份(建议形如
|
||
// <plugin>.<section>,前端按 ID 映射内置分区组件);Title/Icon/Desc 为展示
|
||
// 元数据(icon 取基座前端 IconName 词表,未知值前端回退通用图标);Order
|
||
// 越小越靠前,同 Order 按 ID 字典序。
|
||
type AdminZoneSpec struct {
|
||
ID string `json:"id"`
|
||
Title string `json:"title"`
|
||
Icon string `json:"icon,omitempty"`
|
||
Desc string `json:"desc,omitempty"`
|
||
Order int `json:"order,omitempty"`
|
||
}
|
||
|
||
// SurfaceSaveRequest 是一次页面提交(EXT-17):Spec 为 core.SurfaceSpec 的
|
||
// 严格 JSON(未知字段拒绝);身份信封由宿主按分发 Owner/PluginID 绑定,
|
||
// 不采信请求内自报的 owner/pluginId。
|
||
type SurfaceSaveRequest struct {
|
||
SurfaceID core.ID `json:"surfaceId,omitempty"`
|
||
ConversationID core.ID `json:"conversationId,omitempty"`
|
||
SourceRunID core.ID `json:"sourceRunId,omitempty"`
|
||
BaseRevision int `json:"baseRevision,omitempty"`
|
||
ObservedEpoch int64 `json:"observedEpoch,omitempty"`
|
||
ContextVersion string `json:"contextVersion,omitempty"`
|
||
SemanticText string `json:"semanticText,omitempty"`
|
||
Spec json.RawMessage `json:"spec"`
|
||
}
|
||
|
||
// SurfaceHost 是 EXT-17 可选能力接口:插件经宿主保存/读取生成页面。
|
||
// 通过对分发 Host 句柄做类型断言获取(断言失败=能力缺失,明确报错不降级)。
|
||
type SurfaceHost interface {
|
||
// SurfaceSave 校验并保存页面;禁用/epoch 失配/规格违规/revision 冲突
|
||
// 分别返回确定错误,携带字段级清单时调用方可修复后至多重提一次。
|
||
SurfaceSave(ctx context.Context, req SurfaceSaveRequest) (core.SurfaceSnapshot, error)
|
||
// SurfaceGet 读取整页快照(owner 作用域;历史页面禁用后仍可读)。
|
||
SurfaceGet(ctx context.Context, surfaceID core.ID) (core.SurfaceSnapshot, error)
|
||
// SurfaceList 列出当前 owner 名下(可按会话过滤)的页面元数据。
|
||
SurfaceList(ctx context.Context, conversationID core.ID) ([]core.SurfaceMeta, error)
|
||
// SurfaceActivation 观察本插件在当前 owner 的启禁与 epoch(装配能力时
|
||
// 快照 ObservedEpoch,拦截禁用后迟到的在途提交)。
|
||
SurfaceActivation(ctx context.Context) (enabled bool, epoch int64, err error)
|
||
}
|
||
|
||
// IntentSpec 意图声明规格(G2-2):id 形如 <domain>.<intent>;phrases 是触发
|
||
// 短语(宿主归一后包含匹配,最长短语优先);command 须为已注册命令 id;
|
||
// params 为参数模板(调用方可覆盖)。宿主侧路由入口:system.intent 命令。
|
||
type IntentSpec struct {
|
||
ID string `json:"id"`
|
||
Phrases []string `json:"phrases"`
|
||
Command string `json:"command"`
|
||
Params map[string]any `json:"params,omitempty"`
|
||
Description string `json:"description,omitempty"`
|
||
}
|
||
|
||
// InProc 是随下游二进制编译进来的扩展。
|
||
type InProc struct {
|
||
ID string
|
||
Register func(reg Registry) error
|
||
}
|
||
|
||
// Options 是下游自有二进制的启动参数,字段与 agent-system serve 对齐。
|
||
type Options struct {
|
||
Addr string
|
||
DataDir string
|
||
PluginsDir string
|
||
Mock bool
|
||
StorageDSN string
|
||
ArtifactsDir string
|
||
AuthMode string
|
||
// DisableUploadIndexing 关闭上传端点的自动检索索引(EXT-9;零值=false=现状:
|
||
// 可解析文本照旧进索引。下游以发布状态机自管索引时置 true)。
|
||
DisableUploadIndexing bool
|
||
// DisableConvCardMirror 关闭会话卡向项目公共流的镜像(EXT-12 最小开关;
|
||
// 零值=false=现状全量镜像。true 时会话卡只进本人会话流,不发布即不持久,
|
||
// 项目流重放不含会话卡;项目级卡不受影响)。
|
||
DisableConvCardMirror bool
|
||
// DisableExecTools 启动级硬关闭执行原语(run_shell / run_python /
|
||
// register_artifact 及小队内核执行配置):用户设置 runtime.exec 无法覆盖。
|
||
// 零值=false=现状。多用户共享部署在执行隔离就位前应置 true。
|
||
DisableExecTools bool
|
||
// ExecBackend 执行后端(EXT-16):空/direct=现状直启;sandbox=bwrap 隔离档
|
||
// (模型生成代码按不可信对待:断网、只读系统根、tmpfs 遮蔽 /home 与 /tmp、
|
||
// 仅会话工作目录与技能目录挂载、进程数限额、超时杀整个沙箱)。
|
||
// DisableExecTools 优先级高于此选择。
|
||
ExecBackend string
|
||
}
|
||
|
||
var (
|
||
mu sync.Mutex
|
||
compiled []InProc
|
||
runner func(Options) error
|
||
)
|
||
|
||
// RegisterCompiled 在 init 中登记进程内扩展。
|
||
func RegisterCompiled(e InProc) {
|
||
mu.Lock()
|
||
defer mu.Unlock()
|
||
compiled = append(compiled, e)
|
||
}
|
||
|
||
// Compiled 返回已登记扩展的副本。
|
||
func Compiled() []InProc {
|
||
mu.Lock()
|
||
defer mu.Unlock()
|
||
out := make([]InProc, len(compiled))
|
||
copy(out, compiled)
|
||
return out
|
||
}
|
||
|
||
// ResetCompiled 只给测试清状态。
|
||
func ResetCompiled() {
|
||
mu.Lock()
|
||
compiled = nil
|
||
mu.Unlock()
|
||
}
|
||
|
||
// SetRunner 由 link 包在 init 时注入。下游不要调用。
|
||
func SetRunner(fn func(Options) error) {
|
||
mu.Lock()
|
||
runner = fn
|
||
mu.Unlock()
|
||
}
|
||
|
||
// scopedHostFactory 由组合根在装配 hostcaps 后注入(EXT-11):按 owner+pluginID
|
||
// 构造与分发期同构的受限 Host。下游不要调用 SetScopedHostFactory。
|
||
var scopedHostFactory func(owner, pluginID string) Host
|
||
|
||
// SetScopedHostFactory 组合根注入(EXT-11)。下游不要调用。
|
||
func SetScopedHostFactory(fn func(owner, pluginID string) Host) {
|
||
mu.Lock()
|
||
scopedHostFactory = fn
|
||
mu.Unlock()
|
||
}
|
||
|
||
// NewScopedHost 按 owner+pluginID 重建受限宿主句柄(EXT-11):供下游任务恢复等
|
||
// 非分发期路径获取合法句柄;能力与分发期同构(owner 作用域、来源前缀强制、
|
||
// KV/任务台账隔离)。未 link 或未注入时返回错误(fail-closed,不降级)。
|
||
func NewScopedHost(owner, pluginID string) (Host, error) {
|
||
mu.Lock()
|
||
fn := scopedHostFactory
|
||
mu.Unlock()
|
||
if fn == nil {
|
||
return nil, ErrNotLinked
|
||
}
|
||
if owner == "" || pluginID == "" {
|
||
return nil, errors.New("extension: scoped host requires owner and pluginID")
|
||
}
|
||
h := fn(owner, pluginID)
|
||
if h == nil {
|
||
return nil, errors.New("extension: scoped host factory returned nil")
|
||
}
|
||
return h, nil
|
||
}
|
||
|
||
// Main 启动带有已登记扩展的服务。
|
||
func Main(opt Options) error {
|
||
mu.Lock()
|
||
fn := runner
|
||
mu.Unlock()
|
||
if fn == nil {
|
||
return ErrNotLinked
|
||
}
|
||
return fn(opt)
|
||
}
|