This repository has no description
6.8 kB
182 lines
1let
2 domain = "omni.lassul.us";
3 port = 8771;
4 stateDir = "/var/lib/omnigent";
5in
6{
7 config,
8 lib,
9 pkgs,
10 self,
11 ...
12}:
13let
14 # Server-side agent catalog entry so a browser session can pick omp without
15 # any per-user config: the generic ACP harness drives `omp acp` (omp speaks
16 # the Agent Client Protocol natively). The command is unqualified on purpose
17 # — it is resolved on the *host* that runs the session, not here.
18 ompAgentConfig = pkgs.writeText "omp-config.yaml" ''
19 spec_version: 1
20 name: omp
21 description: Oh My Pi (omp) over ACP — runs on whichever machine hosts the session.
22 prompt: You are omp (Oh My Pi), a concise coding assistant.
23
24 executor:
25 type: omnigent
26 config:
27 harness: acp
28 acp_agent:
29 name: Oh My Pi
30 command: omp acp
31 # See tools/covibe/flake-module.nix: omp fails session/new outright
32 # when omnigent's MCP relay cannot start, and the relay's
33 # `python -I -m omnigent…` child cannot import omnigent under a
34 # nixpkgs-wrapped interpreter.
35 omnigent_mcp: false
36
37 os_env:
38 type: caller_process
39 cwd: "."
40 sandbox:
41 type: none
42 '';
43
44 # `--agent` takes a bundle *directory* holding config.yaml.
45 ompAgent = pkgs.runCommand "omnigent-agent-omp" { } ''
46 mkdir -p $out
47 cp ${ompAgentConfig} $out/config.yaml
48 '';
49
50 # One identity per line. Admins bypass every per-session ACL, which is what
51 # makes sharing manageable at all: a session's creator gets LEVEL_OWNER, and
52 # only owners/managers can grant others read (watch + diffs) or edit (steer).
53 admins = pkgs.writeText "omnigent-admins" ''
54 lass@lassul.us
55 '';
56in
57{
58 # omnigent: self-hosted agent meta-harness. The server owns sessions,
59 # transcripts, per-session ACLs (read / edit / manage / owner), the per-file
60 # diff view and the browser UI; the agents themselves run on whichever
61 # machine registers as a *host*, so no coding agent runs on neoprism unless
62 # someone points a host at it. `s covibe` in a project directory is the
63 # client: it runs omp locally and registers the session here.
64 #
65 # Browser auth is the local pocket-id IdP. pocket-id has no declarative
66 # client API, so register the client by hand at https://id.lassul.us:
67 # name: omnigent
68 # callback URL: https://omni.lassul.us/auth/callback
69 # logout URL: https://omni.lassul.us/
70 # then paste its id + secret into the clan prompt below.
71
72 # Generated cookie-signing secret plus the prompted pocket-id client
73 # id/secret, materialised into one EnvironmentFile.
74 clan.core.vars.generators.omnigent = {
75 files."env" = { };
76 prompts.oidc_client = {
77 description = ''
78 Register an OIDC client in pocket-id (https://id.lassul.us):
79 name: omnigent
80 callback URL: https://omni.lassul.us/auth/callback
81 Generate a client secret, then paste both lines:
82 OMNIGENT_OIDC_CLIENT_ID=...
83 OMNIGENT_OIDC_CLIENT_SECRET=...
84 '';
85 type = "multiline";
86 persist = true;
87 };
88 runtimeInputs = [ pkgs.openssl ];
89 script = ''
90 {
91 printf 'OMNIGENT_OIDC_COOKIE_SECRET=%s\n' "$(openssl rand -hex 32)"
92 cat "$prompts"/oidc_client
93 } > "$out/env"
94 '';
95 };
96
97 systemd.services.omnigent = {
98 description = "omnigent agent meta-harness (${domain})";
99 wantedBy = [ "multi-user.target" ];
100 after = [
101 "network-online.target"
102 "nginx.service"
103 ];
104 wants = [ "network-online.target" ];
105 environment = {
106 HOME = stateDir;
107 OMNIGENT_AUTH_ENABLED = "1";
108 OMNIGENT_AUTH_PROVIDER = "oidc";
109 OMNIGENT_OIDC_ISSUER = "https://id.lassul.us";
110 OMNIGENT_OIDC_REDIRECT_URI = "https://${domain}/auth/callback";
111 OMNIGENT_OIDC_LOGOUT_REDIRECT_URI = "https://${domain}/";
112 OMNIGENT_OIDC_ALLOWED_DOMAINS = "lassul.us";
113 # pocket-id emits `email` *and* `email_verified` in the id_token when the
114 # `email` scope is requested (which omnigent does), but the value of
115 # `email_verified` is the users.email_verified column, whose default comes
116 # from the app-config key `emailsVerified` — shipped as false. omnigent
117 # otherwise hard-rejects the login with "Could not determine user email
118 # from IdP". The gate exists for IdPs where a user can self-assert an
119 # arbitrary address; here users are provisioned by hand in pocket-id
120 # (signups are disabled) and the domain allowlist above still applies, so
121 # the marker carries no information for us.
122 OMNIGENT_OIDC_SKIP_EMAIL_VERIFICATION = "1";
123 # Default is 8 hours, which expires the `s covibe` session JWT roughly
124 # once a working day and forces a browser round-trip through pocket-id.
125 # A week is the pragmatic setting for a single-operator host behind an
126 # invite-less IdP; the tokens live 0600 in ~/.omnigent/auth_tokens.json,
127 # so shorten this if a laptop ever leaves the house untrusted.
128 OMNIGENT_OIDC_SESSION_TTL_HOURS = "168";
129 OMNIGENT_ADMIN_LIST_PATH = "${admins}";
130 # The wheel checks PyPI for updates on every start otherwise.
131 OMNIGENT_NO_UPDATE_CHECK = "1";
132 # Off by default upstream; be explicit, it phones home otherwise.
133 OMNIGENT_TELEMETRY_ENABLED = "0";
134 };
135 serviceConfig = {
136 ExecStart = lib.concatStringsSep " " [
137 (lib.getExe self.packages.${pkgs.system}.omnigent)
138 "server"
139 "--host 127.0.0.1"
140 "--port ${toString port}"
141 "--no-open"
142 "--database-uri sqlite:///${stateDir}/chat.db"
143 "--artifact-location ${stateDir}/artifacts"
144 "--agent ${ompAgent}"
145 ];
146 EnvironmentFile = [ config.clan.core.vars.generators.omnigent.files."env".path ];
147 Restart = "always";
148 RestartSec = 5;
149 DynamicUser = true;
150 StateDirectory = "omnigent";
151 WorkingDirectory = stateDir;
152 ProtectSystem = "strict";
153 ProtectHome = true;
154 PrivateTmp = true;
155 NoNewPrivileges = true;
156 ProtectKernelTunables = true;
157 ProtectControlGroups = true;
158 RestrictAddressFamilies = [
159 "AF_INET"
160 "AF_INET6"
161 "AF_UNIX"
162 ];
163 };
164 };
165
166 services.nginx.virtualHosts.${domain} = {
167 enableACME = true;
168 forceSSL = true;
169 locations."/" = {
170 proxyPass = "http://127.0.0.1:${toString port}";
171 # The session event stream is SSE (omnigent sets X-Accel-Buffering: no,
172 # which nginx honours) and the terminal/host/runner tunnels are
173 # WebSockets that idle between turns, so the default 60s read timeout
174 # would sever live sessions.
175 proxyWebsockets = true;
176 extraConfig = ''
177 proxy_read_timeout 1d;
178 proxy_send_timeout 1d;
179 '';
180 };
181 };
182}