feat: configure portal services and terminal

This commit is contained in:
Haitao Pan 2026-06-14 08:43:53 +08:00
parent f8a27b8058
commit 35f664856f
18 changed files with 1572 additions and 236 deletions

98
api/auth.go Normal file
View File

@ -0,0 +1,98 @@
package main
import (
"crypto/subtle"
"net/http"
"os"
"path/filepath"
"strings"
)
type AuthConfig struct {
tokens []string
}
func NewAuthConfig() AuthConfig {
return AuthConfig{tokens: loadAuthTokens()}
}
func (a AuthConfig) Required() bool {
return len(a.tokens) > 0
}
func (a AuthConfig) Authorize(r *http.Request) bool {
if !a.Required() {
return true
}
token := requestToken(r)
if token == "" {
return false
}
for _, expected := range a.tokens {
if constantTimeTokenEqual(token, expected) {
return true
}
}
return false
}
func loadAuthTokens() []string {
candidates := []string{
os.Getenv("AI_WORKSPACE_AUTH_TOKEN"),
os.Getenv("XWORKSPACE_CONSOLE_AUTH_TOKEN"),
os.Getenv("XWORKMATE_BRIDGE_AUTH_TOKEN"),
os.Getenv("BRIDGE_AUTH_TOKEN"),
os.Getenv("BRIDGE_REVIEW_AUTH_TOKEN"),
os.Getenv("INTERNAL_SERVICE_TOKEN"),
}
if home, err := os.UserHomeDir(); err == nil {
candidates = append(candidates,
readTokenFile(filepath.Join(home, ".ai_workspace_auth_token")),
readTokenFile(filepath.Join(home, ".config", "xworkspace", "auth-token")),
)
}
seen := map[string]struct{}{}
tokens := make([]string, 0, len(candidates))
for _, candidate := range candidates {
token := strings.TrimSpace(candidate)
if token == "" {
continue
}
if _, ok := seen[token]; ok {
continue
}
seen[token] = struct{}{}
tokens = append(tokens, token)
}
return tokens
}
func readTokenFile(path string) string {
content, err := os.ReadFile(path)
if err != nil {
return ""
}
return strings.TrimSpace(string(content))
}
func requestToken(r *http.Request) string {
header := strings.TrimSpace(r.Header.Get("Authorization"))
if len(header) > 7 && strings.EqualFold(header[:7], "Bearer ") {
return strings.TrimSpace(header[7:])
}
if header != "" {
return header
}
if token := strings.TrimSpace(r.Header.Get("X-Bridge-Token")); token != "" {
return token
}
return strings.TrimSpace(r.Header.Get("X-XWorkspace-Token"))
}
func constantTimeTokenEqual(actual string, expected string) bool {
if len(actual) != len(expected) {
return false
}
return subtle.ConstantTimeCompare([]byte(actual), []byte(expected)) == 1
}

114
api/portal_services.go Normal file
View File

@ -0,0 +1,114 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
)
type PortalServiceProvider struct {
file string
}
func NewPortalServiceProvider() PortalServiceProvider {
file := os.Getenv("XWORKSPACE_PORTAL_SERVICES_FILE")
if file == "" {
if home, err := os.UserHomeDir(); err == nil {
file = filepath.Join(home, ".config", "xworkspace", "portal-services.json")
}
}
return PortalServiceProvider{file: file}
}
func (p PortalServiceProvider) Services() []PortalService {
if services := p.loadFromFile(); len(services) > 0 {
return services
}
return defaultPortalServices()
}
func (p PortalServiceProvider) loadFromFile() []PortalService {
if p.file == "" {
return nil
}
content, err := os.ReadFile(p.file)
if err != nil {
return nil
}
var wrapper PortalServicesResponse
if err := json.Unmarshal(content, &wrapper); err == nil && len(wrapper.Services) > 0 {
return normalizePortalServices(wrapper.Services)
}
var services []PortalService
if err := json.Unmarshal(content, &services); err == nil && len(services) > 0 {
return normalizePortalServices(services)
}
return nil
}
func normalizePortalServices(services []PortalService) []PortalService {
normalized := make([]PortalService, 0, len(services))
for _, service := range services {
if service.Key == "" || service.Name == "" || service.URL == "" {
continue
}
if service.OpenMode != "external" {
service.OpenMode = "iframe"
}
normalized = append(normalized, service)
}
return normalized
}
func defaultPortalServices() []PortalService {
return []PortalService{
{
Key: "litellm",
Name: "LiteLLM Admin UI",
URL: "http://localhost:4000/ui",
OpenMode: "iframe",
HealthURL: "http://127.0.0.1:4000/ui",
Description: "Model routing and provider administration.",
Icon: "chart",
Match: []string{"litellm", "lite"},
Port: 4000,
Role: "model-router",
},
{
Key: "openclaw",
Name: "OpenClaw",
URL: "http://127.0.0.1:18789/channels",
OpenMode: "external",
HealthURL: "http://127.0.0.1:18789/channels",
Description: "Gateway dashboard.",
Icon: "claw",
Match: []string{"openclaw", "gateway"},
Port: 18789,
Role: "gateway",
},
{
Key: "vault",
Name: "Vault Server",
URL: "http://127.0.0.1:8200/ui",
OpenMode: "external",
HealthURL: "http://127.0.0.1:8200/ui",
Description: "Vault UI.",
Icon: "shield",
Match: []string{"vault"},
Port: 8200,
},
{
Key: "terminal",
Name: "Terminal",
URL: "http://127.0.0.1:7681",
OpenMode: "iframe",
HealthURL: "http://127.0.0.1:7681",
Description: "Local ttyd terminal.",
Icon: "terminal",
Match: []string{"ttyd", "terminal"},
Port: 7681,
},
}
}

View File

@ -1,57 +0,0 @@
package main
import (
"net/http"
"net/http/httputil"
"net/url"
"strings"
)
func NewLocalProxy(target string, prefix string) http.Handler {
upstream, err := url.Parse(target)
if err != nil {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, err.Error(), http.StatusInternalServerError)
})
}
proxy := httputil.NewSingleHostReverseProxy(upstream)
originalDirector := proxy.Director
proxy.Director = func(r *http.Request) {
originalDirector(r)
r.URL.Scheme = upstream.Scheme
r.URL.Host = upstream.Host
r.URL.Path = strings.TrimPrefix(r.URL.Path, prefix)
if r.URL.Path == "" {
r.URL.Path = "/"
}
r.Host = upstream.Host
}
proxy.ModifyResponse = func(resp *http.Response) error {
stripFrameBlockingHeaders(resp.Header)
return nil
}
return proxy
}
func stripFrameBlockingHeaders(header http.Header) {
header.Del("X-Frame-Options")
csp := header.Get("Content-Security-Policy")
if csp == "" {
return
}
header.Set("Content-Security-Policy", removeCSPDirective(csp, "frame-ancestors"))
}
func removeCSPDirective(csp string, directive string) string {
parts := strings.Split(csp, ";")
kept := parts[:0]
for _, part := range parts {
trimmed := strings.TrimSpace(part)
if trimmed == "" || strings.HasPrefix(strings.ToLower(trimmed), directive) {
continue
}
kept = append(kept, trimmed)
}
return strings.Join(kept, "; ")
}

View File

@ -8,15 +8,19 @@ import (
) )
type Server struct { type Server struct {
serviceProbe ServiceProbe serviceProbe ServiceProbe
metricProbe MetricProbe metricProbe MetricProbe
portalServices PortalServiceProvider
auth AuthConfig
} }
func NewServer() *Server { func NewServer() *Server {
serviceProbe := NewServiceProbe() serviceProbe := NewServiceProbe()
return &Server{ return &Server{
serviceProbe: serviceProbe, serviceProbe: serviceProbe,
metricProbe: NewMetricProbe(), metricProbe: NewMetricProbe(),
portalServices: NewPortalServiceProvider(),
auth: NewAuthConfig(),
} }
} }
@ -26,12 +30,11 @@ func (s *Server) ListenAndServe(addr string) error {
func (s *Server) routes() http.Handler { func (s *Server) routes() http.Handler {
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/auth/status", s.authStatusHandler)
mux.HandleFunc("/health", s.healthHandler) mux.HandleFunc("/health", s.healthHandler)
mux.HandleFunc("/services", s.servicesHandler) mux.HandleFunc("/services", s.servicesHandler)
mux.HandleFunc("/portal/services", s.portalServicesHandler)
mux.HandleFunc("/metrics/simple", s.metricsHandler) mux.HandleFunc("/metrics/simple", s.metricsHandler)
mux.Handle("/proxy/openclaw/", NewLocalProxy("http://127.0.0.1:18789", "/proxy/openclaw"))
mux.Handle("/proxy/vault/", NewLocalProxy("http://127.0.0.1:8200", "/proxy/vault"))
mux.Handle("/ui/", NewLocalProxy("http://127.0.0.1:8200", ""))
return mux return mux
} }
@ -44,7 +47,7 @@ func (s *Server) withCORS(next http.Handler) http.Handler {
w.Header().Set("Access-Control-Allow-Origin", "http://127.0.0.1:17000") w.Header().Set("Access-Control-Allow-Origin", "http://127.0.0.1:17000")
} }
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS") w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type") w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Bridge-Token, X-XWorkspace-Token")
if r.Method == http.MethodOptions { if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
return return
@ -62,7 +65,14 @@ func allowedOrigin(origin string) bool {
} }
} }
func (s *Server) authStatusHandler(w http.ResponseWriter, r *http.Request) {
writeJSON(w, AuthStatusResponse{Required: s.auth.Required()})
}
func (s *Server) healthHandler(w http.ResponseWriter, r *http.Request) { func (s *Server) healthHandler(w http.ResponseWriter, r *http.Request) {
if !s.requireAuth(w, r) {
return
}
services := s.serviceProbe.Probe() services := s.serviceProbe.Probe()
writeJSON(w, HealthResponse{ writeJSON(w, HealthResponse{
Status: "ok", Status: "ok",
@ -77,13 +87,36 @@ func (s *Server) healthHandler(w http.ResponseWriter, r *http.Request) {
} }
func (s *Server) servicesHandler(w http.ResponseWriter, r *http.Request) { func (s *Server) servicesHandler(w http.ResponseWriter, r *http.Request) {
if !s.requireAuth(w, r) {
return
}
writeJSON(w, s.serviceProbe.Probe()) writeJSON(w, s.serviceProbe.Probe())
} }
func (s *Server) portalServicesHandler(w http.ResponseWriter, r *http.Request) {
if !s.requireAuth(w, r) {
return
}
writeJSON(w, PortalServicesResponse{Services: s.portalServices.Services()})
}
func (s *Server) metricsHandler(w http.ResponseWriter, r *http.Request) { func (s *Server) metricsHandler(w http.ResponseWriter, r *http.Request) {
if !s.requireAuth(w, r) {
return
}
_, _ = w.Write([]byte("xworkspace_systemd_services " + strconv.Itoa(len(s.serviceProbe.Probe())) + "\n")) _, _ = w.Write([]byte("xworkspace_systemd_services " + strconv.Itoa(len(s.serviceProbe.Probe())) + "\n"))
} }
func (s *Server) requireAuth(w http.ResponseWriter, r *http.Request) bool {
if s.auth.Authorize(r) {
return true
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusUnauthorized)
_ = json.NewEncoder(w).Encode(map[string]string{"error": "unauthorized"})
return false
}
func writeJSON(w http.ResponseWriter, v any) { func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Content-Type", "application/json; charset=utf-8")
enc := json.NewEncoder(w) enc := json.NewEncoder(w)

View File

@ -9,6 +9,27 @@ type Service struct {
URL string `json:"url,omitempty"` URL string `json:"url,omitempty"`
} }
type PortalService struct {
Key string `json:"key"`
Name string `json:"name"`
URL string `json:"url"`
OpenMode string `json:"openMode"`
HealthURL string `json:"healthUrl,omitempty"`
Description string `json:"description,omitempty"`
Icon string `json:"icon,omitempty"`
Match []string `json:"match,omitempty"`
Port int `json:"port,omitempty"`
Role string `json:"role,omitempty"`
}
type PortalServicesResponse struct {
Services []PortalService `json:"services"`
}
type AuthStatusResponse struct {
Required bool `json:"required"`
}
type HealthResponse struct { type HealthResponse struct {
Status string `json:"status"` Status string `json:"status"`
Arch string `json:"arch"` Arch string `json:"arch"`

View File

@ -6,7 +6,9 @@ Wants=network-online.target
[Service] [Service]
Type=simple Type=simple
WorkingDirectory=%h/xworkspace-console/api WorkingDirectory=%h/xworkspace-console/api
ExecStart=/usr/local/go/bin/go run . EnvironmentFile=-%h/.config/xworkspace/portal.env
Environment=XWORKSPACE_PORTAL_SERVICES_FILE=%h/.config/xworkspace/portal-services.json
ExecStart=/usr/bin/env go run .
Restart=always Restart=always
RestartSec=2 RestartSec=2

View File

@ -1,14 +1,25 @@
'use client'; 'use client';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { customWorkspaceTabs, fallbackMetrics, initialTabs, labelsEn, labelsZh } from '@/lib/data'; import {
import type { NavItem, RuntimeMetrics, Service, Tab } from '@/lib/data'; buildInitialTabs,
import { fetchDashboardStatus } from '@/lib/api'; buildNavSections,
customWorkspaceTabs,
fallbackMetrics,
findPortalService,
initialTabs,
labelsEn,
labelsZh,
portalServices,
portalServiceToTab,
} from '@/lib/data';
import type { NavItem, PortalService, RuntimeMetrics, Service, Tab } from '@/lib/data';
import { fetchAuthStatus, fetchDashboardStatus, fetchPortalServices, validateBridgeToken } from '@/lib/api';
import { Sidebar } from './Sidebar'; import { Sidebar } from './Sidebar';
import { Topbar } from './Topbar'; import { Topbar } from './Topbar';
import { WorkspaceTabs } from './WorkspaceTabs'; import { WorkspaceTabs } from './WorkspaceTabs';
import { WorkspaceHome } from './WorkspaceHome'; import { WorkspaceHome } from './WorkspaceHome';
import { EmbedView } from './EmbedView'; import { ServicePanel } from './ServicePanel';
export function AppShell() { export function AppShell() {
const [selectedTab, setSelectedTab] = useState('workspace'); const [selectedTab, setSelectedTab] = useState('workspace');
@ -19,14 +30,33 @@ export function AppShell() {
const [language, setLanguage] = useState<'en' | 'zh'>('en'); const [language, setLanguage] = useState<'en' | 'zh'>('en');
const [theme, setTheme] = useState<'light' | 'dark'>('light'); const [theme, setTheme] = useState<'light' | 'dark'>('light');
const [remoteMode, setRemoteMode] = useState(true); const [remoteMode, setRemoteMode] = useState(true);
const [portalServicesConfig, setPortalServicesConfig] = useState<PortalService[]>(portalServices);
const [authRequired, setAuthRequired] = useState(false);
const [authStatusLoaded, setAuthStatusLoaded] = useState(false);
const [authToken, setAuthToken] = useState('');
const [tokenInput, setTokenInput] = useState('');
const [tokenLoaded, setTokenLoaded] = useState(false);
const [authError, setAuthError] = useState(false);
const [authChecking, setAuthChecking] = useState(false);
useEffect(() => { useEffect(() => {
let active = true; let active = true;
const refresh = () => { const refresh = () => {
fetchDashboardStatus().then((data) => { if (!tokenLoaded || !authStatusLoaded || (authRequired && !authToken)) return;
if (!active || !data) return; Promise.all([fetchDashboardStatus(authToken), fetchPortalServices(authToken)]).then(([statusResult, portalResult]) => {
setServices(data.services); if (!active) return;
setMetrics(data.metrics); if (statusResult.unauthorized || portalResult.unauthorized) {
setAuthRequired(true);
setAuthError(true);
return;
}
if (statusResult.data) {
setServices(statusResult.data.services);
setMetrics(statusResult.data.metrics);
}
if (portalResult.data?.length) {
setPortalServicesConfig(portalResult.data);
}
}); });
}; };
refresh(); refresh();
@ -35,6 +65,29 @@ export function AppShell() {
active = false; active = false;
window.clearInterval(timer); window.clearInterval(timer);
}; };
}, [authRequired, authStatusLoaded, authToken, tokenLoaded]);
useEffect(() => {
setTabs((existingTabs) => {
const workspaceTab = existingTabs.find((tab) => tab.id === 'workspace') ?? buildInitialTabs([])[0];
const customTabs = existingTabs.filter((tab) => tab.id !== 'workspace' && !tab.serviceKey);
return [workspaceTab, ...portalServicesConfig.map(portalServiceToTab), ...customTabs];
});
const activeServiceKey = selectedTab.startsWith('service-') ? selectedTab.replace(/^service-/, '') : undefined;
if (activeServiceKey && !findPortalService(activeServiceKey, portalServicesConfig)) {
setSelectedTab('workspace');
}
}, [portalServicesConfig]);
useEffect(() => {
const storedToken = window.localStorage.getItem('xworkspace-bridge-token') ?? '';
setAuthToken(storedToken);
setTokenInput(storedToken);
setTokenLoaded(true);
fetchAuthStatus().then((status) => {
if (status?.required) setAuthRequired(true);
setAuthStatusLoaded(true);
});
}, []); }, []);
useEffect(() => { useEffect(() => {
@ -71,7 +124,9 @@ export function AppShell() {
const currentServices = services ?? []; const currentServices = services ?? [];
const selected = tabs.find((tab) => tab.id === selectedTab); const selected = tabs.find((tab) => tab.id === selectedTab);
const selectedService = findPortalService(selected?.serviceKey, portalServicesConfig);
const labels = language === 'zh' ? labelsZh : labelsEn; const labels = language === 'zh' ? labelsZh : labelsEn;
const dynamicNavSections = useMemo(() => buildNavSections(portalServicesConfig), [portalServicesConfig]);
const summary = useMemo(() => { const summary = useMemo(() => {
const runningServices = currentServices.filter((service) => service.state === 'Running').length; const runningServices = currentServices.filter((service) => service.state === 'Running').length;
@ -79,11 +134,15 @@ export function AppShell() {
}, [currentServices, metrics]); }, [currentServices, metrics]);
const openTab = (item: NavItem | Tab) => { const openTab = (item: NavItem | Tab) => {
const service = findPortalService(item.serviceKey, portalServicesConfig);
const nextItem = service ? portalServiceToTab(service) : item;
setTabs((existingTabs) => { setTabs((existingTabs) => {
if (existingTabs.some((tab) => tab.id === item.id)) return existingTabs; if (existingTabs.some((tab) => tab.id === nextItem.id)) {
return [...existingTabs, { ...item, closable: true }]; return existingTabs.map((tab) => (tab.id === nextItem.id ? { ...tab, ...nextItem, closable: tab.closable ?? true } : tab));
}
return [...existingTabs, { ...nextItem, closable: true }];
}); });
setSelectedTab(item.id); setSelectedTab(nextItem.id);
}; };
const closeTab = (tabId: string) => { const closeTab = (tabId: string) => {
@ -99,10 +158,35 @@ export function AppShell() {
openTab(nextTab); openTab(nextTab);
}; };
const submitToken = async () => {
const nextToken = tokenInput.trim();
if (!nextToken) return;
setAuthChecking(true);
setAuthError(false);
const portalResult = await validateBridgeToken(nextToken);
setAuthChecking(false);
if (portalResult.unauthorized || !portalResult.data?.length) {
setAuthError(true);
return;
}
window.localStorage.setItem('xworkspace-bridge-token', nextToken);
setPortalServicesConfig(portalResult.data);
setAuthToken(nextToken);
setTabs(buildInitialTabs(portalResult.data));
};
if (tokenLoaded && authStatusLoaded && authRequired && (!authToken || authError)) {
return <AuthGate token={tokenInput} error={authError} checking={authChecking} onTokenChange={setTokenInput} onSubmit={submitToken} />;
}
return ( return (
<div className={[sidebarCollapsed ? 'app-shell sidebar-collapsed' : 'app-shell', theme === 'dark' ? 'theme-dark' : '', remoteMode ? 'remote-mode' : ''].join(' ')}> <div className={[sidebarCollapsed ? 'app-shell sidebar-collapsed' : 'app-shell', theme === 'dark' ? 'theme-dark' : '', remoteMode ? 'remote-mode' : ''].join(' ')}>
<Sidebar <Sidebar
labels={labels} labels={labels}
navSections={dynamicNavSections}
collapsed={sidebarCollapsed} collapsed={sidebarCollapsed}
selectedTab={selectedTab} selectedTab={selectedTab}
onToggle={() => setSidebarCollapsed((value) => !value)} onToggle={() => setSidebarCollapsed((value) => !value)}
@ -126,12 +210,53 @@ export function AppShell() {
<WorkspaceTabs tabs={tabs} selectedTab={selectedTab} onSelect={setSelectedTab} onClose={closeTab} onAdd={addCustomTab} /> <WorkspaceTabs tabs={tabs} selectedTab={selectedTab} onSelect={setSelectedTab} onClose={closeTab} onAdd={addCustomTab} />
{selected?.kind === 'embed' && selected.id !== 'workspace' ? ( {selected?.kind === 'embed' && selectedService ? (
<EmbedView tab={selected} onBack={() => setSelectedTab('workspace')} /> <ServicePanel service={selectedService} onBack={() => setSelectedTab('workspace')} />
) : ( ) : (
<WorkspaceHome labels={labels} services={currentServices} metrics={metrics} onOpenService={openTab} /> <WorkspaceHome labels={labels} services={currentServices} metrics={metrics} portalServices={portalServicesConfig} onOpenService={openTab} />
)} )}
</main> </main>
</div> </div>
); );
} }
function AuthGate({
token,
error,
checking,
onTokenChange,
onSubmit,
}: {
token: string;
error: boolean;
checking: boolean;
onTokenChange: (value: string) => void;
onSubmit: () => void | Promise<void>;
}) {
return (
<main className="auth-gate">
<form
className="auth-card"
onSubmit={(event) => {
event.preventDefault();
onSubmit();
}}
>
<span className="brand-mark">AI</span>
<h1>AI Workspace Portal</h1>
<p>Enter the xworkmate-bridge token to load local services.</p>
<input
autoFocus
type="password"
value={token}
onChange={(event) => onTokenChange(event.target.value)}
placeholder="Bridge token"
aria-label="Bridge token"
disabled={checking}
/>
{error ? <small>Token rejected by the local API.</small> : null}
<button type="submit" disabled={checking}>{checking ? 'Unlocking...' : 'Unlock'}</button>
</form>
</main>
);
}

View File

@ -1,27 +1,30 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import { acpAgents, findServiceDef, serviceRegistry, skillGroups } from '@/lib/data'; import { acpAgents, findPortalService, findPortalServiceByRole, findPortalServiceStatus, portalServiceToNavItem, skillGroups } from '@/lib/data';
import type { Labels, NavItem, RuntimeMetrics, Service } from '@/lib/data'; import type { Labels, NavItem, PortalService, RuntimeMetrics, Service } from '@/lib/data';
import { Icon } from './Icon'; import { Icon } from './Icon';
export function ArchPipeline({ export function ArchPipeline({
labels, labels,
services, services,
metrics, metrics,
portalServices,
onOpenService, onOpenService,
}: { }: {
labels: Labels; labels: Labels;
services: Service[]; services: Service[];
metrics: RuntimeMetrics; metrics: RuntimeMetrics;
portalServices: PortalService[];
onOpenService: (item: NavItem) => void; onOpenService: (item: NavItem) => void;
}) { }) {
const [skillsOpen, setSkillsOpen] = useState(false); const [skillsOpen, setSkillsOpen] = useState(false);
const gatewayService = findPortalServiceByRole('gateway', portalServices);
const modelRouterService = findPortalServiceByRole('model-router', portalServices);
const stateOf = (id: string): Service['state'] | undefined => { const openPortalService = (serviceKey?: string) => {
const def = serviceRegistry.find((item) => item.id === id); const serviceConfig = findPortalService(serviceKey, portalServices);
const service = services.find((entry) => def?.match?.some((token) => entry.name.toLowerCase().includes(token))); if (serviceConfig) onOpenService(portalServiceToNavItem(serviceConfig));
return service?.state;
}; };
const dot = (state?: Service['state']) => ( const dot = (state?: Service['state']) => (
<i className={state === 'Running' ? 'dot good' : state ? 'dot bad' : 'dot idle'} /> <i className={state === 'Running' ? 'dot good' : state ? 'dot bad' : 'dot idle'} />
@ -48,16 +51,15 @@ export function ArchPipeline({
<small>APP Chat / Web Chat<br />XWorkmate Bridge</small> <small>APP Chat / Web Chat<br />XWorkmate Bridge</small>
</div> </div>
<button type="button" className="gateway-card node-card" onClick={() => onOpenService(serviceRegistry.find((item) => item.id === 'openclaw')!)}> <button type="button" className="gateway-card node-card" onClick={() => openPortalService(gatewayService?.key)}>
<div className="gateway-meta"> <div className="gateway-meta">
<span className="node-index blue">1</span> <span className="node-index blue">1</span>
<span className="node-title">{labels.gatewayBand}</span> <span className="node-title">{labels.gatewayBand}</span>
</div> </div>
{dot(stateOf('openclaw'))} {dot(findPortalServiceStatus(services, gatewayService?.key, portalServices))}
<strong>OpenClaw Gateway</strong> <strong>{gatewayService?.name ?? labels.gatewayBand}</strong>
<small>v2026.6.1</small> <small>{gatewayService?.description ?? 'Gateway service'}</small>
<small>127.0.0.1:18789</small> <small>{gatewayService?.url ?? 'Not configured'}</small>
<small>token auth</small>
<small>Local Only</small> <small>Local Only</small>
</button> </button>
</div> </div>
@ -98,7 +100,7 @@ export function ArchPipeline({
<span className="node-index green">3</span> <span className="node-index green">3</span>
<strong>{labels.skillBand} Layer</strong> <strong>{labels.skillBand} Layer</strong>
<small>{totalSkills}+ {labels.skillsCount}</small> <small>{totalSkills}+ {labels.skillsCount}</small>
{dot(stateOf('bridge'))} {dot()}
</div> </div>
<div className="skill-stack"> <div className="skill-stack">
{skillGroups.map((group) => ( {skillGroups.map((group) => (
@ -119,12 +121,12 @@ export function ArchPipeline({
<div><span>Skills</span><b>{totalSkills}+</b><Icon name="sparkles" /></div> <div><span>Skills</span><b>{totalSkills}+</b><Icon name="sparkles" /></div>
</aside> </aside>
<button type="button" className="model-layer node-card" onClick={() => onOpenService(serviceRegistry.find((item) => item.id === 'litellm')!)}> <button type="button" className="model-layer node-card" onClick={() => openPortalService(modelRouterService?.key)}>
<div className="node-head"> <div className="node-head">
<span className="node-index amber">4</span> <span className="node-index amber">4</span>
<strong>{labels.modelBand} Layer</strong> <strong>{labels.modelBand} Layer</strong>
<small>LiteLLM · 4000 · OpenAI-compatible · Anthropic-compatible</small> <small>{modelRouterService ? `${modelRouterService.name} · ${modelRouterService.url}` : 'Not configured'}</small>
{dot(stateOf('litellm'))} {dot(findPortalServiceStatus(services, modelRouterService?.key, portalServices))}
</div> </div>
</button> </button>
@ -145,5 +147,3 @@ export function ArchPipeline({
</section> </section>
); );
} }
export { findServiceDef };

View File

@ -1,50 +0,0 @@
'use client';
import { useState } from 'react';
import type { Tab } from '@/lib/data';
import { Icon } from './Icon';
export function EmbedView({ tab, onBack }: { tab: Tab; onBack: () => void }) {
const [reloadKey, setReloadKey] = useState(0);
const frameBlocked = tab.frameMode === 'external';
return (
<div className="workspace-body">
<section className="embed-panel">
<div className="embed-toolbar">
<button type="button" className="embed-tool" aria-label="Back to workspace" onClick={onBack}>
<Icon name="arrow-left" />
</button>
<strong>{tab.label}</strong>
<span className="embed-url" title={tab.href}>{tab.href}</span>
<div className="embed-toolbar-actions">
<button type="button" className="embed-tool" aria-label="Reload embedded page" onClick={() => setReloadKey((value) => value + 1)} disabled={frameBlocked}>
<Icon name="refresh" />
</button>
<a className="embed-tool" href={tab.href} target="_blank" rel="noreferrer" aria-label="Open in browser">
<Icon name="external" />
</a>
</div>
</div>
{frameBlocked ? (
<div className="external-embed-fallback">
<div>
<span className="external-embed-icon"><Icon name={tab.icon ?? 'external'} /></span>
<strong>{tab.label}</strong>
<p>This service blocks embedded frames. Open it in a dedicated browser tab.</p>
<a href={tab.href} target="_blank" rel="noreferrer">Open {tab.label}</a>
</div>
</div>
) : (
<iframe
key={reloadKey}
title={`${tab.label} workspace`}
src={tab.href}
allow="camera; microphone; display-capture; autoplay; clipboard-read; clipboard-write; fullscreen"
allowFullScreen
referrerPolicy="no-referrer"
/>
)}
</section>
</div>
);
}

View File

@ -1,7 +1,7 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import { findServiceDef } from '@/lib/data'; import { findPortalServiceForStatus } from '@/lib/data';
import type { Labels, RuntimeMetrics, Service } from '@/lib/data'; import type { Labels, RuntimeMetrics, Service } from '@/lib/data';
import { Icon } from './Icon'; import { Icon } from './Icon';
@ -17,14 +17,14 @@ export function PanelsRow({ labels, services, metrics }: { labels: Labels; servi
</div> </div>
<div className="health-row"> <div className="health-row">
{services.map((service) => { {services.map((service) => {
const def = findServiceDef(service.name); const portalService = findPortalServiceForStatus(service.name);
const running = service.state === 'Running'; const running = service.state === 'Running';
return ( return (
<div className="health-item" key={service.name} title={service.name}> <div className="health-item" key={service.name} title={service.name}>
<span className={running ? 'health-icon good' : 'health-icon bad'}> <span className={running ? 'health-icon good' : 'health-icon bad'}>
<Icon name={def?.icon ?? 'cube'} /> <Icon name={portalService?.icon ?? 'cube'} />
</span> </span>
<small>{def?.label ?? service.name}</small> <small>{portalService?.name ?? service.name}</small>
<em>{running ? labels.healthy : labels.degraded}</em> <em>{running ? labels.healthy : labels.degraded}</em>
</div> </div>
); );

View File

@ -0,0 +1,65 @@
'use client';
import { useRef, useState } from 'react';
import type { PortalService } from '@/lib/data';
import { Icon } from './Icon';
export function ServicePanel({ service, onBack }: { service: PortalService; onBack: () => void }) {
const [reloadKey, setReloadKey] = useState(0);
const iframeRef = useRef<HTMLIFrameElement | null>(null);
const external = service.openMode === 'external';
const focusFrame = () => {
iframeRef.current?.focus();
iframeRef.current?.contentWindow?.focus();
};
return (
<div className="workspace-body">
<section className="embed-panel">
<div className="embed-toolbar">
<button type="button" className="embed-tool" aria-label="Back to workspace" onClick={onBack}>
<Icon name="arrow-left" />
</button>
<strong>{service.name}</strong>
<span className="embed-url" title={service.url}>{service.url}</span>
<div className="embed-toolbar-actions">
<button type="button" className="embed-tool" aria-label="Reload embedded page" onClick={() => setReloadKey((value) => value + 1)} disabled={external}>
<Icon name="refresh" />
</button>
<a className="embed-tool" href={service.url} target="_blank" rel="noreferrer" aria-label="Open in browser">
<Icon name="external" />
</a>
</div>
</div>
{external ? (
<div className="external-embed-fallback">
<div>
<span className="external-embed-icon"><Icon name={service.icon ?? 'external'} /></span>
<strong>{service.name}</strong>
<p>{service.description ?? 'Open this service in a dedicated browser tab.'}</p>
<a href={service.url} target="_blank" rel="noreferrer">
<Icon name="external" />
<span>Open {service.name}</span>
</a>
</div>
</div>
) : (
<iframe
ref={iframeRef}
key={reloadKey}
title={`${service.name} workspace`}
src={service.url}
tabIndex={0}
allow="camera; microphone; display-capture; autoplay; clipboard-read; clipboard-write; fullscreen"
allowFullScreen
onLoad={focusFrame}
onFocus={focusFrame}
onPointerDown={focusFrame}
onMouseDown={focusFrame}
referrerPolicy="no-referrer"
style={{ pointerEvents: 'auto' }}
/>
)}
</section>
</div>
);
}

View File

@ -1,12 +1,12 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import { navSections } from '@/lib/data'; import type { Labels, NavItem, NavSection } from '@/lib/data';
import type { Labels, NavItem } from '@/lib/data';
import { Icon } from './Icon'; import { Icon } from './Icon';
export function Sidebar({ export function Sidebar({
labels, labels,
navSections,
collapsed, collapsed,
selectedTab, selectedTab,
onToggle, onToggle,
@ -18,6 +18,7 @@ export function Sidebar({
onToggleRemoteMode, onToggleRemoteMode,
}: { }: {
labels: Labels; labels: Labels;
navSections: NavSection[];
collapsed: boolean; collapsed: boolean;
selectedTab: string; selectedTab: string;
onToggle: () => void; onToggle: () => void;

View File

@ -20,6 +20,7 @@ export function TerminalDrawer({
const [height, setHeight] = useState(250); const [height, setHeight] = useState(250);
const [dragging, setDragging] = useState(false); const [dragging, setDragging] = useState(false);
const dragStart = useRef({ y: 0, height: 250 }); const dragStart = useRef({ y: 0, height: 250 });
const iframeRef = useRef<HTMLIFrameElement | null>(null);
const onDragStart = (event: React.PointerEvent) => { const onDragStart = (event: React.PointerEvent) => {
if (expanded || collapsed) return; if (expanded || collapsed) return;
@ -42,6 +43,11 @@ export function TerminalDrawer({
setDragging(false); setDragging(false);
}; };
const focusTerminal = () => {
iframeRef.current?.focus();
iframeRef.current?.contentWindow?.focus();
};
return ( return (
<section className={[expanded ? 'terminal-drawer expanded' : 'terminal-drawer', collapsed ? 'collapsed' : '', dragging ? 'dragging' : ''].join(' ')}> <section className={[expanded ? 'terminal-drawer expanded' : 'terminal-drawer', collapsed ? 'collapsed' : '', dragging ? 'dragging' : ''].join(' ')}>
<div <div
@ -71,7 +77,18 @@ export function TerminalDrawer({
</div> </div>
<div className="terminal-frame" style={!expanded && !collapsed ? { height } : undefined}> <div className="terminal-frame" style={!expanded && !collapsed ? { height } : undefined}>
{!collapsed ? ( {!collapsed ? (
<iframe title="ttyd terminal" src="http://127.0.0.1:7681" loading="lazy" style={dragging ? { pointerEvents: 'none' } : undefined} /> <iframe
ref={iframeRef}
title="ttyd terminal"
src="http://127.0.0.1:7681"
loading="lazy"
tabIndex={0}
onLoad={focusTerminal}
onFocus={focusTerminal}
onPointerDown={focusTerminal}
onMouseDown={focusTerminal}
style={dragging ? { pointerEvents: 'none' } : undefined}
/>
) : null} ) : null}
</div> </div>
</section> </section>

View File

@ -1,15 +1,17 @@
import type { Labels, NavItem, RuntimeMetrics, Service } from '@/lib/data'; import type { Labels, NavItem, PortalService, RuntimeMetrics, Service } from '@/lib/data';
import { ArchPipeline } from './ArchPipeline'; import { ArchPipeline } from './ArchPipeline';
export function WorkspaceHome({ export function WorkspaceHome({
labels, labels,
services, services,
metrics, metrics,
portalServices,
onOpenService, onOpenService,
}: { }: {
labels: Labels; labels: Labels;
services: Service[]; services: Service[];
metrics: RuntimeMetrics; metrics: RuntimeMetrics;
portalServices: PortalService[];
onOpenService: (item: NavItem) => void; onOpenService: (item: NavItem) => void;
}) { }) {
return ( return (
@ -23,7 +25,7 @@ export function WorkspaceHome({
</div> </div>
</div> </div>
<ArchPipeline labels={labels} services={services} metrics={metrics} onOpenService={onOpenService} /> <ArchPipeline labels={labels} services={services} metrics={metrics} portalServices={portalServices} onOpenService={onOpenService} />
</div> </div>
</section> </section>
</div> </div>

View File

@ -1,7 +1,17 @@
import type { DashboardStatus, Service } from './data'; import type { DashboardStatus, PortalService, Service } from './data';
const BASE = 'http://127.0.0.1:8788'; const BASE = 'http://127.0.0.1:8788';
export type ApiResult<T> = {
data: T | null;
unauthorized: boolean;
};
const authHeaders = (token?: string): HeadersInit => {
const trimmed = token?.trim();
return trimmed ? { Authorization: `Bearer ${trimmed}`, 'X-Bridge-Token': trimmed } : {};
};
const normalizeServiceState = (state?: string): Service['state'] => { const normalizeServiceState = (state?: string): Service['state'] => {
if (state === 'active' || state === 'running' || state === 'Running') return 'Running'; if (state === 'active' || state === 'running' || state === 'Running') return 'Running';
if (state === 'inactive' || state === 'failed' || state === 'Stopped') return 'Stopped'; if (state === 'inactive' || state === 'failed' || state === 'Stopped') return 'Stopped';
@ -17,35 +27,77 @@ const mapService = (item: { name?: string; unit?: string; state?: string; detail
state: normalizeServiceState(item.state), state: normalizeServiceState(item.state),
}); });
export async function fetchDashboardStatus(): Promise<DashboardStatus | null> { export async function fetchAuthStatus(): Promise<{ required: boolean } | null> {
try { try {
const response = await fetch(`${BASE}/health`, { cache: 'no-store' }); const response = await fetch(`${BASE}/auth/status`, { cache: 'no-store' });
if (!response.ok) return null; if (!response.ok) return null;
const data = await response.json(); const data = await response.json();
if (!Array.isArray(data.services)) return null; return { required: Boolean(data.required) };
return {
services: data.services.map(mapService),
metrics: {
activeSessions: Number(data.metrics?.activeSessions ?? 0),
connectedAgents: Number(data.metrics?.connectedAgents ?? 0),
activeModels: Number(data.metrics?.activeModels ?? 0),
skillsAvailable: Number(data.metrics?.skillsAvailable ?? 0),
workers: Number(data.metrics?.workers ?? 0),
},
};
} catch { } catch {
return null; return null;
} }
} }
export async function fetchServices(): Promise<Service[] | null> { export async function fetchDashboardStatus(token?: string): Promise<ApiResult<DashboardStatus>> {
try { try {
const response = await fetch(`${BASE}/services`, { cache: 'no-store' }); const response = await fetch(`${BASE}/health`, { cache: 'no-store', headers: authHeaders(token) });
if (!response.ok) return null; if (response.status === 401) return { data: null, unauthorized: true };
if (!response.ok) return { data: null, unauthorized: false };
const data = await response.json(); const data = await response.json();
if (!Array.isArray(data)) return null; if (!Array.isArray(data.services)) return { data: null, unauthorized: false };
return data.map(mapService); return {
data: {
services: data.services.map(mapService),
metrics: {
activeSessions: Number(data.metrics?.activeSessions ?? 0),
connectedAgents: Number(data.metrics?.connectedAgents ?? 0),
activeModels: Number(data.metrics?.activeModels ?? 0),
skillsAvailable: Number(data.metrics?.skillsAvailable ?? 0),
workers: Number(data.metrics?.workers ?? 0),
},
},
unauthorized: false,
};
} catch { } catch {
return null; return { data: null, unauthorized: false };
} }
} }
export async function fetchServices(token?: string): Promise<ApiResult<Service[]>> {
try {
const response = await fetch(`${BASE}/services`, { cache: 'no-store', headers: authHeaders(token) });
if (response.status === 401) return { data: null, unauthorized: true };
if (!response.ok) return { data: null, unauthorized: false };
const data = await response.json();
if (!Array.isArray(data)) return { data: null, unauthorized: false };
return { data: data.map(mapService), unauthorized: false };
} catch {
return { data: null, unauthorized: false };
}
}
export async function fetchPortalServices(token?: string): Promise<ApiResult<PortalService[]>> {
try {
const response = await fetch(`${BASE}/portal/services`, { cache: 'no-store', headers: authHeaders(token) });
if (response.status === 401) return { data: null, unauthorized: true };
if (!response.ok) return { data: null, unauthorized: false };
const data = await response.json();
const services = Array.isArray(data.services) ? data.services : Array.isArray(data) ? data : null;
if (!services) return { data: null, unauthorized: false };
return {
data: services
.filter((service: Partial<PortalService>) => service.key && service.name && service.url)
.map((service: PortalService) => ({
...service,
openMode: service.openMode === 'external' ? 'external' : 'iframe',
})),
unauthorized: false,
};
} catch {
return { data: null, unauthorized: false };
}
}
export async function validateBridgeToken(token: string): Promise<ApiResult<PortalService[]>> {
return fetchPortalServices(token);
}

View File

@ -6,7 +6,20 @@ export type Tab = {
icon?: string; icon?: string;
closable?: boolean; closable?: boolean;
source?: 'builtin' | 'custom'; source?: 'builtin' | 'custom';
frameMode?: 'iframe' | 'external'; serviceKey?: string;
};
export type PortalService = {
key: string;
name: string;
url: string;
openMode: 'iframe' | 'external';
healthUrl?: string;
description?: string;
icon?: string;
match?: string[];
port?: number;
role?: 'gateway' | 'model-router';
}; };
export type Service = { export type Service = {
@ -37,47 +50,135 @@ export type NavItem = {
icon: string; icon: string;
href: string; href: string;
kind: Tab['kind']; kind: Tab['kind'];
serviceKey?: string;
}; };
export type ServiceDef = NavItem & { export type NavSectionItem = NavItem & {
group: number; group: number;
port?: number; port?: number;
match?: string[]; match?: string[];
frameMode?: Tab['frameMode'];
}; };
export const serviceRegistry: ServiceDef[] = [ export type NavSection = { id: string; titleKey: string; items: NavSectionItem[] };
{ id: 'workspace', label: 'Overview', icon: 'home', href: '#workspace', kind: 'internal', group: 0 },
{ id: 'openclaw', label: 'OpenClaw', icon: 'claw', href: 'http://127.0.0.1:8788/proxy/openclaw/channels', kind: 'embed', group: 1, port: 18789, match: ['openclaw', 'gateway'] }, export const portalServices: PortalService[] = [
{ id: 'vault', label: 'Vault Server', icon: 'shield', href: 'http://127.0.0.1:8788/proxy/vault/ui/', kind: 'embed', group: 1, port: 8200, match: ['vault'] }, {
{ id: 'litellm', label: 'LiteLLM Admin UI', icon: 'chart', href: 'http://localhost:4000/ui', kind: 'embed', group: 1, port: 4000, match: ['litellm', 'lite'] }, key: 'litellm',
{ id: 'bridge', label: 'Bridge', icon: 'bridge', href: '#bridge', kind: 'internal', group: 2, match: ['bridge'] }, name: 'LiteLLM Admin UI',
{ id: 'runtime', label: 'Runtime', icon: 'cube', href: '#runtime', kind: 'internal', group: 2 }, url: 'http://localhost:4000/ui',
{ id: 'terminal', label: 'Terminal', icon: 'terminal', href: 'http://127.0.0.1:7681', kind: 'embed', group: 2, port: 7681 }, openMode: 'iframe',
description: 'Model routing and provider administration.',
icon: 'chart',
match: ['litellm', 'lite'],
port: 4000,
role: 'model-router',
},
{
key: 'openclaw',
name: 'OpenClaw',
url: 'http://127.0.0.1:18789/channels',
openMode: 'external',
description: 'Gateway dashboard. Opens outside the portal because the service blocks embedded frames.',
icon: 'claw',
match: ['openclaw', 'gateway'],
port: 18789,
role: 'gateway',
},
{
key: 'vault',
name: 'Vault Server',
url: 'http://127.0.0.1:8200/ui',
openMode: 'external',
description: 'Vault UI. Opens outside the portal because the service blocks embedded frames.',
icon: 'shield',
match: ['vault'],
port: 8200,
},
{
key: 'terminal',
name: 'Terminal',
url: 'http://127.0.0.1:7681',
openMode: 'iframe',
healthUrl: 'http://127.0.0.1:7681',
description: 'Local ttyd terminal.',
icon: 'terminal',
match: ['ttyd', 'terminal'],
port: 7681,
},
]; ];
export const navSections: { id: string; titleKey: string; items: ServiceDef[] }[] = [ export const workspaceNavItems: NavSectionItem[] = [
{ id: 'workspace', label: 'Overview', icon: 'home', href: '#workspace', kind: 'internal', group: 0 },
{ id: 'architecture', label: 'Architecture', icon: 'network', href: '#architecture', kind: 'internal', group: 0 },
];
export const portalServiceToNavItem = (service: PortalService): NavSectionItem => ({
id: `service-${service.key}`,
label: service.name,
icon: service.icon ?? 'cube',
href: service.url,
kind: 'embed',
group: 1,
port: service.port,
match: service.match,
serviceKey: service.key,
});
export const portalServiceToTab = (service: PortalService): Tab => ({
id: `service-${service.key}`,
label: service.name,
href: service.url,
kind: 'embed',
icon: service.icon ?? 'cube',
closable: true,
source: 'builtin',
serviceKey: service.key,
});
export const portalNavItems = portalServices.map(portalServiceToNavItem);
export const portalTabs = portalServices.map(portalServiceToTab);
export const buildNavSections = (services: PortalService[] = portalServices): NavSection[] => [
{ {
id: 'overview', id: 'overview',
titleKey: '', titleKey: '',
items: [ items: workspaceNavItems,
serviceRegistry.find((item) => item.id === 'workspace')!,
{ id: 'architecture', label: 'Architecture', icon: 'network', href: '#architecture', kind: 'internal', group: 0 },
],
}, },
{ {
id: 'services', id: 'services',
titleKey: 'navServices', titleKey: 'navServices',
items: [ items: services.map(portalServiceToNavItem),
...serviceRegistry.filter((item) => item.group === 1),
serviceRegistry.find((item) => item.id === 'terminal')!,
],
}, },
]; ];
export const findServiceDef = (serviceName: string): ServiceDef | undefined => { export const navSections = buildNavSections();
export const findPortalService = (serviceKey?: string, services: PortalService[] = portalServices): PortalService | undefined => {
if (!serviceKey) return undefined;
return services.find((service) => service.key === serviceKey);
};
export const findPortalServiceByRole = (role: PortalService['role'], services: PortalService[] = portalServices): PortalService | undefined => {
if (!role) return undefined;
return services.find((service) => service.role === role);
};
export const findPortalServiceForStatus = (serviceName: string, services: PortalService[] = portalServices): PortalService | undefined => {
const name = serviceName.toLowerCase(); const name = serviceName.toLowerCase();
return serviceRegistry.find((def) => def.match?.some((token) => name.includes(token))); return services.find((service) => {
const tokens = service.match ?? [service.key, service.name];
return tokens.some((token) => name.includes(token.toLowerCase()));
});
};
export const findPortalServiceStatus = (
services: Service[],
serviceKey?: string,
portalServiceConfig: PortalService[] = portalServices,
): Service['state'] | undefined => {
const serviceConfig = findPortalService(serviceKey, portalServiceConfig);
if (!serviceConfig) return undefined;
return services.find((entry) => findPortalServiceForStatus(entry.name, portalServiceConfig)?.key === serviceConfig.key)?.state;
}; };
export const customWorkspaceTabs: Tab[] = [ export const customWorkspaceTabs: Tab[] = [
@ -85,10 +186,13 @@ export const customWorkspaceTabs: Tab[] = [
{ id: 'bridge-console', label: 'Bridge', href: '#bridge-console', kind: 'internal', icon: 'bridge', closable: true, source: 'custom' }, { id: 'bridge-console', label: 'Bridge', href: '#bridge-console', kind: 'internal', icon: 'bridge', closable: true, source: 'custom' },
]; ];
export const initialTabs: Tab[] = [ export const buildInitialTabs = (services: PortalService[] = portalServices): Tab[] => [
{ id: 'workspace', label: 'Workspace', href: '#workspace', kind: 'internal', icon: 'home', source: 'builtin' }, { id: 'workspace', label: 'Workspace', href: '#workspace', kind: 'internal', icon: 'home', source: 'builtin' },
...services.map(portalServiceToTab),
]; ];
export const initialTabs = buildInitialTabs();
export const fallbackMetrics: RuntimeMetrics = { export const fallbackMetrics: RuntimeMetrics = {
activeSessions: 0, activeSessions: 0,
connectedAgents: 0, connectedAgents: 0,

View File

@ -40,6 +40,72 @@ button {
font: inherit; font: inherit;
} }
.auth-gate {
min-height: 100vh;
display: grid;
place-items: center;
padding: 28px;
background: #edf2fa;
}
.auth-card {
width: min(420px, 100%);
display: grid;
gap: 14px;
padding: 28px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--paper);
box-shadow: var(--shadow);
}
.auth-card .brand-mark {
width: 48px;
height: 48px;
display: inline-grid;
place-items: center;
border-radius: 8px;
color: #fff;
background: linear-gradient(135deg, #4f7df3, #5b4fe3);
font-size: 16px;
font-weight: 900;
}
.auth-card h1 {
margin: 4px 0 0;
font-size: 26px;
}
.auth-card p,
.auth-card small {
margin: 0;
color: var(--muted);
}
.auth-card small {
color: var(--red);
}
.auth-card input {
width: 100%;
height: 46px;
border: 1px solid var(--line-strong);
border-radius: 8px;
padding: 0 12px;
font: inherit;
background: #fff;
}
.auth-card button {
height: 46px;
border: 0;
border-radius: 8px;
color: #fff;
background: var(--blue);
font-weight: 800;
cursor: pointer;
}
.app-shell { .app-shell {
min-height: 100vh; min-height: 100vh;
display: grid; display: grid;
@ -934,7 +1000,7 @@ button {
aspect-ratio: 16 / 10; aspect-ratio: 16 / 10;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 8px; border-radius: 8px;
background: var(--panel); background: #fff;
} }
.external-embed-fallback > div { .external-embed-fallback > div {
@ -952,7 +1018,7 @@ button {
height: 44px; height: 44px;
border-radius: 8px; border-radius: 8px;
background: var(--soft); background: var(--soft);
color: var(--accent); color: var(--blue);
} }
.external-embed-icon .icon { .external-embed-icon .icon {
@ -975,15 +1041,21 @@ button {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
height: 34px; height: 34px;
gap: 8px;
padding: 0 14px; padding: 0 14px;
border-radius: 7px; border-radius: 7px;
background: var(--text); background: var(--text);
color: var(--panel); color: #fff;
font-size: 13px; font-size: 13px;
font-weight: 750; font-weight: 750;
text-decoration: none; text-decoration: none;
} }
.external-embed-fallback a .icon {
width: 14px;
height: 14px;
}
.terminal-drawer { .terminal-drawer {
margin: 18px 22px 28px; margin: 18px 22px 28px;
padding: 18px; padding: 18px;

View File

@ -1,5 +1,5 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -e set -euo pipefail
# ============================================================================== # ==============================================================================
# AI Workspace All-in-One Bootstrap Script # AI Workspace All-in-One Bootstrap Script
@ -15,46 +15,747 @@ set -e
# GATEWAY_OPENCLAW_PUBLIC_ACCESS # GATEWAY_OPENCLAW_PUBLIC_ACCESS
# VAULT_PUBLIC_ACCESS # VAULT_PUBLIC_ACCESS
# XWORKSPACE_CONSOLE_ENABLE_XRDP # XWORKSPACE_CONSOLE_ENABLE_XRDP
# VAULT_PASS (Will be securely passed as vault password if set) # AI_WORKSPACE_AUTH_TOKEN / XWORKSPACE_CONSOLE_AUTH_TOKEN
# / XWORKMATE_BRIDGE_AUTH_TOKEN / BRIDGE_AUTH_TOKEN / INTERNAL_SERVICE_TOKEN
# / DEPLOY_TOKEN
# Unified auth token passed to xworkmate-bridge, LiteLLM, OpenClaw, and Vault.
# PLAYBOOK_DIR (optional local playbooks checkout; useful for macOS validation)
# XWORKSPACE_CONSOLE_DIR (optional local xworkspace-console checkout for macOS)
# AI_WORKSPACE_DARWIN_MODE=local (default on macOS) | ansible
# ============================================================================== # ==============================================================================
REPO_URL=${REPO_URL:-"https://github.com/ai-workspace-infra/playbooks.git"} REPO_URL=${REPO_URL:-"https://github.com/ai-workspace-infra/playbooks.git"}
BRANCH=${BRANCH:-"main"} BRANCH=${BRANCH:-"main"}
TARGET_DIR="/tmp/ai-workspace-deploy" TARGET_DIR=${TARGET_DIR:-"/tmp/ai-workspace-deploy"}
PLAYBOOK_DIR=${PLAYBOOK_DIR:-""}
XWORKSPACE_CONSOLE_REPO_URL=${XWORKSPACE_CONSOLE_REPO_URL:-"https://github.com/ai-workspace-lab/xworkspace-console.git"}
XWORKSPACE_CONSOLE_DIR=${XWORKSPACE_CONSOLE_DIR:-""}
AUTH_TOKEN_FILE=${AI_WORKSPACE_AUTH_TOKEN_FILE:-"$HOME/.ai_workspace_auth_token"}
VAULT_FILE=${AI_WORKSPACE_VAULT_PASSWORD_FILE:-"$HOME/.vault_password"}
# Function: Output messages # Function: Output messages
info() { info() {
echo -e "\033[1;34m[INFO]\033[0m $*" echo -e "\033[1;34m[INFO]\033[0m $*" >&2
} }
success() { success() {
echo -e "\033[1;32m[SUCCESS]\033[0m $*" echo -e "\033[1;32m[SUCCESS]\033[0m $*" >&2
} }
error() { error() {
echo -e "\033[1;31m[ERROR]\033[0m $*" >&2 echo -e "\033[1;31m[ERROR]\033[0m $*" >&2
exit 1 exit 1
} }
info "Starting AI Workspace All-in-One Bootstrap..." mask_secret() {
local val="${1:-}"
if [ -z "$val" ]; then
echo "<empty>"
elif [ "${#val}" -le 8 ]; then
echo "<hidden>"
else
echo "${val:0:4}...${val: -4}"
fi
}
# 1. Install prerequisites (git, curl, ansible) if missing detect_os() {
if ! command -v ansible-playbook >/dev/null 2>&1 || ! command -v git >/dev/null 2>&1; then case "$(uname -s)" in
Darwin) echo "darwin" ;;
Linux) echo "linux" ;;
*) echo "unknown" ;;
esac
}
install_prerequisites() {
local os="$1"
info "Installing required dependencies (git, ansible)..." info "Installing required dependencies (git, ansible)..."
if [ -f /etc/debian_version ]; then if [ "$os" = "linux" ]; then
sudo apt-get update -y if [ -f /etc/debian_version ]; then
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y git curl software-properties-common sudo apt-get update -y
sudo apt-add-repository --yes --update ppa:ansible/ansible sudo DEBIAN_FRONTEND=noninteractive apt-get install -y git curl software-properties-common
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y ansible sudo apt-add-repository --yes --update ppa:ansible/ansible
elif [ -f /etc/redhat-release ]; then sudo DEBIAN_FRONTEND=noninteractive apt-get install -y ansible
sudo yum install -y epel-release elif [ -f /etc/redhat-release ]; then
sudo yum install -y git curl ansible sudo yum install -y epel-release
sudo yum install -y git curl ansible
else
error "Unsupported Linux distribution. Please install git and ansible manually."
fi
elif [ "$os" = "darwin" ]; then
if command -v brew >/dev/null 2>&1; then
brew install git ansible
else
error "macOS requires git and ansible. Install Homebrew or install them manually, then rerun."
fi
else else
error "Unsupported OS. Please install git and ansible manually." error "Unsupported OS. Please install git and ansible manually."
fi fi
success "Dependencies installed." success "Dependencies installed."
}
resolve_unified_auth_token() {
local token="${AI_WORKSPACE_AUTH_TOKEN:-}"
if [ -z "$token" ]; then token="${XWORKSPACE_CONSOLE_AUTH_TOKEN:-}"; fi
if [ -z "$token" ]; then token="${XWORKMATE_BRIDGE_AUTH_TOKEN:-}"; fi
if [ -z "$token" ]; then token="${BRIDGE_AUTH_TOKEN:-}"; fi
if [ -z "$token" ]; then token="${INTERNAL_SERVICE_TOKEN:-}"; fi
if [ -z "$token" ]; then token="${DEPLOY_TOKEN:-}"; fi
if [ -n "$token" ]; then
printf '%s' "$token" > "$AUTH_TOKEN_FILE"
chmod 600 "$AUTH_TOKEN_FILE"
info "Using provided unified auth token: $(mask_secret "$token")"
printf '%s' "$token"
return
fi
if [ -f "$AUTH_TOKEN_FILE" ]; then
info "Found existing unified auth token at $AUTH_TOKEN_FILE, reusing it."
tr -d '\r\n' < "$AUTH_TOKEN_FILE"
return
fi
info "No unified auth token provided. Generating a secure random token..."
openssl rand -base64 32 | tr -d '\r\n' > "$AUTH_TOKEN_FILE"
chmod 600 "$AUTH_TOKEN_FILE"
info "Generated new unified auth token and saved to $AUTH_TOKEN_FILE"
cat "$AUTH_TOKEN_FILE"
}
require_or_install_macos_cmds() {
local missing=()
for cmd in git node npm go curl lsof python3; do
if ! command -v "$cmd" >/dev/null 2>&1; then
missing+=("$cmd")
fi
done
if [ "${#missing[@]}" -eq 0 ]; then
return
fi
if ! command -v brew >/dev/null 2>&1; then
error "Missing required commands on macOS: ${missing[*]}. Install Homebrew or install them manually."
fi
info "Installing missing macOS dependencies: ${missing[*]}"
for cmd in "${missing[@]}"; do
case "$cmd" in
git) brew install git ;;
node|npm) brew install node ;;
go) brew install go ;;
python3) brew install python@3.13 ;;
curl) brew install curl ;;
lsof) error "lsof is part of macOS; it is missing from PATH." ;;
esac
done
}
macos_litellm_python() {
for py in python3.13 python3.12 python3.11; do
if command -v "$py" >/dev/null 2>&1; then
command -v "$py"
return
fi
done
if command -v brew >/dev/null 2>&1; then
info "Installing python@3.13 for LiteLLM compatibility..."
brew install python@3.13
command -v python3.13
return
fi
error "LiteLLM requires Python 3.11-3.13 on macOS. Install python3.13 or Homebrew."
}
macos_openclaw_bin() {
if command -v openclaw >/dev/null 2>&1; then
command -v openclaw
return
fi
local prefix="$HOME/.local/share/xworkspace/node"
info "Installing OpenClaw CLI locally under $prefix..."
mkdir -p "$prefix"
npm install --prefix "$prefix" openclaw@2026.6.1 @openclaw/codex@2026.6.1
printf '%s/bin/openclaw\n' "$prefix"
}
macos_vault_bin() {
if command -v vault >/dev/null 2>&1; then
command -v vault
return
fi
if command -v brew >/dev/null 2>&1; then
info "Installing Vault CLI/server with Homebrew..."
brew install hashicorp/tap/vault || brew install vault
command -v vault
return
fi
error "Vault is required for local macOS deployment. Install vault or Homebrew."
}
macos_ttyd_bin() {
if command -v ttyd >/dev/null 2>&1; then
command -v ttyd
return
fi
if command -v brew >/dev/null 2>&1; then
info "Installing ttyd with Homebrew..."
brew install ttyd
command -v ttyd
return
fi
error "ttyd is required for the local Portal terminal. Install ttyd or Homebrew."
}
macos_postgres_tool() {
local tool=$1
if command -v "$tool" >/dev/null 2>&1; then
command -v "$tool"
return
fi
for base in /opt/homebrew/opt/postgresql@16/bin /usr/local/opt/postgresql@16/bin /opt/homebrew/opt/postgresql@15/bin /usr/local/opt/postgresql@15/bin /opt/homebrew/opt/postgresql@14/bin /usr/local/opt/postgresql@14/bin /opt/homebrew/bin /usr/local/bin; do
if [ -x "$base/$tool" ]; then
printf '%s/%s\n' "$base" "$tool"
return
fi
done
if command -v brew >/dev/null 2>&1; then
info "Installing PostgreSQL for LiteLLM local UI storage..."
brew install postgresql@16 || brew install postgresql
if command -v "$tool" >/dev/null 2>&1; then
command -v "$tool"
return
fi
for base in /opt/homebrew/opt/postgresql@16/bin /usr/local/opt/postgresql@16/bin /opt/homebrew/opt/postgresql/bin /usr/local/opt/postgresql/bin; do
if [ -x "$base/$tool" ]; then
printf '%s/%s\n' "$base" "$tool"
return
fi
done
fi
error "PostgreSQL tool '$tool' is required for local LiteLLM UI login."
}
resolve_console_dir() {
if [ -n "$XWORKSPACE_CONSOLE_DIR" ]; then
[ -d "$XWORKSPACE_CONSOLE_DIR/dashboard" ] && [ -d "$XWORKSPACE_CONSOLE_DIR/api" ] || \
error "XWORKSPACE_CONSOLE_DIR must contain dashboard/ and api/: $XWORKSPACE_CONSOLE_DIR"
cd "$XWORKSPACE_CONSOLE_DIR"
pwd
return
fi
local script_dir=""
if [ -n "${BASH_SOURCE[0]:-}" ]; then
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd -P || true)"
fi
if [ -n "$script_dir" ] && [ -d "$script_dir/../dashboard" ] && [ -d "$script_dir/../api" ]; then
cd "$script_dir/.."
pwd
return
fi
if [ -d "$PWD/dashboard" ] && [ -d "$PWD/api" ]; then
pwd
return
fi
local checkout_dir="${XWORKSPACE_CONSOLE_CHECKOUT_DIR:-$HOME/xworkspace-console}"
if [ -d "$checkout_dir/.git" ]; then
info "Updating xworkspace-console checkout at $checkout_dir..."
git -C "$checkout_dir" fetch origin
git -C "$checkout_dir" reset --hard origin/main
else
info "Cloning xworkspace-console to $checkout_dir..."
git clone "$XWORKSPACE_CONSOLE_REPO_URL" "$checkout_dir"
fi
cd "$checkout_dir"
pwd
}
write_litellm_config() {
local config_file=$1
mkdir -p "$(dirname "$config_file")"
cat > "$config_file" <<'YAML'
model_list: []
general_settings:
master_key: "os.environ/LITELLM_MASTER_KEY"
database_url: "os.environ/DATABASE_URL"
store_model_in_db: true
drop_rate_limit_requests: true
router_settings:
routing_strategy: simple-shuffle
num_retries: 2
retry_after: 30
fallbacks: []
litellm_settings:
drop_params: true
set_verbose: false
request_timeout: 600
telemetry: false
YAML
}
ensure_secret_file() {
local file=$1
mkdir -p "$(dirname "$file")"
if [ -s "$file" ]; then
tr -d '\r\n' < "$file"
return
fi
openssl rand -hex 20 > "$file"
chmod 600 "$file"
cat "$file"
}
ensure_litellm_venv() {
local venv_dir=$1
local py_bin=$2
if [ ! -x "$venv_dir/bin/litellm" ]; then
info "Creating LiteLLM virtualenv at $venv_dir..."
rm -rf "$venv_dir"
"$py_bin" -m venv "$venv_dir"
"$venv_dir/bin/python" -m pip install --upgrade pip
"$venv_dir/bin/python" -m pip install 'litellm[proxy,extra-proxy]'
return
fi
if ! "$venv_dir/bin/python" -c 'import prisma' >/dev/null 2>&1; then
info "Adding LiteLLM database dependencies to existing virtualenv..."
"$venv_dir/bin/python" -m pip install 'litellm[extra-proxy]'
fi
}
ensure_litellm_prisma_client() {
local venv_dir=$1
local database_url=$2
local schema_file
schema_file="$("$venv_dir/bin/python" - <<'PY'
import importlib.util
import pathlib
spec = importlib.util.find_spec("litellm.proxy")
if spec is None or spec.origin is None:
raise SystemExit("Unable to locate litellm.proxy schema")
print(pathlib.Path(spec.origin).with_name("schema.prisma"))
PY
)"
info "Syncing LiteLLM Prisma client and database schema..."
PATH="$venv_dir/bin:$PATH" DATABASE_URL="$database_url" "$venv_dir/bin/prisma" db push --schema "$schema_file"
}
write_local_portal_config() {
local token=$1
local config_dir=$2
mkdir -p "$config_dir"
cat > "$config_dir/portal-services.json" <<'JSON'
{
"services": [
{
"key": "litellm",
"name": "LiteLLM Admin UI",
"url": "http://localhost:4000/ui",
"openMode": "iframe",
"healthUrl": "http://127.0.0.1:4000/ui",
"description": "Model routing and provider administration.",
"icon": "chart",
"match": ["litellm", "lite"],
"port": 4000,
"role": "model-router"
},
{
"key": "openclaw",
"name": "OpenClaw",
"url": "http://127.0.0.1:18789/channels",
"openMode": "external",
"healthUrl": "http://127.0.0.1:18789/channels",
"description": "Gateway dashboard.",
"icon": "claw",
"match": ["openclaw", "gateway"],
"port": 18789,
"role": "gateway"
},
{
"key": "vault",
"name": "Vault Server",
"url": "http://127.0.0.1:8200/ui",
"openMode": "external",
"healthUrl": "http://127.0.0.1:8200/ui",
"description": "Vault UI.",
"icon": "shield",
"match": ["vault"],
"port": 8200
},
{
"key": "terminal",
"name": "Terminal",
"url": "http://127.0.0.1:7681",
"openMode": "iframe",
"healthUrl": "http://127.0.0.1:7681",
"description": "Local ttyd terminal.",
"icon": "terminal",
"match": ["ttyd", "terminal"],
"port": 7681
}
]
}
JSON
printf '%s\n' "$token" > "$config_dir/auth-token"
chmod 600 "$config_dir/auth-token"
printf '%s\n' "$token" > "$AUTH_TOKEN_FILE"
chmod 600 "$AUTH_TOKEN_FILE"
cat > "$config_dir/portal.env" <<EOF
AI_WORKSPACE_AUTH_TOKEN=$token
XWORKSPACE_CONSOLE_AUTH_TOKEN=$token
BRIDGE_AUTH_TOKEN=$token
XWORKMATE_BRIDGE_AUTH_TOKEN=$token
INTERNAL_SERVICE_TOKEN=$token
LITELLM_MASTER_KEY=$token
OPENCLAW_GATEWAY_TOKEN=$token
VAULT_TOKEN=$token
VAULT_SERVER_ROOT_ACCESS_TOKEN=$token
VAULT_ADMIN_PASSWORD=$token
XWORKSPACE_PORTAL_SERVICES_FILE=$config_dir/portal-services.json
EOF
chmod 600 "$config_dir/portal.env"
}
stop_managed_pid() {
local pid_file=$1
if [ ! -f "$pid_file" ]; then
return
fi
local pid
pid="$(cat "$pid_file" 2>/dev/null || true)"
if [ -n "$pid" ] && kill -0 "$pid" >/dev/null 2>&1; then
info "Stopping previous managed process $pid..."
kill "$pid" >/dev/null 2>&1 || true
sleep 1
fi
rm -f "$pid_file"
}
ensure_port_available_for_repo() {
local port=$1
local repo_dir=$2
local pid
pid="$(lsof -nP -tiTCP:"$port" -sTCP:LISTEN | head -n 1 || true)"
if [ -z "$pid" ]; then
return
fi
local cwd
cwd="$(lsof -a -p "$pid" -d cwd -Fn 2>/dev/null | sed -n 's/^n//p' | head -n 1 || true)"
if [ -n "$cwd" ] && [[ "$cwd" == "$repo_dir"* ]]; then
info "Port $port is already used by this checkout (pid $pid); restarting it."
kill "$pid" >/dev/null 2>&1 || true
sleep 1
return
fi
error "Port $port is already in use by pid $pid ($cwd). Stop it or choose a clean local session."
}
ensure_port_available() {
local port=$1
local pid
pid="$(lsof -nP -tiTCP:"$port" -sTCP:LISTEN | head -n 1 || true)"
if [ -z "$pid" ]; then
return
fi
local command_name
command_name="$(ps -p "$pid" -o comm= 2>/dev/null || true)"
error "Port $port is already in use by pid $pid ($command_name). Stop it or choose another port."
}
wait_for_url() {
local url=$1
local header=${2:-}
local attempts=120
local status
for _ in $(seq 1 "$attempts"); do
if [ -n "$header" ]; then
status="$(curl -sS -o /dev/null -w '%{http_code}' -H "$header" "$url" 2>/dev/null || true)"
else
status="$(curl -sS -o /dev/null -w '%{http_code}' "$url" 2>/dev/null || true)"
fi
case "$status" in
2*|3*|401) return 0 ;;
esac
sleep 0.5
done
error "Timed out waiting for $url"
}
wait_for_postgres() {
local pg_isready_bin=$1
local socket_dir=$2
local port=$3
local attempts=60
for _ in $(seq 1 "$attempts"); do
if "$pg_isready_bin" -h "$socket_dir" -p "$port" >/dev/null 2>&1; then
return 0
fi
sleep 0.5
done
error "Timed out waiting for local PostgreSQL on port $port"
}
deploy_launch_agent() {
local label=$1
local workdir=$2
local command=$3
local stdout_log=$4
local stderr_log=$5
local plist_dir="$HOME/Library/LaunchAgents"
local plist="$plist_dir/$label.plist"
local domain="gui/$(id -u)"
mkdir -p "$plist_dir" "$(dirname "$stdout_log")" "$(dirname "$stderr_log")"
cat > "$plist" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>$label</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>-lc</string>
<string>$command</string>
</array>
<key>WorkingDirectory</key>
<string>$workdir</string>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>$stdout_log</string>
<key>StandardErrorPath</key>
<string>$stderr_log</string>
</dict>
</plist>
EOF
launchctl bootout "$domain" "$plist" >/dev/null 2>&1 || true
launchctl bootstrap "$domain" "$plist" >/dev/null
launchctl kickstart -k "$domain/$label" >/dev/null
}
ensure_macos_litellm_database() {
local config_dir=$1
local state_dir=$2
local tool_path=$3
local pg_port="${AI_WORKSPACE_LITELLM_POSTGRES_PORT:-15432}"
local pg_data="$HOME/.local/share/xworkspace/postgres-data"
local pg_socket_dir="$state_dir/postgres-socket"
local db_name="litellm"
local db_user="litellm"
local db_password_file="$config_dir/litellm-db-password"
local db_password postgres_bin initdb_bin psql_bin pg_isready_bin
db_password="$(ensure_secret_file "$db_password_file")"
postgres_bin="$(macos_postgres_tool postgres)"
initdb_bin="$(macos_postgres_tool initdb)"
psql_bin="$(macos_postgres_tool psql)"
pg_isready_bin="$(macos_postgres_tool pg_isready)"
mkdir -p "$pg_data" "$pg_socket_dir"
chmod 700 "$pg_socket_dir"
if [ ! -f "$pg_data/PG_VERSION" ]; then
info "Initializing local PostgreSQL data directory at $pg_data ..."
"$initdb_bin" -D "$pg_data" --auth-local=trust --auth-host=scram-sha-256 >/dev/null
fi
launchctl bootout "gui/$(id -u)" "$HOME/Library/LaunchAgents/plus.svc.xworkspace.postgres.plist" >/dev/null 2>&1 || true
sleep 1
ensure_port_available "$pg_port"
info "Starting local PostgreSQL for LiteLLM on 127.0.0.1:$pg_port ..."
deploy_launch_agent \
"plus.svc.xworkspace.postgres" \
"$HOME" \
"exec /usr/bin/env PATH='$tool_path' '$postgres_bin' -D '$pg_data' -h 127.0.0.1 -p '$pg_port' -k '$pg_socket_dir'" \
"$state_dir/postgres.log" \
"$state_dir/postgres.err.log"
wait_for_postgres "$pg_isready_bin" "$pg_socket_dir" "$pg_port"
"$psql_bin" -h "$pg_socket_dir" -p "$pg_port" -d postgres -v ON_ERROR_STOP=1 >/dev/null <<SQL
DO \$\$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '$db_user') THEN
CREATE ROLE "$db_user" LOGIN PASSWORD '$db_password';
ELSE
ALTER ROLE "$db_user" LOGIN PASSWORD '$db_password';
END IF;
END
\$\$;
SELECT format('CREATE DATABASE %I OWNER %I', '$db_name', '$db_user')
WHERE NOT EXISTS (SELECT 1 FROM pg_database WHERE datname = '$db_name') \gexec
ALTER DATABASE "$db_name" OWNER TO "$db_user";
SQL
PGPASSWORD="$db_password" "$psql_bin" -h 127.0.0.1 -p "$pg_port" -U "$db_user" -d "$db_name" -v ON_ERROR_STOP=1 -Atc 'select 1' >/dev/null
printf 'postgresql://%s:%s@127.0.0.1:%s/%s?sslmode=disable\n' "$db_user" "$db_password" "$pg_port" "$db_name"
}
start_macos_target_services() {
local config_dir=$1
local state_dir=$2
local tool_path=$3
local litellm_py litellm_venv litellm_config litellm_bin openclaw_bin vault_bin ttyd_bin litellm_database_url
chmod 700 "$state_dir"
litellm_py="$(macos_litellm_python)"
litellm_venv="$HOME/.local/share/xworkspace/litellm-venv"
litellm_config="$config_dir/litellm-config.yaml"
ensure_litellm_venv "$litellm_venv" "$litellm_py"
litellm_database_url="$(ensure_macos_litellm_database "$config_dir" "$state_dir" "$tool_path")"
ensure_litellm_prisma_client "$litellm_venv" "$litellm_database_url"
write_litellm_config "$litellm_config"
litellm_bin="$litellm_venv/bin/litellm"
openclaw_bin="$(macos_openclaw_bin)"
vault_bin="$(macos_vault_bin)"
ttyd_bin="$(macos_ttyd_bin)"
launchctl bootout "gui/$(id -u)" "$HOME/Library/LaunchAgents/plus.svc.xworkspace.litellm.plist" >/dev/null 2>&1 || true
launchctl bootout "gui/$(id -u)" "$HOME/Library/LaunchAgents/plus.svc.xworkspace.openclaw.plist" >/dev/null 2>&1 || true
launchctl bootout "gui/$(id -u)" "$HOME/Library/LaunchAgents/plus.svc.xworkspace.vault.plist" >/dev/null 2>&1 || true
launchctl bootout "gui/$(id -u)" "$HOME/Library/LaunchAgents/plus.svc.xworkspace.ttyd.plist" >/dev/null 2>&1 || true
info "Starting LiteLLM on http://127.0.0.1:4000 ..."
deploy_launch_agent \
"plus.svc.xworkspace.litellm" \
"$HOME" \
"exec /usr/bin/env PATH='$tool_path' DATABASE_URL='$litellm_database_url' LITELLM_MASTER_KEY=\"\$(cat '$config_dir/auth-token')\" LITELLM_SALT_KEY=\"\$(cat '$config_dir/auth-token')\" UI_USERNAME=admin UI_PASSWORD=\"\$(cat '$config_dir/auth-token')\" '$litellm_bin' --host 127.0.0.1 --port 4000 --config '$litellm_config' --use_prisma_db_push" \
"$state_dir/litellm.log" \
"$state_dir/litellm.err.log"
wait_for_url "http://127.0.0.1:4000/ui"
info "Starting OpenClaw on http://127.0.0.1:18789/channels ..."
deploy_launch_agent \
"plus.svc.xworkspace.openclaw" \
"$HOME" \
"exec /usr/bin/env PATH='$tool_path' OPENCLAW_GATEWAY_TOKEN=\"\$(cat '$config_dir/auth-token')\" '$openclaw_bin' gateway run --dev --force --bind loopback --auth token --token \"\$(cat '$config_dir/auth-token')\" --port 18789" \
"$state_dir/openclaw.log" \
"$state_dir/openclaw.err.log"
wait_for_url "http://127.0.0.1:18789/channels"
info "Starting Vault on http://127.0.0.1:8200/ui ..."
deploy_launch_agent \
"plus.svc.xworkspace.vault" \
"$HOME" \
"exec /usr/bin/env PATH='$tool_path' VAULT_ADDR=http://127.0.0.1:8200 '$vault_bin' server -dev -dev-listen-address=127.0.0.1:8200 -dev-root-token-id=\"\$(cat '$config_dir/auth-token')\"" \
"$state_dir/vault.log" \
"$state_dir/vault.err.log"
wait_for_url "http://127.0.0.1:8200/ui"
info "Starting ttyd terminal on http://127.0.0.1:7681 ..."
deploy_launch_agent \
"plus.svc.xworkspace.ttyd" \
"$HOME" \
"exec /usr/bin/env PATH='$tool_path' '$ttyd_bin' -W -i 127.0.0.1 -p 7681 -w '$HOME' /bin/zsh -l" \
"$state_dir/ttyd.log" \
"$state_dir/ttyd.err.log"
wait_for_url "http://127.0.0.1:7681/"
}
deploy_macos_local() {
require_or_install_macos_cmds
local token=$1
local console_dir config_dir state_dir api_log dashboard_log api_err dashboard_err go_bin npm_bin node_bin tool_path
console_dir="$(resolve_console_dir)"
config_dir="$HOME/.config/xworkspace"
state_dir="$HOME/.local/state/xworkspace"
go_bin="$(command -v go)"
npm_bin="$(command -v npm)"
node_bin="$(command -v node)"
tool_path="$(dirname "$node_bin"):$(dirname "$go_bin"):$(dirname "$npm_bin"):/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin"
mkdir -p "$state_dir"
api_log="$state_dir/xworkspace-api.log"
dashboard_log="$state_dir/xworkspace-console.log"
api_err="$state_dir/xworkspace-api.err.log"
dashboard_err="$state_dir/xworkspace-console.err.log"
info "Deploying AI Workspace Portal locally on macOS from $console_dir"
write_local_portal_config "$token" "$config_dir"
stop_managed_pid "$state_dir/xworkspace-api.pid"
stop_managed_pid "$state_dir/xworkspace-console.pid"
launchctl bootout "gui/$(id -u)" "$HOME/Library/LaunchAgents/plus.svc.xworkspace.api.plist" >/dev/null 2>&1 || true
launchctl bootout "gui/$(id -u)" "$HOME/Library/LaunchAgents/plus.svc.xworkspace.console.plist" >/dev/null 2>&1 || true
launchctl bootout "gui/$(id -u)" "$HOME/Library/LaunchAgents/plus.svc.xworkspace.litellm.plist" >/dev/null 2>&1 || true
launchctl bootout "gui/$(id -u)" "$HOME/Library/LaunchAgents/plus.svc.xworkspace.openclaw.plist" >/dev/null 2>&1 || true
launchctl bootout "gui/$(id -u)" "$HOME/Library/LaunchAgents/plus.svc.xworkspace.vault.plist" >/dev/null 2>&1 || true
launchctl bootout "gui/$(id -u)" "$HOME/Library/LaunchAgents/plus.svc.xworkspace.ttyd.plist" >/dev/null 2>&1 || true
ensure_port_available_for_repo 8788 "$console_dir"
ensure_port_available_for_repo 17000 "$console_dir"
ensure_port_available_for_repo 4000 "$console_dir"
ensure_port_available_for_repo 18789 "$console_dir"
ensure_port_available_for_repo 8200 "$console_dir"
ensure_port_available_for_repo 7681 "$console_dir"
info "Building dashboard assets..."
(cd "$console_dir/dashboard" && npm install && npm run build)
start_macos_target_services "$config_dir" "$state_dir" "$tool_path"
info "Starting xworkspace API on http://127.0.0.1:8788 ..."
deploy_launch_agent \
"plus.svc.xworkspace.api" \
"$console_dir/api" \
"exec /usr/bin/env PATH='$tool_path' XWORKSPACE_PORTAL_SERVICES_FILE='$config_dir/portal-services.json' '$go_bin' run ." \
"$api_log" \
"$api_err"
wait_for_url "http://127.0.0.1:8788/auth/status"
info "Starting AI Workspace Portal on http://127.0.0.1:17000 ..."
deploy_launch_agent \
"plus.svc.xworkspace.console" \
"$console_dir/dashboard" \
"exec /usr/bin/env PATH='$tool_path' '$npm_bin' run preview -- --host 127.0.0.1 --port 17000" \
"$dashboard_log" \
"$dashboard_err"
wait_for_url "http://127.0.0.1:17000/"
local status_ok status_bad
status_ok="$(curl -sS -o /dev/null -w '%{http_code}' -H "Authorization: Bearer $token" http://127.0.0.1:8788/portal/services)"
status_bad="$(curl -sS -o /dev/null -w '%{http_code}' -H "Authorization: Bearer wrong-token" http://127.0.0.1:8788/portal/services)"
[ "$status_ok" = "200" ] || error "Expected valid token to unlock portal services, got HTTP $status_ok"
[ "$status_bad" = "401" ] || error "Expected invalid token to be rejected, got HTTP $status_bad"
success "AI Workspace Portal is running at http://127.0.0.1:17000/"
info "Use the same xworkmate-bridge token to unlock the Portal."
info "Logs: $api_log, $api_err, $dashboard_log, and $dashboard_err"
}
info "Starting AI Workspace All-in-One Bootstrap..."
# 1. Install prerequisites (git, curl, ansible) if missing
OS_NAME="$(detect_os)"
if [ "$OS_NAME" = "darwin" ] && [ "${AI_WORKSPACE_DARWIN_MODE:-local}" = "local" ]; then
UNIFIED_AUTH_TOKEN="$(resolve_unified_auth_token)"
export AI_WORKSPACE_AUTH_TOKEN="$UNIFIED_AUTH_TOKEN"
export XWORKSPACE_CONSOLE_AUTH_TOKEN="${XWORKSPACE_CONSOLE_AUTH_TOKEN:-$UNIFIED_AUTH_TOKEN}"
export INTERNAL_SERVICE_TOKEN="${INTERNAL_SERVICE_TOKEN:-$UNIFIED_AUTH_TOKEN}"
export BRIDGE_AUTH_TOKEN="${BRIDGE_AUTH_TOKEN:-$UNIFIED_AUTH_TOKEN}"
export XWORKMATE_BRIDGE_AUTH_TOKEN="${XWORKMATE_BRIDGE_AUTH_TOKEN:-$UNIFIED_AUTH_TOKEN}"
export LITELLM_MASTER_KEY="${LITELLM_MASTER_KEY:-$UNIFIED_AUTH_TOKEN}"
export OPENCLAW_GATEWAY_TOKEN="${OPENCLAW_GATEWAY_TOKEN:-$UNIFIED_AUTH_TOKEN}"
export VAULT_TOKEN="${VAULT_TOKEN:-$UNIFIED_AUTH_TOKEN}"
export VAULT_SERVER_ROOT_ACCESS_TOKEN="${VAULT_SERVER_ROOT_ACCESS_TOKEN:-$UNIFIED_AUTH_TOKEN}"
export VAULT_ADMIN_PASSWORD="${VAULT_ADMIN_PASSWORD:-$UNIFIED_AUTH_TOKEN}"
deploy_macos_local "$UNIFIED_AUTH_TOKEN"
exit 0
fi
if ! command -v ansible-playbook >/dev/null 2>&1 || ! command -v git >/dev/null 2>&1; then
install_prerequisites "$OS_NAME"
fi fi
# 2. Clone Repository # 2. Clone Repository
if [ -d "$TARGET_DIR" ]; then if [ -n "$PLAYBOOK_DIR" ]; then
[ -d "$PLAYBOOK_DIR" ] || error "PLAYBOOK_DIR does not exist: $PLAYBOOK_DIR"
info "Using local playbooks repository at $PLAYBOOK_DIR"
cd "$PLAYBOOK_DIR"
elif [ -d "$TARGET_DIR" ]; then
info "Updating existing repository in $TARGET_DIR..." info "Updating existing repository in $TARGET_DIR..."
cd "$TARGET_DIR" cd "$TARGET_DIR"
git fetch origin git fetch origin
@ -66,16 +767,25 @@ else
fi fi
# 3. Construct Ansible variables from Environment Variables # 3. Construct Ansible variables from Environment Variables
ANSIBLE_EXTRA_VARS="" ANSIBLE_EXTRA_VARS=()
# Helper function to append to extra vars if set # Helper function to append to extra vars if set
append_var() { append_var() {
local env_name=$1 local env_name=$1
local ansible_var=$2 local ansible_var=$2
local val="${!env_name}" local val="${!env_name:-}"
if [ -n "$val" ]; then if [ -n "$val" ]; then
info "Applying parameter: $ansible_var = $val" info "Applying parameter: $ansible_var = $val"
ANSIBLE_EXTRA_VARS="$ANSIBLE_EXTRA_VARS -e \"$ansible_var=$val\"" ANSIBLE_EXTRA_VARS+=("-e" "$ansible_var=$val")
fi
}
append_secret_var() {
local ansible_var=$1
local val=$2
if [ -n "$val" ]; then
info "Applying secret parameter: $ansible_var = $(mask_secret "$val")"
ANSIBLE_EXTRA_VARS+=("-e" "$ansible_var=$val")
fi fi
} }
@ -87,29 +797,56 @@ append_var "GATEWAY_OPENCLAW_PUBLIC_ACCESS" "gateway_openclaw_public_access"
append_var "VAULT_PUBLIC_ACCESS" "vault_public_access" append_var "VAULT_PUBLIC_ACCESS" "vault_public_access"
append_var "XWORKSPACE_CONSOLE_ENABLE_XRDP" "xworkspace_console_enable_xrdp" append_var "XWORKSPACE_CONSOLE_ENABLE_XRDP" "xworkspace_console_enable_xrdp"
# 4. Handle Vault Password (Auth Token) # 4. Resolve one auth token for the bridge and downstream service UIs/APIs.
# If DEPLOY_TOKEN is provided, use it. Otherwise, generate a random one or reuse existing. UNIFIED_AUTH_TOKEN="$(resolve_unified_auth_token)"
VAULT_FILE="$HOME/.vault_password" append_secret_var "ai_workspace_auth_token" "$UNIFIED_AUTH_TOKEN"
append_secret_var "xworkspace_console_auth_token" "$UNIFIED_AUTH_TOKEN"
append_secret_var "xworkmate_bridge_auth_token" "$UNIFIED_AUTH_TOKEN"
append_secret_var "litellm_master_key" "$UNIFIED_AUTH_TOKEN"
append_secret_var "litellm_ui_password" "$UNIFIED_AUTH_TOKEN"
append_secret_var "gateway_openclaw_gateway_token" "$UNIFIED_AUTH_TOKEN"
append_secret_var "vault_server_root_access_token" "$UNIFIED_AUTH_TOKEN"
append_secret_var "vault_root_token" "$UNIFIED_AUTH_TOKEN"
append_secret_var "vault_admin_password" "$UNIFIED_AUTH_TOKEN"
ANSIBLE_EXTRA_VARS+=("-e" "vault_admin_init_enabled=true")
if [ -n "$DEPLOY_TOKEN" ]; then # Export environment fallbacks for roles/scripts that read environment directly.
echo "$DEPLOY_TOKEN" > "$VAULT_FILE" export AI_WORKSPACE_AUTH_TOKEN="$UNIFIED_AUTH_TOKEN"
info "Using provided DEPLOY_TOKEN as the Vault password." export XWORKSPACE_CONSOLE_AUTH_TOKEN="${XWORKSPACE_CONSOLE_AUTH_TOKEN:-$UNIFIED_AUTH_TOKEN}"
export INTERNAL_SERVICE_TOKEN="${INTERNAL_SERVICE_TOKEN:-$UNIFIED_AUTH_TOKEN}"
export BRIDGE_AUTH_TOKEN="${BRIDGE_AUTH_TOKEN:-$UNIFIED_AUTH_TOKEN}"
export XWORKMATE_BRIDGE_AUTH_TOKEN="${XWORKMATE_BRIDGE_AUTH_TOKEN:-$UNIFIED_AUTH_TOKEN}"
export LITELLM_MASTER_KEY="${LITELLM_MASTER_KEY:-$UNIFIED_AUTH_TOKEN}"
export OPENCLAW_GATEWAY_TOKEN="${OPENCLAW_GATEWAY_TOKEN:-$UNIFIED_AUTH_TOKEN}"
export VAULT_TOKEN="${VAULT_TOKEN:-$UNIFIED_AUTH_TOKEN}"
export VAULT_SERVER_ROOT_ACCESS_TOKEN="${VAULT_SERVER_ROOT_ACCESS_TOKEN:-$UNIFIED_AUTH_TOKEN}"
export VAULT_ADMIN_PASSWORD="${VAULT_ADMIN_PASSWORD:-$UNIFIED_AUTH_TOKEN}"
# 5. Handle Ansible Vault password.
# Keep this separate from the runtime auth token, but reuse DEPLOY_TOKEN for
# backward compatibility when no explicit vault password is provided.
if [ -n "${ANSIBLE_VAULT_PASSWORD:-}" ]; then
printf '%s' "$ANSIBLE_VAULT_PASSWORD" > "$VAULT_FILE"
info "Using provided ANSIBLE_VAULT_PASSWORD for Ansible Vault."
elif [ -n "${DEPLOY_TOKEN:-}" ]; then
printf '%s' "$DEPLOY_TOKEN" > "$VAULT_FILE"
info "Using DEPLOY_TOKEN as the Ansible Vault password for backward compatibility."
elif [ -f "$VAULT_FILE" ]; then elif [ -f "$VAULT_FILE" ]; then
info "Found existing Vault password at $VAULT_FILE, reusing it." info "Found existing Ansible Vault password at $VAULT_FILE, reusing it."
else else
info "No DEPLOY_TOKEN provided and no existing vault password found. Generating a secure random token..." info "No Ansible Vault password provided. Generating a secure random password..."
# Generate a random 32-character token
openssl rand -base64 32 > "$VAULT_FILE" openssl rand -base64 32 > "$VAULT_FILE"
info "Generated new Vault password and saved to $VAULT_FILE" info "Generated new Ansible Vault password and saved to $VAULT_FILE"
fi fi
# Ensure correct permissions for the vault file # Ensure correct permissions for the vault file
chmod 600 "$VAULT_FILE" chmod 600 "$VAULT_FILE"
VAULT_OPT="--vault-password-file $VAULT_FILE"
# 5. Run Ansible Playbook locally # 6. Run Ansible Playbook locally
info "Running Ansible Playbook locally..." info "Running Ansible Playbook locally..."
eval "ansible-playbook -i '127.0.0.1,' -c local setup-ai-workspace-all-in-one.yml $VAULT_OPT $ANSIBLE_EXTRA_VARS" ansible-playbook -i '127.0.0.1,' -c local setup-ai-workspace-all-in-one.yml \
--vault-password-file "$VAULT_FILE" \
"${ANSIBLE_EXTRA_VARS[@]}"
RET=$? RET=$?
if [ $RET -eq 0 ]; then if [ $RET -eq 0 ]; then