From 78df558da3f71bccf6ab2299c38a500eab856e51 Mon Sep 17 00:00:00 2001 From: cooper Date: Wed, 23 Sep 2026 05:51:02 -0700 Subject: [PATCH 1/2] feat(agent): add the v1 Connect foundation with AgentService and ProjectService First slice of ADR 0004. proto/stackpanel/agent/v1 adds AgentService, whose GetAgentInfo reports the agent version and the procedure paths it serves so the Studio can gate features across agent versions, and ProjectService (list, add, remove and set-default over the user-level project registry; per ADR 0005, selecting a project is a client-side choice). The v1 services share one interceptor chain: Bearer-token auth, with GetAgentInfo public so the Studio can check compatibility before pairing, and panic recovery. The origin allowlist moves out of requireAuth into withAllowedOrigin so both API surfaces share it. The legacy JSON-schema and protobuf-ts generators skip the v1 package. Refs: stackpanel-thq.8.3, stackpanel-thq.8 --- apps/stackpanel-go/cmd/cli/agent.go | 2 + .../internal/agent/config/config.go | 4 + .../internal/agent/project/connect.go | 158 +++++ .../internal/agent/project/connect_test.go | 150 +++++ .../internal/agent/server/connect_v1.go | 146 +++++ .../internal/agent/server/connect_v1_test.go | 84 +++ .../internal/agent/server/cors_auth.go | 20 +- .../internal/agent/server/server.go | 4 + packages/proto/buf.gen.yaml | 6 + .../gen/gopb/stackpanel/agent/v1/agent.pb.go | 185 ++++++ .../agent/v1/agentv1connect/agent.connect.go | 112 ++++ .../v1/agentv1connect/project.connect.go | 211 +++++++ .../gopb/stackpanel/agent/v1/project.pb.go | 560 ++++++++++++++++++ .../v1/agent-AgentService_connectquery.d.ts | 10 + .../v1/agent-AgentService_connectquery.js | 10 + .../gen/ts/stackpanel/agent/v1/agent_pb.d.ts | 75 +++ .../gen/ts/stackpanel/agent/v1/agent_pb.js | 40 ++ .../project-ProjectService_connectquery.d.ts | 31 + .../v1/project-ProjectService_connectquery.js | 34 ++ .../ts/stackpanel/agent/v1/project_pb.d.ts | 248 ++++++++ .../gen/ts/stackpanel/agent/v1/project_pb.js | 90 +++ .../proto/stackpanel/agent/v1/agent.proto | 32 + .../proto/stackpanel/agent/v1/project.proto | 83 +++ 23 files changed, 2288 insertions(+), 7 deletions(-) create mode 100644 apps/stackpanel-go/internal/agent/project/connect.go create mode 100644 apps/stackpanel-go/internal/agent/project/connect_test.go create mode 100644 apps/stackpanel-go/internal/agent/server/connect_v1.go create mode 100644 apps/stackpanel-go/internal/agent/server/connect_v1_test.go create mode 100644 packages/proto/gen/gopb/stackpanel/agent/v1/agent.pb.go create mode 100644 packages/proto/gen/gopb/stackpanel/agent/v1/agentv1connect/agent.connect.go create mode 100644 packages/proto/gen/gopb/stackpanel/agent/v1/agentv1connect/project.connect.go create mode 100644 packages/proto/gen/gopb/stackpanel/agent/v1/project.pb.go create mode 100644 packages/proto/gen/ts/stackpanel/agent/v1/agent-AgentService_connectquery.d.ts create mode 100644 packages/proto/gen/ts/stackpanel/agent/v1/agent-AgentService_connectquery.js create mode 100644 packages/proto/gen/ts/stackpanel/agent/v1/agent_pb.d.ts create mode 100644 packages/proto/gen/ts/stackpanel/agent/v1/agent_pb.js create mode 100644 packages/proto/gen/ts/stackpanel/agent/v1/project-ProjectService_connectquery.d.ts create mode 100644 packages/proto/gen/ts/stackpanel/agent/v1/project-ProjectService_connectquery.js create mode 100644 packages/proto/gen/ts/stackpanel/agent/v1/project_pb.d.ts create mode 100644 packages/proto/gen/ts/stackpanel/agent/v1/project_pb.js create mode 100644 packages/proto/proto/stackpanel/agent/v1/agent.proto create mode 100644 packages/proto/proto/stackpanel/agent/v1/project.proto diff --git a/apps/stackpanel-go/cmd/cli/agent.go b/apps/stackpanel-go/cmd/cli/agent.go index c4307eb6..22196848 100644 --- a/apps/stackpanel-go/cmd/cli/agent.go +++ b/apps/stackpanel-go/cmd/cli/agent.go @@ -103,6 +103,8 @@ func runAgent(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to load config: %w", err) } + cfg.Version = Version + // Only override when the user explicitly passed --port; both the flag // default and config.Load default to 9876, so we can't distinguish // "user typed --port 9876" from "user omitted --port". This means diff --git a/apps/stackpanel-go/internal/agent/config/config.go b/apps/stackpanel-go/internal/agent/config/config.go index ee73fd6a..55c51e3b 100644 --- a/apps/stackpanel-go/internal/agent/config/config.go +++ b/apps/stackpanel-go/internal/agent/config/config.go @@ -34,6 +34,10 @@ type Config struct { // Authentication token (from STACKPANEL_AUTH_TOKEN) AuthToken string + // Version is the agent build version reported by + // stackpanel.agent.v1.AgentService/GetAgentInfo. + Version string + // TestPairingToken enables deterministic pairing for integration tests. // Normally the agent generates a random signing key on startup, making // the JWT pairing token unpredictable. This field pins both the signing diff --git a/apps/stackpanel-go/internal/agent/project/connect.go b/apps/stackpanel-go/internal/agent/project/connect.go new file mode 100644 index 00000000..d380d67b --- /dev/null +++ b/apps/stackpanel-go/internal/agent/project/connect.go @@ -0,0 +1,158 @@ +package project + +import ( + "context" + "errors" + "fmt" + "path/filepath" + "sort" + + "connectrpc.com/connect" + "google.golang.org/protobuf/types/known/timestamppb" + + agentv1 "github.com/darkmatter/stackpanel/packages/proto/gen/gopb/stackpanel/agent/v1" + "github.com/darkmatter/stackpanel/packages/proto/gen/gopb/stackpanel/agent/v1/agentv1connect" + "github.com/darkmatter/stackpanel/stackpanel-go/pkg/userconfig" +) + +var _ agentv1connect.ProjectServiceHandler = (*ConnectHandler)(nil) + +// ConnectHandler serves stackpanel.agent.v1.ProjectService over the +// user-level project registry. +// +// Projects are selected per request (ADR 0005), so this service only +// maintains the registry; it never changes which project other requests +// run against. +type ConnectHandler struct { + registry *userconfig.Manager + + // checkListed reports whether a listed project is still usable; it runs + // for every project on every list, so it must stay cheap. + checkListed func(path string) error + + // checkAdded fully validates a project before it is registered. + checkAdded func(path string) error +} + +// NewConnectHandler returns a ProjectService backed by the manager's registry. +func NewConnectHandler(m *Manager) *ConnectHandler { + return &ConnectHandler{ + registry: m.UserConfigManager(), + checkListed: QuickValidate, + checkAdded: ValidateProject, + } +} + +func (h *ConnectHandler) ListProjects( + _ context.Context, + _ *connect.Request[agentv1.ListProjectsRequest], +) (*connect.Response[agentv1.ListProjectsResponse], error) { + projects := h.registry.ListProjects() + sort.SliceStable(projects, func(i, j int) bool { + return projects[i].LastOpened.After(projects[j].LastOpened) + }) + + res := &agentv1.ListProjectsResponse{ + Projects: make([]*agentv1.Project, 0, len(projects)), + } + for _, p := range projects { + res.Projects = append(res.Projects, h.toProto(p)) + } + if def := h.registry.GetDefaultProject(); def != nil { + res.DefaultProjectId = projectID(*def) + } + return connect.NewResponse(res), nil +} + +func (h *ConnectHandler) AddProject( + _ context.Context, + req *connect.Request[agentv1.AddProjectRequest], +) (*connect.Response[agentv1.AddProjectResponse], error) { + path := req.Msg.GetPath() + if !filepath.IsAbs(path) { + return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("path must be absolute: %q", path)) + } + path = filepath.Clean(path) + if err := h.checkAdded(path); err != nil { + return nil, connect.NewError(connect.CodeInvalidArgument, err) + } + + p, err := h.registry.AddProject(path, "") + if err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + return connect.NewResponse(&agentv1.AddProjectResponse{Project: h.toProto(*p)}), nil +} + +func (h *ConnectHandler) RemoveProject( + _ context.Context, + req *connect.Request[agentv1.RemoveProjectRequest], +) (*connect.Response[agentv1.RemoveProjectResponse], error) { + p, err := h.lookup(req.Msg.GetProjectId()) + if err != nil { + return nil, err + } + if err := h.registry.RemoveProject(p.Path); err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + return connect.NewResponse(&agentv1.RemoveProjectResponse{}), nil +} + +func (h *ConnectHandler) SetDefaultProject( + _ context.Context, + req *connect.Request[agentv1.SetDefaultProjectRequest], +) (*connect.Response[agentv1.SetDefaultProjectResponse], error) { + if req.Msg.GetProjectId() == "" { + if err := h.registry.ClearDefaultProject(); err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + return connect.NewResponse(&agentv1.SetDefaultProjectResponse{}), nil + } + + p, err := h.lookup(req.Msg.GetProjectId()) + if err != nil { + return nil, err + } + if err := h.registry.SetDefaultProject(p.Path); err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + return connect.NewResponse(&agentv1.SetDefaultProjectResponse{}), nil +} + +func (h *ConnectHandler) lookup(id string) (*userconfig.Project, error) { + if id == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("project_id is required")) + } + p := h.registry.GetProjectByID(id) + if p == nil { + return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("unknown project %q", id)) + } + return p, nil +} + +func (h *ConnectHandler) toProto(p userconfig.Project) *agentv1.Project { + out := &agentv1.Project{ + Id: projectID(p), + Name: p.Name, + Path: p.Path, + Valid: true, + } + if !p.LastOpened.IsZero() { + out.LastOpenedAt = timestamppb.New(p.LastOpened) + } + if err := h.checkListed(p.Path); err != nil { + out.Valid = false + out.InvalidReason = err.Error() + } + return out +} + +// projectID is the identifier clients send in the Stackpanel-Project header. +// Older registry entries have no stored ID, so it falls back to the one +// derived from the path, matching userconfig.Manager.GetProjectByID. +func projectID(p userconfig.Project) string { + if p.ID != "" { + return p.ID + } + return userconfig.GenerateProjectID(p.Path) +} diff --git a/apps/stackpanel-go/internal/agent/project/connect_test.go b/apps/stackpanel-go/internal/agent/project/connect_test.go new file mode 100644 index 00000000..fb8e05fe --- /dev/null +++ b/apps/stackpanel-go/internal/agent/project/connect_test.go @@ -0,0 +1,150 @@ +package project + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + + "connectrpc.com/connect" + + agentv1 "github.com/darkmatter/stackpanel/packages/proto/gen/gopb/stackpanel/agent/v1" + "github.com/darkmatter/stackpanel/packages/proto/gen/gopb/stackpanel/agent/v1/agentv1connect" + "github.com/darkmatter/stackpanel/stackpanel-go/pkg/userconfig" +) + +var errGone = errors.New("project not found") + +// newTestClient serves a ProjectService over a registry in a temp config dir. +// Validity is decided by the `invalid` set so tests don't depend on the real +// validators (which reject temp dirs as suspicious and evaluate Nix). +func newTestClient(t *testing.T, invalid map[string]bool) (agentv1connect.ProjectServiceClient, *userconfig.Manager) { + t.Helper() + t.Setenv("HOME", t.TempDir()) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + reg, err := userconfig.NewManager() + if err != nil { + t.Fatalf("userconfig.NewManager: %v", err) + } + + check := func(path string) error { + if invalid[path] { + return errGone + } + return nil + } + mux := http.NewServeMux() + mux.Handle(agentv1connect.NewProjectServiceHandler(&ConnectHandler{ + registry: reg, + checkListed: check, + checkAdded: check, + })) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + return agentv1connect.NewProjectServiceClient(srv.Client(), srv.URL), reg +} + +func TestListProjectsReportsValidityAndDefault(t *testing.T) { + good := filepath.Join(t.TempDir(), "good") + gone := filepath.Join(t.TempDir(), "gone") + client, reg := newTestClient(t, map[string]bool{gone: true}) + + goodProject, err := reg.AddProject(good, "") + if err != nil { + t.Fatal(err) + } + if _, err := reg.AddProject(gone, ""); err != nil { + t.Fatal(err) + } + if err := reg.SetDefaultProject(goodProject.Path); err != nil { + t.Fatal(err) + } + + res, err := client.ListProjects(context.Background(), connect.NewRequest(&agentv1.ListProjectsRequest{})) + if err != nil { + t.Fatalf("ListProjects: %v", err) + } + + byPath := map[string]*agentv1.Project{} + for _, p := range res.Msg.GetProjects() { + byPath[p.GetPath()] = p + } + if len(byPath) != 2 { + t.Fatalf("got %d projects, want 2", len(byPath)) + } + if p := byPath[goodProject.Path]; !p.GetValid() || p.GetInvalidReason() != "" { + t.Errorf("good project: valid=%v reason=%q, want valid", p.GetValid(), p.GetInvalidReason()) + } + if p := byPath[filepath.Clean(gone)]; p.GetValid() || p.GetInvalidReason() != errGone.Error() { + t.Errorf("gone project: valid=%v reason=%q, want invalid with reason", p.GetValid(), p.GetInvalidReason()) + } + if got, want := res.Msg.GetDefaultProjectId(), byPath[goodProject.Path].GetId(); got != want { + t.Errorf("default project id = %q, want %q", got, want) + } +} + +func TestAddProjectValidatesBeforeRegistering(t *testing.T) { + bad := filepath.Join(t.TempDir(), "bad") + client, reg := newTestClient(t, map[string]bool{bad: true}) + ctx := context.Background() + + for _, path := range []string{"relative/dir", bad} { + _, err := client.AddProject(ctx, connect.NewRequest(&agentv1.AddProjectRequest{Path: path})) + if connect.CodeOf(err) != connect.CodeInvalidArgument { + t.Errorf("AddProject(%q) code = %v, want InvalidArgument", path, connect.CodeOf(err)) + } + } + if n := len(reg.ListProjects()); n != 0 { + t.Fatalf("rejected paths were registered: %d projects", n) + } + + dir := filepath.Join(t.TempDir(), "app") + first, err := client.AddProject(ctx, connect.NewRequest(&agentv1.AddProjectRequest{Path: dir})) + if err != nil { + t.Fatalf("AddProject: %v", err) + } + again, err := client.AddProject(ctx, connect.NewRequest(&agentv1.AddProjectRequest{Path: dir})) + if err != nil { + t.Fatalf("AddProject again: %v", err) + } + if first.Msg.GetProject().GetId() != again.Msg.GetProject().GetId() || len(reg.ListProjects()) != 1 { + t.Errorf("re-adding a registered path should return the existing project") + } +} + +func TestDefaultAndRemoveResolveProjectsByID(t *testing.T) { + client, reg := newTestClient(t, nil) + ctx := context.Background() + + p, err := reg.AddProject(filepath.Join(t.TempDir(), "app"), "") + if err != nil { + t.Fatal(err) + } + id := projectID(*p) + + if _, err := client.SetDefaultProject(ctx, connect.NewRequest(&agentv1.SetDefaultProjectRequest{ProjectId: id})); err != nil { + t.Fatalf("SetDefaultProject: %v", err) + } + if got := reg.GetDefaultProjectPath(); got != p.Path { + t.Errorf("default path = %q, want %q", got, p.Path) + } + if _, err := client.SetDefaultProject(ctx, connect.NewRequest(&agentv1.SetDefaultProjectRequest{})); err != nil { + t.Fatalf("clear default: %v", err) + } + if reg.GetDefaultProject() != nil { + t.Error("default project was not cleared") + } + + _, err = client.RemoveProject(ctx, connect.NewRequest(&agentv1.RemoveProjectRequest{ProjectId: "no-such-id"})) + if connect.CodeOf(err) != connect.CodeNotFound { + t.Errorf("RemoveProject(unknown) code = %v, want NotFound", connect.CodeOf(err)) + } + if _, err := client.RemoveProject(ctx, connect.NewRequest(&agentv1.RemoveProjectRequest{ProjectId: id})); err != nil { + t.Fatalf("RemoveProject: %v", err) + } + if n := len(reg.ListProjects()); n != 0 { + t.Errorf("project still registered after RemoveProject: %d", n) + } +} diff --git a/apps/stackpanel-go/internal/agent/server/connect_v1.go b/apps/stackpanel-go/internal/agent/server/connect_v1.go new file mode 100644 index 00000000..1a7b3057 --- /dev/null +++ b/apps/stackpanel-go/internal/agent/server/connect_v1.go @@ -0,0 +1,146 @@ +package server + +import ( + "context" + "errors" + "net/http" + "strings" + + "connectrpc.com/connect" + "github.com/rs/zerolog/log" + "google.golang.org/protobuf/reflect/protoreflect" + "google.golang.org/protobuf/reflect/protoregistry" + + agentv1 "github.com/darkmatter/stackpanel/packages/proto/gen/gopb/stackpanel/agent/v1" + "github.com/darkmatter/stackpanel/packages/proto/gen/gopb/stackpanel/agent/v1/agentv1connect" + "github.com/darkmatter/stackpanel/stackpanel-go/internal/agent/project" +) + +// mountV1 serves the v1 Connect services (ADR 0004). CORS and the origin +// allowlist stay at the HTTP layer; authentication is part of the Connect +// interceptor chain so no procedure can be mounted without it. +func (s *Server) mountV1(mux *http.ServeMux) { + routes := newV1Routes(s.config.Version, s.isValidToken, project.NewConnectHandler(s.projectMgr)) + for _, r := range routes { + mux.Handle(r.path, s.withCORS(s.withAllowedOrigin(r.handler.ServeHTTP))) + } +} + +type v1Route struct { + path string + handler http.Handler +} + +// newV1Routes builds every v1 service behind one interceptor chain and +// reports exactly the procedures they serve through GetAgentInfo. +func newV1Routes( + version string, + validToken func(token string) bool, + projects agentv1connect.ProjectServiceHandler, +) []v1Route { + opts := connect.WithHandlerOptions( + connect.WithInterceptors(authInterceptor{valid: validToken}), + connect.WithRecover(recoverRPC), + ) + info := &agentInfoService{version: version} + + routes := make([]v1Route, 0, 2) + add := func(path string, handler http.Handler) { + routes = append(routes, v1Route{path: path, handler: handler}) + } + add(agentv1connect.NewAgentServiceHandler(info, opts)) + add(agentv1connect.NewProjectServiceHandler(projects, opts)) + + info.procedures = procedurePaths(routes) + return routes +} + +// procedurePaths lists the procedures behind each mounted service path +// ("/./"), read from the registered descriptors so the +// list cannot drift from what is actually served. +func procedurePaths(routes []v1Route) []string { + var out []string + for _, r := range routes { + name := protoreflect.FullName(strings.Trim(r.path, "/")) + desc, err := protoregistry.GlobalFiles.FindDescriptorByName(name) + if err != nil { + // Generated handlers register their descriptors at init, so a + // miss means the route table itself is wrong. + panic("v1 route without a registered service descriptor: " + r.path) + } + methods := desc.(protoreflect.ServiceDescriptor).Methods() + for i := range methods.Len() { + out = append(out, r.path+string(methods.Get(i).Name())) + } + } + return out +} + +// agentInfoService implements stackpanel.agent.v1.AgentService. +type agentInfoService struct { + version string + procedures []string +} + +func (a *agentInfoService) GetAgentInfo( + context.Context, + *connect.Request[agentv1.GetAgentInfoRequest], +) (*connect.Response[agentv1.GetAgentInfoResponse], error) { + return connect.NewResponse(&agentv1.GetAgentInfoResponse{ + Version: a.version, + Procedures: a.procedures, + }), nil +} + +var errUnauthenticated = errors.New("missing or invalid token") + +// authInterceptor requires a valid agent token as an Authorization Bearer +// credential on every procedure except GetAgentInfo, which the Studio calls +// before pairing to learn whether this agent build supports it. +type authInterceptor struct { + valid func(token string) bool +} + +func (a authInterceptor) WrapUnary(next connect.UnaryFunc) connect.UnaryFunc { + return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) { + if err := a.check(req.Spec().Procedure, req.Header()); err != nil { + return nil, err + } + return next(ctx, req) + } +} + +func (a authInterceptor) WrapStreamingClient(next connect.StreamingClientFunc) connect.StreamingClientFunc { + return next +} + +func (a authInterceptor) WrapStreamingHandler(next connect.StreamingHandlerFunc) connect.StreamingHandlerFunc { + return func(ctx context.Context, conn connect.StreamingHandlerConn) error { + if err := a.check(conn.Spec().Procedure, conn.RequestHeader()); err != nil { + return err + } + return next(ctx, conn) + } +} + +func (a authInterceptor) check(procedure string, header http.Header) error { + if procedure == agentv1connect.AgentServiceGetAgentInfoProcedure { + return nil + } + const scheme = "bearer " + auth := strings.TrimSpace(header.Get("Authorization")) + if len(auth) <= len(scheme) || !strings.EqualFold(auth[:len(scheme)], scheme) { + return connect.NewError(connect.CodeUnauthenticated, errUnauthenticated) + } + if !a.valid(strings.TrimSpace(auth[len(scheme):])) { + return connect.NewError(connect.CodeUnauthenticated, errUnauthenticated) + } + return nil +} + +// recoverRPC reports a handler panic to the caller as Internal and logs it +// with the procedure, instead of dropping the connection. +func recoverRPC(_ context.Context, spec connect.Spec, _ http.Header, p any) error { + log.Error().Str("procedure", spec.Procedure).Interface("panic", p).Msg("v1 handler panicked") + return connect.NewError(connect.CodeInternal, errors.New("internal error")) +} diff --git a/apps/stackpanel-go/internal/agent/server/connect_v1_test.go b/apps/stackpanel-go/internal/agent/server/connect_v1_test.go new file mode 100644 index 00000000..9c6efbc7 --- /dev/null +++ b/apps/stackpanel-go/internal/agent/server/connect_v1_test.go @@ -0,0 +1,84 @@ +package server + +import ( + "context" + "net/http" + "net/http/httptest" + "slices" + "testing" + + "connectrpc.com/connect" + + agentv1 "github.com/darkmatter/stackpanel/packages/proto/gen/gopb/stackpanel/agent/v1" + "github.com/darkmatter/stackpanel/packages/proto/gen/gopb/stackpanel/agent/v1/agentv1connect" +) + +// newV1TestServer serves the v1 routes with a token validator that accepts +// only "good" and a ProjectService whose methods all return Unimplemented, +// so reaching a handler is distinguishable from being rejected by auth. +func newV1TestServer(t *testing.T) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + routes := newV1Routes("1.2.3", func(token string) bool { return token == "good" }, + agentv1connect.UnimplementedProjectServiceHandler{}) + for _, r := range routes { + mux.Handle(r.path, r.handler) + } + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + return srv +} + +func TestGetAgentInfoIsPublicAndListsServedProcedures(t *testing.T) { + srv := newV1TestServer(t) + client := agentv1connect.NewAgentServiceClient(srv.Client(), srv.URL) + + res, err := client.GetAgentInfo(context.Background(), connect.NewRequest(&agentv1.GetAgentInfoRequest{})) + if err != nil { + t.Fatalf("GetAgentInfo without a token: %v", err) + } + if got := res.Msg.GetVersion(); got != "1.2.3" { + t.Errorf("version = %q, want 1.2.3", got) + } + for _, want := range []string{ + agentv1connect.AgentServiceGetAgentInfoProcedure, + agentv1connect.ProjectServiceListProjectsProcedure, + agentv1connect.ProjectServiceAddProjectProcedure, + agentv1connect.ProjectServiceRemoveProjectProcedure, + agentv1connect.ProjectServiceSetDefaultProjectProcedure, + } { + if !slices.Contains(res.Msg.GetProcedures(), want) { + t.Errorf("procedures missing %s; got %v", want, res.Msg.GetProcedures()) + } + } +} + +func TestV1ProceduresRequireBearerToken(t *testing.T) { + srv := newV1TestServer(t) + client := agentv1connect.NewProjectServiceClient(srv.Client(), srv.URL) + + cases := []struct { + name string + authorization string + want connect.Code + }{ + {"no header", "", connect.CodeUnauthenticated}, + {"wrong scheme", "Token good", connect.CodeUnauthenticated}, + {"invalid token", "Bearer bad", connect.CodeUnauthenticated}, + // A valid token reaches the (unimplemented) handler. + {"valid token", "Bearer good", connect.CodeUnimplemented}, + {"scheme is case-insensitive", "bearer good", connect.CodeUnimplemented}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + req := connect.NewRequest(&agentv1.ListProjectsRequest{}) + if tc.authorization != "" { + req.Header().Set("Authorization", tc.authorization) + } + _, err := client.ListProjects(context.Background(), req) + if got := connect.CodeOf(err); got != tc.want { + t.Errorf("code = %v, want %v (err: %v)", got, tc.want, err) + } + }) + } +} diff --git a/apps/stackpanel-go/internal/agent/server/cors_auth.go b/apps/stackpanel-go/internal/agent/server/cors_auth.go index f25695ad..7bd42210 100644 --- a/apps/stackpanel-go/internal/agent/server/cors_auth.go +++ b/apps/stackpanel-go/internal/agent/server/cors_auth.go @@ -102,18 +102,24 @@ func (s *Server) withCORS(next http.HandlerFunc) http.HandlerFunc { // requireAuth enforces both origin allowlist and JWT token validation. // Origin is checked first to fast-fail cross-origin attacks before token parsing. func (s *Server) requireAuth(next http.HandlerFunc) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - origin := r.Header.Get("Origin") - if origin != "" && !s.isOriginAllowed(origin) { - s.writeAPIError(w, http.StatusForbidden, "origin not allowed") - return - } - + return s.withAllowedOrigin(func(w http.ResponseWriter, r *http.Request) { if !s.hasValidToken(r) { s.writeAPIError(w, http.StatusUnauthorized, "missing or invalid token") return } + next(w, r) + }) +} + +// withAllowedOrigin rejects browser requests from origins outside the +// allowlist. Requests without an Origin header (CLI, scripts) pass through. +func (s *Server) withAllowedOrigin(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if origin := r.Header.Get("Origin"); origin != "" && !s.isOriginAllowed(origin) { + s.writeAPIError(w, http.StatusForbidden, "origin not allowed") + return + } next(w, r) } } diff --git a/apps/stackpanel-go/internal/agent/server/server.go b/apps/stackpanel-go/internal/agent/server/server.go index 8042e410..c3269486 100644 --- a/apps/stackpanel-go/internal/agent/server/server.go +++ b/apps/stackpanel-go/internal/agent/server/server.go @@ -470,6 +470,10 @@ func New(cfg *config.Config) (*Server, error) { path, handler := gopbconnect.NewAgentServiceHandler(agentService) mux.Handle(path, s.withCORS(s.requireAuth(handler.ServeHTTP))) + // v1 Connect services (ADR 0004). They replace the REST endpoints and the + // AgentService above one domain at a time. + s.mountV1(mux) + // SSE endpoint for real-time config updates mux.HandleFunc("/api/events", s.withCORS(s.requireAuth(s.handleSSE))) diff --git a/packages/proto/buf.gen.yaml b/packages/proto/buf.gen.yaml index 23ef6e06..b6111f67 100644 --- a/packages/proto/buf.gen.yaml +++ b/packages/proto/buf.gen.yaml @@ -5,6 +5,10 @@ plugins: # JSON - remote: buf.build/bufbuild/protoschema-jsonschema:v0.5.2 out: gen/json + # Legacy outputs; the v1 agent API (ADR 0004) is generated only by the + # protobuf-es, connect-go and connect-query plugins. + exclude_types: + - stackpanel.agent.v1 # Go - remote: buf.build/protocolbuffers/go out: gen/gopb @@ -35,6 +39,8 @@ plugins: # TypeScript (using protobuf-ts for better ergonomics) - remote: buf.build/community/timostamm-protobuf-ts out: gen/ts + exclude_types: + - stackpanel.agent.v1 opt: - long_type_string - output_typescript diff --git a/packages/proto/gen/gopb/stackpanel/agent/v1/agent.pb.go b/packages/proto/gen/gopb/stackpanel/agent/v1/agent.pb.go new file mode 100644 index 00000000..822e3e74 --- /dev/null +++ b/packages/proto/gen/gopb/stackpanel/agent/v1/agent.pb.go @@ -0,0 +1,185 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.12 +// protoc (unknown) +// source: stackpanel/agent/v1/agent.proto + +package agentv1 + +import ( + reflect "reflect" + sync "sync" + unsafe "unsafe" + + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type GetAgentInfoRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetAgentInfoRequest) Reset() { + *x = GetAgentInfoRequest{} + mi := &file_stackpanel_agent_v1_agent_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetAgentInfoRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetAgentInfoRequest) ProtoMessage() {} + +func (x *GetAgentInfoRequest) ProtoReflect() protoreflect.Message { + mi := &file_stackpanel_agent_v1_agent_proto_msgTypes[0] + 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 GetAgentInfoRequest.ProtoReflect.Descriptor instead. +func (*GetAgentInfoRequest) Descriptor() ([]byte, []int) { + return file_stackpanel_agent_v1_agent_proto_rawDescGZIP(), []int{0} +} + +type GetAgentInfoResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Build version of the agent binary (the value `stack version` prints). + Version string `protobuf:"bytes,1,opt,name=version,proto3" json:"version,omitempty"` + // Procedures this agent serves, as Connect procedure paths such as + // "/stackpanel.agent.v1.ProjectService/ListProjects". Listing procedures + // rather than services also covers RPCs added to an existing service in a + // newer agent. Clients must treat any procedure not listed here as + // unavailable, and must ignore entries they do not recognize. + Procedures []string `protobuf:"bytes,2,rep,name=procedures,proto3" json:"procedures,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetAgentInfoResponse) Reset() { + *x = GetAgentInfoResponse{} + mi := &file_stackpanel_agent_v1_agent_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetAgentInfoResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetAgentInfoResponse) ProtoMessage() {} + +func (x *GetAgentInfoResponse) ProtoReflect() protoreflect.Message { + mi := &file_stackpanel_agent_v1_agent_proto_msgTypes[1] + 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 GetAgentInfoResponse.ProtoReflect.Descriptor instead. +func (*GetAgentInfoResponse) Descriptor() ([]byte, []int) { + return file_stackpanel_agent_v1_agent_proto_rawDescGZIP(), []int{1} +} + +func (x *GetAgentInfoResponse) GetVersion() string { + if x != nil { + return x.Version + } + return "" +} + +func (x *GetAgentInfoResponse) GetProcedures() []string { + if x != nil { + return x.Procedures + } + return nil +} + +var File_stackpanel_agent_v1_agent_proto protoreflect.FileDescriptor + +const file_stackpanel_agent_v1_agent_proto_rawDesc = "" + + "\n" + + "\x1fstackpanel/agent/v1/agent.proto\x12\x13stackpanel.agent.v1\"\x15\n" + + "\x13GetAgentInfoRequest\"P\n" + + "\x14GetAgentInfoResponse\x12\x18\n" + + "\aversion\x18\x01 \x01(\tR\aversion\x12\x1e\n" + + "\n" + + "procedures\x18\x02 \x03(\tR\n" + + "procedures2x\n" + + "\fAgentService\x12h\n" + + "\fGetAgentInfo\x12(.stackpanel.agent.v1.GetAgentInfoRequest\x1a).stackpanel.agent.v1.GetAgentInfoResponse\"\x03\x90\x02\x01BVZTgithub.com/darkmatter/stackpanel/packages/proto/gen/gopb/stackpanel/agent/v1;agentv1b\x06proto3" + +var ( + file_stackpanel_agent_v1_agent_proto_rawDescOnce sync.Once + file_stackpanel_agent_v1_agent_proto_rawDescData []byte +) + +func file_stackpanel_agent_v1_agent_proto_rawDescGZIP() []byte { + file_stackpanel_agent_v1_agent_proto_rawDescOnce.Do(func() { + file_stackpanel_agent_v1_agent_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_stackpanel_agent_v1_agent_proto_rawDesc), len(file_stackpanel_agent_v1_agent_proto_rawDesc))) + }) + return file_stackpanel_agent_v1_agent_proto_rawDescData +} + +var ( + file_stackpanel_agent_v1_agent_proto_msgTypes = make([]protoimpl.MessageInfo, 2) + file_stackpanel_agent_v1_agent_proto_goTypes = []any{ + (*GetAgentInfoRequest)(nil), // 0: stackpanel.agent.v1.GetAgentInfoRequest + (*GetAgentInfoResponse)(nil), // 1: stackpanel.agent.v1.GetAgentInfoResponse + } +) + +var file_stackpanel_agent_v1_agent_proto_depIdxs = []int32{ + 0, // 0: stackpanel.agent.v1.AgentService.GetAgentInfo:input_type -> stackpanel.agent.v1.GetAgentInfoRequest + 1, // 1: stackpanel.agent.v1.AgentService.GetAgentInfo:output_type -> stackpanel.agent.v1.GetAgentInfoResponse + 1, // [1:2] is the sub-list for method output_type + 0, // [0:1] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_stackpanel_agent_v1_agent_proto_init() } +func file_stackpanel_agent_v1_agent_proto_init() { + if File_stackpanel_agent_v1_agent_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_stackpanel_agent_v1_agent_proto_rawDesc), len(file_stackpanel_agent_v1_agent_proto_rawDesc)), + NumEnums: 0, + NumMessages: 2, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_stackpanel_agent_v1_agent_proto_goTypes, + DependencyIndexes: file_stackpanel_agent_v1_agent_proto_depIdxs, + MessageInfos: file_stackpanel_agent_v1_agent_proto_msgTypes, + }.Build() + File_stackpanel_agent_v1_agent_proto = out.File + file_stackpanel_agent_v1_agent_proto_goTypes = nil + file_stackpanel_agent_v1_agent_proto_depIdxs = nil +} diff --git a/packages/proto/gen/gopb/stackpanel/agent/v1/agentv1connect/agent.connect.go b/packages/proto/gen/gopb/stackpanel/agent/v1/agentv1connect/agent.connect.go new file mode 100644 index 00000000..72dbc841 --- /dev/null +++ b/packages/proto/gen/gopb/stackpanel/agent/v1/agentv1connect/agent.connect.go @@ -0,0 +1,112 @@ +// Code generated by protoc-gen-connect-go. DO NOT EDIT. +// +// Source: stackpanel/agent/v1/agent.proto + +package agentv1connect + +import ( + context "context" + errors "errors" + http "net/http" + strings "strings" + + connect "connectrpc.com/connect" + v1 "github.com/darkmatter/stackpanel/packages/proto/gen/gopb/stackpanel/agent/v1" +) + +// This is a compile-time assertion to ensure that this generated file and the connect package are +// compatible. If you get a compiler error that this constant is not defined, this code was +// generated with a version of connect newer than the one compiled into your binary. You can fix the +// problem by either regenerating this code with an older version of connect or updating the connect +// version compiled into your binary. +const _ = connect.IsAtLeastVersion1_13_0 + +const ( + // AgentServiceName is the fully-qualified name of the AgentService service. + AgentServiceName = "stackpanel.agent.v1.AgentService" +) + +// These constants are the fully-qualified names of the RPCs defined in this package. They're +// exposed at runtime as Spec.Procedure and as the final two segments of the HTTP route. +// +// Note that these are different from the fully-qualified method names used by +// google.golang.org/protobuf/reflect/protoreflect. To convert from these constants to +// reflection-formatted method names, remove the leading slash and convert the remaining slash to a +// period. +const ( + // AgentServiceGetAgentInfoProcedure is the fully-qualified name of the AgentService's GetAgentInfo + // RPC. + AgentServiceGetAgentInfoProcedure = "/stackpanel.agent.v1.AgentService/GetAgentInfo" +) + +// AgentServiceClient is a client for the stackpanel.agent.v1.AgentService service. +type AgentServiceClient interface { + GetAgentInfo(context.Context, *connect.Request[v1.GetAgentInfoRequest]) (*connect.Response[v1.GetAgentInfoResponse], error) +} + +// NewAgentServiceClient constructs a client for the stackpanel.agent.v1.AgentService service. By +// default, it uses the Connect protocol with the binary Protobuf Codec, asks for gzipped responses, +// and sends uncompressed requests. To use the gRPC or gRPC-Web protocols, supply the +// connect.WithGRPC() or connect.WithGRPCWeb() options. +// +// The URL supplied here should be the base URL for the Connect or gRPC server (for example, +// http://api.acme.com or https://acme.com/grpc). +func NewAgentServiceClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) AgentServiceClient { + baseURL = strings.TrimRight(baseURL, "/") + agentServiceMethods := v1.File_stackpanel_agent_v1_agent_proto.Services().ByName("AgentService").Methods() + return &agentServiceClient{ + getAgentInfo: connect.NewClient[v1.GetAgentInfoRequest, v1.GetAgentInfoResponse]( + httpClient, + baseURL+AgentServiceGetAgentInfoProcedure, + connect.WithSchema(agentServiceMethods.ByName("GetAgentInfo")), + connect.WithIdempotency(connect.IdempotencyNoSideEffects), + connect.WithClientOptions(opts...), + ), + } +} + +// agentServiceClient implements AgentServiceClient. +type agentServiceClient struct { + getAgentInfo *connect.Client[v1.GetAgentInfoRequest, v1.GetAgentInfoResponse] +} + +// GetAgentInfo calls stackpanel.agent.v1.AgentService.GetAgentInfo. +func (c *agentServiceClient) GetAgentInfo(ctx context.Context, req *connect.Request[v1.GetAgentInfoRequest]) (*connect.Response[v1.GetAgentInfoResponse], error) { + return c.getAgentInfo.CallUnary(ctx, req) +} + +// AgentServiceHandler is an implementation of the stackpanel.agent.v1.AgentService service. +type AgentServiceHandler interface { + GetAgentInfo(context.Context, *connect.Request[v1.GetAgentInfoRequest]) (*connect.Response[v1.GetAgentInfoResponse], error) +} + +// NewAgentServiceHandler builds an HTTP handler from the service implementation. It returns the +// path on which to mount the handler and the handler itself. +// +// By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf +// and JSON codecs. They also support gzip compression. +func NewAgentServiceHandler(svc AgentServiceHandler, opts ...connect.HandlerOption) (string, http.Handler) { + agentServiceMethods := v1.File_stackpanel_agent_v1_agent_proto.Services().ByName("AgentService").Methods() + agentServiceGetAgentInfoHandler := connect.NewUnaryHandler( + AgentServiceGetAgentInfoProcedure, + svc.GetAgentInfo, + connect.WithSchema(agentServiceMethods.ByName("GetAgentInfo")), + connect.WithIdempotency(connect.IdempotencyNoSideEffects), + connect.WithHandlerOptions(opts...), + ) + return "/stackpanel.agent.v1.AgentService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case AgentServiceGetAgentInfoProcedure: + agentServiceGetAgentInfoHandler.ServeHTTP(w, r) + default: + http.NotFound(w, r) + } + }) +} + +// UnimplementedAgentServiceHandler returns CodeUnimplemented from all methods. +type UnimplementedAgentServiceHandler struct{} + +func (UnimplementedAgentServiceHandler) GetAgentInfo(context.Context, *connect.Request[v1.GetAgentInfoRequest]) (*connect.Response[v1.GetAgentInfoResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("stackpanel.agent.v1.AgentService.GetAgentInfo is not implemented")) +} diff --git a/packages/proto/gen/gopb/stackpanel/agent/v1/agentv1connect/project.connect.go b/packages/proto/gen/gopb/stackpanel/agent/v1/agentv1connect/project.connect.go new file mode 100644 index 00000000..743912b0 --- /dev/null +++ b/packages/proto/gen/gopb/stackpanel/agent/v1/agentv1connect/project.connect.go @@ -0,0 +1,211 @@ +// Code generated by protoc-gen-connect-go. DO NOT EDIT. +// +// Source: stackpanel/agent/v1/project.proto + +package agentv1connect + +import ( + context "context" + errors "errors" + http "net/http" + strings "strings" + + connect "connectrpc.com/connect" + v1 "github.com/darkmatter/stackpanel/packages/proto/gen/gopb/stackpanel/agent/v1" +) + +// This is a compile-time assertion to ensure that this generated file and the connect package are +// compatible. If you get a compiler error that this constant is not defined, this code was +// generated with a version of connect newer than the one compiled into your binary. You can fix the +// problem by either regenerating this code with an older version of connect or updating the connect +// version compiled into your binary. +const _ = connect.IsAtLeastVersion1_13_0 + +const ( + // ProjectServiceName is the fully-qualified name of the ProjectService service. + ProjectServiceName = "stackpanel.agent.v1.ProjectService" +) + +// These constants are the fully-qualified names of the RPCs defined in this package. They're +// exposed at runtime as Spec.Procedure and as the final two segments of the HTTP route. +// +// Note that these are different from the fully-qualified method names used by +// google.golang.org/protobuf/reflect/protoreflect. To convert from these constants to +// reflection-formatted method names, remove the leading slash and convert the remaining slash to a +// period. +const ( + // ProjectServiceListProjectsProcedure is the fully-qualified name of the ProjectService's + // ListProjects RPC. + ProjectServiceListProjectsProcedure = "/stackpanel.agent.v1.ProjectService/ListProjects" + // ProjectServiceAddProjectProcedure is the fully-qualified name of the ProjectService's AddProject + // RPC. + ProjectServiceAddProjectProcedure = "/stackpanel.agent.v1.ProjectService/AddProject" + // ProjectServiceRemoveProjectProcedure is the fully-qualified name of the ProjectService's + // RemoveProject RPC. + ProjectServiceRemoveProjectProcedure = "/stackpanel.agent.v1.ProjectService/RemoveProject" + // ProjectServiceSetDefaultProjectProcedure is the fully-qualified name of the ProjectService's + // SetDefaultProject RPC. + ProjectServiceSetDefaultProjectProcedure = "/stackpanel.agent.v1.ProjectService/SetDefaultProject" +) + +// ProjectServiceClient is a client for the stackpanel.agent.v1.ProjectService service. +type ProjectServiceClient interface { + ListProjects(context.Context, *connect.Request[v1.ListProjectsRequest]) (*connect.Response[v1.ListProjectsResponse], error) + // Validates the directory at path and adds it to the registry. Adding a + // path that is already registered returns the existing project. + // Fails with InvalidArgument when path is not a stackpanel project. + AddProject(context.Context, *connect.Request[v1.AddProjectRequest]) (*connect.Response[v1.AddProjectResponse], error) + // Removes a project from the registry. The directory is left untouched. + RemoveProject(context.Context, *connect.Request[v1.RemoveProjectRequest]) (*connect.Response[v1.RemoveProjectResponse], error) + // Sets the project used for requests that carry no Stackpanel-Project + // header, such as CLI and TUI calls. + SetDefaultProject(context.Context, *connect.Request[v1.SetDefaultProjectRequest]) (*connect.Response[v1.SetDefaultProjectResponse], error) +} + +// NewProjectServiceClient constructs a client for the stackpanel.agent.v1.ProjectService service. +// By default, it uses the Connect protocol with the binary Protobuf Codec, asks for gzipped +// responses, and sends uncompressed requests. To use the gRPC or gRPC-Web protocols, supply the +// connect.WithGRPC() or connect.WithGRPCWeb() options. +// +// The URL supplied here should be the base URL for the Connect or gRPC server (for example, +// http://api.acme.com or https://acme.com/grpc). +func NewProjectServiceClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) ProjectServiceClient { + baseURL = strings.TrimRight(baseURL, "/") + projectServiceMethods := v1.File_stackpanel_agent_v1_project_proto.Services().ByName("ProjectService").Methods() + return &projectServiceClient{ + listProjects: connect.NewClient[v1.ListProjectsRequest, v1.ListProjectsResponse]( + httpClient, + baseURL+ProjectServiceListProjectsProcedure, + connect.WithSchema(projectServiceMethods.ByName("ListProjects")), + connect.WithIdempotency(connect.IdempotencyNoSideEffects), + connect.WithClientOptions(opts...), + ), + addProject: connect.NewClient[v1.AddProjectRequest, v1.AddProjectResponse]( + httpClient, + baseURL+ProjectServiceAddProjectProcedure, + connect.WithSchema(projectServiceMethods.ByName("AddProject")), + connect.WithClientOptions(opts...), + ), + removeProject: connect.NewClient[v1.RemoveProjectRequest, v1.RemoveProjectResponse]( + httpClient, + baseURL+ProjectServiceRemoveProjectProcedure, + connect.WithSchema(projectServiceMethods.ByName("RemoveProject")), + connect.WithClientOptions(opts...), + ), + setDefaultProject: connect.NewClient[v1.SetDefaultProjectRequest, v1.SetDefaultProjectResponse]( + httpClient, + baseURL+ProjectServiceSetDefaultProjectProcedure, + connect.WithSchema(projectServiceMethods.ByName("SetDefaultProject")), + connect.WithClientOptions(opts...), + ), + } +} + +// projectServiceClient implements ProjectServiceClient. +type projectServiceClient struct { + listProjects *connect.Client[v1.ListProjectsRequest, v1.ListProjectsResponse] + addProject *connect.Client[v1.AddProjectRequest, v1.AddProjectResponse] + removeProject *connect.Client[v1.RemoveProjectRequest, v1.RemoveProjectResponse] + setDefaultProject *connect.Client[v1.SetDefaultProjectRequest, v1.SetDefaultProjectResponse] +} + +// ListProjects calls stackpanel.agent.v1.ProjectService.ListProjects. +func (c *projectServiceClient) ListProjects(ctx context.Context, req *connect.Request[v1.ListProjectsRequest]) (*connect.Response[v1.ListProjectsResponse], error) { + return c.listProjects.CallUnary(ctx, req) +} + +// AddProject calls stackpanel.agent.v1.ProjectService.AddProject. +func (c *projectServiceClient) AddProject(ctx context.Context, req *connect.Request[v1.AddProjectRequest]) (*connect.Response[v1.AddProjectResponse], error) { + return c.addProject.CallUnary(ctx, req) +} + +// RemoveProject calls stackpanel.agent.v1.ProjectService.RemoveProject. +func (c *projectServiceClient) RemoveProject(ctx context.Context, req *connect.Request[v1.RemoveProjectRequest]) (*connect.Response[v1.RemoveProjectResponse], error) { + return c.removeProject.CallUnary(ctx, req) +} + +// SetDefaultProject calls stackpanel.agent.v1.ProjectService.SetDefaultProject. +func (c *projectServiceClient) SetDefaultProject(ctx context.Context, req *connect.Request[v1.SetDefaultProjectRequest]) (*connect.Response[v1.SetDefaultProjectResponse], error) { + return c.setDefaultProject.CallUnary(ctx, req) +} + +// ProjectServiceHandler is an implementation of the stackpanel.agent.v1.ProjectService service. +type ProjectServiceHandler interface { + ListProjects(context.Context, *connect.Request[v1.ListProjectsRequest]) (*connect.Response[v1.ListProjectsResponse], error) + // Validates the directory at path and adds it to the registry. Adding a + // path that is already registered returns the existing project. + // Fails with InvalidArgument when path is not a stackpanel project. + AddProject(context.Context, *connect.Request[v1.AddProjectRequest]) (*connect.Response[v1.AddProjectResponse], error) + // Removes a project from the registry. The directory is left untouched. + RemoveProject(context.Context, *connect.Request[v1.RemoveProjectRequest]) (*connect.Response[v1.RemoveProjectResponse], error) + // Sets the project used for requests that carry no Stackpanel-Project + // header, such as CLI and TUI calls. + SetDefaultProject(context.Context, *connect.Request[v1.SetDefaultProjectRequest]) (*connect.Response[v1.SetDefaultProjectResponse], error) +} + +// NewProjectServiceHandler builds an HTTP handler from the service implementation. It returns the +// path on which to mount the handler and the handler itself. +// +// By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf +// and JSON codecs. They also support gzip compression. +func NewProjectServiceHandler(svc ProjectServiceHandler, opts ...connect.HandlerOption) (string, http.Handler) { + projectServiceMethods := v1.File_stackpanel_agent_v1_project_proto.Services().ByName("ProjectService").Methods() + projectServiceListProjectsHandler := connect.NewUnaryHandler( + ProjectServiceListProjectsProcedure, + svc.ListProjects, + connect.WithSchema(projectServiceMethods.ByName("ListProjects")), + connect.WithIdempotency(connect.IdempotencyNoSideEffects), + connect.WithHandlerOptions(opts...), + ) + projectServiceAddProjectHandler := connect.NewUnaryHandler( + ProjectServiceAddProjectProcedure, + svc.AddProject, + connect.WithSchema(projectServiceMethods.ByName("AddProject")), + connect.WithHandlerOptions(opts...), + ) + projectServiceRemoveProjectHandler := connect.NewUnaryHandler( + ProjectServiceRemoveProjectProcedure, + svc.RemoveProject, + connect.WithSchema(projectServiceMethods.ByName("RemoveProject")), + connect.WithHandlerOptions(opts...), + ) + projectServiceSetDefaultProjectHandler := connect.NewUnaryHandler( + ProjectServiceSetDefaultProjectProcedure, + svc.SetDefaultProject, + connect.WithSchema(projectServiceMethods.ByName("SetDefaultProject")), + connect.WithHandlerOptions(opts...), + ) + return "/stackpanel.agent.v1.ProjectService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case ProjectServiceListProjectsProcedure: + projectServiceListProjectsHandler.ServeHTTP(w, r) + case ProjectServiceAddProjectProcedure: + projectServiceAddProjectHandler.ServeHTTP(w, r) + case ProjectServiceRemoveProjectProcedure: + projectServiceRemoveProjectHandler.ServeHTTP(w, r) + case ProjectServiceSetDefaultProjectProcedure: + projectServiceSetDefaultProjectHandler.ServeHTTP(w, r) + default: + http.NotFound(w, r) + } + }) +} + +// UnimplementedProjectServiceHandler returns CodeUnimplemented from all methods. +type UnimplementedProjectServiceHandler struct{} + +func (UnimplementedProjectServiceHandler) ListProjects(context.Context, *connect.Request[v1.ListProjectsRequest]) (*connect.Response[v1.ListProjectsResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("stackpanel.agent.v1.ProjectService.ListProjects is not implemented")) +} + +func (UnimplementedProjectServiceHandler) AddProject(context.Context, *connect.Request[v1.AddProjectRequest]) (*connect.Response[v1.AddProjectResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("stackpanel.agent.v1.ProjectService.AddProject is not implemented")) +} + +func (UnimplementedProjectServiceHandler) RemoveProject(context.Context, *connect.Request[v1.RemoveProjectRequest]) (*connect.Response[v1.RemoveProjectResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("stackpanel.agent.v1.ProjectService.RemoveProject is not implemented")) +} + +func (UnimplementedProjectServiceHandler) SetDefaultProject(context.Context, *connect.Request[v1.SetDefaultProjectRequest]) (*connect.Response[v1.SetDefaultProjectResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("stackpanel.agent.v1.ProjectService.SetDefaultProject is not implemented")) +} diff --git a/packages/proto/gen/gopb/stackpanel/agent/v1/project.pb.go b/packages/proto/gen/gopb/stackpanel/agent/v1/project.pb.go new file mode 100644 index 00000000..1af9f4ed --- /dev/null +++ b/packages/proto/gen/gopb/stackpanel/agent/v1/project.pb.go @@ -0,0 +1,560 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.12 +// protoc (unknown) +// source: stackpanel/agent/v1/project.proto + +package agentv1 + +import ( + reflect "reflect" + sync "sync" + unsafe "unsafe" + + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + timestamppb "google.golang.org/protobuf/types/known/timestamppb" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type Project struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Stable identifier derived from the project path; the value clients send + // in the Stackpanel-Project header. + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + // Display name. + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + // Absolute path to the project root (the directory containing .stack/). + Path string `protobuf:"bytes,3,opt,name=path,proto3" json:"path,omitempty"` + LastOpenedAt *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=last_opened_at,json=lastOpenedAt,proto3" json:"last_opened_at,omitempty"` + // False when the directory no longer exists or is no longer a valid + // stackpanel project; invalid_reason then says why. + Valid bool `protobuf:"varint,5,opt,name=valid,proto3" json:"valid,omitempty"` + InvalidReason string `protobuf:"bytes,6,opt,name=invalid_reason,json=invalidReason,proto3" json:"invalid_reason,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Project) Reset() { + *x = Project{} + mi := &file_stackpanel_agent_v1_project_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Project) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Project) ProtoMessage() {} + +func (x *Project) ProtoReflect() protoreflect.Message { + mi := &file_stackpanel_agent_v1_project_proto_msgTypes[0] + 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 Project.ProtoReflect.Descriptor instead. +func (*Project) Descriptor() ([]byte, []int) { + return file_stackpanel_agent_v1_project_proto_rawDescGZIP(), []int{0} +} + +func (x *Project) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *Project) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *Project) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +func (x *Project) GetLastOpenedAt() *timestamppb.Timestamp { + if x != nil { + return x.LastOpenedAt + } + return nil +} + +func (x *Project) GetValid() bool { + if x != nil { + return x.Valid + } + return false +} + +func (x *Project) GetInvalidReason() string { + if x != nil { + return x.InvalidReason + } + return "" +} + +type ListProjectsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListProjectsRequest) Reset() { + *x = ListProjectsRequest{} + mi := &file_stackpanel_agent_v1_project_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListProjectsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListProjectsRequest) ProtoMessage() {} + +func (x *ListProjectsRequest) ProtoReflect() protoreflect.Message { + mi := &file_stackpanel_agent_v1_project_proto_msgTypes[1] + 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 ListProjectsRequest.ProtoReflect.Descriptor instead. +func (*ListProjectsRequest) Descriptor() ([]byte, []int) { + return file_stackpanel_agent_v1_project_proto_rawDescGZIP(), []int{1} +} + +type ListProjectsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Most recently opened first. + Projects []*Project `protobuf:"bytes,1,rep,name=projects,proto3" json:"projects,omitempty"` + // Empty when no default project is set. + DefaultProjectId string `protobuf:"bytes,2,opt,name=default_project_id,json=defaultProjectId,proto3" json:"default_project_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListProjectsResponse) Reset() { + *x = ListProjectsResponse{} + mi := &file_stackpanel_agent_v1_project_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListProjectsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListProjectsResponse) ProtoMessage() {} + +func (x *ListProjectsResponse) ProtoReflect() protoreflect.Message { + mi := &file_stackpanel_agent_v1_project_proto_msgTypes[2] + 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 ListProjectsResponse.ProtoReflect.Descriptor instead. +func (*ListProjectsResponse) Descriptor() ([]byte, []int) { + return file_stackpanel_agent_v1_project_proto_rawDescGZIP(), []int{2} +} + +func (x *ListProjectsResponse) GetProjects() []*Project { + if x != nil { + return x.Projects + } + return nil +} + +func (x *ListProjectsResponse) GetDefaultProjectId() string { + if x != nil { + return x.DefaultProjectId + } + return "" +} + +type AddProjectRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Absolute path to the project root. + Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AddProjectRequest) Reset() { + *x = AddProjectRequest{} + mi := &file_stackpanel_agent_v1_project_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AddProjectRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AddProjectRequest) ProtoMessage() {} + +func (x *AddProjectRequest) ProtoReflect() protoreflect.Message { + mi := &file_stackpanel_agent_v1_project_proto_msgTypes[3] + 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 AddProjectRequest.ProtoReflect.Descriptor instead. +func (*AddProjectRequest) Descriptor() ([]byte, []int) { + return file_stackpanel_agent_v1_project_proto_rawDescGZIP(), []int{3} +} + +func (x *AddProjectRequest) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +type AddProjectResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Project *Project `protobuf:"bytes,1,opt,name=project,proto3" json:"project,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AddProjectResponse) Reset() { + *x = AddProjectResponse{} + mi := &file_stackpanel_agent_v1_project_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AddProjectResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AddProjectResponse) ProtoMessage() {} + +func (x *AddProjectResponse) ProtoReflect() protoreflect.Message { + mi := &file_stackpanel_agent_v1_project_proto_msgTypes[4] + 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 AddProjectResponse.ProtoReflect.Descriptor instead. +func (*AddProjectResponse) Descriptor() ([]byte, []int) { + return file_stackpanel_agent_v1_project_proto_rawDescGZIP(), []int{4} +} + +func (x *AddProjectResponse) GetProject() *Project { + if x != nil { + return x.Project + } + return nil +} + +type RemoveProjectRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + ProjectId string `protobuf:"bytes,1,opt,name=project_id,json=projectId,proto3" json:"project_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RemoveProjectRequest) Reset() { + *x = RemoveProjectRequest{} + mi := &file_stackpanel_agent_v1_project_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RemoveProjectRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RemoveProjectRequest) ProtoMessage() {} + +func (x *RemoveProjectRequest) ProtoReflect() protoreflect.Message { + mi := &file_stackpanel_agent_v1_project_proto_msgTypes[5] + 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 RemoveProjectRequest.ProtoReflect.Descriptor instead. +func (*RemoveProjectRequest) Descriptor() ([]byte, []int) { + return file_stackpanel_agent_v1_project_proto_rawDescGZIP(), []int{5} +} + +func (x *RemoveProjectRequest) GetProjectId() string { + if x != nil { + return x.ProjectId + } + return "" +} + +type RemoveProjectResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RemoveProjectResponse) Reset() { + *x = RemoveProjectResponse{} + mi := &file_stackpanel_agent_v1_project_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RemoveProjectResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RemoveProjectResponse) ProtoMessage() {} + +func (x *RemoveProjectResponse) ProtoReflect() protoreflect.Message { + mi := &file_stackpanel_agent_v1_project_proto_msgTypes[6] + 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 RemoveProjectResponse.ProtoReflect.Descriptor instead. +func (*RemoveProjectResponse) Descriptor() ([]byte, []int) { + return file_stackpanel_agent_v1_project_proto_rawDescGZIP(), []int{6} +} + +type SetDefaultProjectRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Empty clears the default. + ProjectId string `protobuf:"bytes,1,opt,name=project_id,json=projectId,proto3" json:"project_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SetDefaultProjectRequest) Reset() { + *x = SetDefaultProjectRequest{} + mi := &file_stackpanel_agent_v1_project_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SetDefaultProjectRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SetDefaultProjectRequest) ProtoMessage() {} + +func (x *SetDefaultProjectRequest) ProtoReflect() protoreflect.Message { + mi := &file_stackpanel_agent_v1_project_proto_msgTypes[7] + 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 SetDefaultProjectRequest.ProtoReflect.Descriptor instead. +func (*SetDefaultProjectRequest) Descriptor() ([]byte, []int) { + return file_stackpanel_agent_v1_project_proto_rawDescGZIP(), []int{7} +} + +func (x *SetDefaultProjectRequest) GetProjectId() string { + if x != nil { + return x.ProjectId + } + return "" +} + +type SetDefaultProjectResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SetDefaultProjectResponse) Reset() { + *x = SetDefaultProjectResponse{} + mi := &file_stackpanel_agent_v1_project_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SetDefaultProjectResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SetDefaultProjectResponse) ProtoMessage() {} + +func (x *SetDefaultProjectResponse) ProtoReflect() protoreflect.Message { + mi := &file_stackpanel_agent_v1_project_proto_msgTypes[8] + 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 SetDefaultProjectResponse.ProtoReflect.Descriptor instead. +func (*SetDefaultProjectResponse) Descriptor() ([]byte, []int) { + return file_stackpanel_agent_v1_project_proto_rawDescGZIP(), []int{8} +} + +var File_stackpanel_agent_v1_project_proto protoreflect.FileDescriptor + +const file_stackpanel_agent_v1_project_proto_rawDesc = "" + + "\n" + + "!stackpanel/agent/v1/project.proto\x12\x13stackpanel.agent.v1\x1a\x1fgoogle/protobuf/timestamp.proto\"\xc0\x01\n" + + "\aProject\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x12\n" + + "\x04name\x18\x02 \x01(\tR\x04name\x12\x12\n" + + "\x04path\x18\x03 \x01(\tR\x04path\x12@\n" + + "\x0elast_opened_at\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\flastOpenedAt\x12\x14\n" + + "\x05valid\x18\x05 \x01(\bR\x05valid\x12%\n" + + "\x0einvalid_reason\x18\x06 \x01(\tR\rinvalidReason\"\x15\n" + + "\x13ListProjectsRequest\"~\n" + + "\x14ListProjectsResponse\x128\n" + + "\bprojects\x18\x01 \x03(\v2\x1c.stackpanel.agent.v1.ProjectR\bprojects\x12,\n" + + "\x12default_project_id\x18\x02 \x01(\tR\x10defaultProjectId\"'\n" + + "\x11AddProjectRequest\x12\x12\n" + + "\x04path\x18\x01 \x01(\tR\x04path\"L\n" + + "\x12AddProjectResponse\x126\n" + + "\aproject\x18\x01 \x01(\v2\x1c.stackpanel.agent.v1.ProjectR\aproject\"5\n" + + "\x14RemoveProjectRequest\x12\x1d\n" + + "\n" + + "project_id\x18\x01 \x01(\tR\tprojectId\"\x17\n" + + "\x15RemoveProjectResponse\"9\n" + + "\x18SetDefaultProjectRequest\x12\x1d\n" + + "\n" + + "project_id\x18\x01 \x01(\tR\tprojectId\"\x1b\n" + + "\x19SetDefaultProjectResponse2\xb5\x03\n" + + "\x0eProjectService\x12h\n" + + "\fListProjects\x12(.stackpanel.agent.v1.ListProjectsRequest\x1a).stackpanel.agent.v1.ListProjectsResponse\"\x03\x90\x02\x01\x12]\n" + + "\n" + + "AddProject\x12&.stackpanel.agent.v1.AddProjectRequest\x1a'.stackpanel.agent.v1.AddProjectResponse\x12f\n" + + "\rRemoveProject\x12).stackpanel.agent.v1.RemoveProjectRequest\x1a*.stackpanel.agent.v1.RemoveProjectResponse\x12r\n" + + "\x11SetDefaultProject\x12-.stackpanel.agent.v1.SetDefaultProjectRequest\x1a..stackpanel.agent.v1.SetDefaultProjectResponseBVZTgithub.com/darkmatter/stackpanel/packages/proto/gen/gopb/stackpanel/agent/v1;agentv1b\x06proto3" + +var ( + file_stackpanel_agent_v1_project_proto_rawDescOnce sync.Once + file_stackpanel_agent_v1_project_proto_rawDescData []byte +) + +func file_stackpanel_agent_v1_project_proto_rawDescGZIP() []byte { + file_stackpanel_agent_v1_project_proto_rawDescOnce.Do(func() { + file_stackpanel_agent_v1_project_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_stackpanel_agent_v1_project_proto_rawDesc), len(file_stackpanel_agent_v1_project_proto_rawDesc))) + }) + return file_stackpanel_agent_v1_project_proto_rawDescData +} + +var ( + file_stackpanel_agent_v1_project_proto_msgTypes = make([]protoimpl.MessageInfo, 9) + file_stackpanel_agent_v1_project_proto_goTypes = []any{ + (*Project)(nil), // 0: stackpanel.agent.v1.Project + (*ListProjectsRequest)(nil), // 1: stackpanel.agent.v1.ListProjectsRequest + (*ListProjectsResponse)(nil), // 2: stackpanel.agent.v1.ListProjectsResponse + (*AddProjectRequest)(nil), // 3: stackpanel.agent.v1.AddProjectRequest + (*AddProjectResponse)(nil), // 4: stackpanel.agent.v1.AddProjectResponse + (*RemoveProjectRequest)(nil), // 5: stackpanel.agent.v1.RemoveProjectRequest + (*RemoveProjectResponse)(nil), // 6: stackpanel.agent.v1.RemoveProjectResponse + (*SetDefaultProjectRequest)(nil), // 7: stackpanel.agent.v1.SetDefaultProjectRequest + (*SetDefaultProjectResponse)(nil), // 8: stackpanel.agent.v1.SetDefaultProjectResponse + (*timestamppb.Timestamp)(nil), // 9: google.protobuf.Timestamp + } +) + +var file_stackpanel_agent_v1_project_proto_depIdxs = []int32{ + 9, // 0: stackpanel.agent.v1.Project.last_opened_at:type_name -> google.protobuf.Timestamp + 0, // 1: stackpanel.agent.v1.ListProjectsResponse.projects:type_name -> stackpanel.agent.v1.Project + 0, // 2: stackpanel.agent.v1.AddProjectResponse.project:type_name -> stackpanel.agent.v1.Project + 1, // 3: stackpanel.agent.v1.ProjectService.ListProjects:input_type -> stackpanel.agent.v1.ListProjectsRequest + 3, // 4: stackpanel.agent.v1.ProjectService.AddProject:input_type -> stackpanel.agent.v1.AddProjectRequest + 5, // 5: stackpanel.agent.v1.ProjectService.RemoveProject:input_type -> stackpanel.agent.v1.RemoveProjectRequest + 7, // 6: stackpanel.agent.v1.ProjectService.SetDefaultProject:input_type -> stackpanel.agent.v1.SetDefaultProjectRequest + 2, // 7: stackpanel.agent.v1.ProjectService.ListProjects:output_type -> stackpanel.agent.v1.ListProjectsResponse + 4, // 8: stackpanel.agent.v1.ProjectService.AddProject:output_type -> stackpanel.agent.v1.AddProjectResponse + 6, // 9: stackpanel.agent.v1.ProjectService.RemoveProject:output_type -> stackpanel.agent.v1.RemoveProjectResponse + 8, // 10: stackpanel.agent.v1.ProjectService.SetDefaultProject:output_type -> stackpanel.agent.v1.SetDefaultProjectResponse + 7, // [7:11] is the sub-list for method output_type + 3, // [3:7] is the sub-list for method input_type + 3, // [3:3] is the sub-list for extension type_name + 3, // [3:3] is the sub-list for extension extendee + 0, // [0:3] is the sub-list for field type_name +} + +func init() { file_stackpanel_agent_v1_project_proto_init() } +func file_stackpanel_agent_v1_project_proto_init() { + if File_stackpanel_agent_v1_project_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_stackpanel_agent_v1_project_proto_rawDesc), len(file_stackpanel_agent_v1_project_proto_rawDesc)), + NumEnums: 0, + NumMessages: 9, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_stackpanel_agent_v1_project_proto_goTypes, + DependencyIndexes: file_stackpanel_agent_v1_project_proto_depIdxs, + MessageInfos: file_stackpanel_agent_v1_project_proto_msgTypes, + }.Build() + File_stackpanel_agent_v1_project_proto = out.File + file_stackpanel_agent_v1_project_proto_goTypes = nil + file_stackpanel_agent_v1_project_proto_depIdxs = nil +} diff --git a/packages/proto/gen/ts/stackpanel/agent/v1/agent-AgentService_connectquery.d.ts b/packages/proto/gen/ts/stackpanel/agent/v1/agent-AgentService_connectquery.d.ts new file mode 100644 index 00000000..17a16a74 --- /dev/null +++ b/packages/proto/gen/ts/stackpanel/agent/v1/agent-AgentService_connectquery.d.ts @@ -0,0 +1,10 @@ +// @generated by protoc-gen-connect-query v2.1.1 +// @generated from file stackpanel/agent/v1/agent.proto (package stackpanel.agent.v1, syntax proto3) +/* eslint-disable */ + +import { AgentService } from "./agent_pb"; + +/** + * @generated from rpc stackpanel.agent.v1.AgentService.GetAgentInfo + */ +export const getAgentInfo: typeof AgentService["method"]["getAgentInfo"]; diff --git a/packages/proto/gen/ts/stackpanel/agent/v1/agent-AgentService_connectquery.js b/packages/proto/gen/ts/stackpanel/agent/v1/agent-AgentService_connectquery.js new file mode 100644 index 00000000..73bcef3c --- /dev/null +++ b/packages/proto/gen/ts/stackpanel/agent/v1/agent-AgentService_connectquery.js @@ -0,0 +1,10 @@ +// @generated by protoc-gen-connect-query v2.1.1 +// @generated from file stackpanel/agent/v1/agent.proto (package stackpanel.agent.v1, syntax proto3) +/* eslint-disable */ + +import { AgentService } from "./agent_pb"; + +/** + * @generated from rpc stackpanel.agent.v1.AgentService.GetAgentInfo + */ +export const getAgentInfo = AgentService.method.getAgentInfo; diff --git a/packages/proto/gen/ts/stackpanel/agent/v1/agent_pb.d.ts b/packages/proto/gen/ts/stackpanel/agent/v1/agent_pb.d.ts new file mode 100644 index 00000000..f46b6eff --- /dev/null +++ b/packages/proto/gen/ts/stackpanel/agent/v1/agent_pb.d.ts @@ -0,0 +1,75 @@ +// @generated by protoc-gen-es v2.7.0 +// @generated from file stackpanel/agent/v1/agent.proto (package stackpanel.agent.v1, syntax proto3) +/* eslint-disable */ + +import type { GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file stackpanel/agent/v1/agent.proto. + */ +export declare const file_stackpanel_agent_v1_agent: GenFile; + +/** + * @generated from message stackpanel.agent.v1.GetAgentInfoRequest + */ +export declare type GetAgentInfoRequest = Message<"stackpanel.agent.v1.GetAgentInfoRequest"> & { +}; + +/** + * Describes the message stackpanel.agent.v1.GetAgentInfoRequest. + * Use `create(GetAgentInfoRequestSchema)` to create a new message. + */ +export declare const GetAgentInfoRequestSchema: GenMessage; + +/** + * @generated from message stackpanel.agent.v1.GetAgentInfoResponse + */ +export declare type GetAgentInfoResponse = Message<"stackpanel.agent.v1.GetAgentInfoResponse"> & { + /** + * Build version of the agent binary (the value `stack version` prints). + * + * @generated from field: string version = 1; + */ + version: string; + + /** + * Procedures this agent serves, as Connect procedure paths such as + * "/stackpanel.agent.v1.ProjectService/ListProjects". Listing procedures + * rather than services also covers RPCs added to an existing service in a + * newer agent. Clients must treat any procedure not listed here as + * unavailable, and must ignore entries they do not recognize. + * + * @generated from field: repeated string procedures = 2; + */ + procedures: string[]; +}; + +/** + * Describes the message stackpanel.agent.v1.GetAgentInfoResponse. + * Use `create(GetAgentInfoResponseSchema)` to create a new message. + */ +export declare const GetAgentInfoResponseSchema: GenMessage; + +/** + * AgentService describes the running agent itself. + * + * The Studio is deployed continuously while each user's agent is pinned by + * their flake, so every Studio build talks to agents of many versions. + * Clients call GetAgentInfo when they connect and decide which features to + * offer from the reported capabilities, instead of failing on Unimplemented + * (ADR 0004). + * + * @generated from service stackpanel.agent.v1.AgentService + */ +export declare const AgentService: GenService<{ + /** + * @generated from rpc stackpanel.agent.v1.AgentService.GetAgentInfo + */ + getAgentInfo: { + methodKind: "unary"; + input: typeof GetAgentInfoRequestSchema; + output: typeof GetAgentInfoResponseSchema; + }, +}>; + diff --git a/packages/proto/gen/ts/stackpanel/agent/v1/agent_pb.js b/packages/proto/gen/ts/stackpanel/agent/v1/agent_pb.js new file mode 100644 index 00000000..74c81820 --- /dev/null +++ b/packages/proto/gen/ts/stackpanel/agent/v1/agent_pb.js @@ -0,0 +1,40 @@ +// @generated by protoc-gen-es v2.7.0 +// @generated from file stackpanel/agent/v1/agent.proto (package stackpanel.agent.v1, syntax proto3) +/* eslint-disable */ + +import { fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2"; + +/** + * Describes the file stackpanel/agent/v1/agent.proto. + */ +export const file_stackpanel_agent_v1_agent = /*@__PURE__*/ + fileDesc("Ch9zdGFja3BhbmVsL2FnZW50L3YxL2FnZW50LnByb3RvEhNzdGFja3BhbmVsLmFnZW50LnYxIhUKE0dldEFnZW50SW5mb1JlcXVlc3QiOwoUR2V0QWdlbnRJbmZvUmVzcG9uc2USDwoHdmVyc2lvbhgBIAEoCRISCgpwcm9jZWR1cmVzGAIgAygJMngKDEFnZW50U2VydmljZRJoCgxHZXRBZ2VudEluZm8SKC5zdGFja3BhbmVsLmFnZW50LnYxLkdldEFnZW50SW5mb1JlcXVlc3QaKS5zdGFja3BhbmVsLmFnZW50LnYxLkdldEFnZW50SW5mb1Jlc3BvbnNlIgOQAgFCVlpUZ2l0aHViLmNvbS9kYXJrbWF0dGVyL3N0YWNrcGFuZWwvcGFja2FnZXMvcHJvdG8vZ2VuL2dvcGIvc3RhY2twYW5lbC9hZ2VudC92MTthZ2VudHYxYgZwcm90bzM"); + +/** + * Describes the message stackpanel.agent.v1.GetAgentInfoRequest. + * Use `create(GetAgentInfoRequestSchema)` to create a new message. + */ +export const GetAgentInfoRequestSchema = /*@__PURE__*/ + messageDesc(file_stackpanel_agent_v1_agent, 0); + +/** + * Describes the message stackpanel.agent.v1.GetAgentInfoResponse. + * Use `create(GetAgentInfoResponseSchema)` to create a new message. + */ +export const GetAgentInfoResponseSchema = /*@__PURE__*/ + messageDesc(file_stackpanel_agent_v1_agent, 1); + +/** + * AgentService describes the running agent itself. + * + * The Studio is deployed continuously while each user's agent is pinned by + * their flake, so every Studio build talks to agents of many versions. + * Clients call GetAgentInfo when they connect and decide which features to + * offer from the reported capabilities, instead of failing on Unimplemented + * (ADR 0004). + * + * @generated from service stackpanel.agent.v1.AgentService + */ +export const AgentService = /*@__PURE__*/ + serviceDesc(file_stackpanel_agent_v1_agent, 0); + diff --git a/packages/proto/gen/ts/stackpanel/agent/v1/project-ProjectService_connectquery.d.ts b/packages/proto/gen/ts/stackpanel/agent/v1/project-ProjectService_connectquery.d.ts new file mode 100644 index 00000000..c2d14a20 --- /dev/null +++ b/packages/proto/gen/ts/stackpanel/agent/v1/project-ProjectService_connectquery.d.ts @@ -0,0 +1,31 @@ +// @generated by protoc-gen-connect-query v2.1.1 +// @generated from file stackpanel/agent/v1/project.proto (package stackpanel.agent.v1, syntax proto3) +/* eslint-disable */ + +import { ProjectService } from "./project_pb"; + +/** + * @generated from rpc stackpanel.agent.v1.ProjectService.ListProjects + */ +export const listProjects: typeof ProjectService["method"]["listProjects"]; +/** + * Validates the directory at path and adds it to the registry. Adding a + * path that is already registered returns the existing project. + * Fails with InvalidArgument when path is not a stackpanel project. + * + * @generated from rpc stackpanel.agent.v1.ProjectService.AddProject + */ +export const addProject: typeof ProjectService["method"]["addProject"]; +/** + * Removes a project from the registry. The directory is left untouched. + * + * @generated from rpc stackpanel.agent.v1.ProjectService.RemoveProject + */ +export const removeProject: typeof ProjectService["method"]["removeProject"]; +/** + * Sets the project used for requests that carry no Stackpanel-Project + * header, such as CLI and TUI calls. + * + * @generated from rpc stackpanel.agent.v1.ProjectService.SetDefaultProject + */ +export const setDefaultProject: typeof ProjectService["method"]["setDefaultProject"]; diff --git a/packages/proto/gen/ts/stackpanel/agent/v1/project-ProjectService_connectquery.js b/packages/proto/gen/ts/stackpanel/agent/v1/project-ProjectService_connectquery.js new file mode 100644 index 00000000..d6016c2f --- /dev/null +++ b/packages/proto/gen/ts/stackpanel/agent/v1/project-ProjectService_connectquery.js @@ -0,0 +1,34 @@ +// @generated by protoc-gen-connect-query v2.1.1 +// @generated from file stackpanel/agent/v1/project.proto (package stackpanel.agent.v1, syntax proto3) +/* eslint-disable */ + +import { ProjectService } from "./project_pb"; + +/** + * @generated from rpc stackpanel.agent.v1.ProjectService.ListProjects + */ +export const listProjects = ProjectService.method.listProjects; + +/** + * Validates the directory at path and adds it to the registry. Adding a + * path that is already registered returns the existing project. + * Fails with InvalidArgument when path is not a stackpanel project. + * + * @generated from rpc stackpanel.agent.v1.ProjectService.AddProject + */ +export const addProject = ProjectService.method.addProject; + +/** + * Removes a project from the registry. The directory is left untouched. + * + * @generated from rpc stackpanel.agent.v1.ProjectService.RemoveProject + */ +export const removeProject = ProjectService.method.removeProject; + +/** + * Sets the project used for requests that carry no Stackpanel-Project + * header, such as CLI and TUI calls. + * + * @generated from rpc stackpanel.agent.v1.ProjectService.SetDefaultProject + */ +export const setDefaultProject = ProjectService.method.setDefaultProject; diff --git a/packages/proto/gen/ts/stackpanel/agent/v1/project_pb.d.ts b/packages/proto/gen/ts/stackpanel/agent/v1/project_pb.d.ts new file mode 100644 index 00000000..3eff3e3f --- /dev/null +++ b/packages/proto/gen/ts/stackpanel/agent/v1/project_pb.d.ts @@ -0,0 +1,248 @@ +// @generated by protoc-gen-es v2.7.0 +// @generated from file stackpanel/agent/v1/project.proto (package stackpanel.agent.v1, syntax proto3) +/* eslint-disable */ + +import type { GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2"; +import type { Message } from "@bufbuild/protobuf"; +import type { Timestamp } from "@bufbuild/protobuf/wkt"; + +/** + * Describes the file stackpanel/agent/v1/project.proto. + */ +export declare const file_stackpanel_agent_v1_project: GenFile; + +/** + * @generated from message stackpanel.agent.v1.Project + */ +export declare type Project = Message<"stackpanel.agent.v1.Project"> & { + /** + * Stable identifier derived from the project path; the value clients send + * in the Stackpanel-Project header. + * + * @generated from field: string id = 1; + */ + id: string; + + /** + * Display name. + * + * @generated from field: string name = 2; + */ + name: string; + + /** + * Absolute path to the project root (the directory containing .stack/). + * + * @generated from field: string path = 3; + */ + path: string; + + /** + * @generated from field: google.protobuf.Timestamp last_opened_at = 4; + */ + lastOpenedAt?: Timestamp; + + /** + * False when the directory no longer exists or is no longer a valid + * stackpanel project; invalid_reason then says why. + * + * @generated from field: bool valid = 5; + */ + valid: boolean; + + /** + * @generated from field: string invalid_reason = 6; + */ + invalidReason: string; +}; + +/** + * Describes the message stackpanel.agent.v1.Project. + * Use `create(ProjectSchema)` to create a new message. + */ +export declare const ProjectSchema: GenMessage; + +/** + * @generated from message stackpanel.agent.v1.ListProjectsRequest + */ +export declare type ListProjectsRequest = Message<"stackpanel.agent.v1.ListProjectsRequest"> & { +}; + +/** + * Describes the message stackpanel.agent.v1.ListProjectsRequest. + * Use `create(ListProjectsRequestSchema)` to create a new message. + */ +export declare const ListProjectsRequestSchema: GenMessage; + +/** + * @generated from message stackpanel.agent.v1.ListProjectsResponse + */ +export declare type ListProjectsResponse = Message<"stackpanel.agent.v1.ListProjectsResponse"> & { + /** + * Most recently opened first. + * + * @generated from field: repeated stackpanel.agent.v1.Project projects = 1; + */ + projects: Project[]; + + /** + * Empty when no default project is set. + * + * @generated from field: string default_project_id = 2; + */ + defaultProjectId: string; +}; + +/** + * Describes the message stackpanel.agent.v1.ListProjectsResponse. + * Use `create(ListProjectsResponseSchema)` to create a new message. + */ +export declare const ListProjectsResponseSchema: GenMessage; + +/** + * @generated from message stackpanel.agent.v1.AddProjectRequest + */ +export declare type AddProjectRequest = Message<"stackpanel.agent.v1.AddProjectRequest"> & { + /** + * Absolute path to the project root. + * + * @generated from field: string path = 1; + */ + path: string; +}; + +/** + * Describes the message stackpanel.agent.v1.AddProjectRequest. + * Use `create(AddProjectRequestSchema)` to create a new message. + */ +export declare const AddProjectRequestSchema: GenMessage; + +/** + * @generated from message stackpanel.agent.v1.AddProjectResponse + */ +export declare type AddProjectResponse = Message<"stackpanel.agent.v1.AddProjectResponse"> & { + /** + * @generated from field: stackpanel.agent.v1.Project project = 1; + */ + project?: Project; +}; + +/** + * Describes the message stackpanel.agent.v1.AddProjectResponse. + * Use `create(AddProjectResponseSchema)` to create a new message. + */ +export declare const AddProjectResponseSchema: GenMessage; + +/** + * @generated from message stackpanel.agent.v1.RemoveProjectRequest + */ +export declare type RemoveProjectRequest = Message<"stackpanel.agent.v1.RemoveProjectRequest"> & { + /** + * @generated from field: string project_id = 1; + */ + projectId: string; +}; + +/** + * Describes the message stackpanel.agent.v1.RemoveProjectRequest. + * Use `create(RemoveProjectRequestSchema)` to create a new message. + */ +export declare const RemoveProjectRequestSchema: GenMessage; + +/** + * @generated from message stackpanel.agent.v1.RemoveProjectResponse + */ +export declare type RemoveProjectResponse = Message<"stackpanel.agent.v1.RemoveProjectResponse"> & { +}; + +/** + * Describes the message stackpanel.agent.v1.RemoveProjectResponse. + * Use `create(RemoveProjectResponseSchema)` to create a new message. + */ +export declare const RemoveProjectResponseSchema: GenMessage; + +/** + * @generated from message stackpanel.agent.v1.SetDefaultProjectRequest + */ +export declare type SetDefaultProjectRequest = Message<"stackpanel.agent.v1.SetDefaultProjectRequest"> & { + /** + * Empty clears the default. + * + * @generated from field: string project_id = 1; + */ + projectId: string; +}; + +/** + * Describes the message stackpanel.agent.v1.SetDefaultProjectRequest. + * Use `create(SetDefaultProjectRequestSchema)` to create a new message. + */ +export declare const SetDefaultProjectRequestSchema: GenMessage; + +/** + * @generated from message stackpanel.agent.v1.SetDefaultProjectResponse + */ +export declare type SetDefaultProjectResponse = Message<"stackpanel.agent.v1.SetDefaultProjectResponse"> & { +}; + +/** + * Describes the message stackpanel.agent.v1.SetDefaultProjectResponse. + * Use `create(SetDefaultProjectResponseSchema)` to create a new message. + */ +export declare const SetDefaultProjectResponseSchema: GenMessage; + +/** + * ProjectService manages the agent's registry of known projects, stored in + * ~/.config/stackpanel/stackpanel.yaml. + * + * It is the one service that does not run inside a project. Every other + * service resolves its project per request from the Stackpanel-Project + * header, falling back to the registry's default project (ADR 0005), so + * "opening" a project is a client-side choice, not agent state. + * + * @generated from service stackpanel.agent.v1.ProjectService + */ +export declare const ProjectService: GenService<{ + /** + * @generated from rpc stackpanel.agent.v1.ProjectService.ListProjects + */ + listProjects: { + methodKind: "unary"; + input: typeof ListProjectsRequestSchema; + output: typeof ListProjectsResponseSchema; + }, + /** + * Validates the directory at path and adds it to the registry. Adding a + * path that is already registered returns the existing project. + * Fails with InvalidArgument when path is not a stackpanel project. + * + * @generated from rpc stackpanel.agent.v1.ProjectService.AddProject + */ + addProject: { + methodKind: "unary"; + input: typeof AddProjectRequestSchema; + output: typeof AddProjectResponseSchema; + }, + /** + * Removes a project from the registry. The directory is left untouched. + * + * @generated from rpc stackpanel.agent.v1.ProjectService.RemoveProject + */ + removeProject: { + methodKind: "unary"; + input: typeof RemoveProjectRequestSchema; + output: typeof RemoveProjectResponseSchema; + }, + /** + * Sets the project used for requests that carry no Stackpanel-Project + * header, such as CLI and TUI calls. + * + * @generated from rpc stackpanel.agent.v1.ProjectService.SetDefaultProject + */ + setDefaultProject: { + methodKind: "unary"; + input: typeof SetDefaultProjectRequestSchema; + output: typeof SetDefaultProjectResponseSchema; + }, +}>; + diff --git a/packages/proto/gen/ts/stackpanel/agent/v1/project_pb.js b/packages/proto/gen/ts/stackpanel/agent/v1/project_pb.js new file mode 100644 index 00000000..7b3b4bb8 --- /dev/null +++ b/packages/proto/gen/ts/stackpanel/agent/v1/project_pb.js @@ -0,0 +1,90 @@ +// @generated by protoc-gen-es v2.7.0 +// @generated from file stackpanel/agent/v1/project.proto (package stackpanel.agent.v1, syntax proto3) +/* eslint-disable */ + +import { fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2"; +import { file_google_protobuf_timestamp } from "@bufbuild/protobuf/wkt"; + +/** + * Describes the file stackpanel/agent/v1/project.proto. + */ +export const file_stackpanel_agent_v1_project = /*@__PURE__*/ + fileDesc("CiFzdGFja3BhbmVsL2FnZW50L3YxL3Byb2plY3QucHJvdG8SE3N0YWNrcGFuZWwuYWdlbnQudjEijAEKB1Byb2plY3QSCgoCaWQYASABKAkSDAoEbmFtZRgCIAEoCRIMCgRwYXRoGAMgASgJEjIKDmxhc3Rfb3BlbmVkX2F0GAQgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBINCgV2YWxpZBgFIAEoCBIWCg5pbnZhbGlkX3JlYXNvbhgGIAEoCSIVChNMaXN0UHJvamVjdHNSZXF1ZXN0ImIKFExpc3RQcm9qZWN0c1Jlc3BvbnNlEi4KCHByb2plY3RzGAEgAygLMhwuc3RhY2twYW5lbC5hZ2VudC52MS5Qcm9qZWN0EhoKEmRlZmF1bHRfcHJvamVjdF9pZBgCIAEoCSIhChFBZGRQcm9qZWN0UmVxdWVzdBIMCgRwYXRoGAEgASgJIkMKEkFkZFByb2plY3RSZXNwb25zZRItCgdwcm9qZWN0GAEgASgLMhwuc3RhY2twYW5lbC5hZ2VudC52MS5Qcm9qZWN0IioKFFJlbW92ZVByb2plY3RSZXF1ZXN0EhIKCnByb2plY3RfaWQYASABKAkiFwoVUmVtb3ZlUHJvamVjdFJlc3BvbnNlIi4KGFNldERlZmF1bHRQcm9qZWN0UmVxdWVzdBISCgpwcm9qZWN0X2lkGAEgASgJIhsKGVNldERlZmF1bHRQcm9qZWN0UmVzcG9uc2UytQMKDlByb2plY3RTZXJ2aWNlEmgKDExpc3RQcm9qZWN0cxIoLnN0YWNrcGFuZWwuYWdlbnQudjEuTGlzdFByb2plY3RzUmVxdWVzdBopLnN0YWNrcGFuZWwuYWdlbnQudjEuTGlzdFByb2plY3RzUmVzcG9uc2UiA5ACARJdCgpBZGRQcm9qZWN0EiYuc3RhY2twYW5lbC5hZ2VudC52MS5BZGRQcm9qZWN0UmVxdWVzdBonLnN0YWNrcGFuZWwuYWdlbnQudjEuQWRkUHJvamVjdFJlc3BvbnNlEmYKDVJlbW92ZVByb2plY3QSKS5zdGFja3BhbmVsLmFnZW50LnYxLlJlbW92ZVByb2plY3RSZXF1ZXN0Giouc3RhY2twYW5lbC5hZ2VudC52MS5SZW1vdmVQcm9qZWN0UmVzcG9uc2UScgoRU2V0RGVmYXVsdFByb2plY3QSLS5zdGFja3BhbmVsLmFnZW50LnYxLlNldERlZmF1bHRQcm9qZWN0UmVxdWVzdBouLnN0YWNrcGFuZWwuYWdlbnQudjEuU2V0RGVmYXVsdFByb2plY3RSZXNwb25zZUJWWlRnaXRodWIuY29tL2RhcmttYXR0ZXIvc3RhY2twYW5lbC9wYWNrYWdlcy9wcm90by9nZW4vZ29wYi9zdGFja3BhbmVsL2FnZW50L3YxO2FnZW50djFiBnByb3RvMw", [file_google_protobuf_timestamp]); + +/** + * Describes the message stackpanel.agent.v1.Project. + * Use `create(ProjectSchema)` to create a new message. + */ +export const ProjectSchema = /*@__PURE__*/ + messageDesc(file_stackpanel_agent_v1_project, 0); + +/** + * Describes the message stackpanel.agent.v1.ListProjectsRequest. + * Use `create(ListProjectsRequestSchema)` to create a new message. + */ +export const ListProjectsRequestSchema = /*@__PURE__*/ + messageDesc(file_stackpanel_agent_v1_project, 1); + +/** + * Describes the message stackpanel.agent.v1.ListProjectsResponse. + * Use `create(ListProjectsResponseSchema)` to create a new message. + */ +export const ListProjectsResponseSchema = /*@__PURE__*/ + messageDesc(file_stackpanel_agent_v1_project, 2); + +/** + * Describes the message stackpanel.agent.v1.AddProjectRequest. + * Use `create(AddProjectRequestSchema)` to create a new message. + */ +export const AddProjectRequestSchema = /*@__PURE__*/ + messageDesc(file_stackpanel_agent_v1_project, 3); + +/** + * Describes the message stackpanel.agent.v1.AddProjectResponse. + * Use `create(AddProjectResponseSchema)` to create a new message. + */ +export const AddProjectResponseSchema = /*@__PURE__*/ + messageDesc(file_stackpanel_agent_v1_project, 4); + +/** + * Describes the message stackpanel.agent.v1.RemoveProjectRequest. + * Use `create(RemoveProjectRequestSchema)` to create a new message. + */ +export const RemoveProjectRequestSchema = /*@__PURE__*/ + messageDesc(file_stackpanel_agent_v1_project, 5); + +/** + * Describes the message stackpanel.agent.v1.RemoveProjectResponse. + * Use `create(RemoveProjectResponseSchema)` to create a new message. + */ +export const RemoveProjectResponseSchema = /*@__PURE__*/ + messageDesc(file_stackpanel_agent_v1_project, 6); + +/** + * Describes the message stackpanel.agent.v1.SetDefaultProjectRequest. + * Use `create(SetDefaultProjectRequestSchema)` to create a new message. + */ +export const SetDefaultProjectRequestSchema = /*@__PURE__*/ + messageDesc(file_stackpanel_agent_v1_project, 7); + +/** + * Describes the message stackpanel.agent.v1.SetDefaultProjectResponse. + * Use `create(SetDefaultProjectResponseSchema)` to create a new message. + */ +export const SetDefaultProjectResponseSchema = /*@__PURE__*/ + messageDesc(file_stackpanel_agent_v1_project, 8); + +/** + * ProjectService manages the agent's registry of known projects, stored in + * ~/.config/stackpanel/stackpanel.yaml. + * + * It is the one service that does not run inside a project. Every other + * service resolves its project per request from the Stackpanel-Project + * header, falling back to the registry's default project (ADR 0005), so + * "opening" a project is a client-side choice, not agent state. + * + * @generated from service stackpanel.agent.v1.ProjectService + */ +export const ProjectService = /*@__PURE__*/ + serviceDesc(file_stackpanel_agent_v1_project, 0); + diff --git a/packages/proto/proto/stackpanel/agent/v1/agent.proto b/packages/proto/proto/stackpanel/agent/v1/agent.proto new file mode 100644 index 00000000..1667dfc8 --- /dev/null +++ b/packages/proto/proto/stackpanel/agent/v1/agent.proto @@ -0,0 +1,32 @@ +syntax = "proto3"; + +package stackpanel.agent.v1; + +option go_package = "github.com/darkmatter/stackpanel/packages/proto/gen/gopb/stackpanel/agent/v1;agentv1"; + +// AgentService describes the running agent itself. +// +// The Studio is deployed continuously while each user's agent is pinned by +// their flake, so every Studio build talks to agents of many versions. +// Clients call GetAgentInfo when they connect and decide which features to +// offer from the reported capabilities, instead of failing on Unimplemented +// (ADR 0004). +service AgentService { + rpc GetAgentInfo(GetAgentInfoRequest) returns (GetAgentInfoResponse) { + option idempotency_level = NO_SIDE_EFFECTS; + } +} + +message GetAgentInfoRequest {} + +message GetAgentInfoResponse { + // Build version of the agent binary (the value `stack version` prints). + string version = 1; + + // Procedures this agent serves, as Connect procedure paths such as + // "/stackpanel.agent.v1.ProjectService/ListProjects". Listing procedures + // rather than services also covers RPCs added to an existing service in a + // newer agent. Clients must treat any procedure not listed here as + // unavailable, and must ignore entries they do not recognize. + repeated string procedures = 2; +} diff --git a/packages/proto/proto/stackpanel/agent/v1/project.proto b/packages/proto/proto/stackpanel/agent/v1/project.proto new file mode 100644 index 00000000..cdf01083 --- /dev/null +++ b/packages/proto/proto/stackpanel/agent/v1/project.proto @@ -0,0 +1,83 @@ +syntax = "proto3"; + +package stackpanel.agent.v1; + +import "google/protobuf/timestamp.proto"; + +option go_package = "github.com/darkmatter/stackpanel/packages/proto/gen/gopb/stackpanel/agent/v1;agentv1"; + +// ProjectService manages the agent's registry of known projects, stored in +// ~/.config/stackpanel/stackpanel.yaml. +// +// It is the one service that does not run inside a project. Every other +// service resolves its project per request from the Stackpanel-Project +// header, falling back to the registry's default project (ADR 0005), so +// "opening" a project is a client-side choice, not agent state. +service ProjectService { + rpc ListProjects(ListProjectsRequest) returns (ListProjectsResponse) { + option idempotency_level = NO_SIDE_EFFECTS; + } + + // Validates the directory at path and adds it to the registry. Adding a + // path that is already registered returns the existing project. + // Fails with InvalidArgument when path is not a stackpanel project. + rpc AddProject(AddProjectRequest) returns (AddProjectResponse); + + // Removes a project from the registry. The directory is left untouched. + rpc RemoveProject(RemoveProjectRequest) returns (RemoveProjectResponse); + + // Sets the project used for requests that carry no Stackpanel-Project + // header, such as CLI and TUI calls. + rpc SetDefaultProject(SetDefaultProjectRequest) returns (SetDefaultProjectResponse); +} + +message Project { + // Stable identifier derived from the project path; the value clients send + // in the Stackpanel-Project header. + string id = 1; + + // Display name. + string name = 2; + + // Absolute path to the project root (the directory containing .stack/). + string path = 3; + + google.protobuf.Timestamp last_opened_at = 4; + + // False when the directory no longer exists or is no longer a valid + // stackpanel project; invalid_reason then says why. + bool valid = 5; + string invalid_reason = 6; +} + +message ListProjectsRequest {} + +message ListProjectsResponse { + // Most recently opened first. + repeated Project projects = 1; + + // Empty when no default project is set. + string default_project_id = 2; +} + +message AddProjectRequest { + // Absolute path to the project root. + string path = 1; +} + +message AddProjectResponse { + Project project = 1; +} + +message RemoveProjectRequest { + string project_id = 1; +} + +message RemoveProjectResponse {} + +message SetDefaultProjectRequest { + // Empty clears the default. + string project_id = 1; +} + +message SetDefaultProjectResponse {} From 62801a8046af02644a5c4851af5305431b081f01 Mon Sep 17 00:00:00 2001 From: cooper Date: Wed, 23 Sep 2026 06:31:28 -0700 Subject: [PATCH 2/2] fix(agent): cap v1 Connect request size before auth connect-go reads and gzip-decompresses a unary request message before any interceptor runs, and it applies no size limit by default. The v1 handlers set none, so a caller without a token could make the agent buffer an arbitrarily large, or highly compressed, payload before the auth interceptor rejected it. GetAgentInfo and ListProjects are NO_SIDE_EFFECTS and accept GET, so a web page could send such a payload in the query string without an Origin header. Limit v1 request messages to 2 MiB, the cap REST applies to request bodies. The limit covers the bytes read, the decompressed message and GET query messages, and connect answers ResourceExhausted before auth or the handler runs. Refs: stackpanel-thq.8.3 --- .../internal/agent/server/connect_v1.go | 4 + .../internal/agent/server/connect_v1_test.go | 82 +++++++++++++++++++ 2 files changed, 86 insertions(+) diff --git a/apps/stackpanel-go/internal/agent/server/connect_v1.go b/apps/stackpanel-go/internal/agent/server/connect_v1.go index 1a7b3057..77784004 100644 --- a/apps/stackpanel-go/internal/agent/server/connect_v1.go +++ b/apps/stackpanel-go/internal/agent/server/connect_v1.go @@ -31,6 +31,9 @@ type v1Route struct { handler http.Handler } +// Connect reads and decompresses the body before interceptors run, so the limit must apply before auth. +const v1ReadMaxBytes = 2 << 20 // same 2 MiB cap as REST request bodies + // newV1Routes builds every v1 service behind one interceptor chain and // reports exactly the procedures they serve through GetAgentInfo. func newV1Routes( @@ -39,6 +42,7 @@ func newV1Routes( projects agentv1connect.ProjectServiceHandler, ) []v1Route { opts := connect.WithHandlerOptions( + connect.WithReadMaxBytes(v1ReadMaxBytes), connect.WithInterceptors(authInterceptor{valid: validToken}), connect.WithRecover(recoverRPC), ) diff --git a/apps/stackpanel-go/internal/agent/server/connect_v1_test.go b/apps/stackpanel-go/internal/agent/server/connect_v1_test.go index 9c6efbc7..f516067d 100644 --- a/apps/stackpanel-go/internal/agent/server/connect_v1_test.go +++ b/apps/stackpanel-go/internal/agent/server/connect_v1_test.go @@ -2,9 +2,12 @@ package server import ( "context" + "io" "net/http" "net/http/httptest" "slices" + "strings" + "sync/atomic" "testing" "connectrpc.com/connect" @@ -82,3 +85,82 @@ func TestV1ProceduresRequireBearerToken(t *testing.T) { }) } } + +// recordingProjects is a ProjectService whose AddProject records that it ran, +// so a request rejected before the handler is distinguishable from one that +// reached it. +type recordingProjects struct { + agentv1connect.UnimplementedProjectServiceHandler + called atomic.Bool +} + +func (p *recordingProjects) AddProject( + context.Context, + *connect.Request[agentv1.AddProjectRequest], +) (*connect.Response[agentv1.AddProjectResponse], error) { + p.called.Store(true) + return connect.NewResponse(&agentv1.AddProjectResponse{}), nil +} + +// countingBody counts the request bytes the server reads off the wire. +type countingBody struct { + io.ReadCloser + n *atomic.Int64 +} + +func (b countingBody) Read(p []byte) (int, error) { + n, err := b.ReadCloser.Read(p) + b.n.Add(int64(n)) + return n, err +} + +func TestV1RejectsOversizedRequestsBeforeAuth(t *testing.T) { + var authRan atomic.Bool + var wireBytes atomic.Int64 + projects := &recordingProjects{} + mux := http.NewServeMux() + routes := newV1Routes("1.2.3", func(string) bool { authRan.Store(true); return false }, projects) + for _, r := range routes { + mux.Handle(r.path, http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + req.Body = countingBody{req.Body, &wireBytes} + r.handler.ServeHTTP(w, req) + })) + } + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + + // Just over the limit, and compressible enough that the gzip case sends a + // few KiB that only inflate past it on the server. + path := strings.Repeat("a", v1ReadMaxBytes+1024) + cases := []struct { + name string + opts []connect.ClientOption + maxWireBytes int64 + }{ + {"uncompressed", nil, 0}, + {"gzip inflates past the limit", []connect.ClientOption{connect.WithSendGzip()}, 64 << 10}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + wireBytes.Store(0) + client := agentv1connect.NewProjectServiceClient(srv.Client(), srv.URL, tc.opts...) + // An invalid token: if auth ran first, the call would fail with + // Unauthenticated instead. + req := connect.NewRequest(&agentv1.AddProjectRequest{Path: path}) + req.Header().Set("Authorization", "Bearer bad") + _, err := client.AddProject(context.Background(), req) + if got := connect.CodeOf(err); got != connect.CodeResourceExhausted { + t.Fatalf("code = %v, want %v (err: %v)", got, connect.CodeResourceExhausted, err) + } + if tc.maxWireBytes > 0 && wireBytes.Load() > tc.maxWireBytes { + t.Errorf("sent %d bytes; the gzip case must fail on the inflated size", wireBytes.Load()) + } + }) + } + if authRan.Load() { + t.Error("token validation ran for an oversized request") + } + if projects.called.Load() { + t.Error("AddProject ran for an oversized request") + } +}