mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 03:52:26 +08:00
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:
@@ -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")
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user