From e7c0fde0b18822e8b399e7bfdd66014e6dad6310 Mon Sep 17 00:00:00 2001 From: oqyude Date: Fri, 9 Oct 2026 14:48:04 +0300 Subject: [PATCH] tty added and opencode fixed --- home/modules/opencode.nix | 58 ++++++++++++----- modules/server/default.nix | 1 + modules/server/nginx.nix | 44 +++++++++++++ modules/server/ttyd.nix | 126 +++++++++++++++++++++++++++++++++++++ 4 files changed, 215 insertions(+), 14 deletions(-) create mode 100644 modules/server/ttyd.nix diff --git a/home/modules/opencode.nix b/home/modules/opencode.nix index fe5a4b4..58f24c2 100644 --- a/home/modules/opencode.nix +++ b/home/modules/opencode.nix @@ -3,10 +3,17 @@ # Mirrors ~/.config/opencode/ on the current workstation. # Imported by home/server.nix (sapphira). Auto-enables programs.opencode. # -# Three files this module owns on disk (via xdg.configFile): +# Files this module owns on disk: # ~/.config/opencode/opencode.json <- programs.opencode.settings # ~/.config/opencode/tui.json <- programs.opencode.tui -# ~/.config/opencode/oh-my-openagent.json <- oh-my-openagent plugin config +# ~/.omo/omo.jsonc <- oh-my-openagent plugin's PRIMARY +# runtime config (>= v5.x reads +# only this path; the legacy +# ~/.config/opencode/oh-my-openagent.json +# is read only by the migration shim). +# ~/.config/opencode/oh-my-openagent.json <- legacy mirror, kept so omo doctor +# and any downgrade that re-reads +# the old path see the same content. # # Override any field in the importing module if needed. { @@ -17,10 +24,21 @@ ... }: let - # Body of ~/.config/opencode/oh-my-openagent.json. + # Body of ~/.omo/omo.jsonc (and the legacy mirror). # Loaded by the oh-my-openagent opencode plugin on startup. + # + # Plugins >= 5.x resolve their config from ~/.omo/omo.jsonc, NOT from + # ~/.config/opencode/oh-my-openagent.json. Pinning _migrations here prevents + # the 2026-07-opencode-config-unification migration from running on every + # startup and re-backing-up the file (which would otherwise leave us with + # an empty omo.jsonc that drops every agent override — see the journal entry + # below). ohMyOpenagentConfig = { - "$schema" = "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json"; + "$schema" = "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/omo.schema.json"; + _migrations = [ + "2026-07-opencode-config-unification" + "2026-08-reasoning-unification" + ]; agents = { sisyphus = { @@ -270,17 +288,29 @@ in }; }; - # ~/.config/opencode/oh-my-openagent.json — read by the plugin on startup. + # ~/.omo/omo.jsonc — primary file the oh-my-openagent plugin reads at runtime + # (>= v5.x). This path lives outside XDG_CONFIG_HOME (~/.config), so use + # home.file rather than xdg.configFile. # - # NOTE: the oh-my-openagent plugin runs a `2026-07-opencode-config-unification` - # migration on every startup that backs up this file and tries to write its - # consolidated form to ~/.omo/omo.jsonc. The backup directory name embeds the - # source's content-hashed store path; because HM does not delete the previous - # generation's store path until garbage collection, the same path is reused on - # every retry and omo logs "Migration backup path already exists" forever. - # Recovery: `rm -rf ~/.omo/migration-backup-*` and let omo retry; if the - # migration keeps failing on the same backup path, the plugin/omo version - # probably expects a new schema and this config needs updating. + # ~/.config/opencode/oh-my-openagent.json is kept as a legacy mirror so + # `omo doctor`, the migration shim, and any future downgrade that re-reads the + # old path see the same content. + # + # MIGRATION TRAP (do not just point back at the legacy path): + # The plugin runs a `2026-07-opencode-config-unification` migration on every + # startup that backs up ~/.omo/omo.jsonc and tries to rewrite it from + # ~/.config/opencode/oh-my-openagent.json. The backup directory name embeds + # the source's content-hashed store path; because HM does not delete the + # previous generation's store path until garbage collection, the same path + # is reused on every retry and omo logs "Migration backup path already + # exists" forever — meanwhile the user's agent overrides disappear and the + # plugin's built-in fallback chain (which references providers like + # kimi-for-coding that opencode's provider registry no longer ships) gets + # picked instead, surfacing as `ProviderModelNotFoundError: + # kimi-for-coding/kimi-for-coding-highspeed` on every subagent spawn. + # Pinning _migrations above makes the migration a no-op; the omo.jsonc + # below is the actual config the plugin sees. + home.file."${config.home.homeDirectory}/.omo/omo.jsonc".text = builtins.toJSON ohMyOpenagentConfig; xdg.configFile."opencode/oh-my-openagent.json".text = builtins.toJSON ohMyOpenagentConfig; # Same extras on the user's PATH too, so `omo doctor` and standalone invocations diff --git a/modules/server/default.nix b/modules/server/default.nix index 4d712dc..75244ea 100644 --- a/modules/server/default.nix +++ b/modules/server/default.nix @@ -31,6 +31,7 @@ ./samba.nix ./syncthing.nix ./systemd.nix + ./ttyd.nix ./uptime-kuma.nix # ../containers/remnawave.nix # ./coturn.nix diff --git a/modules/server/nginx.nix b/modules/server/nginx.nix index 0f14ffe..a34eb06 100644 --- a/modules/server/nginx.nix +++ b/modules/server/nginx.nix @@ -182,6 +182,50 @@ in }; }; }; + # tty.zeroq.su — web-shell (ttyd) behind Authelia forward-auth. + # ttyd listens on 127.0.0.1:7681 only (modules/server/ttyd.nix), so + # nginx is the only ingress. Same auth_request / 401→302 wiring as + # vtimeline.zeroq.su above — the wildcard rule `*.zeroq.su` in + # modules/server/authelia.nix already covers this subdomain under + # `one_factor`, so no policy change is needed. + "tty.zeroq.su" = { + forceSSL = true; + enableACME = true; + locations = { + "/" = { + proxyPass = "http://127.0.0.1:7681"; + proxyWebsockets = true; + extraConfig = '' + auth_request /authelia; + auth_request_set $authelia_user $upstream_http_remote_user; + # Same 401→302 trick as vtimeline.zeroq.su: a bare 302 from + # Authelia surfaces to the client as a 500 ("auth request + # unexpected status"), so we rewrite the response status to + # a 302 pointing at the authelia login UI with an absolute + # `$scheme://$host$request_uri` so the post-login `rd` + # lands the user back on tty.zeroq.su, not on + # authelia.zeroq.su/. + error_page 401 =302 https://authelia.zeroq.su/?rd=$scheme://$host$request_uri; + ''; + }; + "= /authelia" = { + extraConfig = '' + internal; + proxy_pass http://127.0.0.1:9091/api/authz/forward-auth; + proxy_set_header X-Original-URL $request_uri; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Method $request_method; + proxy_set_header X-Forwarded-Uri $request_uri; + proxy_set_header X-Forwarded-For $remote_addr; + # Same Accept-forces-401 trick as vtimeline.zeroq.su — see + # the comment there for why Authelia's default 302 is + # harmful here. + proxy_set_header Accept "application/json"; + ''; + }; + }; + }; # Authelia login UI — same podman container on 127.0.0.1:9091 as the # forward-auth endpoint above, just exposed on a separate vhost so # Authelia has a stable absolute URL to redirect users to. Authelia diff --git a/modules/server/ttyd.nix b/modules/server/ttyd.nix new file mode 100644 index 0000000..2ba60e9 --- /dev/null +++ b/modules/server/ttyd.nix @@ -0,0 +1,126 @@ +{ + config, + lib, + pkgs, + ... +}: +# ttyd — web-терминал на 127.0.0.1:7681 (loopback only), за +# reverse-proxy vhost `tty.zeroq.su` (modules/server/nginx.nix). +# Nginx + authelia — единственный ingress; ttyd снаружи недоступен. +# +# Рантайм-наблюдение (Oct 2026): первый прогон с +# `entrypoint = [ pkgs.bashInteractive ]` поднимал bash на slave-pty +# (/dev/pts/N создавался, fd 0/1/2 указывали туда), но bash НЕ входил +# в interactive mode — wchan уходил в `poll_schedule_timeout`, PS1 не +# рисовался, xterm.js канвас оставался пустым. Без явного `-i` и без +# TERM в env systemd-юнита bash имеет право считать себя non-interactive +# даже при isatty(0)=true. Лечится обоими сразу: +# 1. entrypoint = login-shell пользователя + явный `-i`; +# 2. systemd.services.ttyd.environment с TERM и HOME. +# +# Дополнительно: переключаемся с bash на zsh потому, что у `oqyude` +# в /etc/passwd shell = /run/current-system/sw/bin/zsh. Подсовывать +# bash шеллу, чей home настроен на zsh, значит терять dotfiles и +# PATH-модификации, загружаемые при штатном login. +# +# `-l` (login-mode) подгружает полный login-env NixOS: /etc/zshenv, +# /etc/zprofile, ~/.zprofile и через /etc/profile — все +# /etc/profile.d/*.sh, где NixOS выставляет PATH, XDG_DATA_DIRS, +# NIX_PATH и т.п. Без `-l` PATH остаётся тем узким, что в +# systemd-Environment (coreutils/findutils/grep/sed/systemd) — без +# nix/git/docker/man/…, что и было видно в первом прогоне. +# +# Замечание про PATH: NixOS раскладывает login-env по двум НЕСВЯЗАННЫМ +# стекам — bash-стейку (/etc/profile → set-environment с PATH/XDG/ +# NIX_PATH/GTK_PATH/INFOPATH/…) и zsh-стейку (/etc/zprofile + /etc/zshrc). +# Zsh сам по себе /etc/profile не source'ит — поэтому PATH внутри +# ttyd-shell остаётся урезанным (только coreutils/findutils/grep/sed/ +# systemd от systemd.Environment). Это сознательно НЕ лечится здесь +# (см. todo D2 в docs/arch/todo.md если файл существует): вопрос +# глобальный, должен решаться или через environment.etc."zshrc.local" +# в modules/essentials/, или через ~/.zshenv у пользователя. На этом +# этапе ограничиваемся `zsh -i -l` — оно уже подгружает /etc/zprofile +# (cd /etc/nixos + fastfetch) и /etc/zshrc (oh-my-zsh, aliases, +# compinit, syntax-highlighting). Это то, что просили. +# +# Решения, отступающие от дефолтов upstream `services.ttyd`: +# +# user = "oqyude" +# Default — root. С authelia `one_factor` (один пароль) это +# эквивалент "root-shell за единым паролем". Идём от +# пользователя-администратора (modules/users.nix: oqyude, +# isNormalUser, wheel/disk/audio/networkmanager/libvirtd). Цена: +# ttyd-юзер не правит systemd-юниты напрямую — для этого sudo. +# +# interface = "127.0.0.1" +# Биндим исключительно на loopback. Роутер пробрасывает 443 (плюс +# 80/22/8443/22000) на sapphira, но сам ttyd наружу выставлять +# нельзя: это "SSH на порту 7681 без TLS". Доступ — ТОЛЬКО через +# 127.0.0.1 + nginx-proxy. +# +# entrypoint = [ userShell "-i" "-l" ] +# userShell — login-shell пользователя из users.users..shell +# (fallback = /run/current-system/sw/bin/bash, если атрибут не +# выставлен). `-i` форсит interactive mode — без него bash/zsh +# могут стартовать non-interactive при наличии pty в fd 0. +# `-l` (login-shell) подгружает /etc/zshenv, /etc/zprofile, +# /etc/zshrc — login-env zsh-стейка NixOS: cd /etc/nixos + +# fastfetch, oh-my-zsh, aliases, compinit, syntax-highlighting. +# PATH остаётся урезанным — см. блок про PATH-замечание выше. +# +# writeable = true +# Upstream-assertion требует явного значения. +# +# checkOrigin = true +# Без него WebSocket-апгрейд от любого origin проходит — XSS на +# любом *.zeroq.su vhost превращается в RCE через ttyd. +# +# maxClients = 0 (default) +# Решение владельца: без лимита; ttyd разделяет TTY между всеми +# WS-сессиями, состояние общее. +# +# Шрифт (clientOptions.fontFamily): НЕ задаём. Ttyd генерирует HTML +# с fallback-стеком `courier-new, courier, monospace`, который на +# клиенте отрисовывается через CSS-generic → monospace (Liberation Mono +# на Linux, Menlo на macOS, Consolas/Cascadia на Windows). Fira Code +# (предыдущее значение) требовал установленный на клиенте шрифт; если +# его нет, xterm.js падает на generic mono, но в Canvas API без +# CSS-стека рендеринг становится нестабильным. Явный `monospace` = +# "всегда рисуется системным шрифтом". Если потом захочется Fira +# Code / JetBrains Mono / другой — задать `fontFamily = "Fira Code, +# monospace"` (с trailing monospace, чтобы Canvas нашёл fallback). +# +# systemd-окружение: TERM и HOME пробрасываются явно. systemd по +# дефолту не выставляет TERM (User=oqyude даёт только euid), а +# bash/zsh полагаются на TERM для history-substitution и line-editing. +# HOME нужен zsh для определения `ZDOTDIR` (~/.zshrc и т.п.). +let + cfg = config.services.ttyd; + # `users.users..shell` в NixOS 26.x — это пакет (derivation), + # а в 24.x был путём. `lib.getExe` умеет оба: derivation → bin/, + # string → возвращает как есть. + userShellExe = + let shell = config.users.users.${cfg.user}.shell or "/run/current-system/sw/bin/bash"; + in if builtins.isString shell then shell else lib.getExe shell; +in +{ + services.ttyd = { + enable = true; + port = 7681; + interface = "127.0.0.1"; + user = "oqyude"; + entrypoint = [ userShellExe "-i" "-l" ]; + writeable = true; + checkOrigin = true; + maxClients = 0; + clientOptions = { + fontSize = "14"; + }; + }; + + systemd.services.ttyd.environment = { + HOME = "/home/oqyude"; + TERM = "xterm-256color"; + USER = "oqyude"; + }; +}