Something went wrong. Try again.
small gleam coding and persistent agent daemon with a detachable cli and a canvas ui forked from okami.mom/albedo
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561package webui
import ( "bufio" "context" "encoding/json" "io" "net" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "sync/atomic" "testing" "time"
"albedo/cli/internal/daemon")
// These cover the boundary that keeps the daemon token out of the browser;// the e2e suites drive the daemon through the CLI and never load this page.
type fakeUpstream struct { snapshot daemon.ConnectionSnapshot refreshes atomic.Int32}
func (f *fakeUpstream) Snapshot() daemon.ConnectionSnapshot { return f.snapshot }func (f *fakeUpstream) Refresh(context.Context) error { f.refreshes.Add(1) return nil}
func newServer(t *testing.T, daemonHandler http.HandlerFunc) (*Server, *fakeUpstream) { t.Helper() t.Setenv("ALBEDO_HOME", t.TempDir()) dist := t.TempDir() if err := os.WriteFile(filepath.Join(dist, "index.html"), []byte("<!doctype html>"), 0o600); err != nil { t.Fatal(err) } upstream := &fakeUpstream{snapshot: daemon.ConnectionSnapshot{Token: "secret"}} if daemonHandler != nil { backend := httptest.NewServer(daemonHandler) t.Cleanup(backend.Close) upstream.snapshot.Port = backend.Listener.Addr().(*net.TCPAddr).Port } server, err := New(dist, upstream) if err != nil { t.Fatal(err) } return server, upstream}
func request(method, target string, cookie string) *http.Request { r := httptest.NewRequest(method, "http://127.0.0.1:4317"+target, nil) if cookie != "" { r.AddCookie(&http.Cookie{Name: cookieName, Value: cookie}) } return r}
// refuseOrigins checks that the server answers each browser Origin on host// with 403.func refuseOrigins(t *testing.T, server *Server, host string, origins map[string]string) { t.Helper() for name, origin := range origins { t.Run(name, func(t *testing.T) { call := request(http.MethodPost, "/api/sessions", server.key) call.Host = host call.Header.Set("Origin", origin) response := httptest.NewRecorder() server.ServeHTTP(response, call) if response.Code != http.StatusForbidden { t.Errorf("Origin %q on Host %q answered %d, want 403", origin, host, response.Code) } }) }}
func TestLaunchKeyBecomesCookieAndUnlocksTheAPI(t *testing.T) { var forwarded *http.Request server, _ := newServer(t, func(w http.ResponseWriter, r *http.Request) { forwarded = r.Clone(context.Background()) w.WriteHeader(http.StatusOK) })
signIn := httptest.NewRecorder() server.ServeHTTP(signIn, request(http.MethodGet, "/?key="+server.key, "")) if signIn.Code != http.StatusSeeOther || signIn.Header().Get("Location") != "/" { t.Fatalf("sign-in answered %d to %q", signIn.Code, signIn.Header().Get("Location")) } cookies := signIn.Result().Cookies() if len(cookies) != 1 || cookies[0].Value != server.key || !cookies[0].HttpOnly || cookies[0].SameSite != http.SameSiteStrictMode { t.Fatalf("sign-in set %+v", cookies) }
call := request(http.MethodGet, "/api/sessions?scope=roots", server.key) call.Header.Set("Origin", "http://127.0.0.1:4317") response := httptest.NewRecorder() server.ServeHTTP(response, call) if response.Code != http.StatusOK || forwarded == nil { t.Fatalf("api answered %d, forwarded %v", response.Code, forwarded != nil) } if forwarded.URL.Path != "/sessions" || forwarded.URL.RawQuery != "scope=roots" { t.Errorf("forwarded %s?%s", forwarded.URL.Path, forwarded.URL.RawQuery) } if forwarded.Header.Get("Authorization") != "Bearer secret" { t.Errorf("forwarded authorization %q", forwarded.Header.Get("Authorization")) } if forwarded.Header.Get("Origin") != "" || forwarded.Header.Get("Cookie") != "" { t.Errorf("forwarded browser headers: origin %q cookie %q", forwarded.Header.Get("Origin"), forwarded.Header.Get("Cookie")) }}
func TestRefusesWithoutReachingTheDaemon(t *testing.T) { var reached atomic.Bool server, _ := newServer(t, func(http.ResponseWriter, *http.Request) { reached.Store(true) })
crossOrigin := request(http.MethodPost, "/api/sessions", server.key) crossOrigin.Header.Set("Origin", "http://127.0.0.1:9999") rebound := request(http.MethodGet, "/api/sessions", server.key) rebound.Host = "evil.example:4317"
for name, test := range map[string]struct { request *http.Request status int }{ "no cookie": {request(http.MethodGet, "/api/sessions", ""), http.StatusUnauthorized}, "wrong cookie": {request(http.MethodGet, "/api/sessions", "guess"), http.StatusUnauthorized}, "wrong key": {request(http.MethodGet, "/?key=guess", ""), http.StatusForbidden}, "cross origin": {crossOrigin, http.StatusForbidden}, "foreign host": {rebound, http.StatusMisdirectedRequest}, } { response := httptest.NewRecorder() server.ServeHTTP(response, test.request) if response.Code != test.status { t.Errorf("%s: status %d, want %d", name, response.Code, test.status) } if len(response.Result().Cookies()) != 0 { t.Errorf("%s: set a cookie", name) } } if reached.Load() { t.Fatal("a refused request reached the daemon") }}
func TestNetworksReadAsCIDROrTrailingZeros(t *testing.T) { for text, want := range map[string]string{ "10.0.0.0": "10.0.0.0/8", "100.64.0.0": "100.64.0.0/16", "192.168.1.0": "192.168.1.0/24", "10.0.0.174": "10.0.0.174/32", "100.64.0.0/10": "100.64.0.0/10", "192.168.1.7/24": "192.168.1.0/24", } { network, err := ParseNetwork(text) if err != nil || network.String() != want { t.Errorf("%s read as %v (%v), want %s", text, network, err, want) } } for _, text := range []string{"", "lan", "fe80::1", "10.0.0"} { if _, err := ParseNetwork(text); err == nil { t.Errorf("%q was accepted", text) } }}
func TestSharedNetworksStillNeedTheKey(t *testing.T) { server, _ := newServer(t, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) home, _ := ParseNetwork("10.0.0.0") if shared := server.Share([]*net.IPNet{home}, []string{"10.0.0.5", "100.64.0.5"}); len(shared) != 1 || shared[0] != "10.0.0.5" { t.Fatalf("shared %v", shared) } lan := func(target, cookie, from string) *http.Request { r := request(http.MethodGet, target, cookie) r.Host = "10.0.0.5:4317" r.RemoteAddr = from + ":50000" return r } outside := lan("/api/sessions", server.key, "10.0.0.9") outside.Host = "100.64.0.5:4317"
for name, test := range map[string]struct { request *http.Request status int }{ "signed out": {lan("/api/sessions", "", "10.0.0.9"), http.StatusUnauthorized}, "sign in": {lan("/?key="+server.key, "", "10.0.0.9"), http.StatusSeeOther}, "signed in": {lan("/api/sessions", server.key, "10.0.0.9"), http.StatusOK}, "this machine": {lan("/api/sessions", server.key, "127.0.0.1"), http.StatusOK}, "other network": {lan("/api/sessions", server.key, "192.168.1.4"), http.StatusForbidden}, "address not given": {outside, http.StatusMisdirectedRequest}, } { response := httptest.NewRecorder() server.ServeHTTP(response, test.request) if response.Code != test.status { t.Errorf("%s: status %d, want %d", name, response.Code, test.status) } }}
func TestUnreachableDaemonIsRediscovered(t *testing.T) { server, upstream := newServer(t, nil) closed, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatal(err) } upstream.snapshot.Port = closed.Addr().(*net.TCPAddr).Port closed.Close()
response := httptest.NewRecorder() server.ServeHTTP(response, request(http.MethodGet, "/api/sessions", server.key)) if response.Code != http.StatusBadGateway || upstream.refreshes.Load() != 1 { t.Fatalf("status %d after %d refreshes", response.Code, upstream.refreshes.Load()) }}
func TestStaticAssetsAreCachedButTheShellIsNot(t *testing.T) { server, _ := newServer(t, nil) if err := os.MkdirAll(filepath.Join(server.dist, "assets"), 0o700); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(server.dist, "assets", "index-abc.js"), []byte("1"), 0o600); err != nil { t.Fatal(err) } for path, cache := range map[string]string{"/": "no-cache", "/assets/index-abc.js": "public, max-age=31536000, immutable"} { response := httptest.NewRecorder() server.ServeHTTP(response, request(http.MethodGet, path, "")) if response.Code != http.StatusOK || response.Header().Get("Cache-Control") != cache { t.Errorf("%s: %d with cache %q", path, response.Code, response.Header().Get("Cache-Control")) } }}
func TestMissingBuildNamesTheFix(t *testing.T) { if _, err := New(t.TempDir(), &fakeUpstream{}); err == nil { t.Fatal("an empty dist was accepted") }}
func TestArtifactFrameBoundary(t *testing.T) { var got *http.Request server, _ := newServer(t, func(w http.ResponseWriter, r *http.Request) { got = r.Clone(context.Background()) w.Header().Set("Content-Type", "text/html") // A copy from the daemon must not stand beside the frame's own. w.Header().Set("Access-Control-Allow-Origin", "https://elsewhere") _, _ = w.Write([]byte("<h1>ok</h1>")) }) req := request(http.MethodGet, "/frame/id.key/1/index.html", "") req.Header.Set("Cookie", "albedo_web=bad") req.Header.Set("Origin", "https://elsewhere") req.Header.Set("Referer", "https://elsewhere/") response := httptest.NewRecorder() server.ServeHTTP(response, req) if response.Code != http.StatusOK || got == nil || got.URL.Path != "/extensions/artifacts/frame/id.key/1/index.html" { t.Fatalf("frame answered %d, request %+v", response.Code, got) } if got.Header.Get("Authorization") != "Bearer secret" || got.Header.Get("Cookie") != "" || got.Header.Get("Origin") != "" || got.Header.Get("Referer") != "" { t.Fatalf("browser headers crossed boundary: %+v", got.Header) } for key, want := range frameHeaders { if values := response.Header().Values(key); len(values) != 1 || values[0] != want { t.Errorf("%s = %q", key, values) } } for _, method := range []string{http.MethodPut, http.MethodDelete} { response = httptest.NewRecorder() server.ServeHTTP(response, request(method, "/frame/id.key/1/index.html", "")) if response.Code != http.StatusMethodNotAllowed { t.Errorf("%s answered %d", method, response.Code) } } response = httptest.NewRecorder() server.ServeHTTP(response, request(http.MethodPost, "/frame/id.key/1/index.html", "")) if response.Code != http.StatusMethodNotAllowed { t.Errorf("post file answered %d", response.Code) }}
func TestALatestTabForwardsItsChecksUncached(t *testing.T) { var asked []string server, _ := newServer(t, func(w http.ResponseWriter, r *http.Request) { asked = append(asked, r.URL.RequestURI()) w.Header().Set("Cache-Control", "no-store") if r.URL.Path == "/extensions/artifacts/frame/id.key/latest" { w.Header().Set("Location", "latest/") w.WriteHeader(http.StatusFound) return } _, _ = w.Write([]byte(`{"version":2}`)) }) for _, target := range []string{"/frame/id.key/latest", "/frame/id.key/1/.albedo/version?after=1"} { response := httptest.NewRecorder() server.ServeHTTP(response, request(http.MethodGet, target, "")) if response.Header().Get("Cache-Control") != "no-store" || response.Header().Get("Content-Security-Policy") != frameHeaders["Content-Security-Policy"] { t.Errorf("%s answered %d with %v", target, response.Code, response.Header()) } if target == "/frame/id.key/latest" && (response.Code != http.StatusFound || response.Header().Get("Location") != "latest/") { t.Errorf("the bare latest address redirected %d to %q", response.Code, response.Header().Get("Location")) } } if want := []string{"/extensions/artifacts/frame/id.key/latest", "/extensions/artifacts/frame/id.key/1/.albedo/version?after=1"}; strings.Join(asked, " ") != strings.Join(want, " ") { t.Fatalf("forwarded %v", asked) }}
func TestATabPostsItsMessagesToTheArtifactsChat(t *testing.T) { var inputs []map[string]string var paths []string server, _ := newServer(t, func(w http.ResponseWriter, r *http.Request) { if r.Header.Get("Authorization") != "Bearer secret" { w.WriteHeader(http.StatusUnauthorized) return } switch { case r.Method == http.MethodGet && r.URL.Path == "/extensions/artifacts/items/art": _, _ = w.Write([]byte(`{"key":"right","title":"garden","session_id":"chat","session_deleted":false}`)) case r.Method == http.MethodPut && strings.HasPrefix(r.URL.Path, "/sessions/chat/inputs/"): var body map[string]string _ = json.NewDecoder(r.Body).Decode(&body) inputs = append(inputs, body) paths = append(paths, r.URL.Path) w.WriteHeader(http.StatusAccepted) default: w.WriteHeader(http.StatusNotFound) } }) post := func(capability, body string) *httptest.ResponseRecorder { r := httptest.NewRequest(http.MethodPost, "http://127.0.0.1:4317/frame/"+capability+"/2/.albedo/message", strings.NewReader(body)) response := httptest.NewRecorder() server.ServeHTTP(response, r) return response }
if response := post("art.right", `{"text":"pick 3","gesture":true}`); response.Code != http.StatusNoContent { t.Fatalf("message answered %d: %s", response.Code, response.Body) } if len(inputs) != 1 || inputs[0]["kind"] != "message" || inputs[0]["text"] != "From the artifact “garden” (v2):\n\npick 3" { t.Fatalf("posted %v", inputs) } if id := strings.TrimPrefix(paths[0], "/sessions/chat/inputs/"); len(id) != 36 || id[14] != '7' { t.Errorf("input id %q is not a UUIDv7", id) } if response := post("art.wrong", `{"text":"pick 3","gesture":true}`); response.Code != http.StatusNotFound { t.Errorf("a wrong key answered %d", response.Code) } if response := post("art.right", `{"text":" "}`); response.Code != http.StatusBadRequest { t.Errorf("an empty message answered %d", response.Code) } for i := range unpromptedPerMinute { if response := post("art.right", `{"text":"tick"}`); response.Code != http.StatusNoContent { t.Fatalf("unprompted message %d answered %d", i, response.Code) } } held := post("art.right", `{"text":"tick"}`) if held.Code != http.StatusTooManyRequests || !strings.Contains(held.Body.String(), "beside the chat") { t.Errorf("past the loop guard: %d %s", held.Code, held.Body) } if response := post("art.right", `{"text":"a click","gesture":true}`); response.Code != http.StatusNoContent { t.Errorf("a message with a gesture past the guard answered %d", response.Code) } if got := len(inputs); got != 2+unpromptedPerMinute { t.Errorf("posted %d messages, want %d", got, 2+unpromptedPerMinute) } for _, header := range []string{"Content-Security-Policy", "Access-Control-Allow-Origin"} { if held.Header().Get(header) == "" { t.Errorf("a refused message lacks %s", header) } }}
func TestWebSocketUpgradeKeepsTokenBehindProxy(t *testing.T) { forwarded := make(chan *http.Request, 1) server, _ := newServer(t, func(w http.ResponseWriter, r *http.Request) { forwarded <- r.Clone(context.Background()) connection, buffered, err := w.(http.Hijacker).Hijack() if err != nil { t.Error(err) return } defer connection.Close() if err := connection.SetDeadline(time.Now().Add(5 * time.Second)); err != nil { t.Error(err) return } _, _ = buffered.WriteString("HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n") if err := buffered.Flush(); err != nil { t.Error(err) return } // Echo bytes after the upgrade to exercise both halves of the tunnel. _, _ = io.Copy(connection, buffered) }) proxy := httptest.NewServer(server) defer proxy.Close() connection, err := net.Dial("tcp", proxy.Listener.Addr().String()) if err != nil { t.Fatal(err) } defer connection.Close() if err := connection.SetDeadline(time.Now().Add(5 * time.Second)); err != nil { t.Fatal(err) } call, err := http.NewRequest(http.MethodGet, proxy.URL+"/api/events", nil) if err != nil { t.Fatal(err) } call.AddCookie(&http.Cookie{Name: cookieName, Value: server.key}) call.Header.Set("Origin", proxy.URL) call.Header.Set("Authorization", "Bearer browser-supplied") call.Header.Set("Referer", proxy.URL+"/") call.Header.Set("Connection", "Upgrade") call.Header.Set("Upgrade", "websocket") call.Header.Set("Sec-WebSocket-Version", "13") call.Header.Set("Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ==") if err := call.Write(connection); err != nil { t.Fatal(err) } reader := bufio.NewReader(connection) response, err := http.ReadResponse(reader, call) if err != nil { t.Fatal(err) } defer response.Body.Close() if response.StatusCode != http.StatusSwitchingProtocols || response.Header.Get("Upgrade") != "websocket" { t.Fatalf("upgrade answered %s with %v", response.Status, response.Header) } got := <-forwarded if got.URL.Path != "/events" || got.Header.Get("Authorization") != "Bearer secret" { t.Fatalf("upgrade forwarded %s with authorization %q", got.URL, got.Header.Get("Authorization")) } for _, header := range []string{"Origin", "Cookie", "Referer"} { if got.Header.Get(header) != "" { t.Errorf("forwarded browser %s: %q", header, got.Header.Get(header)) } } if strings.Contains(response.Header.Get("Authorization"), "secret") || strings.Contains(response.Header.Get("Set-Cookie"), "secret") { t.Fatal("daemon token reached browser") } frame := []byte{0x81, 0x82, 1, 2, 3, 4, 'o' ^ 1, 'k' ^ 2} if _, err := connection.Write(frame); err != nil { t.Fatal(err) } echo := make([]byte, len(frame)) if _, err := io.ReadFull(reader, echo); err != nil { t.Fatal(err) } if string(echo) != string(frame) { t.Fatalf("tunnel echoed %x, want %x", echo, frame) }}
func TestTailnetNamesAreAnsweredAndServedOnlyWhenRunning(t *testing.T) { status := `{"BackendState":"Running","Self":{"DNSName":"Caladon.lan.klbr.net."}}` tailnet, ok := parseTailnet([]byte(status)) if !ok || len(tailnet.Names) != 2 || tailnet.Names[0] != "caladon.lan.klbr.net" || tailnet.Names[1] != "caladon" { t.Fatalf("parsed %v, %v", tailnet, ok) } for _, down := range []string{`{"BackendState":"Stopped","Self":{"DNSName":"a.b."}}`, `{"BackendState":"Running","Self":{}}`, `nope`} { if _, ok := parseTailnet([]byte(down)); ok { t.Errorf("%s was taken as a tailnet", down) } }
server, _ := newServer(t, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) server.Share([]*net.IPNet{TailscaleNetwork}, []string{"100.64.0.8"}) server.Answer(tailnet.Names...) for host, status := range map[string]int{"Caladon:4317": http.StatusOK, "caladon.lan.klbr.net:4317": http.StatusOK, "evil.example:4317": http.StatusMisdirectedRequest} { call := request(http.MethodGet, "/api/sessions", server.key) call.Host, call.RemoteAddr = host, "100.64.0.3:5000" response := httptest.NewRecorder() server.ServeHTTP(response, call) if response.Code != status { t.Errorf("%s: status %d, want %d", host, response.Code, status) } }}
// TestAnOriginFromAProxyThatTerminatesTLSIsAccepted pins the bug this guards:// behind `tailscale serve`, or any proxy that terminates TLS while forwarding// the name it serves, the page is https while this server speaks http, so an// origin compared against "http://"+Host answered 403 to every POST from the// page it had served.func TestAnOriginFromAProxyThatTerminatesTLSIsAccepted(t *testing.T) { var reached atomic.Bool server, _ := newServer(t, func(http.ResponseWriter, *http.Request) { reached.Store(true) }) server.Answer("albedo.example.ts.net")
throughProxy := request(http.MethodPost, "/api/sessions", server.key) throughProxy.Host = "albedo.example.ts.net" throughProxy.Header.Set("Origin", "https://albedo.example.ts.net")
response := httptest.NewRecorder() server.ServeHTTP(response, throughProxy) if response.Code != http.StatusOK || !reached.Load() { t.Fatalf("https origin through a proxy answered %d, reached %v", response.Code, reached.Load()) }
refuseOrigins(t, server, "albedo.example.ts.net", map[string]string{ "another host": "https://evil.example", "opaque origin": "null", })}
// TestAPublishedOriginIsAnsweredAndAccepted pins the other half a proxy needs:// --public-url names the page's origin, but a proxy may forward its own address// as Host (nginx's default), so the published name must be answered as well as// the public origin accepted beside the request host's own.func TestAPublishedOriginIsAnsweredAndAccepted(t *testing.T) { var reached atomic.Bool server, _ := newServer(t, func(http.ResponseWriter, *http.Request) { reached.Store(true) }) // The flag says the default port; the browser leaves it out. public, err := ParseOrigin("https://Albedo.Example.TS.net:443") if err != nil { t.Fatal(err) } server.Publish(public)
// The last is a proxy that forwards the default port the browser left out. for _, host := range []string{"albedo.example.ts.net", "albedo.example.ts.net:443", "127.0.0.1:4317"} { t.Run(host, func(t *testing.T) { reached.Store(false) call := request(http.MethodPost, "/api/sessions", server.key) call.Host = host call.Header.Set("Origin", "https://albedo.example.ts.net") response := httptest.NewRecorder() server.ServeHTTP(response, call) if response.Code != http.StatusOK || !reached.Load() { t.Fatalf("Host %s: answered %d, reached %v", host, response.Code, reached.Load()) } }) }
refuseOrigins(t, server, "127.0.0.1:4317", map[string]string{ "another host": "https://evil.example", "other scheme": "http://albedo.example.ts.net:443", "opaque origin": "null", })
// Unpublished, that origin is nobody's page: only the request host's own // origin passes, so a proxy that rewrites Host needs --public-url. server, _ = newServer(t, func(http.ResponseWriter, *http.Request) {}) call := request(http.MethodPost, "/api/sessions", server.key) call.Header.Set("Origin", "https://albedo.example.ts.net") response := httptest.NewRecorder() server.ServeHTTP(response, call) if response.Code != http.StatusForbidden { t.Errorf("an unpublished origin answered %d, want 403", response.Code) } unpublishedHost := request(http.MethodGet, "/api/sessions", server.key) unpublishedHost.Host = "albedo.example.ts.net" response = httptest.NewRecorder() server.ServeHTTP(response, unpublishedHost) if response.Code != http.StatusMisdirectedRequest { t.Errorf("an unpublished name answered %d, want 421", response.Code) }}