Skip to content
Merged
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
9 changes: 9 additions & 0 deletions api/main_create_owner_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,15 @@ func TestCreateSpritzOwnerUsesIDAndOmitsEmail(t *testing.T) {
if _, exists := owner["email"]; exists {
t.Fatalf("expected owner.email to be omitted from response, got %#v", owner["email"])
}
if data["accessUrl"] != "http://tidal-ember.spritz-test.svc.cluster.local:8080/#chat/tidal-ember" {
t.Fatalf("expected accessUrl to prefer chat url, got %#v", data["accessUrl"])
}
if data["chatUrl"] != "http://tidal-ember.spritz-test.svc.cluster.local:8080/#chat/tidal-ember" {
t.Fatalf("expected chatUrl in response, got %#v", data["chatUrl"])
}
if data["workspaceUrl"] != "http://tidal-ember.spritz-test.svc.cluster.local:8080" {
t.Fatalf("expected workspaceUrl in response, got %#v", data["workspaceUrl"])
}
}

func TestCreateSpritzRejectsOwnerIDMismatchForNonAdmin(t *testing.T) {
Expand Down
6 changes: 6 additions & 0 deletions api/provisioning.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@ type provisionerPolicy struct {
type createSpritzResponse struct {
Spritz *spritzv1.Spritz `json:"spritz"`
AccessURL string `json:"accessUrl,omitempty"`
ChatURL string `json:"chatUrl,omitempty"`
WorkspaceURL string `json:"workspaceUrl,omitempty"`
Namespace string `json:"namespace,omitempty"`
OwnerID string `json:"ownerId,omitempty"`
ActorID string `json:"actorId,omitempty"`
Expand Down Expand Up @@ -963,9 +965,13 @@ func summarizeCreateResponse(spritz *spritzv1.Spritz, principal principal, prese
}
createdAt := spritz.CreationTimestamp.DeepCopy()
idleExpiresAt, maxExpiresAt, expiresAt := lifecycleExpiryTimes(spritz, time.Now())
workspaceURL := spritzv1.WorkspaceURLForSpritz(spritz)
chatURL := spritzv1.ChatURLForSpritz(spritz)
return createSpritzResponse{
Spritz: spritz,
AccessURL: spritzv1.AccessURLForSpritz(spritz),
ChatURL: chatURL,
WorkspaceURL: workspaceURL,
Namespace: spritz.Namespace,
OwnerID: spritz.Spec.Owner.ID,
ActorID: principal.ID,
Expand Down
24 changes: 18 additions & 6 deletions cli/test/provisioner-create.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@ test('create uses bearer auth and provisioner fields for preset-based creation',
res.end(JSON.stringify({
status: 'success',
data: {
accessUrl: 'https://console.example.com/w/openclaw-tide-wind/',
accessUrl: 'https://console.example.com/#chat/openclaw-tide-wind',
chatUrl: 'https://console.example.com/#chat/openclaw-tide-wind',
workspaceUrl: 'https://console.example.com/w/openclaw-tide-wind/',
ownerId: 'user-123',
presetId: 'openclaw',
},
Expand Down Expand Up @@ -79,7 +81,9 @@ test('create uses bearer auth and provisioner fields for preset-based creation',
});

const payload = JSON.parse(stdout);
assert.equal(payload.accessUrl, 'https://console.example.com/w/openclaw-tide-wind/');
assert.equal(payload.accessUrl, 'https://console.example.com/#chat/openclaw-tide-wind');
assert.equal(payload.chatUrl, 'https://console.example.com/#chat/openclaw-tide-wind');
assert.equal(payload.workspaceUrl, 'https://console.example.com/w/openclaw-tide-wind/');
assert.equal(payload.ownerId, 'user-123');
assert.equal(payload.presetId, 'openclaw');
});
Expand All @@ -98,7 +102,9 @@ test('create falls back to local owner identity without bearer auth', async (t)
res.end(JSON.stringify({
status: 'success',
data: {
accessUrl: 'http://localhost:8080/w/claude-code-tender-otter/',
accessUrl: 'http://localhost:8080/#chat/claude-code-tender-otter',
chatUrl: 'http://localhost:8080/#chat/claude-code-tender-otter',
workspaceUrl: 'http://localhost:8080/w/claude-code-tender-otter/',
ownerId: 'local-user',
},
}));
Expand Down Expand Up @@ -161,7 +167,9 @@ test('create allows server-side default preset resolution', async (t) => {
res.end(JSON.stringify({
status: 'success',
data: {
accessUrl: 'https://console.example.com/w/openclaw-tide-wind/',
accessUrl: 'https://console.example.com/#chat/openclaw-tide-wind',
chatUrl: 'https://console.example.com/#chat/openclaw-tide-wind',
workspaceUrl: 'https://console.example.com/w/openclaw-tide-wind/',
ownerId: 'user-123',
presetId: 'openclaw',
},
Expand Down Expand Up @@ -227,7 +235,9 @@ test('create uses active profile api url and bearer token without SPRITZ env var
res.end(JSON.stringify({
status: 'success',
data: {
accessUrl: 'https://console.example.com/w/openclaw-profile-smoke/',
accessUrl: 'https://console.example.com/#chat/openclaw-profile-smoke',
chatUrl: 'https://console.example.com/#chat/openclaw-profile-smoke',
workspaceUrl: 'https://console.example.com/w/openclaw-profile-smoke/',
ownerId: 'user-123',
presetId: 'openclaw',
},
Expand Down Expand Up @@ -313,7 +323,9 @@ test('create uses active profile api url and bearer token without SPRITZ env var
});

const payload = JSON.parse(stdout);
assert.equal(payload.accessUrl, 'https://console.example.com/w/openclaw-profile-smoke/');
assert.equal(payload.accessUrl, 'https://console.example.com/#chat/openclaw-profile-smoke');
assert.equal(payload.chatUrl, 'https://console.example.com/#chat/openclaw-profile-smoke');
assert.equal(payload.workspaceUrl, 'https://console.example.com/w/openclaw-profile-smoke/');
});

test('profile show redacts bearer tokens', async () => {
Expand Down
40 changes: 36 additions & 4 deletions operator/api/v1/access_url.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
package v1

import "fmt"
import (
"fmt"
"net/url"
"strings"
)

const defaultWebPort = int32(8080)

// AccessURLForSpritz returns the canonical access URL for a spritz based on its
// ingress or primary service port configuration.
func AccessURLForSpritz(spritz *Spritz) string {
// WorkspaceURLForSpritz returns the canonical workspace URL for a spritz based
// on its ingress or primary service port configuration.
func WorkspaceURLForSpritz(spritz *Spritz) string {
if spritz == nil {
return ""
}
Expand Down Expand Up @@ -36,6 +40,34 @@ func AccessURLForSpritz(spritz *Spritz) string {
return fmt.Sprintf("http://%s.%s.svc.cluster.local:%d", spritz.Name, spritz.Namespace, servicePort)
}

// ChatURLForSpritz returns the canonical agent chat URL for a spritz when the
// workspace is exposed through a web surface.
func ChatURLForSpritz(spritz *Spritz) string {
workspaceURL := WorkspaceURLForSpritz(spritz)
if workspaceURL == "" {
return ""
}
parsed, err := url.Parse(workspaceURL)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return ""
}
parsed.Path = "/"
parsed.RawPath = "/"
parsed.RawQuery = ""
parsed.Fragment = fmt.Sprintf("chat/%s", url.PathEscape(strings.TrimSpace(spritz.Name)))
return parsed.String()
}

// AccessURLForSpritz returns the canonical primary access URL for a spritz.
// Human-facing clients should use the chat URL when available, and otherwise
// fall back to the workspace URL.
func AccessURLForSpritz(spritz *Spritz) string {
if chatURL := ChatURLForSpritz(spritz); chatURL != "" {
return chatURL
}
return WorkspaceURLForSpritz(spritz)
}

// IsWebEnabled reports whether the web surface should be exposed for a spritz.
func IsWebEnabled(spec SpritzSpec) bool {
if spec.Features == nil || spec.Features.Web == nil {
Expand Down
76 changes: 76 additions & 0 deletions operator/api/v1/access_url_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package v1

import (
"testing"

metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)

func TestWorkspaceURLForSpritzUsesIngressPath(t *testing.T) {
spritz := &Spritz{
ObjectMeta: metav1ObjectMeta("openclaw-tide-wind", "spritz-test"),
Spec: SpritzSpec{
Ingress: &SpritzIngress{
Host: "console.example.com",
Path: "/w/openclaw-tide-wind",
},
},
}

if got := WorkspaceURLForSpritz(spritz); got != "https://console.example.com/w/openclaw-tide-wind/" {
t.Fatalf("expected workspace url, got %q", got)
}
}

func TestChatURLForSpritzUsesRootHashRoute(t *testing.T) {
spritz := &Spritz{
ObjectMeta: metav1ObjectMeta("openclaw-tide-wind", "spritz-test"),
Spec: SpritzSpec{
Ingress: &SpritzIngress{
Host: "console.example.com",
Path: "/w/openclaw-tide-wind",
},
},
}

if got := ChatURLForSpritz(spritz); got != "https://console.example.com/#chat/openclaw-tide-wind" {
t.Fatalf("expected chat url, got %q", got)
}
}

func TestAccessURLForSpritzPromotesChatURL(t *testing.T) {
spritz := &Spritz{
ObjectMeta: metav1ObjectMeta("openclaw-tide-wind", "spritz-test"),
Spec: SpritzSpec{
Ingress: &SpritzIngress{
Host: "console.example.com",
Path: "/w/openclaw-tide-wind",
},
},
}

if got := AccessURLForSpritz(spritz); got != "https://console.example.com/#chat/openclaw-tide-wind" {
t.Fatalf("expected access url to prefer chat url, got %q", got)
}
}

func TestAccessURLForSpritzFallsBackToWorkspaceURL(t *testing.T) {
spritz := &Spritz{
ObjectMeta: metav1ObjectMeta("openclaw-tide-wind", "spritz-test"),
Spec: SpritzSpec{
Ports: []SpritzPort{{ContainerPort: 8080}},
},
}

want := "http://openclaw-tide-wind.spritz-test.svc.cluster.local:8080/#chat/openclaw-tide-wind"
if got := AccessURLForSpritz(spritz); got != want {
t.Fatalf("expected access url %q, got %q", want, got)
}
}

func metav1ObjectMeta(name, namespace string) metav1.ObjectMeta {
return metav1.ObjectMeta{
Name: name,
Namespace: namespace,
}
}
Loading