Skip to content
Merged
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
40 changes: 40 additions & 0 deletions cmd/clawtool-installer/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
52 changes: 44 additions & 8 deletions cmd/clawtool-installer/frontend/dist/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
</style>
</head>
<body>
Expand Down Expand Up @@ -377,6 +388,8 @@ <h1 class="vh">Updates</h1>
// ── Cross-device (circle key) ───────────────────────────────
const COPY = '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h10"/></svg>';
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);
Expand All @@ -393,31 +406,54 @@ <h1 class="vh">Updates</h1>
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 '<div class="switchrow"><div class="txt"><div class="st">Reachable on your LAN</div>' +
'<div class="sd">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.</div></div>' +
'<label class="switch' + (lanBusy ? " busy" : "") + '"><input type="checkbox" id="lan-sw"' + (lanState.enabled ? " checked" : "") + '><span class="track"></span><span class="knob"></span></label></div>';
}
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 =
'<div class="xd"><div class="head"><span class="t">Cross-device peering</span><span class="pill on"><span class="pd"></span>On</span></div>' +
inner =
'<div class="head"><span class="t">Cross-device peering</span><span class="pill on"><span class="pd"></span>On</span></div>' +
'<div class="desc">Devices that share this circle key can see each other’s agents over your network.</div>' +
'<div class="keybox"><code id="ckey" title="' + esc(k) + '">' + esc(maskKey(k)) + '</code><button class="iconbtn" id="ck-copy" title="Copy key">' + COPY + '</button></div>' +
'<div class="hint">On your other devices, open clawtool → Network → <b>Join with a key</b>, then paste this key.</div>' +
'<div style="margin-top:14px"><button class="btn ghost danger" id="ck-leave">Leave circle</button></div></div>';
$("ck-copy").addEventListener("click", () => copyText(k, "Circle key copied"));
$("ck-leave").addEventListener("click", circleLeave);
'<div style="margin-top:14px"><button class="btn ghost danger" id="ck-leave">Leave circle</button></div>';
} else {
el.innerHTML =
'<div class="xd"><div class="head"><span class="t">Cross-device peering</span><span class="pill"><span class="pd"></span>Off</span></div>' +
inner =
'<div class="head"><span class="t">Cross-device peering</span><span class="pill"><span class="pd"></span>Off</span></div>' +
'<div class="desc">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.</div>' +
'<div class="actions" style="margin-top:14px"><button class="btn primary" id="ck-gen">Generate circle key</button><button class="btn ghost" id="ck-join-toggle">Join with a key…</button></div>' +
'<div class="reveal" id="ck-join"><input class="input" id="ck-input" placeholder="Paste the circle key from your other device" autocomplete="off" spellcheck="false" /><div class="actions" style="margin-top:10px"><button class="btn primary" id="ck-join-go">Join circle</button></div></div></div>';
'<div class="reveal" id="ck-join"><input class="input" id="ck-input" placeholder="Paste the circle key from your other device" autocomplete="off" spellcheck="false" /><div class="actions" style="margin-top:10px"><button class="btn primary" id="ck-join-go">Join circle</button></div></div>';
}
el.innerHTML = '<div class="xd">' + inner + lanSwitchHTML() + '</div>';
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…"; }
Expand Down
47 changes: 47 additions & 0 deletions internal/cli/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <subcommand>

Expand All @@ -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:<port>/mcp).
lan <on|off|status>
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
Expand Down
79 changes: 73 additions & 6 deletions internal/daemon/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
66 changes: 66 additions & 0 deletions internal/daemon/lan_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
2 changes: 1 addition & 1 deletion internal/server/biam_sse_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
2 changes: 1 addition & 1 deletion internal/server/circle_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
Loading
Loading