Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
02ea6d7
docs(provisioning): define external provisioner architecture
onutc Mar 11, 2026
37d3dcb
refactor(terminology): replace spritz kind fields with type
onutc Mar 11, 2026
7066adb
feat(provisioning): add service principal provisioner flow
onutc Mar 11, 2026
6592f1d
fix(provisioning): address service principal review findings
onutc Mar 11, 2026
dc41888
fix(provisioning): address local review findings
onutc Mar 11, 2026
cff2ef5
fix(provisioning): tighten service principal policy
onutc Mar 11, 2026
2c73552
fix(provisioning): preserve preset and bearer defaults
onutc Mar 11, 2026
37c6ffe
fix(provisioning): honor default presets and retry pending ids
onutc Mar 11, 2026
42fe8ef
fix(provisioning): tighten auth and async prompt activity
onutc Mar 11, 2026
b43c754
fix(provisioning): tighten create contract semantics
onutc Mar 11, 2026
5b31f26
fix(provisioning): redact preset secrets and replay pending creates
onutc Mar 11, 2026
f36bb89
fix(provisioning): harden header auth and ssh activity
onutc Mar 11, 2026
9c4900a
fix(provisioning): tighten idempotent replay ownership
onutc Mar 11, 2026
dd07a01
fix(provisioning): scope reservations to control namespace
onutc Mar 11, 2026
5deac54
fix(provisioning): harden replay and preset discovery
onutc Mar 11, 2026
1e9d347
fix(provisioning): align control namespace and lifecycle copies
onutc Mar 11, 2026
aea1f7c
fix(provisioning): preserve pending idempotent names
onutc Mar 11, 2026
e6b58fb
fix(provisioning): align default preset flows
onutc Mar 11, 2026
632726b
feat(provisioning): enforce strict idempotency cutover
onutc Mar 11, 2026
d516b93
refactor(provisioning): extract create transaction flow
onutc Mar 11, 2026
a435d18
refactor(api): extract create normalization and reservation store
onutc Mar 11, 2026
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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ The backend inside a workspace is pluggable. OpenClaw is one example runtime. An

> Spritz is in active development and should be treated as alpha software. APIs, CRDs, Helm values, and UI details may still change while the deployment model is being hardened.

[Deployment Spec](docs/2026-02-24-simplest-spritz-deployment-spec.md) · [ACP Architecture](docs/2026-03-09-acp-port-and-agent-chat-architecture.md) · [Portable Auth](docs/2026-02-24-portable-authentication-and-account-architecture.md) · [OpenClaw Integration](OPENCLAW.md)
[Deployment Spec](docs/2026-02-24-simplest-spritz-deployment-spec.md) · [ACP Architecture](docs/2026-03-09-acp-port-and-agent-chat-architecture.md) · [Portable Auth](docs/2026-02-24-portable-authentication-and-account-architecture.md) · [External Provisioners](docs/2026-03-11-external-provisioner-and-service-principal-architecture.md) · [OpenClaw Integration](OPENCLAW.md)

## Vision

Expand Down Expand Up @@ -182,4 +182,5 @@ Spritz is intended to remain portable and standalone:
- `docs/2026-02-24-simplest-spritz-deployment-spec.md`
- `docs/2026-02-24-portable-authentication-and-account-architecture.md`
- `docs/2026-03-09-acp-port-and-agent-chat-architecture.md`
- `docs/2026-03-11-external-provisioner-and-service-principal-architecture.md`
- `OPENCLAW.md`
5 changes: 4 additions & 1 deletion api/acp_agents.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,13 @@ func (s *server) listACPAgents(c echo.Context) error {
if err := s.client.List(c.Request().Context(), list, opts...); err != nil {
return writeError(c, http.StatusInternalServerError, err.Error())
}
if err := authorizeHumanOnly(principal, s.auth.enabled()); err != nil {
return writeForbidden(c)
}

records := make([]acpAgentResponse, 0, len(list.Items))
for _, item := range list.Items {
if s.auth.enabled() && !principal.IsAdmin && item.Spec.Owner.ID != principal.ID {
if err := authorizeHumanOwnedAccess(principal, item.Spec.Owner.ID, s.auth.enabled()); err != nil {
continue
}
if !spritzSupportsACPConversations(&item) {
Expand Down
3 changes: 3 additions & 0 deletions api/acp_bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,9 @@ func (s *server) bootstrapACPConversation(c echo.Context) error {
if s.auth.enabled() && (!ok || principal.ID == "") {
return writeError(c, http.StatusUnauthorized, "unauthenticated")
}
if err := authorizeHumanOnly(principal, s.auth.enabled()); err != nil {
return writeForbidden(c)
}
namespace := s.requestNamespace(c)
if namespace == "" {
namespace = "default"
Expand Down
18 changes: 15 additions & 3 deletions api/acp_conversations.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ func (s *server) listACPConversations(c echo.Context) error {

namespace := s.requestNamespace(c)
spritzName := strings.TrimSpace(c.QueryParam("spritz"))
if err := authorizeHumanOnly(principal, s.auth.enabled()); err != nil {
return writeForbidden(c)
}
list := &spritzv1.SpritzConversationList{}
opts := []client.ListOption{}
if namespace != "" {
Expand All @@ -45,7 +48,7 @@ func (s *server) listACPConversations(c echo.Context) error {
if spritzName != "" {
labels[acpConversationSpritzLabelKey] = spritzName
}
if s.auth.enabled() && !principal.IsAdmin {
if s.auth.enabled() && !principal.isAdminPrincipal() {
labels[acpConversationOwnerLabelKey] = ownerLabelValue(principal.ID)
}
opts = append(opts, client.MatchingLabels(labels))
Expand All @@ -55,7 +58,7 @@ func (s *server) listACPConversations(c echo.Context) error {

items := make([]spritzv1.SpritzConversation, 0, len(list.Items))
for _, item := range list.Items {
if s.auth.enabled() && !principal.IsAdmin && item.Spec.Owner.ID != principal.ID {
if err := authorizeHumanOwnedAccess(principal, item.Spec.Owner.ID, s.auth.enabled()); err != nil {
continue
}
if spritzName != "" && item.Spec.SpritzName != spritzName {
Expand All @@ -75,6 +78,9 @@ func (s *server) getACPConversation(c echo.Context) error {
if s.auth.enabled() && (!ok || principal.ID == "") {
return writeError(c, http.StatusUnauthorized, "unauthenticated")
}
if err := authorizeHumanOnly(principal, s.auth.enabled()); err != nil {
return writeForbidden(c)
}
conversation, err := s.getAuthorizedConversation(c.Request().Context(), principal, s.requestNamespace(c), c.Param("id"))
if err != nil {
return s.writeACPConversationError(c, err)
Expand All @@ -90,6 +96,9 @@ func (s *server) createACPConversation(c echo.Context) error {
if s.auth.enabled() && (!ok || principal.ID == "") {
return writeError(c, http.StatusUnauthorized, "unauthenticated")
}
if err := authorizeHumanOnly(principal, s.auth.enabled()); err != nil {
return writeForbidden(c)
}

var body createACPConversationRequest
if err := decodeACPBody(c, &body); err != nil {
Expand Down Expand Up @@ -135,6 +144,9 @@ func (s *server) updateACPConversation(c echo.Context) error {
if s.auth.enabled() && (!ok || principal.ID == "") {
return writeError(c, http.StatusUnauthorized, "unauthenticated")
}
if err := authorizeHumanOnly(principal, s.auth.enabled()); err != nil {
return writeForbidden(c)
}
conversation, err := s.getAuthorizedConversation(c.Request().Context(), principal, s.requestNamespace(c), c.Param("id"))
if err != nil {
return s.writeACPConversationError(c, err)
Expand Down Expand Up @@ -189,7 +201,7 @@ func (s *server) getAuthorizedConversation(ctx context.Context, principal princi
if err := s.client.Get(ctx, clientKey(namespace, name), conversation); err != nil {
return nil, err
}
if s.auth.enabled() && !principal.IsAdmin && conversation.Spec.Owner.ID != principal.ID {
if err := authorizeHumanOwnedAccess(principal, conversation.Spec.Owner.ID, s.auth.enabled()); err != nil {
return nil, errForbidden
}
return conversation, nil
Expand Down
35 changes: 30 additions & 5 deletions api/acp_gateway.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package main

import (
"encoding/json"
"net/http"
"strings"
"sync"

"github.com/gorilla/websocket"
Expand All @@ -16,6 +18,9 @@ func (s *server) openACPConversationConnection(c echo.Context) error {
if s.auth.enabled() && (!ok || principal.ID == "") {
return writeError(c, http.StatusUnauthorized, "unauthenticated")
}
if err := authorizeHumanOnly(principal, s.auth.enabled()); err != nil {
return writeForbidden(c)
}
namespace := s.requestNamespace(c)
conversation, err := s.getAuthorizedConversation(c.Request().Context(), principal, namespace, c.Param("id"))
if err != nil {
Expand Down Expand Up @@ -47,19 +52,26 @@ func (s *server) openACPConversationConnection(c echo.Context) error {
_ = workspaceConn.Close()
}()

return proxyWebSockets(browserConn, workspaceConn)
return proxyWebSockets(
browserConn,
workspaceConn,
func(payload []byte) {
s.scheduleACPPromptActivity(c.Logger(), spritz.Namespace, spritz.Name, payload)
},
nil,
)
}

func proxyWebSockets(left, right *websocket.Conn) error {
func proxyWebSockets(left, right *websocket.Conn, onLeftMessage, onRightMessage func([]byte)) error {
errCh := make(chan error, 2)
closeOnce := sync.Once{}
closeBoth := func() {
_ = left.Close()
_ = right.Close()
}

go proxyWebSocketDirection(left, right, errCh)
go proxyWebSocketDirection(right, left, errCh)
go proxyWebSocketDirection(left, right, errCh, onLeftMessage)
go proxyWebSocketDirection(right, left, errCh, onRightMessage)

err := <-errCh
closeOnce.Do(closeBoth)
Expand All @@ -69,16 +81,29 @@ func proxyWebSockets(left, right *websocket.Conn) error {
return err
}

func proxyWebSocketDirection(src, dst *websocket.Conn, errCh chan<- error) {
func proxyWebSocketDirection(src, dst *websocket.Conn, errCh chan<- error, onMessage func([]byte)) {
for {
msgType, payload, err := src.ReadMessage()
if err != nil {
errCh <- err
return
}
if onMessage != nil {
onMessage(payload)
}
if err := dst.WriteMessage(msgType, payload); err != nil {
errCh <- err
return
}
}
}

func isACPPromptMessage(payload []byte) bool {
var message struct {
Method string `json:"method"`
}
if err := json.Unmarshal(payload, &message); err != nil {
return false
}
return strings.TrimSpace(message.Method) == "session/prompt"
}
57 changes: 57 additions & 0 deletions api/acp_gateway_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package main

import (
"context"
"sync/atomic"
"testing"
"time"
)

type noopWarnLogger struct{}

func (noopWarnLogger) Warnf(string, ...interface{}) {}

func TestScheduleACPPromptActivityDoesNotBlockPromptPath(t *testing.T) {
started := make(chan struct{}, 1)
release := make(chan struct{})
var calls atomic.Int32

s := &server{
activityRecorder: func(ctx context.Context, namespace, name string, when time.Time) error {
calls.Add(1)
select {
case started <- struct{}{}:
default:
}
select {
case <-release:
return nil
case <-ctx.Done():
return ctx.Err()
}
},
}

start := time.Now()
s.scheduleACPPromptActivity(noopWarnLogger{}, "spritz-test", "young-crest", []byte(`{"jsonrpc":"2.0","method":"session/prompt"}`))
if elapsed := time.Since(start); elapsed > 50*time.Millisecond {
t.Fatalf("expected prompt activity scheduling to return immediately, took %s", elapsed)
}

select {
case <-started:
case <-time.After(250 * time.Millisecond):
t.Fatalf("expected background activity recorder to start")
}

close(release)

deadline := time.Now().Add(250 * time.Millisecond)
for time.Now().Before(deadline) {
if calls.Load() == 1 {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("expected one background activity write, got %d", calls.Load())
}
2 changes: 1 addition & 1 deletion api/acp_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ func (s *server) getAuthorizedSpritz(ctx context.Context, principal principal, n
}
return nil, err
}
if s.auth.enabled() && !principal.IsAdmin && spritz.Spec.Owner.ID != principal.ID {
if err := authorizeHumanOwnedAccess(principal, spritz.Spec.Owner.ID, s.auth.enabled()); err != nil {
return nil, errForbidden
}
return spritz, nil
Expand Down
44 changes: 42 additions & 2 deletions api/acp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (

"github.com/gorilla/websocket"
"github.com/labstack/echo/v4"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
Expand All @@ -26,6 +27,9 @@ func newACPTestScheme(t *testing.T) *runtime.Scheme {
if err := spritzv1.AddToScheme(scheme); err != nil {
t.Fatalf("failed to register spritz scheme: %v", err)
}
if err := corev1.AddToScheme(scheme); err != nil {
t.Fatalf("failed to register core scheme: %v", err)
}
return scheme
}

Expand All @@ -41,8 +45,9 @@ func newACPTestServer(t *testing.T, objects ...client.Object) *server {
scheme: scheme,
namespace: "spritz-test",
auth: authConfig{
mode: authModeHeader,
headerID: "X-Spritz-User-Id",
mode: authModeHeader,
headerID: "X-Spritz-User-Id",
headerDefaultType: principalTypeHuman,
},
internalAuth: internalAuthConfig{enabled: false},
acp: acpConfig{
Expand Down Expand Up @@ -427,6 +432,41 @@ func TestListAndPatchACPConversationsByID(t *testing.T) {
}
}

func TestListACPConversationsAllowsAdminToSeeAllOwners(t *testing.T) {
now := metav1.Now()
spritz := readyACPSpritz("tidy-otter", "user-1")
ownerOne := conversationFor("tidy-otter-user-1", "tidy-otter", "user-1", "Owner one", now)
ownerTwo := conversationFor("tidy-otter-user-2", "tidy-otter", "user-2", "Owner two", now)

s := newACPTestServer(t, spritz, ownerOne, ownerTwo)
s.auth.adminIDs = map[string]struct{}{"admin-1": {}}
e := echo.New()
secured := e.Group("", s.authMiddleware())
secured.GET("/api/acp/conversations", s.listACPConversations)

req := httptest.NewRequest(http.MethodGet, "/api/acp/conversations?spritz=tidy-otter", nil)
req.Header.Set("X-Spritz-User-Id", "admin-1")
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)

if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String())
}

var payload struct {
Status string `json:"status"`
Data struct {
Items []spritzv1.SpritzConversation `json:"items"`
} `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("failed to decode list response: %v", err)
}
if len(payload.Data.Items) != 2 {
t.Fatalf("expected 2 visible conversations for admin, got %d", len(payload.Data.Items))
}
}

func TestPatchACPConversationRejectsSessionIDMutation(t *testing.T) {
spritz := readyACPSpritz("tidy-otter", "user-1")
conversation := conversationFor("tidy-otter-new", "tidy-otter", "user-1", "Latest", metav1.Now())
Expand Down
61 changes: 61 additions & 0 deletions api/activity.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package main

import (
"context"
"strings"
"time"

apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/util/retry"

spritzv1 "spritz.sh/operator/api/v1"
)

const acpPromptActivityTimeout = 2 * time.Second

type warnLogger interface {
Warnf(string, ...interface{})
}

func (s *server) recordSpritzActivity(ctx context.Context, namespace, name string, when time.Time) error {
if s.activityRecorder != nil {
return s.activityRecorder(ctx, namespace, name, when)
}
return s.markSpritzActivity(ctx, namespace, name, when)
}

func (s *server) scheduleACPPromptActivity(logger warnLogger, namespace, name string, payload []byte) {
if !isACPPromptMessage(payload) {
return
}
when := time.Now()
go func() {
ctx, cancel := context.WithTimeout(context.Background(), acpPromptActivityTimeout)
defer cancel()
if err := s.recordSpritzActivity(ctx, namespace, name, when); err != nil && logger != nil {
logger.Warnf("failed to record acp activity for %s/%s: %v", namespace, name, err)
}
}()
}

func (s *server) markSpritzActivity(ctx context.Context, namespace, name string, when time.Time) error {
if strings.TrimSpace(name) == "" {
return nil
}
return retry.RetryOnConflict(retry.DefaultRetry, func() error {
current := &spritzv1.Spritz{}
if err := s.client.Get(ctx, clientKey(namespace, name), current); err != nil {
if apierrors.IsNotFound(err) {
return nil
}
return err
}
timestamp := metav1.NewTime(when.UTC())
if current.Status.LastActivityAt != nil && !current.Status.LastActivityAt.Time.Before(timestamp.Time) {
return nil
}
current.Status.LastActivityAt = &timestamp
return s.client.Status().Update(ctx, current)
})
}
Loading
Loading