-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathflake.nix
More file actions
2497 lines (2399 loc) · 129 KB
/
Copy pathflake.nix
File metadata and controls
2497 lines (2399 loc) · 129 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
{
description = "agent-box: reproducible multi-user coding agent hosts (bare-metal NixOS + VM images)";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
};
outputs = { self, nixpkgs, ... }:
let
# The module itself is arch-agnostic, and the deployed fleet is aarch64
# (deploy/aws/template.yaml offers Graviton instances only), so the cheap
# eval-level checks and the assemble app are exposed for both — a
# maintainer on an ARM box can run them natively.
systems = [ "x86_64-linux" "aarch64-linux" ];
eachSystem = nixpkgs.lib.genAttrs systems;
# The qcow2 image (BIOS/GRUB partitioning) and the interactive
# runNixOSTest checks stay pinned here: a NixOS test needs a same-arch
# guest to get KVM, so cross-arch it is unbuildable without emulation.
# To offer them elsewhere, add the system to `vmSystems`.
imageSystem = "x86_64-linux";
vmSystems = [ imageSystem ];
# Across runners, preserve the existing four-test concurrency budget.
ciVmLanes = {
sessions = { jobs = 1; checks = [ "sessions" "session-limit" ]; };
webhook = { jobs = 1; checks = [ "webhook" ]; };
browser = {
jobs = 1;
checks = [ "connect" "sessions-web" "settings-page" ];
};
host = {
jobs = 1;
checks = [ "containers" "memory-protection" "ttyd-isolation" "web-surface" ];
};
};
# VM runners must not evaluate every native assertion to find a driver.
vmTestsFor = system:
let pkgs = nixpkgs.legacyPackages.${system}; in {
# Interactive VM test for the whole user-facing web surface, in one
# guest (issue #312 — this was three tests with the same node
# definition): the per-user ~/downloads file drop served behind the
# auth gate (issue #132), an agent adding a vhost by writing ~/sites/
# and reloading caddy via the narrow polkit rule with no
# nixos-rebuild (issue #40), and wrong-password basic-auth attempts
# getting the client IP banned by the fail2ban jail. Needs KVM (or
# slow TCG); CI enables /dev/kvm before building this.
web-surface = pkgs.testers.runNixOSTest
(import ./tests/web-surface.nix { agent-box = self.nixosModules.agent-box; });
# Interactive VM test: the per-user settings page (issue #36) adds a
# secret through the browser (behind basic auth), writes the
# user-owned 0600 env file, lists key names only, and the agent unit
# picks the file up as an optional EnvironmentFile — no rebuild.
settings-page = pkgs.testers.runNixOSTest
(import ./tests/settings-page.nix { agent-box = self.nixosModules.agent-box; });
connect = pkgs.testers.runNixOSTest
(import ./tests/connect.nix { agent-box = self.nixosModules.agent-box; });
# Interactive VM test (issue 62): protectMemory defaults — zram
# swap active, agent unit's OOMScoreAdjust applied, and earlyoom
# kills a runaway memory hog while the box stays responsive
# (instead of the swapless refault livelock that froze a deployed
# 2 GB box for hours).
memory-protection = pkgs.testers.runNixOSTest
(import ./tests/memory-protection.nix { agent-box = self.nixosModules.agent-box; });
# Interactive VM test (issue 600): the HOST half of rootless
# docker - the subuid range, the capped newuidmap/newgidmap, an
# unprivileged user namespace that is actually allowed, the 0700
# runtime dir, DOCKER_HOST in a session, and a per-user sudo
# grant that stops at the user. Plus the behaviour the design
# leans on hardest: with no docker installed the unit is a clean
# "condition failed" rather than a restart loop, and the granted
# `restart` alone picks a newly installed one up.
containers = pkgs.testers.runNixOSTest
(import ./tests/containers.nix { agent-box = self.nixosModules.agent-box; });
# Interactive VM test (issue #59): sessions are runtime data — the
# seeded "main" session starts, `agent-box-session add/rm` brings a
# second agent up and down as the user (no sudo, no rebuild), the
# runtime session lives inside the hardened unit's cgroup, and the
# supervisor's own bookkeeping survives two writers racing it.
sessions = pkgs.testers.runNixOSTest
(import ./tests/sessions.nix { agent-box = self.nixosModules.agent-box; });
# The browser half of the same box (issue #312 — this and `sessions`
# were one test that ran for 325s, more than the other five checks
# put together, so no amount of --max-jobs could shorten the wave):
# the tabbed workspace at /<user>/, the settings page's session
# manager, the /sessions/* CRUD routes, the live feed and the
# transcript download, all behind the web auth gate. Shares
# tests/sessions-common.nix with `sessions`, so both halves drive
# the same box.
sessions-web = pkgs.testers.runNixOSTest
(import ./tests/sessions-web.nix { agent-box = self.nixosModules.agent-box; });
# Interactive VM test (issue #662): admission during BOOT RECOVERY,
# not just when a session is added live. A two-session limit with
# three configured sessions leaves one queued after first start;
# freeing a slot admits it, `restart`/`restart --all` refuse at
# capacity (exit 75) without touching the sessions they would have
# restarted, and the same admission decisions hold across a reboot.
session-limit = pkgs.testers.runNixOSTest
(import ./tests/session-limit.nix { agent-box = self.nixosModules.agent-box; });
# Interactive VM test (issue #628): the browser terminal's
# transport belongs to its own user and the proxy in front of it.
# Two real linux users in one guest, because the user boundary is
# the only boundary this deployment has: nothing listens on TCP,
# the unix socket is 0660 <user>:caddy inside a 2750 <user>:caddy
# directory, the second user is refused by the kernel and by the
# auth gate, the owner still attaches, TYPES, reconnects and
# starts a stopped session, and cross-origin (and origin-less)
# WebSockets are refused by ttyd's --check-origin.
ttyd-isolation = pkgs.testers.runNixOSTest
(import ./tests/ttyd-isolation.nix { agent-box = self.nixosModules.agent-box; });
# Interactive VM test (issue #101): the per-user webhook receiver, ON
# BY DEFAULT. Socket-activated 0660 <user>:caddy ingress, the
# unauthenticated public path next to a still-401ing vhost, HMAC
# accept/reject (and 404 before any secret exists — why default-on is
# safe), IPC fan-out into a stand-in session peer applying its own
# filter, and the discovery surface (CLI on PATH,
# AGENT_BOX_WEBHOOK_URL, per-session LOCAL_WEBHOOK_SESSION, seeded
# claude plugin settings).
webhook = pkgs.testers.runNixOSTest
(import ./tests/webhook.nix { agent-box = self.nixosModules.agent-box; });
};
# Golden behavior snapshot (issue #154, Phase 0): every module-generated
# systemd unit, published /etc file, tmpfiles rule and script payload
# from the two golden configurations, with store hashes normalized to a
# fixed placeholder. The committed copy lives at tests/golden/; the
# golden-snapshot check below diffs the two, so the portability refactor
# (phases 1-3) provably keeps the rendered configuration byte-stable —
# an intentional change is made visible by regenerating the fixture
# (`nix run .#update-golden`) and reviewing that diff in the PR.
goldenSnapshotFor = system:
let
pkgs = nixpkgs.legacyPackages.${system};
lib = nixpkgs.lib;
# Evaluated for the CHECK's system (not pinned to imageSystem like
# nixosConfigurations.vm) so the snapshot builds natively on both CI
# (x86_64) and the deployed aarch64 fleet; hash normalization makes
# the rendered fixture identical across systems, and on
# x86_64-linux the vm entry IS nixosConfigurations.vm's eval.
configs = {
vm = [ self.nixosModules.agent-box ./hosts/vm.nix ];
# hosts/vm.nix never enables web/selfUpdate; the overlay pins the
# whole Caddy/ttyd/settings/webhook/self-update surface too.
web = [ self.nixosModules.agent-box ./hosts/vm.nix ./tests/golden-web.nix ];
};
# multi-user.target/sockets.target (issue #154 Phase 3): the module
# doesn't own these units, but it drops a `Wants=` override onto
# each one to enable a per-user %i template instance (see the
# agent-box@/agent-box-settings@/agent-box-webhook@/
# agent-web-terminal@ instances below) — a mechanism the earlier
# per-instance `wantedBy` attempt got wrong in a way no eval-level
# check caught (only a real VM boot did, unit stayed inactive).
# Capturing the override text here is what would have caught it.
unitFilter = n:
builtins.match
"(agent-box|agent-web|caddy|fail2ban|earlyoom).*|multi-user\\.target|sockets\\.target"
n != null;
# /etc content the module owns or materially shapes. The fail2ban
# dir entries (filter.d/, action.d/) are upstream package trees and
# deliberately excluded; the module's own filter and the jail
# settings land in the files below.
etcFilter = n:
builtins.match
# agent-box/units/*.env (issue #154 Phase 3): the per-user
# generated env files the "%i" template units' EnvironmentFile=
# reads — the payload capture below only scans Nix-visible
# `environment` attrs, so these plain-text files are the review
# surface for what moved out of that attrset.
"agent-box-guides/.*|agent-box/session-limit|agent-box/units/.*|caddy/caddy_config|codex/config\\.toml|fail2ban/(fail2ban|jail)\\.local|fail2ban/filter\\.d/agent-web-auth\\.conf|sudoers"
n != null;
manifestOf = modules:
let sys = nixpkgs.lib.nixosSystem { inherit system modules; }; in
{
# Unit text is an eval-time string; wantedBy/requiredBy are
# realized as .wants/.requires symlinks and would be invisible
# in it, so they ride along explicitly.
units = lib.mapAttrs
(n: u: {
inherit (u) text;
wantedBy = lib.naturalSort u.wantedBy;
requiredBy = lib.naturalSort u.requiredBy;
})
(lib.filterAttrs (n: u: unitFilter n && (u.text or null) != null)
sys.config.systemd.units);
etc = lib.mapAttrs (n: e: "${e.source}")
(lib.filterAttrs (n: e: etcFilter n) sys.config.environment.etc);
# The module's OWN rules, stated by the module. This was a
# filter over the whole system's rules for the substring
# "agent-box", which is a predicate that can miss: #370's two
# `d /home/<user>/.config` rules are named after the user, so
# the lock never captured them and `one-spec-both-backends`
# kept reporting a native-vs-NixOS divergence that had been
# fixed a week earlier. An inventory has to come from the thing
# being inventoried.
tmpfiles = sys.config.services.agent-box.internal.tmpfilesRules;
};
# The manifest string keeps its Nix string context, so building it
# realizes everything the captured texts reference — which is what
# lets the builder dereference the generated payload scripts
# (supervisor, session CLI, attach script, ...) inside the sandbox.
manifest = pkgs.writeText "agent-box-golden-manifest.json"
(builtins.toJSON (lib.mapAttrs (_: manifestOf) configs));
in
pkgs.runCommand "agent-box-golden-snapshot"
{ nativeBuildInputs = [ pkgs.python3 ]; inherit manifest; } ''
python3 ${./bin/golden-snapshot.py} "$manifest" "$out"
'';
# One spec, two backends (issue #451). The native renderer's config
# schema was a third hand-mirrored copy of the option tree, and the two
# test configs that drove the two fixtures had drifted into describing
# different boxes — so nothing could compare what the backends produced
# for the SAME box. tests/spec.nix evaluates the golden web
# configuration's own options into that JSON, `nix run
# .#update-native-config` commits it, and the one-spec-both-backends
# check below fails when the committed copy has drifted.
#
# Same eval as goldenSnapshotFor's `web` entry, deliberately: the point
# is that both fixtures come from one configuration.
nativeConfigFor = system:
let
pkgs = nixpkgs.legacyPackages.${system};
sys = nixpkgs.lib.nixosSystem {
inherit system;
modules = [ self.nixosModules.agent-box ./hosts/vm.nix ./tests/golden-web.nix ];
};
in
pkgs.writeText "agent-box-native-config.json"
(builtins.toJSON
(import ./tests/spec.nix { lib = nixpkgs.lib; } sys.config));
in
{
# The portable module. Import into any NixOS host:
# imports = [ inputs.agent-box.nixosModules.agent-box ];
nixosModules.agent-box = import ./modules/agent-box.nix;
nixosModules.default = self.nixosModules.agent-box;
# Bootable VM config used both by the qcow2 generator (below) and by
# nixos-rebuild build-vm --flake .#vm
# (build-vm injects boot/filesystem, so this stays hardware-free).
nixosConfigurations.vm = nixpkgs.lib.nixosSystem {
system = imageSystem;
modules = [ self.nixosModules.agent-box ./hosts/vm.nix ];
};
# Standalone qcow2 image (BIOS boot), built via the image API upstreamed
# into nixpkgs (NixOS 25.05+): nix build .#vm -> result/*.qcow2
# The `qemu` variant extends the fs/bootloader-free vm config with its own
# partition table + GRUB, so the base config stays usable for build-vm.
packages = eachSystem (system:
{
# Native checks are discovered, so a newly registered check cannot
# silently miss CI. VM lanes evaluate only vmTestsFor below.
ci-native = nixpkgs.legacyPackages.${system}.linkFarm
"agent-box-ci-native"
(nixpkgs.lib.mapAttrsToList (name: path:
assert nixpkgs.lib.assertMsg (!(path ? driver))
"Register VM checks in vmTestsFor so CI schedules their drivers";
{ inherit name path; })
(builtins.removeAttrs self.checks.${system}
(builtins.attrNames (vmTestsFor system))));
# Rendered golden snapshot (issue #154) — input of the
# golden-snapshot check, materialized into tests/golden by
# `nix run .#update-golden`.
golden-snapshot = goldenSnapshotFor system;
# The native renderer's config, evaluated from the golden web
# configuration's own options (issue #451) — input of the
# one-spec-both-backends check, materialized into
# tests/native/config.json by `nix run .#update-native-config`.
native-config = nativeConfigFor system;
# The runtime profile a non-NixOS host installs instead of a system
# closure (issue #154 Phase 4):
# nix profile install github:defangdevs/agent-box/<rev>#runtime
# Built from modules/src/*, the same payloads the NixOS module
# embeds — see nix/runtime.nix.
runtime = import ./nix/runtime.nix {
pkgs = nixpkgs.legacyPackages.${system};
# The pinned webhook.py, from the same file the module's
# services.agent-box.webhook.{repo,rev,sha256} defaults read
# (nix/webhook-pin.nix). Without it runtime.nix has no pin to
# wrap, so the profile shipped NONE of the webhook payloads and
# every native box rendered a receiver unit, a CLI wrapper and a
# spawn command pointing at binaries that were not there — issue
# #425. builtins.fetchurl, as the module does it: a hash is given,
# so it is pure, and no build step stands between the pin and the
# profile.
localWebhookScript =
let pin = import ./nix/webhook-pin.nix; in
builtins.fetchurl {
url = "https://raw.githubusercontent.com/${pin.repo}/${pin.rev}"
+ "/local-webhook/webhook.py";
sha256 = pin.sha256;
};
# The bundled agent CLIs are unfree, and a flake package has no
# host configuration.nix to carry an allowUnfreePredicate — so
# allow exactly those two here, the same set (and the same
# reasoning) as the module's agentPkgs import.
agentPkgs = import nixpkgs {
inherit system;
config.allowUnfreePredicate = pkg:
builtins.elem (nixpkgs.lib.getName pkg) [ "claude-code" "codex" ];
};
};
}
// nixpkgs.lib.optionalAttrs (system == imageSystem) (
let
image = self.nixosConfigurations.vm.config.system.build.images.qemu;
in
{
vm = image;
default = image;
} // nixpkgs.lib.mapAttrs' (lane: spec:
nixpkgs.lib.nameValuePair "ci-vm-${lane}"
(nixpkgs.legacyPackages.${system}.linkFarm "agent-box-ci-vm-${lane}"
(map (name: { inherit name; path = (vmTestsFor system).${name}.driver; })
spec.checks))) ciVmLanes
));
# `nix run .#assemble` — regenerate the committed modules/agent-box.nix
# from modules/agent-box.nix.in + modules/src/*. Run from the repo root;
# edits the working tree in place.
apps = eachSystem (system:
let
pkgs = nixpkgs.legacyPackages.${system};
in
{
assemble = {
type = "app";
program = "${pkgs.writeShellScript "agent-box-assemble" ''
exec ${pkgs.python3}/bin/python3 "$PWD/bin/assemble-module.py" "$@"
''}";
};
# `nix run .#update-native-config` — regenerate the committed
# tests/native/config.json from the golden web configuration's own
# options (issue #451). Run it after a module option change that
# the native schema should carry, then regenerate the native
# fixture with `python3 tests/test_agentbox.py --update`; both
# diffs belong in the same pull request.
update-native-config = {
type = "app";
program = "${pkgs.writeShellScript "agent-box-update-native-config" ''
set -euo pipefail
out=$(nix build --no-link --print-out-paths "$PWD#native-config")
# BOTH dialects, from the one spec. config.yaml is the shape
# cloud-init writes and tests/test_agentbox.py renders it
# alongside the JSON to prove the two describe the same box —
# so a hand-maintained copy is a third mirror, and it had
# already drifted (its hostLabel and sudoAllowlist were the old
# hand-written ones). Generated in canonical block style: what
# the test needs is that agentbox's YAML path parses to the same
# box, not that this file uses flow lists.
${pkgs.python3.withPackages (ps: [ ps.pyyaml ])}/bin/python3 -c 'import json,sys,yaml
data = json.load(open(sys.argv[1]))
header = "# Generated by `nix run .#update-native-config` from the golden web\n" \
"# configuration (tests/spec.nix) — do not edit. Same box as\n" \
"# config.json, spelled in YAML: the shape cloud-init writes.\n"
open(sys.argv[2], "w").write(json.dumps(data, indent=2, sort_keys=True) + "\n")
open(sys.argv[3], "w").write(header + yaml.safe_dump(data, sort_keys=True))' \
"$out" "$PWD/tests/native/config.json" "$PWD/tests/native/config.yaml"
echo "wrote tests/native/config.{json,yaml} from $out — review the diff and commit"
''}";
};
# `nix run .#update-golden` — regenerate the committed golden
# fixture (tests/golden) after an INTENTIONAL behavior change; the
# resulting diff is the reviewable statement of what changed. Run
# from the repo root; edits the working tree in place.
update-golden = {
type = "app";
program = "${pkgs.writeShellScript "agent-box-update-golden" ''
set -euo pipefail
out=$(nix build --no-link --print-out-paths "$PWD#golden-snapshot")
rm -rf "$PWD/tests/golden"
cp -rT --no-preserve=mode,ownership,timestamps "$out" "$PWD/tests/golden"
echo "wrote tests/golden from $out — review the diff and commit"
''}";
};
# `nix run .#compile-azure-bicep` — regenerate the committed
# deploy/azure/agent-box.json from deploy/azure/agent-box.bicep. Run from the
# repo root; edits the working tree in place. Uses nixpkgs' own
# `bicep` rather than the CI-pinned `az bicep` (deploy/azure/.bicep-version):
# check_azure_template.py strips the version-derived `_generator`
# block before diffing, so any Bicep CLI produces an equivalent
# build and nothing outside the flake needs installing.
compile-azure-bicep = {
type = "app";
program = "${pkgs.writeShellScript "agent-box-compile-azure-bicep" ''
set -euo pipefail
${pkgs.bicep}/bin/bicep build "$PWD/deploy/azure/agent-box.bicep" \
--outfile "$PWD/deploy/azure/agent-box.json"
echo "wrote deploy/azure/agent-box.json from deploy/azure/agent-box.bicep — review the diff and commit"
''}";
};
});
# CI validation entrypoints (`nix build .#checks.<system>.<name>`).
# NOTE: prefer these over `nix flake check` — the VM nixosConfiguration is
# intentionally bootloader/filesystem-free (the generator supplies them),
# so its `toplevel` (which flake check builds) does not evaluate.
checks = eachSystem (system:
let
pkgs = nixpkgs.legacyPackages.${system};
# A full, bootable multi-user system (qemu-vm supplies boot/fs) built
# from the published bare-metal example — proves the module evaluates
# and generates a per-user service for every configured agent.
multiUser = nixpkgs.lib.nixosSystem {
inherit system;
modules = [
self.nixosModules.agent-box
./hosts/bare-metal.nix
({ modulesPath, ... }: { imports = [ (modulesPath + "/virtualisation/qemu-vm.nix") ]; })
];
};
services = multiUser.config.systemd.services;
# issue #154 Phase 3: "agent-box@" is the systemd %i template unit,
# shipped verbatim via systemd.packages (not a systemd.services
# Nix declaration — a template-level drop-in there was found to
# silently never merge into any real instance, so all host-level
# content moved onto the per-instance declaration below). Each
# configured user gets its own "agent-box@<user>" drop-in instead
# of a flat "agent-box-<user>" unit.
wanted = [ "agent-box@alice" "agent-box@bob" "agent-box@coder" "agent-box@ci" ];
missing = builtins.filter (n: ! builtins.hasAttr n services) wanted;
# A fully-featured eval — web on, so every per-user unit the module
# can render (terminal, settings, webhook) is rendered, and
# `config.systemd.services` is the ground truth for "what did the
# module actually define". Shared by the two checks below that both
# need that ground truth rather than a hand-maintained name list.
webBaseline = nixpkgs.lib.nixosSystem {
inherit system;
modules = [
self.nixosModules.agent-box
({ modulesPath, ... }: { imports = [ (modulesPath + "/virtualisation/qemu-vm.nix") ]; })
{
services.agent-box = {
enable = true;
agent = "claude";
users.agent.web.passwordHashFile = "/var/lib/agent-box-web/password-hash";
web = { enable = true; domain = "phantom-unit.test"; user = "agent"; };
};
system.stateVersion = "25.05";
}
];
};
in
{
ci-scheduling =
let
scheduled = nixpkgs.lib.sort builtins.lessThan
(nixpkgs.lib.concatMap (lane: lane.checks)
(builtins.attrValues ciVmLanes));
in
assert nixpkgs.lib.assertMsg
(scheduled == builtins.attrNames (vmTestsFor imageSystem))
"Every VM check must appear in exactly one ciVmLanes entry";
pkgs.runCommand "agent-box-ci-scheduling" {
nativeBuildInputs = [ (pkgs.python3.withPackages (ps: [ ps.pyyaml ])) ];
} ''
python3 ${./tests/test-ci-scheduling.py} ${./scripts/ci-vm-tests.sh} \
${./.github/workflows/ci.yml} \
${pkgs.writeText "ci-vm-lanes.json" (builtins.toJSON ciVmLanes)}
touch "$out"
'';
# Use the flake's pin, not the runner's mutable nixpkgs registry.
# Shellcheck findings in deploy-test.yml are tracked separately;
# this preserves the existing actionlint-only gate.
workflow-lint = pkgs.runCommand "agent-box-workflow-lint" {
nativeBuildInputs = [ pkgs.actionlint ];
} ''
actionlint -shellcheck= ${./.github/workflows}/*.yml
touch "$out"
'';
# Eval-level assertion; cheap.
multi-user = assert missing == [ ];
pkgs.runCommand "agent-box-multi-user-ok" { } ''
printf 'generated services: %s\n' ${nixpkgs.lib.escapeShellArg (toString wanted)} > "$out"
'';
# Regression coverage for issue #687: sudoGranted (which drives
# NoNewPrivileges and whether /run/wrappers joins the agent's
# PATH) has to ask about the reboot grant in both directions —
# count it when the reboot sudo rule actually renders, and never
# count it when web.enable leaves that rule unrendered. The fix
# was recovered from a closed PR (#655) whose own test coverage
# never reached master, so this is written fresh rather than
# ported.
#
# containers.enable = false and no custom sudoAllowlist isolate
# the reboot grant as the only thing that could ever put the
# agent unit's escape hatch (NoNewPrivileges=false, /run/wrappers
# on PATH) up — web.enable itself also implies the caddy-reload
# grant, which is why the web-ENABLED case below can't regress on
# its own, but the web-DISABLED case can and, before the second
# fix commit, did (rootUser resolves from a user's
# web.passwordHashFile regardless of web.enable, so a web-off box
# with rebootButton left at its true default wrongly got the
# escape hatch for a rule that is never rendered).
sudo-granted-reboot-gate =
let
mkEval = webEnable: nixpkgs.lib.nixosSystem {
inherit system;
modules = [
self.nixosModules.agent-box
({ modulesPath, ... }: { imports = [ (modulesPath + "/virtualisation/qemu-vm.nix") ]; })
{
services.agent-box = {
enable = true;
agent = "claude";
containers.enable = false;
users.agent.web.passwordHashFile = "/var/lib/agent-box-web/password-hash";
web = {
enable = webEnable;
domain = "sudo-granted.test";
user = "agent";
rebootButton = true;
};
};
system.stateVersion = "25.05";
}
];
};
webOff = mkEval false;
webOn = mkEval true;
unitOf = m: m.config.systemd.services."agent-box@agent";
hasRebootRule = m:
builtins.any
(r: (r.users or [ ]) == [ "agent" ] && builtins.any
(c: (c.command or "") == "/run/current-system/sw/bin/systemctl reboot --no-block")
(r.commands or [ ]))
m.config.security.sudo.extraRules;
escapeHatchOpen = m:
let u = unitOf m; in
u.serviceConfig.NoNewPrivileges == false
&& nixpkgs.lib.hasInfix "/run/wrappers" u.environment.PATH;
failures =
nixpkgs.lib.optional (hasRebootRule webOff)
"web.enable = false rendered a reboot sudo rule anyway"
++ nixpkgs.lib.optional (escapeHatchOpen webOff)
"web.enable = false with rebootButton = true wrongly opened the sudo escape hatch (agent-box#687)"
++ nixpkgs.lib.optional (! hasRebootRule webOn)
"web.enable = true with rebootButton = true rendered no reboot sudo rule"
++ nixpkgs.lib.optional (! escapeHatchOpen webOn)
"a rendered reboot sudo rule must open the sudo escape hatch (agent-box#687)";
in
if failures != [ ]
then throw ("sudo-granted-reboot-gate:\n" + nixpkgs.lib.concatMapStringsSep "\n" (m: " ${m}") failures)
else pkgs.runCommand "agent-box-sudo-granted-reboot-gate-ok" { } ''
printf 'sudoGranted tracks the reboot grant in both directions\n' > "$out"
'';
# Guard (issue #362): a test or host example that overrides a unit
# by an old/misspelled name — e.g. the flat "agent-box-settings-agent"
# a rename left behind, instead of the real per-instance
# "agent-box-settings@agent" — does not fail at eval. NixOS happily
# defines a brand-new, never-started unit under that name, and the
# override silently never reaches the real daemon. A VM test then
# fails three steps removed from the cause (a timeout, not a missing
# override), and a host example just ships a no-op.
#
# `phantomBaseline` below is a fully-featured eval (web on, so the
# settings/webhook/terminal per-user units all render) — the ground
# truth for "which units does the module actually define". Scanning
# a fixed list of names (the grep stopgap from the issue) has the
# same blind spot as the sweep that missed this in the first place:
# it can only reject names it already knows about. Eval knows what
# actually got rendered, so it catches a name nobody anticipated.
# Guard (this file's `webBaseline`, agent-box#386 + this change): a
# socket-activated unit MUST set stopIfChanged = false. NixOS's
# default two-step restart stops such a unit in the OLD
# configuration, before the new one's daemon-reload, while its
# .socket keeps listening — so a client (or a webhook delivery)
# arriving in that window re-activates the daemon from systemd's
# CACHED old unit definition, and switch-to-configuration's later
# start step then finds it already running and leaves the stale
# survivor in place until the next reboot. It cost the settings
# page a wrong rev (#386) and the webhook receiver a spawn command
# pointing into a garbage-collected generation.
#
# The socket-activation relation is declared in the VERBATIM unit
# text shipped via systemd.packages, not in the Nix attrs, so it is
# read from that text — which is also what makes this catch a unit
# nobody thought to list here. `%i` templates only: every
# socket-activated unit the module has is per-user.
socket-activated-restart =
let
unitDir = ./modules/src/units;
templates = builtins.filter (n: nixpkgs.lib.hasSuffix "@.service" n)
(builtins.attrNames (builtins.readDir unitDir));
# DIRECTIVES only, never comments (issue #628): a comment is
# allowed to name another unit as a precedent, and
# agent-web-terminal@.service now names
# agent-box-settings@.socket as exactly that - which is not a
# socket-activation relation, and flagged this template as an
# offender it can never satisfy. A `#` line, leading whitespace
# allowed, is not configuration.
directives = n:
nixpkgs.lib.concatStringsSep "\n" (builtins.filter
(l: builtins.match "[[:space:]]*#.*" l == null)
(nixpkgs.lib.splitString "\n"
(builtins.readFile (unitDir + "/${n}"))));
# Either direction counts: Requires= is what makes the socket
# start the daemon, After= alone still means the socket outlives
# the service's stop and can re-activate it.
activated = builtins.filter
(n: nixpkgs.lib.hasInfix ".socket" (directives n))
templates;
svc = webBaseline.config.systemd.services;
# "agent-box-settings@.service" -> the baseline's own instance.
instanceOf = n: (nixpkgs.lib.removeSuffix "@.service" n) + "@agent";
offenders = builtins.filter
(n:
let i = instanceOf n; in
builtins.hasAttr i svc && svc.${i}.stopIfChanged != false)
activated;
in
if offenders != [ ]
then
throw (
"socket-activated unit(s) without stopIfChanged = false — the " +
"two-step restart lets the .socket re-activate the daemon from " +
"systemd's cached OLD unit definition, and the stale survivor " +
"is never replaced (agent-box#386):\n" +
nixpkgs.lib.concatMapStringsSep "\n"
(n: " ${n} -> systemd.services.\"${instanceOf n}\"")
offenders
)
else
pkgs.runCommand "agent-box-socket-activated-restart-ok" { } ''
printf 'stopIfChanged = false on: %s\n' \
${nixpkgs.lib.escapeShellArg (toString activated)} > "$out"
'';
phantom-unit-overrides =
let
phantomBaseline = webBaseline;
hasAt = n: nixpkgs.lib.hasInfix "@" n;
# "agent-box-settings@agent" -> "agent-box-settings@": the
# per-instance override target is expected to differ from the
# baseline's own username, so only the template half is checked.
templateOf = n: nixpkgs.lib.head (nixpkgs.lib.splitString "@" n) + "@";
# services and sockets are DIFFERENT unit types — a name that
# exists only as a socket must still fail a
# systemd.services.NAME override targeting it (and vice versa),
# so each class gets its own flat/template sets rather than one
# merged pool.
knownByClass = {
services = builtins.attrNames phantomBaseline.config.systemd.services;
sockets = builtins.attrNames phantomBaseline.config.systemd.sockets;
};
setsFor = class:
let known = knownByClass.${class}; in
{
flatNames = builtins.filter (n: ! hasAt n) known;
templatePrefixes =
nixpkgs.lib.unique (map templateOf (builtins.filter hasAt known));
};
isKnownUnit = class: name:
let sets = setsFor class; in
if hasAt name
then builtins.elem (templateOf name) sets.templatePrefixes
else builtins.elem name sets.flatNames;
# `systemd.services.NAME` / `systemd.sockets.NAME` at attribute-path
# position, NAME bare ("caddy") or quoted with an "@" instance
# ("agent-box-settings@agent"). No "." in the class: it must stop
# before a chained ".environment" / ".serviceConfig" continuation
# rather than swallowing it into the captured name.
pattern = ''systemd\.(services|sockets)\."?([A-Za-z0-9_@-]+)"?'';
namesInFile = file:
let
matches = builtins.filter builtins.isList
(builtins.split pattern (builtins.readFile file));
in
map (g: { class = builtins.elemAt g 0; name = builtins.elemAt g 1; inherit file; })
matches;
# Every test node config and published host example — the two
# places an override can name a unit that no longer exists.
nixFilesIn = dir:
map (n: dir + "/${n}")
(builtins.filter (n: nixpkgs.lib.hasSuffix ".nix" n)
(builtins.attrNames (builtins.readDir dir)));
scanFiles = nixFilesIn ./tests ++ nixFilesIn ./hosts;
overrides = builtins.concatMap namesInFile scanFiles;
phantoms = builtins.filter (o: ! isKnownUnit o.class o.name) overrides;
in
if phantoms != [ ]
then
throw (
"phantom systemd unit override(s) — name not found among the " +
"module's own rendered units of that class (renamed unit? " +
"wrong class? missing \"@\"?):\n" +
nixpkgs.lib.concatMapStringsSep "\n"
(o: " ${toString o.file}: systemd.${o.class}.${o.name}")
phantoms
)
else
pkgs.runCommand "agent-box-phantom-unit-overrides-ok" { } ''
printf 'no phantom systemd unit overrides in tests/ or hosts/\n' > "$out"
'';
# Guard (issue #132): the module's REAL generated Caddyfile (the VM
# test below swaps in a `tls internal` stand-in) must carry the
# authenticated downloads route for a web user — the handle, the
# strip_prefix, and file_server rooted at the caddy-readable backing
# dir. Cheap: realises only the tiny rendered config + a grep.
#
# Since issue #631 it also guards the ORIGIN ISOLATION of that
# route: ~/downloads holds whatever an agent put there, it is
# served from the same origin as the settings page and the
# terminals, and served inline one hostile .html or .svg is
# same-origin privileged JavaScript. So the generated Caddyfile
# must hand those files over as attachments under a `sandbox` CSP,
# must NOT let an attacker-supplied index.html stand in for the
# listing (`index off`), and must carry the vhost-wide
# `frame-ancestors 'self'` that keeps a management page framable
# only by this box itself.
download-route =
let
sys = nixpkgs.lib.nixosSystem {
inherit system;
modules = [
self.nixosModules.agent-box
({ modulesPath, ... }: { imports = [ (modulesPath + "/virtualisation/qemu-vm.nix") ]; })
{
services.agent-box = {
enable = true;
agent = "claude";
users.agent.web.passwordHashFile = "/var/lib/agent-box-web/password-hash";
web = {
enable = true;
domain = "downloads.test";
user = "agent";
};
};
system.stateVersion = "25.05";
}
];
};
in
pkgs.runCommand "agent-box-download-route-ok"
{ caddyfile = sys.config.services.caddy.configFile; } ''
grep -qF 'handle /agent/downloads/*' "$caddyfile"
# The drop reaches the per-user daemon, and caddy does NOT
# open the tree itself (issue #630): a site root is not a
# filesystem sandbox, and this caddy can read every user's
# drop, so a `file_server` here follows an agent's symlink
# into a sibling's files under one shared identity. The
# daemon runs as the one user whose drop it is. Asserted as
# an absence as well as a presence, because the regression
# is silent: the route keeps working, it just stops being
# confined.
grep -qF 'reverse_proxy unix//run/agent-box-settings/agent.sock' "$caddyfile"
# Comments stripped first: `file_server` is NAMED in the
# header fragment's prose (and in the downloads comments
# themselves), so a whole-file grep would fail on the
# explanation of the rule it is checking.
directives=$(grep -v '^[[:space:]]*#' "$caddyfile")
for pattern in 'file_server' 'root \* /var/lib/agent-box-downloads' \
'uri strip_prefix /agent/downloads'; do
if printf '%s\n' "$directives" | grep -q "$pattern"; then
echo "downloads must not be served from the filesystem by caddy:" >&2
printf '%s\n' "$directives" | grep -n "$pattern" >&2
exit 1
fi
done
# Origin isolation (issue #631): the artifacts go out as
# attachments under a sandbox CSP ...
grep -qF "Content-Disposition \"attachment\"" "$caddyfile"
grep -qF \
"Content-Security-Policy \"sandbox; frame-ancestors 'none'\"" \
"$caddyfile"
# ... deferred, or the vhost-wide header would overwrite it:
# caddy sorts same-directive routes by path specificity, so the
# matched header runs BEFORE the unmatched one and a static set
# would lose.
grep -qE '^ *defer$' "$caddyfile"
# ... and only for FILES, so the listing still renders. The
# `index off` that used to keep that exemption safe is gone
# with the file_server it configured: since #630 the daemon
# renders every listing itself, never looks for an
# index.html, and 404s a FILE reached at a path ending in
# "/" -- so nothing attacker-supplied can occupy the one
# shape this matcher exempts.
grep -qF 'not path */' "$caddyfile"
# ... and a management page is framable only by this box.
grep -qF \
"Content-Security-Policy \"frame-ancestors 'self'\"" \
"$caddyfile"
printf 'downloads route present, served by the daemon, and isolated\n' > "$out"
'';
# Guard: the module's REAL generated Caddyfile (every VM test swaps
# in a `tls internal` stand-in) must give each session a path of
# its own — /<user>/<session>/ rewritten onto ttyd's own path with
# the session in ?arg= — and must keep the bare /<user>/ landing
# page pointed at the settings daemon. It also has to PARSE: a
# typo in a matcher here takes the whole web UI down at once, and
# nothing else in CI adapts this file. Cheap: realises only the
# tiny rendered config, then adapts it with stand-in secrets.
session-route =
let
sys = nixpkgs.lib.nixosSystem {
inherit system;
modules = [
self.nixosModules.agent-box
({ modulesPath, ... }: { imports = [ (modulesPath + "/virtualisation/qemu-vm.nix") ]; })
{
services.agent-box = {
enable = true;
agent = "claude";
users.agent.web.passwordHashFile = "/var/lib/agent-box-web/password-hash";
# A SECOND user, because the point of the scheme is
# hosting several on one Caddy (issue #221): every user
# must get their own landing page and session paths, not
# just the one whose daemon serves the vhost root.
users.bob.web.passwordHashFile = "/var/lib/agent-box-web/bob-hash";
web = {
enable = true;
domain = "sessions.test";
user = "agent";
};
};
system.stateVersion = "25.05";
}
];
};
in
pkgs.runCommand "agent-box-session-route-ok"
{ caddyfile = sys.config.services.caddy.configFile;
nativeBuildInputs = [ pkgs.caddy ]; } ''
# The landing page is the daemon's, and only without an arg=:
# the session routes rewrite onto this same path WITH one, and
# ttyd has to keep receiving those.
grep -qF 'path /agent/' "$caddyfile"
grep -qF 'not query arg=*' "$caddyfile"
# One path per session, plus the canonical trailing slash.
grep -qF 'path_regexp sess_agent ^/agent/([^/]+)/(.*)$' "$caddyfile"
grep -qF 'rewrite @sess_agent /agent/{re.sess_agent.2}?arg={re.sess_agent.1}&{query}' "$caddyfile"
grep -qF 'redir @sess_bare_agent /agent/{re.bare_agent.1}/' "$caddyfile"
# The names a session may not take are excluded from both, so
# the pages that own them keep answering.
grep -qF 'not path /agent/settings* /agent/downloads/* /agent/webhook*' "$caddyfile"
# Ordering: the rewrite must be emitted before the catch-all it
# feeds, and the landing page before it too.
rw=$(grep -n 'rewrite @sess_agent' "$caddyfile" | cut -d: -f1)
home=$(grep -n 'handle @home_agent {' "$caddyfile" | cut -d: -f1)
catchall=$(grep -n 'handle /agent/\* {' "$caddyfile" | cut -d: -f1)
[ "$home" -lt "$catchall" ]
[ "$rw" -lt "$catchall" ]
# The terminal upstream is that user's UNIX socket, never a
# loopback port (issue #628): a port is reachable by every
# local user, and ttyd runs --writable with no credential of
# its own, so the auth in front of the public URL was all that
# stood between two users on one box. Both spellings are
# asserted -- the socket present AND no `127.0.0.1:` upstream
# anywhere in the file -- because a half-converted Caddyfile
# (one of the three auth branches left on the port) would pass
# a check that only looked for the socket.
grep -qF 'reverse_proxy unix//run/agent-box-ttyd/agent/ttyd.sock' "$caddyfile"
grep -qF 'reverse_proxy unix//run/agent-box-ttyd/bob/ttyd.sock' "$caddyfile"
[ "$(grep -c 'reverse_proxy unix//run/agent-box-ttyd/' "$caddyfile")" = 6 ]
# Anchored at the start of a DIRECTIVE, so the header
# fragment's self-serve vhost example -- a comment that
# reverse-proxies to 127.0.0.1:3000 -- does not count. Telling
# an agent to proxy their own app to a localhost port is still
# exactly right; it is only a TERMINAL that must not be
# reachable that way.
if grep -Eq '^[[:space:]]*reverse_proxy[[:space:]]+127\.0\.0\.1:' "$caddyfile"; then
echo "a terminal is still proxied over a loopback port" >&2
exit 1
fi
# And it all parses. The secrets are Caddy env placeholders,
# absent in a build sandbox: stand-ins keep basic_auth happy.
# One set PER USER — an unset algorithm placeholder collapses to
# nothing and Caddy then reads the login name as the algorithm,
# which is its own reason to adapt this file in CI.
WEB_PASSWORD_ALGORITHM_AGENT=bcrypt \
WEB_PASSWORD_HASH_AGENT='$2a$14$ptCNRCTOMkoUnEXBv0kPWuOJHhYtnpBWQZbLFXW/Ehg5AGKQMoS/W' \
WEB_COOKIE_SECRET_AGENT=0123456789abcdef \
WEB_PASSWORD_ALGORITHM_BOB=bcrypt \
WEB_PASSWORD_HASH_BOB='$2a$14$ptCNRCTOMkoUnEXBv0kPWuOJHhYtnpBWQZbLFXW/Ehg5AGKQMoS/W' \
WEB_COOKIE_SECRET_BOB=fedcba9876543210 \
caddy validate --config "$caddyfile" --adapter caddyfile
# The second user gets the same shape, on their own path.
grep -qF 'handle @home_bob {' "$caddyfile"
grep -qF 'path_regexp sess_bob ^/bob/([^/]+)/(.*)$' "$caddyfile"
grep -qF 'rewrite @sess_bob /bob/{re.sess_bob.2}?arg={re.sess_bob.1}&{query}' "$caddyfile"
bobrw=$(grep -n 'rewrite @sess_bob' "$caddyfile" | cut -d: -f1)
bobcatch=$(grep -n 'handle /bob/\* {' "$caddyfile" | cut -d: -f1)
[ "$bobrw" -lt "$bobcatch" ]
printf 'per-session routes present for every user, and the Caddyfile adapts\n' > "$out"
'';
# Guard (issue #541): portal handover adds the ONE unauthenticated
# route on this vhost, so its shape is worth locking down in a
# cheap eval check rather than only in a VM test. Three things
# must hold, and each has a way of silently going wrong:
#
# - the handover endpoint carries NO basic_auth (a gate there
# defeats the whole flow, exactly as with the webhook ingress);
# - it is EXCLUDED from the session rewrites, which run before any
# handle and would otherwise turn /<user>/auth/handoff into
# /<user>/handoff?arg=auth — a session named "auth" that does
# not exist, with nothing to say why;
# - a user WITHOUT a complete mapping gets no such route at all,
# because an unauthenticated endpoint on a box nobody wired to a
# portal is pure surface.
portal-route =
let
sys = nixpkgs.lib.nixosSystem {
inherit system;
modules = [
self.nixosModules.agent-box
({ modulesPath, ... }: { imports = [ (modulesPath + "/virtualisation/qemu-vm.nix") ]; })
{
services.agent-box = {
enable = true;
agent = "claude";
users.agent.web.passwordHashFile = "/var/lib/agent-box-web/password-hash";
# The MVP shape: a portal account and no project.
# portalUser IS the mapping — the route's URL already
# names the linux user, so the token authorizes the one
# addressed rather than selecting one.
users.agent.web.portalUser = "usr_2Nk9x";
# Deliberately maps NOTHING: bob names a project with
# no portal account to narrow. portalProject narrows a
# mapping, it cannot create one — so bob must get no
# route, or an unauthenticated endpoint would stand
# with nothing behind it to authorize against.
users.bob.web.passwordHashFile = "/var/lib/agent-box-web/bob-hash";
users.bob.web.portalProject = "orphan-proj";
web = {
enable = true;
domain = "portal.test";
user = "agent";
portalIssuer = "https://station.example.com";
};
};
system.stateVersion = "25.05";
}
];
};
in
pkgs.runCommand "agent-box-portal-route-ok"
{ caddyfile = sys.config.services.caddy.configFile;
nativeBuildInputs = [ pkgs.caddy ]; } ''
# 1. The handover surface exists for the fully-mapped user...
grep -qF 'handle /agent/auth/*' "$caddyfile"
# ...and reaches the daemon, which is what verifies the token.
# caddy cannot: it has no JWT module in this build, which is
# the whole reason the check lives in the daemon (issue #541).
grep -qF 'reverse_proxy unix//run/agent-box-settings/agent.sock' "$caddyfile"
# 2. And carries NO auth of its own. Read the block, not the
# file: basic_auth appears all over this Caddyfile, so a
# whole-file grep would pass while the gate sat right here.
block=$(awk '/^[[:space:]]*handle \/agent\/auth\/\*/{f=1} f{print} \
f&&/^[[:space:]]*}[[:space:]]*$/{exit}' "$caddyfile")
printf '%s\n' "$block" | grep -qF 'reverse_proxy'
if printf '%s\n' "$block" | grep -q 'basic_auth\|forward_auth'; then
echo "handover endpoint must be unauthenticated:" >&2
printf '%s\n' "$block" >&2
exit 1
fi
# 3. bob declares a project and no account, so he gets NO
# handover route: there would be nothing to authorize against.
if grep -qF 'handle /bob/auth/*' "$caddyfile"; then
echo "bob declares no portalUser and must have no handover route" >&2
exit 1
fi
# 4. The rewrites let it through. These run BEFORE any handle,
# so without the exclusions the route above is unreachable and
# every check up to here would still pass.
grep -qF 'not path /agent/settings /agent/downloads /agent/webhook /agent/token /agent/ws /agent/auth' "$caddyfile"
grep -qF 'not path /agent/settings* /agent/downloads/* /agent/webhook* /agent/auth/*' "$caddyfile"
# 5. Every authenticated route also admits a live portal