From 29eead917f6b619693d99e668a0ca19c5ccb27d1 Mon Sep 17 00:00:00 2001 From: shaw Date: Sun, 20 Sep 2026 11:09:45 +0800 Subject: [PATCH] =?UTF-8?q?feat(pluginapi):=20HostService=20=E8=B4=A6?= =?UTF-8?q?=E5=8F=B7=E7=9B=AE=E5=BD=95=E8=BF=94=E5=9B=9E=E7=BB=93=E6=9E=84?= =?UTF-8?q?=E5=8C=96=E5=8F=AA=E8=AF=BB=E5=85=83=E6=95=B0=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 纯宿主侧改动,向后兼容。 - 契约(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 全绿。 --- .../openai_plugin_account_directory.go | 152 +++++++--- .../openai_plugin_account_directory_test.go | 181 ++++++++++++ .../internal/service/plugin_host_services.go | 131 ++++++++- .../plugin_host_services_broker_test.go | 4 +- .../service/plugin_host_services_test.go | 90 ++++-- backend/internal/service/plugin_manager.go | 33 ++- backend/pkg/pluginapi/v1/plugin.pb.go | 263 ++++++++++++++---- backend/pkg/pluginapi/v1/plugin.proto | 58 +++- backend/pkg/pluginapi/v1/plugin_grpc.pb.go | 22 +- backend/pkg/pluginapi/v1/runtime.go | 6 +- 10 files changed, 786 insertions(+), 154 deletions(-) create mode 100644 backend/internal/service/openai_plugin_account_directory_test.go diff --git a/backend/internal/service/openai_plugin_account_directory.go b/backend/internal/service/openai_plugin_account_directory.go index 03ca4c58e9..8f41489402 100644 --- a/backend/internal/service/openai_plugin_account_directory.go +++ b/backend/internal/service/openai_plugin_account_directory.go @@ -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) diff --git a/backend/internal/service/openai_plugin_account_directory_test.go b/backend/internal/service/openai_plugin_account_directory_test.go new file mode 100644 index 0000000000..ed25dad686 --- /dev/null +++ b/backend/internal/service/openai_plugin_account_directory_test.go @@ -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") +} diff --git a/backend/internal/service/plugin_host_services.go b/backend/internal/service/plugin_host_services.go index e81ddeb9e5..da6c957a16 100644 --- a/backend/internal/service/plugin_host_services.go +++ b/backend/internal/service/plugin_host_services.go @@ -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 不能为负") diff --git a/backend/internal/service/plugin_host_services_broker_test.go b/backend/internal/service/plugin_host_services_broker_test.go index d1e3e5f178..0fd27543f9 100644 --- a/backend/internal/service/plugin_host_services_broker_test.go +++ b/backend/internal/service/plugin_host_services_broker_test.go @@ -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) }) diff --git a/backend/internal/service/plugin_host_services_test.go b/backend/internal/service/plugin_host_services_test.go index 52c474c93e..9bdc000555 100644 --- a/backend/internal/service/plugin_host_services_test.go +++ b/backend/internal/service/plugin_host_services_test.go @@ -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") } } diff --git a/backend/internal/service/plugin_manager.go b/backend/internal/service/plugin_manager.go index 8e69978eb5..23ae5ae824 100644 --- a/backend/internal/service/plugin_manager.go +++ b/backend/internal/service/plugin_manager.go @@ -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 { diff --git a/backend/pkg/pluginapi/v1/plugin.pb.go b/backend/pkg/pluginapi/v1/plugin.pb.go index cf2f5bb295..3d594939c3 100644 --- a/backend/pkg/pluginapi/v1/plugin.pb.go +++ b/backend/pkg/pluginapi/v1/plugin.pb.go @@ -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, }, diff --git a/backend/pkg/pluginapi/v1/plugin.proto b/backend/pkg/pluginapi/v1/plugin.proto index ee3d36eed8..d82778747f 100644 --- a/backend/pkg/pluginapi/v1/plugin.proto +++ b/backend/pkg/pluginapi/v1/plugin.proto @@ -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 { diff --git a/backend/pkg/pluginapi/v1/plugin_grpc.pb.go b/backend/pkg/pluginapi/v1/plugin_grpc.pb.go index 88c235da4c..d5c1ed56e9 100644 --- a/backend/pkg/pluginapi/v1/plugin_grpc.pb.go +++ b/backend/pkg/pluginapi/v1/plugin_grpc.pb.go @@ -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() diff --git a/backend/pkg/pluginapi/v1/runtime.go b/backend/pkg/pluginapi/v1/runtime.go index fcd748612e..22a138aaaa 100644 --- a/backend/pkg/pluginapi/v1/runtime.go +++ b/backend/pkg/pluginapi/v1/runtime.go @@ -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" )