Repository navigation
Expand file tree
/
Copy pathdevcontainer-assert.sh
More file actions
executable file
·2728 lines (2556 loc) · 152 KB
/
Copy pathdevcontainer-assert.sh
File metadata and controls
executable file
·2728 lines (2556 loc) · 152 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
#!/usr/bin/env bash
set -euo pipefail
# Assert the devcontainer permission/isolation invariants.
#
# Two modes:
# unit — no container, no real secrets. Exercises the
# real init-env.sh / tailscale-connect.sh scripts
# and the static devcontainer.json invariants.
# container <cfg> <id> <prf> — runs inside an already-started container (via
# `docker exec`) to assert the live git identity,
# tailscale presence, and stripped env.
#
# Kept verbatim-portable: generated projects ship this script unchanged, so the
# git-identity assertions check RELATIONSHIPS (e.g. a "-bot" suffix), never the
# template author's literal name/email.
fail() {
echo "ASSERT FAIL: $*" >&2
exit 1
}
# Read a scalar from the document root without depending on yq's TOML parser.
# The yq build available on GitHub's Ubuntu runner interprets TOML arrays of
# tables differently from the Homebrew build, even for an unrelated root key.
toml_root_scalar() {
local key="$1" file="$2"
awk -v key="$key" '
BEGIN { in_root = 1 }
/^[[:space:]]*\[/ { in_root = 0 }
in_root {
line = $0
sub(/[[:space:]]*#.*/, "", line)
if (line ~ "^[[:space:]]*" key "[[:space:]]*=") {
sub("^[[:space:]]*" key "[[:space:]]*=[[:space:]]*", "", line)
sub(/[[:space:]]*$/, "", line)
if (line ~ /^".*"$/) {
sub(/^"/, "", line)
sub(/"$/, "", line)
}
print line
exit
}
}
' "$file"
}
# renovate: datasource=npm depName=@devcontainers/cli
DEVCONTAINER_CLI_VERSION=0.89.0
devcontainer_cli() {
if command -v devcontainer >/dev/null 2>&1; then
devcontainer "$@"
else
npx --yes "@devcontainers/cli@${DEVCONTAINER_CLI_VERSION}" "$@"
fi
}
# The shared toolchain image every consumer Dockerfile must extend. The
# package is public and identical for all generated repos, so the literal
# name stays verbatim-portable.
HARMON_IMAGE="ghcr.io/evanharmon1/harmon-devcontainer"
HARMON_IMAGE_MANIFEST="/usr/local/share/harmon-devcontainer/manifest.json"
# The ONE image for which "Copilot autonomy is enabled but there is no
# copilot binary" is a legitimate state rather than a defect: the
# pre-harness-matrix pin, which ships no copilot at all. Bounding the
# exception to this exact digest makes it EXPIRE by itself the moment the
# sync-pin PR (#1152) bumps .devcontainer/Dockerfile — after that, an
# enabled marker with no wrapper-resolving copilot is a real failure on
# every image, which is what keeps the assertion from being a permanent
# success-note.
HARMON_PRE_HARNESS_MATRIX_DIGEST="sha256:b8a305693e996ac5289bf6f18ae47dd75d9d04ed1763499ef03f52949ea5b519"
# assert_image_pin <dockerfile>
# The Dockerfile must extend exactly one approved immutable reference —
# the public package pinned by BOTH the sha-<40-hex-source-commit> tag and
# the sha256 manifest-list digest. Floating tags (latest, main, release
# aliases) or digestless tags could silently change bytes under every
# consumer, so any other FROM shape fails (harmon-init#489).
assert_image_pin() {
# Dockerfile instructions are case-insensitive and tolerate leading
# whitespace, so match by uppercased first token — a lowercase `from` or
# trailing `user root` must not slip past the pin/permission checks.
local dockerfile="$1" from_count ref source digest last_user
from_count="$(awk 'toupper($1) == "FROM" { n++ } END { print n + 0 }' "$dockerfile")"
[ "$from_count" = "1" ] ||
fail "${dockerfile} must have exactly one FROM line extending the shared image (found ${from_count})"
ref="$(awk 'toupper($1) == "FROM" { print $2; exit }' "$dockerfile")"
case "$ref" in
"${HARMON_IMAGE}:sha-"*"@sha256:"*) ;;
*) fail "${dockerfile} FROM '${ref}' is not the approved immutable '${HARMON_IMAGE}:sha-<source-commit>@sha256:<manifest-digest>' reference" ;;
esac
source="${ref#"${HARMON_IMAGE}:sha-"}"
source="${source%%@*}"
digest="${ref##*@sha256:}"
case "$source" in
????????????????????????????????????????)
case "$source" in *[!0-9a-f]*) fail "${dockerfile} image tag 'sha-${source}' is not a 40-hex source commit" ;; esac
;;
*) fail "${dockerfile} image tag 'sha-${source}' is not a 40-hex source commit" ;;
esac
case "$digest" in
????????????????????????????????????????????????????????????????)
case "$digest" in *[!0-9a-f]*) fail "${dockerfile} image digest 'sha256:${digest}' is not a 64-hex manifest digest" ;; esac
;;
*) fail "${dockerfile} image digest 'sha256:${digest}' is not a 64-hex manifest digest" ;;
esac
awk 'toupper($1) == "RUN" && $2 == "/usr/local/sbin/install-harmon-repo-config" && NF == 2 { found = 1 }
END { exit !found }' "$dockerfile" ||
fail "${dockerfile} does not invoke the image's install-harmon-repo-config overlay installer"
last_user="$(awk 'toupper($1) == "USER" { user = $2 } END { print user }' "$dockerfile")"
[ "$last_user" = "vscode" ] ||
fail "${dockerfile} does not finish as USER vscode (last USER is '${last_user}')"
printf '%s\n' "$source"
}
# pinned_image_digest <dockerfile> — the sha256 manifest digest off the
# Dockerfile's FROM line. assert_image_pin has already proved the reference's
# shape and that the RUNNING container's manifest revision matches its source
# commit, so this is a read of an already-validated pin, not a second parser.
pinned_image_digest() {
local ref
ref="$(awk 'toupper($1) == "FROM" { print $2; exit }' "$1")"
printf 'sha256:%s' "${ref##*@sha256:}"
}
# ── unit mode ─────────────────────────────────────────────────────────
assert_unit() {
# Resolve the repo root from the script's own location BEFORE we cd away,
# so init-env.sh's "only pull on a clean main" guard short-circuits when we
# run it from a throwaway, non-repo working directory.
local script_dir repo_root init_env ts_connect bash_bin codex_config
local bot_autonomy bot_autonomy_module_dir codex_module claude_module codex_bot_config
local codex_system_config
script_dir="$(cd "$(dirname "$0")" && pwd -P)"
repo_root="$(cd "$script_dir/.." && pwd -P)"
init_env="${repo_root}/.devcontainer/scripts/init-env.sh"
ts_connect="${repo_root}/.devcontainer/scripts/tailscale-connect.sh"
codex_config="${repo_root}/.devcontainer/config/codex-managed-config.toml"
codex_system_config="${repo_root}/.devcontainer/config/codex-system-config.toml"
bot_autonomy="${repo_root}/.devcontainer/scripts/bot-autonomy.sh"
bot_autonomy_module_dir="${repo_root}/.devcontainer/config/bot-autonomy"
codex_module="${bot_autonomy_module_dir}/codex-cli.sh"
claude_module="${bot_autonomy_module_dir}/claude-code.sh"
codex_bot_config="${repo_root}/.devcontainer/config/codex-managed-config.bot.toml"
bash_bin="$(command -v bash)"
[ -f "$init_env" ] || fail "init-env.sh not found at ${init_env}"
[ -f "$ts_connect" ] || fail "tailscale-connect.sh not found at ${ts_connect}"
[ -f "$codex_config" ] || fail "Codex managed config not found at ${codex_config}"
[ -f "$codex_system_config" ] ||
fail "Codex system defaults config not found at ${codex_system_config}"
[ -x "$bot_autonomy" ] || fail "bot-autonomy.sh not found or not executable at ${bot_autonomy}"
[ -x "$codex_module" ] || fail "bot-autonomy Codex module not found at ${codex_module}"
[ -x "$claude_module" ] || fail "bot-autonomy Claude Code module not found at ${claude_module}"
[ -f "$codex_bot_config" ] || fail "Codex bot managed config not found at ${codex_bot_config}"
# Registry coverage, structural parity, and per-module fixture behavior
# are scripts/test-bot-autonomy.sh's job (wired into `task verify`
# unconditionally); this file only asserts the surrounding wiring.
local bot_config dev_config agent_config shell_aliases gh_browser
bot_config="${repo_root}/.devcontainer/devcontainer.json"
dev_config="${repo_root}/.devcontainer/dev/devcontainer.json"
agent_config="${repo_root}/.devcontainer/agent/devcontainer.json"
shell_aliases="${repo_root}/.devcontainer/config/shell-aliases.sh"
gh_browser="${repo_root}/.devcontainer/config/gh-browser.sh"
[ -f "$bot_config" ] || fail "bot devcontainer.json not found at ${bot_config}"
[ -f "$dev_config" ] || fail "dev devcontainer.json not found at ${dev_config}"
[ -f "$agent_config" ] || fail "agent devcontainer.json not found at ${agent_config}"
[ -f "$shell_aliases" ] || fail "shell-aliases.sh not found at ${shell_aliases}"
[ -x "$gh_browser" ] || fail "GitHub browser bridge is missing or not executable at ${gh_browser}"
grep '^unset BROWSER$' "$shell_aliases" >/dev/null ||
fail "shell-aliases.sh no longer removes generic BROWSER from interactive shells"
grep -E '^[[:space:]]*alias([[:space:]]+-[a-zA-Z0-9]+)*[[:space:]]+pnpm-relock(=|[[:space:]]|$)' "$shell_aliases" >/dev/null ||
fail "shell-aliases.sh does not define pnpm-relock alias"
! grep -E '^[[:space:]]*alias([[:space:]]+-[a-zA-Z0-9]+)*[[:space:]]+fresh(=|[[:space:]]|$)' "$shell_aliases" >/dev/null ||
fail "shell-aliases.sh still defines fresh alias, shadowing Fresh editor"
! grep -E '^[[:space:]]*alias([[:space:]]+-[a-zA-Z0-9]+)*[[:space:]]+(mc|nano|ttt)(=|[[:space:]]|$)' "$shell_aliases" >/dev/null ||
fail "shell-aliases.sh defines alias shadowing mc, nano, or ttt"
if command -v zsh >/dev/null 2>&1; then
for shadowed in fresh mc nano ttt; do
if zsh -c ". '$shell_aliases' 2>/dev/null && alias '$shadowed'" >/dev/null 2>&1; then
fail "shell-aliases.sh defines alias shadowing ${shadowed} in zsh"
fi
done
fi
# `task` and the rest of the shared toolchain come from the pinned public
# image, never a devcontainer Feature: the go-task Feature resolved
# "latest" through the anonymous GitHub API from inside the build and
# 403'd at random on shared runner IPs (harmon-init#427). Asserted here,
# in unit mode, because this is the gated path — `task ci` runs it with no
# container. The container-mode check below proves the binary actually
# landed, but it needs a built image and so runs only from the manual
# smoke tasks.
local dockerfile cfg
dockerfile="${repo_root}/.devcontainer/Dockerfile"
[ -f "$dockerfile" ] || fail "devcontainer Dockerfile not found at ${dockerfile}"
for cfg in "$bot_config" "$dev_config"; do
if grep 'features/go-task' "$cfg" >/dev/null; then
fail "${cfg} installs task via a devcontainer Feature — the pinned shared image ships it (harmon-init#427)"
fi
done
assert_image_pin "$dockerfile" >/dev/null
# Run from a non-repo temp dir so `git rev-parse --is-inside-work-tree`
# inside init-env.sh is false and the rebuild `git pull` never fires.
local work_dir env_file codex_fixture
work_dir="$(mktemp -d)"
cd "$work_dir"
# The shared managed layer is the balanced human default. Bot post-create
# must switch only that profile to the Docker-boundary autonomy preset.
#
# Model and reasoning effort are DEFAULTS, so they are asserted against the
# /etc/codex/config.toml layer -- never the managed one. Codex treats every
# key in managed_config.toml as an unoverridable requirement, so a pin
# there silently downgrades any worker dispatched with `-c` (an explicit
# `-m` still overrode a pinned model; `-c model=` did not) (harmon-init#1186). The guard below is the durable half of that fix: it
# fails if a preference ever drifts back into the boundary layer, in either
# profile, which is how the bug arrived in the first place.
[ "$(toml_root_scalar model "$codex_system_config")" = "gpt-6.1-sol" ] ||
fail "Codex devcontainer default model is not gpt-6.1-sol"
[ "$(toml_root_scalar model_reasoning_effort "$codex_system_config")" = "medium" ] ||
fail "Codex devcontainer default reasoning is not medium"
[ "$(toml_root_scalar project_doc_max_bytes "$codex_system_config")" = "65536" ] ||
fail "Codex devcontainer default project-doc budget is not 65536"
awk '
/^[[:space:]]*\[/ { in_tui = ($0 ~ /^[[:space:]]*\["?tui"?\]/) ; next }
in_tui && /^[[:space:]]*"?status_line"?[[:space:]]*=/ { found = 1 }
END { exit(found ? 0 : 1) }
' "$codex_system_config" ||
fail "status_line is not inside a [tui] table in the defaults layer;" \
"as a root key Codex does not read it as the TUI status line"
# Presence above, separation here: deleting a moved default from the system
# file, or moving one back into a managed file, must both fail. Checking
# only the second would let the first pass silently.
local codex_boundary_file codex_forbidden_key
for codex_boundary_file in "$codex_config" "$codex_bot_config"; do
for codex_forbidden_key in model model_reasoning_effort project_doc_max_bytes; do
if grep -qE "^[[:space:]]*\"?${codex_forbidden_key}\"?[[:space:]]*=" "$codex_boundary_file"; then
fail "${codex_boundary_file##*/} pins '${codex_forbidden_key}' in the" \
"unoverridable managed layer; overridable defaults belong in" \
"codex-system-config.toml (harmon-init#1186)"
fi
done
if grep -qE '^[[:space:]]*\[tui\]' "$codex_boundary_file"; then
fail "${codex_boundary_file##*/} pins a [tui] table in the unoverridable" \
"managed layer; it belongs in codex-system-config.toml (harmon-init#1186)"
fi
done
[ "$(toml_root_scalar sandbox_mode "$codex_config")" = "workspace-write" ] ||
fail "human Codex baseline does not enable workspace-write"
[ "$(toml_root_scalar approval_policy "$codex_config")" = "on-request" ] ||
fail "human Codex baseline does not use on-request approvals"
if grep -E 'session-start-context|post-edit-format|enforce-conventional-commits' "$codex_config" >/dev/null; then
fail "system-managed Codex hooks delegate into checkout-controlled tasks"
fi
# Codex and Claude Code bot policy now come from bot-autonomy modules,
# installed and verified via bot-autonomy.sh apply/verify — never called
# directly from post-create.sh. Fixture-level apply/verify coverage for
# both modules (checksum install, drift detection) lives in
# scripts/test-bot-autonomy.sh; this asserts the surrounding wiring only.
codex_fixture="${work_dir}/codex-managed.toml"
cp "$codex_config" "$codex_fixture"
BOT_AUTONOMY_CODEX_MANAGED="$codex_fixture" bash "$codex_module" apply >/dev/null
[ "$(toml_root_scalar sandbox_mode "$codex_fixture")" = "danger-full-access" ] ||
fail "bot-autonomy Codex module did not remove the nested sandbox"
[ "$(toml_root_scalar approval_policy "$codex_fixture")" = "never" ] ||
fail "bot-autonomy Codex module did not disable approval prompts"
# The Agent-Deck conductor-setup block was extracted out of
# post-create-common.sh into its own script, so bot post-create can run
# it AFTER apply (a conductor-spawned `claude` must not run before the
# bot's policy is written) without delaying dev's identical step.
local conductor_script
conductor_script="${repo_root}/.devcontainer/scripts/post-create-conductor.sh"
[ -x "$conductor_script" ] || fail "post-create-conductor.sh not found at ${conductor_script}"
# Match the actual invocation, not the phrase alone — a nearby comment in
# post-create-common.sh legitimately still SAYS "agent-deck conductor
# setup" (explaining why link-claude-json.sh must run early) without the
# block itself having come back.
if grep 'agent-deck conductor setup "\$REPO_NAME"' "${repo_root}/.devcontainer/scripts/post-create-common.sh" >/dev/null; then
fail "the Agent-Deck conductor-setup block was not extracted out of post-create-common.sh"
fi
grep 'agent-deck conductor setup "\$REPO_NAME"' "$conductor_script" >/dev/null ||
fail "post-create-conductor.sh does not contain the extracted conductor-setup block"
# Each profile's step order matches the corrected ordering: shared setup
# (with the conductor block already extracted out, checked above), then
# ensure-antigravity-cli.sh, then the profile's own autonomy step
# (bot-autonomy.sh apply for bot; apply-antigravity-settings.sh for dev),
# then the conductor step LAST — never before the autonomy step.
assert_step_order() {
local file="$1" step_after_shared="$2"
local order
order="$(grep -Ev '^[[:space:]]*#' "$file" |
grep -E 'post-create-common\.sh|ensure-antigravity-cli\.sh|post-create-conductor\.sh|bot-autonomy\.sh apply|apply-antigravity-settings\.sh')"
case "$(printf '%s\n' "$order" | sed -n '1p')" in
*"post-create-common.sh"*) ;;
*) fail "${file} does not run post-create-common.sh first" ;;
esac
case "$(printf '%s\n' "$order" | sed -n '2p')" in
*"ensure-antigravity-cli.sh"*) ;;
*) fail "${file} does not run ensure-antigravity-cli.sh second" ;;
esac
case "$(printf '%s\n' "$order" | sed -n '3p')" in
*"$step_after_shared"*) ;;
*) fail "${file} does not run ${step_after_shared} third" ;;
esac
case "$(printf '%s\n' "$order" | tail -1)" in
*"post-create-conductor.sh"*) ;;
*) fail "${file} does not run post-create-conductor.sh last" ;;
esac
}
assert_step_order "${repo_root}/.devcontainer/post-create.sh" "bot-autonomy.sh apply"
assert_step_order "${repo_root}/.devcontainer/dev/post-create.sh" "apply-antigravity-settings.sh"
# verify SHALL also run at the end of post-create (not only post-start),
# so a divergence between what apply wrote and a harness's actual
# effective state (e.g. an already-present workspace-level OpenCode
# override) fails container creation rather than surfacing only later.
case "$(grep -Ev '^[[:space:]]*#' "${repo_root}/.devcontainer/post-create.sh" | grep -E 'bot-autonomy\.sh (apply|verify)' | tail -1)" in
*"bot-autonomy.sh verify"*) ;;
*) fail "bot post-create does not call bot-autonomy.sh verify after apply" ;;
esac
# Strip comments first: dev/post-create.sh's own explanatory comment names
# bot-autonomy.sh to say it does NOT call it, which a bare grep would
# misread as a real invocation.
if grep 'bot-autonomy.sh' \
< <(grep -Ev '^[[:space:]]*#' "${repo_root}/.devcontainer/dev/post-create.sh") >/dev/null; then
fail "human post-create calls bot-autonomy.sh (bot-only)"
fi
grep 'bot-autonomy.sh verify' "${repo_root}/.devcontainer/post-start.sh" >/dev/null ||
fail "bot post-start does not call bot-autonomy.sh verify"
# verify must run BEFORE post-start-common.sh (whose conductor-start block
# must never launch against a drifted policy) and NODE_OPTIONS must be
# unset before verify (so a Node-based harness CLI verify step is not
# itself broken by an inherited VS Code debug value).
#
# The grep filter below deliberately sees only those three Node-relevant
# steps, so this asserts their ORDER RELATIVE TO EACH OTHER, not that
# nothing whatsoever precedes them. The gh-identity tripwire (10b) does
# run first, by design — it is a bash script driving a Go binary, so the
# unset does not apply to it, and running it ahead of the set -e verify
# step is what makes it fire on every start.
local post_start_order
post_start_order="$(grep -Ev '^[[:space:]]*#' "${repo_root}/.devcontainer/post-start.sh" |
grep -E 'unset NODE_OPTIONS|bot-autonomy\.sh verify|post-start-common\.sh')"
[ "$(printf '%s\n' "$post_start_order" | sed -n '1p')" = "unset NODE_OPTIONS" ] ||
fail "bot post-start does not unset NODE_OPTIONS before the Node-dependent startup steps"
case "$(printf '%s\n' "$post_start_order" | sed -n '2p')" in
*"bot-autonomy.sh verify") ;;
*) fail "bot post-start does not call bot-autonomy.sh verify immediately after unsetting NODE_OPTIONS" ;;
esac
case "$(printf '%s\n' "$post_start_order" | sed -n '3p')" in
*"post-start-common.sh") ;;
*) fail "bot post-start does not call post-start-common.sh after bot-autonomy.sh verify" ;;
esac
# Behavioral proof of that ordering, not just textual: run the REAL,
# unmodified post-start.sh in an isolated scratch tree (its own
# .devcontainer/scripts/post-start-common.sh shadowed by a sentinel-only
# stand-in, so no real side effect — sudo chown, git config writes, the
# Agent-Deck conductor-start block — ever fires). `set -euo pipefail`
# is what actually enforces the ordering at runtime; grepping line order
# alone cannot prove a verify failure really aborts the script before
# post-start-common.sh's conductor-start block would ever run.
local ps_work ps_sentinel ps_registry ps_module_dir
ps_work="${work_dir}/post-start-behavioral"
mkdir -p "${ps_work}/.devcontainer/scripts" "${ps_work}/.devcontainer/config/bot-autonomy"
cp "${repo_root}/.devcontainer/post-start.sh" "${ps_work}/.devcontainer/post-start.sh"
cp "${repo_root}/.devcontainer/scripts/bot-autonomy.sh" "${ps_work}/.devcontainer/scripts/bot-autonomy.sh"
cp "${repo_root}/.devcontainer/config/bot-autonomy/unsupported.json" "${ps_work}/.devcontainer/config/bot-autonomy/"
echo '{}' >"${ps_work}/.devcontainer/config/bot-autonomy/aliases.json"
cat >"${ps_work}/.devcontainer/scripts/post-start-common.sh" <<'SENTINEL_SCRIPT'
#!/usr/bin/env bash
set -euo pipefail
echo "SENTINEL: post-start-common.sh reached" >>"${SENTINEL_LOG:?SENTINEL_LOG not set}"
SENTINEL_SCRIPT
chmod +x "${ps_work}/.devcontainer/post-start.sh" "${ps_work}/.devcontainer/scripts/bot-autonomy.sh" \
"${ps_work}/.devcontainer/scripts/post-start-common.sh"
# A registry resolving cleanly via the unsupported bucket alone (no real
# harness executable required) so verify's SUCCESS path is exercised
# without depending on claude/codex/agy/opencode being installed here.
ps_registry="${ps_work}/registry.json"
printf '{"harnesses": [{"slug": "qwen-code"}]}' >"$ps_registry"
ps_module_dir="${ps_work}/.devcontainer/config/bot-autonomy"
ps_sentinel="${ps_work}/sentinel-drifted.log"
rm -f "$ps_sentinel"
if (cd "$ps_work" && BOT_AUTONOMY_REGISTRY=/nonexistent-registry.json \
SENTINEL_LOG="$ps_sentinel" bash .devcontainer/post-start.sh) >/dev/null 2>&1; then
fail "post-start.sh exited 0 despite bot-autonomy.sh verify failing (drifted-policy fixture)"
fi
[ ! -f "$ps_sentinel" ] ||
fail "post-start-common.sh's conductor-start block ran despite a drifted policy — bot-autonomy.sh verify did not abort post-start.sh first"
ps_sentinel="${ps_work}/sentinel-clean.log"
rm -f "$ps_sentinel"
(cd "$ps_work" && BOT_AUTONOMY_REGISTRY="$ps_registry" BOT_AUTONOMY_CONFIG_DIR="$ps_module_dir" \
SENTINEL_LOG="$ps_sentinel" bash .devcontainer/post-start.sh) >/dev/null 2>&1 ||
fail "post-start.sh failed against a correctly-configured (verify-clean) fixture"
[ -f "$ps_sentinel" ] ||
fail "post-start-common.sh never ran even though bot-autonomy.sh verify passed"
ps_sentinel="${ps_work}/sentinel-node-options.log"
rm -f "$ps_sentinel"
(cd "$ps_work" && NODE_OPTIONS="--require /nonexistent/bootloader.js" \
BOT_AUTONOMY_REGISTRY="$ps_registry" BOT_AUTONOMY_CONFIG_DIR="$ps_module_dir" \
SENTINEL_LOG="$ps_sentinel" bash .devcontainer/post-start.sh) >/dev/null 2>&1 ||
fail "post-start.sh failed with an inherited NODE_OPTIONS value that would break a Node-based harness CLI, against an otherwise correctly-configured fixture"
[ -f "$ps_sentinel" ] ||
fail "post-start-common.sh never ran when only NODE_OPTIONS (correctly unset before verify) was hostile"
# `docker exec` has no workspace-folder-aware default cwd — the
# Dockerfile sets no WORKDIR, so a bare exec lands wherever the base
# image defaults to, not the mounted workspace folder — so this
# script's own container-mode exec of bot-autonomy.sh verify (a
# relative path) must pin its working directory explicitly. Guard
# against that regressing silently: the exec line and the one after it
# must together carry either `-w` on `docker exec` or an absolute path
# to the script.
local exec_pair
exec_pair="$(grep -A1 -F 'bot_autonomy_out="$(docker exec' "${repo_root}/scripts/devcontainer-assert.sh")"
[ -n "$exec_pair" ] ||
fail "devcontainer-assert.sh no longer execs bot-autonomy.sh verify the expected way in container mode"
case "$exec_pair" in
*' -w '*) ;;
*'bash /'*) ;;
*) fail "devcontainer-assert.sh's container-mode bot-autonomy.sh verify exec pins no working directory (-w on docker exec) and uses no absolute script path — a relative path resolves against the container's default cwd, not the workspace folder" ;;
esac
# has_var <var> <env-file> → true if the file sets VAR= on its own line.
has_var() {
grep -q "^${1}=" "$2"
}
# These allow-list cases run init-env.sh with most vars unset, so its
# missing-var warning (asserted in 6 below) fires on every one. They assert
# on the ENV-FILE, not stderr, so the warning is discarded: a green run must
# not print "warning:" blocks a reader has to triage. `set -e` still catches
# a nonzero exit, and 6 is where stderr is captured and checked.
local bot_allow=(GH_TOKEN FOREMAN_AGENT_GH_TOKEN CLAUDE_CODE_OAUTH_TOKEN AGENT_DECK_TELEGRAM_KEY)
local dev_allow=(TS_AUTHKEY CLAUDE_CODE_OAUTH_TOKEN AGENT_DECK_TELEGRAM_KEY)
# 1. Bot strips TS_AUTHKEY from the host env; keeps allowed vars —
# including the read-only agent token foreman requires for dispatch.
env_file="${work_dir}/env-bot-strip"
: >"$env_file"
TS_AUTHKEY=fake GH_TOKEN=fake FOREMAN_AGENT_GH_TOKEN=fake bash "$init_env" "$env_file" "${bot_allow[@]}" 2>/dev/null
has_var GH_TOKEN "$env_file" || fail "bot profile dropped allowed GH_TOKEN"
has_var FOREMAN_AGENT_GH_TOKEN "$env_file" || fail "bot profile dropped allowed FOREMAN_AGENT_GH_TOKEN (foreman dispatch needs it)"
if has_var TS_AUTHKEY "$env_file"; then
fail "bot profile leaked TS_AUTHKEY into the env-file"
fi
# 2. Bot evicts a STALE TS_AUTHKEY already in the file when TS_AUTHKEY is
# unset in the host env.
env_file="${work_dir}/env-bot-evict"
printf 'TS_AUTHKEY=stale\nGH_TOKEN=old\n' >"$env_file"
(unset TS_AUTHKEY && GH_TOKEN=new bash "$init_env" "$env_file" "${bot_allow[@]}") 2>/dev/null
if has_var TS_AUTHKEY "$env_file"; then
fail "bot profile failed to evict a stale TS_AUTHKEY"
fi
# 3. Dev keeps TS_AUTHKEY when the dev allow-list includes it — and refuses
# GH_TOKEN even though the host env sets it. The human profile commits as
# the operator, so it must authenticate as the operator (via `gh auth
# login`) rather than carry the bot's PAT.
env_file="${work_dir}/env-dev-keep"
: >"$env_file"
TS_AUTHKEY=keep GH_TOKEN=fake FOREMAN_AGENT_GH_TOKEN=fake bash "$init_env" "$env_file" "${dev_allow[@]}" 2>/dev/null
has_var TS_AUTHKEY "$env_file" || fail "dev profile dropped allowed TS_AUTHKEY"
if has_var GH_TOKEN "$env_file"; then
fail "dev profile injected GH_TOKEN — a human profile must carry no bot credential"
fi
if has_var FOREMAN_AGENT_GH_TOKEN "$env_file"; then
fail "dev profile injected FOREMAN_AGENT_GH_TOKEN — agent tokens stay in the bot profile"
fi
# 3b. Dev evicts a STALE GH_TOKEN already in the file when GH_TOKEN is unset
# in the host env. This is what retires the value an earlier rebuild wrote
# back when the dev allow-list still included it — dropping it from the
# allow-list alone would leave the old token sitting in the env-file.
env_file="${work_dir}/env-dev-evict"
printf 'GH_TOKEN=stale\nTS_AUTHKEY=old\n' >"$env_file"
(unset GH_TOKEN && TS_AUTHKEY=new bash "$init_env" "$env_file" "${dev_allow[@]}") 2>/dev/null
if has_var GH_TOKEN "$env_file"; then
fail "dev profile failed to evict a stale GH_TOKEN"
fi
# 4. ANTHROPIC_API_KEY is ALWAYS stripped, even if passed in the allow-list
# (it silently overrides CLAUDE_CODE_OAUTH_TOKEN).
env_file="${work_dir}/env-anthropic"
: >"$env_file"
ANTHROPIC_API_KEY=secret GH_TOKEN=fake bash "$init_env" "$env_file" GH_TOKEN ANTHROPIC_API_KEY 2>/dev/null
if has_var ANTHROPIC_API_KEY "$env_file"; then
fail "ANTHROPIC_API_KEY was allowed into the env-file"
fi
# 5. An unknown var passed in the allow-list cannot be smuggled in.
env_file="${work_dir}/env-smuggle"
: >"$env_file"
HARMON_SMUGGLE=evil bash "$init_env" "$env_file" GH_TOKEN HARMON_SMUGGLE 2>/dev/null
if has_var HARMON_SMUGGLE "$env_file"; then
fail "an unknown var was smuggled into the env-file via the allow-list"
fi
# 6. A var allow-listed for this profile but absent from BOTH the host env
# and the env-file must WARN on stderr. The silence this replaces is how
# a missing TS_AUTHKEY went unnoticed for hours in a Coder workspace
# (harmon-init#639): the container came up fine and only the Tailscale-
# dependent step failed, far from the cause.
local warn_out warn_rc
env_file="${work_dir}/env-warn-dev"
: >"$env_file"
warn_rc=0
warn_out="$(unset TS_AUTHKEY CLAUDE_CODE_OAUTH_TOKEN AGENT_DECK_TELEGRAM_KEY &&
bash "$init_env" "$env_file" "${dev_allow[@]}" 2>&1 >/dev/null)" || warn_rc=$?
[ "$warn_rc" -eq 0 ] ||
fail "init-env.sh exited ${warn_rc} on a missing allow-listed var — it runs as initializeCommand on the HOST, so a nonzero exit aborts the container build"
case "$warn_out" in
*TS_AUTHKEY*) ;;
*) fail "dev profile did not warn about a TS_AUTHKEY missing from both the host env and the env-file" ;;
esac
# The bot profile must stay SILENT about TS_AUTHKEY even when it is
# missing everywhere: it is off that allow-list by design (no tailnet
# path from a bypassPermissions container), so naming it would advertise
# a credential the profile must never hold — and train the reader to
# expect one. Warning from BASE_MANAGED_VARS/EVICT_VARS instead of the
# post-filter allow-list is the accident this pins down.
env_file="${work_dir}/env-warn-bot"
: >"$env_file"
warn_rc=0
warn_out="$(unset TS_AUTHKEY GH_TOKEN FOREMAN_AGENT_GH_TOKEN CLAUDE_CODE_OAUTH_TOKEN AGENT_DECK_TELEGRAM_KEY &&
bash "$init_env" "$env_file" "${bot_allow[@]}" 2>&1 >/dev/null)" || warn_rc=$?
[ "$warn_rc" -eq 0 ] || fail "init-env.sh exited ${warn_rc} warning under the bot profile"
case "$warn_out" in
*TS_AUTHKEY*) fail "bot profile warned about a missing TS_AUTHKEY — it is off that allow-list by design and must never be advertised there" ;;
esac
case "$warn_out" in
*GH_TOKEN*) ;;
*) fail "bot profile did not warn about a GH_TOKEN missing from both the host env and the env-file" ;;
esac
# A value already in the env-file is the out-of-band case init-env.sh
# exists to preserve (1Password-managed, never exported to the host
# shell): warn about nothing, and leave the value intact.
env_file="${work_dir}/env-warn-quiet"
printf 'TS_AUTHKEY=fromop\nCLAUDE_CODE_OAUTH_TOKEN=tok\nAGENT_DECK_TELEGRAM_KEY=key\n' >"$env_file"
warn_rc=0
warn_out="$(unset TS_AUTHKEY CLAUDE_CODE_OAUTH_TOKEN AGENT_DECK_TELEGRAM_KEY &&
bash "$init_env" "$env_file" "${dev_allow[@]}" 2>&1 >/dev/null)" || warn_rc=$?
[ "$warn_rc" -eq 0 ] || fail "init-env.sh exited ${warn_rc} with every allow-listed var already in the env-file"
[ -z "$warn_out" ] || fail "init-env.sh warned about vars already present in the env-file: ${warn_out}"
grep '^TS_AUTHKEY=fromop$' "$env_file" >/dev/null ||
fail "init-env.sh did not preserve the out-of-band TS_AUTHKEY value in the env-file"
# A bare "VAR=" env-file line leaves the container with no usable value,
# so the presence test requires at least one character after the `=`.
env_file="${work_dir}/env-warn-blank"
printf 'TS_AUTHKEY=\nCLAUDE_CODE_OAUTH_TOKEN=tok\nAGENT_DECK_TELEGRAM_KEY=key\n' >"$env_file"
warn_rc=0
warn_out="$(unset TS_AUTHKEY CLAUDE_CODE_OAUTH_TOKEN AGENT_DECK_TELEGRAM_KEY &&
bash "$init_env" "$env_file" "${dev_allow[@]}" 2>&1 >/dev/null)" || warn_rc=$?
[ "$warn_rc" -eq 0 ] || fail "init-env.sh exited ${warn_rc} on a blank env-file entry"
case "$warn_out" in
*TS_AUTHKEY*) ;;
*) fail "init-env.sh treated a blank 'TS_AUTHKEY=' env-file line as a usable value" ;;
esac
# The warning names vars, never VALUES — it lands in build logs. Asserted
# with a var exported EMPTY (which "${!var:-}" treats as unset, so it
# warns) alongside two carrying sentinel secrets that must not appear.
env_file="${work_dir}/env-warn-novalue"
: >"$env_file"
warn_rc=0
warn_out="$(TS_AUTHKEY= CLAUDE_CODE_OAUTH_TOKEN=harmon-sentinel-one \
AGENT_DECK_TELEGRAM_KEY=harmon-sentinel-two \
bash "$init_env" "$env_file" "${dev_allow[@]}" 2>&1 >/dev/null)" || warn_rc=$?
[ "$warn_rc" -eq 0 ] || fail "init-env.sh exited ${warn_rc} on an allow-listed var exported empty"
case "$warn_out" in
*TS_AUTHKEY*) ;;
*) fail "init-env.sh did not warn about an allow-listed var exported empty — no usable value reaches the container" ;;
esac
case "$warn_out" in
*harmon-sentinel*) fail "init-env.sh printed a secret VALUE in its warning output" ;;
esac
# 6b. A run that changes nothing must not TOUCH the env-file. init-env.sh is
# an initializeCommand, so it fires on every VS Code connect, not just
# rebuilds — and an unconditional rewrite churns the file's mtime, which
# is enough for Coder's devcontainer integration to read the config as
# dirty and recreate the container. mtime, not just content, is the
# assertion: a byte-identical rewrite would pass a content-only check
# and still cause the recreation loop.
local mtime_before mtime_after content_before
env_file="${work_dir}/env-idempotent"
printf 'TS_AUTHKEY=stable\nCLAUDE_CODE_OAUTH_TOKEN=tok\nAGENT_DECK_TELEGRAM_KEY=key\n' >"$env_file"
# First run settles the file (it may legitimately rewrite here).
TS_AUTHKEY=stable CLAUDE_CODE_OAUTH_TOKEN=tok AGENT_DECK_TELEGRAM_KEY=key \
bash "$init_env" "$env_file" "${dev_allow[@]}" 2>/dev/null
content_before="$(cat "$env_file")"
mtime_before="$(stat -c %Y "$env_file" 2>/dev/null || stat -f %m "$env_file")"
sleep 1
# Second run with an identical host env: nothing to inject, nothing to evict.
TS_AUTHKEY=stable CLAUDE_CODE_OAUTH_TOKEN=tok AGENT_DECK_TELEGRAM_KEY=key \
bash "$init_env" "$env_file" "${dev_allow[@]}" 2>/dev/null
mtime_after="$(stat -c %Y "$env_file" 2>/dev/null || stat -f %m "$env_file")"
[ "$mtime_before" = "$mtime_after" ] ||
fail "init-env.sh rewrote an unchanged env-file (mtime ${mtime_before} -> ${mtime_after}) — the mtime churn makes Coder recreate the container on every connect"
[ "$(cat "$env_file")" = "$content_before" ] ||
fail "init-env.sh changed the contents of an already-settled env-file"
# A run that DOES have work to do must still write. Guards the obvious
# wrong fix for the above: skipping the write unconditionally.
env_file="${work_dir}/env-idempotent-change"
printf 'TS_AUTHKEY=old\n' >"$env_file"
TS_AUTHKEY=new bash "$init_env" "$env_file" "${dev_allow[@]}" 2>/dev/null
grep '^TS_AUTHKEY=new$' "$env_file" >/dev/null ||
fail "init-env.sh skipped a write it needed to make — the changed TS_AUTHKEY never reached the env-file"
# 6c. Permissions are enforced even when the CONTENT needs no write. A
# pre-existing env-file (copied from devcontainer.env.example under a
# permissive umask) holds secrets at 0644, and a skip-on-identical run
# must still tighten it — while leaving mtime alone (chmod never
# touches mtime, so both assertions can hold at once).
env_file="${work_dir}/env-loose-perms"
printf 'TS_AUTHKEY=stable\nCLAUDE_CODE_OAUTH_TOKEN=tok\nAGENT_DECK_TELEGRAM_KEY=key\n' >"$env_file"
chmod 644 "$env_file"
mtime_before="$(stat -c %Y "$env_file" 2>/dev/null || stat -f %m "$env_file")"
sleep 1
TS_AUTHKEY=stable CLAUDE_CODE_OAUTH_TOKEN=tok AGENT_DECK_TELEGRAM_KEY=key \
bash "$init_env" "$env_file" "${dev_allow[@]}" 2>/dev/null
mode_after="$(stat -c '%a' "$env_file" 2>/dev/null || stat -f '%Lp' "$env_file")"
[ "$mode_after" = "600" ] ||
fail "init-env.sh left a secret env-file at mode ${mode_after} — 0600 must be enforced on every run, not only on rewrites"
mtime_after="$(stat -c %Y "$env_file" 2>/dev/null || stat -f %m "$env_file")"
[ "$mtime_before" = "$mtime_after" ] ||
fail "init-env.sh churned mtime while fixing permissions on an unchanged env-file"
# 7. tailscale-connect.sh no-ops (exit 0, prints its "unavailable" message)
# when `tailscale` is not on PATH. Invoke with an absolute bash path so
# the unreachable PATH doesn't also hide the interpreter.
# `env -u DEVCONTAINER_TAILSCALE` is load-bearing: THIS SUITE RUNS INSIDE
# A DEVCONTAINER, and the dev profile sets that marker to true, which
# makes a missing CLI fatal rather than a skip. Without the strip, this
# assertion silently inverts depending on which profile runs it — passing
# on the host and on the bot profile, failing on dev.
local ts_out
if ! ts_out="$(env -u DEVCONTAINER_TAILSCALE -u DEVCONTAINER_TAILSCALE_REQUIRED -u DEVCONTAINER_TAILSCALE_OPTIONAL \
PATH="/nonexistent" "$bash_bin" "$ts_connect" 2>&1)"; then
fail "tailscale-connect.sh exited nonzero when tailscale is absent"
fi
case "$ts_out" in
*"unavailable"*) ;;
*) fail "tailscale-connect.sh did not report tailscale unavailable: ${ts_out}" ;;
esac
# 7b. The tailnet gate itself. A profile whose defining feature is the
# tailnet must FAIL ITS BUILD when it cannot join one — it must never
# start clean, report success, and sit there logged out. Every path in
# this script used to `exit 0`, which is exactly that failure.
#
# Every case strips both knobs from the inherited environment for the
# reason above, then sets only what it is testing.
local ts_bin ts_sock ts_sock_ready ts_up_marker ts_rc ts_case_out ts_dep ts_dep_path
ts_bin="${work_dir}/tailscale-bin"
mkdir -p "$ts_bin"
ln -s "$bash_bin" "${ts_bin}/bash"
for ts_dep in seq sleep tail cat printf hostname basename cut git; do
ts_dep_path="$(command -v "$ts_dep" 2>/dev/null)" || continue
ln -s "$ts_dep_path" "${ts_bin}/${ts_dep}"
done
# timeout is NOT optional and NOT resolvable by name alone: Homebrew
# coreutils on macOS installs it as `gtimeout`, the same split
# devcontainer-smoke.sh handles. tailscale-connect.sh calls bare `timeout`,
# and the constrained PATH below is all it can see — so resolve whichever
# exists and link it UNDER THE NAME the script calls. Skipping it silently
# (as a `continue` in the loop above would) leaves cases (h)-(j) exiting
# 127 on a supported dev platform, with the suite blaming tailscale.
ts_dep_path="$(command -v timeout || command -v gtimeout)" ||
fail "neither timeout nor gtimeout is available for the tailscale unit cases"
ln -s "$ts_dep_path" "${ts_bin}/timeout"
# sudo → exec "$@" and pgrep → exit 0: the connect paths below must be
# reachable without root and without a real daemon. pgrep succeeding means
# the script skips the start-tailscaled branch and goes straight to the
# unconditional socket wait, which is the ordering this change introduced.
printf '%s\n' '#!/bin/sh' 'exec "$@"' >"${ts_bin}/sudo"
printf '%s\n' '#!/bin/sh' 'exit 0' >"${ts_bin}/pgrep"
chmod 0755 "${ts_bin}/sudo" "${ts_bin}/pgrep"
# The tailscale stub answers `status --json` from TS_STUB_STATE and `up`
# from TS_STUB_UP_RC, touching TS_STUB_UP_MARKER so a case can tell whether
# `up` ran at all — which is how case (g) proves the already-connected fast
# path short-circuits instead of reconnecting.
cat >"${ts_bin}/tailscale" <<'TS_STUB'
#!/bin/sh
case "$1" in
status)
# TS_STUB_STATUS_SILENT=1 is a daemon that is NOT answering: the socket
# inode is there (a crashed tailscaled leaves one behind) but `status`
# returns nothing. The readiness loop must keep waiting and then bail,
# rather than treating the stale inode as proof the daemon is up.
[ "${TS_STUB_STATUS_SILENT:-0}" = "1" ] && exit 1
# TS_STUB_STATUS_HANG=1 is a daemon that accepts the request and never
# answers. It must be killed by the probe's own timeout, and the loop must
# still finish on its wall-clock deadline rather than multiplying the hang
# by its iteration count.
if [ "${TS_STUB_STATUS_HANG:-0}" = "1" ]; then
sleep 120
exit 1
fi
printf '{"BackendState": "%s"}\n' "${TS_STUB_STATE:-NeedsLogin}"
exit 0
;;
up)
[ -n "${TS_STUB_UP_MARKER:-}" ] && : >"${TS_STUB_UP_MARKER}"
[ "${TS_STUB_UP_RC:-0}" = "0" ] || echo "stub: tailscale up refused the key" >&2
exit "${TS_STUB_UP_RC:-0}"
;;
esac
exit 0
TS_STUB
chmod 0755 "${ts_bin}/tailscale"
# Cases (g)-(m) reach the connect paths, which means getting past
# `[ -S "${TS_SOCKET}" ]` — so they need a REAL unix socket; there is no
# way to satisfy `-S` without binding one. Cases (a)-(f) all bail before
# the socket wait and need none.
#
# Creating it is best-effort and must NEVER fail the suite, because two
# environments legitimately cannot: a sandbox that denies AF_UNIX bind
# (Codex's workspace-write sandbox does), and any host whose TMPDIR makes
# the path exceed sun_path's ~104-byte limit — mktemp -d under a long
# TMPDIR is enough, and the error there is a confusing "path too long"
# rather than anything about tailscale. Both used to take down every
# assertion in this file, including the ones that have nothing to do with
# the tailnet, and this script ships verbatim to generated repos where the
# same `task ci` is expected to run locally.
#
# So: bind if we can, and otherwise SKIP (g)-(l) loudly. The skip is
# printed rather than silent — a quiet coverage hole is the failure mode
# this whole change exists to prevent — and CI and the devcontainer, where
# the bind succeeds, still run every case.
ts_sock="${work_dir}/ts.sock"
ts_sock_ready=no
if ! command -v python3 >/dev/null 2>&1; then
echo "==> SKIP tailscale cases (g)-(m): python3 is unavailable to create a stub socket."
elif python3 -c 'import socket, sys
s = socket.socket(socket.AF_UNIX)
s.bind(sys.argv[1])
s.listen(1)' "$ts_sock" 2>/dev/null && [ -S "$ts_sock" ]; then
ts_sock_ready=yes
else
echo "==> SKIP tailscale cases (g)-(m): cannot bind a stub unix socket at ${ts_sock}"
echo " (sandbox denial, or TMPDIR makes the path exceed sun_path's ~104-byte limit)."
echo " Cases (a)-(f) still run; CI and the devcontainer run the full set."
fi
ts_up_marker="${work_dir}/ts-up-ran"
# ts_connect_run <expected-rc> <label> [VAR=VAL ...]
# Runs tailscale-connect.sh with both knobs stripped and only the named
# variables set, then asserts the exit code. Output lands in ts_case_out.
ts_connect_run() {
local want_rc="$1" label="$2"
shift 2
ts_rc=0
# The caller's assignments come LAST so a case can override any of the
# defaults — case (a) overrides PATH to take the CLI away, and a fixed
# PATH here would silently hand it back and test the wrong path.
ts_case_out="$(env -u DEVCONTAINER_TAILSCALE -u DEVCONTAINER_TAILSCALE_REQUIRED -u DEVCONTAINER_TAILSCALE_OPTIONAL \
-u TS_AUTHKEY -u TS_AUTH_KEY -u TS_STUB_STATE -u TS_STUB_UP_RC \
-u TS_STUB_STATUS_SILENT -u TS_STUB_STATUS_HANG \
TS_SOCKET_PATH="$ts_sock" TS_STUB_UP_MARKER="$ts_up_marker" \
PATH="$ts_bin" "$@" "$bash_bin" "$ts_connect" 2>&1)" || ts_rc=$?
[ "$ts_rc" = "$want_rc" ] ||
fail "tailscale-connect.sh case ${label}: exited ${ts_rc}, expected ${want_rc}: ${ts_case_out}"
}
# (a) optional + no CLI → exit 0, and still says "unavailable" (test 7's
# assertion greps for that substring, so the wording is a contract).
ts_connect_run 0 "a (optional, no CLI)" PATH="/nonexistent"
case "$ts_case_out" in
*"unavailable"*) ;;
*) fail "tailscale-connect.sh case a: did not report tailscale unavailable: ${ts_case_out}" ;;
esac
# (b) required + no CLI → exit 1, FATAL. The missing-CLI path is the one
# that used to be most obviously harmless.
ts_rc=0
ts_case_out="$(env -u DEVCONTAINER_TAILSCALE_OPTIONAL \
DEVCONTAINER_TAILSCALE_REQUIRED=true PATH="/nonexistent" \
"$bash_bin" "$ts_connect" 2>&1)" || ts_rc=$?
[ "$ts_rc" = "1" ] ||
fail "tailscale-connect.sh case b: exited ${ts_rc} with no CLI and the tailnet required, expected 1"
case "$ts_case_out" in
*FATAL*) ;;
*) fail "tailscale-connect.sh case b: required + no CLI did not report FATAL: ${ts_case_out}" ;;
esac
# (c) required + CLI + no key → exit 1, and names the variable to set.
ts_connect_run 1 "c (required, no key)" DEVCONTAINER_TAILSCALE_REQUIRED=true
case "$ts_case_out" in
*TS_AUTHKEY*) ;;
*) fail "tailscale-connect.sh case c: missing-key FATAL does not name TS_AUTHKEY: ${ts_case_out}" ;;
esac
# (d) optional + CLI + no key → exit 0. The bot profile and every plain
# local devcontainer live here; this is the behavior that must NOT
# change.
ts_connect_run 0 "d (optional, no key)"
# (e) required + the opt-out → exit 0. The smoke test's path.
ts_connect_run 0 "e (required, opted out)" \
DEVCONTAINER_TAILSCALE_REQUIRED=true DEVCONTAINER_TAILSCALE_OPTIONAL=true
# (f) required + DEVCONTAINER_TAILSCALE_OPTIONAL=1 → exit 1. ONLY the exact
# string `true` demotes; a truthy-looking value must not disarm a gate.
ts_connect_run 1 "f (required, OPTIONAL=1 is not true)" \
DEVCONTAINER_TAILSCALE_REQUIRED=true DEVCONTAINER_TAILSCALE_OPTIONAL=1
if [ "$ts_sock_ready" = "yes" ]; then
# (g) required + BackendState=Running → exit 0 via the fast path, and
# `tailscale up` is NOT called. Proves readiness is judged by
# BackendState rather than by reconnecting unconditionally.
rm -f "$ts_up_marker"
ts_connect_run 0 "g (required, already Running)" \
DEVCONTAINER_TAILSCALE_REQUIRED=true TS_AUTHKEY=stub-key TS_STUB_STATE=Running
[ ! -e "$ts_up_marker" ] ||
fail "tailscale-connect.sh case g: ran 'tailscale up' although BackendState was already Running"
# (h) required + NeedsLogin + up succeeds → exit 0, and reports the
# environment-appropriate node name.
rm -f "$ts_up_marker"
ts_connect_run 0 "h (required, NeedsLogin, up ok)" \
DEVCONTAINER_TAILSCALE_REQUIRED=true TS_AUTHKEY=stub-key \
TS_STUB_STATE=NeedsLogin TS_STUB_UP_RC=0
[ -e "$ts_up_marker" ] ||
fail "tailscale-connect.sh case h: never ran 'tailscale up' from NeedsLogin"
case "$ts_case_out" in
*"Connected to tailnet as cr-"* | *"Connected to tailnet as dc-"* | *"Connected to tailnet as gh-"*) ;;
*) fail "tailscale-connect.sh case h: did not report a prefixed node name: ${ts_case_out}" ;;
esac
# (i) required + NeedsLogin + up FAILS → exit 1, FATAL. THE case this whole
# change exists for: an expired or already-consumed auth key. Mutating
# the script back to its swallow-on-failure behavior makes this one
# return 0, which is the mutation test the issue asks for.
ts_connect_run 1 "i (required, NeedsLogin, up failed)" \
DEVCONTAINER_TAILSCALE_REQUIRED=true TS_AUTHKEY=stub-key \
TS_STUB_STATE=NeedsLogin TS_STUB_UP_RC=1
case "$ts_case_out" in
*FATAL*) ;;
*) fail "tailscale-connect.sh case i: failed connect did not report FATAL: ${ts_case_out}" ;;
esac
# (j) optional + NeedsLogin + up FAILS → exit 0. The unchanged behavior
# everywhere the tailnet is genuinely optional.
ts_connect_run 0 "j (optional, NeedsLogin, up failed)" \
TS_AUTHKEY=stub-key TS_STUB_STATE=NeedsLogin TS_STUB_UP_RC=1
# (k) required + a STALE socket: the inode exists (this fixture binds
# and then exits, which is exactly what a crashed tailscaled leaves
# behind) but the daemon does not answer. Readiness must not be
# satisfied by the inode alone — it must wait, then bail. `up` must
# never run against a daemon that never came up.
rm -f "$ts_up_marker"
ts_connect_run 1 "k (required, stale socket, daemon silent)" \
DEVCONTAINER_TAILSCALE_REQUIRED=true TS_AUTHKEY=stub-key TS_STUB_STATUS_SILENT=1
case "$ts_case_out" in
*"not answering"*) ;;
*) fail "tailscale-connect.sh case k: did not report an unanswering daemon: ${ts_case_out}" ;;
esac
[ ! -e "$ts_up_marker" ] ||
fail "tailscale-connect.sh case k: ran 'tailscale up' against a daemon that never answered"
# (l) the same, optional → exit 0. A silent daemon is not fatal where
# the tailnet was never required.
ts_connect_run 0 "l (optional, stale socket, daemon silent)" \
TS_AUTHKEY=stub-key TS_STUB_STATUS_SILENT=1
# (m) required + a daemon that HANGS rather than answering. The point
# is the BOUND, not just the exit code: a probe timeout multiplied
# by an iteration count would take minutes, and an unkilled probe
# would never return at all, so the failure message's deadline
# would be a lie precisely when it is load-bearing. Allow generous
# slack over the 10s deadline (CI is slow and each probe carries
# its own budget) while still failing decisively on a loop that
# multiplies instead of deadlines.
local ts_hang_start ts_hang_elapsed
ts_hang_start=$SECONDS
ts_connect_run 1 "m (required, daemon hangs)" \
DEVCONTAINER_TAILSCALE_REQUIRED=true TS_AUTHKEY=stub-key TS_STUB_STATUS_HANG=1
ts_hang_elapsed=$((SECONDS - ts_hang_start))
[ "$ts_hang_elapsed" -lt 60 ] ||
fail "tailscale-connect.sh case m: took ${ts_hang_elapsed}s against a hanging daemon — the readiness wait is not bounded by its deadline"
case "$ts_case_out" in
*"not answering"*) ;;
*) fail "tailscale-connect.sh case m: did not report an unanswering daemon: ${ts_case_out}" ;;
esac
fi
# 8. Antigravity runs without permission prompts inside the container,
# which is the isolation boundary. The apply helper must enforce those
# keys while preserving unrelated settings in the persistent volume.
local agy_defaults agy_apply agy_ensure agy_home agy_settings agy_backup agy_workspace agy_workspace_moved
agy_defaults="${repo_root}/.devcontainer/config/antigravity-settings.json"
agy_dev_defaults="${repo_root}/.devcontainer/config/antigravity-settings-dev.json"
agy_apply="${repo_root}/.devcontainer/config/apply-antigravity-settings.sh"
agy_ensure="${repo_root}/.devcontainer/config/ensure-antigravity-cli.sh"
[ -f "$agy_defaults" ] || fail "Antigravity defaults not found at ${agy_defaults}"
[ -f "$agy_apply" ] || fail "Antigravity settings helper not found at ${agy_apply}"
[ -f "$agy_ensure" ] || fail "Antigravity compatibility installer not found at ${agy_ensure}"
# ensure-antigravity-cli.sh now owns ~/.local/bin/agy-real (never agy
# directly) and is gated entirely on the rendered
# HARMON_BOT_AUTONOMY_ANTIGRAVITY marker — no unconditional download.
local agy_roll_home agy_system_binary
agy_roll_home="${work_dir}/agy-roll-home"
agy_system_binary="${work_dir}/agy-system-binary"
mkdir -p "${agy_roll_home}/.local/bin"
printf '%s\n' '#!/bin/sh' 'printf "%s\\n" "1.1.11"' >"$agy_system_binary"
printf '%s\n' '#!/bin/sh' 'printf "%s\\n" "1.0.0"' >"${agy_roll_home}/.local/bin/agy-real"
chmod 0755 "$agy_system_binary" "${agy_roll_home}/.local/bin/agy-real"
# Disabled marker (including entirely absent): no download. This models a
# rolling update from a pre-ownership-metadata release: neither the natural
# agy -> agy-real shape nor the markerless executable proves ownership, so
# both paths must be preserved byte-for-byte.
local agy_disabled_home agy_disabled_real_before
agy_disabled_home="${work_dir}/agy-disabled-home"
agy_disabled_real_before="${work_dir}/agy-disabled-real-before"
mkdir -p "${agy_disabled_home}/.local/bin"
printf '%s\n' '#!/bin/sh' 'printf "%s\\n" "1.0.0"' >"${agy_disabled_home}/.local/bin/agy-real"
chmod 0755 "${agy_disabled_home}/.local/bin/agy-real"
cp "${agy_disabled_home}/.local/bin/agy-real" "$agy_disabled_real_before"
ln -s "${agy_disabled_home}/.local/bin/agy-real" "${agy_disabled_home}/.local/bin/agy"
# env -u: the outer shell (or containerEnv) may export
# HARMON_BOT_AUTONOMY_ANTIGRAVITY=enabled; clear it so the script sees
# the disabled path.
env -u HARMON_BOT_AUTONOMY_ANTIGRAVITY HOME="$agy_disabled_home" bash "$agy_ensure" >/dev/null
cmp -s "$agy_disabled_real_before" "${agy_disabled_home}/.local/bin/agy-real" ||
fail "disabled cleanup changed a markerless pre-metadata agy-real"
[ -L "${agy_disabled_home}/.local/bin/agy" ] &&
[ "$(readlink "${agy_disabled_home}/.local/bin/agy")" = "${agy_disabled_home}/.local/bin/agy-real" ] ||
fail "disabled cleanup changed a markerless pre-metadata agy symlink"
[ ! -e "${agy_disabled_home}/.local/bin/.agy-real.harmon-init-owned" ] ||
fail "disabled cleanup claimed a markerless pre-metadata agy-real"
[ ! -e "${agy_disabled_home}/.local/bin/.agy.harmon-init-owned" ] ||
fail "disabled cleanup claimed a markerless pre-metadata agy launcher"
# Enabled marker, current shared-image binary already sufficient, no
# pre-existing local shadow: no shadow copy is created, and agy stays
# absent rather than becoming a dangling symlink.
local agy_image_home
agy_image_home="${work_dir}/agy-image-home"
mkdir -p "$agy_image_home"
HOME="$agy_image_home" HARMON_BOT_AUTONOMY_ANTIGRAVITY=enabled \
HARMON_ANTIGRAVITY_SYSTEM_BINARY="$agy_system_binary" bash "$agy_ensure" >/dev/null
[ ! -e "${agy_image_home}/.local/bin/agy-real" ] ||
fail "current shared-image Antigravity binary created a persistent shadow copy"
[ ! -e "${agy_image_home}/.local/bin/agy" ] && [ ! -L "${agy_image_home}/.local/bin/agy" ] ||
fail "agy is present (or a dangling symlink) with no agy-real to back it"
# Enabled marker, a stale local shadow already exists: refreshed to the
# pinned version, and agy (re)pointed at it as a plain symlink.
HOME="$agy_roll_home" HARMON_BOT_AUTONOMY_ANTIGRAVITY=enabled \
HARMON_ANTIGRAVITY_SYSTEM_BINARY="$agy_system_binary" bash "$agy_ensure" >/dev/null
[ "$("${agy_roll_home}/.local/bin/agy-real" --version)" = "1.1.11" ] ||
fail "stale user-local Antigravity binary still shadows the shared-image pin"
[ "$(readlink -f "${agy_roll_home}/.local/bin/agy")" = "$(readlink -f "${agy_roll_home}/.local/bin/agy-real")" ] ||
fail "ensure-antigravity-cli.sh did not point agy at agy-real as a plain symlink"
[ -f "${agy_roll_home}/.local/bin/.agy-real.harmon-init-owned" ] &&
[ -f "${agy_roll_home}/.local/bin/.agy.harmon-init-owned" ] ||
fail "ensure-antigravity-cli.sh did not publish independent path ownership proofs"
# Toggling back to disabled fully removes both — not merely skips the
# download — reaching absence rather than a dangling link.
# env -u: same containerEnv leak as the disabled-marker case above.
env -u HARMON_BOT_AUTONOMY_ANTIGRAVITY HOME="$agy_roll_home" bash "$agy_ensure" >/dev/null
[ ! -e "${agy_roll_home}/.local/bin/agy-real" ] &&
[ ! -e "${agy_roll_home}/.local/bin/agy" ] &&
[ ! -e "${agy_roll_home}/.local/bin/.agy-real.harmon-init-owned" ] &&
[ ! -e "${agy_roll_home}/.local/bin/.agy.harmon-init-owned" ] ||
fail "toggling the marker off did not fully remove agy-real/agy"
agy_home="${work_dir}/agy-home"
agy_settings="${agy_home}/.gemini/antigravity-cli/settings.json"
agy_backup="${agy_settings}.harmon-init-autonomy-backup"
agy_workspace="${work_dir}/trusted-workspace"
agy_workspace_moved="${work_dir}/trusted-workspace-renamed"
mkdir -p "$(dirname "$agy_settings")"
printf '%s\n' '{"model":"Gemini test","toolPermission":"request-review","permissions":{"allow":["command(task)"],"bash":"deny"}}' >"$agy_settings"
HOME="$agy_home" bash "$agy_apply" apply "$agy_defaults" "$agy_workspace" >/dev/null
jq -e '