diff --git a/AGENTS.md b/AGENTS.md index f3c951f..f037e2d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -192,3 +192,4 @@ Both servers use **Lix** (`nix.package = pkgs.lixPackageSets.stable.lix`). Atala - **deploy-rs `--remote-build`**: the build happens on the target machine, not in CI. This means CI just needs Nix + network, not build capacity. The dev shell (via `nix develop`) provides `deploy-rs`. - **Darwin overlays duplication**: `atalanta/default.nix` re-declares `nixpkgs.overlays` inline instead of consuming `unstable-overlays`. This is because nix-darwin's `nixpkgs.overlays` option is separate from the module system's overlay injection. If you add a new overlay to `unstable-overlays` in `flake.nix`, also add it to `atalanta/default.nix` if atalanta needs it. - **`services.nix` nixdoc format**: functions in `lib/services.nix` use nixdoc-compatible `/**` docstring syntax. Keep that format when adding functions there — it feeds the auto-generated docs. +- **zmx hosts and one-shot SSH**: hosts with `atelier.ssh.hosts..zmx = true` (and the `t.*`/`p.*`/`e.*` patterns) carry a `RemoteCommand` that auto-attaches a zmx session, so `ssh `, `scp` and `rsync` would normally fail with `Cannot execute command-line and remote command`. A `Match` block cancels it whenever `ZMX_OFF`, `AI_AGENT`, `CLAUDECODE` or `CRUSH` is set, so agent shells already get plain SSH. Prefix with `ZMX_OFF=1` if you ever see that error. For long remote work use `ssh zmx run ` and read it back with `ssh zmx history `. diff --git a/docs/src/modules/ssh.md b/docs/src/modules/ssh.md index 25ff1c0..698f7d8 100644 --- a/docs/src/modules/ssh.md +++ b/docs/src/modules/ssh.md @@ -17,9 +17,37 @@ All options under `atelier.ssh`: |--------|------|---------|-------------| | `zmx.enable` | bool | `false` | Install zmx and autossh | | `zmx.hosts` | list of strings | `[]` | Host patterns to auto-attach via zmx | +| `zmx.bypassEnv` | list of strings | `[ "ZMX_OFF" "AI_AGENT" "CLAUDECODE" "CRUSH" ]` | Env vars that turn zmx auto-attach off | When zmx is enabled for a host, the SSH config injects `RemoteCommand`, `RequestTTY force`, and `ControlMaster`/`ControlPersist` settings. Shell aliases are also added: `zmls`, `zmk`, `zma`, `ash`. +#### Bypass + +`RemoteCommand` makes one-shot commands a hard error: `ssh terebithia ls`, `scp`, +`rsync` and `git` over a zmx host all die with `Cannot execute command-line and +remote command`. To fix that, a `Match` block is emitted ahead of every host +block that cancels `RemoteCommand` when any variable in `zmx.bypassEnv` is set: + +``` +Match originalhost t.*,prattle,terebithia exec "test -n \"$ZMX_OFF$AI_AGENT$CLAUDECODE$CRUSH\"" + RemoteCommand none + RequestTTY auto +``` + +So coding agents get plain SSH for free, and anyone else can opt out per command: + +```bash +ZMX_OFF=1 ssh terebithia uptime +ZMX_OFF=1 rsync -a ./build/ terebithia:/srv/app/ +``` + +Persistence is still available on purpose, without a TTY: + +```bash +ssh terebithia zmx run build nix build .#foo # runs in a session, survives disconnect +ssh terebithia zmx history build # read the scrollback later +``` + ### Hosts Per-host config under `atelier.ssh.hosts.`: diff --git a/modules/home/apps/ssh.nix b/modules/home/apps/ssh.nix index 90e9904..5d452fa 100644 --- a/modules/home/apps/ssh.nix +++ b/modules/home/apps/ssh.nix @@ -19,6 +19,21 @@ in default = [ ]; description = "List of host patterns to enable zmx auto-attach (e.g., 'd.*')"; }; + bypassEnv = mkOption { + type = types.listOf types.str; + default = [ + "ZMX_OFF" + "AI_AGENT" + "CLAUDECODE" + "CRUSH" + ]; + description = '' + Environment variables that, when non-empty, disable zmx auto-attach + for zmx hosts. Without this, `ssh host cmd`, `scp` and `rsync` fail + with "Cannot execute command-line and remote command", which coding + agents hit constantly. + ''; + }; }; agent = { @@ -220,6 +235,22 @@ in else { }; + # Every host that gets a zmx RemoteCommand, patterns included + zmxHosts = cfg.zmx.hosts ++ attrNames (filterAttrs (_: h: h.zmx) cfg.hosts); + + # RemoteCommand makes `ssh host cmd`, scp and rsync hard errors. Give + # them back by cancelling the zmx block whenever a bypass variable is + # set, so agents get plain ssh and humans still land in a session. + bypassBlock = optionalAttrs (cfg.zmx.enable && zmxHosts != [ ] && cfg.zmx.bypassEnv != [ ]) { + zmx-bypass = hm.dag.entryBefore (attrNames (hostSettings // zmxSettings)) { + header = ''Match originalhost ${concatStringsSep "," zmxHosts} exec "test -n \"${ + concatMapStrings (v: "$" + v) cfg.zmx.bypassEnv + }\""''; + RemoteCommand = "none"; + RequestTTY = "auto"; + }; + }; + # Default block for global SSH options defaultBlock = { "*" = { @@ -227,7 +258,7 @@ in }; }; in - defaultBlock // hostSettings // zmxSettings; + defaultBlock // hostSettings // zmxSettings // bypassBlock; extraConfig = cfg.extraConfig; };