From 84e61fbdcbd61aa44b9dcec972948caad0e1d39d Mon Sep 17 00:00:00 2001 From: Michael Heller <21163552+mdheller@users.noreply.github.com> Date: Tue, 4 Aug 2026 03:50:55 -0400 Subject: [PATCH] feat(cloudshell): SSH bastion + k3s twin admin surface via cloudshell-fog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit turtle-ssh-tunnel (new, 419 lines): - Reads ~/.config/sourceos/cloudshell.yaml (or env vars: CLOUDSHELL_HOST/USER/PORT/KEY) - connect: os.execvp ssh → replaces process (true interactive session) - tunnel [start|stop|status]: ssh -N -L {16443}:{k3s-host}:{6443} in background; PID → ~/.local/state/sourceos/k3s-tunnel.pid - proxy [start|stop|status]: SOCKS5 on localhost:1080 via cloudshell-fog; PID → cloudshell-proxy.pid - exec : non-interactive remote command - copy : scp wrapper with cloudshell host expansion - Emits mesh events on connect + tunnel-start turtle-shell-init.zsh: - csh → SSH into cloudshell-fog bastion (interactive) - ktunnel [start|stop|status] → k3s API tunnel through bastion - kproxy [start|stop|status] → SOCKS5 proxy through cloudshell - k3s → kubectl with KUBECONFIG=~/.kube/config-k3s-twin; warns when tunnel PID is dead - csh-exec / csh-copy / csh-status convenience wrappers turtleterm.lua: - CMD+SHIFT+K → csh (CloudShell SSH) - Palette: cloudshell_ssh / k3s_tunnel / cloudshell_status turtle-mesh-serve: - _k3s_tunnel_alive(): reads k3s-tunnel.pid, sends kill -0 to check liveness - gather_state(): k3s_tunnel_up bool in returned dict - Dashboard status bar: k3s badge (green=tunnel up / red=down) --- assets/sourceos/bin/turtle-mesh-serve | 20 +- assets/sourceos/bin/turtle-ssh-tunnel | 419 ++++++++++++++++++++ assets/sourceos/shell/turtle-shell-init.zsh | 46 +++ assets/sourceos/turtleterm.lua | 8 + 4 files changed, 491 insertions(+), 2 deletions(-) create mode 100755 assets/sourceos/bin/turtle-ssh-tunnel diff --git a/assets/sourceos/bin/turtle-mesh-serve b/assets/sourceos/bin/turtle-mesh-serve index b1ed8515c02..ae2a171e88d 100755 --- a/assets/sourceos/bin/turtle-mesh-serve +++ b/assets/sourceos/bin/turtle-mesh-serve @@ -79,6 +79,16 @@ def _searxng_alive() -> bool: return False +def _k3s_tunnel_alive() -> bool: + pid_file = Path.home() / ".local" / "state" / "sourceos" / "k3s-tunnel.pid" + try: + pid = int(pid_file.read_text().strip()) + os.kill(pid, 0) + return True + except Exception: + return False + + def gather_state() -> dict: mesh = load_jsonl_tail(MESH_DIR / "context.jsonl", 60) active = load_json(MESH_DIR / "active.json") @@ -130,8 +140,9 @@ def gather_state() -> dict: "board": board, "bb_cands": bb_cands, "notes": notes, - "searxng_ok": searxng_ok, - "runbooks": runbooks, + "searxng_ok": searxng_ok, + "k3s_tunnel_up": _k3s_tunnel_alive(), + "runbooks": runbooks, } @@ -270,6 +281,7 @@ h2{font-size:12px;color:var(--dim);text-transform:uppercase;letter-spacing:.08em CI — PRs — Board — + k3s … @@ -335,6 +347,10 @@ function render(state) { document.getElementById('board-badge').innerHTML = bd.score !== undefined ? `Board ${Number(bd.score).toFixed(1)}%` : 'Board —' + document.getElementById('k3s-badge').innerHTML = state.k3s_tunnel_up + ? `k3s up` + : `k3s down` + document.getElementById('ts-badge').textContent = new Date(state.ts).toLocaleTimeString() // Active focus diff --git a/assets/sourceos/bin/turtle-ssh-tunnel b/assets/sourceos/bin/turtle-ssh-tunnel new file mode 100755 index 00000000000..23fbed0206c --- /dev/null +++ b/assets/sourceos/bin/turtle-ssh-tunnel @@ -0,0 +1,419 @@ +#!/usr/bin/env python3 +"""turtle-ssh-tunnel — cloudshell-fog SSH bastion + k3s twin tunnel manager. + +Manages SSH connections and tunnels to the cloudshell-fog bastion and the +sovereign k3s edge twin cluster. + +Config: ~/.config/sourceos/cloudshell.yaml (or env vars) + +Usage: + turtle-ssh-tunnel status # show all tunnel/connection state + turtle-ssh-tunnel connect # SSH into cloudshell-fog (interactive) + turtle-ssh-tunnel tunnel [start|stop|status] + turtle-ssh-tunnel proxy [start|stop|status] + turtle-ssh-tunnel exec [args…] # run command non-interactively + turtle-ssh-tunnel copy # scp with cloudshell: host expansion +""" +from __future__ import annotations + +import argparse +import datetime +import json +import os +import signal +import subprocess +import sys +from pathlib import Path + +try: + import yaml as _yaml # type: ignore + _HAS_YAML = True +except ImportError: + _HAS_YAML = False + +# ── colour helpers ───────────────────────────────────────────────────────────── + +_RESET = "\033[0m" +_BOLD = "\033[1m" +_DIM = "\033[2m" +_GREEN = "\033[38;2;63;185;80m" +_RED = "\033[38;2;248;81;73m" +_YELLOW = "\033[38;2;210;153;34m" +_TEAL = "\033[38;2;57;197;207m" +_BLUE = "\033[38;2;88;166;255m" + +_NO_COLOUR = not sys.stdout.isatty() + + +def c(colour: str, text: str) -> str: + if _NO_COLOUR: + return text + return colour + text + _RESET + + +# ── config ───────────────────────────────────────────────────────────────────── + +_DEFAULT_CONFIG: dict = { + "cloudshell": { + "host": "cloudshell.sourceos.io", + "user": "sourceos", + "port": 22, + "identity": "~/.ssh/id_ed25519_sourceos", + }, + "k3s_twin": { + "host": "127.0.0.1", + "port": 6443, + "tunnel_local_port": 16443, + "kubeconfig": "~/.kube/config-k3s-twin", + }, +} + + +def _load_yaml_simple(path: Path) -> dict: + """Minimal YAML subset parser (key: value, nested via indent).""" + result: dict = {} + stack: list[tuple[int, dict]] = [(0, result)] + for line in path.read_text(errors="replace").splitlines(): + stripped = line.lstrip() + if not stripped or stripped.startswith("#"): + continue + indent = len(line) - len(stripped) + if ":" not in stripped: + continue + key, _, val = stripped.partition(":") + key = key.strip() + val = val.strip() + # pop stack to current indent level + while len(stack) > 1 and stack[-1][0] >= indent: + stack.pop() + parent = stack[-1][1] + if val == "" or val.startswith("#"): + child: dict = {} + parent[key] = child + stack.append((indent, child)) + else: + # strip inline comments + val = val.split(" #")[0].strip() + # bare type coercion + if val.isdigit(): + parent[key] = int(val) + elif val.lower() in ("true", "yes"): + parent[key] = True + elif val.lower() in ("false", "no"): + parent[key] = False + else: + parent[key] = val + return result + + +def load_config() -> dict: + cfg = { + "cloudshell": dict(_DEFAULT_CONFIG["cloudshell"]), + "k3s_twin": dict(_DEFAULT_CONFIG["k3s_twin"]), + } + cfg_path = Path.home() / ".config" / "sourceos" / "cloudshell.yaml" + if cfg_path.exists(): + try: + if _HAS_YAML: + raw = _yaml.safe_load(cfg_path.read_text()) or {} + else: + raw = _load_yaml_simple(cfg_path) + for section in ("cloudshell", "k3s_twin"): + if section in raw and isinstance(raw[section], dict): + cfg[section].update(raw[section]) + except Exception as exc: + print(c(_YELLOW, f"⚠ cloudshell.yaml parse error: {exc}"), file=sys.stderr) + + # env-var overrides + cs = cfg["cloudshell"] + cs["host"] = os.getenv("CLOUDSHELL_HOST", str(cs["host"])) + cs["user"] = os.getenv("CLOUDSHELL_USER", str(cs["user"])) + cs["port"] = int(os.getenv("CLOUDSHELL_PORT", str(cs["port"]))) + cs["identity"] = os.getenv("CLOUDSHELL_KEY", str(cs["identity"])) + + # expand ~ + cs["identity"] = str(Path(cs["identity"]).expanduser()) + k3s = cfg["k3s_twin"] + k3s["kubeconfig"] = str(Path(str(os.getenv("CLOUDSHELL_K3S_KUBECONFIG", k3s["kubeconfig"]))).expanduser()) + k3s["tunnel_local_port"] = int(os.getenv("CLOUDSHELL_K3S_TUNNEL_PORT", str(k3s["tunnel_local_port"]))) + + return cfg + + +# ── state paths ──────────────────────────────────────────────────────────────── + +_STATE_DIR = Path(os.getenv("XDG_STATE_HOME", str(Path.home() / ".local/state"))) / "sourceos" +_TUNNEL_PID = _STATE_DIR / "k3s-tunnel.pid" +_PROXY_PID = _STATE_DIR / "cloudshell-proxy.pid" +_MESH_CONTEXT = _STATE_DIR / "memory-mesh" / "context.jsonl" + + +def _pid_alive(pid_file: Path) -> tuple[bool, int]: + """Return (alive, pid). alive=False if file missing or process dead.""" + if not pid_file.exists(): + return False, 0 + try: + pid = int(pid_file.read_text().strip()) + os.kill(pid, 0) + return True, pid + except (ValueError, ProcessLookupError, PermissionError): + return False, 0 + + +def _write_pid(pid_file: Path, pid: int) -> None: + pid_file.parent.mkdir(parents=True, exist_ok=True) + pid_file.write_text(str(pid)) + + +def _kill_pid_file(pid_file: Path, label: str) -> None: + alive, pid = _pid_alive(pid_file) + if not alive: + print(c(_YELLOW, f" {label} is not running")) + return + try: + os.kill(pid, signal.SIGTERM) + pid_file.unlink(missing_ok=True) + print(c(_GREEN, f" ✓ {label} stopped (pid {pid})")) + except Exception as exc: + print(c(_RED, f" ✗ could not stop {label}: {exc}"), file=sys.stderr) + + +def _emit_mesh_event(event_type: str, title: str, data: dict) -> None: + try: + _MESH_CONTEXT.parent.mkdir(parents=True, exist_ok=True) + ts = datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z") + ev = json.dumps({"ts": ts, "type": event_type, "title": title, "data": data}) + with _MESH_CONTEXT.open("a") as f: + f.write(ev + "\n") + except Exception: + pass + + +def _build_ssh_base(cfg: dict) -> list[str]: + cs = cfg["cloudshell"] + cmd = ["ssh"] + if cs["identity"] and Path(cs["identity"]).exists(): + cmd += ["-i", cs["identity"]] + cmd += ["-p", str(cs["port"])] + return cmd + + +# ── subcommands ──────────────────────────────────────────────────────────────── + +def cmd_status(cfg: dict, _args: argparse.Namespace) -> int: + cs = cfg["cloudshell"] + k3s = cfg["k3s_twin"] + + print(c(_TEAL, "◆ cloudshell-fog / k3s twin — tunnel status") + "\n") + + # Cloudshell config + print(c(_DIM, " cloudshell host : ") + c(_BLUE, f"{cs['user']}@{cs['host']}:{cs['port']}")) + id_path = cs["identity"] + id_ok = Path(id_path).exists() + print(c(_DIM, " identity key : ") + (c(_GREEN, id_path) if id_ok else c(_RED, f"{id_path} (NOT FOUND)"))) + + # k3s tunnel + t_alive, t_pid = _pid_alive(_TUNNEL_PID) + lp = k3s["tunnel_local_port"] + t_label = ( + c(_GREEN, f"UP (pid {t_pid}) — localhost:{lp} → {k3s['host']}:{k3s['port']}") + if t_alive + else c(_RED, "DOWN") + ) + print(c(_DIM, " k3s tunnel : ") + t_label) + if t_alive: + kc = k3s["kubeconfig"] + print(c(_DIM, " kubeconfig hint : ") + c(_DIM, f"KUBECONFIG={kc} kubectl ...")) + + # SOCKS5 proxy + p_alive, p_pid = _pid_alive(_PROXY_PID) + p_label = ( + c(_GREEN, f"UP (pid {p_pid}) — SOCKS5 localhost:1080") + if p_alive + else c(_RED, "DOWN") + ) + print(c(_DIM, " SOCKS5 proxy : ") + p_label) + print() + return 0 + + +def cmd_connect(cfg: dict, args: argparse.Namespace) -> int: + cs = cfg["cloudshell"] + ssh_cmd = _build_ssh_base(cfg) + [f"{cs['user']}@{cs['host']}"] + _emit_mesh_event( + "cloudshell-connect", + "connected to cloudshell-fog", + {"host": cs["host"], "user": cs["user"]}, + ) + print(c(_TEAL, f" → ssh {' '.join(ssh_cmd[1:])}")) + os.execvp("ssh", ssh_cmd) # replaces current process + + +def cmd_tunnel(cfg: dict, args: argparse.Namespace) -> int: + sub = getattr(args, "tunnel_sub", "status") + cs = cfg["cloudshell"] + k3s = cfg["k3s_twin"] + + if sub == "status" or sub is None: + alive, pid = _pid_alive(_TUNNEL_PID) + lp = k3s["tunnel_local_port"] + if alive: + print(c(_GREEN, f" ✓ k3s tunnel UP (pid {pid}) — localhost:{lp} → {k3s['host']}:{k3s['port']}")) + print(c(_DIM, f" KUBECONFIG={k3s['kubeconfig']} kubectl ...")) + else: + print(c(_RED, " ✗ k3s tunnel DOWN — run: ktunnel start")) + return 0 + + if sub == "stop": + _kill_pid_file(_TUNNEL_PID, "k3s tunnel") + return 0 + + if sub == "start": + alive, pid = _pid_alive(_TUNNEL_PID) + if alive: + print(c(_YELLOW, f" ⚠ k3s tunnel already running (pid {pid})")) + return 0 + lp = k3s["tunnel_local_port"] + ssh_cmd = _build_ssh_base(cfg) + [ + "-N", + "-L", f"{lp}:{k3s['host']}:{k3s['port']}", + f"{cs['user']}@{cs['host']}", + ] + print(c(_TEAL, f" → {' '.join(ssh_cmd)}")) + proc = subprocess.Popen( + ssh_cmd, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + _write_pid(_TUNNEL_PID, proc.pid) + _emit_mesh_event( + "cloudshell-connect", + "k3s tunnel started via cloudshell-fog", + {"host": cs["host"], "local_port": lp, "pid": proc.pid}, + ) + print(c(_GREEN, f" ✓ k3s tunnel started (pid {proc.pid})")) + print(c(_DIM, f" localhost:{lp} → {cs['host']} → {k3s['host']}:{k3s['port']}")) + print(c(_DIM, f" KUBECONFIG={k3s['kubeconfig']} kubectl ...")) + return 0 + + print(c(_RED, f"unknown tunnel sub-command: {sub}"), file=sys.stderr) + return 1 + + +def cmd_proxy(cfg: dict, args: argparse.Namespace) -> int: + sub = getattr(args, "proxy_sub", "status") + cs = cfg["cloudshell"] + + if sub == "status" or sub is None: + alive, pid = _pid_alive(_PROXY_PID) + if alive: + print(c(_GREEN, f" ✓ SOCKS5 proxy UP (pid {pid}) — localhost:1080")) + else: + print(c(_RED, " ✗ SOCKS5 proxy DOWN — run: kproxy start")) + return 0 + + if sub == "stop": + _kill_pid_file(_PROXY_PID, "SOCKS5 proxy") + return 0 + + if sub == "start": + alive, pid = _pid_alive(_PROXY_PID) + if alive: + print(c(_YELLOW, f" ⚠ SOCKS5 proxy already running (pid {pid})")) + return 0 + ssh_cmd = _build_ssh_base(cfg) + [ + "-N", "-D", "1080", + f"{cs['user']}@{cs['host']}", + ] + print(c(_TEAL, f" → {' '.join(ssh_cmd)}")) + proc = subprocess.Popen( + ssh_cmd, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + _write_pid(_PROXY_PID, proc.pid) + print(c(_GREEN, f" ✓ SOCKS5 proxy started (pid {proc.pid}) — localhost:1080")) + print(c(_DIM, " set HTTPS_PROXY=socks5://localhost:1080 to route traffic")) + return 0 + + print(c(_RED, f"unknown proxy sub-command: {sub}"), file=sys.stderr) + return 1 + + +def cmd_exec(cfg: dict, args: argparse.Namespace) -> int: + if not args.cmd: + print("Usage: turtle-ssh-tunnel exec [args…]", file=sys.stderr) + return 1 + cs = cfg["cloudshell"] + ssh_cmd = _build_ssh_base(cfg) + [f"{cs['user']}@{cs['host']}"] + args.cmd + result = subprocess.run(ssh_cmd) + return result.returncode + + +def cmd_copy(cfg: dict, args: argparse.Namespace) -> int: + if len(args.paths) != 2: + print("Usage: turtle-ssh-tunnel copy ", file=sys.stderr) + return 1 + cs = cfg["cloudshell"] + # Expand "cloudshell:" prefix → user@host: + remote = f"{cs['user']}@{cs['host']}" + paths = [p.replace("cloudshell:", remote + ":") for p in args.paths] + id_opt = [] + if cs["identity"] and Path(cs["identity"]).exists(): + id_opt = ["-i", cs["identity"]] + port_opt = ["-P", str(cs["port"])] + scp_cmd = ["scp"] + id_opt + port_opt + paths + print(c(_TEAL, f" → {' '.join(scp_cmd)}")) + result = subprocess.run(scp_cmd) + return result.returncode + + +# ── main ─────────────────────────────────────────────────────────────────────── + +def main() -> int: + parser = argparse.ArgumentParser( + prog="turtle-ssh-tunnel", + description="cloudshell-fog SSH bastion + k3s twin tunnel manager", + ) + sub = parser.add_subparsers(dest="command") + + sub.add_parser("status", help="show tunnel/connection state") + sub.add_parser("connect", help="SSH into cloudshell-fog interactively") + + p_tunnel = sub.add_parser("tunnel", help="manage k3s API tunnel") + p_tunnel.add_argument("tunnel_sub", nargs="?", choices=["start", "stop", "status"], default="status") + + p_proxy = sub.add_parser("proxy", help="SOCKS5 proxy via cloudshell") + p_proxy.add_argument("proxy_sub", nargs="?", choices=["start", "stop", "status"], default="status") + + p_exec = sub.add_parser("exec", help="run command on cloudshell non-interactively") + p_exec.add_argument("cmd", nargs=argparse.REMAINDER) + + p_copy = sub.add_parser("copy", help="scp with cloudshell: host expansion") + p_copy.add_argument("paths", nargs=2) + + args = parser.parse_args() + cfg = load_config() + + dispatch = { + "status": cmd_status, + "connect": cmd_connect, + "tunnel": cmd_tunnel, + "proxy": cmd_proxy, + "exec": cmd_exec, + "copy": cmd_copy, + } + + fn = dispatch.get(args.command or "status") + if fn is None: + parser.print_help() + return 1 + return fn(cfg, args) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/assets/sourceos/shell/turtle-shell-init.zsh b/assets/sourceos/shell/turtle-shell-init.zsh index 5949c8825b7..a8d461c06ac 100644 --- a/assets/sourceos/shell/turtle-shell-init.zsh +++ b/assets/sourceos/shell/turtle-shell-init.zsh @@ -1909,3 +1909,49 @@ PYEOF printf '\n\e[38;2;63;185;80m✓\e[0m session restored: \e[1m%s\e[0m\n' "$name" } + +# ============================================================ +# cloudshell-fog + k3s twin admin surface +# ============================================================ +_turtle_ssh_tunnel_bin() { + local _bin + _bin="$(dirname "$(readlink -f "${(%):-%x}" 2>/dev/null || echo "${0:A}")")/../bin/turtle-ssh-tunnel" + [[ -x "$_bin" ]] && echo "$_bin" && return + echo "turtle-ssh-tunnel" +} + +# csh — SSH into cloudshell-fog bastion +csh() { python3 "$(_turtle_ssh_tunnel_bin)" connect "$@" } + +# ktunnel — manage k3s API tunnel through cloudshell bastion +ktunnel() { python3 "$(_turtle_ssh_tunnel_bin)" tunnel "${1:-status}" } + +# kproxy — SOCKS5 proxy through cloudshell for sovereign browsing +kproxy() { python3 "$(_turtle_ssh_tunnel_bin)" proxy "${1:-status}" } + +# k3s — kubectl pointed at the k3s twin (via tunnel) +# Usage: k3s get pods -n kube-system +k3s() { + local _cfg="${CLOUDSHELL_K3S_KUBECONFIG:-$HOME/.kube/config-k3s-twin}" + local _tunnel_port="${CLOUDSHELL_K3S_TUNNEL_PORT:-16443}" + # Warn if tunnel is not up + local _tpid_file="${HOME}/.local/state/sourceos/k3s-tunnel.pid" + if [[ -f "$_tpid_file" ]]; then + local _tpid; _tpid="$(cat "$_tpid_file" 2>/dev/null)" + if ! kill -0 "$_tpid" 2>/dev/null; then + printf '\e[33m⚠ k3s tunnel not running — start with: ktunnel start\e[0m\n' >&2 + fi + else + printf '\e[33m⚠ k3s tunnel not running — start with: ktunnel start\e[0m\n' >&2 + fi + KUBECONFIG="$_cfg" kubectl "$@" +} + +# csh-exec — run a command on cloudshell non-interactively +csh-exec() { python3 "$(_turtle_ssh_tunnel_bin)" exec "$@" } + +# csh-copy — scp via cloudshell +csh-copy() { python3 "$(_turtle_ssh_tunnel_bin)" copy "$@" } + +# csh-status — show all cloudshell/tunnel status at a glance +csh-status() { python3 "$(_turtle_ssh_tunnel_bin)" status } diff --git a/assets/sourceos/turtleterm.lua b/assets/sourceos/turtleterm.lua index 24e1f4f8991..1863234a699 100644 --- a/assets/sourceos/turtleterm.lua +++ b/assets/sourceos/turtleterm.lua @@ -1450,6 +1450,10 @@ local PALETTE_COMMANDS = { { label = '⏱ Resource usage (last cmd) —', id = 'rss_info' }, { label = '📋 Runbook list (rb list) —', id = 'runbook_list' }, { label = '🔍 Runbook search (rb search) —', id = 'runbook_search' }, + -- cloudshell-fog + k3s twin + { label = '☁ CloudShell SSH (csh) CMD+SHIFT+K', id = 'cloudshell_ssh' }, + { label = '☁ k3s tunnel start (ktunnel) —', id = 'k3s_tunnel' }, + { label = '☁ CloudShell status (csh-status) —', id = 'cloudshell_status' }, } local function turtle_command_palette() @@ -1551,6 +1555,9 @@ local function turtle_command_palette() noetica_search = act.SendString('noes '), runbook_list = act.SendString('rb list\n'), runbook_search = act.SendString('rb search '), + cloudshell_ssh = act.SendString('csh\n'), + k3s_tunnel = act.SendString('ktunnel start\n'), + cloudshell_status = act.SendString('csh-status\n'), } local a = dispatch[id] if a then w:perform_action(a, p) end @@ -2157,6 +2164,7 @@ config.keys = { { key = 'l', mods = 'CMD|SHIFT', action = turtle_recall() }, -- Memory mesh recall { key = 'u', mods = 'CMD|SHIFT', action = turtle_mesh_push() }, -- Sync mesh to GCS { key = 'b', mods = 'CMD|SHIFT', action = act.SendString('bb ') }, -- Open in BearBrowser + { key = 'k', mods = 'CMD|SHIFT', action = act.SendString('csh\n') }, -- CloudShell SSH -- Image gallery (lsi equivalent from WezTerm) { key = 'g', mods = 'CMD|SHIFT', action = wezterm.action_callback(function(w, p) local cwd = ''