Something went wrong. Try again.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636const std = @import("std");const Allocator = std.mem.Allocator;const ArenaAllocator = std.heap.ArenaAllocator;const cli_args = @import("args.zig");const diagnostics = @import("diagnostics.zig");const Action = @import("ghostty.zig").Action;const DiskCache = @import("ssh_cache.zig").DiskCache;const internal_os = @import("../os/main.zig");const ghostty_terminfo = @import("../terminfo/main.zig").ghostty;
const log = std.log.scoped(.ssh);
const usage = \\Usage: ghostty +ssh [flags] [--] <ssh args...> \\ \\Flags: \\ --forward-env[=bool] Enable TERM / SendEnv forwarding. Default: true. \\ --terminfo[=bool] Install Ghostty terminfo on first connect. Default: true. \\ --cache[=bool] Use the terminfo install cache. Default: true. \\ --ssh=<path> Path to the ssh binary. Default: first `ssh` on PATH. \\ --verbose Print +ssh status lines to stderr. \\ --help Show full help. \\ \\ssh flags and the destination go after +ssh's own flags (or after `--`). \\;
pub const Options = struct { /// Set by the CLI parser for deinit. _arena: ?ArenaAllocator = null,
/// Maps to the `ssh-env` shell integration feature. @"forward-env": bool = true,
/// Maps to the `ssh-terminfo` shell integration feature. terminfo: bool = true,
/// When false, both cache read and write are bypassed. cache: bool = true,
/// The wrapped `ssh` binary. /// `/`-containing values are treated as paths; otherwise resolved via PATH. ssh: []const u8 = "ssh",
/// When true, print verbose output to stderr. verbose: bool = false,
/// Arguments passed through to `ssh` verbatim. Populated by /// `parseManuallyHook` when we reach the first non-flag argument (or /// an explicit `--`). _ssh_args: std.ArrayList([]const u8) = .empty,
/// Enables arg parsing diagnostics so unknown flags become /// diagnostics rather than fatal errors. _diagnostics: diagnostics.DiagnosticList = .{},
pub fn deinit(self: *Options) void { if (self._arena) |arena| arena.deinit(); self.* = undefined; }
/// Enables `-h` and `--help` to work. pub fn help(_: Options) !void { return Action.help_error; }
/// Manual parse hook. For each argument: /// - If it's a literal `--`, consume everything after it as ssh /// args and stop parsing. /// - If it doesn't start with `--`, this is the start of the ssh /// argv. Consume this arg and everything after as ssh args and /// stop parsing. /// - Otherwise (a `--foo` arg), return true so the generic parser /// handles it as one of our own flags. pub fn parseManuallyHook( self: *Options, alloc: Allocator, arg: []const u8, iter: anytype, ) Allocator.Error!bool { if (std.mem.eql(u8, arg, "--")) { while (iter.next()) |rest| { try self._ssh_args.append(alloc, try alloc.dupe(u8, rest)); } return false; }
if (!std.mem.startsWith(u8, arg, "--")) { try self._ssh_args.append(alloc, try alloc.dupe(u8, arg)); while (iter.next()) |rest| { try self._ssh_args.append(alloc, try alloc.dupe(u8, rest)); } return false; }
return true; }};
/// Wrap `ssh` to automatically configure Ghostty terminal integration on/// remote hosts.////// Any arguments that aren't recognized as `+ssh` flags are passed to/// the real `ssh` binary unchanged. You can use `--` as an explicit/// disambiguator if needed, though it's almost never required: `ssh`/// has no long flags, and `+ssh` defines no short flags, so there's/// nothing to collide.////// This is typically called via Ghostty's shell integration. When/// `shell-integration-features` includes `ssh-env` or `ssh-terminfo`,/// each shell defines an `ssh` function that runs:////// ghostty +ssh <flags> -- "$@"////// You can also run `ghostty +ssh` directly, or alias it yourself (e.g./// `alias ssh='ghostty +ssh --'`) if you prefer not to use the shell/// integration.////// `+ssh` performs up to two pieces of setup before launching `ssh`:////// 1. **Environment forwarding** (`--forward-env`). Sets `TERM` to/// `xterm-256color` and requests `SendEnv` forwarding of/// `COLORTERM`, `TERM_PROGRAM`, and `TERM_PROGRAM_VERSION` so the/// remote shell can still detect that it's running inside Ghostty./// The remote `sshd_config` must list these in `AcceptEnv` for/// forwarding to succeed.////// 2. **Terminfo install** (`--terminfo`). On the first connection to a/// given destination, installs Ghostty's terminfo entry on the remote/// host using `infocmp -x xterm-ghostty | ssh tic -x -` over a/// shared `ControlMaster` connection. Successful installs are cached/// (see `ghostty +ssh-cache`) so subsequent connections skip this/// step. When terminfo is successfully installed or already cached,/// `TERM` is set to `xterm-ghostty` instead of `xterm-256color`.////// If `--terminfo` install fails (e.g. `tic` not available on the/// remote, filesystem permissions), a warning is logged and the/// connection continues with `TERM=xterm-256color`.////// Flags:////// * `--forward-env=<bool>`: Enable `TERM` / `SendEnv` environment/// forwarding. Default: `true`.////// * `--terminfo=<bool>`: Enable automatic terminfo install on first/// connection. Default: `true`.////// * `--cache=<bool>`: Use the terminfo install cache. Default: `true`./// When `false`, both the cache read (skip-if-installed) and the/// cache write (record-on-success) are bypassed, and every/// connection performs the install. To one-shot reinstall a single/// host while keeping the cache in use, prefer `ghostty +ssh-cache/// --remove=<host>` followed by a normal connection.////// * `--ssh=<path>`: Path to the `ssh` binary to execute. Default: the/// first `ssh` found on `PATH`.////// * `--verbose`: Print +ssh status lines to stderr, and surface/// remote stderr during the terminfo install.////// Examples:////// # Basic invocation using defaults:/// ghostty +ssh user@example.com////// # Forward Ghostty env vars but skip the terminfo install:/// ghostty +ssh --terminfo=false user@example.com////// # `ssh` flags (short-form `-p`, etc.) pass through unchanged:/// ghostty +ssh -p 2222 -i ~/.ssh/id_ed25519 user@example.com////// # Use `--` explicitly if your ssh args might collide with our flags:/// ghostty +ssh -- --some-rare-ssh-arg user@example.com////// Pass `--verbose` to see what `+ssh` is doing. For cache inspection/// and management, see `ghostty +ssh-cache`.////// Available since: 1.4.0pub fn run(alloc_gpa: Allocator) !u8 { var opts: Options = .{}; defer opts.deinit();
{ var iter = try cli_args.argsIterator(alloc_gpa); defer iter.deinit(); try cli_args.parse(Options, alloc_gpa, &opts, &iter); }
var stderr_buffer: [1024]u8 = undefined; var stderr_file: std.fs.File = .stderr(); var stderr_writer = stderr_file.writer(&stderr_buffer); const stderr = &stderr_writer.interface;
// Any diagnostic from the arg parser is an unknown flag or bad // value. Reject loudly — silently forwarding `--typo` to ssh would // produce confusing downstream errors. if (!opts._diagnostics.empty()) { for (opts._diagnostics.items()) |diag| { if (diag.key.len > 0) { stderr.print( "Error: unknown flag `--{s}`.\n", .{diag.key}, ) catch {}; } else { stderr.print("Error: {s}\n", .{diag.message}) catch {}; } } stderr.print("\n{s}", .{usage}) catch {}; stderr.flush() catch {}; return 2; }
const result = runInner(alloc_gpa, &opts, stderr);
stderr.flush() catch {}; return result;}
fn runInner( gpa: Allocator, opts: *const Options, stderr: *std.Io.Writer,) !u8 { var arena = ArenaAllocator.init(gpa); defer arena.deinit(); const alloc = arena.allocator();
if (opts._ssh_args.items.len == 0) { try stderr.print("Error: no ssh arguments provided.\n\n{s}", .{usage}); return 2; }
const session: struct { term: []const u8, to_cache: ?struct { cache: DiskCache, dest: []const u8 } = null, } = session: { if (!opts.terminfo) break :session .{ .term = "xterm-256color" };
const dest = resolveDestination(alloc, opts.ssh, opts._ssh_args.items) orelse { warnPrint(stderr, "could not resolve ssh destination; skipping terminfo install", .{}); break :session .{ .term = "xterm-256color" }; };
const cache: ?DiskCache = if (opts.cache) cache: { const path = DiskCache.defaultPath(alloc, "ghostty") catch |err| { warnPrint(stderr, "ghostty terminfo cache unavailable: {}", .{err}); break :session .{ .term = "xterm-256color" }; }; break :cache .{ .path = path }; } else null;
if (cache) |c| { if (c.contains(alloc, dest) catch false) { verbosePrint(opts, stderr, "dest: {s} (cached, skipping install)", .{dest}); break :session .{ .term = "xterm-ghostty" }; } else { verbosePrint(opts, stderr, "dest: {s} (not cached, will install)", .{dest}); } } else { verbosePrint(opts, stderr, "dest: {s} (cache disabled, will install)", .{dest}); }
stderr.print("Setting up xterm-ghostty terminfo on {s}...\n", .{dest}) catch {}; stderr.flush() catch {};
installRemoteTerminfo(alloc, opts, stderr) catch |err| { warnPrint(stderr, "failed to install terminfo: {}", .{err}); break :session .{ .term = "xterm-256color" }; }; break :session .{ .term = "xterm-ghostty", .to_cache = if (cache) |c| .{ .cache = c, .dest = dest } else null, }; };
// Build the full argv: [ssh, ...our opts, ...user args] const env_opts: []const []const u8 = if (opts.@"forward-env") env_opts: { const set_term = try std.fmt.allocPrint( alloc, "SetEnv=TERM={s}", .{session.term}, ); break :env_opts &.{ "-o", set_term, "-o", "SendEnv=COLORTERM", "-o", "SendEnv=TERM_PROGRAM", "-o", "SendEnv=TERM_PROGRAM_VERSION", }; } else &.{}; const argv = try std.mem.concat(alloc, []const u8, &.{ &.{opts.ssh}, env_opts, opts._ssh_args.items, }); verbosePrint(opts, stderr, "exec: {f}", .{Joined{ .items = argv }});
const exit_code = childExec(alloc, argv) catch |err| { try stderr.print("Error: failed to run {s}: {}\n", .{ argv[0], err }); return 1; }; verbosePrint(opts, stderr, "exit: {d}", .{exit_code});
// Attempt to cache (if needed) on a successful ssh execution. if (exit_code == 0) if (session.to_cache) |entry| { if (entry.cache.add(alloc, entry.dest, std.time.timestamp())) |_| { verbosePrint(opts, stderr, "cache: wrote {s}", .{entry.dest}); } else |err| { log.debug("cache add failed for '{s}': {}", .{ entry.dest, err }); } };
return exit_code;}
/// Log to `.ssh` and, if `--verbose`, also print to stderr.fn verbosePrint( opts: *const Options, stderr: *std.Io.Writer, comptime fmt: []const u8, args: anytype,) void { log.debug(fmt, args); if (!opts.verbose) return; stderr.print("+ssh: " ++ fmt ++ "\n", args) catch return; stderr.flush() catch return;}
/// Log a warning and also print a `Warning: <msg>` line to stderr.fn warnPrint( stderr: *std.Io.Writer, comptime fmt: []const u8, args: anytype,) void { log.warn(fmt, args); stderr.print("Warning: " ++ fmt ++ "\n", args) catch return; stderr.flush() catch return;}
/// Space-joined items, formattable as `{f}`.const Joined = struct { items: []const []const u8,
pub fn format(self: Joined, writer: *std.Io.Writer) !void { for (self.items, 0..) |a, i| { if (i > 0) try writer.writeByte(' '); try writer.writeAll(a); } }
test { const testing = std.testing; var buf: [128]u8 = undefined; { var w: std.Io.Writer = .fixed(&buf); try w.print("{f}", .{Joined{ .items = &.{} }}); try testing.expectEqualStrings("", buf[0..w.end]); } { var w: std.Io.Writer = .fixed(&buf); try w.print("{f}", .{Joined{ .items = &.{"only"} }}); try testing.expectEqualStrings("only", buf[0..w.end]); } { var w: std.Io.Writer = .fixed(&buf); try w.print("{f}", .{Joined{ .items = &.{ "a", "b", "c" } }}); try testing.expectEqualStrings("a b c", buf[0..w.end]); } }};
fn checkExit(term: std.process.Child.Term, label: []const u8) error{ChildFailed}!void { switch (term) { .Exited => |rc| if (rc != 0) { log.warn("{s} exited with non-zero status: {d}", .{ label, rc }); return error.ChildFailed; }, else => { log.warn("{s} terminated abnormally: {}", .{ label, term }); return error.ChildFailed; }, }}
/// Run `ssh -G <args>` and parse the output for `user` and `hostname`./// Returns the resolved `user@hostname`, or null if the destination/// could not be resolved.fn resolveDestination( alloc: Allocator, ssh: []const u8, args: []const []const u8,) ?[]const u8 { const argv = std.mem.concat(alloc, []const u8, &.{ &.{ ssh, "-G" }, args, }) catch return null; const result = std.process.Child.run(.{ .allocator = alloc, .argv = argv, }) catch |err| { log.warn("ssh -G spawn failed: {}", .{err}); return null; }; checkExit(result.term, "ssh -G") catch return null; return parseDestination(alloc, result.stdout);}
/// Parse `ssh -G` output for `user` and `hostname` and return the/// formatted `user@hostname`. Returns null if either key is missing/// or formatting fails.fn parseDestination(alloc: Allocator, stdout: []const u8) ?[]const u8 { var user: []const u8 = ""; var host: []const u8 = ""; var it = std.mem.tokenizeScalar(u8, stdout, '\n'); while (it.next()) |line| { const space = std.mem.indexOfScalar(u8, line, ' ') orelse continue; const key = line[0..space]; const value = line[space + 1 ..]; if (std.mem.eql(u8, key, "user")) { user = value; } else if (std.mem.eql(u8, key, "hostname")) { host = value; } if (user.len > 0 and host.len > 0) break; }
if (user.len == 0) { log.warn("ssh -G output missing user", .{}); return null; } if (host.len == 0) { log.warn("ssh -G output missing hostname", .{}); return null; }
return std.fmt.allocPrint(alloc, "{s}@{s}", .{ user, host }) catch null;}
/// Install Ghostty's terminfo on the remote host over a short-lived SSH/// ControlMaster connection. The master tears down with the client/// (`ControlPersist=no`) so no socket lingers.fn installRemoteTerminfo( alloc: Allocator, opts: *const Options, stderr: *std.Io.Writer,) !void { var buf: std.Io.Writer.Allocating = .init(alloc); defer buf.deinit(); try ghostty_terminfo.encode(&buf.writer); const terminfo = buf.written();
// ControlPath is in TMPDIR with a short, random basename. ssh uses // ControlPath as the bind address for a Unix domain socket; macOS // limits sockaddr_un.sun_path to ~104 bytes, so keeping the path // short leaves margin. const control_path = try internal_os.randomTmpPath(alloc, "ghostty-ssh-"); const control_path_opt = try std.fmt.allocPrint( alloc, "ControlPath={s}", .{control_path}, );
// Under --verbose, let remote stderr through (the `tic` step is // the most common failure source) and inherit ssh's stderr so it // reaches the user's terminal. Other steps stay quiet either way. const remote_script = if (opts.verbose) \\infocmp xterm-ghostty >/dev/null 2>&1 && exit 0 \\command -v tic >/dev/null 2>&1 || exit 1 \\mkdir -p ~/.terminfo 2>/dev/null && tic -x - && exit 0 \\exit 1 else \\infocmp xterm-ghostty >/dev/null 2>&1 && exit 0 \\command -v tic >/dev/null 2>&1 || exit 1 \\mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && exit 0 \\exit 1 ;
// Set up an SSH ControlMaster scoped to this single install: // - ControlMaster=yes makes our client also act as the master, // so `infocmp | ssh tic` runs over a single connection. // - ControlPersist=no tears the master down when our client // exits; no socket lingers on the remote side. const argv = try std.mem.concat(alloc, []const u8, &.{ &.{opts.ssh}, &.{ "-o", "ControlMaster=yes", "-o", "ControlPersist=no", "-o", control_path_opt, }, opts._ssh_args.items, &.{remote_script}, }); verbosePrint(opts, stderr, "exec: {f}", .{Joined{ .items = argv }});
var child: std.process.Child = .init(argv, alloc); child.stdin_behavior = .Pipe; child.stdout_behavior = .Ignore; child.stderr_behavior = if (opts.verbose) .Inherit else .Ignore;
child.spawn() catch |err| { log.warn("terminfo install spawn failed: {}", .{err}); return error.InstallFailed; };
if (child.stdin) |stdin| { stdin.writeAll(terminfo) catch {}; stdin.close(); child.stdin = null; }
const term = child.wait() catch |err| { log.warn("terminfo install wait failed: {}", .{err}); return error.InstallFailed; }; checkExit(term, "terminfo install") catch return error.InstallFailed;}
/// Returns `128 + signum` for signal-killed children, matching shell convention.fn childExec(alloc: Allocator, argv: []const []const u8) !u8 { var child: std.process.Child = .init(argv, alloc); child.stdin_behavior = .Inherit; child.stdout_behavior = .Inherit; child.stderr_behavior = .Inherit;
try child.spawn(); const term = try child.wait(); return switch (term) { .Exited => |rc| rc, .Signal => |sig| @as(u8, 128) + @as(u8, @intCast(@min(sig, 127))), .Stopped, .Unknown => 1, };}
fn parseTestArgs(alloc: Allocator, opts: *Options, line: []const u8) !void { var iter = try std.process.ArgIteratorGeneral(.{}).init(alloc, line); defer iter.deinit(); try cli_args.parse(Options, alloc, opts, &iter);}
test "parseManuallyHook: bare destination starts ssh args" { const testing = std.testing; var opts: Options = .{}; defer opts.deinit(); try parseTestArgs(testing.allocator, &opts, "--terminfo=false user@example.com"); try testing.expectEqual(false, opts.terminfo); try testing.expectEqual(true, opts.@"forward-env"); try testing.expectEqual(@as(usize, 1), opts._ssh_args.items.len); try testing.expectEqualStrings("user@example.com", opts._ssh_args.items[0]);}
test "parseManuallyHook: short ssh flags pass through verbatim" { const testing = std.testing; var opts: Options = .{}; defer opts.deinit(); try parseTestArgs(testing.allocator, &opts, "-p 2222 user@example.com"); try testing.expectEqual(@as(usize, 3), opts._ssh_args.items.len); try testing.expectEqualStrings("-p", opts._ssh_args.items[0]); try testing.expectEqualStrings("2222", opts._ssh_args.items[1]); try testing.expectEqualStrings("user@example.com", opts._ssh_args.items[2]);}
test "parseManuallyHook: explicit -- separator" { const testing = std.testing; var opts: Options = .{}; defer opts.deinit(); try parseTestArgs( testing.allocator, &opts, "--verbose -- --some-rare-ssh-arg user@example.com", ); try testing.expectEqual(true, opts.verbose); try testing.expectEqual(@as(usize, 2), opts._ssh_args.items.len); try testing.expectEqualStrings("--some-rare-ssh-arg", opts._ssh_args.items[0]); try testing.expectEqualStrings("user@example.com", opts._ssh_args.items[1]);}
test "parseDestination: typical ssh -G output" { const testing = std.testing; var arena = ArenaAllocator.init(testing.allocator); defer arena.deinit(); const stdout = \\user alice \\hostname example.com \\port 22 \\identityfile ~/.ssh/id_ed25519 \\ ; const result = parseDestination(arena.allocator(), stdout); try testing.expectEqualStrings("alice@example.com", result.?);}
test "parseDestination: hostname before user" { const testing = std.testing; var arena = ArenaAllocator.init(testing.allocator); defer arena.deinit(); const stdout = \\hostname example.com \\port 22 \\user alice \\ ; const result = parseDestination(arena.allocator(), stdout); try testing.expectEqualStrings("alice@example.com", result.?);}
test "parseDestination: missing hostname returns null" { const testing = std.testing; var arena = ArenaAllocator.init(testing.allocator); defer arena.deinit(); const stdout = "user alice\nport 22\n"; try testing.expectEqual(@as(?[]const u8, null), parseDestination(arena.allocator(), stdout));}
test "parseDestination: missing user returns null" { const testing = std.testing; var arena = ArenaAllocator.init(testing.allocator); defer arena.deinit(); const stdout = "hostname example.com\nport 22\n"; try testing.expectEqual(@as(?[]const u8, null), parseDestination(arena.allocator(), stdout));}
test "parseDestination: empty input returns null" { const testing = std.testing; var arena = ArenaAllocator.init(testing.allocator); defer arena.deinit(); try testing.expectEqual(@as(?[]const u8, null), parseDestination(arena.allocator(), ""));}
test "parseDestination: IPv6 hostname" { const testing = std.testing; var arena = ArenaAllocator.init(testing.allocator); defer arena.deinit(); const stdout = "user alice\nhostname ::1\n"; const result = parseDestination(arena.allocator(), stdout); try testing.expectEqualStrings("alice@::1", result.?);}