Skip to content
Closed
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
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,12 @@ reads JSONL files below `.codex/sessions` and `.claude/projects`.
The UI server owns login identity and audit attribution; the daemon does not
manage browser users. Local password login is a single emergency account from
`AUTH_USERNAME`/`AUTH_PASSWORD`, while OAuth identities are discovered during
login. There is intentionally no user-management API or user CRUD.
login. There is intentionally no user-management API or user CRUD. When neither
password login nor OAuth is configured, browser access is read-only: query APIs
remain available, while mutations, command execution, interactive terminals,
and Jupyter proxy access return HTTP 403. Configure either authentication method
to enable those operations. Webhook ingress and the runtime LLM facade retain
their existing public-access behavior.

Database migrations live under `internal/dbmigrate/migrations/` and use one
ordered sequence for the UI database. Applied migrations are tracked with
Expand Down
13 changes: 9 additions & 4 deletions internal/app/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,8 @@ func TestTokenManagementAndMachineProxyIntegration(t *testing.T) {
w.WriteHeader(http.StatusNoContent)
}))
defer upstream.Close()
t.Setenv("AUTH_PASSWORD", "")
t.Setenv("AUTH_SECRET", "")
t.Setenv("AUTH_PASSWORD", "secret")
t.Setenv("AUTH_SECRET", "test-secret")
t.Setenv("AGENT_COMPOSE_URL", upstream.URL)
t.Setenv("UI_DATABASE_PATH", t.TempDir()+"/ui.db")

Expand All @@ -57,6 +57,7 @@ func TestTokenManagementAndMachineProxyIntegration(t *testing.T) {

create := httptest.NewRequest(http.MethodPost, "/api/ui/v1/tokens", strings.NewReader(`{"name":"automation","role":"admin","expiresInDays":90}`))
create.Header.Set("Content-Type", "application/json")
create.SetBasicAuth("admin", "secret")
createdResponse := httptest.NewRecorder()
browser.ServeHTTP(createdResponse, create)
if createdResponse.Code != http.StatusCreated {
Expand All @@ -70,7 +71,9 @@ func TestTokenManagementAndMachineProxyIntegration(t *testing.T) {
t.Fatalf("created response = %q, err = %v", createdResponse.Body.String(), err)
}
auditResponse := httptest.NewRecorder()
browser.ServeHTTP(auditResponse, httptest.NewRequest(http.MethodGet, "/api/ui/v1/audit/events", nil))
auditRequest := httptest.NewRequest(http.MethodGet, "/api/ui/v1/audit/events", nil)
auditRequest.SetBasicAuth("admin", "secret")
browser.ServeHTTP(auditResponse, auditRequest)
if auditResponse.Code != http.StatusOK || !strings.Contains(auditResponse.Body.String(), "POST /api/ui/v1/tokens") {
t.Fatalf("audit response = %d: %s", auditResponse.Code, auditResponse.Body.String())
}
Expand All @@ -83,7 +86,9 @@ func TestTokenManagementAndMachineProxyIntegration(t *testing.T) {
t.Fatalf("proxy status = %d: %s", response.Code, response.Body.String())
}
auditResponse = httptest.NewRecorder()
browser.ServeHTTP(auditResponse, httptest.NewRequest(http.MethodGet, "/api/ui/v1/audit/events", nil))
auditRequest = httptest.NewRequest(http.MethodGet, "/api/ui/v1/audit/events", nil)
auditRequest.SetBasicAuth("admin", "secret")
browser.ServeHTTP(auditResponse, auditRequest)
if auditResponse.Code != http.StatusOK || !strings.Contains(auditResponse.Body.String(), `"id":"token:`+created.ID+`"`) ||
!strings.Contains(auditResponse.Body.String(), `"displayName":"automation"`) {
t.Fatalf("token audit attribution = %d: %s", auditResponse.Code, auditResponse.Body.String())
Expand Down
9 changes: 7 additions & 2 deletions internal/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -129,9 +129,14 @@ func (a *Manager) Protect(next echo.HandlerFunc) echo.HandlerFunc {
if !a.enabled {
principal := audit.Principal{ID: "local:default", Source: "local", Username: "local", DisplayName: "local", AuthMethod: "disabled"}
c.SetRequest(r.WithContext(audit.WithPrincipal(r.Context(), principal)))
return next(c)
if isPublicAuthRequest(r) || isRuntimeLLMFacadeRequest(r) || isReadOnlyRequest(r) {
return next(c)
}
a.record(r, audit.Input{Actor: principal, Category: "authentication", Action: "access.denied", Method: r.Method, Path: r.URL.Path, Outcome: "denied", Status: http.StatusForbidden})
c.Response().Header().Set("Cache-Control", "no-store")
return echoJSON(c, http.StatusForbidden, map[string]string{"code": "permission_denied", "message": "authentication is required for this operation"})
}
if isPublicAuthPath(r.URL.Path) || isRuntimeLLMFacadeRequest(r) {
if isPublicAuthRequest(r) || isRuntimeLLMFacadeRequest(r) {
return next(c)
}
if !a.protectsPath(r.URL.Path, r.Header.Get("Accept")) {
Expand Down
88 changes: 88 additions & 0 deletions internal/auth/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import (
"strings"
"testing"

"agent-compose-ui/internal/audit"

"github.com/labstack/echo/v4"
)

Expand All @@ -27,6 +29,92 @@ func TestAuthStatusDisabled(t *testing.T) {
}
}

func TestDisabledAuthAllowsOnlyReadOnlyRequests(t *testing.T) {
t.Setenv("AUTH_PASSWORD", "")
t.Setenv("OAUTH_APIKEY", "")

tests := []struct {
name string
method string
path string
want int
}{
{"query rpc", http.MethodPost, "/agentcompose.v2.RunService/GetRun", http.StatusNoContent},
{"stream query rpc", http.MethodPost, "/agentcompose.v2.ProjectService/StreamSchedulerRuns", http.StatusNoContent},
{"health rpc", http.MethodPost, "/health.v1.HealthService/Status", http.StatusNoContent},
{"rest query", http.MethodGet, "/api/events/event-1/trace", http.StatusNoContent},
{"ui query", http.MethodGet, "/api/ui/v1/projects/project-1/yaml", http.StatusNoContent},
{"run mutation", http.MethodPost, "/agentcompose.v2.RunService/RunAgent", http.StatusForbidden},
{"settings mutation", http.MethodPost, "/agentcompose.v2.SettingsService/UpdateGlobalEnv", http.StatusForbidden},
{"workspace upload", http.MethodPost, "/api/agent-compose/workspaces/workspace-1/upload", http.StatusForbidden},
{"terminal websocket", http.MethodGet, "/api/terminal/attach", http.StatusForbidden},
{"jupyter proxy", http.MethodGet, "/jupyter/sandbox-1/lab", http.StatusForbidden},
{"legacy jupyter proxy", http.MethodGet, "/agent-compose/session/sandbox-1/lab", http.StatusForbidden},
{"future api", http.MethodGet, "/api/future-query", http.StatusForbidden},
{"future mutation", http.MethodPatch, "/future/write-api", http.StatusForbidden},
{"webhook ingress", http.MethodPost, "/api/webhooks/webhook.github.push", http.StatusNoContent},
{"webhook non-ingress method", http.MethodDelete, "/api/webhooks/webhook.github.push", http.StatusForbidden},
{"runtime llm facade", http.MethodPost, "/api/runtime/sandboxes/sandbox-1/llm/openai/v1/responses", http.StatusNoContent},
{"legacy runtime llm facade", http.MethodPost, "/api/runtime/sessions/sandbox-1/llm/openai/v1/responses", http.StatusNoContent},
{"malformed runtime llm facade", http.MethodPost, "/api/runtime/sandboxes/sandbox-1/extra/llm/openai/v1/responses", http.StatusForbidden},
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
called := false
auth := NewManagerFromEnv()
handler := newTestApp(auth, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
called = true
w.WriteHeader(http.StatusNoContent)
}))
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, httptest.NewRequest(test.method, test.path, nil))

if recorder.Code != test.want {
t.Fatalf("status = %d, want %d: %s", recorder.Code, test.want, recorder.Body.String())
}
if called != (test.want == http.StatusNoContent) {
t.Fatalf("backend called = %t", called)
}
if test.want == http.StatusForbidden {
if recorder.Header().Get("Cache-Control") != "no-store" || !strings.Contains(recorder.Body.String(), `"code":"permission_denied"`) {
t.Fatalf("forbidden response headers=%v body=%s", recorder.Header(), recorder.Body.String())
}
}
})
}
}

func TestDisabledAuthAuditsDeniedOperation(t *testing.T) {
t.Setenv("AUTH_PASSWORD", "")
t.Setenv("OAUTH_APIKEY", "")
store, err := audit.OpenStore(t.TempDir()+"/ui.db", 180)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = store.Close() })

auth := NewManagerFromEnv(store)
handler := newTestApp(auth, http.NotFoundHandler())
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, "/agentcompose.v2.RunService/RunAgent", nil))
if recorder.Code != http.StatusForbidden {
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusForbidden)
}

page, err := store.Query(t.Context(), audit.Filter{Limit: 10})
if err != nil {
t.Fatal(err)
}
if len(page.Items) != 1 {
t.Fatalf("audit items = %d, want 1", len(page.Items))
}
event := page.Items[0]
if event.Actor.ID != "local:default" || event.Action != "access.denied" || event.Outcome != "denied" || event.Status != http.StatusForbidden {
t.Fatalf("audit event = %#v", event)
}
}

func TestAuthProtectsRPCAndAcceptsBasicAuth(t *testing.T) {
t.Setenv("AUTH_USERNAME", "admin")
t.Setenv("AUTH_PASSWORD", "secret")
Expand Down
35 changes: 25 additions & 10 deletions internal/auth/paths.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,23 +6,38 @@ import (
"strings"
)

func isPublicAuthPath(path string) bool {
if strings.HasPrefix(path, "/api/webhooks/") {
return true
func isPublicAuthRequest(r *http.Request) bool {
switch r.URL.Path {
case "/login":
return r.Method == http.MethodGet || r.Method == http.MethodHead
case "/api/auth/status", "/oauth/authorize", "/oauth/callback":
return r.Method == http.MethodGet || r.Method == http.MethodHead
case "/api/auth/login", "/api/auth/logout":
return r.Method == http.MethodPost
default:
return r.Method == http.MethodPost && strings.HasPrefix(r.URL.Path, "/api/webhooks/")
}
return path == "/login" || path == "/api/auth/status" || path == "/api/auth/login" || path == "/api/auth/logout" ||
path == "/oauth/authorize" || path == "/oauth/callback"
}

func isRuntimeLLMFacadeRequest(r *http.Request) bool {
if r.Method != http.MethodPost {
return false
}
path := strings.TrimRight(r.URL.Path, "/")
return strings.HasPrefix(path, "/api/runtime/sessions/") &&
(strings.HasSuffix(path, "/llm/openai/v1/responses") ||
strings.HasSuffix(path, "/llm/openai/v1/chat/completions") ||
strings.HasSuffix(path, "/llm/anthropic/v1/messages"))
for _, prefix := range []string{"/api/runtime/sandboxes/", "/api/runtime/sessions/"} {
if !strings.HasPrefix(r.URL.Path, prefix) {
continue
}
parts := strings.Split(strings.TrimPrefix(r.URL.Path, prefix), "/")
switch {
case len(parts) == 5 && parts[0] != "" && parts[1] == "llm" && parts[2] == "openai" && parts[3] == "v1" && parts[4] == "responses":
return true
case len(parts) == 6 && parts[0] != "" && parts[1] == "llm" && parts[2] == "openai" && parts[3] == "v1" && parts[4] == "chat" && parts[5] == "completions":
return true
case len(parts) == 5 && parts[0] != "" && parts[1] == "llm" && parts[2] == "anthropic" && parts[3] == "v1" && parts[4] == "messages":
return true
}
}
return false
}

func acceptsHTML(r *http.Request) bool {
Expand Down
99 changes: 99 additions & 0 deletions internal/auth/read_only.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package auth

import (
"net/http"
"strings"
)

var readOnlyProcedures = map[string]struct{}{
"/agentcompose.v2.ProjectService/GetProject": {},
"/agentcompose.v2.ProjectService/ListProjects": {},
"/agentcompose.v2.ProjectService/WatchProject": {},
"/agentcompose.v2.ProjectService/GetScheduler": {},
"/agentcompose.v2.ProjectService/ListSchedulers": {},
"/agentcompose.v2.ProjectService/ListSchedulerEvents": {},
"/agentcompose.v2.ProjectService/ListProjectSchedulerEvents": {},
"/agentcompose.v2.ProjectService/StreamProjectSchedulerEvents": {},
"/agentcompose.v2.ProjectService/GetSchedulerRun": {},
"/agentcompose.v2.ProjectService/ListSchedulerRuns": {},
"/agentcompose.v2.ProjectService/BatchGetLatestSchedulerRuns": {},
"/agentcompose.v2.ProjectService/StreamSchedulerRuns": {},
"/agentcompose.v2.RunService/GetRun": {},
"/agentcompose.v2.RunService/ListRuns": {},
"/agentcompose.v2.RunService/FollowRunLogs": {},
"/agentcompose.v2.RunService/ListRunEvents": {},
"/agentcompose.v2.RunService/ListSandboxRunEvents": {},
"/agentcompose.v2.ImageService/ListImages": {},
"/agentcompose.v2.ImageService/InspectImage": {},
"/agentcompose.v2.CacheService/ListCaches": {},
"/agentcompose.v2.CacheService/InspectCache": {},
"/agentcompose.v2.VolumeService/ListVolumes": {},
"/agentcompose.v2.VolumeService/InspectVolume": {},
"/agentcompose.v2.SandboxService/GetSandboxStats": {},
"/agentcompose.v2.SandboxService/GetSandbox": {},
"/agentcompose.v2.SandboxService/ListSandboxes": {},
"/agentcompose.v2.SandboxService/ListSandboxHistory": {},
"/agentcompose.v2.SandboxService/WatchSandbox": {},
"/agentcompose.v2.DashboardService/GetDashboardOverview": {},
"/agentcompose.v2.DashboardService/WatchDashboardOverview": {},
"/agentcompose.v2.SettingsService/GetGlobalEnv": {},
"/agentcompose.v2.SettingsService/GetCapabilityGatewayConfig": {},
"/agentcompose.v2.SettingsService/ListWorkspacePresets": {},
"/agentcompose.v2.CapabilityService/GetCapabilityStatus": {},
"/agentcompose.v2.CapabilityService/ListCapabilitySets": {},
"/agentcompose.v2.CapabilityService/GetCapabilityCatalog": {},
"/agentcompose.v2.ResourceService/ResolveID": {},
"/health.v1.HealthService/Status": {},
"/health.v1.HealthService/WatchStatus": {},
}

func isReadOnlyRequest(r *http.Request) bool {
if r.URL.RawPath != "" {
return false
}
if r.Method == http.MethodPost {
_, ok := readOnlyProcedures[r.URL.Path]
return ok
}
if r.Method != http.MethodGet && r.Method != http.MethodHead {
return false
}
return isReadOnlyRESTPath(r.URL.Path) || isReadOnlyUIPath(r.URL.Path)
}

func isReadOnlyUIPath(path string) bool {
if path == "/api/ui/v1/projects" || path == "/api/ui/v1/runs/unlinked" || path == "/api/ui/v1/tokens" {
return true
}
if path == "/api/ui/v1/audit/events" || path == "/api/ui/v1/audit/export" {
return true
}
if strings.HasPrefix(path, "/api/ui/v1/projects/") {
segments := strings.Split(strings.TrimPrefix(path, "/api/ui/v1/projects/"), "/")
return (len(segments) == 1 && segments[0] != "") ||
(len(segments) == 2 && segments[0] != "" && segments[1] == "yaml")
}
const sandboxPrefix = "/api/ui/v1/sandboxes/"
if strings.HasPrefix(path, sandboxPrefix) {
segments := strings.Split(strings.TrimPrefix(path, sandboxPrefix), "/")
return len(segments) >= 2 && segments[0] != "" && segments[1] == "agent-records"
}
return false
}

func isReadOnlyRESTPath(path string) bool {
if path == "/api/version" || path == "/api/webhook-sources" || path == "/api/events" || path == "/api/events/topics" {
return true
}
if strings.HasPrefix(path, "/api/events/") {
segments := strings.Split(strings.TrimPrefix(path, "/api/events/"), "/")
return (len(segments) == 1 && segments[0] != "") ||
(len(segments) == 2 && segments[0] != "" && (segments[1] == "sessions" || segments[1] == "sandboxes" || segments[1] == "runs" || segments[1] == "trace"))
}
const workspacePrefix = "/api/agent-compose/workspaces/"
if strings.HasPrefix(path, workspacePrefix) {
segments := strings.Split(strings.TrimPrefix(path, workspacePrefix), "/")
return len(segments) == 2 && segments[0] != "" && (segments[1] == "files" || segments[1] == "download")
}
return false
}
26 changes: 16 additions & 10 deletions src/AppShell.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,8 @@
const path = router.path.replace(/\/+$/, '') || '/';
let target = '';
if (path === '/ui' || path === '/workbench') target = '/';
else if (path === '/automation-tasks') target = '/automations';
else if (path === '/automation-tasks' || path.startsWith('/automations')) target = '/projects';
else if (path.startsWith('/automation-runs')) target = '/projects';
else if (path === '/agents') target = '/projects';
else if (path.startsWith('/debug/runs/')) target = `/runs/${path.slice('/debug/runs/'.length)}/terminal`;
if (target) router.replace(target);
Expand Down Expand Up @@ -118,6 +119,11 @@
const runDetailId = $derived(matchDetail('/runs', p));
const sandboxDetailId = $derived(matchDetail('/sandboxes', p));

function projectSubroute(path: string, segment: 'automations' | 'automation-runs'): boolean {
const parts = path.split('/').filter(Boolean);
return parts[0] === 'projects' && Boolean(parts[1]) && parts[2] === segment;
}

$effect(() => {
void p;
mobileNavigationOpen = false;
Expand Down Expand Up @@ -190,25 +196,25 @@
class="min-h-0 min-w-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-contain pb-[env(safe-area-inset-bottom)]"
>
{#if p === '/'}
<Overview />
{:else if p.startsWith('/projects') || p.startsWith('/agents')}
<Projects />
{:else if p.startsWith('/automation-runs/')}
<Overview canWrite={auth.enabled} />
{:else if projectSubroute(p, 'automation-runs')}
<AutomationRunDetail />
{:else if p.startsWith('/automations')}
{:else if projectSubroute(p, 'automations')}
<Automations />
{:else if p.startsWith('/projects') || p.startsWith('/agents')}
<Projects />
{:else if p.startsWith('/events/')}
<EventDetail />
<EventDetail canWrite={auth.enabled} />
{:else if p.startsWith('/events')}
<Events />
{:else if sandboxDetailId}
<SandboxDetail />
<SandboxDetail canWrite={auth.enabled} />
{:else if p.startsWith('/sandboxes')}
<Sandboxes />
{:else if p === '/runs/unlinked'}
<UnlinkedRuns />
{:else if runDetailId}
<RunDetail />
<RunDetail canWrite={auth.enabled} />
{:else if p.startsWith('/settings/caches')}
<Caches />
{:else if p.startsWith('/audit')}
Expand All @@ -226,7 +232,7 @@
{:else if p.startsWith('/skills')}
<SpecResources kind="skills" />
{:else}
<Overview />
<Overview canWrite={auth.enabled} />
{/if}
</main>
</div>
Expand Down
Loading
Loading