Files
sub2api/backend/pkg/pluginapi/v1/plugin.proto
T
shaw 29eead917f 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 全绿。
2026-09-20 11:26:19 +08:00

275 lines
8.9 KiB
Protocol Buffer

syntax = "proto3";
package sub2api.plugin.v1;
option go_package = "github.com/Wei-Shaw/sub2api/pkg/pluginapi/v1;pluginv1";
service TransportPlugin {
rpc GetInfo(GetInfoRequest) returns (GetInfoResponse);
rpc Health(HealthRequest) returns (HealthResponse);
rpc ValidateConfig(ValidateConfigRequest) returns (ValidateConfigResponse);
rpc ApplyConfig(ApplyConfigRequest) returns (ApplyConfigResponse);
rpc TestConfig(TestConfigRequest) returns (TestConfigResponse);
rpc Forward(stream ForwardRequest) returns (stream ForwardResponse);
// InitHostServices hands the plugin a go-plugin broker stream id it can dial
// back to reach the host-provided HostService. It is an optional, generic
// lifecycle hook: plugins built against an older contract leave it
// Unimplemented and the host simply runs without offering host services.
rpc InitHostServices(InitHostServicesRequest) returns (InitHostServicesResponse);
}
// HostService is implemented by the host and dialed by the plugin over the
// go-plugin broker id delivered via InitHostServices. It exposes generic,
// capability-neutral facilities that any plugin may use; new facilities are
// added here as new RPCs without changing the transport contract version.
service HostService {
rpc KVGet(KVGetRequest) returns (KVGetResponse);
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
// (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);
}
message GetInfoRequest {}
message GetInfoResponse {
string plugin_id = 1;
string plugin_version = 2;
uint32 protocol_version = 3;
uint32 transport_api_version = 4;
repeated string capabilities = 5;
}
message HealthRequest {}
message HealthResponse {
bool healthy = 1;
string message = 2;
// status_json is an optional plugin-defined JSON blob describing current runtime
// state (e.g. a status summary for the config UI). It MUST be produced passively
// — reading it must not apply config, reach upstream, or cause side effects — so
// the host can expose it via a lightweight, ungated status endpoint. Ignored by
// the host's health gating.
string status_json = 3;
}
message ValidateConfigRequest {
bytes config_json = 1;
}
message ValidateConfigResponse {
bool valid = 1;
string message = 2;
bytes normalized_config_json = 3;
}
message ApplyConfigRequest {
bytes config_json = 1;
}
message ApplyConfigResponse {
bool applied = 1;
string message = 2;
}
message TestConfigRequest {
bytes config_json = 1;
}
message TestConfigResponse {
bool success = 1;
string message = 2;
int64 latency_ms = 3;
// status_json is an optional plugin-defined JSON blob surfaced to the config UI
// alongside the test result (e.g. a runtime status summary). Ignored by the host.
string status_json = 4;
}
message HeaderValues {
repeated string values = 1;
}
message ForwardRequestStart {
string request_id = 1;
string method = 2;
string url = 3;
string host = 4;
map<string, HeaderValues> headers = 5;
string proxy_url = 6;
int64 account_id = 7;
int32 account_concurrency = 8;
string platform = 9;
string account_type = 10;
int64 content_length = 11;
bool has_body = 12;
}
message ForwardRequest {
oneof frame {
ForwardRequestStart start = 1;
bytes body_chunk = 2;
bool body_end = 3;
}
}
message ForwardResponseStart {
int32 status_code = 1;
string status = 2;
string protocol = 3;
int32 protocol_major = 4;
int32 protocol_minor = 5;
map<string, HeaderValues> headers = 6;
int64 content_length = 7;
}
message ForwardResponseEnd {
int64 bytes_received = 1;
int64 duration_ms = 2;
}
message ForwardResponseError {
string code = 1;
string message = 2;
bool request_sent = 3;
}
message ForwardResponse {
oneof frame {
ForwardResponseStart start = 1;
bytes body_chunk = 2;
ForwardResponseEnd end = 3;
ForwardResponseError error = 4;
}
}
message InitHostServicesRequest {
// host_service_id is the broker stream id the plugin dials to reach HostService.
uint32 host_service_id = 1;
// host_service_api_version lets the plugin reject a host it cannot talk to.
uint32 host_service_api_version = 2;
}
message InitHostServicesResponse {
// ready is true once the plugin has successfully dialed back the host service.
bool ready = 1;
string message = 2;
}
// KV messages back a namespaced, per-plugin persistent key-value store. The
// host derives the owning plugin from the runtime that serves the connection,
// so a plugin can never read or write another plugin's namespace.
message KVGetRequest {
string namespace = 1;
string key = 2;
}
message KVGetResponse {
bool found = 1;
bytes value = 2;
}
message KVSetRequest {
string namespace = 1;
string key = 2;
bytes value = 3;
// ttl_seconds > 0 sets an expiry; 0 stores the entry without expiry.
int64 ttl_seconds = 4;
}
message KVSetResponse {}
message KVDeleteRequest {
string namespace = 1;
string key = 2;
}
message KVDeleteResponse {}
message KVListRequest {
string namespace = 1;
string key_prefix = 2;
int32 limit = 3;
}
message KVListResponse {
repeated string keys = 1;
}
message ListAccountsRequest {
string platform = 1;
string account_type = 2;
}
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 {
int64 account_id = 1;
}
message ResolveOutboundIdentityResponse {
bool found = 1;
int64 account_id = 2;
string platform = 3;
string account_type = 4;
string proxy_url = 5;
string token = 6;
map<string, HeaderValues> headers = 7;
}