From f03d2d1b330b8cad138edd2905e92e3002c3fd41 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 20 Dec 2025 16:43:08 +0100 Subject: [PATCH] feat: advertise cli path for remote ssh --- apps/macos/Sources/Clawdis/AppState.swift | 6 ++ apps/macos/Sources/Clawdis/Constants.swift | 1 + .../Clawdis/GatewayDiscoveryModel.swift | 7 +++ .../Sources/Clawdis/GeneralSettings.swift | 6 ++ apps/macos/Sources/Clawdis/Onboarding.swift | 9 +++ apps/macos/Sources/Clawdis/Utilities.swift | 28 ++++++++- docs/bonjour.md | 1 + docs/discovery.md | 3 +- docs/mac/remote.md | 1 + src/gateway/server.ts | 63 ++++++++++++++++--- src/infra/bonjour.test.ts | 4 ++ src/infra/bonjour.ts | 4 ++ 12 files changed, 123 insertions(+), 10 deletions(-) diff --git a/apps/macos/Sources/Clawdis/AppState.swift b/apps/macos/Sources/Clawdis/AppState.swift index 9e80b3d96..e54d98adf 100644 --- a/apps/macos/Sources/Clawdis/AppState.swift +++ b/apps/macos/Sources/Clawdis/AppState.swift @@ -179,6 +179,10 @@ final class AppState { didSet { self.ifNotPreview { UserDefaults.standard.set(self.remoteProjectRoot, forKey: remoteProjectRootKey) } } } + var remoteCliPath: String { + didSet { self.ifNotPreview { UserDefaults.standard.set(self.remoteCliPath, forKey: remoteCliPathKey) } } + } + private var earBoostTask: Task? init(preview: Bool = false) { @@ -235,6 +239,7 @@ final class AppState { self.remoteTarget = UserDefaults.standard.string(forKey: remoteTargetKey) ?? "" self.remoteIdentity = UserDefaults.standard.string(forKey: remoteIdentityKey) ?? "" self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? "" + self.remoteCliPath = UserDefaults.standard.string(forKey: remoteCliPathKey) ?? "" self.canvasEnabled = UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true self.peekabooBridgeEnabled = UserDefaults.standard .object(forKey: peekabooBridgeEnabledKey) as? Bool ?? true @@ -368,6 +373,7 @@ extension AppState { state.remoteTarget = "user@example.com" state.remoteIdentity = "~/.ssh/id_ed25519" state.remoteProjectRoot = "~/Projects/clawdis" + state.remoteCliPath = "" state.attachExistingGatewayOnly = false return state } diff --git a/apps/macos/Sources/Clawdis/Constants.swift b/apps/macos/Sources/Clawdis/Constants.swift index 37ee7480a..966d1744a 100644 --- a/apps/macos/Sources/Clawdis/Constants.swift +++ b/apps/macos/Sources/Clawdis/Constants.swift @@ -21,6 +21,7 @@ let connectionModeKey = "clawdis.connectionMode" let remoteTargetKey = "clawdis.remoteTarget" let remoteIdentityKey = "clawdis.remoteIdentity" let remoteProjectRootKey = "clawdis.remoteProjectRoot" +let remoteCliPathKey = "clawdis.remoteCliPath" let canvasEnabledKey = "clawdis.canvasEnabled" let cameraEnabledKey = "clawdis.cameraEnabled" let peekabooBridgeEnabledKey = "clawdis.peekabooBridgeEnabled" diff --git a/apps/macos/Sources/Clawdis/GatewayDiscoveryModel.swift b/apps/macos/Sources/Clawdis/GatewayDiscoveryModel.swift index 174396e6f..5cb398ef8 100644 --- a/apps/macos/Sources/Clawdis/GatewayDiscoveryModel.swift +++ b/apps/macos/Sources/Clawdis/GatewayDiscoveryModel.swift @@ -17,6 +17,7 @@ final class GatewayDiscoveryModel { var lanHost: String? var tailnetDns: String? var sshPort: Int + var cliPath: String? var stableID: String var debugID: String var isLocal: Bool @@ -66,6 +67,7 @@ final class GatewayDiscoveryModel { var lanHost: String? var tailnetDns: String? var sshPort = 22 + var cliPath: String? if let value = txt["lanHost"] { let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) @@ -81,6 +83,10 @@ final class GatewayDiscoveryModel { { sshPort = parsed } + if let value = txt["cliPath"] { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + cliPath = trimmed.isEmpty ? nil : trimmed + } let isLocal = Self.isLocalGateway( lanHost: lanHost, @@ -93,6 +99,7 @@ final class GatewayDiscoveryModel { lanHost: lanHost, tailnetDns: tailnetDns, sshPort: sshPort, + cliPath: cliPath, stableID: BridgeEndpointID.stableID(result.endpoint), debugID: BridgeEndpointID.prettyDescription(result.endpoint), isLocal: isLocal) diff --git a/apps/macos/Sources/Clawdis/GeneralSettings.swift b/apps/macos/Sources/Clawdis/GeneralSettings.swift index 6f137694f..fa7c005d0 100644 --- a/apps/macos/Sources/Clawdis/GeneralSettings.swift +++ b/apps/macos/Sources/Clawdis/GeneralSettings.swift @@ -181,6 +181,11 @@ struct GeneralSettings: View { .textFieldStyle(.roundedBorder) .frame(width: 280) } + LabeledContent("CLI path") { + TextField("/Applications/Clawdis.app/.../clawdis", text: self.$state.remoteCliPath) + .textFieldStyle(.roundedBorder) + .frame(width: 280) + } } .padding(.top, 4) } label: { @@ -612,6 +617,7 @@ extension GeneralSettings { target += ":\(gateway.sshPort)" } self.state.remoteTarget = target + self.state.remoteCliPath = gateway.cliPath ?? "" } } diff --git a/apps/macos/Sources/Clawdis/Onboarding.swift b/apps/macos/Sources/Clawdis/Onboarding.swift index c13a11694..f5ed75f33 100644 --- a/apps/macos/Sources/Clawdis/Onboarding.swift +++ b/apps/macos/Sources/Clawdis/Onboarding.swift @@ -396,6 +396,14 @@ struct OnboardingView: View { .textFieldStyle(.roundedBorder) .frame(width: fieldWidth) } + GridRow { + Text("CLI path") + .font(.callout.weight(.semibold)) + .frame(width: labelWidth, alignment: .leading) + TextField("/Applications/Clawdis.app/.../clawdis", text: self.$state.remoteCliPath) + .textFieldStyle(.roundedBorder) + .frame(width: fieldWidth) + } } Text("Tip: keep Tailscale enabled so your gateway stays reachable.") @@ -436,6 +444,7 @@ struct OnboardingView: View { } self.state.remoteTarget = target } + self.state.remoteCliPath = gateway.cliPath ?? "" self.state.connectionMode = .remote MacNodeModeCoordinator.shared.setPreferredBridgeStableID(gateway.stableID) diff --git a/apps/macos/Sources/Clawdis/Utilities.swift b/apps/macos/Sources/Clawdis/Utilities.swift index 08c9800cb..be6d1cca2 100644 --- a/apps/macos/Sources/Clawdis/Utilities.swift +++ b/apps/macos/Sources/Clawdis/Utilities.swift @@ -490,6 +490,7 @@ enum CommandResolver { ].joined(separator: ":") let quotedArgs = ([subcommand] + extraArgs).map(self.shellQuote).joined(separator: " ") let userPRJ = settings.projectRoot.trimmingCharacters(in: .whitespacesAndNewlines) + let userCLI = settings.cliPath.trimmingCharacters(in: .whitespacesAndNewlines) let projectSection = if userPRJ.isEmpty { """ @@ -506,9 +507,31 @@ enum CommandResolver { """ } + let cliSection = if userCLI.isEmpty { + "" + } else { + """ + CLI_HINT=\(self.shellQuote(userCLI)) + if [ -n "$CLI_HINT" ]; then + if [ -x "$CLI_HINT" ]; then + CLI="$CLI_HINT" + "$CLI_HINT" \(quotedArgs); + exit $?; + elif [ -f "$CLI_HINT" ]; then + if command -v node >/dev/null 2>&1; then + CLI="node $CLI_HINT" + node "$CLI_HINT" \(quotedArgs); + exit $?; + fi + fi + fi + """ + } + let scriptBody = """ PATH=\(exportedPath); CLI=""; + \(cliSection) \(projectSection) if command -v clawdis >/dev/null 2>&1; then CLI="$(command -v clawdis)" @@ -543,6 +566,7 @@ enum CommandResolver { let target: String let identity: String let projectRoot: String + let cliPath: String } static func connectionSettings(defaults: UserDefaults = .standard) -> RemoteSettings { @@ -557,11 +581,13 @@ enum CommandResolver { let target = defaults.string(forKey: remoteTargetKey) ?? "" let identity = defaults.string(forKey: remoteIdentityKey) ?? "" let projectRoot = defaults.string(forKey: remoteProjectRootKey) ?? "" + let cliPath = defaults.string(forKey: remoteCliPathKey) ?? "" return RemoteSettings( mode: mode, target: self.sanitizedTarget(target), identity: identity, - projectRoot: projectRoot) + projectRoot: projectRoot, + cliPath: cliPath) } static var attachExistingGatewayOnly: Bool { diff --git a/docs/bonjour.md b/docs/bonjour.md index 0b7b8fdb0..b0f7e505e 100644 --- a/docs/bonjour.md +++ b/docs/bonjour.md @@ -94,6 +94,7 @@ The Gateway advertises small non-secret hints to make UI flows convenient: - `gatewayPort=` (informational; the Gateway WS is typically loopback-only) - `bridgePort=` (only when bridge is enabled) - `canvasPort=` (only when the canvas host is running; enabled by default; default `18793`) +- `cliPath=` (optional; absolute path to a runnable `clawdis` entrypoint or binary) - `tailnetDns=` (optional hint; auto-detected from Tailscale when available; may be absent) ## Debugging on macOS diff --git a/docs/discovery.md b/docs/discovery.md index d84d471dc..92097231d 100644 --- a/docs/discovery.md +++ b/docs/discovery.md @@ -55,7 +55,8 @@ Troubleshooting and beacon details: `docs/bonjour.md`. - `gatewayPort=18789` (loopback WS port; informational) - `bridgePort=18790` (when bridge is enabled) - `canvasPort=18793` (when the canvas host is running; enabled by default) -- `tailnetDns=` (optional hint; auto-detected when Tailscale is available) + - `cliPath=` (optional; absolute path to a runnable `clawdis` entrypoint or binary) + - `tailnetDns=` (optional hint; auto-detected when Tailscale is available) Disable/override: - `CLAWDIS_DISABLE_BONJOUR=1` disables advertising. diff --git a/docs/mac/remote.md b/docs/mac/remote.md index 4709fa144..0ace161b0 100644 --- a/docs/mac/remote.md +++ b/docs/mac/remote.md @@ -25,6 +25,7 @@ This flow lets the macOS app act as a full remote control for a Clawdis gateway - If the gateway is on the same LAN and advertises Bonjour, pick it from the discovered list to auto-fill this field. - **Identity file** (advanced): path to your key. - **Project root** (advanced): remote checkout path used for commands. + - **CLI path** (advanced): optional path to a runnable `clawdis` entrypoint/binary (auto-filled when advertised). 3) Hit **Test remote**. Success indicates the remote `clawdis status --json` runs correctly. Failures usually mean PATH/CLI issues; exit 127 means the CLI isn’t found remotely. 4) Health checks and Web Chat will now run through this SSH tunnel automatically. diff --git a/src/gateway/server.ts b/src/gateway/server.ts index d9cc68773..3bff1e824 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -22,6 +22,7 @@ import { type CanvasHostServer, startCanvasHost, } from "../canvas-host/server.js"; +import { handleA2uiHttpRequest } from "../canvas-host/a2ui.js"; import { createDefaultDeps } from "../cli/deps.js"; import { agentCommand } from "../commands/agent.js"; import { getHealthSnapshot, type HealthSummary } from "../commands/health.js"; @@ -105,6 +106,37 @@ import { handleControlUiHttpRequest } from "./control-ui.js"; ensureClawdisCliOnPath(); +function resolveBonjourCliPath(): string | undefined { + const envPath = process.env.CLAWDIS_CLI_PATH?.trim(); + if (envPath) return envPath; + + const isFile = (candidate: string) => { + try { + return fs.statSync(candidate).isFile(); + } catch { + return false; + } + }; + + const execDir = path.dirname(process.execPath); + const siblingCli = path.join(execDir, "clawdis"); + if (isFile(siblingCli)) return siblingCli; + + const argvPath = process.argv[1]; + if (argvPath && isFile(argvPath)) { + const base = path.basename(argvPath); + if (!base.includes("gateway-daemon")) return argvPath; + } + + const cwd = process.cwd(); + const distCli = path.join(cwd, "dist", "index.js"); + if (isFile(distCli)) return distCli; + const binCli = path.join(cwd, "bin", "clawdis.js"); + if (isFile(binCli)) return binCli; + + return undefined; +} + let stopBrowserControlServerIfStarted: (() => Promise) | null = null; async function startBrowserControlServerIfEnabled(): Promise { @@ -976,6 +1008,9 @@ export async function startGatewayServer( "gateway bind is tailnet, but no tailnet interface was found; refusing to start gateway", ); } + const tailnetIPv4 = pickPrimaryTailnetIPv4(); + const tailnetIPv6 = pickPrimaryTailnetIPv6(); + const hasTailnet = Boolean(tailnetIPv4 || tailnetIPv6); const controlUiEnabled = opts.controlUiEnabled ?? cfgForServer.gateway?.controlUi?.enabled ?? true; if (!isLoopbackHost(bindHost) && !getGatewayToken()) { @@ -988,13 +1023,20 @@ export async function startGatewayServer( // Don't interfere with WebSocket upgrades; ws handles the 'upgrade' event. if (String(req.headers.upgrade ?? "").toLowerCase() === "websocket") return; - if (controlUiEnabled) { - if (handleControlUiHttpRequest(req, res)) return; - } + void (async () => { + if (await handleA2uiHttpRequest(req, res)) return; + if (controlUiEnabled) { + if (handleControlUiHttpRequest(req, res)) return; + } - res.statusCode = 404; - res.setHeader("Content-Type", "text/plain; charset=utf-8"); - res.end("Not Found"); + res.statusCode = 404; + res.setHeader("Content-Type", "text/plain; charset=utf-8"); + res.end("Not Found"); + })().catch((err) => { + res.statusCode = 500; + res.setHeader("Content-Type", "text/plain; charset=utf-8"); + res.end(String(err)); + }); }); let bonjourStop: (() => Promise) | null = null; let bridge: Awaited> | null = null; @@ -1057,6 +1099,7 @@ export async function startGatewayServer( const canvasHostEnabled = process.env.CLAWDIS_SKIP_CANVAS_HOST !== "1" && cfgAtStart.canvasHost?.enabled !== false; + const preferGatewayA2uiHost = hasTailnet && !isLoopbackHost(bindHost); if (canvasHostEnabled) { try { @@ -2029,7 +2072,7 @@ export async function startGatewayServer( host: bridgeHost, port: bridgePort, serverName: machineDisplayName, - canvasHostPort: canvasHost?.port, + canvasHostPort: preferGatewayA2uiHost ? port : canvasHost?.port, onRequest: (nodeId, req) => handleBridgeRequest(nodeId, req), onAuthenticated: async (node) => { const host = node.displayName?.trim() || node.nodeId; @@ -2148,6 +2191,7 @@ export async function startGatewayServer( canvasPort: canvasHost?.port, sshPort, tailnetDns, + cliPath: resolveBonjourCliPath(), }); bonjourStop = bonjour.stop; } catch (err) { @@ -2318,7 +2362,10 @@ export async function startGatewayServer( const remoteAddr = ( socket as WebSocket & { _socket?: { remoteAddress?: string } } )._socket?.remoteAddress; - const canvasHostUrl = deriveCanvasHostUrl(req, canvasHost?.port); + const canvasHostUrl = deriveCanvasHostUrl( + req, + preferGatewayA2uiHost ? port : canvasHost?.port, + ); logWs("in", "open", { connId, remoteAddr }); const isWebchatConnect = (params: ConnectParams | null | undefined) => params?.client?.mode === "webchat" || diff --git a/src/infra/bonjour.test.ts b/src/infra/bonjour.test.ts index cda234706..d4e59922a 100644 --- a/src/infra/bonjour.test.ts +++ b/src/infra/bonjour.test.ts @@ -97,6 +97,7 @@ describe("gateway bonjour advertiser", () => { sshPort: 2222, bridgePort: 18790, tailnetDns: "host.tailnet.ts.net", + cliPath: "/opt/homebrew/bin/clawdis", }); expect(createService).toHaveBeenCalledTimes(1); @@ -116,6 +117,9 @@ describe("gateway bonjour advertiser", () => { expect((bridgeCall?.[0]?.txt as Record)?.sshPort).toBe( "2222", ); + expect((bridgeCall?.[0]?.txt as Record)?.cliPath).toBe( + "/opt/homebrew/bin/clawdis", + ); expect((bridgeCall?.[0]?.txt as Record)?.transport).toBe( "bridge", ); diff --git a/src/infra/bonjour.ts b/src/infra/bonjour.ts index 818220695..cb8e7e17e 100644 --- a/src/infra/bonjour.ts +++ b/src/infra/bonjour.ts @@ -14,6 +14,7 @@ export type GatewayBonjourAdvertiseOpts = { bridgePort?: number; canvasPort?: number; tailnetDns?: string; + cliPath?: string; }; function isDisabledByEnv() { @@ -115,6 +116,9 @@ export async function startGatewayBonjourAdvertiser( if (typeof opts.tailnetDns === "string" && opts.tailnetDns.trim()) { txtBase.tailnetDns = opts.tailnetDns.trim(); } + if (typeof opts.cliPath === "string" && opts.cliPath.trim()) { + txtBase.cliPath = opts.cliPath.trim(); + } const services: Array<{ label: string; svc: BonjourService }> = [];