diff --git a/home/modules/opencode.nix b/home/modules/opencode.nix new file mode 100644 index 0000000..e1a31d4 --- /dev/null +++ b/home/modules/opencode.nix @@ -0,0 +1,281 @@ +# Declarative OpenCode + oh-my-openagent (oh-my-opencode) plugin setup. +# +# 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): +# ~/.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 +# +# Override any field in the importing module if needed. +{ + config, + lib, + pkgs, + ... +}: +let + # Body of ~/.config/opencode/oh-my-openagent.json. + # Loaded by the oh-my-openagent opencode plugin on startup. + ohMyOpenagentConfig = { + "$schema" = "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json"; + + agents = { + sisyphus = { + model = "opencode/claude-opus-5"; + variant = "max"; + fallback_models = [ + { model = "opencode/kimi-k3"; } + { + model = "opencode/gpt-5.6-sol"; + variant = "medium"; + } + { model = "opencode/glm-5"; } + { model = "opencode/big-pickle"; } + ]; + }; + hephaestus = { + model = "opencode/gpt-5.6-sol"; + variant = "medium"; + }; + oracle = { + model = "opencode/gpt-5.6-sol"; + variant = "xhigh"; + fallback_models = [ + { + model = "opencode/gemini-3.1-pro"; + variant = "high"; + } + { + model = "opencode/claude-opus-5"; + variant = "max"; + } + ]; + }; + librarian = { + model = "minimax-coding-plan/MiniMax-M3"; + }; + explore = { + model = "opencode/gpt-5-nano"; + fallback_models = [ + { model = "minimax-coding-plan/MiniMax-M3"; } + ]; + }; + "multimodal-looker" = { + model = "opencode/gpt-5.6-sol"; + variant = "low"; + fallback_models = [ + { model = "opencode/gpt-5-nano"; } + ]; + }; + prometheus = { + model = "opencode/claude-fable-5"; + variant = "high"; + fallback_models = [ + { + model = "opencode/kimi-k3"; + variant = "high"; + } + ]; + }; + metis = { + model = "opencode/claude-opus-5"; + variant = "high"; + fallback_models = [ + { + model = "opencode/kimi-k3"; + variant = "low"; + } + ]; + }; + momus = { + model = "opencode/gpt-5.6-sol"; + variant = "xhigh"; + fallback_models = [ + { + model = "opencode/claude-opus-5"; + variant = "max"; + } + { + model = "opencode/gemini-3.1-pro"; + variant = "high"; + } + ]; + }; + atlas = { + model = "opencode/claude-sonnet-4-6"; + fallback_models = [ + { + model = "opencode/gpt-5.6-sol"; + variant = "medium"; + } + { model = "minimax-coding-plan/MiniMax-M3"; } + ]; + }; + "sisyphus-junior" = { + model = "opencode/claude-sonnet-4-6"; + fallback_models = [ + { + model = "opencode/gpt-5.6-sol"; + variant = "medium"; + } + { model = "minimax-coding-plan/MiniMax-M3"; } + { model = "opencode/big-pickle"; } + ]; + }; + }; + + categories = { + "visual-engineering" = { + model = "opencode/gemini-3.1-pro"; + variant = "high"; + fallback_models = [ + { model = "opencode/glm-5"; } + { + model = "opencode/claude-opus-5"; + variant = "max"; + } + ]; + }; + ultrabrain = { + model = "opencode/gpt-5.6-sol"; + variant = "xhigh"; + fallback_models = [ + { + model = "opencode/gemini-3.1-pro"; + variant = "high"; + } + { + model = "opencode/claude-opus-5"; + variant = "max"; + } + ]; + }; + deep = { + model = "opencode/gpt-5.6-sol"; + variant = "medium"; + fallback_models = [ + { + model = "opencode/claude-opus-5"; + variant = "max"; + } + { + model = "opencode/gemini-3.1-pro"; + variant = "high"; + } + ]; + }; + artistry = { + model = "opencode/gemini-3.1-pro"; + variant = "high"; + fallback_models = [ + { + model = "opencode/claude-opus-5"; + variant = "max"; + } + { + model = "opencode/gpt-5.6-sol"; + variant = "high"; + } + ]; + }; + quick = { + model = "opencode/gpt-5.4-mini"; + fallback_models = [ + { model = "opencode/gemini-3-flash"; } + { model = "minimax-coding-plan/MiniMax-M3"; } + { model = "opencode/gpt-5-nano"; } + ]; + }; + "unspecified-low" = { + model = "opencode/claude-sonnet-4-6"; + fallback_models = [ + { + model = "opencode/gpt-5.6-sol"; + variant = "medium"; + } + { model = "opencode/gemini-3-flash"; } + { model = "minimax-coding-plan/MiniMax-M3"; } + ]; + }; + "unspecified-high" = { + model = "opencode/claude-sonnet-4-6"; + fallback_models = [ + { + model = "opencode/gpt-5.6-sol"; + variant = "medium"; + } + { model = "opencode/gemini-3-flash"; } + { model = "minimax-coding-plan/MiniMax-M3"; } + ]; + }; + writing = { + model = "opencode/gemini-3-flash"; + fallback_models = [ + { model = "opencode/claude-sonnet-4-6"; } + { model = "minimax-coding-plan/MiniMax-M3"; } + ]; + }; + }; + }; +in +{ + programs.opencode = { + enable = true; + + # npx for MCP servers (webpage-mcp) and the plugin loader itself. + # nodejs_22 includes npm; plain nodejs does NOT. + extraPackages = [ pkgs.nodejs_22 ]; + + # ~/.config/opencode/opencode.json + settings = { + plugin = [ "oh-my-openagent@latest" ]; + mcp = { + webpage = { + type = "local"; + command = [ + "npx" + "-y" + "-p" + "webpage-mcp@latest" + "webpage-mcp-stdio" + ]; + }; + }; + }; + + # ~/.config/opencode/tui.json + # Mirrors workstation: oh-my-openagent also registered for the TUI. + tui = { + plugin = [ "oh-my-openagent@latest" ]; + }; + }; + + # ~/.config/opencode/oh-my-openagent.json — read by the plugin on startup. + xdg.configFile."opencode/oh-my-openagent.json".text = builtins.toJSON ohMyOpenagentConfig; + + # Expose `opencode web` as a systemd user service. nginx on sapphira + # proxies https://opencode.zeroq.su -> 127.0.0.1:4096. + # + # --hostname 0.0.0.0 binds the listener to every interface (matches the + # "0.0.0.0" intent; nginx then reverse-proxies 127.0.0.1:4096 internally). + # --cors https://opencode.zeroq.su lets the browser session reach the + # server from that origin without CORS rejection. + # + # SECURITY: with no password, anyone reaching the upstream socket gets full + # opencode. Bind 0.0.0.0 + listener == bridge == shell. The password is + # supplied via sops-managed EnvironmentFile, declared in modules/users.nix + # and decrypted to a path hardcoded here (home-manager modules cannot read + # `config.sops.*` — sops-nix options are NixOS-only). + programs.opencode.web = { + enable = true; + environmentFile = "${config.home.homeDirectory}/.config/opencode/server.env"; + extraArgs = [ + "--hostname" + "0.0.0.0" + "--cors" + "https://opencode.zeroq.su" + ]; + }; +} \ No newline at end of file diff --git a/home/server.nix b/home/server.nix index 3b9fe82..55a85ff 100644 --- a/home/server.nix +++ b/home/server.nix @@ -8,6 +8,7 @@ { imports = [ ./minimal.nix + ./modules/opencode.nix ]; home.file = xlib.helpers.mkSymlinks config { "${config.home.homeDirectory}/External/Music" = "Music"; diff --git a/modules/server/nginx.nix b/modules/server/nginx.nix index 7443d8c..59c1377 100644 --- a/modules/server/nginx.nix +++ b/modules/server/nginx.nix @@ -194,6 +194,17 @@ in proxyWebsockets = true; }; }; + # sapphira itself: opencode web runs as a systemd user service + # (programs.opencode.web.enable in home/modules/opencode.nix) on + # 127.0.0.1:4096 with --hostname 0.0.0.0. + "opencode.zeroq.su" = { + forceSSL = true; + enableACME = true; + locations."/" = { + proxyPass = "http://127.0.0.1:4096"; + proxyWebsockets = true; + }; + }; "nextcloud.zeroq.su" = { forceSSL = true; enableACME = true; diff --git a/modules/users.nix b/modules/users.nix index c6e6a3a..c60fab9 100644 --- a/modules/users.nix +++ b/modules/users.nix @@ -8,7 +8,9 @@ let user = "${xlib.device.username}"; userGroup = config.users.users."${user}".group; - # sops secret factory: name == key by default, owner/group default to root + # sops secret factory: name == key by default, owner/group default to root. + # `format` and `sopsFile` default to yaml + defaultSopsFile, matching every + # pre-existing caller. mkSecret = { path, @@ -16,14 +18,16 @@ let key ? null, owner ? null, group ? null, + format ? "yaml", + sopsFile ? null, }: { - format = "yaml"; - inherit path mode; + inherit format path mode; } // lib.optionalAttrs (key != null) { inherit key; } // lib.optionalAttrs (owner != null) { inherit owner; } - // lib.optionalAttrs (group != null) { inherit group; }; + // lib.optionalAttrs (group != null) { inherit group; } + // lib.optionalAttrs (sopsFile != null) { inherit sopsFile; }; # default owner = device user mkUserSecret = @@ -98,6 +102,35 @@ in path = "${xlib.dirs.user-home}/.config/sops/age/keys.txt"; mode = "0600"; }; + # opencode web server creds + Gemini API key. + # Decrypted as a single dotenv file (no `key`) and consumed by the + # systemd user unit opencode-web as EnvironmentFile. + # Source: secrets/opencode.env (encrypted, see sops/age below). + opencode_server = mkUserSecret { + path = "${xlib.dirs.user-home}/.config/opencode/server.env"; + mode = "0600"; + format = "dotenv"; + sopsFile = ../secrets/opencode.env; + }; + # opencode provider credentials (XDG_DATA_HOME/opencode/...). + # Both files are read by opencode at startup to populate the providers + # list. Mirror of ~/.local/share/opencode/ on the workstation. + # key = "" → decrypt the WHOLE file as-is (the JSON has no top-level + # field named after the secret; it IS the secret). + opencode_auth = mkUserSecret { + path = "${xlib.dirs.user-home}/.local/share/opencode/auth.json"; + mode = "0600"; + format = "json"; + sopsFile = ../secrets/opencode-auth.json; + key = ""; + }; + opencode_account = mkUserSecret { + path = "${xlib.dirs.user-home}/.local/share/opencode/account.json"; + mode = "0600"; + format = "json"; + sopsFile = ../secrets/opencode-account.json; + key = ""; + }; ssh_key_private = mkUserSecret { path = "${xlib.dirs.user-home}/.ssh/id_ed25519"; mode = "0600"; diff --git a/secrets/opencode-account.json b/secrets/opencode-account.json new file mode 100644 index 0000000..05deddd --- /dev/null +++ b/secrets/opencode-account.json @@ -0,0 +1,29 @@ +{ + "version": "ENC[AES256_GCM,data:Og==,iv:7CSdsBk5u2UKOn1dE6CYOiPlmYYkUmmxV1VD6nVIIoc=,tag:z1z57WidbvdlIY5CHQU/TA==,type:int]", + "accounts": { + "fa02561b1001pVHOSO4a6JW9Cv": { + "id": "ENC[AES256_GCM,data:Xm+h0mYIkOAhRI2MZt/nNxMZ+UW7twFu3a4=,iv:PQlE5hc5UPpShQju31AjNKlmaBovCH0eKGnMi1wIfwg=,tag:P1qA5OAQMyICDk52m3jCfA==,type:str]", + "serviceID": "ENC[AES256_GCM,data:5S0wMZ1ES5GjSnp1uXhuxLdjlA==,iv:6DvaCiDjBo/tKpjVSazxSNtticZjAmrcA00jjzXsKmc=,tag:S24kxmT6GJyoKSywJGCYlw==,type:str]", + "description": "ENC[AES256_GCM,data:HEISFOphDA==,iv:3IvEjXPs1RA0xeOO2k3ECdGUXb55ueR0yxJSdWDqqho=,tag:YgtEMx7nPxgY4xjr8B3isA==,type:str]", + "credential": { + "type": "ENC[AES256_GCM,data:kdOU,iv:8umxWXKvaDqYO5woOQDqTuuwtdgJ7oVJD8XU7D841k4=,tag:QBRDcCCIqbfbvY3d2CD67w==,type:str]", + "key": "ENC[AES256_GCM,data:PGzIgMDQkclf4RiDO3lQE/fQ4V0hM6nvFB/XpUqdMiAKvW/cQyCdmxdwwJQe4FSPHwyYzYDfi0VULf/tfYq+hOx5mC5F0/9ldLw2cbf68TPdcCMjIZEokLUAqe0qJlTnGxdO4PRzzL1LvOKr3Mlo4KcgoUpmeFPxwvxL2Wk=,iv:Z4gna9cUuz3YjtS2v0+WsKmJV66dryl+XtBdLbUGhrI=,tag:WPgVnEFfrJtG2pXRHGXVDQ==,type:str]" + } + } + }, + "active": { + "minimax-coding-plan": "ENC[AES256_GCM,data:Bt0Yhiz5qmJ1BIU8bDle7mVtyVC6r/lX4gY=,iv:CRmuL1bL0Jc+RFeoSbZyyIHSyBd/RojNjzzVRRODFjs=,tag:mOdKWxDWQLgYSVYiM6hugw==,type:str]" + }, + "sops": { + "age": [ + { + "enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAyUk03UnVZOUtKcDJhTUdy\nMUhBcDNROGxHR1RPcEZjeHdnYmtWck5KcVZnClIvckVxZmdBQWg3b0FsVFRVRVBP\nRUVaVTFqeDFQbVYvNk42MnlFVlJpTmsKLS0tIFBBaWJwb09ySGFGUHZsajhlb01v\nek10Q3FBWUM5Wms0UUFtV1p1b29mL1kK+hr3lbwBDmtedEeA0hnXSAxC/HOE/9iz\n5kMSbzno+UXnVG/EfLHsks2G43caBmCZlUp7spTt5DLnpwL923GnFQ==\n-----END AGE ENCRYPTED FILE-----\n", + "recipient": "age13l2gtk0nzr484zprp7e0pkrt0ne0j4asyn2pjmlaw73nte7t7d8q4sqtxm" + } + ], + "lastmodified": "2026-10-03T13:34:07Z", + "mac": "ENC[AES256_GCM,data:e+4yZeDnprtRi1jkP8A9DxNOjemIIi9VwOANAnT2f1UEJVBm6v3MPrlLPZ3Kyg7I2wnIw25Ih6boDn92fxWrIut8PmFPFmlCUu0v4mK49YQmB4dA/i/RYQMAZ6pG2JtPMUWOYsHppU67RB/hKQmJigZSz49tBOHiDrgUa+kfK9c=,iv:872D82r8gIl+mMYNy7iEhnxG/1HwrNg1TO6QXIf7Vo4=,tag:Sd8pSaYZhRxRY6jUL9DxhQ==,type:str]", + "unencrypted_suffix": "_unencrypted", + "version": "3.13.3" + } +} diff --git a/secrets/opencode-auth.json b/secrets/opencode-auth.json new file mode 100644 index 0000000..86dc6ee --- /dev/null +++ b/secrets/opencode-auth.json @@ -0,0 +1,18 @@ +{ + "minimax-coding-plan": { + "type": "ENC[AES256_GCM,data:cW4a,iv:giJwLOEm5ZoyX1fly0R0xTUsMqtAClmpuSk6d9kaHb4=,tag:YrR/kW3ZFOxot9WeP9kibw==,type:str]", + "key": "ENC[AES256_GCM,data:SvZTe8f3/Es1/DOLpcXuY7IyJpLlkuEN38wUd1uBdOdkzA9QZxkroQ93fuvyqSDFAIfDEfSQsAElME3VzaFVB0Y8t97wB2gXWm4xHXjFyzcu+uaOq/deBQ+B13/xMFfjsMlKR1H1WJ4VRZYY3sc70Wsvid+6hxOdO/a/i3k=,iv:x53HYXFeQ9NEMr5SDM8KEOsrzIMzdNAtSeY26FdKkMw=,tag:uW2xCO+cA7nKHWHpBZCVkw==,type:str]" + }, + "sops": { + "age": [ + { + "enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBlSkw3Z0VrTjBBa1VsQlRp\nbHUrZ3hXb1Q1Z0kxdVJhV1hVWnNLUUdDclhjCjg0aTNuUlhNNzdFajhPNE1DN2Fn\nZzJZNVRUYUxpNDFJK3pLTkE4UWFsbkUKLS0tIE8wUkZuN21aaXhpZlNzUnBZR0tC\nU2xwcjRJNnRPdTJ6elRhS08ybjlOS1kKBIfDuZSIOFoxCUc21GnqxipT0ouxDHsB\nXGBvj8zkJ+07tDVMYAhjWYkQK9wBNtUMvgxKedvLZNIn0Hm0Z0rPkw==\n-----END AGE ENCRYPTED FILE-----\n", + "recipient": "age13l2gtk0nzr484zprp7e0pkrt0ne0j4asyn2pjmlaw73nte7t7d8q4sqtxm" + } + ], + "lastmodified": "2026-10-03T13:34:07Z", + "mac": "ENC[AES256_GCM,data:lUAw+AL62sxoy695aV1lYgUm4JQwzC+7c36vupe/mJCeNNzVm5ZadGaGsno28EmF4fGxOVsJzn939nhNRtQZ6MwZNhShoSZBmpO51vHRm1YsfAR1sWi/u9akbcu906fjrJLyql9sWWmnxiqxn70gq4Ov6ptsx0VjtiwyWOk+cps=,iv:zckNKtUMu+4DKYp9hwbMPtm6bh46iRNGguff3AIfOa0=,tag:b7svS76JLEJmb+gkPNhQhQ==,type:str]", + "unencrypted_suffix": "_unencrypted", + "version": "3.13.3" + } +} diff --git a/secrets/opencode.env b/secrets/opencode.env new file mode 100644 index 0000000..30212ab --- /dev/null +++ b/secrets/opencode.env @@ -0,0 +1,9 @@ +OPENCODE_SERVER_USERNAME=ENC[AES256_GCM,data:6dqD4TnURrk=,iv:UUOBwiykDe9Wv/78wmmx5JnJEWScQRQh2yM+806lF7Y=,tag:vpSB652uQ+ASZQh4PS12Sw==,type:str] +OPENCODE_SERVER_PASSWORD=ENC[AES256_GCM,data:mAIHJ5+pupEFdbTurc6davXecgPrHA==,iv:n3BNhwxbJJ6WVq02ANUi0nNAploCsPQIF/JBT1TXxHg=,tag:2YKnOytFny9x8rg8X/7nxA==,type:str] +GEMINI_API_KEY=ENC[AES256_GCM,data:hKbpsv1ZrhROPMHYUUAc/oErz0JPSORLr7/B8N1VgbqVZ1FoVf7LM5o=,iv:+3hzXJkjSwpBdwB231PnuE7T+c7A6bmYCckKAqljO0Q=,tag:VVKsFxptQEg6GZAdCQ1A+g==,type:str] +sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBPZFdZZUZxOXRXUUF1NVdV\nRmUrQ2FqeGJWdTZvVkxncEpBb3FpaW5VUEM4Ck1WQ0xLVzBrOG1IOER1dXhxZUEy\nTnV3SnlFSi83b3VGdWtQZ0hYeXRyNEUKLS0tIHhqai9mYVRmR091S0w5cmNMK0Y0\nZVVUdzB6Ky9PUFExclBNcnZUQWFrOXcKQq4qlRz5+1GR3LB9/CkZSz1nyFthk7mg\n2j3wUXQ41kyRUu8pM40eCxHVkpMaa/7fjZ40nRjrnwZ7Brd5Q8z3MQ==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_0__map_recipient=age13l2gtk0nzr484zprp7e0pkrt0ne0j4asyn2pjmlaw73nte7t7d8q4sqtxm +sops_lastmodified=2026-10-03T12:47:19Z +sops_mac=ENC[AES256_GCM,data:thYwpX+SrNRL3hdvUzXPzh3Q07Dt6ZF6cplKS5Iopj7twS5lQoB4C1OuWf00GUUPDEOaxF3WK24XiRkMoA8TZHSLJ/k8HPV9tDlPnNHMh3pMj9pIfR5NTDMASmld17PjBvwPMOB/uvMn26O1G6G3ImW4Zioy3AyDP2zmed9+3Xk=,iv:uKGvvwvDTEQom636P9YcUjLMpIrRusCFI9HJqNJihkw=,tag:Qfc5KRSNn+Yy74pKKvkjOA==,type:str] +sops_unencrypted_suffix=_unencrypted +sops_version=3.13.3