feat: advertise cli path for remote ssh

This commit is contained in:
Peter Steinberger
2025-12-20 16:43:08 +01:00
parent c7048973bb
commit f03d2d1b33
12 changed files with 123 additions and 10 deletions

View File

@@ -179,6 +179,10 @@ final class AppState {
didSet { self.ifNotPreview { UserDefaults.standard.set(self.remoteProjectRoot, forKey: remoteProjectRootKey) } } 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<Void, Never>? private var earBoostTask: Task<Void, Never>?
init(preview: Bool = false) { init(preview: Bool = false) {
@@ -235,6 +239,7 @@ final class AppState {
self.remoteTarget = UserDefaults.standard.string(forKey: remoteTargetKey) ?? "" self.remoteTarget = UserDefaults.standard.string(forKey: remoteTargetKey) ?? ""
self.remoteIdentity = UserDefaults.standard.string(forKey: remoteIdentityKey) ?? "" self.remoteIdentity = UserDefaults.standard.string(forKey: remoteIdentityKey) ?? ""
self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? "" 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.canvasEnabled = UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true
self.peekabooBridgeEnabled = UserDefaults.standard self.peekabooBridgeEnabled = UserDefaults.standard
.object(forKey: peekabooBridgeEnabledKey) as? Bool ?? true .object(forKey: peekabooBridgeEnabledKey) as? Bool ?? true
@@ -368,6 +373,7 @@ extension AppState {
state.remoteTarget = "user@example.com" state.remoteTarget = "user@example.com"
state.remoteIdentity = "~/.ssh/id_ed25519" state.remoteIdentity = "~/.ssh/id_ed25519"
state.remoteProjectRoot = "~/Projects/clawdis" state.remoteProjectRoot = "~/Projects/clawdis"
state.remoteCliPath = ""
state.attachExistingGatewayOnly = false state.attachExistingGatewayOnly = false
return state return state
} }

View File

@@ -21,6 +21,7 @@ let connectionModeKey = "clawdis.connectionMode"
let remoteTargetKey = "clawdis.remoteTarget" let remoteTargetKey = "clawdis.remoteTarget"
let remoteIdentityKey = "clawdis.remoteIdentity" let remoteIdentityKey = "clawdis.remoteIdentity"
let remoteProjectRootKey = "clawdis.remoteProjectRoot" let remoteProjectRootKey = "clawdis.remoteProjectRoot"
let remoteCliPathKey = "clawdis.remoteCliPath"
let canvasEnabledKey = "clawdis.canvasEnabled" let canvasEnabledKey = "clawdis.canvasEnabled"
let cameraEnabledKey = "clawdis.cameraEnabled" let cameraEnabledKey = "clawdis.cameraEnabled"
let peekabooBridgeEnabledKey = "clawdis.peekabooBridgeEnabled" let peekabooBridgeEnabledKey = "clawdis.peekabooBridgeEnabled"

View File

@@ -17,6 +17,7 @@ final class GatewayDiscoveryModel {
var lanHost: String? var lanHost: String?
var tailnetDns: String? var tailnetDns: String?
var sshPort: Int var sshPort: Int
var cliPath: String?
var stableID: String var stableID: String
var debugID: String var debugID: String
var isLocal: Bool var isLocal: Bool
@@ -66,6 +67,7 @@ final class GatewayDiscoveryModel {
var lanHost: String? var lanHost: String?
var tailnetDns: String? var tailnetDns: String?
var sshPort = 22 var sshPort = 22
var cliPath: String?
if let value = txt["lanHost"] { if let value = txt["lanHost"] {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -81,6 +83,10 @@ final class GatewayDiscoveryModel {
{ {
sshPort = parsed sshPort = parsed
} }
if let value = txt["cliPath"] {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
cliPath = trimmed.isEmpty ? nil : trimmed
}
let isLocal = Self.isLocalGateway( let isLocal = Self.isLocalGateway(
lanHost: lanHost, lanHost: lanHost,
@@ -93,6 +99,7 @@ final class GatewayDiscoveryModel {
lanHost: lanHost, lanHost: lanHost,
tailnetDns: tailnetDns, tailnetDns: tailnetDns,
sshPort: sshPort, sshPort: sshPort,
cliPath: cliPath,
stableID: BridgeEndpointID.stableID(result.endpoint), stableID: BridgeEndpointID.stableID(result.endpoint),
debugID: BridgeEndpointID.prettyDescription(result.endpoint), debugID: BridgeEndpointID.prettyDescription(result.endpoint),
isLocal: isLocal) isLocal: isLocal)

View File

@@ -181,6 +181,11 @@ struct GeneralSettings: View {
.textFieldStyle(.roundedBorder) .textFieldStyle(.roundedBorder)
.frame(width: 280) .frame(width: 280)
} }
LabeledContent("CLI path") {
TextField("/Applications/Clawdis.app/.../clawdis", text: self.$state.remoteCliPath)
.textFieldStyle(.roundedBorder)
.frame(width: 280)
}
} }
.padding(.top, 4) .padding(.top, 4)
} label: { } label: {
@@ -612,6 +617,7 @@ extension GeneralSettings {
target += ":\(gateway.sshPort)" target += ":\(gateway.sshPort)"
} }
self.state.remoteTarget = target self.state.remoteTarget = target
self.state.remoteCliPath = gateway.cliPath ?? ""
} }
} }

View File

@@ -396,6 +396,14 @@ struct OnboardingView: View {
.textFieldStyle(.roundedBorder) .textFieldStyle(.roundedBorder)
.frame(width: fieldWidth) .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.") Text("Tip: keep Tailscale enabled so your gateway stays reachable.")
@@ -436,6 +444,7 @@ struct OnboardingView: View {
} }
self.state.remoteTarget = target self.state.remoteTarget = target
} }
self.state.remoteCliPath = gateway.cliPath ?? ""
self.state.connectionMode = .remote self.state.connectionMode = .remote
MacNodeModeCoordinator.shared.setPreferredBridgeStableID(gateway.stableID) MacNodeModeCoordinator.shared.setPreferredBridgeStableID(gateway.stableID)

View File

@@ -490,6 +490,7 @@ enum CommandResolver {
].joined(separator: ":") ].joined(separator: ":")
let quotedArgs = ([subcommand] + extraArgs).map(self.shellQuote).joined(separator: " ") let quotedArgs = ([subcommand] + extraArgs).map(self.shellQuote).joined(separator: " ")
let userPRJ = settings.projectRoot.trimmingCharacters(in: .whitespacesAndNewlines) let userPRJ = settings.projectRoot.trimmingCharacters(in: .whitespacesAndNewlines)
let userCLI = settings.cliPath.trimmingCharacters(in: .whitespacesAndNewlines)
let projectSection = if userPRJ.isEmpty { 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 = """ let scriptBody = """
PATH=\(exportedPath); PATH=\(exportedPath);
CLI=""; CLI="";
\(cliSection)
\(projectSection) \(projectSection)
if command -v clawdis >/dev/null 2>&1; then if command -v clawdis >/dev/null 2>&1; then
CLI="$(command -v clawdis)" CLI="$(command -v clawdis)"
@@ -543,6 +566,7 @@ enum CommandResolver {
let target: String let target: String
let identity: String let identity: String
let projectRoot: String let projectRoot: String
let cliPath: String
} }
static func connectionSettings(defaults: UserDefaults = .standard) -> RemoteSettings { static func connectionSettings(defaults: UserDefaults = .standard) -> RemoteSettings {
@@ -557,11 +581,13 @@ enum CommandResolver {
let target = defaults.string(forKey: remoteTargetKey) ?? "" let target = defaults.string(forKey: remoteTargetKey) ?? ""
let identity = defaults.string(forKey: remoteIdentityKey) ?? "" let identity = defaults.string(forKey: remoteIdentityKey) ?? ""
let projectRoot = defaults.string(forKey: remoteProjectRootKey) ?? "" let projectRoot = defaults.string(forKey: remoteProjectRootKey) ?? ""
let cliPath = defaults.string(forKey: remoteCliPathKey) ?? ""
return RemoteSettings( return RemoteSettings(
mode: mode, mode: mode,
target: self.sanitizedTarget(target), target: self.sanitizedTarget(target),
identity: identity, identity: identity,
projectRoot: projectRoot) projectRoot: projectRoot,
cliPath: cliPath)
} }
static var attachExistingGatewayOnly: Bool { static var attachExistingGatewayOnly: Bool {

View File

@@ -94,6 +94,7 @@ The Gateway advertises small non-secret hints to make UI flows convenient:
- `gatewayPort=<port>` (informational; the Gateway WS is typically loopback-only) - `gatewayPort=<port>` (informational; the Gateway WS is typically loopback-only)
- `bridgePort=<port>` (only when bridge is enabled) - `bridgePort=<port>` (only when bridge is enabled)
- `canvasPort=<port>` (only when the canvas host is running; enabled by default; default `18793`) - `canvasPort=<port>` (only when the canvas host is running; enabled by default; default `18793`)
- `cliPath=<path>` (optional; absolute path to a runnable `clawdis` entrypoint or binary)
- `tailnetDns=<magicdns>` (optional hint; auto-detected from Tailscale when available; may be absent) - `tailnetDns=<magicdns>` (optional hint; auto-detected from Tailscale when available; may be absent)
## Debugging on macOS ## Debugging on macOS

View File

@@ -55,7 +55,8 @@ Troubleshooting and beacon details: `docs/bonjour.md`.
- `gatewayPort=18789` (loopback WS port; informational) - `gatewayPort=18789` (loopback WS port; informational)
- `bridgePort=18790` (when bridge is enabled) - `bridgePort=18790` (when bridge is enabled)
- `canvasPort=18793` (when the canvas host is running; enabled by default) - `canvasPort=18793` (when the canvas host is running; enabled by default)
- `tailnetDns=<magicdns>` (optional hint; auto-detected when Tailscale is available) - `cliPath=<path>` (optional; absolute path to a runnable `clawdis` entrypoint or binary)
- `tailnetDns=<magicdns>` (optional hint; auto-detected when Tailscale is available)
Disable/override: Disable/override:
- `CLAWDIS_DISABLE_BONJOUR=1` disables advertising. - `CLAWDIS_DISABLE_BONJOUR=1` disables advertising.

View File

@@ -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. - 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. - **Identity file** (advanced): path to your key.
- **Project root** (advanced): remote checkout path used for commands. - **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 isnt found remotely. 3) Hit **Test remote**. Success indicates the remote `clawdis status --json` runs correctly. Failures usually mean PATH/CLI issues; exit 127 means the CLI isnt found remotely.
4) Health checks and Web Chat will now run through this SSH tunnel automatically. 4) Health checks and Web Chat will now run through this SSH tunnel automatically.

View File

@@ -22,6 +22,7 @@ import {
type CanvasHostServer, type CanvasHostServer,
startCanvasHost, startCanvasHost,
} from "../canvas-host/server.js"; } from "../canvas-host/server.js";
import { handleA2uiHttpRequest } from "../canvas-host/a2ui.js";
import { createDefaultDeps } from "../cli/deps.js"; import { createDefaultDeps } from "../cli/deps.js";
import { agentCommand } from "../commands/agent.js"; import { agentCommand } from "../commands/agent.js";
import { getHealthSnapshot, type HealthSummary } from "../commands/health.js"; import { getHealthSnapshot, type HealthSummary } from "../commands/health.js";
@@ -105,6 +106,37 @@ import { handleControlUiHttpRequest } from "./control-ui.js";
ensureClawdisCliOnPath(); 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<void>) | null = null; let stopBrowserControlServerIfStarted: (() => Promise<void>) | null = null;
async function startBrowserControlServerIfEnabled(): Promise<void> { async function startBrowserControlServerIfEnabled(): Promise<void> {
@@ -976,6 +1008,9 @@ export async function startGatewayServer(
"gateway bind is tailnet, but no tailnet interface was found; refusing to start gateway", "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 = const controlUiEnabled =
opts.controlUiEnabled ?? cfgForServer.gateway?.controlUi?.enabled ?? true; opts.controlUiEnabled ?? cfgForServer.gateway?.controlUi?.enabled ?? true;
if (!isLoopbackHost(bindHost) && !getGatewayToken()) { if (!isLoopbackHost(bindHost) && !getGatewayToken()) {
@@ -988,13 +1023,20 @@ export async function startGatewayServer(
// Don't interfere with WebSocket upgrades; ws handles the 'upgrade' event. // Don't interfere with WebSocket upgrades; ws handles the 'upgrade' event.
if (String(req.headers.upgrade ?? "").toLowerCase() === "websocket") return; if (String(req.headers.upgrade ?? "").toLowerCase() === "websocket") return;
if (controlUiEnabled) { void (async () => {
if (handleControlUiHttpRequest(req, res)) return; if (await handleA2uiHttpRequest(req, res)) return;
} if (controlUiEnabled) {
if (handleControlUiHttpRequest(req, res)) return;
}
res.statusCode = 404; res.statusCode = 404;
res.setHeader("Content-Type", "text/plain; charset=utf-8"); res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end("Not Found"); 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<void>) | null = null; let bonjourStop: (() => Promise<void>) | null = null;
let bridge: Awaited<ReturnType<typeof startNodeBridgeServer>> | null = null; let bridge: Awaited<ReturnType<typeof startNodeBridgeServer>> | null = null;
@@ -1057,6 +1099,7 @@ export async function startGatewayServer(
const canvasHostEnabled = const canvasHostEnabled =
process.env.CLAWDIS_SKIP_CANVAS_HOST !== "1" && process.env.CLAWDIS_SKIP_CANVAS_HOST !== "1" &&
cfgAtStart.canvasHost?.enabled !== false; cfgAtStart.canvasHost?.enabled !== false;
const preferGatewayA2uiHost = hasTailnet && !isLoopbackHost(bindHost);
if (canvasHostEnabled) { if (canvasHostEnabled) {
try { try {
@@ -2029,7 +2072,7 @@ export async function startGatewayServer(
host: bridgeHost, host: bridgeHost,
port: bridgePort, port: bridgePort,
serverName: machineDisplayName, serverName: machineDisplayName,
canvasHostPort: canvasHost?.port, canvasHostPort: preferGatewayA2uiHost ? port : canvasHost?.port,
onRequest: (nodeId, req) => handleBridgeRequest(nodeId, req), onRequest: (nodeId, req) => handleBridgeRequest(nodeId, req),
onAuthenticated: async (node) => { onAuthenticated: async (node) => {
const host = node.displayName?.trim() || node.nodeId; const host = node.displayName?.trim() || node.nodeId;
@@ -2148,6 +2191,7 @@ export async function startGatewayServer(
canvasPort: canvasHost?.port, canvasPort: canvasHost?.port,
sshPort, sshPort,
tailnetDns, tailnetDns,
cliPath: resolveBonjourCliPath(),
}); });
bonjourStop = bonjour.stop; bonjourStop = bonjour.stop;
} catch (err) { } catch (err) {
@@ -2318,7 +2362,10 @@ export async function startGatewayServer(
const remoteAddr = ( const remoteAddr = (
socket as WebSocket & { _socket?: { remoteAddress?: string } } socket as WebSocket & { _socket?: { remoteAddress?: string } }
)._socket?.remoteAddress; )._socket?.remoteAddress;
const canvasHostUrl = deriveCanvasHostUrl(req, canvasHost?.port); const canvasHostUrl = deriveCanvasHostUrl(
req,
preferGatewayA2uiHost ? port : canvasHost?.port,
);
logWs("in", "open", { connId, remoteAddr }); logWs("in", "open", { connId, remoteAddr });
const isWebchatConnect = (params: ConnectParams | null | undefined) => const isWebchatConnect = (params: ConnectParams | null | undefined) =>
params?.client?.mode === "webchat" || params?.client?.mode === "webchat" ||

View File

@@ -97,6 +97,7 @@ describe("gateway bonjour advertiser", () => {
sshPort: 2222, sshPort: 2222,
bridgePort: 18790, bridgePort: 18790,
tailnetDns: "host.tailnet.ts.net", tailnetDns: "host.tailnet.ts.net",
cliPath: "/opt/homebrew/bin/clawdis",
}); });
expect(createService).toHaveBeenCalledTimes(1); expect(createService).toHaveBeenCalledTimes(1);
@@ -116,6 +117,9 @@ describe("gateway bonjour advertiser", () => {
expect((bridgeCall?.[0]?.txt as Record<string, string>)?.sshPort).toBe( expect((bridgeCall?.[0]?.txt as Record<string, string>)?.sshPort).toBe(
"2222", "2222",
); );
expect((bridgeCall?.[0]?.txt as Record<string, string>)?.cliPath).toBe(
"/opt/homebrew/bin/clawdis",
);
expect((bridgeCall?.[0]?.txt as Record<string, string>)?.transport).toBe( expect((bridgeCall?.[0]?.txt as Record<string, string>)?.transport).toBe(
"bridge", "bridge",
); );

View File

@@ -14,6 +14,7 @@ export type GatewayBonjourAdvertiseOpts = {
bridgePort?: number; bridgePort?: number;
canvasPort?: number; canvasPort?: number;
tailnetDns?: string; tailnetDns?: string;
cliPath?: string;
}; };
function isDisabledByEnv() { function isDisabledByEnv() {
@@ -115,6 +116,9 @@ export async function startGatewayBonjourAdvertiser(
if (typeof opts.tailnetDns === "string" && opts.tailnetDns.trim()) { if (typeof opts.tailnetDns === "string" && opts.tailnetDns.trim()) {
txtBase.tailnetDns = 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 }> = []; const services: Array<{ label: string; svc: BonjourService }> = [];