feat(pluginapi): HostService 账号目录返回结构化只读元数据

纯宿主侧改动,向后兼容。

- 契约(pkg/pluginapi/v1/plugin.proto,已重新生成):ListAccounts 增补
  repeated AccountInfo accounts(保留 account_ids 向后兼容),新增 metadata_json
  只读快照字段。HostServiceAPIVersion 1→2(InitHostServices 运行时协商,加法式,
  旧调用方忽略新字段即可)。
- 权限边界:可见范围由清单能力经 pluginCapabilityAccountScopeGrants 推导,
  ListAccounts 与 ResolveOutboundIdentity 均以该范围为权威边界。
- metadata_json 采用 denylist:剥离 Credentials 与含反向引用的关联对象
  Groups/AccountGroups(避免 encoding/json 循环引用),其余字段自动透传,
  数据模型新增非机密字段无需改宿主。

测试:范围/元数据/无凭据泄漏用例、denylist 反射护栏 + 循环引用安全断言、纵深过滤用例;
build/vet/gofmt、-tags unit 与 -race 全绿。
This commit is contained in:
shaw
2026-09-20 11:26:19 +08:00
parent 1a9d49e16f
commit 29eead917f
10 changed files with 786 additions and 154 deletions
@@ -2,57 +2,145 @@ package service
import (
"context"
"encoding/json"
"net/http"
"strings"
)
// OpenAIGatewayService implements service.PluginAccountDirectory for the OpenAI
// OAuth outbound transport capability. The directory is intentionally scoped to
// OpenAI OAuth-like, non-shadow accounts regardless of the requested filter, so a
// plugin can never enumerate or resolve credentials outside that set. The host
// additionally only wires this directory into plugins whose manifest declares the
// matching capability (see PluginManager.buildHostServices).
// OpenAIGatewayService implements service.PluginAccountDirectory. The directory
// itself is generic: it enumerates accounts and resolves outbound identities
// strictly within the scope the host derived from the plugin's declared
// capabilities (see PluginManager.buildHostServices). A plugin can never widen
// that scope, and metadata never carries credentials — those are only handed out
// by ResolvePluginOutboundIdentity.
//
// The implementation lives on OpenAIGatewayService because it owns both the
// account repository (for scope-neutral metadata) and the OpenAI token/header
// minting used by identity resolution. Metadata listing is platform-neutral;
// only the credential resolution is OpenAI-specific and degrades to (nil, nil)
// for account kinds it cannot mint tokens for.
// ListPluginAccounts returns the ids of active OpenAI OAuth-like accounts.
func (s *OpenAIGatewayService) ListPluginAccounts(ctx context.Context, platform, accountType string) ([]int64, error) {
if s == nil || s.accountRepo == nil {
// ListPluginAccounts returns the readable metadata for every account within the
// plugin's scope, optionally narrowed by the plugin's requested (platform,
// accountType) filter. Active accounts that are currently NOT schedulable because
// they are paused (rate-limited / temp-unschedulable / overloaded) are included
// with their status intact, so the plugin can skip them instead of probing them —
// a paused account keeps Status=="active", so the repository's active-only query
// still returns it. (Administratively disabled / expired accounts are already
// excluded by the repository query.) Shadow accounts are excluded here: they hold
// no credentials of their own and are not independently schedulable.
func (s *OpenAIGatewayService) ListPluginAccounts(ctx context.Context, scope PluginAccountScope, platform, accountType string) ([]PluginAccountInfo, error) {
if s == nil || s.accountRepo == nil || scope.Empty() {
return nil, nil
}
if p := strings.TrimSpace(platform); p != "" && p != PlatformOpenAI {
return nil, nil
}
if at := strings.TrimSpace(accountType); at != "" && at != AccountTypeOAuth {
// Setup-token style accounts are OAuth-like but not AccountTypeOAuth; the
// current binding only routes AccountTypeOAuth, so honour that filter.
return nil, nil
}
accounts, err := s.accountRepo.ListByPlatform(ctx, PlatformOpenAI)
if err != nil {
return nil, err
}
ids := make([]int64, 0, len(accounts))
for i := range accounts {
account := accounts[i]
if account.Status == StatusActive && account.IsOpenAIOAuthLike() && !account.IsShadow() && account.Type == AccountTypeOAuth {
ids = append(ids, account.ID)
reqPlatform := strings.TrimSpace(platform)
reqAccountType := strings.TrimSpace(accountType)
infos := make([]PluginAccountInfo, 0)
for _, scopePlatform := range scope.Platforms() {
if reqPlatform != "" && reqPlatform != scopePlatform {
continue
}
accounts, err := s.accountRepo.ListByPlatform(ctx, scopePlatform)
if err != nil {
return nil, err
}
for i := range accounts {
account := accounts[i]
if account.IsShadow() {
continue
}
// Defense-in-depth: the contract exposes only active accounts (paused
// ones stay active; disabled/expired are out). ListByPlatform already
// filters to active at the DB, but do not silently depend on that — a
// paused account keeps Status=="active" and still passes here.
if !account.IsActive() {
continue
}
if !scope.Contains(account.Platform, account.Type) {
continue
}
if reqAccountType != "" && reqAccountType != account.Type {
continue
}
infos = append(infos, accountToPluginInfo(&account))
}
}
return ids, nil
return infos, nil
}
// accountToPluginInfo maps a domain account onto the host's readable, non-secret
// view: a small stable typed core plus a full JSON snapshot. Schedulable is the
// host-authoritative decision so the plugin never has to re-derive the pause rules.
func accountToPluginInfo(account *Account) PluginAccountInfo {
return PluginAccountInfo{
ID: account.ID,
Platform: account.Platform,
AccountType: account.Type,
Name: account.Name,
Status: account.Status,
Schedulable: account.IsSchedulable(),
IsShadow: account.IsShadow(),
MetadataJSON: accountReadableSnapshotJSON(account),
}
}
// accountReadableSnapshotJSON marshals the account's readable field set to JSON.
// It is a DENYLIST on purpose: any non-secret field added to the account model
// later flows through automatically with no host change. Only two categories are
// stripped:
//
// - Credentials: holds the refresh_token (and possibly api_key), a long-lived
// credential that ResolveOutboundIdentity does NOT hand out (that channel only
// mints a short-lived access token). Keeping it out of this list keeps the
// credential surface exactly what the outbound-identity channel already
// exposes. (Extra and the proxy — including its password, which is already
// handed out via the resolved ProxyURL — are intentionally NOT stripped.)
// - Groups / AccountGroups: relational graphs with *Group/*Account
// back-references. encoding/json does NOT detect reference cycles and would
// recurse into a stack-overflow panic if the reverse relation is ever
// populated. GroupIDs already conveys membership, so drop these for
// correctness (not secrecy).
//
// Unexported fields (e.g. the hot-path caches) are never marshaled by encoding/json.
func accountReadableSnapshotJSON(account *Account) []byte {
if account == nil {
return nil
}
clone := *account
clone.Credentials = nil
clone.Groups = nil
clone.AccountGroups = nil
data, err := json.Marshal(&clone)
if err != nil {
return nil
}
return data
}
// ResolvePluginOutboundIdentity resolves the access token plus the outbound
// identity headers and proxy the host would attach to a live request for the
// account. It returns (nil, nil) for out-of-scope accounts or when no token can
// be resolved.
func (s *OpenAIGatewayService) ResolvePluginOutboundIdentity(ctx context.Context, accountID int64) (*PluginOutboundIdentity, error) {
if s == nil || s.accountRepo == nil || accountID <= 0 {
// account. It returns (nil, nil) for accounts outside the plugin's scope, for
// account kinds it cannot mint tokens for, or when no token can be resolved.
func (s *OpenAIGatewayService) ResolvePluginOutboundIdentity(ctx context.Context, scope PluginAccountScope, accountID int64) (*PluginOutboundIdentity, error) {
if s == nil || s.accountRepo == nil || accountID <= 0 || scope.Empty() {
return nil, nil
}
account, err := s.accountRepo.GetByID(ctx, accountID)
if err != nil {
return nil, err
}
if account == nil || account.Type != AccountTypeOAuth || !account.IsOpenAIOAuthLike() || account.IsShadow() {
if account == nil || account.IsShadow() {
return nil, nil
}
// Scope is the authoritative permission boundary: refuse any account the
// plugin's declared capabilities do not cover, even if it exists.
if !scope.Contains(account.Platform, account.Type) {
return nil, nil
}
// Credential minting is OpenAI-specific; degrade to (nil, nil) for kinds this
// resolver cannot issue tokens for.
if account.Type != AccountTypeOAuth || !account.IsOpenAIOAuthLike() {
return nil, nil
}
token, _, err := s.GetAccessToken(ctx, account)
@@ -0,0 +1,181 @@
package service
import (
"context"
"encoding/json"
"reflect"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// pluginDirRepoStub embeds AccountRepository so it satisfies the full interface;
// only ListByPlatform is implemented (the sole method the directory listing uses).
// Any other call panics, which keeps the test honest about the surface it touches.
type pluginDirRepoStub struct {
AccountRepository
byPlatform map[string][]Account
}
func (r *pluginDirRepoStub) ListByPlatform(_ context.Context, platform string) ([]Account, error) {
return r.byPlatform[platform], nil
}
func TestListPluginAccounts_ScopeAndSchedulable(t *testing.T) {
future := time.Now().Add(time.Hour)
parentID := int64(1)
openai := []Account{
// schedulable active oauth. Credentials hold secrets (stripped); Extra is
// released.
{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true,
Name: "primary",
Credentials: map[string]any{"access_token": "SECRET-TOKEN", "refresh_token": "SECRET-REFRESH"},
Extra: map[string]any{"existing_key": "ek-value", "openai_compact_mode": "auto"}},
// active but temp-unschedulable (paused) — status stays active, must still be
// returned, but not schedulable
{ID: 2, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true,
TempUnschedulableUntil: &future, TempUnschedulableReason: "429 from upstream"},
// active but rate-limited (429) — status stays active, returned, not schedulable
{ID: 3, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true,
RateLimitResetAt: &future},
// shadow oauth — excluded entirely
{ID: 4, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true,
ParentAccountID: &parentID},
// apikey type — out of (openai, oauth) scope, excluded
{ID: 5, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Status: StatusActive, Schedulable: true},
}
svc := &OpenAIGatewayService{accountRepo: &pluginDirRepoStub{byPlatform: map[string][]Account{PlatformOpenAI: openai}}}
scope := newPluginAccountScope(pluginAccountScopeEntry{Platform: PlatformOpenAI, AccountType: AccountTypeOAuth})
infos, err := svc.ListPluginAccounts(context.Background(), scope, "", "")
require.NoError(t, err)
got := map[int64]PluginAccountInfo{}
for _, info := range infos {
got[info.ID] = info
}
// 1,2,3 in scope; 4 shadow and 5 apikey excluded.
require.Len(t, infos, 3)
assert.Contains(t, got, int64(1))
assert.Contains(t, got, int64(2))
assert.Contains(t, got, int64(3))
assert.NotContains(t, got, int64(4), "shadow account must be excluded")
assert.NotContains(t, got, int64(5), "out-of-scope account type must be excluded")
// Host-authoritative schedulable decision.
assert.True(t, got[1].Schedulable, "active oauth is schedulable")
assert.False(t, got[2].Schedulable, "temp-unschedulable account is not schedulable")
assert.False(t, got[3].Schedulable, "rate-limited account is not schedulable")
// Metadata carries readable info (incl. Extra) but NEVER the raw Credentials blob.
meta := string(got[1].MetadataJSON)
assert.NotContains(t, meta, "SECRET-TOKEN", "credentials must never appear in metadata")
assert.NotContains(t, meta, "SECRET-REFRESH", "credentials must never appear in metadata")
assert.Contains(t, meta, "ek-value", "Extra is intentionally released")
assert.Contains(t, meta, "openai_compact_mode", "Extra is intentionally released")
assert.Contains(t, meta, "primary", "readable name must be present in metadata")
assert.Contains(t, string(got[2].MetadataJSON), "429 from upstream", "pause reason must be readable in metadata")
}
// TestAccountReadableSnapshot_DenylistTripwire fails whenever a new EXPORTED field
// is added to Account without being classified as either safe-to-expose or
// stripped by accountReadableSnapshotJSON. Because the snapshot is a denylist, a
// newly added secret-bearing field would otherwise silently ship to plugins. When
// this test fails: add the field to `stripped` (and zero it in
// accountReadableSnapshotJSON) if it can hold secrets/heavy data, otherwise add it
// to `safeToExpose`.
func TestAccountReadableSnapshot_DenylistTripwire(t *testing.T) {
// Fields the snapshot intentionally strips. Credentials = long-lived secret
// (refresh_token) not handed out by ResolveOutboundIdentity. Groups/AccountGroups
// = relational graphs with back-references that would cycle under encoding/json.
stripped := map[string]struct{}{
"Credentials": {}, "Groups": {}, "AccountGroups": {},
}
// Fields intentionally exposed as readable metadata (incl. Extra and Proxy —
// the proxy password is already handed out via ResolveOutboundIdentity's URL).
safeToExpose := map[string]struct{}{
"ID": {}, "Name": {}, "Notes": {}, "Platform": {}, "Type": {}, "Extra": {},
"Proxy": {}, "ProxyID": {}, "ProxyFallbackOriginID": {}, "ProxyFallbackOriginName": {},
"Concurrency": {}, "Priority": {}, "RateMultiplier": {}, "LoadFactor": {},
"Status": {}, "ErrorMessage": {}, "LastUsedAt": {}, "ExpiresAt": {},
"AutoPauseOnExpired": {}, "CreatedAt": {}, "UpdatedAt": {}, "Schedulable": {},
"RateLimitedAt": {}, "RateLimitResetAt": {}, "OverloadUntil": {},
"TempUnschedulableUntil": {}, "TempUnschedulableReason": {},
"SessionWindowStart": {}, "SessionWindowEnd": {}, "SessionWindowStatus": {},
"ParentAccountID": {}, "QuotaDimension": {}, "GroupIDs": {},
}
tp := reflect.TypeOf(Account{})
for i := 0; i < tp.NumField(); i++ {
f := tp.Field(i)
if f.PkgPath != "" {
continue // unexported: never marshaled by encoding/json
}
_, isStripped := stripped[f.Name]
_, isSafe := safeToExpose[f.Name]
if !isStripped && !isSafe {
t.Fatalf("Account.%s is a new exported field not classified for the plugin snapshot: "+
"add it to accountReadableSnapshotJSON's denylist (if it holds secrets or is a "+
"cyclic/heavy relation) or to safeToExpose (if it is non-secret readable metadata)", f.Name)
}
}
// The raw Credentials blob must never serialize; Extra and the proxy ARE released.
acct := &Account{
ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive,
Credentials: map[string]any{"access_token": "AT", "refresh_token": "LEAK-REFRESH"},
Extra: map[string]any{"opaque": "extra-released"},
Proxy: &Proxy{Host: "host", Port: 1, Username: "user", Password: "pw-released"},
}
snap := accountReadableSnapshotJSON(acct)
require.NotNil(t, snap)
var m map[string]any
require.NoError(t, json.Unmarshal(snap, &m))
assert.NotContains(t, string(snap), "LEAK-REFRESH", "raw Credentials must never appear in metadata")
assert.Contains(t, string(snap), "extra-released", "Extra is intentionally released")
assert.Contains(t, string(snap), "pw-released", "proxy is intentionally released (already exposed via 打票)")
// Cycle safety: a populated Groups/AccountGroups back-reference cycle must NOT
// crash json.Marshal (encoding/json does not detect cycles). Stripping them
// guarantees the snapshot still returns valid JSON instead of stack-overflowing.
g := &Group{ID: 7, Name: "g7"}
ag := AccountGroup{GroupID: 7, Group: g, Account: acct}
g.AccountGroups = []AccountGroup{ag} // g -> ag -> g (and ag -> acct -> ...)
acct.Groups = []*Group{g}
acct.AccountGroups = []AccountGroup{ag}
cyc := accountReadableSnapshotJSON(acct)
require.NotNil(t, cyc, "snapshot must survive a cyclic Groups/AccountGroups graph")
require.NoError(t, json.Unmarshal(cyc, &m))
// Shallow-copy safety: the source account must be untouched.
assert.NotNil(t, acct.Credentials, "snapshot must not mutate the source account")
assert.NotNil(t, acct.Proxy, "snapshot must not mutate the source account")
assert.Len(t, acct.Groups, 1, "snapshot must not mutate the source account's Groups")
}
func TestListPluginAccounts_ExcludesNonActiveDefenseInDepth(t *testing.T) {
// Even if a repo were to return a non-active account, the directory must not
// expose it (defense-in-depth beyond ListByPlatform's DB filter).
svc := &OpenAIGatewayService{accountRepo: &pluginDirRepoStub{byPlatform: map[string][]Account{
PlatformOpenAI: {
{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true},
{ID: 2, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusDisabled},
{ID: 3, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusError},
},
}}}
scope := newPluginAccountScope(pluginAccountScopeEntry{Platform: PlatformOpenAI, AccountType: AccountTypeOAuth})
infos, err := svc.ListPluginAccounts(context.Background(), scope, "", "")
require.NoError(t, err)
require.Len(t, infos, 1)
assert.Equal(t, int64(1), infos[0].ID)
}
func TestListPluginAccounts_EmptyScopeReturnsNothing(t *testing.T) {
svc := &OpenAIGatewayService{accountRepo: &pluginDirRepoStub{byPlatform: map[string][]Account{
PlatformOpenAI: {{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true}},
}}}
infos, err := svc.ListPluginAccounts(context.Background(), PluginAccountScope{}, "", "")
require.NoError(t, err)
assert.Empty(t, infos, "an empty scope must never enumerate accounts")
}
+118 -13
View File
@@ -3,6 +3,7 @@ package service
import (
"context"
"net/http"
"strings"
"time"
pluginv1 "github.com/Wei-Shaw/sub2api/pkg/pluginapi/v1"
@@ -46,13 +47,92 @@ type PluginOutboundIdentity struct {
Headers http.Header
}
// PluginAccountDirectory 让插件枚举其能力所覆盖的账号,并按需解析这些账号的出站身份,
// 无需等待一条真实请求流经插件。这是一项敏感能力(会把账号凭据交给插件进程),因此
// 宿主只对「其声明能力确实覆盖这些账号」的插件开放(见 PluginManager.buildHostServices)。
// 实现方自身也必须把返回范围收敛到该能力对应的账号集合。
// PluginAccountInfo 是宿主向插件公开的、单个账号的「可读、非机密」视图。它绝不包含
// 凭据(access_token / refresh_token / cookie 等)——凭据只经 ResolvePluginOutboundIdentity
// 交付。范围内的账号一律返回(包含 active 但当前被暂停的:限流 / 临时不可调度 / 过载),
// 由插件自行决策(例如跳过已暂停账号,避免密集打扰);管理性禁用 / 过期账号由仓储查询
// 上游过滤,不在此出现。Schedulable 是宿主权威判定(account.IsSchedulable),是插件
// 排除账号的首选信号。
//
// 只保留少量稳定的决策核心字段;账号其余可读信息(含日后新增字段)全部放进
// MetadataJSON,从而账号模型新增字段时宿主无需改契约、无需改映射、插件无需重编。
type PluginAccountInfo struct {
ID int64
Platform string
AccountType string
Name string
Status string
Schedulable bool
IsShadow bool
// MetadataJSON 是账号全量可读、非机密字段的 JSON 对象(键为账号模型字段名)。
MetadataJSON []byte
}
// pluginAccountScopeEntry 是账号范围中的一条 (platform, accountType) 规则。
// Platform 为具体平台;AccountType 为空表示该平台下任意账号类型。
type pluginAccountScopeEntry struct {
Platform string
AccountType string
}
// PluginAccountScope 是宿主授予某插件的账号可见范围,由该插件清单声明的能力推导而来
// (见 PluginManager.buildHostServices)。它是权限边界的唯一事实来源:插件永远无法
// 越过它枚举或解析范围外账号,也无法自行扩大范围。
type PluginAccountScope struct {
entries []pluginAccountScopeEntry
}
func newPluginAccountScope(entries ...pluginAccountScopeEntry) PluginAccountScope {
clean := make([]pluginAccountScopeEntry, 0, len(entries))
for _, e := range entries {
if strings.TrimSpace(e.Platform) == "" {
continue
}
clean = append(clean, e)
}
return PluginAccountScope{entries: clean}
}
// Empty 报告范围是否为空。空范围表示插件不具备任何账号目录能力。
func (s PluginAccountScope) Empty() bool { return len(s.entries) == 0 }
// Contains 报告某 (platform, accountType) 是否落在范围内。AccountType 为空的范围条目
// 匹配该平台下任意类型。
func (s PluginAccountScope) Contains(platform, accountType string) bool {
for _, e := range s.entries {
if e.Platform != "" && e.Platform != platform {
continue
}
if e.AccountType != "" && e.AccountType != accountType {
continue
}
return true
}
return false
}
// Platforms 返回范围内去重后的具体平台列表,供实现方按平台枚举账号。
func (s PluginAccountScope) Platforms() []string {
seen := make(map[string]struct{}, len(s.entries))
out := make([]string, 0, len(s.entries))
for _, e := range s.entries {
if _, ok := seen[e.Platform]; ok {
continue
}
seen[e.Platform] = struct{}{}
out = append(out, e.Platform)
}
return out
}
// PluginAccountDirectory 让插件枚举其范围内的账号(携带全量可读元数据),并按需解析
// 这些账号的出站身份,无需等待一条真实请求流经插件。ResolvePluginOutboundIdentity 会把
// 账号凭据交给插件进程,属敏感能力;ListPluginAccounts 只交付非机密元数据。两者都必须
// 把范围收敛到宿主传入的 scope。宿主只对「清单声明了对应账号能力」的插件构造 scope
// (见 PluginManager.buildHostServices)。
type PluginAccountDirectory interface {
ListPluginAccounts(ctx context.Context, platform, accountType string) ([]int64, error)
ResolvePluginOutboundIdentity(ctx context.Context, accountID int64) (*PluginOutboundIdentity, error)
ListPluginAccounts(ctx context.Context, scope PluginAccountScope, platform, accountType string) ([]PluginAccountInfo, error)
ResolvePluginOutboundIdentity(ctx context.Context, scope PluginAccountScope, accountID int64) (*PluginOutboundIdentity, error)
}
// pluginHostServiceServer 实现 pluginv1.HostServiceServer,是宿主经 go-plugin broker
@@ -63,10 +143,12 @@ type pluginHostServiceServer struct {
pluginKey string
store PluginKVStore
directory PluginAccountDirectory
// scope 是宿主授予本插件的账号可见范围。账号目录的两个 RPC 都以它为权限边界。
scope PluginAccountScope
}
func newPluginHostServiceServer(pluginKey string, store PluginKVStore, directory PluginAccountDirectory) *pluginHostServiceServer {
return &pluginHostServiceServer{pluginKey: pluginKey, store: store, directory: directory}
func newPluginHostServiceServer(pluginKey string, store PluginKVStore, directory PluginAccountDirectory, scope PluginAccountScope) *pluginHostServiceServer {
return &pluginHostServiceServer{pluginKey: pluginKey, store: store, directory: directory, scope: scope}
}
func (s *pluginHostServiceServer) ready() bool {
@@ -171,27 +253,35 @@ func (s *pluginHostServiceServer) KVList(ctx context.Context, req *pluginv1.KVLi
}
func (s *pluginHostServiceServer) ListAccounts(ctx context.Context, req *pluginv1.ListAccountsRequest) (*pluginv1.ListAccountsResponse, error) {
if s == nil || s.directory == nil {
if s == nil || s.directory == nil || s.scope.Empty() {
return nil, status.Error(codes.Unavailable, "账号目录不可用")
}
if req == nil {
return nil, status.Error(codes.InvalidArgument, "请求为空")
}
ids, err := s.directory.ListPluginAccounts(ctx, req.Platform, req.AccountType)
infos, err := s.directory.ListPluginAccounts(ctx, s.scope, req.Platform, req.AccountType)
if err != nil {
return nil, status.Errorf(codes.Internal, "列举账号失败: %v", err)
}
return &pluginv1.ListAccountsResponse{AccountIds: ids}, nil
resp := &pluginv1.ListAccountsResponse{
AccountIds: make([]int64, 0, len(infos)),
Accounts: make([]*pluginv1.AccountInfo, 0, len(infos)),
}
for i := range infos {
resp.AccountIds = append(resp.AccountIds, infos[i].ID)
resp.Accounts = append(resp.Accounts, accountInfoToPlugin(infos[i]))
}
return resp, nil
}
func (s *pluginHostServiceServer) ResolveOutboundIdentity(ctx context.Context, req *pluginv1.ResolveOutboundIdentityRequest) (*pluginv1.ResolveOutboundIdentityResponse, error) {
if s == nil || s.directory == nil {
if s == nil || s.directory == nil || s.scope.Empty() {
return nil, status.Error(codes.Unavailable, "账号目录不可用")
}
if req == nil || req.AccountId <= 0 {
return nil, status.Error(codes.InvalidArgument, "account_id 无效")
}
identity, err := s.directory.ResolvePluginOutboundIdentity(ctx, req.AccountId)
identity, err := s.directory.ResolvePluginOutboundIdentity(ctx, s.scope, req.AccountId)
if err != nil {
return nil, status.Errorf(codes.Internal, "解析账号出站身份失败: %v", err)
}
@@ -209,6 +299,21 @@ func (s *pluginHostServiceServer) ResolveOutboundIdentity(ctx context.Context, r
}, nil
}
// accountInfoToPlugin 把宿主的账号元数据视图映射为线上契约消息。只映射稳定的决策核心
// 字段;其余可读信息经 MetadataJSON 透传。绝不携带凭据。
func accountInfoToPlugin(info PluginAccountInfo) *pluginv1.AccountInfo {
return &pluginv1.AccountInfo{
Id: info.ID,
Platform: info.Platform,
AccountType: info.AccountType,
Name: info.Name,
Status: info.Status,
Schedulable: info.Schedulable,
IsShadow: info.IsShadow,
MetadataJson: info.MetadataJSON,
}
}
func pluginKVTTL(seconds int64) (time.Duration, error) {
if seconds < 0 {
return 0, status.Error(codes.InvalidArgument, "ttl_seconds 不能为负")
@@ -89,7 +89,7 @@ func TestOfferPluginHostServices_BrokerRoundtrip(t *testing.T) {
tc := dispenseTransportClient(t, probe)
store := newFakePluginKVStore()
hostServer := newPluginHostServiceServer("test.plugin", store, nil)
hostServer := newPluginHostServiceServer("test.plugin", store, nil, PluginAccountScope{})
offerPluginHostServices(context.Background(), &PluginInstallation{PluginKey: "test.plugin"}, tc.TransportPluginClient, tc.Broker, hostServer, 5*time.Second)
probe.mu.Lock()
@@ -111,7 +111,7 @@ func TestOfferPluginHostServices_UnimplementedIsGraceful(t *testing.T) {
tc := dispenseTransportClient(t, &noHostServicesPlugin{})
store := newFakePluginKVStore()
hostServer := newPluginHostServiceServer("test.plugin", store, nil)
hostServer := newPluginHostServiceServer("test.plugin", store, nil, PluginAccountScope{})
require.NotPanics(t, func() {
offerPluginHostServices(context.Background(), &PluginInstallation{PluginKey: "test.plugin"}, tc.TransportPluginClient, tc.Broker, hostServer, 5*time.Second)
})
@@ -81,7 +81,7 @@ func (f *fakePluginKVStore) List(_ context.Context, pluginKey, namespace, keyPre
func TestPluginHostServiceServer_SetGetDeleteRoundtrip(t *testing.T) {
store := newFakePluginKVStore()
server := newPluginHostServiceServer("local.example.plugin", store, nil)
server := newPluginHostServiceServer("local.example.plugin", store, nil, PluginAccountScope{})
ctx := context.Background()
_, err := server.KVSet(ctx, &pluginv1.KVSetRequest{Namespace: "state", Key: "alpha", Value: []byte("v1"), TtlSeconds: 60})
@@ -102,7 +102,7 @@ func TestPluginHostServiceServer_SetGetDeleteRoundtrip(t *testing.T) {
}
func TestPluginHostServiceServer_GetNotFound(t *testing.T) {
server := newPluginHostServiceServer("local.example.plugin", newFakePluginKVStore(), nil)
server := newPluginHostServiceServer("local.example.plugin", newFakePluginKVStore(), nil, PluginAccountScope{})
got, err := server.KVGet(context.Background(), &pluginv1.KVGetRequest{Namespace: "state", Key: "missing"})
require.NoError(t, err)
assert.False(t, got.Found)
@@ -111,8 +111,8 @@ func TestPluginHostServiceServer_GetNotFound(t *testing.T) {
func TestPluginHostServiceServer_NamespaceIsolationAcrossPlugins(t *testing.T) {
store := newFakePluginKVStore()
first := newPluginHostServiceServer("local.plugin.one", store, nil)
second := newPluginHostServiceServer("local.plugin.two", store, nil)
first := newPluginHostServiceServer("local.plugin.one", store, nil, PluginAccountScope{})
second := newPluginHostServiceServer("local.plugin.two", store, nil, PluginAccountScope{})
ctx := context.Background()
_, err := first.KVSet(ctx, &pluginv1.KVSetRequest{Namespace: "state", Key: "shared", Value: []byte("first")})
@@ -131,7 +131,7 @@ func TestPluginHostServiceServer_NamespaceIsolationAcrossPlugins(t *testing.T) {
func TestPluginHostServiceServer_ListPrefixAndLimit(t *testing.T) {
store := newFakePluginKVStore()
server := newPluginHostServiceServer("local.example.plugin", store, nil)
server := newPluginHostServiceServer("local.example.plugin", store, nil, PluginAccountScope{})
ctx := context.Background()
for _, key := range []string{"account-1", "account-2", "account-3", "other-1"} {
_, err := server.KVSet(ctx, &pluginv1.KVSetRequest{Namespace: "state", Key: key, Value: []byte("x")})
@@ -148,7 +148,7 @@ func TestPluginHostServiceServer_ListPrefixAndLimit(t *testing.T) {
}
func TestPluginHostServiceServer_Validation(t *testing.T) {
server := newPluginHostServiceServer("local.example.plugin", newFakePluginKVStore(), nil)
server := newPluginHostServiceServer("local.example.plugin", newFakePluginKVStore(), nil, PluginAccountScope{})
ctx := context.Background()
oversized := make([]byte, pluginKVMaxValueBytes+1)
@@ -200,19 +200,19 @@ func TestPluginHostServiceServer_Validation(t *testing.T) {
}
func TestPluginHostServiceServer_UnavailableWhenNoStore(t *testing.T) {
server := newPluginHostServiceServer("local.example.plugin", nil, nil)
server := newPluginHostServiceServer("local.example.plugin", nil, nil, PluginAccountScope{})
_, err := server.KVGet(context.Background(), &pluginv1.KVGetRequest{Namespace: "state", Key: "k"})
require.Error(t, err)
assert.Equal(t, codes.Unavailable, status.Code(err))
// pluginKey 为空同样视为不可用,避免键前缀塌缩导致的跨插件泄漏。
blank := newPluginHostServiceServer("", newFakePluginKVStore(), nil)
blank := newPluginHostServiceServer("", newFakePluginKVStore(), nil, PluginAccountScope{})
_, err = blank.KVGet(context.Background(), &pluginv1.KVGetRequest{Namespace: "state", Key: "k"})
require.Error(t, err)
assert.Equal(t, codes.Unavailable, status.Code(err))
// pluginKey 含 ':' 会破坏内部键结构,必须判定为不可用而非放行。
tainted := newPluginHostServiceServer("bad:key", newFakePluginKVStore(), nil)
tainted := newPluginHostServiceServer("bad:key", newFakePluginKVStore(), nil, PluginAccountScope{})
_, err = tainted.KVGet(context.Background(), &pluginv1.KVGetRequest{Namespace: "state", Key: "k"})
require.Error(t, err)
assert.Equal(t, codes.Unavailable, status.Code(err))
@@ -222,27 +222,30 @@ func TestPluginHostServiceServer_UnavailableWhenNoStore(t *testing.T) {
// 静默剥夺宿主服务能力。
func TestPluginHostServiceServer_PluginKeyLengthBoundary(t *testing.T) {
ctx := context.Background()
maxLen := newPluginHostServiceServer(strings.Repeat("a", pluginKVMaxPluginKeyLen), newFakePluginKVStore(), nil)
maxLen := newPluginHostServiceServer(strings.Repeat("a", pluginKVMaxPluginKeyLen), newFakePluginKVStore(), nil, PluginAccountScope{})
_, err := maxLen.KVSet(ctx, &pluginv1.KVSetRequest{Namespace: "state", Key: "k", Value: []byte("v")})
require.NoError(t, err)
tooLong := newPluginHostServiceServer(strings.Repeat("a", pluginKVMaxPluginKeyLen+1), newFakePluginKVStore(), nil)
tooLong := newPluginHostServiceServer(strings.Repeat("a", pluginKVMaxPluginKeyLen+1), newFakePluginKVStore(), nil, PluginAccountScope{})
_, err = tooLong.KVSet(ctx, &pluginv1.KVSetRequest{Namespace: "state", Key: "k", Value: []byte("v")})
require.Error(t, err)
assert.Equal(t, codes.Unavailable, status.Code(err))
}
type fakeAccountDirectory struct {
ids []int64
identity *PluginOutboundIdentity
lastReq int64
infos []PluginAccountInfo
identity *PluginOutboundIdentity
lastReq int64
lastScope PluginAccountScope
}
func (f *fakeAccountDirectory) ListPluginAccounts(_ context.Context, _, _ string) ([]int64, error) {
return f.ids, nil
func (f *fakeAccountDirectory) ListPluginAccounts(_ context.Context, scope PluginAccountScope, _, _ string) ([]PluginAccountInfo, error) {
f.lastScope = scope
return f.infos, nil
}
func (f *fakeAccountDirectory) ResolvePluginOutboundIdentity(_ context.Context, accountID int64) (*PluginOutboundIdentity, error) {
func (f *fakeAccountDirectory) ResolvePluginOutboundIdentity(_ context.Context, scope PluginAccountScope, accountID int64) (*PluginOutboundIdentity, error) {
f.lastScope = scope
f.lastReq = accountID
if f.identity == nil || f.identity.AccountID != accountID {
return nil, nil
@@ -253,17 +256,26 @@ func (f *fakeAccountDirectory) ResolvePluginOutboundIdentity(_ context.Context,
func TestPluginHostServiceServer_AccountDirectory(t *testing.T) {
ctx := context.Background()
dir := &fakeAccountDirectory{
ids: []int64{3, 7},
infos: []PluginAccountInfo{
{ID: 3, Platform: "openai", AccountType: "oauth", Status: "active", Schedulable: false, MetadataJSON: []byte(`{"ID":3}`)},
{ID: 7, Platform: "openai", AccountType: "oauth", Status: "active", Schedulable: true, MetadataJSON: []byte(`{"ID":7}`)},
},
identity: &PluginOutboundIdentity{
AccountID: 7, Platform: "openai", AccountType: "oauth", ProxyURL: "http://p:1",
Token: "tok", Headers: http.Header{"Originator": {"codex-tui"}},
},
}
server := newPluginHostServiceServer("local.example.plugin", newFakePluginKVStore(), dir)
scope := newPluginAccountScope(pluginAccountScopeEntry{Platform: "openai", AccountType: "oauth"})
server := newPluginHostServiceServer("local.example.plugin", newFakePluginKVStore(), dir, scope)
list, err := server.ListAccounts(ctx, &pluginv1.ListAccountsRequest{Platform: "openai", AccountType: "oauth"})
require.NoError(t, err)
assert.Equal(t, []int64{3, 7}, list.AccountIds)
require.Len(t, list.Accounts, 2)
assert.False(t, list.Accounts[0].Schedulable)
assert.True(t, list.Accounts[1].Schedulable)
assert.Equal(t, []byte(`{"ID":7}`), list.Accounts[1].MetadataJson)
assert.Equal(t, scope, dir.lastScope, "server must pass its scope to the directory")
resolved, err := server.ResolveOutboundIdentity(ctx, &pluginv1.ResolveOutboundIdentityRequest{AccountId: 7})
require.NoError(t, err)
@@ -284,7 +296,7 @@ func TestPluginHostServiceServer_AccountDirectory(t *testing.T) {
// 无目录时账号目录 RPC 必须返回 Unavailable(KV 仍可用),保证未授权插件拿不到凭据。
func TestPluginHostServiceServer_DirectoryUnavailableWithoutDirectory(t *testing.T) {
server := newPluginHostServiceServer("local.example.plugin", newFakePluginKVStore(), nil)
server := newPluginHostServiceServer("local.example.plugin", newFakePluginKVStore(), nil, PluginAccountScope{})
_, err := server.ListAccounts(context.Background(), &pluginv1.ListAccountsRequest{})
assert.Equal(t, codes.Unavailable, status.Code(err))
_, err = server.ResolveOutboundIdentity(context.Background(), &pluginv1.ResolveOutboundIdentityRequest{AccountId: 1})
@@ -294,18 +306,38 @@ func TestPluginHostServiceServer_DirectoryUnavailableWithoutDirectory(t *testing
require.NoError(t, kvErr)
}
func TestPluginDeclaresOpenAIOAuthCapability(t *testing.T) {
func TestPluginAccountScopeFromManifest(t *testing.T) {
// A declared OpenAI OAuth capability grants exactly the (openai, oauth) scope.
match := PluginManifest{Capabilities: []PluginCapability{{ID: PluginCapabilityOpenAIOAuthOutbound, Platform: PlatformOpenAI, AccountType: AccountTypeOAuth}}}
if !pluginDeclaresOpenAIOAuthCapability(match) {
t.Fatal("matching capability must be recognized")
scope := pluginAccountScopeFromManifest(match)
if scope.Empty() {
t.Fatal("declared OpenAI OAuth capability must grant a non-empty scope")
}
wrongType := PluginManifest{Capabilities: []PluginCapability{{ID: PluginCapabilityOpenAIOAuthOutbound, Platform: PlatformOpenAI, AccountType: "apikey"}}}
if pluginDeclaresOpenAIOAuthCapability(wrongType) {
t.Fatal("wrong account_type must not match")
if !scope.Contains(PlatformOpenAI, AccountTypeOAuth) {
t.Fatal("scope must contain (openai, oauth)")
}
empty := PluginManifest{}
if pluginDeclaresOpenAIOAuthCapability(empty) {
t.Fatal("no capability must not match")
if scope.Contains("anthropic", "oauth") {
t.Fatal("scope must not leak to other platforms")
}
// The granted scope is pinned to the capability id, so a manifest cannot widen
// it by declaring a different platform/account_type on a known capability.
spoof := PluginManifest{Capabilities: []PluginCapability{{ID: PluginCapabilityOpenAIOAuthOutbound, Platform: "anthropic", AccountType: "apikey"}}}
spoofScope := pluginAccountScopeFromManifest(spoof)
if spoofScope.Contains("anthropic", "apikey") {
t.Fatal("granted scope must be pinned to the capability id, not the declared platform/type")
}
if !spoofScope.Contains(PlatformOpenAI, AccountTypeOAuth) {
t.Fatal("known capability must still grant its pinned (openai, oauth) scope")
}
// An unknown capability grants nothing.
unknown := PluginManifest{Capabilities: []PluginCapability{{ID: "some.other.capability", Platform: "x", AccountType: "y"}}}
if !pluginAccountScopeFromManifest(unknown).Empty() {
t.Fatal("unknown capability must grant an empty scope")
}
if !pluginAccountScopeFromManifest(PluginManifest{}).Empty() {
t.Fatal("no capability must grant an empty scope")
}
}
+22 -11
View File
@@ -1057,31 +1057,42 @@ func (m *PluginManager) SetAccountDirectory(directory PluginAccountDirectory) {
// buildHostServices 为单个插件构造绑定其 pluginKey 的宿主服务端点。返回 nil(未配置
// 键值存储或缺少 pluginKey)时,startPluginRuntime 不会向插件暴露任何宿主服务。
// 账号目录(会向插件交付账号凭据)只对「清单声明了 OpenAI OAuth 出站能力」的插件开放,
// 从而把凭据暴露面收敛到本就要处理这些账号的插件。
// 账号目录(会向插件交付账号凭据与全量元数据)只对「清单声明的能力授予了账号范围」的
// 插件开放,且严格限定在该范围内,从而把暴露面收敛到本就要处理这些账号的插件。
func (m *PluginManager) buildHostServices(installation *PluginInstallation) pluginv1.HostServiceServer {
if m.kvStore == nil || installation == nil || strings.TrimSpace(installation.PluginKey) == "" {
return nil
}
scope := pluginAccountScopeFromManifest(installation.Manifest)
var directory PluginAccountDirectory
if pluginDeclaresOpenAIOAuthCapability(installation.Manifest) {
if !scope.Empty() {
m.mu.Lock()
directory = m.accountDirectory
m.mu.Unlock()
}
return newPluginHostServiceServer(installation.PluginKey, m.kvStore, directory)
return newPluginHostServiceServer(installation.PluginKey, m.kvStore, directory, scope)
}
// pluginDeclaresOpenAIOAuthCapability reports whether the (install-validated)
// manifest declares the OpenAI OAuth outbound transport capability.
func pluginDeclaresOpenAIOAuthCapability(manifest PluginManifest) bool {
// pluginCapabilityAccountScopeGrants 把「能力 id」映射到它授予的账号范围条目。这是
// 唯一放宽账号可见性的通用入口:为需要账号访问的新能力扩权只需在此加一行,无需新增
// RPC 或按插件定制目录实现。授予的范围以能力 id 为准并被固定,清单无法通过声明不同的
// platform/account_type 来扩大它。
var pluginCapabilityAccountScopeGrants = map[string]pluginAccountScopeEntry{
PluginCapabilityOpenAIOAuthOutbound: {Platform: PlatformOpenAI, AccountType: AccountTypeOAuth},
}
// pluginAccountScopeFromManifest 从(安装期已校验的)清单声明能力推导出账号可见范围
// (各能力授予范围的并集)。
func pluginAccountScopeFromManifest(manifest PluginManifest) PluginAccountScope {
entries := make([]pluginAccountScopeEntry, 0, len(manifest.Capabilities))
for _, capability := range manifest.Capabilities {
if capability.ID == PluginCapabilityOpenAIOAuthOutbound &&
capability.Platform == PlatformOpenAI && capability.AccountType == AccountTypeOAuth {
return true
grant, ok := pluginCapabilityAccountScopeGrants[capability.ID]
if !ok {
continue
}
entries = append(entries, grant)
}
return false
return newPluginAccountScope(entries...)
}
func (m *PluginManager) removeRuntimeLocked(id int64) *pluginRuntime {
+209 -54
View File
@@ -1704,8 +1704,19 @@ func (x *ListAccountsRequest) GetAccountType() string {
}
type ListAccountsResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
AccountIds []int64 `protobuf:"varint,1,rep,packed,name=account_ids,json=accountIds,proto3" json:"account_ids,omitempty"`
state protoimpl.MessageState `protogen:"open.v1"`
// account_ids is retained for plugins built against host_service_api_version 1.
// The host keeps filling it (the ids of the accounts in `accounts`) so those
// plugins keep working; new plugins should read `accounts` instead.
AccountIds []int64 `protobuf:"varint,1,rep,packed,name=account_ids,json=accountIds,proto3" json:"account_ids,omitempty"`
// accounts carries the full readable metadata for every account in the
// plugin's scope — including active accounts that are currently NOT
// schedulable because they are paused (rate-limited / temp-unschedulable /
// overloaded). This lets a plugin make its own decisions (e.g. skip paused
// accounts) instead of hammering them. (Administratively disabled / expired
// accounts are already excluded upstream by the host.) Available when
// host_service_api_version >= 2.
Accounts []*AccountInfo `protobuf:"bytes,2,rep,name=accounts,proto3" json:"accounts,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@@ -1747,6 +1758,138 @@ func (x *ListAccountsResponse) GetAccountIds() []int64 {
return nil
}
func (x *ListAccountsResponse) GetAccounts() []*AccountInfo {
if x != nil {
return x.Accounts
}
return nil
}
// AccountInfo is the host's readable, non-secret view of one account. It never
// contains credentials (token / refresh_token / cookies); use
// ResolveOutboundIdentity for those.
//
// The typed fields are a small, STABLE decision core. Everything else about the
// account — including any field added to the account model later — is carried in
// metadata_json, so new fields reach plugins WITHOUT a contract change, a host
// mapper edit, or a plugin rebuild. Only a newly added *secret* field would ever
// require a host change (a one-line denylist entry), which is the safe default.
type AccountInfo struct {
state protoimpl.MessageState `protogen:"open.v1"`
Id int64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"`
Platform string `protobuf:"bytes,2,opt,name=platform,proto3" json:"platform,omitempty"`
AccountType string `protobuf:"bytes,3,opt,name=account_type,json=accountType,proto3" json:"account_type,omitempty"`
Name string `protobuf:"bytes,4,opt,name=name,proto3" json:"name,omitempty"`
// status is the coarse lifecycle state: active / disabled / error / expired.
Status string `protobuf:"bytes,5,opt,name=status,proto3" json:"status,omitempty"`
// schedulable is the host-authoritative decision (IsSchedulable) — false when
// the account is active but paused for ANY reason (rate limit,
// temp-unschedulable, overload, expiry auto-pause, quota). This is the primary
// signal a plugin should use to skip an account so it stops probing paused ones.
Schedulable bool `protobuf:"varint,6,opt,name=schedulable,proto3" json:"schedulable,omitempty"`
IsShadow bool `protobuf:"varint,7,opt,name=is_shadow,json=isShadow,proto3" json:"is_shadow,omitempty"`
// metadata_json is a host-produced JSON object holding the account's readable
// field set (status timestamps, rate-limit / overload / temp-unschedulable
// windows and reason, last-used, expiry, session window, group ids, priority,
// concurrency, quota dimension, extra config, proxy, and any future field).
// Keys are the account model's field names. Times are RFC3339 strings (JSON
// null when unset). Only two things are withheld: the raw Credentials blob
// (the refresh_token — a long-lived secret the outbound-identity RPC does not
// hand out; the short-lived access token and proxy URL come from that RPC), and
// the Groups/AccountGroups relation graphs (cyclic — group_ids conveys
// membership). Plugins read whatever keys they need; the host does not have to
// change when the account model gains a field.
MetadataJson []byte `protobuf:"bytes,20,opt,name=metadata_json,json=metadataJson,proto3" json:"metadata_json,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *AccountInfo) Reset() {
*x = AccountInfo{}
mi := &file_plugin_proto_msgTypes[29]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *AccountInfo) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*AccountInfo) ProtoMessage() {}
func (x *AccountInfo) ProtoReflect() protoreflect.Message {
mi := &file_plugin_proto_msgTypes[29]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use AccountInfo.ProtoReflect.Descriptor instead.
func (*AccountInfo) Descriptor() ([]byte, []int) {
return file_plugin_proto_rawDescGZIP(), []int{29}
}
func (x *AccountInfo) GetId() int64 {
if x != nil {
return x.Id
}
return 0
}
func (x *AccountInfo) GetPlatform() string {
if x != nil {
return x.Platform
}
return ""
}
func (x *AccountInfo) GetAccountType() string {
if x != nil {
return x.AccountType
}
return ""
}
func (x *AccountInfo) GetName() string {
if x != nil {
return x.Name
}
return ""
}
func (x *AccountInfo) GetStatus() string {
if x != nil {
return x.Status
}
return ""
}
func (x *AccountInfo) GetSchedulable() bool {
if x != nil {
return x.Schedulable
}
return false
}
func (x *AccountInfo) GetIsShadow() bool {
if x != nil {
return x.IsShadow
}
return false
}
func (x *AccountInfo) GetMetadataJson() []byte {
if x != nil {
return x.MetadataJson
}
return nil
}
type ResolveOutboundIdentityRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
AccountId int64 `protobuf:"varint,1,opt,name=account_id,json=accountId,proto3" json:"account_id,omitempty"`
@@ -1756,7 +1899,7 @@ type ResolveOutboundIdentityRequest struct {
func (x *ResolveOutboundIdentityRequest) Reset() {
*x = ResolveOutboundIdentityRequest{}
mi := &file_plugin_proto_msgTypes[29]
mi := &file_plugin_proto_msgTypes[30]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -1768,7 +1911,7 @@ func (x *ResolveOutboundIdentityRequest) String() string {
func (*ResolveOutboundIdentityRequest) ProtoMessage() {}
func (x *ResolveOutboundIdentityRequest) ProtoReflect() protoreflect.Message {
mi := &file_plugin_proto_msgTypes[29]
mi := &file_plugin_proto_msgTypes[30]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -1781,7 +1924,7 @@ func (x *ResolveOutboundIdentityRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use ResolveOutboundIdentityRequest.ProtoReflect.Descriptor instead.
func (*ResolveOutboundIdentityRequest) Descriptor() ([]byte, []int) {
return file_plugin_proto_rawDescGZIP(), []int{29}
return file_plugin_proto_rawDescGZIP(), []int{30}
}
func (x *ResolveOutboundIdentityRequest) GetAccountId() int64 {
@@ -1806,7 +1949,7 @@ type ResolveOutboundIdentityResponse struct {
func (x *ResolveOutboundIdentityResponse) Reset() {
*x = ResolveOutboundIdentityResponse{}
mi := &file_plugin_proto_msgTypes[30]
mi := &file_plugin_proto_msgTypes[31]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -1818,7 +1961,7 @@ func (x *ResolveOutboundIdentityResponse) String() string {
func (*ResolveOutboundIdentityResponse) ProtoMessage() {}
func (x *ResolveOutboundIdentityResponse) ProtoReflect() protoreflect.Message {
mi := &file_plugin_proto_msgTypes[30]
mi := &file_plugin_proto_msgTypes[31]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -1831,7 +1974,7 @@ func (x *ResolveOutboundIdentityResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use ResolveOutboundIdentityResponse.ProtoReflect.Descriptor instead.
func (*ResolveOutboundIdentityResponse) Descriptor() ([]byte, []int) {
return file_plugin_proto_rawDescGZIP(), []int{30}
return file_plugin_proto_rawDescGZIP(), []int{31}
}
func (x *ResolveOutboundIdentityResponse) GetFound() bool {
@@ -2010,10 +2153,20 @@ const file_plugin_proto_rawDesc = "" +
"\x04keys\x18\x01 \x03(\tR\x04keys\"T\n" +
"\x13ListAccountsRequest\x12\x1a\n" +
"\bplatform\x18\x01 \x01(\tR\bplatform\x12!\n" +
"\faccount_type\x18\x02 \x01(\tR\vaccountType\"7\n" +
"\faccount_type\x18\x02 \x01(\tR\vaccountType\"s\n" +
"\x14ListAccountsResponse\x12\x1f\n" +
"\vaccount_ids\x18\x01 \x03(\x03R\n" +
"accountIds\"?\n" +
"accountIds\x12:\n" +
"\baccounts\x18\x02 \x03(\v2\x1e.sub2api.plugin.v1.AccountInfoR\baccounts\"\xec\x01\n" +
"\vAccountInfo\x12\x0e\n" +
"\x02id\x18\x01 \x01(\x03R\x02id\x12\x1a\n" +
"\bplatform\x18\x02 \x01(\tR\bplatform\x12!\n" +
"\faccount_type\x18\x03 \x01(\tR\vaccountType\x12\x12\n" +
"\x04name\x18\x04 \x01(\tR\x04name\x12\x16\n" +
"\x06status\x18\x05 \x01(\tR\x06status\x12 \n" +
"\vschedulable\x18\x06 \x01(\bR\vschedulable\x12\x1b\n" +
"\tis_shadow\x18\a \x01(\bR\bisShadow\x12#\n" +
"\rmetadata_json\x18\x14 \x01(\fR\fmetadataJson\"?\n" +
"\x1eResolveOutboundIdentityRequest\x12\x1d\n" +
"\n" +
"account_id\x18\x01 \x01(\x03R\taccountId\"\x80\x03\n" +
@@ -2058,7 +2211,7 @@ func file_plugin_proto_rawDescGZIP() []byte {
return file_plugin_proto_rawDescData
}
var file_plugin_proto_msgTypes = make([]protoimpl.MessageInfo, 34)
var file_plugin_proto_msgTypes = make([]protoimpl.MessageInfo, 35)
var file_plugin_proto_goTypes = []any{
(*GetInfoRequest)(nil), // 0: sub2api.plugin.v1.GetInfoRequest
(*GetInfoResponse)(nil), // 1: sub2api.plugin.v1.GetInfoResponse
@@ -2089,54 +2242,56 @@ var file_plugin_proto_goTypes = []any{
(*KVListResponse)(nil), // 26: sub2api.plugin.v1.KVListResponse
(*ListAccountsRequest)(nil), // 27: sub2api.plugin.v1.ListAccountsRequest
(*ListAccountsResponse)(nil), // 28: sub2api.plugin.v1.ListAccountsResponse
(*ResolveOutboundIdentityRequest)(nil), // 29: sub2api.plugin.v1.ResolveOutboundIdentityRequest
(*ResolveOutboundIdentityResponse)(nil), // 30: sub2api.plugin.v1.ResolveOutboundIdentityResponse
nil, // 31: sub2api.plugin.v1.ForwardRequestStart.HeadersEntry
nil, // 32: sub2api.plugin.v1.ForwardResponseStart.HeadersEntry
nil, // 33: sub2api.plugin.v1.ResolveOutboundIdentityResponse.HeadersEntry
(*AccountInfo)(nil), // 29: sub2api.plugin.v1.AccountInfo
(*ResolveOutboundIdentityRequest)(nil), // 30: sub2api.plugin.v1.ResolveOutboundIdentityRequest
(*ResolveOutboundIdentityResponse)(nil), // 31: sub2api.plugin.v1.ResolveOutboundIdentityResponse
nil, // 32: sub2api.plugin.v1.ForwardRequestStart.HeadersEntry
nil, // 33: sub2api.plugin.v1.ForwardResponseStart.HeadersEntry
nil, // 34: sub2api.plugin.v1.ResolveOutboundIdentityResponse.HeadersEntry
}
var file_plugin_proto_depIdxs = []int32{
31, // 0: sub2api.plugin.v1.ForwardRequestStart.headers:type_name -> sub2api.plugin.v1.ForwardRequestStart.HeadersEntry
32, // 0: sub2api.plugin.v1.ForwardRequestStart.headers:type_name -> sub2api.plugin.v1.ForwardRequestStart.HeadersEntry
11, // 1: sub2api.plugin.v1.ForwardRequest.start:type_name -> sub2api.plugin.v1.ForwardRequestStart
32, // 2: sub2api.plugin.v1.ForwardResponseStart.headers:type_name -> sub2api.plugin.v1.ForwardResponseStart.HeadersEntry
33, // 2: sub2api.plugin.v1.ForwardResponseStart.headers:type_name -> sub2api.plugin.v1.ForwardResponseStart.HeadersEntry
13, // 3: sub2api.plugin.v1.ForwardResponse.start:type_name -> sub2api.plugin.v1.ForwardResponseStart
14, // 4: sub2api.plugin.v1.ForwardResponse.end:type_name -> sub2api.plugin.v1.ForwardResponseEnd
15, // 5: sub2api.plugin.v1.ForwardResponse.error:type_name -> sub2api.plugin.v1.ForwardResponseError
33, // 6: sub2api.plugin.v1.ResolveOutboundIdentityResponse.headers:type_name -> sub2api.plugin.v1.ResolveOutboundIdentityResponse.HeadersEntry
10, // 7: sub2api.plugin.v1.ForwardRequestStart.HeadersEntry.value:type_name -> sub2api.plugin.v1.HeaderValues
10, // 8: sub2api.plugin.v1.ForwardResponseStart.HeadersEntry.value:type_name -> sub2api.plugin.v1.HeaderValues
10, // 9: sub2api.plugin.v1.ResolveOutboundIdentityResponse.HeadersEntry.value:type_name -> sub2api.plugin.v1.HeaderValues
0, // 10: sub2api.plugin.v1.TransportPlugin.GetInfo:input_type -> sub2api.plugin.v1.GetInfoRequest
2, // 11: sub2api.plugin.v1.TransportPlugin.Health:input_type -> sub2api.plugin.v1.HealthRequest
4, // 12: sub2api.plugin.v1.TransportPlugin.ValidateConfig:input_type -> sub2api.plugin.v1.ValidateConfigRequest
6, // 13: sub2api.plugin.v1.TransportPlugin.ApplyConfig:input_type -> sub2api.plugin.v1.ApplyConfigRequest
8, // 14: sub2api.plugin.v1.TransportPlugin.TestConfig:input_type -> sub2api.plugin.v1.TestConfigRequest
12, // 15: sub2api.plugin.v1.TransportPlugin.Forward:input_type -> sub2api.plugin.v1.ForwardRequest
17, // 16: sub2api.plugin.v1.TransportPlugin.InitHostServices:input_type -> sub2api.plugin.v1.InitHostServicesRequest
19, // 17: sub2api.plugin.v1.HostService.KVGet:input_type -> sub2api.plugin.v1.KVGetRequest
21, // 18: sub2api.plugin.v1.HostService.KVSet:input_type -> sub2api.plugin.v1.KVSetRequest
23, // 19: sub2api.plugin.v1.HostService.KVDelete:input_type -> sub2api.plugin.v1.KVDeleteRequest
25, // 20: sub2api.plugin.v1.HostService.KVList:input_type -> sub2api.plugin.v1.KVListRequest
27, // 21: sub2api.plugin.v1.HostService.ListAccounts:input_type -> sub2api.plugin.v1.ListAccountsRequest
29, // 22: sub2api.plugin.v1.HostService.ResolveOutboundIdentity:input_type -> sub2api.plugin.v1.ResolveOutboundIdentityRequest
1, // 23: sub2api.plugin.v1.TransportPlugin.GetInfo:output_type -> sub2api.plugin.v1.GetInfoResponse
3, // 24: sub2api.plugin.v1.TransportPlugin.Health:output_type -> sub2api.plugin.v1.HealthResponse
5, // 25: sub2api.plugin.v1.TransportPlugin.ValidateConfig:output_type -> sub2api.plugin.v1.ValidateConfigResponse
7, // 26: sub2api.plugin.v1.TransportPlugin.ApplyConfig:output_type -> sub2api.plugin.v1.ApplyConfigResponse
9, // 27: sub2api.plugin.v1.TransportPlugin.TestConfig:output_type -> sub2api.plugin.v1.TestConfigResponse
16, // 28: sub2api.plugin.v1.TransportPlugin.Forward:output_type -> sub2api.plugin.v1.ForwardResponse
18, // 29: sub2api.plugin.v1.TransportPlugin.InitHostServices:output_type -> sub2api.plugin.v1.InitHostServicesResponse
20, // 30: sub2api.plugin.v1.HostService.KVGet:output_type -> sub2api.plugin.v1.KVGetResponse
22, // 31: sub2api.plugin.v1.HostService.KVSet:output_type -> sub2api.plugin.v1.KVSetResponse
24, // 32: sub2api.plugin.v1.HostService.KVDelete:output_type -> sub2api.plugin.v1.KVDeleteResponse
26, // 33: sub2api.plugin.v1.HostService.KVList:output_type -> sub2api.plugin.v1.KVListResponse
28, // 34: sub2api.plugin.v1.HostService.ListAccounts:output_type -> sub2api.plugin.v1.ListAccountsResponse
30, // 35: sub2api.plugin.v1.HostService.ResolveOutboundIdentity:output_type -> sub2api.plugin.v1.ResolveOutboundIdentityResponse
23, // [23:36] is the sub-list for method output_type
10, // [10:23] is the sub-list for method input_type
10, // [10:10] is the sub-list for extension type_name
10, // [10:10] is the sub-list for extension extendee
0, // [0:10] is the sub-list for field type_name
29, // 6: sub2api.plugin.v1.ListAccountsResponse.accounts:type_name -> sub2api.plugin.v1.AccountInfo
34, // 7: sub2api.plugin.v1.ResolveOutboundIdentityResponse.headers:type_name -> sub2api.plugin.v1.ResolveOutboundIdentityResponse.HeadersEntry
10, // 8: sub2api.plugin.v1.ForwardRequestStart.HeadersEntry.value:type_name -> sub2api.plugin.v1.HeaderValues
10, // 9: sub2api.plugin.v1.ForwardResponseStart.HeadersEntry.value:type_name -> sub2api.plugin.v1.HeaderValues
10, // 10: sub2api.plugin.v1.ResolveOutboundIdentityResponse.HeadersEntry.value:type_name -> sub2api.plugin.v1.HeaderValues
0, // 11: sub2api.plugin.v1.TransportPlugin.GetInfo:input_type -> sub2api.plugin.v1.GetInfoRequest
2, // 12: sub2api.plugin.v1.TransportPlugin.Health:input_type -> sub2api.plugin.v1.HealthRequest
4, // 13: sub2api.plugin.v1.TransportPlugin.ValidateConfig:input_type -> sub2api.plugin.v1.ValidateConfigRequest
6, // 14: sub2api.plugin.v1.TransportPlugin.ApplyConfig:input_type -> sub2api.plugin.v1.ApplyConfigRequest
8, // 15: sub2api.plugin.v1.TransportPlugin.TestConfig:input_type -> sub2api.plugin.v1.TestConfigRequest
12, // 16: sub2api.plugin.v1.TransportPlugin.Forward:input_type -> sub2api.plugin.v1.ForwardRequest
17, // 17: sub2api.plugin.v1.TransportPlugin.InitHostServices:input_type -> sub2api.plugin.v1.InitHostServicesRequest
19, // 18: sub2api.plugin.v1.HostService.KVGet:input_type -> sub2api.plugin.v1.KVGetRequest
21, // 19: sub2api.plugin.v1.HostService.KVSet:input_type -> sub2api.plugin.v1.KVSetRequest
23, // 20: sub2api.plugin.v1.HostService.KVDelete:input_type -> sub2api.plugin.v1.KVDeleteRequest
25, // 21: sub2api.plugin.v1.HostService.KVList:input_type -> sub2api.plugin.v1.KVListRequest
27, // 22: sub2api.plugin.v1.HostService.ListAccounts:input_type -> sub2api.plugin.v1.ListAccountsRequest
30, // 23: sub2api.plugin.v1.HostService.ResolveOutboundIdentity:input_type -> sub2api.plugin.v1.ResolveOutboundIdentityRequest
1, // 24: sub2api.plugin.v1.TransportPlugin.GetInfo:output_type -> sub2api.plugin.v1.GetInfoResponse
3, // 25: sub2api.plugin.v1.TransportPlugin.Health:output_type -> sub2api.plugin.v1.HealthResponse
5, // 26: sub2api.plugin.v1.TransportPlugin.ValidateConfig:output_type -> sub2api.plugin.v1.ValidateConfigResponse
7, // 27: sub2api.plugin.v1.TransportPlugin.ApplyConfig:output_type -> sub2api.plugin.v1.ApplyConfigResponse
9, // 28: sub2api.plugin.v1.TransportPlugin.TestConfig:output_type -> sub2api.plugin.v1.TestConfigResponse
16, // 29: sub2api.plugin.v1.TransportPlugin.Forward:output_type -> sub2api.plugin.v1.ForwardResponse
18, // 30: sub2api.plugin.v1.TransportPlugin.InitHostServices:output_type -> sub2api.plugin.v1.InitHostServicesResponse
20, // 31: sub2api.plugin.v1.HostService.KVGet:output_type -> sub2api.plugin.v1.KVGetResponse
22, // 32: sub2api.plugin.v1.HostService.KVSet:output_type -> sub2api.plugin.v1.KVSetResponse
24, // 33: sub2api.plugin.v1.HostService.KVDelete:output_type -> sub2api.plugin.v1.KVDeleteResponse
26, // 34: sub2api.plugin.v1.HostService.KVList:output_type -> sub2api.plugin.v1.KVListResponse
28, // 35: sub2api.plugin.v1.HostService.ListAccounts:output_type -> sub2api.plugin.v1.ListAccountsResponse
31, // 36: sub2api.plugin.v1.HostService.ResolveOutboundIdentity:output_type -> sub2api.plugin.v1.ResolveOutboundIdentityResponse
24, // [24:37] is the sub-list for method output_type
11, // [11:24] is the sub-list for method input_type
11, // [11:11] is the sub-list for extension type_name
11, // [11:11] is the sub-list for extension extendee
0, // [0:11] is the sub-list for field type_name
}
func init() { file_plugin_proto_init() }
@@ -2161,7 +2316,7 @@ func file_plugin_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_plugin_proto_rawDesc), len(file_plugin_proto_rawDesc)),
NumEnums: 0,
NumMessages: 34,
NumMessages: 35,
NumExtensions: 0,
NumServices: 2,
},
+54 -4
View File
@@ -27,10 +27,13 @@ service HostService {
rpc KVSet(KVSetRequest) returns (KVSetResponse);
rpc KVDelete(KVDeleteRequest) returns (KVDeleteResponse);
rpc KVList(KVListRequest) returns (KVListResponse);
// Account directory: enumerate the accounts the plugin is bound to act on and
// resolve the outbound identity (credentials/headers/proxy) the host would
// attach to a live request. The host restricts both calls to accounts the
// requesting plugin's capability already covers.
// Account directory: enumerate the accounts the plugin is bound to act on
// (with full readable metadata) and resolve the outbound identity
// (credentials/headers/proxy) the host would attach to a live request. The
// host restricts both calls to the account scope the requesting plugin's
// declared capabilities cover; a plugin can never widen that scope. Metadata
// never carries raw credentials — those are only handed out by
// ResolveOutboundIdentity, the dedicated (equally sensitive) credential channel.
rpc ListAccounts(ListAccountsRequest) returns (ListAccountsResponse);
rpc ResolveOutboundIdentity(ResolveOutboundIdentityRequest) returns (ResolveOutboundIdentityResponse);
}
@@ -206,7 +209,54 @@ message ListAccountsRequest {
}
message ListAccountsResponse {
// account_ids is retained for plugins built against host_service_api_version 1.
// The host keeps filling it (the ids of the accounts in `accounts`) so those
// plugins keep working; new plugins should read `accounts` instead.
repeated int64 account_ids = 1;
// accounts carries the full readable metadata for every account in the
// plugin's scope — including active accounts that are currently NOT
// schedulable because they are paused (rate-limited / temp-unschedulable /
// overloaded). This lets a plugin make its own decisions (e.g. skip paused
// accounts) instead of hammering them. (Administratively disabled / expired
// accounts are already excluded upstream by the host.) Available when
// host_service_api_version >= 2.
repeated AccountInfo accounts = 2;
}
// AccountInfo is the host's readable, non-secret view of one account. It never
// contains credentials (token / refresh_token / cookies); use
// ResolveOutboundIdentity for those.
//
// The typed fields are a small, STABLE decision core. Everything else about the
// account — including any field added to the account model later — is carried in
// metadata_json, so new fields reach plugins WITHOUT a contract change, a host
// mapper edit, or a plugin rebuild. Only a newly added *secret* field would ever
// require a host change (a one-line denylist entry), which is the safe default.
message AccountInfo {
int64 id = 1;
string platform = 2;
string account_type = 3;
string name = 4;
// status is the coarse lifecycle state: active / disabled / error / expired.
string status = 5;
// schedulable is the host-authoritative decision (IsSchedulable) — false when
// the account is active but paused for ANY reason (rate limit,
// temp-unschedulable, overload, expiry auto-pause, quota). This is the primary
// signal a plugin should use to skip an account so it stops probing paused ones.
bool schedulable = 6;
bool is_shadow = 7;
// metadata_json is a host-produced JSON object holding the account's readable
// field set (status timestamps, rate-limit / overload / temp-unschedulable
// windows and reason, last-used, expiry, session window, group ids, priority,
// concurrency, quota dimension, extra config, proxy, and any future field).
// Keys are the account model's field names. Times are RFC3339 strings (JSON
// null when unset). Only two things are withheld: the raw Credentials blob
// (the refresh_token — a long-lived secret the outbound-identity RPC does not
// hand out; the short-lived access token and proxy URL come from that RPC), and
// the Groups/AccountGroups relation graphs (cyclic — group_ids conveys
// membership). Plugins read whatever keys they need; the host does not have to
// change when the account model gains a field.
bytes metadata_json = 20;
}
message ResolveOutboundIdentityRequest {
+14 -8
View File
@@ -373,10 +373,13 @@ type HostServiceClient interface {
KVSet(ctx context.Context, in *KVSetRequest, opts ...grpc.CallOption) (*KVSetResponse, error)
KVDelete(ctx context.Context, in *KVDeleteRequest, opts ...grpc.CallOption) (*KVDeleteResponse, error)
KVList(ctx context.Context, in *KVListRequest, opts ...grpc.CallOption) (*KVListResponse, error)
// Account directory: enumerate the accounts the plugin is bound to act on and
// resolve the outbound identity (credentials/headers/proxy) the host would
// attach to a live request. The host restricts both calls to accounts the
// requesting plugin's capability already covers.
// Account directory: enumerate the accounts the plugin is bound to act on
// (with full readable metadata) and resolve the outbound identity
// (credentials/headers/proxy) the host would attach to a live request. The
// host restricts both calls to the account scope the requesting plugin's
// declared capabilities cover; a plugin can never widen that scope. Metadata
// never carries raw credentials — those are only handed out by
// ResolveOutboundIdentity, the dedicated (equally sensitive) credential channel.
ListAccounts(ctx context.Context, in *ListAccountsRequest, opts ...grpc.CallOption) (*ListAccountsResponse, error)
ResolveOutboundIdentity(ctx context.Context, in *ResolveOutboundIdentityRequest, opts ...grpc.CallOption) (*ResolveOutboundIdentityResponse, error)
}
@@ -462,10 +465,13 @@ type HostServiceServer interface {
KVSet(context.Context, *KVSetRequest) (*KVSetResponse, error)
KVDelete(context.Context, *KVDeleteRequest) (*KVDeleteResponse, error)
KVList(context.Context, *KVListRequest) (*KVListResponse, error)
// Account directory: enumerate the accounts the plugin is bound to act on and
// resolve the outbound identity (credentials/headers/proxy) the host would
// attach to a live request. The host restricts both calls to accounts the
// requesting plugin's capability already covers.
// Account directory: enumerate the accounts the plugin is bound to act on
// (with full readable metadata) and resolve the outbound identity
// (credentials/headers/proxy) the host would attach to a live request. The
// host restricts both calls to the account scope the requesting plugin's
// declared capabilities cover; a plugin can never widen that scope. Metadata
// never carries raw credentials — those are only handed out by
// ResolveOutboundIdentity, the dedicated (equally sensitive) credential channel.
ListAccounts(context.Context, *ListAccountsRequest) (*ListAccountsResponse, error)
ResolveOutboundIdentity(context.Context, *ResolveOutboundIdentityRequest) (*ResolveOutboundIdentityResponse, error)
mustEmbedUnimplementedHostServiceServer()
+5 -1
View File
@@ -17,7 +17,11 @@ const (
// HostServiceAPIVersion 是宿主反向服务(HostService)的契约版本。它独立于
// TransportAPIVersion:宿主服务是叠加在传输契约之上的可选能力,通过
// InitHostServices 在运行时协商,因此新增宿主能力不会使既有插件失效。
HostServiceAPIVersion = 1
//
// v2:ListAccounts 在 account_ids 之外增补 AccountInfo 全量可读元数据
// (状态/调度/限流/暂停原因等),供插件排除不可调度账号。纯加法变更,v1
// 插件忽略新字段即可继续工作。
HostServiceAPIVersion = 2
// TransportPluginName 是 go-plugin 中注册的唯一能力名称。
TransportPluginName = "oauth_transport"
)