Repository navigation
Expand file tree
/
Copy pathcli.sh
More file actions
1328 lines (1281 loc) · 71.2 KB
/
Copy pathcli.sh
File metadata and controls
1328 lines (1281 loc) · 71.2 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
#!/bin/sh
# ──────────────────────────────────────────────────────────────────────
# KiroCrew CLI installer (channel / wheel based).
#
# curl -fsSL https://download.crew.kiro.dev/cli.sh | sh
# curl -fsSL https://download.crew.kiro.dev/cli.sh | sh -s -- --channel nightly
#
# Installs the prebuilt `kirocrew` wheel for a release channel. It resolves the
# channel feed, verifies its RSA-SHA256 signature against the public key pinned
# below, downloads the wheel over HTTPS from CloudFront, verifies its SHA-256
# against the signed digest, then installs it (pipx if available, else a managed
# venv). No unsigned/checksum-only fallback exists. Unlike install.sh (which
# builds from a git clone), this pulls the published wheel.
#
# Python: provisions a python-build-standalone CPython via a SHA-256-pinned uv
# into a user-owned directory (the default) — no package manager, no sudo,
# works on old-glibc distros (CentOS 7). Opt out with --system-python to run on
# a system interpreter (>=3.12) instead; the choice is recorded and survives
# updates. If the managed default cannot be provisioned (no network), the run
# falls back to a usable system interpreter rather than failing.
#
# Dependencies: installed from prebuilt wheels ONLY (`pip --only-binary=:all:`),
# so the install never needs a C compiler or -dev headers. A host no wheel of a
# dependency can run on fails with a supported-platform message before any
# build starts; KIROCREW_ALLOW_SOURCE_BUILDS=1 opts back into compiling.
#
# Options / env:
# --channel <nightly|insider|stable> (default: stable; env KIROCREW_CHANNEL)
# --version <X.Y.Z> pin an exact version, verified against
# its immutable signed CLI manifest
# --cdn <base-url> (default CloudFront; env KIROCREW_CDN_BASE)
# --managed-python force the managed default back on after
# a recorded --system-python opt-out
# (env KIROCREW_MANAGED_PYTHON=1)
# --system-python run on the system interpreter instead
# of the managed default (sticky: later
# runs and updates keep the choice)
# (env KIROCREW_MANAGED_PYTHON=0)
# ──────────────────────────────────────────────────────────────────────
set -eu
# Isolate the managed venv from any inherited PYTHONPATH/PYTHONHOME. If the
# caller's environment points these at foreign site-packages (e.g. another
# app's interpreter on a different Python version), pip treats those packages
# as already satisfied and silently skips installing our dependencies into the
# venv -- producing a broken install (ImportError: No module named 'aiohttp').
unset PYTHONPATH PYTHONHOME
# The URL contract splits by class: FEED_BASE serves the mutable pointers
# (latest-cli.json), ARTIFACT_BASE serves the bytes (wheels, cli-manifest.json).
# Both are aliases of the same distribution today; --cdn / KIROCREW_CDN_BASE
# overrides BOTH (test / alternate-CDN escape hatch).
FEED_BASE="${KIROCREW_CDN_BASE:-https://updates.crew.kiro.dev}"
ARTIFACT_BASE="${KIROCREW_CDN_BASE:-https://download.crew.kiro.dev}"
CHANNEL="${KIROCREW_CHANNEL:-stable}"
PIN_VERSION=""
# Three states: "" = undecided (fall back to the persisted python-mode marker,
# then to the managed default), "1" = managed, "0" = system. An explicit env
# value or flag always outranks the marker, so an operator can override a
# sticky choice.
MANAGED_PYTHON="${KIROCREW_MANAGED_PYTHON:-}"
# Pinned uv release used to provision a managed Python interpreter when the
# system has none (or when --managed-python asks for one). uv is only ever
# fetched as a tarball and verified against these SHA-256 digests — the
# astral.sh install script is never piped into a shell, keeping the signed
# installer's no-unsigned-third-party-script invariant. Bump the version and
# all four digests together (each uv release publishes <asset>.tar.gz.sha256
# files beside the tarballs). Linux pins the musl builds: they are fully
# static, so they run on any glibc age — old distros are the whole point of
# this path.
UV_VERSION="0.10.11"
UV_PYTHON_SERIES="cpython-3.12"
UV_SHA_LINUX_X64="d78246139dc6cf3ed6d03c84da762686bced7ad1de67977ee372a45b95a1f6d0"
UV_SHA_LINUX_ARM64="5d80a7f6343d2676dfde1e5126582070a2bbc62df6f60d5527a169be3788532a"
UV_SHA_MACOS_X64="ff90020b554cf02ef8008535c9aab6ef27bb7be6b075359300dec79c361df897"
UV_SHA_MACOS_ARM64="437a7d498dd6564d5bf986074249ba1fc600e73da55ae04d7bd4c24d5f149b95"
# Offline trust root for CLI artifact manifests. These two values are replaced
# together during the operational KMS-key enablement documented in
# packaging/signing/README.md, and must stay in lockstep with
# packaging/signing/cli-manifest-public.pem -- KEY_ID is the SHA-256 of that
# PEM's DER encoding, so a mismatched pair fails closed before any network I/O.
# Never edit these in place to rotate: schema v1 pins exactly one key, so an
# in-place swap breaks every already-deployed installer. Rotation requires the
# dual-trust sequence in packaging/signing/README.md.
CLI_MANIFEST_KEY_ID="sha256:d3a83f0c1ff84a2cbee6bd34d889d8725af34358148a6c18ed3ecbbbcceec06b"
CLI_MANIFEST_PUBLIC_KEY_B64="LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlJQm9qQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FZOEFNSUlCaWdLQ0FZRUF0MnR0NnZ3ZFZ4Z0tWbTRGQVdkeApwZjZFckx3Y2ljUHlHUGh2SXdXRTRqNmg1YjlwMzFiaktMaWlEakxvK3VpQUJPL21vUjdJUUtoaUNSaXY0d0dTCk1mYnd2ZnNhLy8xNlVBbkNURkRDb1pId0IwVm93cTRYWjZ1NHBrdTFqNlBlRXBMNjVqRXZvcjd1a29HS2xiOVMKQlBva01aN0VtYlpWbmJiSWJBVXYrZ0NWajRCWDRpam5GWkJEMmNPcmtkQWdGR3UraU9jRHVlRDNqTExicXVhUwp0K0tLWXltQ2VxaitPazZ0OFBMQ2VRZmYrWVc4YS9wRU03Wm1tMTJ0Y3BRdEF0OHVCSVdkZE9qaTN1c3BhVlA3CkZJUlhzNnJIajIwTDd0dE9kMGpmKzRWQ0ZtV09FWE4rNWc0YS8rNkcrc3lxeDk4VlR2RVF5cDZVdWZnb0FoQkMKLzFVNG5XajdmMVRFQkV4dXBSRXFUK1lmUmp6aFJUR2NGN0czRUp3MmZjUU1taElIdFpVanM3endVY3NmblhDMwpGQzJBR3pBZnExSGV0WHU5amFOQWZSdjdLZXYxT2hvVmMzYUlONEd3UkpZRDNPNUFSQk5SRGpQUVFWUHBaVW5rCjB1WVdpZExSVDVRUVZMYnlSLzJFKytqTWFyRXBkVXRkZGY1anlwZW5pbFhUQWdNQkFBRT0KLS0tLS1FTkQgUFVCTElDIEtFWS0tLS0tCg=="
while [ $# -gt 0 ]; do
case "$1" in
--channel) CHANNEL="${2:?--channel needs a value}"; shift 2 ;;
--channel=*) CHANNEL="${1#*=}"; shift ;;
--version) PIN_VERSION="${2:?--version needs a value}"; shift 2 ;;
--version=*) PIN_VERSION="${1#*=}"; shift ;;
--cdn) FEED_BASE="${2:?--cdn needs a value}"; ARTIFACT_BASE="$2"; shift 2 ;;
--cdn=*) FEED_BASE="${1#*=}"; ARTIFACT_BASE="${1#*=}"; shift ;;
--managed-python) MANAGED_PYTHON=1; shift ;;
--system-python) MANAGED_PYTHON=0; shift ;;
-h|--help)
cat <<'EOF'
KiroCrew CLI installer (channel / wheel based).
curl -fsSL https://download.crew.kiro.dev/cli.sh | sh
curl -fsSL https://download.crew.kiro.dev/cli.sh | sh -s -- --channel nightly
Installs the prebuilt `kirocrew` wheel for a release channel: resolves the
channel feed, verifies its signature against the installer-pinned public key,
downloads the wheel over HTTPS, verifies its SHA-256 against the signed digest,
then installs it (pipx if available, else a managed venv BESIDE the data home —
"$KIROCREW_HOME"-venv or ~/.kiro/crew-venv, never inside the data home itself).
Records the channel in the data home. There is no unsigned fallback.
Options / env:
--channel <nightly|insider|stable> (default: stable; env KIROCREW_CHANNEL)
--version <X.Y.Z> pin an exact version, verified against
its immutable signed CLI manifest
--cdn <base-url> (default CloudFront; env KIROCREW_CDN_BASE)
--managed-python force the managed default back on after
a recorded --system-python opt-out
(env KIROCREW_MANAGED_PYTHON=1)
--system-python run on the system interpreter (>=3.12)
instead of the managed default (sticky:
later runs and updates keep the choice)
(env KIROCREW_MANAGED_PYTHON=0)
KIROCREW_VENV override the managed venv location
KIROCREW_PYTHON_DIR override where uv-provisioned interpreters
are stored (default: beside the data home,
~/.kiro/crew-python)
KIROCREW_UV_URL mirror base for the uv release tarballs
(default: the uv GitHub release tree; the
SHA-256 pin is enforced either way)
UV_PYTHON_INSTALL_MIRROR mirror for the python-build-standalone
interpreter downloads (read by uv)
KIROCREW_ALLOW_SOURCE_BUILDS=1 let pip compile a dependency that has no
prebuilt wheel for this host (needs a C
toolchain and -dev headers); by default
the install refuses instead of building
KIROCREW_INSTALL_PLAIN=1 plain one-line-per-step output on a
terminal (no redrawn progress line)
EOF
exit 0 ;;
*) echo "kirocrew-install: unknown argument '$1'" >&2; exit 2 ;;
esac
done
FEED_BASE="${FEED_BASE%/}"
ARTIFACT_BASE="${ARTIFACT_BASE%/}"
err() { echo "kirocrew-install: $*" >&2; exit 1; }
# ── Progress output ──────────────────────────────────────────────────────────
# The slow steps (the wheel download, pip resolving and fetching every
# dependency, `python -m venv` running ensurepip) take a minute or more; a
# single static line in front of that silence reads as a hang. Under
# `curl ... | sh` only STDIN is the pipe; stdout is still the terminal, so the
# script can tell an interactive run from a logged one and draw accordingly.
#
# _tty 1 when stdout is a terminal that can take a redrawn line.
# CURL_PROGRESS curl's progress flag for artifact downloads: a progress bar
# on a terminal, silent (as before) in a log.
# _run_step LOG MSG CMD...
# runs CMD with stdout+stderr captured to LOG. On a terminal
# it redraws one line: spinner, MSG, elapsed seconds and the
# last line CMD wrote (pip's "Collecting ..." / "Downloading
# ..." lines, so the user sees which package it is on).
# Otherwise it prints one heartbeat line every 30 s so a CI
# log still shows the step is alive. Ends with a "done"/
# "FAILED" line and returns CMD's exit status; the caller
# reads LOG for the failure report, exactly as before.
# Ctrl-C (or TERM) during the step terminates CMD and
# everything CMD forked first (CMD runs in its own process
# group): CMD runs asynchronously, and POSIX starts an async
# child of a non-interactive shell with SIGINT ignored, so
# without this the keypress would stop the spinner while
# pip kept writing the venv. CMD then exits by signal
# (status 128+N), the same status a foreground pip returns
# for the keypress, so the caller's restore-then-err path
# runs unchanged.
# _rs_optional=1 set before a _run_step whose failure the caller
# tolerates ("Updating pip"): the closing line reads as a
# warning instead of FAILED. Consumed by the call.
# _tolerate RC for such a step: swallow an ordinary failure, but an
# interrupt (RC > 128) still ends the install (the
# rebuild's EXIT trap puts a moved-aside venv back).
_tty=0
if [ -t 1 ] && [ "${TERM:-dumb}" != "dumb" ] && [ -z "${KIROCREW_INSTALL_PLAIN:-}" ]; then
_tty=1
fi
CURL_PROGRESS="-s"
if [ "$_tty" = 1 ]; then CURL_PROGRESS="--progress-bar"; fi
_rs_optional=0
_rs_interrupt() {
# Runs from the INT/TERM trap while the step's child is alive: stop the
# whole step and let the wait below collect its signal status. The child
# was started as its own process group (see _run_step), so the negative
# pid reaches its descendants too -- `python -m venv` forks ensurepip and
# pip forks build helpers, and a survivor would keep writing into the tree
# the caller is about to replace.
_rs_sig="$1"
# Before the fork there is no child yet; _run_step stops it once it exists.
[ -n "$_rs_pid" ] || return 0
# `kill -s SIG -- -PGID`: the one spelling both bash and dash accept for a
# process group. Fall back to the pid alone if the group is refused.
kill -s TERM -- "-$_rs_pid" 2>/dev/null || kill -s TERM "$_rs_pid" 2>/dev/null || true
}
# The venv rebuild's rollback, run from the EXIT trap it arms for its whole
# span: an exit for ANY reason after the move-aside -- a signal (the INT,
# TERM and HUP traps in that span just exit), `set -e`, or a fatal shell
# error -- puts the previous install back. Gated on _VENV_MOVED, which is set
# only once the rename actually succeeded, so a stop before that never
# touches a venv this run did not move. A failure branch restores through
# _venv_restore_after_failure, which disarms this once its restore returns.
_venv_rollback_on_exit() {
# A second Ctrl-C or TERM must not abort the restore between its delete and
# its rename: that would leave no venv at all. The restore is short; an
# escalating supervisor still has SIGKILL.
trap '' INT TERM HUP
if [ "${_VENV_MOVED:-0}" = 1 ] && [ -d "${_VENV_BACKUP:-}" ]; then
if _restore_tree "$_VENV_BACKUP" "$VENV"; then
echo "interrupted; the previous install was restored and keeps working." >&2
else
echo "interrupted, and the previous install could not be restored from $_VENV_BACKUP." >&2
fi
fi
rm -rf "$TMP"
}
# A failure branch's restore of the moved-aside venv; reports and exits. The
# delete-then-rename runs with INT, TERM and HUP ignored, as in
# _venv_rollback_on_exit: a stop that exited between the two would leave no
# venv at all and the backup stranded. The EXIT rollback stands down only once
# this restore has returned, so it is never disarmed while the backup is out.
_venv_restore_after_failure() {
trap '' INT TERM HUP
# $1 reports a restore that worked, $2 one that did not.
_restore_tree "$_VENV_BACKUP" "$VENV" || shift
_VENV_MOVED=0
err "$1"
}
_tolerate() {
[ "$1" -le 128 ] && return 0
# An interrupt in a tolerated step still ends the install; the rebuild's
# EXIT trap puts the moved-aside venv back.
exit "$1"
}
_run_step() {
_rs_log="$1"; _rs_msg="$2"; shift 2
_rs_opt=$_rs_optional; _rs_optional=0
: > "$_rs_log"
# The command gets its own process group, so an interrupt can terminate it
# together with everything it forked (`python -m venv` forks ensurepip,
# pip forks build helpers). setsid(1) does that on any Linux userland,
# tty or not; where it is missing (macOS) job control does the same, and
# it is switched off again straight away because the rest of the script
# wants the default foreground-group behaviour. dash off a tty cannot
# enable job control and says so on stderr; that message is dropped and
# the pid-only kill fallback in _rs_interrupt covers the step.
# Own INT/TERM/HUP for the life of the step, taken BEFORE the fork so no
# signal reaches the caller's handler while a child is already writing.
# The caller's traps come back afterwards (the EXIT trap is untouched).
# The trap list goes through a file: dash prints nothing for `trap`
# inside a command substitution, so `$(trap)` would restore nothing. A
# signal the caller does not trap gets an explicit reset appended, so the
# restore is one `.` with no window at the default disposition.
_rs_sig=""
_rs_pid=""
_rs_traps="$TMP/.traps.$$"
trap > "$_rs_traps"
for _rs_s in INT TERM HUP; do
# bash outside POSIX mode prints SIGINT, every other shell INT.
grep -Eq " (SIG)?$_rs_s\$" "$_rs_traps" 2>/dev/null || echo "trap - $_rs_s" >> "$_rs_traps"
done
trap '_rs_interrupt INT' INT
trap '_rs_interrupt TERM' TERM
trap '_rs_interrupt HUP' HUP
if command -v setsid >/dev/null 2>&1; then
setsid "$@" > "$_rs_log" 2>&1 < /dev/null &
else
set -m 2>/dev/null || true
"$@" > "$_rs_log" 2>&1 < /dev/null &
set +m 2>/dev/null || true
fi
_rs_pid=$!
# A signal that landed between the takeover and the fork found no child to
# stop; stop the one that now exists.
if [ -n "$_rs_sig" ]; then _rs_interrupt "$_rs_sig"; fi
# No command substitution while the step owns INT: bash drops a trapped
# SIGINT that lands while it waits on a `$(...)`, so a Ctrl-C there would
# stop nothing. Each value goes through a scratch file and `read` instead.
_rs_out="$TMP/.rs_out.$$"
date +%s > "$_rs_out" 2>/dev/null || :
IFS= read -r _rs_start < "$_rs_out" || :
{ tput cols 2>/dev/null || echo "${COLUMNS:-80}"; } > "$_rs_out"
IFS= read -r _rs_cols < "$_rs_out" || :
case $_rs_cols in ''|*[!0-9]*) _rs_cols=80 ;; esac
_rs_i=0
_rs_beat=0
while kill -0 "$_rs_pid" 2>/dev/null; do
date +%s > "$_rs_out" 2>/dev/null || :
IFS= read -r _rs_now < "$_rs_out" || :
_rs_el=$(( _rs_now - _rs_start ))
if [ "$_tty" = 1 ]; then
case $((_rs_i % 4)) in
0) _rs_f='|' ;; 1) _rs_f='/' ;; 2) _rs_f='-' ;; *) _rs_f='\' ;;
esac
# Everything on the redrawn line is trimmed to the terminal: a line
# that wraps is not replaced by the next \r, it stacks up a new row
# per redraw. Width comes from tput, then $COLUMNS, then 80. The
# prefix is cut to fit first (a very narrow terminal), and the tail
# gets whatever room the prefix leaves.
printf "%.$((_rs_cols - 1))s\n" "$_rs_f $_rs_msg (${_rs_el}s)" > "$_rs_out"
IFS= read -r _rs_head < "$_rs_out" || :
_rs_room=$(( _rs_cols - 1 - ${#_rs_head} - 2 ))
_rs_last=""
if [ "$_rs_room" -ge 8 ]; then
tail -n 1 "$_rs_log" 2>/dev/null | tr -d '\r' \
| sed 's/^[[:space:]]*//' | cut -c1-"$_rs_room" > "$_rs_out"
IFS= read -r _rs_last < "$_rs_out" || :
fi
printf '\r\033[K%s%s' "$_rs_head" "${_rs_last:+ $_rs_last}"
_rs_i=$((_rs_i + 1))
# Fractional sleep is not POSIX; sleep 1 when this sleep lacks it.
sleep 0.25 2>/dev/null || sleep 1
else
if [ $((_rs_el - _rs_beat)) -ge 30 ]; then
_rs_beat=$_rs_el
echo "$_rs_msg ... still running (${_rs_el}s)"
fi
sleep 1
fi
done
_rs_rc=0
wait "$_rs_pid" || _rs_rc=$?
. "$_rs_traps"
rm -f "$_rs_traps" "$_rs_out"
_rs_el=$(( $(date +%s) - _rs_start ))
if [ "$_tty" = 1 ]; then printf '\r\033[K'; fi
if [ -n "$_rs_sig" ]; then
echo "$_rs_msg ... interrupted (SIG$_rs_sig) after ${_rs_el}s" >&2
# A child that exited by signal reports 128+N; make sure an interrupt
# never reads as success even if the child swallowed the TERM.
[ "$_rs_rc" -gt 128 ] || _rs_rc=130
elif [ "$_rs_rc" -eq 0 ]; then
echo "$_rs_msg ... done (${_rs_el}s)"
elif [ "$_rs_opt" = 1 ]; then
echo "$_rs_msg ... skipped (exit $_rs_rc after ${_rs_el}s; continuing)" >&2
else
echo "$_rs_msg ... FAILED after ${_rs_el}s (exit $_rs_rc)" >&2
fi
return "$_rs_rc"
}
# Dependencies come from prebuilt wheels only. Left to itself, pip treats a
# dependency with no wheel for this host as something to BUILD from its
# sdist, and a native one (numpy, Pillow, cryptography, lxml) then needs a C
# toolchain and -dev headers the host was never required to have; the failure
# lands deep inside a compiler run, after the working venv has already been
# moved aside. The managed python-build-standalone interpreter runs on
# old-glibc distros, but each dependency's wheels carry their own glibc floor
# (their manylinux tag), which is the part that varies per host.
# `--only-binary=:all:` makes pip resolve the newest release of every
# dependency that publishes a wheel this host can run, and when no release
# does, fail before any build starts -- which _report_pip_failure turns into
# a supported-platform message. KIROCREW_ALLOW_SOURCE_BUILDS=1 is the opt-in
# for a host that has the toolchain and wants the compile fallback back.
PIP_BINARY_ONLY="--only-binary=:all:"
if [ "${KIROCREW_ALLOW_SOURCE_BUILDS:-0}" = "1" ]; then
PIP_BINARY_ONLY=""
fi
# "<OS> <arch>, <libc>" for the failure message. getconf answers on glibc;
# `ldd --version` covers a libc without it (musl prints its own banner); an
# unknown libc is simply left out rather than guessed.
_platform_description() {
_plat="$(uname -s) $(uname -m)"
_libc="$(getconf GNU_LIBC_VERSION 2>/dev/null || true)"
if [ -z "$_libc" ] && command -v ldd >/dev/null 2>&1; then
_libc="$(ldd --version 2>&1 | head -n 1 || true)"
fi
[ -n "$_libc" ] && _plat="$_plat, $_libc"
printf '%s' "$_plat"
}
# Report a failed pip/pipx install from its captured log ($1). The log tail
# always goes first so pip's own words stay visible. Under binary-only
# resolution, pip's "No matching distribution found" means no release it may
# install has a wheel this host can run. pip's "(from versions: ...)" list
# does not separate the causes: it only counts candidates that survived its
# link filter, so a package whose every wheel targets a newer libc or another
# arch reads "(from versions: none)" -- the same text as an index that does
# not carry the package or could not be reached -- and a numeric list means
# only versions outside the required range have a wheel here. So the report
# names the platform, the packages and the usual cause (a host older than
# the wheels' floor), says the index is the other possibility, and gives the
# way out -- a newer host, or an explicit opt-in to compile -- without
# claiming a verdict pip's text cannot support.
_report_pip_failure() {
if [ -s "$1" ]; then
echo "----- pip output (tail) -----" >&2
tail -n 30 "$1" >&2
echo "-----------------------------" >&2
fi
[ -n "$PIP_BINARY_ONLY" ] || return 0
grep -q 'No matching distribution found for' "$1" 2>/dev/null || return 0
_missing="$(grep 'No matching distribution found for' "$1" \
| sed 's/.*No matching distribution found for //' | head -n 5 | tr '\n' ' ')"
echo "kirocrew-install: pip found no prebuilt wheel it may install on this platform ($(_platform_description)) for: ${_missing:-a required dependency}" >&2
echo "kirocrew-install: Kiro Crew installs prebuilt wheels only and never compiles a dependency. Usually this means the host is older than the wheels' floor: the current dependency set needs a newer Linux (Amazon Linux 2023, RHEL/Rocky 8+, Ubuntu 22.04+, Debian 12+) on x86_64/aarch64, or macOS. It can also mean the package index could not be reached or does not carry these releases; pip's output above shows the retries or the versions it saw. To compile on this host instead, install a C/C++ toolchain and the -dev headers the packages above need, then re-run with KIROCREW_ALLOW_SOURCE_BUILDS=1." >&2
}
# Take the install lock held open on fd 9 (the caller has already run
# `exec 9>>"$lockfile"`), waiting up to $1 seconds for another installer run
# to finish. Two installer runs against one pipx venv must not interleave:
# each copies the venv aside, lets pipx mutate it, and on failure puts ITS
# copy back -- so a run that snapshotted the old venv, then failed after a
# sibling run had already updated it, would restore the stale copy over the
# sibling's finished update. pipx serializes only its own mutation, never our
# copy or our restore, so the whole copy/mutate/restore sequence is
# serialized here instead. flock(2) rather than a lock directory: the kernel
# drops the lock when the holder exits, however it exits, so a crashed run
# leaves nothing stale to detect or clean; the lock lives on the open file
# description, so once this child has taken it on the inherited fd the shell
# keeps holding it until the fd is closed. Only "already locked"
# (BlockingIOError) is waited on; any other error (the fd was not inherited)
# fails the call outright so a broken guarantee is never mistaken for a busy
# one. Exit 3 marks a timeout for the caller's message.
_wait_install_lock() {
"$PY" -c '
import fcntl, sys, time
deadline = time.monotonic() + float(sys.argv[1])
told = False
while True:
try:
fcntl.flock(9, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
if time.monotonic() >= deadline:
sys.exit(3)
if not told:
print("Another kirocrew installer run is working on this install; waiting for it to finish ...", flush=True)
told = True
time.sleep(0.5)
else:
sys.exit(0)
' "$1"
}
# Put a backup tree ($1) back at its original path ($2). Rename-first: the
# half-built tree at $2 is RENAMED aside, the backup renamed into place, and
# only then is the discarded tree deleted. Both renames are metadata-only, so
# a SIGKILL during the slow delete (a network home directory) leaves a
# working install plus a stray `.failed.*` sibling, never no install at all.
# If the rename aside is refused, fall back to deleting $2 in place.
# Succeeds only when the original path is GONE before the move: `mv` onto a
# directory that survived (an immutable file, a read-only remount after an
# I/O error, a mount point) nests the backup INSIDE it and still exits 0,
# which would read as a restore that never happened while `kirocrew` stays
# broken. A dangling symlink at the path fails `-e` yet would still make `mv`
# rename beside it, so `-L` is checked too. The caller reports the outcome.
_restore_tree() {
_rt_discard=""
if [ -e "$2" ] || [ -L "$2" ]; then
_rt_discard="${2%/}.failed.$$"
_rt_n=0
while [ -e "$_rt_discard" ] || [ -L "$_rt_discard" ]; do
_rt_n=$((_rt_n + 1))
_rt_discard="${2%/}.failed.$$.$_rt_n"
done
mv "${2%/}" "$_rt_discard" 2>/dev/null || { _rt_discard=""; rm -rf "$2" 2>/dev/null || true; }
fi
if [ -e "$2" ] || [ -L "$2" ]; then
return 1
fi
mv "$1" "$2" 2>/dev/null || return 1
if [ -n "$_rt_discard" ]; then
rm -rf "$_rt_discard" 2>/dev/null || true
fi
return 0
}
# The channel name IS the storage path segment: publish-cli.yml writes
# feed/<channel>/latest-cli.json and cli/<channel>/<version>/ using the literal
# channel, and "beta" was renamed to "insider" everywhere including the path
# segment (docs/build/release.md -> "Deliberately not built"). So there is
# no name-to-prefix mapping. Reject anything outside the known set here: a
# typo'd channel otherwise reaches the CDN and surfaces as an opaque 403.
case "$CHANNEL" in
nightly|insider|stable) ;;
*) err "unknown channel '$CHANNEL' (expected one of: nightly, insider, stable)" ;;
esac
CHANNEL_PATH="$CHANNEL"
# Canonical physical path of an EXISTING directory (symlinks and `..` resolved),
# or empty output when it cannot be resolved. Used to compare two directory
# paths for identity rather than string equality. Kept POSIX (`cd` + `pwd -P`)
# because `realpath`/`readlink -f` are not portable to macOS's base install.
_canon_dir() {
( cd "$1" 2>/dev/null && pwd -P ) 2>/dev/null || printf ''
}
# True when canonical path $1 IS $2 or is nested beneath it. Used to reject any
# overlap between the old and new venv trees before removing one of them:
# equality alone is not enough, because a nested override (KIROCREW_VENV pointing
# INSIDE the old venv) leaves the paths unequal while making `rm -rf` on the
# parent destroy the new installation. The prefix strip is quoted so the
# comparison stays literal rather than glob-matching a path with metacharacters.
_is_within() {
[ "$1" = "$2" ] && return 0
_within_rest="${1#"$2"/}"
[ "$_within_rest" != "$1" ]
}
[ "$CLI_MANIFEST_KEY_ID" != "UNCONFIGURED" ] \
&& [ "$CLI_MANIFEST_PUBLIC_KEY_B64" != "UNCONFIGURED" ] \
|| err "manifest signing trust root is not configured; refusing unsigned installation"
command -v curl >/dev/null 2>&1 || err "curl is required"
command -v openssl >/dev/null 2>&1 || err "openssl is required to verify the signed manifest"
if command -v sha256sum >/dev/null 2>&1; then SHA_CMD="sha256sum"
elif command -v shasum >/dev/null 2>&1; then SHA_CMD="shasum -a 256"
else err "need sha256sum or shasum to verify the download"; fi
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT INT TERM
# Kiro Crew needs Python >=3.12 at runtime -- that is what every release
# artifact bundles and the only version CI tests, and older published wheels'
# METADATA claimed a lower floor, so pip would install fine and then crash on
# first run. Prefer the exact interpreter the project builds and tests on
# (3.12); 3.13 is untested and only a last resort before bare python3, which
# itself only counts if >=3.12.
# A version-manager shim (mise, pyenv, asdf) can wedge instead of answering --
# notably when HOME does not hold the config the shim expects -- and an
# unbounded probe then hangs the whole install on its FIRST candidate,
# python3.12, leaving a spinning orphan behind. Bound it so a wedged candidate
# fails over to the next one. A healthy interpreter answers in well under a
# tenth of a second, so 5s is generous and caps the whole ladder at ~25s.
# `timeout` is absent from a stock macOS, so its absence must leave the probe
# working rather than fail every candidate.
_PY_PROBE_TIMEOUT=""
if command -v timeout >/dev/null 2>&1; then
_PY_PROBE_TIMEOUT="timeout 5"
fi
_py_usable() {
command -v "$1" >/dev/null 2>&1 || return 1
if [ -n "$_PY_PROBE_TIMEOUT" ]; then
# Unquoted on purpose: expands to two words, or to nothing when unavailable.
# shellcheck disable=SC2086
$_PY_PROBE_TIMEOUT "$1" -c 'import sys; raise SystemExit(0 if sys.version_info >= (3,12) else 1)' 2>/dev/null
return $?
fi
# No `timeout` binary (stock macOS): emulate the same 5s bound with a POSIX
# watchdog, so a wedged version-manager shim still fails over to the next
# candidate instead of hanging the install on its first probe.
"$1" -c 'import sys; raise SystemExit(0 if sys.version_info >= (3,12) else 1)' >/dev/null 2>&1 &
_py_pid=$!
(
# On TERM from the fast-exit path below, kill our own in-flight `sleep`
# before exiting: a plain kill on this subshell cannot reach its child,
# which would otherwise linger for up to a second. kill -9 is untrappable,
# so the parent must TERM us for this cleanup to run.
trap 'kill "${_wd_sleep:-}" 2>/dev/null || true; exit 0' TERM
_i=0
while [ "$_i" -lt 5 ]; do
sleep 1 & _wd_sleep=$!
wait "$_wd_sleep" 2>/dev/null || true
kill -0 "$_py_pid" 2>/dev/null || exit 0
_i=$((_i + 1))
done
kill -9 "$_py_pid" 2>/dev/null || true
) &
_watchdog_pid=$!
# Capture the probe's real exit status: a plain `wait ... || true` would
# overwrite $? and report every candidate as usable. 137 (killed by the
# watchdog) and 1 (version too old) must both read as "not usable".
_py_status=0
wait "$_py_pid" 2>/dev/null || _py_status=$?
kill "$_watchdog_pid" 2>/dev/null || true
wait "$_watchdog_pid" 2>/dev/null || true
return "$_py_status"
}
# Resolve the newest supported interpreter into PY (left empty if none found).
_resolve_python() {
for _c in python3.12 python3.13 python3; do
if _py_usable "$_c"; then PY="$_c"; return 0; fi
done
return 1
}
# ── Managed-Python provisioning (uv + python-build-standalone) ──────────────
# Provision a CPython interpreter without touching the system: uv is a static,
# dependency-free binary, and the interpreters it installs are prebuilt
# python-build-standalone archives that unpack into a user-owned directory —
# no package manager, no sudo, and they run on old-glibc distros (CentOS 7)
# whose base repos never reach 3.10. The pinned uv release is downloaded and
# verified against the SHA-256 digests above before it runs -- an installed
# `uv` on PATH is never consulted (see the note inside).
# Sets PY on success. Network/platform failures return 1 so the caller can
# print guidance; a digest mismatch is a security stop and errs immediately.
_provision_python_via_uv() {
_uv_bin=""
# An installed `uv` on PATH is deliberately NOT consulted. With managed as
# the default, every install and update run reaches this function, and PATH
# commonly leads with user-writable directories (~/.local/bin) that an agent
# session can write -- a planted `uv` shim would be executed by the user's
# own unattended update run. The pinned, SHA-256-verified tarball is the
# only uv this script runs. The download recurs on every managed run; a
# failure degrades to a usable system interpreter unless managed was
# explicitly requested this run (see the mode block below).
# GNU tar's -z shells out to gzip, so both must exist before downloading.
for _uv_tool in tar gzip; do
if ! command -v "$_uv_tool" >/dev/null 2>&1; then
echo "kirocrew-install: $_uv_tool is required to unpack uv" >&2
return 1
fi
done
case "$(uname -s)/$(uname -m)" in
Linux/x86_64) _uv_target="x86_64-unknown-linux-musl"; _uv_sha="$UV_SHA_LINUX_X64" ;;
Linux/aarch64|Linux/arm64) _uv_target="aarch64-unknown-linux-musl"; _uv_sha="$UV_SHA_LINUX_ARM64" ;;
Darwin/x86_64) _uv_target="x86_64-apple-darwin"; _uv_sha="$UV_SHA_MACOS_X64" ;;
Darwin/arm64) _uv_target="aarch64-apple-darwin"; _uv_sha="$UV_SHA_MACOS_ARM64" ;;
*)
echo "kirocrew-install: no pinned uv build for $(uname -s)/$(uname -m)" >&2
return 1 ;;
esac
echo "Downloading uv $UV_VERSION ($_uv_target) ..."
# -L: GitHub release assets redirect to a storage host; --proto-redir keeps
# every hop on HTTPS (curl's redirect default would also allow plain http).
# KIROCREW_UV_URL points air-gapped / proxied hosts at a mirror of the uv
# release tree; the SHA-256 pin below still applies, so a mirror can serve
# the bytes but never substitute them. (uv's own python-build-standalone
# download honors UV_PYTHON_INSTALL_MIRROR, which inherits through env.)
_uv_base="${KIROCREW_UV_URL:-https://github.com/astral-sh/uv/releases/download}"
# shellcheck disable=SC2086
curl -f $CURL_PROGRESS -S -L --proto '=https' --proto-redir '=https' \
"${_uv_base%/}/$UV_VERSION/uv-$_uv_target.tar.gz" \
-o "$TMP/uv.tar.gz" || return 1
_uv_got="$($SHA_CMD "$TMP/uv.tar.gz" | awk '{print $1}')"
[ "$_uv_got" = "$_uv_sha" ] \
|| err "uv download SHA-256 mismatch (expected $_uv_sha, got $_uv_got) — refusing to continue"
( cd "$TMP" && tar -xzf uv.tar.gz ) || return 1
_uv_bin="$TMP/uv-$_uv_target/uv"
[ -x "$_uv_bin" ] || return 1
# The interpreter store lives BESIDE the data home, like the managed venv and
# for the same blast-radius reason: no data-home-wide operation may ever
# reach the interpreter that the venv's shebangs point at.
_uv_data_home="${KIROCREW_HOME:-$HOME/.kiro/crew}"
_uv_py_dir="${KIROCREW_PYTHON_DIR:-${_uv_data_home%/}-python}"
# uv draws its own download progress on a terminal and reuses an already
# installed interpreter without a download, so it is not wrapped in _run_step.
echo "Installing Python ($UV_PYTHON_SERIES) into $_uv_py_dir via uv ..."
UV_PYTHON_INSTALL_DIR="$_uv_py_dir" "$_uv_bin" python install "$UV_PYTHON_SERIES" \
|| return 1
# only-managed: resolve the interpreter just installed, never a system one
# that happens to satisfy the series (matters under --managed-python, where
# a usable system 3.12 may exist but was explicitly opted out of).
_cand="$(UV_PYTHON_INSTALL_DIR="$_uv_py_dir" UV_PYTHON_PREFERENCE=only-managed \
"$_uv_bin" python find "$UV_PYTHON_SERIES" 2>/dev/null || true)"
if [ -n "$_cand" ] && _py_usable "$_cand"; then
PY="$_cand"
echo "Provisioned managed Python: $_cand"
return 0
fi
return 1
}
# Materialize and self-check the embedded trust root. The key id is the
# SHA-256 fingerprint of SubjectPublicKeyInfo DER, so an accidental edit to
# either pinned value fails before the network is consulted. This runs BEFORE
# the interpreter block below -- the managed-Python default may download uv,
# and the fail-before-network guarantee must not depend on how the
# interpreter is provisioned -- so it uses openssl (a hard prerequisite
# checked above) rather than an interpreter to decode the key.
printf '%s' "$CLI_MANIFEST_PUBLIC_KEY_B64" \
| openssl base64 -d -A > "$TMP/cli-manifest-public.pem" 2>/dev/null || true
if ! [ -s "$TMP/cli-manifest-public.pem" ] \
|| ! openssl pkey -pubin -in "$TMP/cli-manifest-public.pem" \
-outform DER -out "$TMP/cli-manifest-public.der" 2>/dev/null; then
err "embedded CLI manifest public key is invalid"
fi
PINNED_KEY_SHA="$($SHA_CMD "$TMP/cli-manifest-public.der" | awk '{print $1}')"
[ "$CLI_MANIFEST_KEY_ID" = "sha256:$PINNED_KEY_SHA" ] \
|| err "embedded CLI manifest public key fingerprint mismatch"
PY=""
# The interpreter choice is STICKY: a completed install records its mode in
# the data home (next to `channel`), and a later run without an explicit flag
# or env value reuses it. Without this, every re-run of the one-liner -- most
# importantly the one `kirocrew update` performs -- would silently flip the
# interpreter under an existing install.
#
# Managed is the DEFAULT: with no flag, no env value, and no recorded pin,
# the installer provisions a python-build-standalone CPython via uv. Opt out
# with --system-python (or KIROCREW_MANAGED_PYTHON=0), which records a
# `system-pinned` marker so the choice survives updates. A bare `system`
# marker is NOT an opt-out: earlier installers recorded it for every default
# install, so it only says "an old default ran here" -- such installs migrate
# onto the managed default at their next update.
_DATA_HOME="${KIROCREW_HOME:-$HOME/.kiro/crew}"
# The marker is agent-writable state, so the READ is guarded like the write:
# only a plain regular file counts (a planted symlink -- e.g. to /dev/zero --
# or a FIFO would wedge an unbounded read or spoof the mode), and the read is
# bounded to the first bytes rather than slurping the file.
_py_mode_file="$_DATA_HOME/python-mode"
# Tracks whether the resolved mode was ASKED FOR in THIS run (flag or env
# value). Only an explicit managed request fails hard when uv cannot
# provision. A `managed` marker records a DEFAULTED choice, so marker-driven
# runs keep the degrade-to-system fallback below -- otherwise the first
# successful default install would turn every later update into a hard
# network dependency on the uv download.
PY_MODE_EXPLICIT=0
PY_MODE_FALLBACK=0
[ -n "$MANAGED_PYTHON" ] && PY_MODE_EXPLICIT=1
if [ -z "$MANAGED_PYTHON" ] && [ -f "$_py_mode_file" ] && [ ! -L "$_py_mode_file" ]; then
case "$(head -c 16 "$_py_mode_file" 2>/dev/null || true)" in
managed)
echo "Reusing the recorded managed-python choice (override with --system-python)."
MANAGED_PYTHON=1 ;;
system-pinned)
echo "Reusing the recorded system-python choice (override with --managed-python)."
MANAGED_PYTHON=0; PY_MODE_EXPLICIT=1 ;;
esac
fi
[ -n "$MANAGED_PYTHON" ] || MANAGED_PYTHON=1
if [ "$MANAGED_PYTHON" = "1" ]; then
echo "Using a managed Python (the default; opt out with --system-python)."
# Interpreters in the store are ALWAYS resolved through the pinned,
# SHA-256-verified uv binary (`uv python install` is idempotent, so a
# healthy interpreter is reused without a re-download). The store is never
# scanned or executed directly: it lives on an agent-writable disk, and a
# planted executable there would otherwise run inside the user's own
# unattended update.
if ! _provision_python_via_uv; then
if [ "$PY_MODE_EXPLICIT" = "1" ]; then
err "could not provision a managed Python via uv. Check the network connection, or re-run with --system-python to use a system interpreter instead."
fi
_resolve_python || true
if [ -n "$PY" ]; then
echo "kirocrew-install: WARNING: could not provision a managed Python (network?); using the system interpreter $PY for this run. The next run retries the managed default." >&2
MANAGED_PYTHON=0
PY_MODE_FALLBACK=1
fi
fi
else
_resolve_python || true
if [ -z "$PY" ]; then
echo "No system Python >=3.12 found; provisioning one via uv ..."
_provision_python_via_uv || true
fi
fi
[ -n "$PY" ] || err "Python >=3.12 is required and could not be found or provisioned. Install Python 3.12+ yourself (your distro's packages, or https://www.python.org/downloads/), then re-run."
# The trust root was materialized and self-checked BEFORE the interpreter
# block above: the managed-Python default may download uv, and a corrupted
# trust root must fail before any network I/O.
if [ -n "$PIN_VERSION" ]; then
case "$PIN_VERSION" in
*[!A-Za-z0-9._+]*) err "invalid pinned version '$PIN_VERSION'" ;;
esac
MANIFEST_URL="$ARTIFACT_BASE/cli/$CHANNEL_PATH/$PIN_VERSION/cli-manifest.json"
echo "Resolving KiroCrew $PIN_VERSION ($CHANNEL channel, pinned) ..."
else
MANIFEST_URL="$FEED_BASE/feed/$CHANNEL_PATH/latest-cli.json"
echo "Resolving KiroCrew ($CHANNEL channel) ..."
fi
# A pinned miss is policy far more often than it is a broken CDN: releases
# published before manifest signing was enabled carry no signed manifest and are
# permanently unpinnable. The URL alone reads as infrastructure failure, which
# sends an operator whose rollback runbook names such a release to file a CDN bug
# instead of to the migration path. There is deliberately no version arithmetic
# here: stock macOS sort has no -V, so the numeric floor lives in the
# documentation this points at and the immutable manifest stays the only thing
# this script enforces. Kept as a helper so the fetch below stays a bare `curl`
# line, which is what test_installer_fetches_authenticated_urls_without_redirects
# scans for when it proves every authenticated fetch refuses redirects.
manifest_miss() {
# 22 is what `curl -f` returns when the host answered with an HTTP error, so
# it is the only status that means the manifest is genuinely absent rather
# than unreachable. On a connect, DNS or timeout failure curl has already
# printed the transport error and the cutoff is not the operator's problem, so
# claiming it there would blame policy for an outage.
if [ "$1" = 22 ] && [ -n "$PIN_VERSION" ]; then
echo "kirocrew-install: '$PIN_VERSION' cannot be pinned: it either predates signed CLI manifests or was never published." >&2
echo "kirocrew-install: Pinning policy and the minimum pinnable release: https://github.com/kirodotdev/KiroCrew/blob/main/docs/guides/install.md#pinning-an-exact-version" >&2
echo "kirocrew-install: Re-run without --version to install the current $CHANNEL release." >&2
fi
err "signed CLI manifest not found at $MANIFEST_URL"
}
# Bound unauthenticated metadata before it reaches disk. curl 7.58+ enforces
# --max-filesize against received bytes even without a Content-Length header.
curl -fsS --proto '=https' --max-filesize 65536 "$MANIFEST_URL" \
-o "$TMP/cli-manifest.json" \
|| manifest_miss $?
# The signature covers canonical JSON containing every field except the
# signature itself. Reject duplicate/extra/missing keys, decode into bounded
# files, and only parse artifact metadata AFTER OpenSSL authenticates those
# canonical bytes against the offline key above.
if ! "$PY" - "$TMP/cli-manifest.json" "$TMP/signed-payload.json" \
"$TMP/manifest-signature.bin" "$CLI_MANIFEST_KEY_ID" <<'PY'
import base64
import json
import sys
manifest_path, payload_path, signature_path, pinned_key_id = sys.argv[1:]
expected = {
"algorithm", "channel", "key_id", "pub_date", "python_requires",
"schema", "sha256", "signature", "version", "wheel_url",
}
# Signed-but-optional: a breaking release adds a fleet floor. The signature
# still covers it (it stays in the canonical payload below), so the set check
# tolerates exactly this key and nothing else.
optional = {"min_version"}
def no_duplicates(pairs):
value = {}
for key, item in pairs:
if key in value:
raise ValueError("duplicate key")
value[key] = item
return value
try:
raw = open(manifest_path, "rb").read(65537)
if len(raw) > 65536:
raise ValueError("oversized manifest")
manifest = json.loads(raw.decode("utf-8"), object_pairs_hook=no_duplicates)
if not isinstance(manifest, dict):
raise ValueError("unexpected fields")
if not expected <= set(manifest) or set(manifest) - expected - optional:
raise ValueError("unexpected fields")
if not all(isinstance(value, str) and value for value in manifest.values()):
raise ValueError("invalid field type")
if manifest["schema"] != "kirocrew-cli-artifact-manifest-v1":
raise ValueError("unsupported schema")
if manifest["algorithm"] != "RSASSA_PKCS1_V1_5_SHA_256":
raise ValueError("unsupported algorithm")
if manifest["key_id"] != pinned_key_id:
raise ValueError("untrusted key id")
signature = base64.b64decode(manifest.pop("signature"), validate=True)
if not signature or len(signature) > 1024:
raise ValueError("invalid signature size")
canonical = (
json.dumps(manifest, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
+ "\n"
).encode("ascii")
if len(canonical) > 16384:
raise ValueError("oversized payload")
open(payload_path, "xb").write(canonical)
open(signature_path, "xb").write(signature)
except (OSError, UnicodeError, ValueError, json.JSONDecodeError):
raise SystemExit(1)
PY
then
err "malformed signed manifest — refusing to install"
fi
if ! openssl dgst -sha256 -verify "$TMP/cli-manifest-public.pem" \
-signature "$TMP/manifest-signature.bin" "$TMP/signed-payload.json" \
>/dev/null 2>&1; then
err "manifest signature verification failed — refusing to install"
fi
echo "Verified signed manifest."
# Validate the authenticated fields against the request. In particular, the
# wheel URL must be the one canonical URL implied by the selected byte host,
# channel, and signed version; a valid signer cannot redirect this installer to
# an unrelated origin by accident.
if ! "$PY" - "$TMP/signed-payload.json" "$CHANNEL" "$PIN_VERSION" \
"$ARTIFACT_BASE" <<'PY'
import json
import re
import sys
path, expected_channel, pinned_version, artifact_base = sys.argv[1:]
payload = json.load(open(path, encoding="ascii"))
expected_fields = {
"algorithm", "channel", "key_id", "pub_date", "python_requires",
"schema", "sha256", "version", "wheel_url",
}
optional_fields = {"min_version"}
try:
if not expected_fields <= set(payload) or set(payload) - expected_fields - optional_fields:
raise ValueError
if payload["channel"] != expected_channel:
raise ValueError
version = payload["version"]
if re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._+]{0,127}", version) is None:
raise ValueError
if pinned_version and version != pinned_version:
raise ValueError
# The floor is metadata for RUNNING installs; this installer always
# installs the signed version itself, so format is all it checks.
if "min_version" in payload and re.fullmatch(
r"[0-9]+(?:\.[0-9]+)*", payload["min_version"]
) is None:
raise ValueError
if re.fullmatch(r"[0-9a-f]{64}", payload["sha256"]) is None:
raise ValueError
if re.fullmatch(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z", payload["pub_date"]) is None:
raise ValueError
if len(payload["python_requires"]) > 128 or any(
ord(char) < 0x20 or ord(char) > 0x7E for char in payload["python_requires"]
):
raise ValueError
wheel_name = f"kirocrew-{version}-py3-none-any.whl"
expected_url = f"{artifact_base}/cli/{expected_channel}/{version}/{wheel_name}"
if payload["wheel_url"] != expected_url:
raise ValueError
except (KeyError, TypeError, ValueError):
raise SystemExit(1)
PY
then
err "signed manifest does not match the requested channel/version/artifact host"
fi
read_field() {
"$PY" -c 'import json,sys; print(json.load(open(sys.argv[1]))[sys.argv[2]])' \
"$TMP/signed-payload.json" "$1"
}
WHEEL_URL="$(read_field wheel_url)"
SHA="$(read_field sha256)"
VER="$(read_field version)"
WHEEL_NAME="kirocrew-${VER}-py3-none-any.whl"
WHL="$TMP/$WHEEL_NAME"
echo "Downloading kirocrew $VER ..."
# $CURL_PROGRESS is one word (-s or --progress-bar), unquoted on purpose.
# shellcheck disable=SC2086
curl -f $CURL_PROGRESS -S --proto '=https' "$WHEEL_URL" -o "$WHL" || err "failed to download wheel from $WHEEL_URL"
_whl_kb=$(( $(wc -c < "$WHL") / 1024 ))
echo "Downloaded $WHEEL_NAME (${_whl_kb} KB); verifying SHA-256 ..."
GOT="$($SHA_CMD "$WHL" | awk '{print $1}')"
[ "$GOT" = "$SHA" ] || err "SHA-256 mismatch (expected $SHA, got $GOT) — refusing to install"
echo "Verified SHA-256."
# Build under a umask that masks group/other WRITE, so bin/kirocrew and its
# dirs are born non-group-writable -- whether pipx or the managed venv builds
# them. `kirocrew service install` refuses to attach its AppArmor
# unprivileged-userns profile to a launcher whose file -- or any ancestor dir
# -- is group- or world-writable, since another local user could plant an
# executable at that path and inherit the grant. venv/pip/pipx honour the
# process umask, so a permissive umask (002, common on shared dev hosts) would
# otherwise yield a 0775 tree and the profile install would refuse, recurring
# on every re-install. Tightening at BIRTH (not with a post-build chmod) leaves
# no window in which a same-group user could modify the tree before it is
# hardened and blessed. We OR the caller's umask with 022 so we only ever ADD
# the write-mask bits -- a stricter umask (e.g. 077) is preserved, never
# loosened. Each branch restores it once its tree is built.
_KC_PREV_UMASK="$(umask)"
umask "$(printf '%03o' "$(( $(umask) | 022 ))")"
if command -v pipx >/dev/null 2>&1; then
echo "Installing with pipx ..."
# `pipx install --force` over an EXISTING kirocrew venv is not transactional
# on pipx's side: its install path removes the whole venv when pip fails,
# so a dependency that no longer resolves (the binary-only policy turning a
# would-be source build into a refusal, a download that dies) would take a
# WORKING `kirocrew` down with it. Keep pipx's own install exactly as it is
# today -- --force installs INTO the existing venv (`--force-reinstall`, no
# --clear), so `pipx inject`ed packages, anything added with `pipx runpip`,
# and every exposed command survive a successful reinstall -- and wrap it in
# a rollback: copy the venv aside first, restore the copy if pipx fails,
# drop it on success. pipx's launcher symlinks point INTO the venv path, so
# a restored tree brings the command back with no relink. A copy, not a
# rename, because the install has to run in place for the venv's extra
# contents to survive; a rename-and-rebuild would silently drop whatever
# pipx's metadata does not record. Guards: pyvenv.cfg proves the target is
# a venv; a symlinked venv root is left alone; if pipx cannot name its venv
# dir there is nothing to copy and the install runs as today; if the copy
# itself fails the installer stops before pipx touches anything. The whole
# copy/mutate/restore runs under one install lock (see _wait_install_lock)
# so a second installer run cannot snapshot the venv this run is about to
# update and later restore that stale snapshot over the finished update.
# The lock file sits beside the venv as a plain file (pipx enumerates only
# directories there) and is never deleted: removing it would let a waiter
# lock a file nobody else opens.
_PIPX_VENV_BACKUP=""
_PIPX_VENV="$(pipx environment --value PIPX_LOCAL_VENVS 2>/dev/null || true)"
_PIPX_VENV="${_PIPX_VENV:+${_PIPX_VENV%/}/kirocrew}"
if [ -n "$_PIPX_VENV" ]; then
_PIPX_LOCK="$_PIPX_VENV.install.lock"
mkdir -p "${_PIPX_VENV%/*}" 2>/dev/null || true
if ! : >> "$_PIPX_LOCK" 2>/dev/null; then
err "could not create the install lock $_PIPX_LOCK (is ${_PIPX_VENV%/*} writable?). Nothing was changed."
fi
exec 9>>"$_PIPX_LOCK"
_wait_install_lock 900 && _st=0 || _st=$?
if [ "$_st" -ne 0 ]; then
[ "$_st" -eq 3 ] \
&& err "another kirocrew installer run has been working on $_PIPX_VENV for 15 minutes (lock $_PIPX_LOCK). Nothing was changed. Wait for it to finish, or stop it, then re-run this installer." \
|| err "could not take the install lock $_PIPX_LOCK. Nothing was changed."
fi
fi