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