From cd245b0750a9728e07b782171e00eef9eaa8aac1 Mon Sep 17 00:00:00 2001 From: bahadirarda Date: Tue, 26 May 2026 03:25:58 +0300 Subject: [PATCH 1/4] feat(daemon): config-driven LAN exposure for the managed daemon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an opt-in LAN-exposure marker (LanExposurePath / LanExposureEnabled / SetLanExposure). When set, Ensure binds the managed daemon to 0.0.0.0 with --allow-lan + a token file instead of the 127.0.0.1 --no-auth loopback default, so circle peers can reach the read-only A2A endpoints. Security invariant (unit-tested): a non-loopback bind is NEVER --no-auth and always carries a token, so code-executing endpoints stay bearer-gated against the network; only the circle-key read-only endpoints are LAN-reachable. Default off — loopback/no-auth behaviour is byte-for-byte unchanged. Foundation only: enabling still needs the app toggle + a loopback-trust middleware tweak so local consumers keep working without a token. --- internal/daemon/daemon.go | 79 ++++++++++++++++++++++++++++++++++--- internal/daemon/lan_test.go | 66 +++++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+), 6 deletions(-) create mode 100644 internal/daemon/lan_test.go diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 874d232..53c9c39 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -103,6 +103,67 @@ func LogPath() string { return filepath.Join(xdg.StateDir(), "daemon.log") } +// LanExposurePath is the marker file that opts this device's shared +// daemon into LAN exposure. Present ⇒ Ensure binds all interfaces with +// --allow-lan + token auth so circle peers can reach the read-only +// endpoints; absent ⇒ the default 127.0.0.1 --no-auth loopback daemon +// (the machine is the trust boundary). Same XDG conventions as StatePath. +func LanExposurePath() string { + return filepath.Join(configDir(), "lan-exposure") +} + +// LanExposureEnabled reports whether LAN exposure has been opted into. +func LanExposureEnabled() bool { + _, err := os.Stat(LanExposurePath()) + return err == nil +} + +// SetLanExposure toggles the LAN-exposure marker. The caller restarts the +// daemon afterwards so Ensure re-reads it and rebinds. +func SetLanExposure(on bool) error { + p := LanExposurePath() + if !on { + if err := os.Remove(p); err != nil && !os.IsNotExist(err) { + return err + } + return nil + } + if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil { + return err + } + return os.WriteFile(p, []byte("1\n"), 0o600) +} + +// daemonServeArgs builds the `serve` argv for the managed daemon. +// +// Default: the loopback, no-auth gateway — the machine is the trust +// boundary and local consumers (codex / claude-code / gemini) reach +// /mcp over 127.0.0.1 without a token. +// +// LAN-exposed: bind all interfaces with --allow-lan + a token file. +// Security invariant — a non-loopback bind is NEVER --no-auth, so +// code-executing endpoints stay bearer-gated and only the read-only +// circle endpoints are reachable by peers (which present the circle +// key). The first --allow-lan run also installs the platform firewall +// rules via serve's auto-firewall hook. +func daemonServeArgs(port int, lanExposed bool) []string { + if lanExposed { + return []string{ + "serve", + "--listen", fmt.Sprintf("0.0.0.0:%d", port), + "--allow-lan", + "--token-file", TokenPath(), + "--mcp-http", + } + } + return []string{ + "serve", + "--listen", fmt.Sprintf("127.0.0.1:%d", port), + "--no-auth", + "--mcp-http", + } +} + // configDir delegates to the central xdg package so every callsite // (daemon, secrets, a2a, telemetry, …) shares one fallback chain. func configDir() string { @@ -360,12 +421,18 @@ func EnsureFrom(ctx context.Context, exePath string) (*State, error) { } } - cmd := exec.Command(self, - "serve", - "--listen", fmt.Sprintf("127.0.0.1:%d", port), - "--no-auth", - "--mcp-http", - ) + lanExposed := LanExposureEnabled() + if lanExposed { + // A non-loopback bind enforces a bearer token, so make sure one + // exists before serve starts (idempotent — leaves an existing + // token untouched). + if _, statErr := os.Stat(TokenPath()); statErr != nil { + gen := exec.Command(self, "serve", "init-token", TokenPath()) + gen.Stdout, gen.Stderr = logFile, logFile + _ = gen.Run() + } + } + cmd := exec.Command(self, daemonServeArgs(port, lanExposed)...) cmd.Stdout = logFile cmd.Stderr = logFile cmd.Stdin = nil diff --git a/internal/daemon/lan_test.go b/internal/daemon/lan_test.go new file mode 100644 index 0000000..073bb3f --- /dev/null +++ b/internal/daemon/lan_test.go @@ -0,0 +1,66 @@ +package daemon + +import ( + "slices" + "strings" + "testing" +) + +// The managed daemon's bind/auth posture is a security invariant: the +// loopback default is no-auth (machine is the trust boundary), and a +// LAN-exposed bind MUST carry a token and MUST NOT be --no-auth, so +// code-executing endpoints stay bearer-gated against the network. +func TestDaemonServeArgs_SecurityInvariants(t *testing.T) { + loop := daemonServeArgs(8765, false) + if !slices.Contains(loop, "127.0.0.1:8765") { + t.Errorf("loopback default must bind 127.0.0.1; got %v", loop) + } + if !slices.Contains(loop, "--no-auth") { + t.Errorf("loopback default is no-auth; got %v", loop) + } + if slices.Contains(loop, "--allow-lan") { + t.Errorf("loopback default must NOT pass --allow-lan; got %v", loop) + } + + lan := daemonServeArgs(8765, true) + if !slices.Contains(lan, "0.0.0.0:8765") { + t.Errorf("LAN bind must listen on all interfaces; got %v", lan) + } + if !slices.Contains(lan, "--allow-lan") { + t.Errorf("LAN bind must pass --allow-lan; got %v", lan) + } + if slices.Contains(lan, "--no-auth") { + t.Fatalf("SECURITY: a LAN bind must never be --no-auth; got %v", lan) + } + if !slices.Contains(lan, "--token-file") { + t.Fatalf("SECURITY: a LAN bind must enforce a token; got %v", lan) + } + // The token path must be the next arg after --token-file and look real. + i := slices.Index(lan, "--token-file") + if i+1 >= len(lan) || !strings.Contains(lan[i+1], "listener-token") { + t.Errorf("--token-file must point at the listener token; got %v", lan) + } +} + +func TestLanExposureToggle(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + if LanExposureEnabled() { + t.Fatal("LAN exposure must default to off") + } + if err := SetLanExposure(true); err != nil { + t.Fatalf("enable: %v", err) + } + if !LanExposureEnabled() { + t.Fatal("expected LAN exposure enabled after SetLanExposure(true)") + } + if err := SetLanExposure(false); err != nil { + t.Fatalf("disable: %v", err) + } + if LanExposureEnabled() { + t.Fatal("expected LAN exposure off after SetLanExposure(false)") + } + // Disabling an already-absent marker is not an error. + if err := SetLanExposure(false); err != nil { + t.Fatalf("idempotent disable: %v", err) + } +} From b6e0814b0ab76ac0d4aee38844490edbdd21a361 Mon Sep 17 00:00:00 2001 From: bahadirarda Date: Tue, 26 May 2026 03:29:33 +0300 Subject: [PATCH 2/4] feat(server): loopback-trust so LAN exposure doesn't break local consumers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the daemon is LAN-exposed (--allow-lan, token enforced), requests originating from loopback (127.0.0.1/::1) now bypass the token — the machine is still its own trust boundary, so codex / claude-code / gemini keep reaching the daemon over loopback with no token and no reprovisioning. LAN callers still need the bearer token (code-exec) or circle key (read-only enumeration). Gated on AllowLAN, so relay / explicit token-only deployments are unchanged (loopback still requires the token there). Unit-tested: loopback bypass, LAN token enforcement, and the no-trust path. --- internal/server/biam_sse_test.go | 2 +- internal/server/circle_test.go | 2 +- internal/server/http.go | 46 +++++++++++++-- internal/server/http_test.go | 4 +- internal/server/loopback_trust_test.go | 79 ++++++++++++++++++++++++++ internal/server/peers_handler_test.go | 4 +- 6 files changed, 126 insertions(+), 11 deletions(-) create mode 100644 internal/server/loopback_trust_test.go diff --git a/internal/server/biam_sse_test.go b/internal/server/biam_sse_test.go index bba3be4..5e56316 100644 --- a/internal/server/biam_sse_test.go +++ b/internal/server/biam_sse_test.go @@ -29,7 +29,7 @@ func newSSETestServer(t *testing.T, token string) (*httptest.Server, *biam.Store biam.Events.ResetForTest() mux := http.NewServeMux() - authed := authMiddleware(token) + authed := authMiddleware(token, false) mux.Handle("/v1/biam/subscribe", authed(http.HandlerFunc(handleBIAMSubscribe))) srv := httptest.NewServer(mux) diff --git a/internal/server/circle_test.go b/internal/server/circle_test.go index 4a4df93..68398db 100644 --- a/internal/server/circle_test.go +++ b/internal/server/circle_test.go @@ -14,7 +14,7 @@ import ( // given bearer token, mirroring the production wiring. func circleMux(token string) *http.ServeMux { mux := http.NewServeMux() - mux.Handle("/v1/agents", circleOrBearer(token)(http.HandlerFunc(handleAgents))) + mux.Handle("/v1/agents", circleOrBearer(token, false)(http.HandlerFunc(handleAgents))) return mux } diff --git a/internal/server/http.go b/internal/server/http.go index c97df4d..c992529 100644 --- a/internal/server/http.go +++ b/internal/server/http.go @@ -145,7 +145,14 @@ func ServeHTTP(ctx context.Context, opts HTTPOptions) error { }() mux := http.NewServeMux() - authed := authMiddleware(token) + // When the daemon is LAN-exposed (--allow-lan) it carries a token so + // the network can't reach code-exec endpoints. Local consumers on this + // machine, though, still connect over loopback without a token — the + // machine remains its own trust boundary. trustLoopback lets requests + // from 127.0.0.1/::1 through, so flipping on LAN exposure never forces a + // token onto codex / claude-code / gemini talking over loopback. + trustLoopback := opts.AllowLAN + authed := authMiddleware(token, trustLoopback) mux.Handle("/v1/health", authed(http.HandlerFunc(handleHealth))) // /v1/agents is read-only and circle-aware: a peer device in the same @@ -153,7 +160,7 @@ func ServeHTTP(ctx context.Context, opts HTTPOptions) error { // agents even without the bearer token. That's what lets a dashboard // on one machine list the agents running on another. Code-executing // endpoints below stay bearer-only. - mux.Handle("/v1/agents", circleOrBearer(token)(http.HandlerFunc(handleAgents))) + mux.Handle("/v1/agents", circleOrBearer(token, trustLoopback)(http.HandlerFunc(handleAgents))) mux.Handle("/v1/send_message", authed(http.HandlerFunc(handleSendMessage))) mux.Handle("/v1/recipes", authed(http.HandlerFunc(handleRecipes))) mux.Handle("/v1/recipe/apply", authed(http.HandlerFunc(handleRecipeApply))) @@ -169,7 +176,7 @@ func ServeHTTP(ctx context.Context, opts HTTPOptions) error { // first-contact pairing gate before any delivery (it enqueues // into local agents' inboxes, not code execution — so circle // auth + the pairing approval are the trust boundary). - mux.Handle("/v1/relay", circleOrBearer(token)(http.HandlerFunc(handleRelay))) + mux.Handle("/v1/relay", circleOrBearer(token, trustLoopback)(http.HandlerFunc(handleRelay))) // /v1/biam/subscribe — SSE A2A async-push (ADR-024 Phase 4). // task-scoped, with Last-Event-ID replay against the per-task // ring buffer in internal/agents/biam.Events. @@ -407,13 +414,19 @@ func InitTokenFile(path string) (string, error) { // shared local daemon's no-auth single-user mode (ServeHTTP only // calls this with "" when opts.NoAuth is set + the loud stderr // warning was already printed). -func authMiddleware(expected string) func(http.Handler) http.Handler { +func authMiddleware(expected string, trustLoopback bool) func(http.Handler) http.Handler { exp := []byte(expected) return func(next http.Handler) http.Handler { if len(exp) == 0 { return next } return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Loopback is the machine's own trust boundary: local consumers + // reach the LAN-exposed daemon over 127.0.0.1 without a token. + if trustLoopback && isLoopbackRequest(r) { + next.ServeHTTP(w, r) + return + } h := r.Header.Get("Authorization") const prefix = "Bearer " if !strings.HasPrefix(h, prefix) { @@ -445,7 +458,7 @@ func authMiddleware(expected string) func(http.Handler) http.Handler { // NEVER wrap a code-executing endpoint (send_message, mcp) with this — // the circle key is deliberately scoped to read-only agent/peer // enumeration. Those stay bearer-only. -func circleOrBearer(token string) func(http.Handler) http.Handler { +func circleOrBearer(token string, trustLoopback bool) func(http.Handler) http.Handler { exp := []byte(token) return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -455,6 +468,12 @@ func circleOrBearer(token string) func(http.Handler) http.Handler { next.ServeHTTP(w, r) return } + // Loopback bypass on a LAN-exposed daemon — same machine-is-the- + // trust-boundary rule as authMiddleware. + if trustLoopback && isLoopbackRequest(r) { + next.ServeHTTP(w, r) + return + } // 1. Bearer token. h := r.Header.Get("Authorization") const prefix = "Bearer " @@ -818,3 +837,20 @@ func IsLoopbackAddress(addr string) bool { } return ip.IsLoopback() } + +// isLoopbackRequest reports whether an HTTP request originated from this +// machine (RemoteAddr is a loopback IP). Used by the auth middlewares to +// keep loopback callers token-free even when the daemon is LAN-exposed — +// the machine is still its own trust boundary. A malformed or missing +// RemoteAddr is treated as non-loopback (fail closed). +func isLoopbackRequest(r *http.Request) bool { + if r == nil { + return false + } + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + host = r.RemoteAddr // some transports set a bare host + } + ip := net.ParseIP(strings.TrimSpace(host)) + return ip != nil && ip.IsLoopback() +} diff --git a/internal/server/http_test.go b/internal/server/http_test.go index 681ba6c..2600051 100644 --- a/internal/server/http_test.go +++ b/internal/server/http_test.go @@ -17,7 +17,7 @@ import ( // token + httptest server so they're independent. func newTestMux(token string) *http.ServeMux { mux := http.NewServeMux() - authed := authMiddleware(token) + authed := authMiddleware(token, false) mux.Handle("/v1/health", authed(http.HandlerFunc(handleHealth))) mux.Handle("/v1/agents", authed(http.HandlerFunc(handleAgents))) mux.Handle("/v1/send_message", authed(http.HandlerFunc(handleSendMessage))) @@ -323,7 +323,7 @@ func TestServeHTTP_RefusesEmptyTokenFile(t *testing.T) { // other tests. func newRecipeMux(token string) *http.ServeMux { mux := http.NewServeMux() - authed := authMiddleware(token) + authed := authMiddleware(token, false) mux.Handle("/v1/recipes", authed(http.HandlerFunc(handleRecipes))) mux.Handle("/v1/recipe/apply", authed(http.HandlerFunc(handleRecipeApply))) return mux diff --git a/internal/server/loopback_trust_test.go b/internal/server/loopback_trust_test.go new file mode 100644 index 0000000..50d4298 --- /dev/null +++ b/internal/server/loopback_trust_test.go @@ -0,0 +1,79 @@ +package server + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func okHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }) +} + +func reqFrom(remote string, header map[string]string) *http.Request { + r := httptest.NewRequest(http.MethodGet, "/v1/agents", nil) + r.RemoteAddr = remote + for k, v := range header { + r.Header.Set(k, v) + } + return r +} + +// On a LAN-exposed daemon (token set, trustLoopback=true), local callers +// over loopback must pass without a token, while LAN callers must not. +func TestTrustLoopback_AuthMiddleware(t *testing.T) { + h := authMiddleware("secret", true)(okHandler()) + + cases := []struct { + name string + remote string + bearer string + want int + }{ + {"loopback v4 no token", "127.0.0.1:5000", "", http.StatusOK}, + {"loopback v6 no token", "[::1]:5000", "", http.StatusOK}, + {"LAN no token", "192.168.1.50:5000", "", http.StatusUnauthorized}, + {"LAN wrong token", "192.168.1.50:5000", "nope", http.StatusUnauthorized}, + {"LAN correct token", "192.168.1.50:5000", "secret", http.StatusOK}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + hdr := map[string]string{} + if c.bearer != "" { + hdr["Authorization"] = "Bearer " + c.bearer + } + rw := httptest.NewRecorder() + h.ServeHTTP(rw, reqFrom(c.remote, hdr)) + if rw.Code != c.want { + t.Fatalf("%s: got %d, want %d", c.name, rw.Code, c.want) + } + }) + } +} + +// Without trustLoopback (relay / explicit token-only deployments), even +// loopback callers must present the token — the prior behaviour is intact. +func TestNoTrustLoopback_StillRequiresToken(t *testing.T) { + h := authMiddleware("secret", false)(okHandler()) + rw := httptest.NewRecorder() + h.ServeHTTP(rw, reqFrom("127.0.0.1:5000", nil)) + if rw.Code != http.StatusUnauthorized { + t.Fatalf("loopback without trust must require a token; got %d", rw.Code) + } +} + +func TestIsLoopbackRequest(t *testing.T) { + cases := map[string]bool{ + "127.0.0.1:80": true, + "[::1]:443": true, + "192.168.1.2:80": false, + "10.0.0.1:9": false, + "garbage": false, + "": false, + } + for remote, want := range cases { + if got := isLoopbackRequest(reqFrom(remote, nil)); got != want { + t.Errorf("isLoopbackRequest(%q) = %v, want %v", remote, got, want) + } + } +} diff --git a/internal/server/peers_handler_test.go b/internal/server/peers_handler_test.go index d5a0ed2..a7beae4 100644 --- a/internal/server/peers_handler_test.go +++ b/internal/server/peers_handler_test.go @@ -23,7 +23,7 @@ func newPeersTestMux(t *testing.T, token string) (*http.ServeMux, *a2a.Registry, reg := a2a.NewRegistry(filepath.Join(t.TempDir(), "peers.json")) a2a.SetGlobal(reg) mux := http.NewServeMux() - authed := authMiddleware(token) + authed := authMiddleware(token, false) mux.Handle("/v1/peers", authed(http.HandlerFunc(handlePeers))) mux.Handle("/v1/peers/", authed(http.HandlerFunc(handlePeers))) cleanup := func() { @@ -68,7 +68,7 @@ func TestPeers_503WhenRegistryNotInstalled(t *testing.T) { defer a2a.SetGlobal(prev) mux := http.NewServeMux() - authed := authMiddleware("tok") + authed := authMiddleware("tok", false) mux.Handle("/v1/peers", authed(http.HandlerFunc(handlePeers))) srv := httptest.NewServer(mux) defer srv.Close() From 7c60b60735017606a9fa151cd2c10bd76255c34d Mon Sep 17 00:00:00 2001 From: bahadirarda Date: Tue, 26 May 2026 03:30:50 +0300 Subject: [PATCH 3/4] feat(cli): `daemon lan ` to toggle LAN exposure Wraps daemon.SetLanExposure + a daemon restart so Ensure rebinds: `on` exposes the daemon to LAN circle peers (0.0.0.0 + token; loopback stays token-free), `off` returns it to loopback. `status` prints on/off for the desktop app to read. The first `on` boot installs the platform firewall rules via serve's --allow-lan hook. --- internal/cli/daemon.go | 47 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/internal/cli/daemon.go b/internal/cli/daemon.go index b580302..1d8e37c 100644 --- a/internal/cli/daemon.go +++ b/internal/cli/daemon.go @@ -34,6 +34,8 @@ func (a *App) runDaemon(args []string) int { return rc } return a.runDaemonStart() + case "lan": + return a.runDaemonLan(args[1:]) case "--help", "-h", "help": a.printDaemonUsage() return 0 @@ -97,6 +99,48 @@ func (a *App) runDaemonURL() int { return 0 } +// runDaemonLan toggles LAN exposure of the shared daemon. `on` rebinds it +// to 0.0.0.0 with --allow-lan + token auth (loopback stays token-free) so +// circle peers can reach the read-only endpoints; `off` returns it to the +// loopback default. Both restart the daemon so Ensure re-reads the marker; +// the first --allow-lan boot installs the platform firewall rules. +func (a *App) runDaemonLan(args []string) int { + sub := "status" + if len(args) > 0 { + sub = args[0] + } + switch sub { + case "on", "off": + on := sub == "on" + if err := daemon.SetLanExposure(on); err != nil { + fmt.Fprintf(a.Stderr, "clawtool daemon lan: %v\n", err) + return 1 + } + if rc := a.runDaemonStop(); rc != 0 { + return rc + } + if rc := a.runDaemonStart(); rc != 0 { + return rc + } + if on { + fmt.Fprintln(a.Stdout, "✓ LAN exposure ON — reachable by circle peers on your network") + } else { + fmt.Fprintln(a.Stdout, "✓ LAN exposure OFF — daemon bound to loopback only") + } + return 0 + case "status": + if daemon.LanExposureEnabled() { + fmt.Fprintln(a.Stdout, "on") + } else { + fmt.Fprintln(a.Stdout, "off") + } + return 0 + default: + fmt.Fprintf(a.Stderr, "clawtool daemon lan: unknown subcommand %q (on|off|status)\n", sub) + return 2 + } +} + func (a *App) printDaemonUsage() { fmt.Fprint(a.Stderr, `Usage: clawtool daemon @@ -107,6 +151,9 @@ Subcommands: status Report pid / port / health / token / log file. path Print the state-file path. url Print the daemon's MCP URL (http://127.0.0.1:/mcp). + lan + Expose the daemon to LAN circle peers (0.0.0.0 + token; loopback + stays token-free) or return it to loopback-only. Restarts the daemon. The daemon is the single backend every host (Codex / OpenCode / Gemini / Claude Code) fans into. One daemon = one BIAM identity = cross-host From 4975eef837e74bb0400b6f82a0cf27ea7e18ba42 Mon Sep 17 00:00:00 2001 From: bahadirarda Date: Tue, 26 May 2026 03:32:59 +0300 Subject: [PATCH 4/4] feat(installer): LAN reachability toggle in the cross-device card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Network → Cross-device card gains a "Reachable on your LAN" switch alongside the circle key. Flipping it on runs `daemon lan on` (rebinds the daemon to 0.0.0.0 + token, loopback stays token-free, firewall auto-prompt on first enable) so circle peers can read this device's agent list; off returns to loopback. Backed by LanStatus/LanEnable/LanDisable App methods. Completes the in-app cross-device flow: shared circle key (trust) + LAN reachability (transport), no CLI. --- cmd/clawtool-installer/app.go | 40 ++++++++++++++ .../frontend/dist/index.html | 52 ++++++++++++++++--- 2 files changed, 84 insertions(+), 8 deletions(-) diff --git a/cmd/clawtool-installer/app.go b/cmd/clawtool-installer/app.go index c1cc23f..74fb29a 100644 --- a/cmd/clawtool-installer/app.go +++ b/cmd/clawtool-installer/app.go @@ -568,6 +568,46 @@ func (a *App) CircleClear() string { return `{"ok":true}` } +// LanStatus reports whether the daemon is LAN-exposed (reachable by +// circle peers) or loopback-only. Reads `clawtool daemon lan status`. +func (a *App) LanStatus() string { + bin, err := locateClawtool() + if err != nil { + return jsonErr(err.Error()) + } + cmd := exec.Command(bin, "daemon", "lan", "status") + hideConsole(cmd) + out, _ := cmd.Output() + b, _ := json.Marshal(struct { + OK bool `json:"ok"` + Enabled bool `json:"enabled"` + }{OK: true, Enabled: firstLine(out) == "on"}) + return string(b) +} + +// LanEnable exposes this device to LAN circle peers; LanDisable returns it +// to loopback-only. Both restart the daemon (the call blocks until it's +// back up); the first enable also triggers the platform firewall prompt. +func (a *App) LanEnable() string { return a.lanSet("on") } +func (a *App) LanDisable() string { return a.lanSet("off") } + +func (a *App) lanSet(mode string) string { + bin, err := locateClawtool() + if err != nil { + return jsonErr(err.Error()) + } + cmd := exec.Command(bin, "daemon", "lan", mode) + hideConsole(cmd) + if out, err := cmd.CombinedOutput(); err != nil { + msg := firstLine(out) + if msg == "" { + msg = "could not change LAN exposure" + } + return jsonErr(msg) + } + return `{"ok":true}` +} + // ensureDaemonBase resolves the daemon's loopback base URL, starting the // daemon if it isn't recorded yet. func (a *App) ensureDaemonBase() (string, error) { diff --git a/cmd/clawtool-installer/frontend/dist/index.html b/cmd/clawtool-installer/frontend/dist/index.html index 90782ea..fd8ae0d 100644 --- a/cmd/clawtool-installer/frontend/dist/index.html +++ b/cmd/clawtool-installer/frontend/dist/index.html @@ -143,6 +143,17 @@ .reveal.show { display: block; } .toast { position: fixed; bottom: 22px; left: 50%; transform: translateX(-50%) translateY(12px); background: var(--fg); color: var(--bg); font-size: 12.5px; padding: 8px 16px; border-radius: 999px; opacity: 0; transition: opacity .2s, transform .2s; pointer-events: none; } .toast.show { opacity: 1; transform: translateX(-50%) translateY(0); } + .switchrow { display: flex; align-items: flex-start; gap: 14px; margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--hair); } + .switchrow .txt { flex: 1; } + .switchrow .st { font-weight: 600; font-size: 13.5px; } + .switchrow .sd { color: var(--secondary); font-size: 12.5px; margin-top: 3px; line-height: 1.45; } + .switch { position: relative; width: 40px; height: 24px; flex: none; cursor: pointer; display: inline-block; } + .switch input { position: absolute; opacity: 0; width: 0; height: 0; } + .switch .track { position: absolute; inset: 0; background: var(--hair); border-radius: 999px; transition: background .18s; } + .switch input:checked ~ .track { background: var(--green); } + .switch .knob { position: absolute; top: 3px; left: 3px; width: 18px; height: 18px; border-radius: 50%; background: #fff; transition: transform .18s; box-shadow: 0 1px 2px rgba(0,0,0,.25); } + .switch input:checked ~ .knob { transform: translateX(16px); } + .switch.busy { opacity: .55; pointer-events: none; } @@ -377,6 +388,8 @@

Updates

// ── Cross-device (circle key) ─────────────────────────────── const COPY = ''; let circleState = { has_key: false, key: "" }; + let lanState = { enabled: false }; + let lanBusy = false; function toast(msg) { let t = $("toast"); if (!t) { t = document.createElement("div"); t.id = "toast"; t.className = "toast"; document.body.appendChild(t); } t.textContent = msg; t.classList.add("show"); clearTimeout(t._h); t._h = setTimeout(() => t.classList.remove("show"), 1900); @@ -393,31 +406,54 @@

Updates

async function loadCircle() { const st = parse(await call("CircleStatus"), { ok: false }); circleState = (st && st.ok) ? st : { has_key: false, key: "" }; + const ln = parse(await call("LanStatus"), { ok: false }); + lanState = (ln && ln.ok) ? ln : { enabled: false }; renderXdCard(); refreshXdStat(); } + function lanSwitchHTML() { + return '
Reachable on your LAN
' + + '
Let circle devices on your network read this device’s agent list. Code execution stays local-only; the first time, your OS may ask to allow it through the firewall.
' + + '
'; + } + async function lanToggle(e) { + const want = !!e.target.checked; + lanBusy = true; renderXdCard(); + toast(want ? "Enabling LAN reachability…" : "Disabling…"); + const r = parse(await call(want ? "LanEnable" : "LanDisable"), { ok: false }); + lanBusy = false; + if (r && r.ok) { lanState.enabled = want; toast(want ? "Reachable on your LAN ✓" : "Loopback-only ✓"); } + else { toast((r && r.error) ? r.error : "Could not change LAN reachability"); } + renderXdCard(); + } function renderXdCard() { const el = $("xd-card"); if (!el) return; + let inner; if (circleState.has_key) { const k = circleState.key || ""; - el.innerHTML = - '
Cross-device peeringOn
' + + inner = + '
Cross-device peeringOn
' + '
Devices that share this circle key can see each other’s agents over your network.
' + '
' + esc(maskKey(k)) + '
' + '
On your other devices, open clawtool → Network → Join with a key, then paste this key.
' + - '
'; - $("ck-copy").addEventListener("click", () => copyText(k, "Circle key copied")); - $("ck-leave").addEventListener("click", circleLeave); + '
'; } else { - el.innerHTML = - '
Cross-device peeringOff
' + + inner = + '
Cross-device peeringOff
' + '
Create a circle to let your devices see each other’s agents. Generate a key here, then join the same circle on your other devices.
' + '
' + - '
'; + '
'; + } + el.innerHTML = '
' + inner + lanSwitchHTML() + '
'; + if (circleState.has_key) { + $("ck-copy").addEventListener("click", () => copyText(circleState.key || "", "Circle key copied")); + $("ck-leave").addEventListener("click", circleLeave); + } else { $("ck-gen").addEventListener("click", circleGenerate); $("ck-join-toggle").addEventListener("click", () => { $("ck-join").classList.toggle("show"); const i = $("ck-input"); if (i) i.focus(); }); $("ck-join-go").addEventListener("click", circleJoin); $("ck-input").addEventListener("keydown", (e) => { if (e.key === "Enter") circleJoin(); }); } + const sw = $("lan-sw"); if (sw) sw.addEventListener("change", lanToggle); } async function circleGenerate() { const b = $("ck-gen"); if (b) { b.disabled = true; b.textContent = "Generating…"; }