Skip to content
Merged
33 changes: 27 additions & 6 deletions cmd/shellwatch/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,11 +102,14 @@ func run() error {
flusher := store.NewLastUsedFlusher(db, clk)
go flusher.Run(ctx, time.Minute)

// First-run seeding (admin account + passkeys + endpoints) + inactive-account
// cleanup.
if res, err := seed.FromConfig(ctx, db, cfg, newUUID, clk.Now()); err != nil {
slog.Warn("first-run seeding failed", "err", err)
} else if res.SeededAdminAccount || res.SeededAdminPasskey {
// First-run seeding (admin account + passkeys + endpoints). Fatal on
// failure like the Node boot (index.ts:43) — continuing without an admin
// account leaves a fresh deployment silently un-loginable.
res, err := seed.FromConfig(ctx, db, cfg, newUUID, clk.Now())
if err != nil {
return fmt.Errorf("first-run seeding failed: %w", err)
}
if res.SeededAdminAccount || res.SeededAdminPasskey {
slog.Info("seeded from config", "adminAccount", res.SeededAdminAccount, "adminPasskey", res.SeededAdminPasskey)
}
webauthnDeps := &webauthn.Deps{
Expand Down Expand Up @@ -143,6 +146,16 @@ func run() error {
RpID: cfg.Security.RpID,
Origin: firstOrigin(cfg.Security.TrustedWebauthnOrigins),
NewConnectionID: newUUID,
// Dead terminal connection -> cancel its stranded sign prompts and
// clear the toasts (#91). signBroker resolves lazily like BrokerFunc.
OnConnectionEnded: func(connID, reason string) {
if signBroker == nil {
return
}
if n := signBroker.CancelForConnection(connID, reason); n > 0 {
slog.Info("cancelled pending sign prompts for dead connection", "connection", connID, "count", n)
}
},
})
manager := terminal.NewManager(factory, clk, 0)
// Idle janitor: auto-close sessions idle >30 min (Node parity, H6).
Expand Down Expand Up @@ -175,11 +188,14 @@ func run() error {
auditWriter.AttachManager(manager, manager.GetSession)
auditWriter.AttachStore(actionStore)

buildInfo := buildinfo.Load(mustGetwd())
mcpDeps := &mcp.Deps{
AgentDeps: agent.Deps{Manager: manager, Endpoints: endpointStore, Demo: demoSvc},
Keys: store.NewSSHKeys(db),
NewID: newUUID,
Version: buildInfo.Display,
SessionTimeout: time.Duration(*cfg.Mcp.SessionTimeoutMinutes) * time.Minute,
MaxOwned: store.NewAccounts(db).MaxSessions,
}

// Account-deleted teardown (app.ts accountLifecycle "deleted", #217): close
Expand All @@ -190,6 +206,11 @@ func run() error {
if n := manager.CloseAllForAccount(accountID, terminal.CloseAccountDeleted); n > 0 {
slog.Info("closed sessions for deleted account", "account", accountID, "count", n)
}
// Purge retained post-mortem sessions too — a deleted account's
// terminal output must not stay readable in memory.
if n := manager.RemoveForAccount(accountID); n > 0 {
slog.Info("purged retained sessions for deleted account", "account", accountID, "count", n)
}
if n := mcpDeps.DropAccount(accountID); n > 0 {
slog.Info("tore down MCP transports for deleted account", "account", accountID, "count", n)
}
Expand All @@ -216,7 +237,7 @@ func run() error {
Resolve: resolve,
TouchLastUsed: flusher.Touch,
StaticFS: staticFS,
BuildInfo: buildinfo.Load(mustGetwd()),
BuildInfo: buildInfo,
WebAuthn: webauthnDeps,
HydraAdmin: admin,
HasPasskeys: func() bool {
Expand Down
26 changes: 18 additions & 8 deletions internal/agent/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,11 @@ type Session struct {
}

// New builds an AgentSession for an account. maxOwned caps concurrent owned
// sessions (default 5).
// sessions: negative means "unresolved, use the default 5"; an explicit 0
// blocks all creation (Node honors a stored max_sessions of 0, and so does
// the REST cap check — MCP must not silently upgrade 0 to 5).
func New(deps Deps, accountID, sourceIP string, maxOwned int) *Session {
if maxOwned <= 0 {
if maxOwned < 0 {
maxOwned = 5
}
return &Session{deps: deps, accountID: accountID, sourceIP: sourceIP, maxOwned: maxOwned, owned: map[string]bool{}}
Expand Down Expand Up @@ -156,16 +158,24 @@ func (s *Session) CreateSession(ctx context.Context, endpointID, reason string)
return nil, fmt.Errorf("unknown endpoint: %s", endpointID)
}
s.mu.Lock()
// Prune ids the manager no longer knows (idle-timeout janitor, server
// hangup, account cleanup) before enforcing the cap. Deliberate divergence
// from Node, which counts the raw set: there, N externally-closed sessions
// permanently starve the cap until the agent reconnects.
// The cap counts only LIVE sessions — deliberate divergence from Node,
// which counts its raw owned set and so lets N externally-closed sessions
// (idle janitor, server hangup) starve the cap until the agent reconnects.
// Dead-but-retained ids (post-mortem closed/error sessions, M6) stay in
// owned so read_output/close_session keep working on them; only ids the
// manager has forgotten entirely are dropped.
live := 0
for id := range s.owned {
if s.deps.Manager.GetSession(id) == nil {
sess := s.deps.Manager.GetSession(id)
if sess == nil {
delete(s.owned, id)
continue
}
if sess.Status != terminal.StatusClosed && sess.Status != terminal.StatusError {
live++
}
}
if len(s.owned) >= s.maxOwned {
if live >= s.maxOwned {
s.mu.Unlock()
return nil, fmt.Errorf("maximum concurrent sessions (%d) reached", s.maxOwned)
}
Expand Down
95 changes: 80 additions & 15 deletions internal/agent/session_cap_test.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
// SPDX-License-Identifier: LicenseRef-FSL-1.1-Apache-2.0
// The maxOwned cap must not count sessions the manager already closed (idle
// The maxOwned cap must not count sessions that died under the agent (idle
// janitor, server hangup) — deliberate divergence from Node, whose raw-set
// count starves the cap after N external closes.
// count starves the cap after N external closes. Post-mortem retained
// sessions (M6) stay OWNED (read_output keeps working); they just don't
// count toward the cap.
package agent

import (
"context"
"sync"
"testing"
"time"

Expand All @@ -26,18 +29,25 @@ func TestCapIgnoresExternallyClosedSessions(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')`)

// Keep every mock so the test can kill transports out from under the
// manager (server hangup), not via explicit close.
var mu sync.Mutex
var mocks []*terminal.MockTransport
mgr := terminal.NewManager(
func(context.Context, terminal.FactoryParams) (terminal.Transport, error) {
return terminal.NewMockTransport(), nil
m := terminal.NewMockTransport()
mu.Lock()
mocks = append(mocks, m)
mu.Unlock()
return m, nil
}, clock.Real{}, 0)
eps := store.NewEndpoints(db, clock.Real{})
sess := New(Deps{Manager: mgr, 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)
}

// Fill the cap (2), then have the MANAGER close both — simulating the
// idle-timeout janitor / a server hangup, not an agent-initiated close.
// Fill the cap (2), write some output, then hang up both transports.
var ids []string
for i := 0; i < 2; i++ {
s, err := sess.CreateSession(ctx, "ep1", "cap test")
Expand All @@ -46,25 +56,80 @@ func TestCapIgnoresExternallyClosedSessions(t *testing.T) {
}
ids = append(ids, s.SessionID)
}
if err := sess.SendKeys(ids[0], []string{"text:hello"}); err != nil {
t.Fatal(err)
}
if _, err := sess.CreateSession(ctx, "ep1", "over cap"); err == nil {
t.Fatal("cap should be enforced while sessions are live")
}
for _, id := range ids {
mgr.Close(id, terminal.CloseIdleTimeout)
mu.Lock()
for _, m := range mocks {
_ = m.Close()
}
mu.Unlock()
waitForStatus(t, mgr, ids[0], terminal.StatusClosed)
waitForStatus(t, mgr, ids[1], terminal.StatusClosed)

// The agent regains full capacity: dead sessions don't count...
for i := 0; i < 2; i++ {
if _, err := sess.CreateSession(ctx, "ep1", "after hangup"); err != nil {
t.Fatalf("create after external close %d: %v", i, err)
}
}
// ...but the dead ids stay OWNED: post-mortem output remains readable
// through the agent even after new creates pruned the live count.
r, err := sess.ReadOutput(ids[0], 0, 100)
if err != nil {
t.Fatalf("post-mortem read on retained session: %v", err)
}
// Close is async through the transport pump; wait for the registry to drop them.
if string(r.Data) != "hello" {
t.Fatalf("post-mortem read: %q", r.Data)
}
if err := sess.CloseSession(ids[0]); err != nil {
t.Fatalf("close_session on retained session: %v", err)
}
}

func waitForStatus(t *testing.T, mgr *terminal.Manager, id string, want terminal.Status) {
t.Helper()
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if len(mgr.ListForAccount("acc")) == 0 {
break
if s := mgr.GetSession(id); s != nil && s.Status == want {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("session %s never reached %s", id, want)
}

// The agent must regain full capacity: stale ids may not starve the cap.
for i := 0; i < 2; i++ {
if _, err := sess.CreateSession(ctx, "ep1", "after janitor"); err != nil {
t.Fatalf("create after external close %d: %v", i, err)
}
// max_sessions = 0 blocks every create (Node: 0 is honored, not defaulted;
// the Go REST path also 429s at 0 — MCP must match).
func TestCapZeroBlocksCreation(t *testing.T) {
db, err := store.Open("sqlite::memory:")
if err != nil {
t.Fatal(err)
}
defer db.Close()
if err := store.Migrate(db); err != nil {
t.Fatal(err)
}
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')`)
mgr := terminal.NewManager(
func(context.Context, terminal.FactoryParams) (terminal.Transport, error) {
return terminal.NewMockTransport(), nil
}, clock.Real{}, 0)
eps := store.NewEndpoints(db, clock.Real{})
sess := New(Deps{Manager: mgr, 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)
if _, err := sess2.CreateSession(ctx, "ep1", "default cap"); err != nil {
t.Fatalf("default-cap create: %v", err)
}
}
9 changes: 7 additions & 2 deletions internal/agent/session_endpoint_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,12 @@ func TestSessionEndpointMutations(t *testing.T) {
if err != nil || !ok {
t.Fatalf("delete: ok=%v err=%v", ok, err)
}
if ep, _ := sess.GetEndpoint(ctx, "ep1"); ep != nil {
t.Error("endpoint still present after delete")
// Soft delete (Node parity): hidden from the list, but get-by-id still
// resolves — endpoint-repo.ts filters enabled only in findAllForAccount.
if eps, _ := sess.ListEndpoints(ctx); len(eps) != 0 {
t.Errorf("deleted endpoint still listed: %+v", eps)
}
if ep, _ := sess.GetEndpoint(ctx, "ep1"); ep == nil {
t.Error("get-by-id must still resolve a soft-deleted endpoint (Node parity)")
}
}
4 changes: 2 additions & 2 deletions internal/agentproxy/route.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,8 @@ func (d *Deps) Handler() http.HandlerFunc {
ClientVersion: util.SanitizeClientReported(r.Header.Get("X-ShellWatch-Version")),
})

// Cancel stranded approvals when the connection ends.
defer d.Broker.Store().CancelForConnection(connID, "agent-proxy connection closed")
// Cancel stranded approvals when the connection ends (+ clear toasts).
defer d.Broker.CancelForConnection(connID, "agent-proxy connection closed")

rw := newWSReadWriter(ctx, wsc)
_ = agent.ServeAgent(ba, rw) // returns on I/O error (client disconnect)
Expand Down
14 changes: 14 additions & 0 deletions internal/approval/broker.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ var (
ErrDenied = errors.New("signing request denied")
// ErrExpired is returned when the action TTL elapses.
ErrExpired = errors.New("signing request expired")
// ErrCancelled is returned when the owning connection died.
ErrCancelled = errors.New("signing request cancelled")
)

// Channel delivers an action to a notification surface (WS toast, push).
Expand Down Expand Up @@ -112,6 +114,18 @@ func (b *Broker) RequestKeyApproval(ctx context.Context, accountID, keyLabel, ke
}
}

// CancelForConnection cancels a dead connection's pending actions AND clears
// their toasts on every channel (index.ts:112-114, M9). An awaiter unblocked
// by the cancel's reject may also notify — a duplicate sign:resolved for the
// same action id is an idempotent toast removal client-side.
func (b *Broker) CancelForConnection(connectionID, reason string) int {
cancelled := b.store.CancelForConnection(connectionID, reason)
for _, a := range cancelled {
b.notifyResolved(a)
}
return len(cancelled)
}

func (b *Broker) notifyResolved(a *Action) {
for _, ch := range b.channels {
ch.Resolved(a)
Expand Down
30 changes: 24 additions & 6 deletions internal/approval/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -261,9 +261,15 @@ func (s *Store) Deny(id string) bool {
}

// CancelForConnection denies every pending action for a dead SSH connection
// (fix for #91: stranded prompts don't outlive the session). The reject
// closure is NOT called — the awaiter is already gone.
func (s *Store) CancelForConnection(connectionID, reason string) int {
// (fix for #91: stranded prompts don't outlive the session). Unlike Node —
// where an unresolved promise is simply garbage-collected — the reject
// closure MUST be called here: a terminal-path forwarding awaiter blocks on
// the broker with context.Background() (passkey_factory.go), so a cancel
// that never rejects would strand that goroutine forever. The error channels
// are buffered, so rejecting an awaiter that already left (agent-proxy's
// request context cancelled first) is harmless. Returns the cancelled
// actions so the caller can clear their toasts (Broker.CancelForConnection).
func (s *Store) CancelForConnection(connectionID, reason string) []*Action {
s.mu.Lock()
var cancelled []*Action
for _, a := range s.actions {
Expand All @@ -274,21 +280,33 @@ func (s *Store) CancelForConnection(connectionID, reason string) int {
}
s.mu.Unlock()
for _, a := range cancelled {
if a.reject != nil {
a.reject(ErrCancelled)
}
s.emitResolved(a, OutcomeCancelled, reason)
}
return len(cancelled)
return cancelled
}

// Sweep expires overdue pending actions (janitor).
// cleanupGrace keeps terminal-state actions fetchable for status polling
// before Sweep deletes them (store.ts:143-145).
const cleanupGrace = 120 * time.Second

// Sweep expires overdue pending actions and deletes terminal-state actions
// older than the grace window (janitor) — without the delete the store grows
// forever and resolved actions stay fetchable indefinitely.
func (s *Store) Sweep() {
now := s.clk.Now()
s.mu.Lock()
var expired []*Action
for _, a := range s.actions {
for id, a := range s.actions {
if a.Status == StatusPending && !a.ExpiresAt.After(now) {
a.Status = StatusExpired
expired = append(expired, a)
}
if a.Status != StatusPending && now.Sub(a.ExpiresAt) > cleanupGrace {
delete(s.actions, id)
}
}
s.mu.Unlock()
for _, a := range expired {
Expand Down
Loading