diff --git a/api/main_create_owner_test.go b/api/main_create_owner_test.go index 5fd61f9..dd23fbd 100644 --- a/api/main_create_owner_test.go +++ b/api/main_create_owner_test.go @@ -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) { diff --git a/api/provisioning.go b/api/provisioning.go index bdeec60..ab3b551 100644 --- a/api/provisioning.go +++ b/api/provisioning.go @@ -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"` @@ -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, diff --git a/cli/test/provisioner-create.test.ts b/cli/test/provisioner-create.test.ts index 75e9ebe..4f061f6 100644 --- a/cli/test/provisioner-create.test.ts +++ b/cli/test/provisioner-create.test.ts @@ -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', }, @@ -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'); }); @@ -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', }, })); @@ -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', }, @@ -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', }, @@ -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 () => { diff --git a/operator/api/v1/access_url.go b/operator/api/v1/access_url.go index 4b84bfa..ba70cfd 100644 --- a/operator/api/v1/access_url.go +++ b/operator/api/v1/access_url.go @@ -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 "" } @@ -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 { diff --git a/operator/api/v1/access_url_test.go b/operator/api/v1/access_url_test.go new file mode 100644 index 0000000..616b385 --- /dev/null +++ b/operator/api/v1/access_url_test.go @@ -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, + } +}