Repository navigation
Expand file tree
/
Copy pathJustfile
More file actions
2559 lines (2485 loc) · 136 KB
/
Copy pathJustfile
File metadata and controls
2559 lines (2485 loc) · 136 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
# Justfile — KubeStellar Hive contributor commands, for ClankeR (the contributor relay)
#
# Install just: brew install just (macOS) or cargo install just
# Usage: just contribute-check claude (optional, read-only preflight) && \
# just contribute-setup claude && just contribute-hive
#
# Ordering (#2543): contribute-setup runs the backend-CLI preflight FIRST —
# before the GH token is written to disk and before hub registration — so a
# machine that isn't ready fails before it costs a credential write or a
# contributor slot. `just contribute-check <cli>` runs the same preflight
# standalone, any time, with zero side effects.
set shell := ["bash", "-euo", "pipefail", "-c"]
hive_image := env("HIVE_CONTRIBUTOR_IMAGE", "ghcr.io/hivecommons/hive-contributor:latest")
# The hosted hub moved to hive.hivecommons.dev on 2026-09-04; the old
# hive.kubestellar.io host answers every path with a 301 to the new one, which
# a WebSocket handshake does not follow and `curl -sf` (no -L) reports as
# unreachable (#7624). The legacy value is still recognised below as "not
# set" so a stale export gets the hive lookup instead of a dead connection.
hive_hub := env("HIVE_HUB", "wss://hive.hivecommons.dev/contribute")
legacy_hive_hub := "wss://hive.kubestellar.io/contribute"
config_dir := env("HOME") + "/.config/hive"
# Container runtime for containerized mode. Empty = auto-detect (docker, then
# podman — Docker wins on discovery order, not isolation posture; see the
# posture note above the detect logic in contribute-hive, and #2535).
# Set HIVE_CONTAINER_RUNTIME=podman to force rootless podman, or =docker.
container_runtime := env("HIVE_CONTAINER_RUNTIME", "")
# Shared contributor workload ceiling. The container runtime and Kubernetes
# recipes render their native unit spelling from these values so the default
# envelope cannot drift between the two deployment paths.
contributor_memory_limit_gib := "4"
contributor_cpu_limit := "2"
# Show available commands
default:
@just --list
# Run the live run-level acceptance test (#8466 layer 2). Requires HIVE_URL and
# HIVE_TOKEN like the rest of src/test, plus HIVE_RUNS_E2E_KEY or
# HIVE_RUNS_E2E_REPO/HIVE_RUNS_E2E_ISSUE for the pinned run/spec issue.
runs-e2e:
cd src && go test -tags integration ./test -run Runs
[private]
check-version skip="false":
#!/usr/bin/env bash
if [[ "{{skip}}" == "true" || "${HIVE_SKIP_VERSION_CHECK:-}" == "true" ]]; then exit 0; fi
LOCAL=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")
# Compare against the checked-out line, not a hard-coded branch: a v5 or
# v6 checkout at its own tip must not be told to pull v4 (#8071). A
# detached HEAD has no line to compare against, so it is not checked.
BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "HEAD")
if [[ "$BRANCH" == "HEAD" ]]; then echo "✓ Detached HEAD (${LOCAL}); skipping branch freshness check"; exit 0; fi
git fetch origin "$BRANCH" --quiet 2>/dev/null || true
REMOTE=$(git rev-parse --short "origin/$BRANCH" 2>/dev/null || echo "unknown")
if [[ "$LOCAL" != "$REMOTE" && "$REMOTE" != "unknown" ]]; then
echo "✗ Version check failed (local: ${LOCAL}, latest: ${REMOTE} on ${BRANCH})"
echo " Run: git pull origin ${BRANCH}"
echo " Or skip: export HIVE_SKIP_VERSION_CHECK=true"
exit 1
fi
echo "✓ Up to date (${LOCAL})"
# Read-only preflight: is this machine ready to contribute? Checks the
# agent backend CLI (the thing most likely to fail) BEFORE any credential is
# written to disk or a contributor slot is registered with the hub. Safe to
# run as many times as you like — it writes nothing.
# Usage: just contribute-check claude
[private]
contribute-check-backend backend="claude":
#!/usr/bin/env bash
set -euo pipefail
# config/backends.conf KNOWN_BACKENDS is the single source of truth for the
# supported set (#13262, #13263); the case below only adds per-CLI probes.
# shellcheck source=config/backends.conf disable=SC1091
source "{{justfile_directory()}}/config/backends.conf"
case " ${KNOWN_BACKENDS} " in
*" {{backend}} "*) ;;
*)
echo "ERROR: Unknown backend '{{backend}}'. Supported: ${KNOWN_BACKENDS// /, }"
exit 1
;;
esac
echo "── Preflight: {{backend}} CLI ──"
# Say which CLI this probes. It is the HOST's copy, which is what local
# mode runs and what container mode stages a sign-in from — but container
# mode (the default) executes the copy baked into the contributor image,
# so a passing host probe does not prove the image ships the CLI (#7661:
# setup said omp was ready, the container said `omp CLI not found`).
echo "(host CLI — used by 'just contribute-hive {{backend}} local'; container mode runs the copy inside the image)"
case "{{backend}}" in
claude)
if ! command -v claude &>/dev/null; then
echo "ERROR: Claude Code not installed. Install: npm i -g @anthropic-ai/claude-code"
exit 1
fi
if claude -p "reply with OK" --max-turns 1 2>/dev/null | grep -qi "ok"; then
echo "Claude Code authenticated and working."
echo " Effort: export AGENT_REASONING_EFFORT=low|medium|high|xhigh|max (unset = Claude Code's default)"
else
echo ""
echo "Claude Code needs authentication."
echo "Run: claude"
echo "Then type /login and follow the prompts."
echo "Once logged in, exit Claude (Ctrl+C) and re-run this check."
exit 1
fi
;;
copilot)
if command -v copilot &>/dev/null || command -v gh &>/dev/null; then
echo "Copilot uses your gh auth — already authenticated."
else
echo "ERROR: Install copilot: gh extension install github/gh-copilot"
exit 1
fi
;;
aider)
if command -v aider &>/dev/null; then
echo "aider detected — export the API key for your chosen provider for containerized runs."
else
echo "ERROR: aider not found. Install: https://aider.chat/docs/install.html"
exit 1
fi
;;
bob)
if command -v bob &>/dev/null; then
# Bob's browser SSO flow cannot complete in a container, so the
# containerized path needs BOBSHELL_API_KEY exported in your shell.
echo "Bob CLI detected — export BOBSHELL_API_KEY for containerized runs."
else
echo "ERROR: Bob CLI not found."
exit 1
fi
;;
goose)
if command -v goose &>/dev/null; then
echo "Goose CLI detected ($(goose --version 2>&1 | head -1))"
if [[ -z "${GOOSE_PROVIDER:-}" ]]; then
echo " TIP: Set GOOSE_PROVIDER and GOOSE_MODEL env vars, or run 'goose configure' first."
echo " Example: export GOOSE_PROVIDER=anthropic GOOSE_MODEL=claude-sonnet-4-6"
else
echo " Provider: ${GOOSE_PROVIDER} / Model: ${GOOSE_MODEL:-default}"
fi
else
echo "ERROR: Goose CLI not found. Install: https://github.com/block/goose/releases"
exit 1
fi
;;
codex)
if command -v codex &>/dev/null; then
CODEX_AUTH_FILE="${CODEX_HOME:-${HOME}/.codex}/auth.json"
if [[ -n "${CODEX_API_KEY:-}" || -n "${OPENAI_API_KEY:-}" ]]; then
echo "Codex CLI detected — API-key auth is present in the environment."
elif [[ -s "$CODEX_AUTH_FILE" ]]; then
echo "Codex CLI detected — auth file present at ${CODEX_AUTH_FILE}."
else
echo "ERROR: Codex CLI detected but ${CODEX_AUTH_FILE} is missing."
echo " Run: codex login --device-auth (or export CODEX_API_KEY for API-key mode)."
exit 1
fi
else
echo "ERROR: Codex CLI not found. Install: npm i -g @openai/codex"
exit 1
fi
;;
pi)
if command -v pi &>/dev/null; then
echo "Pi CLI detected ($(pi --version 2>&1 | head -1))"
if [[ -z "${AGENT_MODEL:-}" ]]; then
echo "ERROR: Pi requires one canonical provider-qualified model."
echo " export AGENT_MODEL=anthropic/claude-sonnet-4-6"
exit 1
fi
if ! PI_READINESS=$(node bin/pi-backend.js "${AGENT_MODEL}" 2>&1); then
echo "ERROR: ${PI_READINESS}"
exit 1
fi
echo "HIVE_BACKEND_READINESS=${PI_READINESS}"
echo " Credential presence is reported as configured_unverified, never as proof of authentication."
else
echo "ERROR: Pi CLI not found. Install: curl -fsSL https://pi.dev/install.sh | sh"
exit 1
fi
;;
litellm)
# LiteLLM: Claude Code pointed at YOUR OWN LiteLLM proxy via
# ANTHROPIC_BASE_URL. No Anthropic login needed — auth is your
# proxy's key, exported locally (never stored by this setup).
if ! command -v claude &>/dev/null; then
echo "ERROR: LiteLLM mode runs Claude Code against your LiteLLM proxy."
echo "Install Claude Code first: npm i -g @anthropic-ai/claude-code"
exit 1
fi
if [[ -z "${HIVE_LITELLM_ENDPOINT:-}" ]]; then
echo "ERROR: HIVE_LITELLM_ENDPOINT not set."
echo " export HIVE_LITELLM_ENDPOINT=https://your-litellm-host:4000"
echo " export HIVE_LITELLM_API_KEY=sk-... # only if your proxy requires a key"
exit 1
fi
if [[ -z "${HIVE_LITELLM_API_KEY:-}" ]]; then
echo "NOTE: HIVE_LITELLM_API_KEY not set — assuming your proxy accepts unauthenticated requests."
fi
echo "LiteLLM endpoint: ${HIVE_LITELLM_ENDPOINT}"
echo " Claude Code will run with ANTHROPIC_BASE_URL=${HIVE_LITELLM_ENDPOINT}"
echo " Set the model your proxy serves: export AGENT_MODEL=<model>"
;;
agy)
if command -v agy &>/dev/null; then
echo "agy CLI detected ($(agy --version 2>&1 | head -1))"
echo " Models: gemini-3.6-flash, claude-sonnet-4-6, gpt-oss-120b, and more"
echo " Set model: export AGENT_MODEL=gemini-3.6-flash-high"
echo " Effort: export AGENT_REASONING_EFFORT=low|medium|high (agy needs --effort with --model)"
# agy has NO OS-level sandbox of its own (config/backends.conf's "no
# confinement mechanism at all" list) — Container is the only mode
# with any host boundary, and is now possible: src/Dockerfile.contributor
# installs the agy binary (#5048; it did not before). agy signs in
# through an interactive Google OAuth flow with no API-key mode, so
# sign in once — either on the host first (this recipe stages a
# signed-in ~/.gemini into the container) or interactively inside the
# container itself.
echo " Recommended: sign in once (run: agy), then run this backend CONTAINERIZED:"
echo " just contribute-hive agy"
echo " Local mode has no sandbox for agy and REFUSES to launch unless you set"
echo " HIVE_AGY_DANGEROUSLY_RUN_UNCONFINED=1, which runs agy directly against your"
echo " host filesystem with no boundary at all — not recommended."
else
echo "ERROR: agy CLI not found. Install: https://antigravity.google/product/antigravity-cli"
echo " Homebrew: brew install --cask antigravity-cli"
exit 1
fi
;;
opencode)
if command -v opencode &>/dev/null; then
echo "opencode CLI detected ($(opencode --version 2>&1 | head -1))"
echo " Provider-agnostic (75+ providers); set model: export AGENT_MODEL=provider/model"
echo " Auth: run 'opencode auth login' — credential stored at ~/.local/share/opencode/auth.json"
echo " opencode only runs in headless mode (CONTRIBUTOR_MODE=headless): 'opencode run' is its"
echo " one-shot entry point and there is no interactive-tmux wiring for it."
else
echo "ERROR: opencode CLI not found. Install: https://opencode.ai/docs/"
exit 1
fi
;;
kilo)
if command -v kilo &>/dev/null; then
echo "Kilo CLI detected ($(kilo --version 2>&1 | head -1))"
echo " Headless only: kilo run <prompt> --model provider/model --format json --auto"
echo " Set KILO_AUTH_CONTENT or KILO_API_KEY (optionally KILO_ORG_ID); do not mount Kilo config."
else
echo "ERROR: kilo CLI not found. Install @kilocode/cli: https://kilo.ai/docs/code-with-ai/platforms/cli"
exit 1
fi
;;
openhands)
if command -v openhands &>/dev/null; then
echo "OpenHands CLI detected ($(openhands --version 2>&1 | head -1))"
echo " Headless only: openhands --headless -t \"<prompt>\" --always-approve --override-with-envs"
echo " Model: export AGENT_MODEL=provider/model (any litellm model id; forwarded as LLM_MODEL)"
echo " Auth: export LLM_API_KEY=... (and LLM_BASE_URL for an OpenAI-compatible gateway),"
echo " or run 'openhands' once interactively (credential stored at ~/.openhands/settings.json)"
echo " openhands has NO sandbox on this path (its Docker sandbox exists only behind 'openhands serve');"
echo " local mode refuses to launch without HIVE_OPENHANDS_DANGEROUSLY_RUN_UNCONFINED=1."
echo " Note: the contributor image does not ship openhands (needs Python 3.12; the image is bookworm/3.11)."
if [[ -z "${LLM_API_KEY:-}" && ! -s "${OPENHANDS_PERSISTENCE_DIR:-$HOME/.openhands}/settings.json" ]]; then
echo " WARNING: no LLM_API_KEY and no ~/.openhands/settings.json — openhands will fail at task time."
fi
else
echo "ERROR: openhands CLI not found. Install: uv tool install openhands --python 3.12 (https://docs.openhands.dev/openhands/usage/cli/installation)"
exit 1
fi
;;
muse)
if command -v muse &>/dev/null; then
echo "Muse Code CLI detected ($(muse --version 2>&1 | head -1))"
echo " Headless only: muse exec \"<prompt>\" --approval-mode never --user-input-auto-resolve"
echo " Model: export AGENT_MODEL=<id from GET https://api.meta.ai/v1/models>"
echo " Query it from the machine that runs muse — the catalog is caller-dependent."
echo " Effort: export AGENT_REASONING_EFFORT=none|minimal|low|medium|high|xhigh|max|ultra"
echo " Auth: export META_API_KEY=..., or run 'muse login' / 'muse auth set --api-key-stdin'"
echo " (credential stored at ~/.config/muse/auth.json; META_API_KEY takes priority)"
echo " muse keeps its OWN OS sandbox on (bubblewrap/seccomp on Linux, seatbelt on macOS);"
echo " local mode narrows it further rather than refusing to launch."
if [[ -z "${META_API_KEY:-}" && ! -s "${HOME}/.config/muse/auth.json" ]]; then
echo " WARNING: no META_API_KEY and no ~/.config/muse/auth.json — muse will exit 1 at task time."
fi
else
echo "ERROR: muse CLI not found. Install: curl -fsSL https://dev.meta.ai/install.sh | bash"
exit 1
fi
;;
omp)
if command -v omp &>/dev/null; then
echo "OMP CLI detected ($(omp --version 2>&1 | head -1))"
echo " Set model: export AGENT_MODEL=provider/model"
echo " Sign in on this host (run: omp, complete its provider setup, quit) — container mode"
echo " stages that sign-in from ~/.omp/agent (#7678); a provider API key in the environment"
echo " (ANTHROPIC_API_KEY, OPENAI_API_KEY, …) is forwarded as well."
echo " OMP is interactive-only; Hive does not claim a generic authentication probe or headless mode."
# Say what container mode will actually stage — which providers are
# signed in on this host and which of them the selection keeps — so
# a missing sign-in is caught here, not discovered as the setup
# wizard inside a container whose relay is waiting at it (#7678).
# Provider NAMES only; credential values are never read out.
node bin/omp-backend.js --describe "${HOME}/.omp" "${AGENT_MODEL:-}" | sed 's/^/ /'
else
echo "ERROR: OMP CLI not found. Install: https://github.com/can1357/oh-my-pi"
exit 1
fi
;;
*)
echo "ERROR: Unknown backend '{{backend}}'. Supported: ${KNOWN_BACKENDS// /, }"
exit 1
;;
esac
echo "✓ {{backend}} preflight passed."
# Read-only preflight you can run standalone, any time, before setup — checks
# the agent backend CLI without writing a credential or registering with the
# hub. Run this FIRST if you're not sure your machine is ready.
# Usage: just contribute-check claude
contribute-check backend="claude": (contribute-check-backend backend)
@echo ""
@echo "✓ Machine looks ready for 'just contribute-setup {{backend}}'."
# End-to-end smoke of the contributor backend integration: the real relay
# against a fake hub, and — where a CLI + credential exist locally — the real
# backend on a one-line task. Keyless machines still run the drift checks and
# the stub wire-contract scenarios; live scenarios skip cleanly. The scheduled
# lane (.github/workflows/backend-smoke.yml) runs the same suite with skips
# escalated to failures.
# Usage: just backend-smoke (or: just backend-smoke claude)
backend-smoke backends="claude codex":
HIVE_SMOKE_BACKENDS="{{backends}}" bash bin/test_backend_smoke.sh
# Contract tests for the pre-kick issue classifier (bin/issue-classifier.sh):
# tiers, model recommendations, trackers, lanes and clustering, run against a
# path-rewritten copy in a temp dir — hermetic, no /var/run or /etc/hive.
# Wire this into .github/workflows/v2-ci.yml next to test_enumerate_actionable.sh
# so it runs in CI too (tracked in the issue that introduced the suite).
test-issue-classifier:
bash bin/test_issue_classifier.sh
# Contract tests for the agent PR-request wrapper (bin/hive-open-pr.sh):
# flag parsing, empty-body refusal, body sourcing, --issues normalization,
# request JSON structure and the python3-less fallback escaper — hermetic via
# HIVE_OPEN_PR_REQ_DIR and a throwaway git repo.
# Wired into .github/workflows/v2-ci.yml next to test_hive_open_issue.sh.
test-hive-open-pr:
bash bin/test_hive_open_pr.sh
# One-time setup: register with hub + authenticate GitHub + authenticate CLI
# Ordering note (#2543): the backend-readiness preflight runs FIRST, before
# any credential is written to disk or a contributor slot is registered —
# so a machine that isn't ready fails before it costs a GH token write or a
# hub registration. check-version still gates everything (it already did).
contribute-setup backend="claude": check-version (contribute-check-backend backend)
#!/usr/bin/env bash
set -euo pipefail
if [[ "{{hive_hub}}" == "wss://hive.hivecommons.dev/contribute" || "{{hive_hub}}" == "{{legacy_hive_hub}}" ]]; then
echo "HIVE_HUB not set — looking up your hives..."
echo ""
_TOKEN=$(gh auth token 2>/dev/null || echo "")
HIVE_LIST=""
if [[ -n "$_TOKEN" ]]; then
MY_HIVES=$(curl -sf -H "Authorization: Bearer ${_TOKEN}" "https://hive.hivecommons.dev/api/saas/my-hives" 2>/dev/null || echo "")
if [[ -n "$MY_HIVES" ]]; then
# /api/saas/my-hives answers with "hives": null for an account that
# owns no SaaS-hosted hive — the normal case for a contributor who
# only lends their CLI to someone else's hive. The old filter treated
# that as "try something else" via `// .[]`, which iterates every
# TOP-LEVEL KEY of the response (alerts, channel_targets, …) and then
# interpolates .name over values that are arrays. jq exits 5 on that,
# and because the recipe runs under `set -euo pipefail` the whole
# setup died with a bare "recipe failed with exit code 5" — never
# reaching the public-registry fallback directly below, which would
# have listed the hives fine.
#
# So: iterate .hives ONLY when it really is an array of objects, and
# never let jq's exit status abort the lookup. An empty result here
# is a valid answer that means "fall through to the registry".
HIVE_LIST=$(echo "$MY_HIVES" | jq -r '(.hives // empty) | select(type == "array") | .[] | select(type == "object") | "\(.id)|\(.name // .project_name // .id)"' 2>/dev/null || true)
fi
fi
if [[ -z "$HIVE_LIST" ]]; then
HIVES_JSON=$(curl -sf "https://hive.hivecommons.dev/api/registry" 2>/dev/null) || {
echo "ERROR: Could not reach hive.hivecommons.dev"
echo "Set HIVE_HUB manually: export HIVE_HUB=wss://<hive>/contribute"
exit 1
}
# Same guards as above: a registry whose shape drifts must degrade to
# "no hives listed" with an actionable message, not kill the recipe.
HIVE_LIST=$(echo "$HIVES_JSON" | jq -r '(.hives // empty) | select(type == "array") | .[] | select(type == "object" and .online == true) | "\(.id)|\(.name // .id)"' 2>/dev/null || true)
fi
if [[ -z "$HIVE_LIST" ]]; then
echo "No hives available. Check https://hive.hivecommons.dev"
echo "Or set the hub directly: export HIVE_HUB=wss://<hive>/contribute"
exit 1
fi
echo "Your hives:"
echo ""
i=1
declare -a HIVE_IDS
while IFS='|' read -r hid hname; do
HIVE_IDS+=("$hid")
printf " %d) %s (%s)\n" "$i" "$hname" "$hid"
i=$((i+1))
done <<< "$HIVE_LIST"
echo ""
read -p "Select a hive [1-$((i-1))]: " CHOICE
if [[ -z "$CHOICE" || "$CHOICE" -lt 1 || "$CHOICE" -gt $((i-1)) ]] 2>/dev/null; then
echo "Invalid selection."
exit 1
fi
SELECTED="${HIVE_IDS[$((CHOICE-1))]}"
# Prefer the registry entry's dashboardUrl regardless of id shape: for
# hosted-* (e.g. LKE slot) hives, <id>.hive.hivecommons.dev serves an
# HTML error page on the contributor API/WS paths, while dashboardUrl
# serves them correctly. Fall back to the derived <id> host only when
# the registry has no dashboardUrl for this entry.
DASH_URL=$(echo "$HIVES_JSON" | jq -r --arg id "$SELECTED" '.hives[] | select(.id==$id) | .dashboardUrl' 2>/dev/null || echo "")
if [[ -n "$DASH_URL" && "$DASH_URL" != "null" ]]; then
DASH_URL=$(echo "$DASH_URL" | sed 's|^http://|ws://|;s|^https://|wss://|')
export HIVE_HUB="${DASH_URL}/contribute"
else
export HIVE_HUB="wss://${SELECTED}.hive.hivecommons.dev/contribute"
fi
echo ""
echo "Selected: ${HIVE_HUB}"
echo "TIP: Next time, run: export HIVE_HUB=${HIVE_HUB}"
echo ""
fi
mkdir -p "{{config_dir}}"
echo "=== Hive Contributor Setup (ClankeR) ==="
echo "✓ Preflight passed — {{backend}} CLI is ready. Proceeding to credential + registration."
echo ""
# ── Step 1: GitHub authentication ──
echo "── Step 1/2: GitHub Authentication ──"
if ! command -v gh &>/dev/null; then
echo "ERROR: gh CLI not found. Install: brew install gh"
exit 1
fi
if gh auth status &>/dev/null; then
GH_USER=$(gh api user --jq '.login' 2>/dev/null || echo "")
echo "Already authenticated as: ${GH_USER}"
else
echo "Logging into GitHub..."
gh auth login --web --scopes "repo,read:org"
GH_USER=$(gh api user --jq '.login' 2>/dev/null || echo "")
echo "Authenticated as: ${GH_USER}"
fi
GH_TOKEN=$(gh auth token 2>/dev/null || echo "")
if [[ -n "$GH_TOKEN" ]]; then
echo "GH_TOKEN=${GH_TOKEN}" > "{{config_dir}}/gh-auth.env"
chmod 600 "{{config_dir}}/gh-auth.env"
fi
echo ""
# ── Step 2: Register with hive hub ──
echo "── Step 2/2: Hive Registration ──"
_HUB="${HIVE_HUB:-{{hive_hub}}}"
HUB_HTTP=$(echo "$_HUB" | sed 's|^wss://|https://|;s|^ws://|http://|;s|/contribute$||')
# SECURITY (H7 / CWE-522): Do NOT forward the contributor's GitHub PAT
# (gh auth token) to the hub. HUB_HTTP is derived from a registry entry's
# dashboardUrl, so a malicious/poisoned registry could harvest the token.
# The register endpoint identifies the contributor by github_username only
# and ignores any Authorization header, so no bearer token is sent here.
RESPONSE=$(curl -sf --max-time 15 -X POST "${HUB_HTTP}/api/contribute/register" \
-H "Content-Type: application/json" \
-d "{\"github_username\": \"${GH_USER}\"}" 2>/dev/null) || {
echo "ERROR: Registration failed. Is the hub running at ${HUB_HTTP}?"
echo " Check: curl -sf ${HUB_HTTP}/api/contribute/status"
exit 1
}
if ! echo "$RESPONSE" | jq empty 2>/dev/null; then
echo "ERROR: Hub returned invalid response: ${RESPONSE:0:200}"
exit 1
fi
TOKEN=$(echo "$RESPONSE" | jq -r '.registration_token')
CID=$(echo "$RESPONSE" | jq -r '.contributor_id')
MSG=$(echo "$RESPONSE" | jq -r '.message')
if [[ -z "$TOKEN" || "$TOKEN" == "null" ]]; then
if echo "$MSG" | grep -qi "already registered"; then
if [[ -f "{{config_dir}}/contributor.env" ]]; then
source "{{config_dir}}/contributor.env"
echo "Already registered — ${GH_USER} (${CONTRIBUTOR_ID:-unknown})"
else
# THE "SAME IDENTITY, NEW MACHINE" DEAD END (#4408). /api/contribute/
# register is unauthenticated, so it correctly refuses to hand back an
# existing contributor's token — otherwise POSTing any known username
# would be an account-takeover primitive. That refusal is right; what
# was missing is what to do next. This used to be a bare
# "Already registered but no local config found." and exit 1, with no
# supported path named here or in the docs.
echo "ERROR: ${GH_USER} is already registered on ${HUB_HTTP}, and this"
echo " machine has no {{config_dir}}/contributor.env to reuse."
echo ""
echo "That is expected: register is unauthenticated, so it will never"
echo "hand an existing contributor's token to whoever asks. You are"
echo "moving an identity, not creating one. Two supported ways forward:"
echo ""
echo " 1. KEEP the credential — copy it from the old machine:"
echo " scp old-machine:{{config_dir}}/contributor.env {{config_dir}}/"
echo " scp old-machine:{{config_dir}}/gh-auth.env {{config_dir}}/"
echo " chmod 600 {{config_dir}}/contributor.env {{config_dir}}/gh-auth.env"
echo " The hub stores only a HASH of the registration token and clears"
echo " the plaintext after first read, so copying the file is the ONLY"
echo " way to reuse the credential — it can never be printed again."
echo " Delete the files on the old machine afterwards."
echo ""
echo " 2. ROTATE the credential — reissue it here:"
echo " just contribute-move {{backend}}"
echo " Proves identity with your GitHub token, reissues one token per"
echo " hub, preserves EVERY hub entry in order, and writes gh-auth.env."
echo " The old machine's relay stops working the moment this runs."
echo ""
echo "See src/docs/contributor-relay.md → 'Moving the relay to another machine'."
exit 1
fi
else
echo "ERROR: ${MSG:-No token received}"
exit 1
fi
else
# MULTI-HUB PRESERVATION (#4408). contributor.env carries positional,
# comma-separated HIVE_HUB / HIVE_REGISTRATION_TOKEN / CONTRIBUTOR_ID
# lists (bin/contributor-relay.js pairs them by index). This block used to
# `cat >` a SINGLE-hub file unconditionally, so a two-hive contributor who
# ran contribute-setup against a hive they had not registered with yet
# silently lost the credential for the first one — a live token that the
# hub can never reprint. Registering an ADDITIONAL hub now appends to the
# existing lists instead, and the previous file is backed up either way.
_PREV_HUBS=""; _PREV_TOKENS=""; _PREV_IDS=""
if [[ -f "{{config_dir}}/contributor.env" ]]; then
_PREV_HUBS=$(grep -m1 '^HIVE_HUB=' "{{config_dir}}/contributor.env" | cut -d= -f2- || true)
_PREV_TOKENS=$(grep -m1 '^HIVE_REGISTRATION_TOKEN=' "{{config_dir}}/contributor.env" | cut -d= -f2- || true)
_PREV_IDS=$(grep -m1 '^CONTRIBUTOR_ID=' "{{config_dir}}/contributor.env" | cut -d= -f2- || true)
cp "{{config_dir}}/contributor.env" "{{config_dir}}/contributor.env.bak"
chmod 600 "{{config_dir}}/contributor.env.bak"
fi
_OUT_HUBS="${_HUB}"; _OUT_TOKENS="${TOKEN}"; _OUT_IDS="${CID}"
if [[ -n "$_PREV_HUBS" ]] && ! printf '%s' ",${_PREV_HUBS}," | grep -qF ",${_HUB},"; then
# Only append when the three lists are already the same length; a
# mismatched file is malformed and appending would misalign every pair.
_N_HUBS=$(printf '%s' "$_PREV_HUBS" | tr ',' '\n' | grep -c . || true)
_N_TOKENS=$(printf '%s' "$_PREV_TOKENS" | tr ',' '\n' | grep -c . || true)
_N_IDS=$(printf '%s' "$_PREV_IDS" | tr ',' '\n' | grep -c . || true)
if [[ "$_N_HUBS" == "$_N_TOKENS" && "$_N_HUBS" == "$_N_IDS" ]]; then
_OUT_HUBS="${_PREV_HUBS},${_HUB}"
_OUT_TOKENS="${_PREV_TOKENS},${TOKEN}"
_OUT_IDS="${_PREV_IDS},${CID}"
echo "Added ${_HUB} to your existing ${_N_HUBS}-hub configuration."
echo " (previous file kept at {{config_dir}}/contributor.env.bak)"
else
echo "WARNING: the positional lists in the existing contributor.env do not line up"
echo " (${_N_HUBS} hub(s), ${_N_TOKENS} token(s), ${_N_IDS} id(s)). Appending to a"
echo " malformed file would transpose every hub/token pair after the"
echo " mismatch, so this run writes a single-hub file instead."
echo " Previous file kept at {{config_dir}}/contributor.env.bak."
fi
fi
cat > "{{config_dir}}/contributor.env" <<EOF
HIVE_REGISTRATION_TOKEN=${_OUT_TOKENS}
HIVE_HUB=${_OUT_HUBS}
CONTRIBUTOR_ID=${_OUT_IDS}
CONTRIBUTOR_USERNAME=${GH_USER}
AGENT_BACKEND={{backend}}
EOF
# contributor.env holds HIVE_REGISTRATION_TOKEN, the sole long-lived
# bearer credential for the contributor WebSocket. Match the 0600 perms
# of its sibling secret files (gh-auth.env, claude-config.json) so the
# token is not left world-readable at the default umask (0644).
chmod 600 "{{config_dir}}/contributor.env"
fi
# Re-tighten on every run: existing users may already have a 0644 file
# created before this fix. Fix it in place if present.
chmod 600 "{{config_dir}}/contributor.env" 2>/dev/null || true
echo "${MSG} — ${GH_USER} (${CID})"
echo ""
# ── {{backend}} CLI readiness was already verified in the preflight
# above, before the credential was written and before this registration
# ran (see #2543). Nothing left to check here — just finalize backend-
# specific local state.
echo "✓ {{backend}} CLI: verified during preflight."
# Persist the LiteLLM endpoint (never the API key) for later runs
if [[ "{{backend}}" == "litellm" && -f "{{config_dir}}/contributor.env" ]]; then
grep -v '^HIVE_LITELLM_ENDPOINT=' "{{config_dir}}/contributor.env" > "{{config_dir}}/contributor.env.tmp" || true
echo "HIVE_LITELLM_ENDPOINT=${HIVE_LITELLM_ENDPOINT}" >> "{{config_dir}}/contributor.env.tmp"
mv "{{config_dir}}/contributor.env.tmp" "{{config_dir}}/contributor.env"
# The rewrite recreates the file at the default umask (0644), dropping
# the 0600 perms. Re-tighten so the token stays owner-only.
chmod 600 "{{config_dir}}/contributor.env"
fi
# Copy CLI config for Docker container (Colima can't bind-mount files)
if [[ "{{backend}}" == "claude" ]] && [[ -f "${HOME}/.claude.json" ]]; then
cp "${HOME}/.claude.json" "{{config_dir}}/claude-config.json"
chmod 600 "{{config_dir}}/claude-config.json"
echo "Claude config staged for Docker container."
fi
echo ""
echo "✓ Setup complete!"
echo " GitHub: ${GH_USER}"
echo " CLI: {{backend}}"
echo " Hub: ${_HUB:-{{hive_hub}}}"
echo ""
echo "Run 'just contribute-hive' to start contributing."
# Move an ALREADY-REGISTERED contributor identity onto this machine (#4408).
# Usage: HIVE_HUB=wss://a/contribute,wss://b/contribute just contribute-move claude
# just contribute-move claude (re-uses the hubs already in contributor.env)
#
# WHY THIS EXISTS. contribute-setup can only bootstrap a NEW identity: the
# register endpoint is unauthenticated, so it refuses — correctly — to hand an
# existing contributor's token to whoever asks for it. On a second machine that
# left `ERROR: Already registered but no local config found.` and nothing else.
# Nothing in hive prevents running the relay from a different machine (auth is a
# plain token-hash lookup: no device binding, no IP pinning, no session
# affinity) — only the setup path was missing.
#
# WHAT IT DOES. Everything contribute-setup does — backend preflight, gh auth,
# gh-auth.env, CLI config staging — except that instead of REGISTERING it
# REISSUES, once per hub, proving identity with your GitHub token
# (POST /api/contribute/reissue-token). It then writes contributor.env with the
# positional HIVE_HUB / HIVE_REGISTRATION_TOKEN / CONTRIBUTOR_ID lists aligned
# in the SAME ORDER, which is what bin/contributor-relay.js pairs by index. A
# multi-hive contributor doing this by hand had to rotate per hub and rebuild
# those lists without transposing them; the relay refuses to start if the
# lengths disagree and misbehaves silently if the order is wrong.
#
# THIS ROTATES. Reissuing overwrites the stored hash, so the old machine's relay
# stops authenticating the moment this succeeds. That is the point — it is what
# makes the move safe to run from a machine you do not fully trust yet. If you
# want to KEEP the credential and switch back and forth, copy contributor.env
# and gh-auth.env (mode 0600) instead; the hub stores only a hash and clears the
# plaintext after first read, so a copy is the only way to reuse a token.
#
# SECURITY: this sends your GitHub token to each hub. contribute-setup
# deliberately does NOT (H7/CWE-522) because it derives the hub URL from a
# registry entry's dashboardUrl, which a poisoned registry controls. Here the
# URL comes from YOUR HIVE_HUB or YOUR existing contributor.env, and
# reissue-token authenticates by GitHub token by design — so the token has to
# go. The two guards below are what keep that honest: TLS is required for any
# non-loopback host, and every receiving host is printed and confirmed before
# anything is sent. HIVE_MOVE_ASSUME_YES=1 skips the prompt for scripted runs.
contribute-move backend="claude": check-version (contribute-check-backend backend)
#!/usr/bin/env bash
set -euo pipefail
for _tool in curl jq; do
if ! command -v "$_tool" &>/dev/null; then
echo "ERROR: ${_tool} not found — required to talk to the hub."
exit 1
fi
done
CONF="{{config_dir}}/contributor.env"
mkdir -p "{{config_dir}}"
if [ -f "{{config_dir}}/profiles.yml" ] && [ "${HIVE_FORCE_MOVE:-}" != "1" ]; then
echo "ERROR: {{config_dir}}/profiles.yml exists, so contributor.env is generated by hivectl profiles."
echo " 'just contribute-move' rewrites contributor.env behind profiles.yml and is unsafe here."
echo " Use: hivectl hives reissue <name> --hub <wss://hive/contribute>"
echo " Or move a credential with: hivectl hives export ...; hivectl hives import ..."
echo " Set HIVE_FORCE_MOVE=1 only if you intentionally want the legacy contributor.env rewrite."
exit 1
fi
echo "=== Move contributor identity to this machine (ClankeR) ==="
echo "✓ Preflight passed — {{backend}} CLI is ready."
echo ""
# ── Which hubs ──
# Read the existing file with grep rather than `source`: sourcing would
# clobber the caller's HIVE_HUB (making the two sources indistinguishable)
# and would execute whatever the file contains.
_ENV_HUBS="${HIVE_HUB:-}"
_FILE_HUBS=""; _FILE_USER=""
if [[ -f "$CONF" ]]; then
_FILE_HUBS=$(grep -m1 '^HIVE_HUB=' "$CONF" | cut -d= -f2- || true)
_FILE_USER=$(grep -m1 '^CONTRIBUTOR_USERNAME=' "$CONF" | cut -d= -f2- || true)
fi
if [[ -n "$_ENV_HUBS" ]]; then
HUBS="$_ENV_HUBS"; HUBS_FROM="HIVE_HUB"
elif [[ -n "$_FILE_HUBS" ]]; then
HUBS="$_FILE_HUBS"; HUBS_FROM="the existing contributor.env"
else
echo "ERROR: no hubs to move. Set HIVE_HUB to the hive(s) you contribute to:"
echo " export HIVE_HUB=wss://<hive>/contribute"
echo " or, for more than one, comma-separated in the order you want them:"
echo " export HIVE_HUB=wss://<hive-a>/contribute,wss://<hive-b>/contribute"
echo ""
echo " Your hub URLs are the HIVE_HUB line of contributor.env on the old"
echo " machine, or the 'Connect' snippet on each hive's /contribute page."
exit 1
fi
declare -a HUB_LIST=()
while IFS= read -r _h; do
_h="$(printf '%s' "$_h" | tr -d '[:space:]')"
[[ -n "$_h" ]] && HUB_LIST+=("$_h")
done < <(printf '%s\n' "$HUBS" | tr ',' '\n')
if [[ "${#HUB_LIST[@]}" -eq 0 ]]; then
echo "ERROR: could not parse any hub URL out of '${HUBS}' (from ${HUBS_FROM})."
exit 1
fi
# ── Step 1: GitHub authentication (same as contribute-setup) ──
echo "── Step 1/3: GitHub Authentication ──"
if ! command -v gh &>/dev/null; then
echo "ERROR: gh CLI not found. Install: brew install gh"
exit 1
fi
if gh auth status &>/dev/null; then
GH_USER=$(gh api user --jq '.login' 2>/dev/null || echo "")
echo "Already authenticated as: ${GH_USER}"
else
echo "Logging into GitHub..."
gh auth login --web --scopes "repo,read:org"
GH_USER=$(gh api user --jq '.login' 2>/dev/null || echo "")
echo "Authenticated as: ${GH_USER}"
fi
GH_TOKEN=$(gh auth token 2>/dev/null || echo "")
if [[ -z "$GH_USER" || -z "$GH_TOKEN" ]]; then
echo "ERROR: could not read your GitHub identity or token from gh."
echo " Try: gh auth login --web --scopes 'repo,read:org'"
exit 1
fi
# gh-auth.env is the second file contribute-hive hard-requires, and nothing
# used to say so — it exits with the same "Not set up yet. Run: just
# contribute-setup <cli>" when it is missing, pointing at the command that
# cannot run on this machine. Written here so the move produces a machine
# that can actually start the relay.
echo "GH_TOKEN=${GH_TOKEN}" > "{{config_dir}}/gh-auth.env"
chmod 600 "{{config_dir}}/gh-auth.env"
if [[ -n "$_FILE_USER" && "$_FILE_USER" != "$GH_USER" ]]; then
echo ""
echo "WARNING: the contributor.env already here belongs to ${_FILE_USER},"
echo " but gh is authenticated as ${GH_USER}. Continuing would"
echo " replace it with ${GH_USER}'s identity."
fi
echo ""
# ── Step 2: confirm, then reissue per hub ──
echo "── Step 2/3: Reissue (${#HUB_LIST[@]} hub(s), from ${HUBS_FROM}) ──"
declare -a HTTP_LIST=()
for _hub in "${HUB_LIST[@]}"; do
_http=$(printf '%s' "$_hub" | sed 's|^wss://|https://|;s|^ws://|http://|;s|/contribute$||')
case "$_http" in
https://*) : ;;
http://127.0.0.1*|http://localhost*|http://[::1]*|http://0.0.0.0*)
# Loopback only: a plaintext hop that never leaves the machine. Any
# other http:// host would put the GitHub token on the wire in clear.
: ;;
http://*)
echo "ERROR: ${_hub} is not TLS-protected."
echo " Reissuing sends your GitHub token to that host, so this"
echo " refuses anything but wss:// (or loopback for local testing)."
exit 1 ;;
*)
echo "ERROR: could not derive an HTTP base from '${_hub}'."
echo " Expected wss://<host>/contribute."
exit 1 ;;
esac
HTTP_LIST+=("$_http")
done
echo "Your GitHub token will be sent to, and a NEW registration token issued by:"
for _http in "${HTTP_LIST[@]}"; do echo " - ${_http}"; done
echo ""
echo "This ROTATES the credential. Any relay still running elsewhere with the"
echo "old token stops authenticating as soon as this completes."
if [[ "${HIVE_MOVE_ASSUME_YES:-}" != "1" ]]; then
# `|| _ANS=""` matters: read returns non-zero at EOF (a piped or
# </dev/null stdin), and under `set -e` that would kill the recipe before
# it could say why it stopped.
read -r -p "Continue? [y/N] " _ANS || _ANS=""
if [[ ! "${_ANS:-}" =~ ^[Yy]$ ]]; then
echo "Aborted — nothing was sent and nothing was changed."
exit 1
fi
fi
echo ""
# Rotations take effect on the hub as they happen, so a later failure must
# NOT discard the tokens already issued — that would lock the contributor
# out of the hubs that succeeded, with no way to reprint those tokens. Every
# success is written; the failures are named and the exit status is red.
NEW_TOKENS=""; NEW_IDS=""; OK_HUBS=""; FAILED=""
for _i in "${!HUB_LIST[@]}"; do
_hub="${HUB_LIST[$_i]}"; _http="${HTTP_LIST[$_i]}"
printf ' %s ... ' "$_http"
# Deliberately NOT `curl -f`: -f discards the response body on an HTTP
# error, and the body is where the hub says WHY — "Not registered as a
# contributor", "Account revoked", "Invalid or missing GitHub token".
# Those three need three different actions, and collapsing them into one
# "request failed" is what sends people to read the Go source.
_BODY=$(mktemp)
_CODE=$(curl -s --max-time 20 -o "$_BODY" -w '%{http_code}' \
-X POST "${_http}/api/contribute/reissue-token" \
-H "Authorization: Bearer ${GH_TOKEN}" 2>/dev/null) || _CODE="000"
_RESP=$(cat "$_BODY"); rm -f "$_BODY"
if [[ "$_CODE" == "000" ]]; then
echo "FAILED (unreachable)"
FAILED="${FAILED}\n ${_hub} — could not connect"
continue
fi
_T=""; _C=""; _E=""
if printf '%s' "$_RESP" | jq empty 2>/dev/null; then
_T=$(printf '%s' "$_RESP" | jq -r '.registration_token // empty')
_C=$(printf '%s' "$_RESP" | jq -r '.contributor_id // empty')
_E=$(printf '%s' "$_RESP" | jq -r '.error // .message // empty')
else
_E="non-JSON response: ${_RESP:0:120}"
fi
if [[ "$_CODE" != "200" || -z "$_T" || -z "$_C" ]]; then
echo "FAILED (HTTP ${_CODE})"
FAILED="${FAILED}\n ${_hub} — HTTP ${_CODE}: ${_E:-no token in response}"
continue
fi
echo "reissued (${_C})"
OK_HUBS="${OK_HUBS:+${OK_HUBS},}${_hub}"
NEW_TOKENS="${NEW_TOKENS:+${NEW_TOKENS},}${_T}"
NEW_IDS="${NEW_IDS:+${NEW_IDS},}${_C}"
done
echo ""
if [[ -z "$OK_HUBS" ]]; then
echo "ERROR: no hub reissued a token — contributor.env was NOT written."
printf 'Failures:%b\n' "$FAILED"
echo ""
echo " * 'Not registered as a contributor' means this identity has no profile"
echo " on that hive yet — that is a first-time setup: just contribute-setup {{backend}}"
echo " * A 401 means gh is authenticated as someone else, or the token lacks"
echo " the read:org scope."
exit 1
fi
# ── Step 3: write contributor.env, preserving hub ORDER and extra keys ──
echo "── Step 3/3: Writing ${CONF} ──"
if [[ -f "$CONF" ]]; then
cp "$CONF" "${CONF}.bak"
chmod 600 "${CONF}.bak"
echo " previous file kept at ${CONF}.bak"
fi
# Keys this recipe owns are rewritten; anything else an operator or an
# earlier setup put in the file (HIVE_LITELLM_ENDPOINT, for one) is carried
# across verbatim rather than silently dropped.
EXTRA=""
if [[ -f "${CONF}.bak" ]]; then
EXTRA=$(grep -vE '^(HIVE_REGISTRATION_TOKEN|HIVE_HUB|CONTRIBUTOR_ID|CONTRIBUTOR_USERNAME|AGENT_BACKEND)=' "${CONF}.bak" || true)
fi
{
printf 'HIVE_REGISTRATION_TOKEN=%s\n' "$NEW_TOKENS"
printf 'HIVE_HUB=%s\n' "$OK_HUBS"
printf 'CONTRIBUTOR_ID=%s\n' "$NEW_IDS"
printf 'CONTRIBUTOR_USERNAME=%s\n' "$GH_USER"
printf 'AGENT_BACKEND=%s\n' "{{backend}}"
[[ -n "$EXTRA" ]] && printf '%s\n' "$EXTRA"
} > "$CONF"
chmod 600 "$CONF"
# Same CLI config staging contribute-setup does (Colima cannot bind-mount files).
if [[ "{{backend}}" == "claude" ]] && [[ -f "${HOME}/.claude.json" ]]; then
cp "${HOME}/.claude.json" "{{config_dir}}/claude-config.json"
chmod 600 "{{config_dir}}/claude-config.json"
echo " Claude config staged for the container."
fi
echo ""
if [[ -n "$FAILED" ]]; then
echo "⚠ Moved with errors."
printf ' These hubs did NOT rotate and are not in contributor.env:%b\n' "$FAILED"
echo " The ones that did are written and usable. Re-run to retry the rest."
else
echo "✓ Move complete!"
fi
echo " GitHub: ${GH_USER}"
echo " CLI: {{backend}}"
echo " Hubs: ${OK_HUBS}"
echo ""
echo "The old machine's relay no longer authenticates. Stop it and delete"
echo "${CONF} and {{config_dir}}/gh-auth.env there."
echo ""
echo "Run 'just contribute-hive' to start contributing from here."
if [[ -n "$FAILED" ]]; then exit 1; fi
# Start contributing — containerized (default; docker or podman) or local mode
# Usage: just contribute-hive (container, default CLI from setup)
# just contribute-hive copilot (container, copilot backend)
# just contribute-hive claude local (UNCONFINED, on your host — see #4918)
#
# Container mode is the default and is the confined one. Local mode runs the
# backend CLI as your own user on your own machine with permission gating
# bypassed and no workspace confinement; the recipe warns about that at launch.
# Runtime: auto-detects docker then podman (discovery order, not posture —
# see src/docs/podman-rootless-ci.md); force with HIVE_CONTAINER_RUNTIME=podman
contribute-hive backend="" mode="docker": check-version
#!/usr/bin/env bash
set -euo pipefail
if [[ ! -f "{{config_dir}}/contributor.env" ]]; then
echo "Not set up yet. Run: just contribute-setup <cli>"
exit 1
fi
if [[ ! -f "{{config_dir}}/gh-auth.env" ]]; then
echo "Not set up yet. Run: just contribute-setup <cli>"
exit 1
fi
set -a
source "{{config_dir}}/gh-auth.env"
source "{{config_dir}}/contributor.env"
set +a
# Handle "just contribute-hive local" (backward compat)
_BACKEND="{{backend}}"
_MODE="{{mode}}"
if [[ "$_BACKEND" == "local" || "$_BACKEND" == "docker" ]]; then
_MODE="$_BACKEND"
_BACKEND=""
fi
if [[ -n "$_BACKEND" ]]; then
BACKEND="$_BACKEND"
else
BACKEND="${AGENT_BACKEND:-claude}"
fi
export AGENT_BACKEND="$BACKEND"
# Bob has no headless credential other than its API key — without it the
# agent would launch and sit at Bob's key prompt forever. Fail fast.
if [[ "$BACKEND" == "bob" && -z "${BOBSHELL_API_KEY:-}" ]]; then
echo "ERROR: BOBSHELL_API_KEY not set — Bob cannot authenticate."
echo " export BOBSHELL_API_KEY=your-bob-api-key"
echo " then re-run: just contribute-hive bob"
exit 1
fi
if [[ "$BACKEND" == "pi" ]]; then
if ! PI_READINESS=$(node bin/pi-backend.js "${AGENT_MODEL:-}" 2>&1); then
echo "ERROR: ${PI_READINESS}"
echo " export AGENT_MODEL=provider/model"
exit 1
fi
echo "HIVE_BACKEND_READINESS=${PI_READINESS}"
# This recipe runs in its own shell, so narrowing the environment does not
# alter the contributor's login shell. It does ensure both local relay/CLI
# launches and the container path can see only the selected provider's
# official credential variables.
while IFS= read -r name; do
if [[ -n "$name" ]]; then unset "$name"; fi
done < <(node bin/pi-backend.js --unselected-env-names "${AGENT_MODEL}")
fi
echo "=== Hive Contributor Agent (ClankeR) ==="
echo "Backend: ${BACKEND}"
# The SOURCED value, not {{hive_hub}}. `hive_hub := env("HIVE_HUB", …)` is
# resolved by just at PARSE time, from the environment just itself was
# started with — but HIVE_HUB arrives only when this recipe sources
# contributor.env above. Interpolating {{hive_hub}} here therefore printed
# the built-in default on every machine whose hub comes from the config
# file, i.e. every hosted spoke, while the relay connected somewhere else
# entirely. Same shape as the ${_HUB:-{{hive_hub}}} fallback contribute-setup
# already uses.
echo "Hub: ${HIVE_HUB:-{{hive_hub}}}"
echo "GitHub: $(gh api user --jq '.login' 2>/dev/null || echo 'authenticated')"
echo ""
if [[ "$_MODE" == "local" ]]; then
# ── Local mode: tmux session + relay (same as container, but on host) ──
#
# SAY WHAT THIS MODE COSTS (#4918). The backend CLI runs as THIS user on
# THIS machine. Claude-family agents now get Claude Code's native OS
# sandbox on this path, and Codex keeps its workspace-write sandbox.
# Backends without a sandbox (and either backend's explicit dangerous
# bypass) remain unconfined. Container mode — the DEFAULT, which is why
# the recipe's `mode` parameter defaults to "docker" — remains the
# backend-independent boundary.
#
# An operator picking `local` was previously told nothing about that
# difference: the recipe's own usage line described it only as "native
# mode". #4918 is what the silence cost. An agent doing entirely correct
# work on an assigned third-party repo ran that repo's own test suite; a
# latent defect in two of its tests let a hook escape its stubs and call
# `rpm-ostree kargs --append-if-missing=...` against the operator's REAL
# deployment, raising three polkit dialogs on their desktop. Nothing was
# written, and the only reason is that the process happened to lack
# privilege. No compromise was involved.
#
# The message names what IS still constrained, deliberately, so it reads
# as a boundary statement rather than an alarm. The agent_sandbox Podman
# path is hub-side only; it does not exist on this contributor path.
_local_truthy() {
case "${1:-}" in 1|true|TRUE|yes|YES|on|ON) return 0 ;; *) return 1 ;; esac
}
# Three postures, not two — an operator reading this banner needs to
# know which one they're actually getting:
# sandboxed — an OS-enforced filesystem boundary (claude/litellm's
# native sandbox, codex/copilot's own sandbox modes)
# denylisted — a command-name floor with NO filesystem boundary
# (opencode's permission.bash denials); real, but not a
# sandbox, and saying "confined" here would be exactly
# the overclaim #4918 is about
# unconfined — nothing at all unless the operator opted in
_LOCAL_POSTURE="unconfined"