Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions cmd/shellwatch/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import (
"github.com/rado0x54/shellwatch/internal/clock"
"github.com/rado0x54/shellwatch/internal/config"
"github.com/rado0x54/shellwatch/internal/demo"
"github.com/rado0x54/shellwatch/internal/endpointsvc"
"github.com/rado0x54/shellwatch/internal/httpserver"
"github.com/rado0x54/shellwatch/internal/hydra"
"github.com/rado0x54/shellwatch/internal/mcp"
Expand Down Expand Up @@ -124,6 +125,7 @@ func run() error {

endpointStore := store.NewEndpoints(db, clk)
demoSvc := demo.NewService(cfg.DemoEndpoints)
epSvc := &endpointsvc.Service{Endpoints: endpointStore, Demo: demoSvc}
credStore := store.NewCredentials(db, clk)
keyDir := sshx.NewKeyDir(cfg.KeyDirectory)
// Discover file keys into ssh_keys + watch the directory for changes so
Expand Down Expand Up @@ -190,7 +192,7 @@ func run() error {

buildInfo := buildinfo.Load(mustGetwd())
mcpDeps := &mcp.Deps{
AgentDeps: agent.Deps{Manager: manager, Endpoints: endpointStore, Demo: demoSvc},
AgentDeps: agent.Deps{Manager: manager, Svc: epSvc},
Keys: store.NewSSHKeys(db),
NewID: newUUID,
Version: buildInfo.Display,
Expand Down Expand Up @@ -245,15 +247,13 @@ func run() error {
return has
},
Endpoints: &rest.Endpoints{
Store: endpointStore,
Demo: demoSvc,
Svc: epSvc,
Sessions: manager,
NewID: newUUID,
},
Sessions: &rest.Sessions{
Manager: manager,
Endpoints: endpointStore,
Demo: demoSvc,
Svc: epSvc,
MaxSessions: store.NewAccounts(db).MaxSessions,
},
WSHub: wsHub,
Expand Down
7 changes: 7 additions & 0 deletions docs/go-backend-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,13 @@ constraint is now one greppable package.
evolving (re-enable `chi-server`/`strict-server` in
`docs/api/oapi-codegen.yaml`; the models are already in use, so the
remaining migration is handler signatures).
- `internal/endpointsvc` is the shared endpoint read/resolution layer:
account-scoped, demo-aware lookup (list merge honoring the visibility
toggle, get-by-id regardless of toggle, `terminal.EndpointRef` resolution
for session creation) plus the field constraints (`userVerification`
enum, description cap) that both REST and MCP validation enforce. REST
handlers (`rest.Endpoints`/`rest.Sessions`) and the agent session
(`agent.Deps.Svc`) consume it — the resolution rules exist once.
- Middleware stack (chi): request-ID → slog access log → IP allowlist (path-
scoped, `/mcp`) → bearer gate (§5.9) → handlers. `/ws`, `/mcp`,
`/agent-proxy`, Hydra provider pages, and static files mount beside the
Expand Down
48 changes: 13 additions & 35 deletions internal/agent/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,16 @@ import (
"fmt"
"sync"

"github.com/rado0x54/shellwatch/internal/demo"
"github.com/rado0x54/shellwatch/internal/endpointsvc"
"github.com/rado0x54/shellwatch/internal/store"
"github.com/rado0x54/shellwatch/internal/terminal"
"github.com/rado0x54/shellwatch/internal/util"
)

// Deps are the collaborators an AgentSession needs.
type Deps struct {
Manager *terminal.Manager
Endpoints *store.Endpoints
Demo *demo.Service
Manager *terminal.Manager
Svc *endpointsvc.Service
}

// Session isolates one agent connection's terminal sessions.
Expand Down Expand Up @@ -68,14 +67,10 @@ type EndpointInfo struct {

// ListEndpoints returns the account's endpoints (+ demo when visible).
func (s *Session) ListEndpoints(ctx context.Context) ([]EndpointInfo, error) {
own, err := s.deps.Endpoints.ListForAccount(ctx, s.accountID)
merged, err := s.deps.Svc.ListForAccount(ctx, s.accountID)
if err != nil {
return nil, err
}
merged := own
if show, _ := s.deps.Endpoints.ShowDemoEndpoints(ctx, s.accountID); show && s.deps.Demo != nil {
merged = append(merged, s.deps.Demo.List(s.accountID)...)
}
out := make([]EndpointInfo, 0, len(merged))
for _, e := range merged {
out = append(out, EndpointInfo{ID: e.ID, Label: e.Label, Host: e.Host, Port: e.Port, Username: e.Username, Description: e.Description})
Expand All @@ -85,23 +80,18 @@ func (s *Session) ListEndpoints(ctx context.Context) ([]EndpointInfo, error) {

// GetEndpoint returns a full endpoint scoped to the account (nil when absent).
func (s *Session) GetEndpoint(ctx context.Context, id string) (*store.Endpoint, error) {
if demo.IsID(id) && s.deps.Demo != nil {
for _, e := range s.deps.Demo.List(s.accountID) {
if e.ID == id {
e := e
return &e, nil
}
}
return nil, nil
ep, err := s.deps.Svc.GetForAccount(ctx, id, s.accountID)
if err != nil || ep == nil {
return nil, err
}
return s.deps.Endpoints.GetForAccount(ctx, id, s.accountID)
return &ep.Endpoint, nil
}

// CreateEndpoint creates an account-scoped endpoint (MCP path — the caller
// supplies the id, unlike REST which mints one).
func (s *Session) CreateEndpoint(ctx context.Context, ep store.Endpoint) error {
ep.AccountID = s.accountID
return s.deps.Endpoints.Create(ctx, ep)
return s.deps.Svc.Endpoints.Create(ctx, ep)
}

// EndpointPatch is a typed partial endpoint update; nil fields keep the
Expand All @@ -123,7 +113,7 @@ type EndpointPatch struct {
// and field validation (userVerification enum, description cap) happen at the
// caller (internal/mcp).
func (s *Session) UpdateEndpoint(ctx context.Context, id string, patch EndpointPatch) (bool, error) {
existing, err := s.deps.Endpoints.GetForAccount(ctx, id, s.accountID)
existing, err := s.deps.Svc.Endpoints.GetForAccount(ctx, id, s.accountID)
if err != nil || existing == nil {
return false, err
}
Expand All @@ -149,20 +139,20 @@ func (s *Session) UpdateEndpoint(ctx context.Context, id string, patch EndpointP
if patch.DescriptionSet {
merged.Description = patch.Description
}
return s.deps.Endpoints.Update(ctx, merged)
return s.deps.Svc.Endpoints.Update(ctx, merged)
}

// DeleteEndpoint removes an account-scoped endpoint. Returns false when nothing
// matched.
func (s *Session) DeleteEndpoint(ctx context.Context, id string) (bool, error) {
return s.deps.Endpoints.Delete(ctx, id, s.accountID)
return s.deps.Svc.Endpoints.Delete(ctx, id, s.accountID)
}

// CreateSession opens a session against an endpoint owned by the account. A
// foreign/unknown id always returns "Unknown endpoint" (no cross-account
// probing / spurious approval prompts).
func (s *Session) CreateSession(ctx context.Context, endpointID, reason string) (*terminal.Session, error) {
ep, err := s.resolveRef(ctx, endpointID)
ep, err := s.deps.Svc.RefForAccount(ctx, endpointID, s.accountID)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -206,18 +196,6 @@ func (s *Session) CreateSession(ctx context.Context, endpointID, reason string)
return sess, nil
}

func (s *Session) resolveRef(ctx context.Context, id string) (*terminal.EndpointRef, error) {
ep, err := s.GetEndpoint(ctx, id)
if err != nil || ep == nil {
return nil, err
}
ref := terminal.EndpointRef{
ID: ep.ID, Label: ep.Label, AccountID: ep.AccountID, Host: ep.Host, Port: int(ep.Port),
Username: ep.Username, UserVerification: ep.UserVerification, AgentForward: ep.AgentForward,
}
return &ref, nil
}

// ListSessions returns this agent's owned sessions.
func (s *Session) ListSessions() []terminal.Session {
s.mu.Lock()
Expand Down
7 changes: 4 additions & 3 deletions internal/agent/session_cap_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"time"

"github.com/rado0x54/shellwatch/internal/clock"
"github.com/rado0x54/shellwatch/internal/endpointsvc"
"github.com/rado0x54/shellwatch/internal/store"
"github.com/rado0x54/shellwatch/internal/terminal"
)
Expand Down Expand Up @@ -42,7 +43,7 @@ func TestCapIgnoresExternallyClosedSessions(t *testing.T) {
return m, nil
}, clock.Real{}, 0)
eps := store.NewEndpoints(db, clock.Real{})
sess := New(Deps{Manager: mgr, Endpoints: eps}, "acc", "", 2)
sess := New(Deps{Manager: mgr, Svc: &endpointsvc.Service{Endpoints: eps}}, "acc", "", 2)
if err := sess.CreateEndpoint(ctx, store.Endpoint{ID: "ep1", Label: "Box", Host: "h", Port: 22, Username: "u", UserVerification: "required"}); err != nil {
t.Fatal(err)
}
Expand Down Expand Up @@ -120,15 +121,15 @@ func TestCapZeroBlocksCreation(t *testing.T) {
return terminal.NewMockTransport(), nil
}, clock.Real{}, 0)
eps := store.NewEndpoints(db, clock.Real{})
sess := New(Deps{Manager: mgr, Endpoints: eps}, "acc", "", 0)
sess := New(Deps{Manager: mgr, Svc: &endpointsvc.Service{Endpoints: eps}}, "acc", "", 0)
if err := sess.CreateEndpoint(ctx, store.Endpoint{ID: "ep1", Label: "Box", Host: "h", Port: 22, Username: "u", UserVerification: "required"}); err != nil {
t.Fatal(err)
}
if _, err := sess.CreateSession(ctx, "ep1", "blocked"); err == nil {
t.Fatal("max_sessions=0 must block creation")
}
// Negative = "no cap resolved" -> default 5 still applies.
sess2 := New(Deps{Manager: mgr, Endpoints: eps}, "acc", "", -1)
sess2 := New(Deps{Manager: mgr, Svc: &endpointsvc.Service{Endpoints: eps}}, "acc", "", -1)
if _, err := sess2.CreateSession(ctx, "ep1", "default cap"); err != nil {
t.Fatalf("default-cap create: %v", err)
}
Expand Down
5 changes: 3 additions & 2 deletions internal/agent/session_endpoint_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"testing"

"github.com/rado0x54/shellwatch/internal/clock"
"github.com/rado0x54/shellwatch/internal/endpointsvc"
"github.com/rado0x54/shellwatch/internal/store"
)

Expand All @@ -21,7 +22,7 @@ func TestSessionEndpointMutations(t *testing.T) {
ctx := context.Background()
db.ExecContext(ctx, `INSERT INTO accounts (id,name,created_at,updated_at) VALUES ('acc','A','2026-01-01T00:00:00Z','2026-01-01T00:00:00Z')`)

sess := New(Deps{Endpoints: store.NewEndpoints(db, clock.Real{})}, "acc", "1.2.3.4", 5)
sess := New(Deps{Svc: &endpointsvc.Service{Endpoints: store.NewEndpoints(db, clock.Real{})}}, "acc", "1.2.3.4", 5)

// Create.
if err := sess.CreateEndpoint(ctx, store.Endpoint{ID: "ep1", Label: "Box", Host: "h", Port: 22, Username: "u", UserVerification: "required"}); err != nil {
Expand Down Expand Up @@ -65,7 +66,7 @@ func TestSessionEndpointMutations(t *testing.T) {
t.Error("update of missing endpoint should return false")
}
// Cross-account isolation: another account can't touch ep1.
other := New(Deps{Endpoints: store.NewEndpoints(db, clock.Real{})}, "acc2", "", 5)
other := New(Deps{Svc: &endpointsvc.Service{Endpoints: store.NewEndpoints(db, clock.Real{})}}, "acc2", "", 5)
if ok, _ := other.DeleteEndpoint(ctx, "ep1"); ok {
t.Error("cross-account delete should not match")
}
Expand Down
103 changes: 103 additions & 0 deletions internal/endpointsvc/endpointsvc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// SPDX-License-Identifier: LicenseRef-FSL-1.1-Apache-2.0
// Package endpointsvc is the shared endpoint read/resolution layer for REST,
// the agent session (MCP), and session creation. It owns the demo-endpoint
// merge rules and the field constraints both wire layers enforce — before it
// existed, the demo-aware resolution was hand-rolled three times and the
// validation constants twice, and the copies had already drifted once.
// Mutations (create/update/delete) go straight to Endpoints: demo entries are
// synthesized per request, never stored, and the wire layers reject demo ids
// before mutating.
package endpointsvc

import (
"context"

"github.com/rado0x54/shellwatch/internal/demo"
"github.com/rado0x54/shellwatch/internal/store"
"github.com/rado0x54/shellwatch/internal/terminal"
)

// DescriptionMaxLen caps endpoint descriptions (REST and MCP alike).
const DescriptionMaxLen = 1000

// UserVerificationValues are the accepted userVerification policies.
var UserVerificationValues = []string{"required", "preferred", "discouraged"}

// IsUserVerification reports whether v is an accepted policy.
func IsUserVerification(v string) bool {
for _, u := range UserVerificationValues {
if u == v {
return true
}
}
return false
}

// Service resolves endpoints account-scoped and demo-aware. Demo may be nil
// (no demo endpoints configured).
type Service struct {
Endpoints *store.Endpoints
Demo *demo.Service
}

// Endpoint is a resolved endpoint plus its provenance.
type Endpoint struct {
store.Endpoint
IsDemo bool
}

// ListForAccount returns the account's own endpoints, then the demo entries
// when the account's visibility toggle shows them (order pinned by the
// endpoints-list golden). A toggle read error degrades to "hidden".
func (s *Service) ListForAccount(ctx context.Context, accountID string) ([]Endpoint, error) {
own, err := s.Endpoints.ListForAccount(ctx, accountID)
if err != nil {
return nil, err
}
out := make([]Endpoint, 0, len(own))
for _, e := range own {
out = append(out, Endpoint{Endpoint: e})
}
if show, _ := s.Endpoints.ShowDemoEndpoints(ctx, accountID); show && s.Demo != nil {
for _, e := range s.Demo.List(accountID) {
out = append(out, Endpoint{Endpoint: e, IsDemo: true})
}
}
return out, nil
}

// GetForAccount resolves one endpoint scoped to the account; nil when the id
// doesn't resolve (unknown and foreign ids are indistinguishable — no
// cross-account probing). Demo ids resolve regardless of the visibility
// toggle, so a caller that already knows the id can still inspect it.
func (s *Service) GetForAccount(ctx context.Context, id, accountID string) (*Endpoint, error) {
if demo.IsID(id) {
if s.Demo != nil {
for _, e := range s.Demo.List(accountID) {
if e.ID == id {
return &Endpoint{Endpoint: e, IsDemo: true}, nil
}
}
}
return nil, nil
}
ep, err := s.Endpoints.GetForAccount(ctx, id, accountID)
if err != nil || ep == nil {
return nil, err
}
return &Endpoint{Endpoint: *ep}, nil
}

// RefForAccount resolves an endpoint into the terminal.EndpointRef that
// session creation consumes; nil when the id doesn't resolve for the account.
func (s *Service) RefForAccount(ctx context.Context, id, accountID string) (*terminal.EndpointRef, error) {
ep, err := s.GetForAccount(ctx, id, accountID)
if err != nil || ep == nil {
return nil, err
}
ref := terminal.EndpointRef{
ID: ep.ID, Label: ep.Label, AccountID: ep.AccountID, Host: ep.Host, Port: int(ep.Port),
Username: ep.Username, UserVerification: ep.UserVerification, AgentForward: ep.AgentForward,
}
return &ref, nil
}
Loading