authorclover caruso <git@paperclover.net> 2026-10-06 23:11:12-07:00
committerclover caruso <git@paperclover.net> 2026-10-09 11:21:14-07:00
log9c3463012eb3fe07f9628e4e35abce6c80fd5355
treed939c4d43e5cbad85271a7edf6d9aa8fb1f49ac4
parent2547169c7c14c468d646a4529194116b1adfb1ee
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU (~clover)

Attribute Shale writes to model accounts and link verified commit identities


29 files changed, 1996 insertions(+), 268 deletions(-)

config/shale.json created+1
...@@ -0,0 +1 @@
1{"actorOwners":{"ai":"8c909fab-98b0-4472-9171-9606ccebe9d5"},"statusDelegates":{"ai":["chat","snowbound"]}}
dashboard/Cargo.lock+363-2
...@@ -156,6 +156,12 @@ dependencies = [...@@ -156,6 +156,12 @@ dependencies = [
156 "tracing",156 "tracing",
157]157]
158158
159[[package]]
160name = "base16ct"
161version = "0.2.0"
162source = "registry+https://github.com/rust-lang/crates.io-index"
163checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
164
159[[package]]165[[package]]
160name = "base64"166name = "base64"
161version = "0.21.7"167version = "0.21.7"
...@@ -284,6 +290,22 @@ dependencies = [...@@ -284,6 +290,22 @@ dependencies = [
284 "windows-link",290 "windows-link",
285]291]
286292
293[[package]]
294name = "cipher"
295version = "0.4.4"
296source = "registry+https://github.com/rust-lang/crates.io-index"
297checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
298dependencies = [
299 "crypto-common",
300 "inout",
301]
302
303[[package]]
304name = "const-oid"
305version = "0.9.6"
306source = "registry+https://github.com/rust-lang/crates.io-index"
307checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
308
287[[package]]309[[package]]
288name = "core-foundation-sys"310name = "core-foundation-sys"
289version = "0.8.7"311version = "0.8.7"
...@@ -320,6 +342,18 @@ version = "0.2.4"...@@ -320,6 +342,18 @@ version = "0.2.4"
320source = "registry+https://github.com/rust-lang/crates.io-index"342source = "registry+https://github.com/rust-lang/crates.io-index"
321checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"343checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
322344
345[[package]]
346name = "crypto-bigint"
347version = "0.5.5"
348source = "registry+https://github.com/rust-lang/crates.io-index"
349checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
350dependencies = [
351 "generic-array",
352 "rand_core 0.6.4",
353 "subtle",
354 "zeroize",
355]
356
323[[package]]357[[package]]
324name = "crypto-common"358name = "crypto-common"
325version = "0.1.7"359version = "0.1.7"
...@@ -382,6 +416,16 @@ version = "2.11.1"...@@ -382,6 +416,16 @@ version = "2.11.1"
382source = "registry+https://github.com/rust-lang/crates.io-index"416source = "registry+https://github.com/rust-lang/crates.io-index"
383checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"417checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
384418
419[[package]]
420name = "der"
421version = "0.7.10"
422source = "registry+https://github.com/rust-lang/crates.io-index"
423checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
424dependencies = [
425 "const-oid",
426 "zeroize",
427]
428
385[[package]]429[[package]]
386name = "der-parser"430name = "der-parser"
387version = "9.0.0"431version = "9.0.0"
...@@ -430,6 +474,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"...@@ -430,6 +474,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
430checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"474checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
431dependencies = [475dependencies = [
432 "block-buffer",476 "block-buffer",
477 "const-oid",
433 "crypto-common",478 "crypto-common",
434 "subtle",479 "subtle",
435]480]
...@@ -466,12 +511,45 @@ version = "1.0.20"...@@ -466,12 +511,45 @@ version = "1.0.20"
466source = "registry+https://github.com/rust-lang/crates.io-index"511source = "registry+https://github.com/rust-lang/crates.io-index"
467checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"512checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
468513
514[[package]]
515name = "ecdsa"
516version = "0.16.9"
517source = "registry+https://github.com/rust-lang/crates.io-index"
518checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
519dependencies = [
520 "der",
521 "digest",
522 "elliptic-curve",
523 "rfc6979",
524 "signature",
525 "spki",
526]
527
469[[package]]528[[package]]
470name = "ego-tree"529name = "ego-tree"
471version = "0.11.0"530version = "0.11.0"
472source = "registry+https://github.com/rust-lang/crates.io-index"531source = "registry+https://github.com/rust-lang/crates.io-index"
473checksum = "b04dc5a38e4f151a79d9f2451ae6037fb6eaf5cba34771f44781f80e508498e3"532checksum = "b04dc5a38e4f151a79d9f2451ae6037fb6eaf5cba34771f44781f80e508498e3"
474533
534[[package]]
535name = "elliptic-curve"
536version = "0.13.8"
537source = "registry+https://github.com/rust-lang/crates.io-index"
538checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
539dependencies = [
540 "base16ct",
541 "crypto-bigint",
542 "digest",
543 "ff",
544 "generic-array",
545 "group",
546 "pkcs8",
547 "rand_core 0.6.4",
548 "sec1",
549 "subtle",
550 "zeroize",
551]
552
475[[package]]553[[package]]
476name = "encoding_rs"554name = "encoding_rs"
477version = "0.8.42"555version = "0.8.42"
...@@ -520,6 +598,16 @@ version = "2.5.0"...@@ -520,6 +598,16 @@ version = "2.5.0"
520source = "registry+https://github.com/rust-lang/crates.io-index"598source = "registry+https://github.com/rust-lang/crates.io-index"
521checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"599checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
522600
601[[package]]
602name = "ff"
603version = "0.13.1"
604source = "registry+https://github.com/rust-lang/crates.io-index"
605checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393"
606dependencies = [
607 "rand_core 0.6.4",
608 "subtle",
609]
610
523[[package]]611[[package]]
524name = "find-msvc-tools"612name = "find-msvc-tools"
525version = "0.1.14"613version = "0.1.14"
...@@ -658,6 +746,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"...@@ -658,6 +746,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
658dependencies = [746dependencies = [
659 "typenum",747 "typenum",
660 "version_check",748 "version_check",
749 "zeroize",
661]750]
662751
663[[package]]752[[package]]
...@@ -712,6 +801,17 @@ dependencies = [...@@ -712,6 +801,17 @@ dependencies = [
712 "regex-syntax",801 "regex-syntax",
713]802]
714803
804[[package]]
805name = "group"
806version = "0.13.0"
807source = "registry+https://github.com/rust-lang/crates.io-index"
808checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
809dependencies = [
810 "ff",
811 "rand_core 0.6.4",
812 "subtle",
813]
814
715[[package]]815[[package]]
716name = "half"816name = "half"
717version = "2.7.1"817version = "2.7.1"
...@@ -758,6 +858,15 @@ version = "0.4.3"...@@ -758,6 +858,15 @@ version = "0.4.3"
758source = "registry+https://github.com/rust-lang/crates.io-index"858source = "registry+https://github.com/rust-lang/crates.io-index"
759checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"859checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
760860
861[[package]]
862name = "hmac"
863version = "0.12.1"
864source = "registry+https://github.com/rust-lang/crates.io-index"
865checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
866dependencies = [
867 "digest",
868]
869
761[[package]]870[[package]]
762name = "home-dashboard"871name = "home-dashboard"
763version = "0.1.0"872version = "0.1.0"
...@@ -771,6 +880,7 @@ dependencies = [...@@ -771,6 +880,7 @@ dependencies = [
771 "globset",880 "globset",
772 "lol_html",881 "lol_html",
773 "openssl",882 "openssl",
883 "percent-encoding",
774 "rand 0.9.5",884 "rand 0.9.5",
775 "regex",885 "regex",
776 "reqwest",886 "reqwest",
...@@ -782,6 +892,7 @@ dependencies = [...@@ -782,6 +892,7 @@ dependencies = [
782 "serde_json",892 "serde_json",
783 "sha1",893 "sha1",
784 "sha2",894 "sha2",
895 "ssh-key",
785 "subtle",896 "subtle",
786 "tokio",897 "tokio",
787 "tokio-stream",898 "tokio-stream",
...@@ -1054,6 +1165,15 @@ dependencies = [...@@ -1054,6 +1165,15 @@ dependencies = [
1054 "serde_core",1165 "serde_core",
1055]1166]
10561167
1168[[package]]
1169name = "inout"
1170version = "0.1.4"
1171source = "registry+https://github.com/rust-lang/crates.io-index"
1172checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
1173dependencies = [
1174 "generic-array",
1175]
1176
1057[[package]]1177[[package]]
1058name = "ipnet"1178name = "ipnet"
1059version = "2.12.2"1179version = "2.12.2"
...@@ -1082,6 +1202,9 @@ name = "lazy_static"...@@ -1082,6 +1202,9 @@ name = "lazy_static"
1082version = "1.5.1"1202version = "1.5.1"
1083source = "registry+https://github.com/rust-lang/crates.io-index"1203source = "registry+https://github.com/rust-lang/crates.io-index"
1084checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb"1204checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb"
1205dependencies = [
1206 "spin",
1207]
10851208
1086[[package]]1209[[package]]
1087name = "libc"1210name = "libc"
...@@ -1089,6 +1212,12 @@ version = "0.2.189"...@@ -1089,6 +1212,12 @@ version = "0.2.189"
1089source = "registry+https://github.com/rust-lang/crates.io-index"1212source = "registry+https://github.com/rust-lang/crates.io-index"
1090checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"1213checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
10911214
1215[[package]]
1216name = "libm"
1217version = "0.2.16"
1218source = "registry+https://github.com/rust-lang/crates.io-index"
1219checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
1220
1092[[package]]1221[[package]]
1093name = "libsqlite3-sys"1222name = "libsqlite3-sys"
1094version = "0.35.0"1223version = "0.35.0"
...@@ -1251,6 +1380,22 @@ dependencies = [...@@ -1251,6 +1380,22 @@ dependencies = [
1251 "num-traits",1380 "num-traits",
1252]1381]
12531382
1383[[package]]
1384name = "num-bigint-dig"
1385version = "0.8.6"
1386source = "registry+https://github.com/rust-lang/crates.io-index"
1387checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7"
1388dependencies = [
1389 "lazy_static",
1390 "libm",
1391 "num-integer",
1392 "num-iter",
1393 "num-traits",
1394 "rand 0.8.8",
1395 "smallvec",
1396 "zeroize",
1397]
1398
1254[[package]]1399[[package]]
1255name = "num-conv"1400name = "num-conv"
1256version = "0.2.2"1401version = "0.2.2"
...@@ -1266,6 +1411,16 @@ dependencies = [...@@ -1266,6 +1411,16 @@ dependencies = [
1266 "num-traits",1411 "num-traits",
1267]1412]
12681413
1414[[package]]
1415name = "num-iter"
1416version = "0.1.46"
1417source = "registry+https://github.com/rust-lang/crates.io-index"
1418checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b"
1419dependencies = [
1420 "num-integer",
1421 "num-traits",
1422]
1423
1269[[package]]1424[[package]]
1270name = "num-traits"1425name = "num-traits"
1271version = "0.2.19"1426version = "0.2.19"
...@@ -1273,6 +1428,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"...@@ -1273,6 +1428,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
1273checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"1428checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
1274dependencies = [1429dependencies = [
1275 "autocfg",1430 "autocfg",
1431 "libm",
1276]1432]
12771433
1278[[package]]1434[[package]]
...@@ -1327,6 +1483,44 @@ dependencies = [...@@ -1327,6 +1483,44 @@ dependencies = [
1327 "vcpkg",1483 "vcpkg",
1328]1484]
13291485
1486[[package]]
1487name = "p256"
1488version = "0.13.2"
1489source = "registry+https://github.com/rust-lang/crates.io-index"
1490checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b"
1491dependencies = [
1492 "ecdsa",
1493 "elliptic-curve",
1494 "primeorder",
1495 "sha2",
1496]
1497
1498[[package]]
1499name = "p384"
1500version = "0.13.1"
1501source = "registry+https://github.com/rust-lang/crates.io-index"
1502checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6"
1503dependencies = [
1504 "ecdsa",
1505 "elliptic-curve",
1506 "primeorder",
1507 "sha2",
1508]
1509
1510[[package]]
1511name = "p521"
1512version = "0.13.3"
1513source = "registry+https://github.com/rust-lang/crates.io-index"
1514checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2"
1515dependencies = [
1516 "base16ct",
1517 "ecdsa",
1518 "elliptic-curve",
1519 "primeorder",
1520 "rand_core 0.6.4",
1521 "sha2",
1522]
1523
1330[[package]]1524[[package]]
1331name = "parking_lot"1525name = "parking_lot"
1332version = "0.12.5"1526version = "0.12.5"
...@@ -1373,6 +1567,15 @@ version = "0.2.3"...@@ -1373,6 +1567,15 @@ version = "0.2.3"
1373source = "registry+https://github.com/rust-lang/crates.io-index"1567source = "registry+https://github.com/rust-lang/crates.io-index"
1374checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"1568checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
13751569
1570[[package]]
1571name = "pem-rfc7468"
1572version = "0.7.0"
1573source = "registry+https://github.com/rust-lang/crates.io-index"
1574checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412"
1575dependencies = [
1576 "base64ct",
1577]
1578
1376[[package]]1579[[package]]
1377name = "percent-encoding"1580name = "percent-encoding"
1378version = "2.3.2"1581version = "2.3.2"
...@@ -1438,6 +1641,27 @@ version = "0.2.17"...@@ -1438,6 +1641,27 @@ version = "0.2.17"
1438source = "registry+https://github.com/rust-lang/crates.io-index"1641source = "registry+https://github.com/rust-lang/crates.io-index"
1439checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"1642checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
14401643
1644[[package]]
1645name = "pkcs1"
1646version = "0.7.5"
1647source = "registry+https://github.com/rust-lang/crates.io-index"
1648checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f"
1649dependencies = [
1650 "der",
1651 "pkcs8",
1652 "spki",
1653]
1654
1655[[package]]
1656name = "pkcs8"
1657version = "0.10.2"
1658source = "registry+https://github.com/rust-lang/crates.io-index"
1659checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
1660dependencies = [
1661 "der",
1662 "spki",
1663]
1664
1441[[package]]1665[[package]]
1442name = "pkg-config"1666name = "pkg-config"
1443version = "0.3.34"1667version = "0.3.34"
...@@ -1474,6 +1698,15 @@ version = "0.1.1"...@@ -1474,6 +1698,15 @@ version = "0.1.1"
1474source = "registry+https://github.com/rust-lang/crates.io-index"1698source = "registry+https://github.com/rust-lang/crates.io-index"
1475checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"1699checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
14761700
1701[[package]]
1702name = "primeorder"
1703version = "0.13.6"
1704source = "registry+https://github.com/rust-lang/crates.io-index"
1705checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6"
1706dependencies = [
1707 "elliptic-curve",
1708]
1709
1477[[package]]1710[[package]]
1478name = "proc-macro2"1711name = "proc-macro2"
1479version = "1.0.107"1712version = "1.0.107"
...@@ -1560,13 +1793,23 @@ version = "6.0.0"...@@ -1560,13 +1793,23 @@ version = "6.0.0"
1560source = "registry+https://github.com/rust-lang/crates.io-index"1793source = "registry+https://github.com/rust-lang/crates.io-index"
1561checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"1794checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
15621795
1796[[package]]
1797name = "rand"
1798version = "0.8.8"
1799source = "registry+https://github.com/rust-lang/crates.io-index"
1800checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"
1801dependencies = [
1802 "rand_chacha 0.3.1",
1803 "rand_core 0.6.4",
1804]
1805
1563[[package]]1806[[package]]
1564name = "rand"1807name = "rand"
1565version = "0.9.5"1808version = "0.9.5"
1566source = "registry+https://github.com/rust-lang/crates.io-index"1809source = "registry+https://github.com/rust-lang/crates.io-index"
1567checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"1810checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
1568dependencies = [1811dependencies = [
1569 "rand_chacha",1812 "rand_chacha 0.9.0",
1570 "rand_core 0.9.5",1813 "rand_core 0.9.5",
1571]1814]
15721815
...@@ -1581,6 +1824,16 @@ dependencies = [...@@ -1581,6 +1824,16 @@ dependencies = [
1581 "rand_core 0.10.1",1824 "rand_core 0.10.1",
1582]1825]
15831826
1827[[package]]
1828name = "rand_chacha"
1829version = "0.3.1"
1830source = "registry+https://github.com/rust-lang/crates.io-index"
1831checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
1832dependencies = [
1833 "ppv-lite86",
1834 "rand_core 0.6.4",
1835]
1836
1584[[package]]1837[[package]]
1585name = "rand_chacha"1838name = "rand_chacha"
1586version = "0.9.0"1839version = "0.9.0"
...@@ -1596,6 +1849,9 @@ name = "rand_core"...@@ -1596,6 +1849,9 @@ name = "rand_core"
1596version = "0.6.4"1849version = "0.6.4"
1597source = "registry+https://github.com/rust-lang/crates.io-index"1850source = "registry+https://github.com/rust-lang/crates.io-index"
1598checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"1851checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
1852dependencies = [
1853 "getrandom 0.2.17",
1854]
15991855
1600[[package]]1856[[package]]
1601name = "rand_core"1857name = "rand_core"
...@@ -1721,6 +1977,16 @@ dependencies = [...@@ -1721,6 +1977,16 @@ dependencies = [
1721 "webpki-roots",1977 "webpki-roots",
1722]1978]
17231979
1980[[package]]
1981name = "rfc6979"
1982version = "0.4.0"
1983source = "registry+https://github.com/rust-lang/crates.io-index"
1984checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2"
1985dependencies = [
1986 "hmac",
1987 "subtle",
1988]
1989
1724[[package]]1990[[package]]
1725name = "ring"1991name = "ring"
1726version = "0.17.14"1992version = "0.17.14"
...@@ -1766,6 +2032,27 @@ dependencies = [...@@ -1766,6 +2032,27 @@ dependencies = [
1766 "uuid",2032 "uuid",
1767]2033]
17682034
2035[[package]]
2036name = "rsa"
2037version = "0.9.10"
2038source = "registry+https://github.com/rust-lang/crates.io-index"
2039checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d"
2040dependencies = [
2041 "const-oid",
2042 "digest",
2043 "num-bigint-dig",
2044 "num-integer",
2045 "num-traits",
2046 "pkcs1",
2047 "pkcs8",
2048 "rand_core 0.6.4",
2049 "sha2",
2050 "signature",
2051 "spki",
2052 "subtle",
2053 "zeroize",
2054]
2055
1769[[package]]2056[[package]]
1770name = "rusqlite"2057name = "rusqlite"
1771version = "0.37.0"2058version = "0.37.0"
...@@ -1906,6 +2193,20 @@ dependencies = [...@@ -1906,6 +2193,20 @@ dependencies = [
1906 "tendril",2193 "tendril",
1907]2194]
19082195
2196[[package]]
2197name = "sec1"
2198version = "0.7.3"
2199source = "registry+https://github.com/rust-lang/crates.io-index"
2200checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
2201dependencies = [
2202 "base16ct",
2203 "der",
2204 "generic-array",
2205 "pkcs8",
2206 "subtle",
2207 "zeroize",
2208]
2209
1909[[package]]2210[[package]]
1910name = "selectors"2211name = "selectors"
1911version = "0.37.0"2212version = "0.37.0"
...@@ -2084,6 +2385,16 @@ dependencies = [...@@ -2084,6 +2385,16 @@ dependencies = [
2084 "libc",2385 "libc",
2085]2386]
20862387
2388[[package]]
2389name = "signature"
2390version = "2.2.0"
2391source = "registry+https://github.com/rust-lang/crates.io-index"
2392checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
2393dependencies = [
2394 "digest",
2395 "rand_core 0.6.4",
2396]
2397
2087[[package]]2398[[package]]
2088name = "simdutf8"2399name = "simdutf8"
2089version = "0.1.5"2400version = "0.1.5"
...@@ -2124,6 +2435,16 @@ version = "0.9.9"...@@ -2124,6 +2435,16 @@ version = "0.9.9"
2124source = "registry+https://github.com/rust-lang/crates.io-index"2435source = "registry+https://github.com/rust-lang/crates.io-index"
2125checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e"2436checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e"
21262437
2438[[package]]
2439name = "spki"
2440version = "0.7.3"
2441source = "registry+https://github.com/rust-lang/crates.io-index"
2442checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
2443dependencies = [
2444 "base64ct",
2445 "der",
2446]
2447
2127[[package]]2448[[package]]
2128name = "sse-stream"2449name = "sse-stream"
2129version = "0.2.6"2450version = "0.2.6"
...@@ -2137,6 +2458,46 @@ dependencies = [...@@ -2137,6 +2458,46 @@ dependencies = [
2137 "pin-project-lite",2458 "pin-project-lite",
2138]2459]
21392460
2461[[package]]
2462name = "ssh-cipher"
2463version = "0.2.0"
2464source = "registry+https://github.com/rust-lang/crates.io-index"
2465checksum = "caac132742f0d33c3af65bfcde7f6aa8f62f0e991d80db99149eb9d44708784f"
2466dependencies = [
2467 "cipher",
2468 "ssh-encoding",
2469]
2470
2471[[package]]
2472name = "ssh-encoding"
2473version = "0.2.0"
2474source = "registry+https://github.com/rust-lang/crates.io-index"
2475checksum = "eb9242b9ef4108a78e8cd1a2c98e193ef372437f8c22be363075233321dd4a15"
2476dependencies = [
2477 "base64ct",
2478 "pem-rfc7468",
2479 "sha2",
2480]
2481
2482[[package]]
2483name = "ssh-key"
2484version = "0.6.7"
2485source = "registry+https://github.com/rust-lang/crates.io-index"
2486checksum = "3b86f5297f0f04d08cabaa0f6bff7cb6aec4d9c3b49d87990d63da9d9156a8c3"
2487dependencies = [
2488 "p256",
2489 "p384",
2490 "p521",
2491 "rsa",
2492 "sec1",
2493 "sha2",
2494 "signature",
2495 "ssh-cipher",
2496 "ssh-encoding",
2497 "subtle",
2498 "zeroize",
2499]
2500
2140[[package]]2501[[package]]
2141name = "stable_deref_trait"2502name = "stable_deref_trait"
2142version = "1.2.1"2503version = "1.2.1"
...@@ -2750,7 +3111,7 @@ dependencies = [...@@ -2750,7 +3111,7 @@ dependencies = [
2750 "openssl",3111 "openssl",
2751 "openssl-sys",3112 "openssl-sys",
2752 "rand 0.9.5",3113 "rand 0.9.5",
2753 "rand_chacha",3114 "rand_chacha 0.9.0",
2754 "serde",3115 "serde",
2755 "serde_cbor_2",3116 "serde_cbor_2",
2756 "serde_json",3117 "serde_json",
dashboard/Cargo.toml+2
...@@ -12,6 +12,7 @@ chrono = "0.4"...@@ -12,6 +12,7 @@ chrono = "0.4"
12futures = "0.3"12futures = "0.3"
13globset = "0.4"13globset = "0.4"
14openssl = "0.10"14openssl = "0.10"
15percent-encoding = "2"
15rand = "0.9"16rand = "0.9"
16lol_html = "3"17lol_html = "3"
17regex = "1"18regex = "1"
...@@ -24,6 +25,7 @@ scraper = { version = "0.27", default-features = false }...@@ -24,6 +25,7 @@ scraper = { version = "0.27", default-features = false }
24serde_json = "1"25serde_json = "1"
25sha1 = "0.10"26sha1 = "0.10"
26sha2 = "0.10.9"27sha2 = "0.10.9"
28ssh-key = { version = "0.6", default-features = false, features = ["std", "ecdsa"] }
27subtle = "2.6"29subtle = "2.6"
28tokio = { version = "1", features = ["full"] }30tokio = { version = "1", features = ["full"] }
29tokio-stream = { version = "0.1", features = ["sync"] }31tokio-stream = { version = "0.1", features = ["sync"] }
dashboard/src/auth.rs+5
...@@ -115,6 +115,10 @@ pub fn save_user(db: &Connection, id: &str, mut profile: Value) -> Result<()> {...@@ -115,6 +115,10 @@ pub fn save_user(db: &Connection, id: &str, mut profile: Value) -> Result<()> {
115 ] {115 ] {
116 profile.as_object_mut().unwrap().remove(key);116 profile.as_object_mut().unwrap().remove(key);
117 }117 }
118 if profile["emailVerified"] == true {
119 let conflict: bool = db.query_row("SELECT EXISTS(SELECT 1 FROM commit_emails WHERE email=lower(?) AND verified=1 AND user_id!=?)", sql![string(&profile["email"]),id], |r| r.get(0))?;
120 if conflict { return Err(Error::new(409, "This email belongs to another account. Ask an admin to check its owner.")); }
121 }
118 db.execute(122 db.execute(
119 "UPDATE users SET profile=? WHERE id=?",123 "UPDATE users SET profile=? WHERE id=?",
120 sql![profile.to_string(), id],124 sql![profile.to_string(), id],
...@@ -188,6 +192,7 @@ impl Store {...@@ -188,6 +192,7 @@ impl Store {
188 CREATE TABLE IF NOT EXISTS pending (hash TEXT PRIMARY KEY,kind TEXT NOT NULL,data TEXT NOT NULL,expires INTEGER NOT NULL);192 CREATE TABLE IF NOT EXISTS pending (hash TEXT PRIMARY KEY,kind TEXT NOT NULL,data TEXT NOT NULL,expires INTEGER NOT NULL);
189 CREATE TABLE IF NOT EXISTS migration (digest TEXT PRIMARY KEY);193 CREATE TABLE IF NOT EXISTS migration (digest TEXT PRIMARY KEY);
190 CREATE TABLE IF NOT EXISTS attempts (key TEXT PRIMARY KEY,count INTEGER NOT NULL,expires INTEGER NOT NULL);")?;194 CREATE TABLE IF NOT EXISTS attempts (key TEXT PRIMARY KEY,count INTEGER NOT NULL,expires INTEGER NOT NULL);")?;
195 identity::initialise(&db)?;
191 oidc::initialise(&db)?;196 oidc::initialise(&db)?;
192 guest::initialise(&db)?;197 guest::initialise(&db)?;
193 for name in GROUPS {198 for name in GROUPS {
dashboard/src/bot.svg created+1
...@@ -0,0 +1 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v3m-8 6H2m20 0h-2"/><rect x="4" y="6" width="16" height="14" rx="3"/><path d="M8 11v2m8-2v2m-8 4h8"/></svg>
dashboard/src/claude.svg created+1
...@@ -0,0 +1 @@
1<svg width="248" height="248" viewBox="0 0 248 248" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M52.4285 162.873L98.7844 136.879L99.5485 134.602L98.7844 133.334H96.4921L88.7237 132.862L62.2346 132.153L39.3113 131.207L17.0249 130.026L11.4214 128.844L6.2 121.873L6.7094 118.447L11.4214 115.257L18.171 115.847L33.0711 116.911L55.485 118.447L71.6586 119.392L95.728 121.873H99.5485L100.058 120.337L98.7844 119.392L97.7656 118.447L74.5877 102.732L49.4995 86.1905L36.3823 76.62L29.3779 71.7757L25.8121 67.2858L24.2839 57.3608L30.6515 50.2716L39.3113 50.8623L41.4763 51.4531L50.2636 58.1879L68.9842 72.7209L93.4357 90.6804L97.0015 93.6343L98.4374 92.6652L98.6571 91.9801L97.0015 89.2625L83.757 65.2772L69.621 40.8192L63.2534 30.6579L61.5978 24.632C60.9565 22.1032 60.579 20.0111 60.579 17.4246L67.8381 7.49965L71.9133 6.19995L81.7193 7.49965L85.7946 11.0443L91.9074 24.9865L101.714 46.8451L116.996 76.62L121.453 85.4816L123.873 93.6343L124.764 96.1155H126.292V94.6976L127.566 77.9197L129.858 57.3608L132.15 30.8942L132.915 23.4505L136.608 14.4708L143.994 9.62643L149.725 12.344L154.437 19.0788L153.8 23.4505L150.998 41.6463L145.522 70.1215L141.957 89.2625H143.994L146.414 86.7813L156.093 74.0206L172.266 53.698L179.398 45.6635L187.803 36.802L193.152 32.5484H203.34L210.726 43.6549L207.415 55.1159L196.972 68.3492L188.312 79.5739L175.896 96.2095L168.191 109.585L168.882 110.689L170.738 110.53L198.755 104.504L213.91 101.787L231.994 98.7149L240.144 102.496L241.036 106.395L237.852 114.311L218.495 119.037L195.826 123.645L162.07 131.592L161.696 131.893L162.137 132.547L177.36 133.925L183.855 134.279H199.774L229.447 136.524L237.215 141.605L241.8 147.867L241.036 152.711L229.065 158.737L213.019 154.956L175.45 145.977L162.587 142.787H160.805V143.85L171.502 154.366L191.242 172.089L215.82 195.011L217.094 200.682L213.91 205.172L210.599 204.699L188.949 188.394L180.544 181.069L161.696 165.118H160.422V166.772L164.752 173.152L187.803 207.771L188.949 218.405L187.294 221.832L181.308 223.959L174.813 222.777L161.187 203.754L147.305 182.486L136.098 163.345L134.745 164.2L128.075 235.42L125.019 239.082L117.887 241.8L111.902 237.31L108.718 229.984L111.902 215.452L115.722 196.547L118.779 181.541L121.58 162.873L123.291 156.636L123.14 156.219L121.773 156.449L107.699 175.752L86.304 204.699L69.3663 222.777L65.291 224.431L58.2867 220.768L58.9235 214.27L62.8713 208.48L86.304 178.705L100.44 160.155L109.551 149.507L109.462 147.967L108.959 147.924L46.6977 188.512L35.6182 189.93L30.7788 185.44L31.4156 178.115L33.7079 175.752L52.4285 162.873Z" fill="#D97757"/></svg>
dashboard/src/host-protocol.json created+1
...@@ -0,0 +1 @@
1{"request":2097152,"response":16777216}
dashboard/src/host.rs+6-2
...@@ -1,6 +1,10 @@...@@ -1,6 +1,10 @@
1use crate::*;1use crate::*;
2use tokio::io::{AsyncReadExt, AsyncWriteExt};2use tokio::io::{AsyncReadExt, AsyncWriteExt};
33
4#[derive(serde::Deserialize)]
5struct Limits { request: usize, response: usize }
6static LIMITS: std::sync::LazyLock<Limits> = std::sync::LazyLock::new(|| serde_json::from_str(include_str!("host-protocol.json")).expect("host protocol limits"));
7
4static SLOTS: tokio::sync::Semaphore = tokio::sync::Semaphore::const_new(4);8static SLOTS: tokio::sync::Semaphore = tokio::sync::Semaphore::const_new(4);
59
6pub async fn sample(app: Arc<App>) -> Result<Arc<Document>> {10pub async fn sample(app: Arc<App>) -> Result<Arc<Document>> {
...@@ -48,7 +52,7 @@ pub async fn open(request: Value) -> Result<(Value, tokio::net::UnixStream)> {...@@ -48,7 +52,7 @@ pub async fn open(request: Value) -> Result<(Value, tokio::net::UnixStream)> {
48 }52 }
49 let mut message = serde_json::to_vec(&request)?;53 let mut message = serde_json::to_vec(&request)?;
50 message.push(b'\n');54 message.push(b'\n');
51 if message.len() > 65536 {55 if message.len() > LIMITS.request {
52 return Err(Error::new(56 return Err(Error::new(
53 400,57 400,
54 "The host request is too large. Narrow the selection.",58 "The host request is too large. Narrow the selection.",
...@@ -64,7 +68,7 @@ pub async fn open(request: Value) -> Result<(Value, tokio::net::UnixStream)> {...@@ -64,7 +68,7 @@ pub async fn open(request: Value) -> Result<(Value, tokio::net::UnixStream)> {
6468
65pub async fn response(socket: &mut tokio::net::UnixStream) -> Result<Value> {69pub async fn response(socket: &mut tokio::net::UnixStream) -> Result<Value> {
66 let length = socket.read_u32().await? as usize;70 let length = socket.read_u32().await? as usize;
67 if length > 16 * 1024 * 1024 {71 if length > LIMITS.response {
68 return Err(Error::new(72 return Err(Error::new(
69 502,73 502,
70 "The host response is too large. Narrow the selection.",74 "The host response is too large. Narrow the selection.",
dashboard/src/identity.rs created+133
...@@ -0,0 +1,133 @@
1use crate::*;
2use rusqlite::{Connection, OptionalExtension, params as sql};
3use ssh_key::{HashAlg, PublicKey};
4
5pub fn initialise(db: &Connection) -> Result<()> {
6 db.execute_batch("CREATE TABLE IF NOT EXISTS commit_emails (email TEXT NOT NULL,user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,verified INTEGER NOT NULL DEFAULT 0,PRIMARY KEY(user_id,email));
7 CREATE UNIQUE INDEX IF NOT EXISTS confirmed_commit_email ON commit_emails(email) WHERE verified=1;
8 CREATE TABLE IF NOT EXISTS signing_keys (fingerprint TEXT NOT NULL,user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,label TEXT NOT NULL,public_key TEXT NOT NULL,created INTEGER NOT NULL,verified INTEGER NOT NULL DEFAULT 0,PRIMARY KEY(user_id,fingerprint));
9 CREATE UNIQUE INDEX IF NOT EXISTS confirmed_signing_key ON signing_keys(fingerprint) WHERE verified=1;")?;
10 Ok(())
11}
12
13pub fn email(value: &Value) -> Result<String> {
14 let email = string(value).trim().to_lowercase();
15 if email.len() > 254 || email.is_empty() || email.chars().any(char::is_whitespace)
16 || email.chars().any(char::is_control) || email.split('@').count() != 2
17 || email.starts_with('@') || email.ends_with('@') {
18 return Err(Error::new(400, "Enter a full email address."));
19 }
20 Ok(email)
21}
22
23pub fn add_email(db: &Connection, id: &str, value: &Value, verified: bool) -> Result<()> {
24 let email = email(value)?;
25 let primary: Option<String> = db.query_row("SELECT id FROM users WHERE lower(json_extract(profile,'$.email'))=? AND json_extract(profile,'$.emailVerified')=1", [&email], |r| r.get(0)).optional()?;
26 let owner: Option<String> = db.query_row("SELECT user_id FROM commit_emails WHERE email=? AND verified=1", [&email], |r| r.get(0)).optional()?;
27 let exists: bool = db.query_row("SELECT EXISTS(SELECT 1 FROM commit_emails WHERE email=? AND user_id=?)", sql![email,id], |r| r.get(0))?;
28 if primary.as_deref().is_some_and(|owner| owner != id) || owner.as_deref().is_some_and(|owner| owner != id) {
29 return Err(Error::new(409, "This email belongs to another account. Ask an admin to check its owner."));
30 }
31 let count: i64 = db.query_row("SELECT count(*) FROM commit_emails WHERE user_id=?", [id], |r| r.get(0))?;
32 if !exists && count >= 64 { return Err(Error::new(400, "Remove an email before adding another.")); }
33 db.execute("INSERT INTO commit_emails VALUES (?,?,?) ON CONFLICT(user_id,email) DO UPDATE SET verified=max(verified,excluded.verified)", sql![email,id,verified])?;
34 Ok(())
35}
36
37pub fn add_key(db: &Connection, id: &str, body: &Value, verified: bool) -> Result<()> {
38 let source = string(&body["publicKey"]).trim();
39 let label = string(&body["label"]).trim();
40 if source.len() > 16384 || source.contains(['\n','\r']) || label.len() > 128 || label.chars().any(char::is_control) {
41 return Err(Error::new(400, "Paste one SSH public key and use a label under 128 characters."));
42 }
43 let mut key = PublicKey::from_openssh(source).map_err(|_| Error::new(400, "Paste an SSH public key, such as an ssh-ed25519 key."))?;
44 if key.algorithm().as_str() == "ssh-dss" { return Err(Error::new(400, "DSA keys are obsolete. Use an Ed25519, RSA, or ECDSA public key.")); }
45 let fingerprint = key.fingerprint(HashAlg::Sha256).to_string();
46 let label = if label.is_empty() { key.comment() } else { label }.to_owned();
47 if label.len() > 128 || label.chars().any(char::is_control) { return Err(Error::new(400, "Use a key label under 128 characters.")); }
48 key.set_comment("");
49 let public_key = key.to_openssh().map_err(|_| Error::new(400, "Paste an SSH public key."))?;
50 let owner: Option<String> = db.query_row("SELECT user_id FROM signing_keys WHERE fingerprint=? AND verified=1", [&fingerprint], |r| r.get(0)).optional()?;
51 if owner.as_deref().is_some_and(|owner| owner != id) { return Err(Error::new(409, "This key belongs to another account. Ask an admin to check its owner.")); }
52 let count: i64 = db.query_row("SELECT count(*) FROM signing_keys WHERE user_id=?", [id], |r| r.get(0))?;
53 let exists: bool = db.query_row("SELECT EXISTS(SELECT 1 FROM signing_keys WHERE user_id=? AND fingerprint=?)",sql![id,fingerprint],|r|r.get(0))?;
54 if !exists && count >= 64 { return Err(Error::new(400, "Remove a signing key before adding another.")); }
55 db.execute("INSERT INTO signing_keys VALUES (?,?,?,?,?,?) ON CONFLICT(user_id,fingerprint) DO UPDATE SET verified=max(verified,excluded.verified)", sql![fingerprint,id,label,public_key,(now()*1000.0) as i64,verified])?;
56 Ok(())
57}
58
59pub fn confirm_key(db: &Connection, id: &str, fingerprint: &str) -> Result<()> {
60 let body: Option<Value> = db.query_row("SELECT public_key,label FROM signing_keys WHERE user_id=? AND fingerprint=?",sql![id,fingerprint],|r| Ok(json!({"publicKey":r.get::<_,String>(0)?,"label":r.get::<_,String>(1)?}))).optional()?;
61 add_key(db,id,&body.ok_or_else(|| Error::new(404,"Add this signing key before confirming ownership."))?,true)
62}
63
64pub fn account(db: &Connection, id: &str) -> Result<Value> {
65 let mut emails = db.prepare("SELECT email,verified FROM commit_emails WHERE user_id=? ORDER BY email")?;
66 let emails = emails.query_map([id], |r| Ok(json!({"email":r.get::<_,String>(0)?,"verified":r.get::<_,bool>(1)?})))?.collect::<std::result::Result<Vec<_>,_>>()?;
67 let mut keys = db.prepare("SELECT fingerprint,label,public_key,created,verified FROM signing_keys WHERE user_id=? ORDER BY created,fingerprint")?;
68 let keys = keys.query_map([id], |r| Ok(json!({"fingerprint":r.get::<_,String>(0)?,"label":r.get::<_,String>(1)?,"publicKey":r.get::<_,String>(2)?,"createdDate":r.get::<_,i64>(3)?,"verified":r.get::<_,bool>(4)?})))?.collect::<std::result::Result<Vec<_>,_>>()?;
69 Ok(json!({"emails":emails,"signingKeys":keys}))
70}
71
72pub fn import(auth: &auth::Store, input: &Value) -> Result<Value> {
73 let mut db = auth.db.lock().unwrap();
74 let tx = db.transaction()?;
75 let id: String = tx.query_row("SELECT id FROM users WHERE username=? AND json_extract(profile,'$.enabled')=1", [string(&input["username"])], |r| r.get(0)).optional()?.ok_or_else(|| Error::new(404,"Choose an enabled account."))?;
76 if !input["emails"].is_array() || !input["signingKeys"].is_array() { return Err(Error::new(400,"Supply email and signing key lists.")); }
77 for email in array(&input["emails"]) { add_email(&tx,&id,email,true)?; }
78 for key in array(&input["signingKeys"]) { add_key(&tx,&id,key,true)?; }
79 let result = account(&tx,&id)?;
80 tx.commit()?;
81 Ok(result)
82}
83
84#[cfg(test)]
85mod tests {
86 use super::*;
87 fn store() -> Connection {
88 let db = Connection::open_in_memory().unwrap();
89 db.execute_batch("PRAGMA foreign_keys=ON; CREATE TABLE users(id TEXT PRIMARY KEY,profile TEXT); INSERT INTO users VALUES('one','{\"email\":\"primary@example.test\",\"emailVerified\":true}'),('two','{}');").unwrap();
90 initialise(&db).unwrap();
91 db
92 }
93 #[test]
94 fn unconfirmed_key_claim_cannot_block_its_owner() {
95 let db = store();
96 let body = json!({"publicKey":"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMpxNpkRLTUijhd3HSaOvKYn2MWXEY+YEFdsPNZhBROn"});
97 add_key(&db,"two",&body,false).unwrap();
98 assert_eq!(account(&db,"two").unwrap()["signingKeys"][0]["verified"],false);
99 add_key(&db,"one",&body,true).unwrap();
100 let fingerprint = account(&db,"two").unwrap()["signingKeys"][0]["fingerprint"].as_str().unwrap().to_owned();
101 assert!(confirm_key(&db,"two",&fingerprint).is_err());
102 assert_eq!(account(&db,"two").unwrap()["signingKeys"][0]["verified"],false);
103 }
104
105 #[test]
106 fn confirmation_cannot_steal_an_alias_or_verified_primary() {
107 let db = store();
108 add_email(&db,"one",&json!(" Alias@Example.test "),false).unwrap();
109 add_email(&db,"two",&json!("alias@example.test"),false).unwrap();
110 add_email(&db,"one",&json!("alias@example.test"),true).unwrap();
111 assert!(add_email(&db,"two",&json!("alias@example.test"),true).is_err());
112 assert!(add_email(&db,"two",&json!("primary@example.test"),false).is_err());
113 assert_eq!(account(&db,"one").unwrap()["emails"][0],json!({"email":"alias@example.test","verified":true}));
114 for value in ["@host", "name@", "a@b@c", "a b@c", "a\nb@c"] { assert!(email(&json!(value)).is_err()); }
115 db.execute("DELETE FROM users WHERE id='one'",[]).unwrap();
116 add_email(&db,"two",&json!("alias@example.test"),true).unwrap();
117 }
118 #[test]
119 fn signing_keys_deduplicate_material_and_cannot_change_owner() {
120 let db = store();
121 let key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMpxNpkRLTUijhd3HSaOvKYn2MWXEY+YEFdsPNZhBROn";
122 add_key(&db,"one",&json!({"publicKey":format!("{key} device"),"label":"first"}),true).unwrap();
123 add_key(&db,"one",&json!({"publicKey":format!("{key} different comment"),"label":"second"}),true).unwrap();
124 assert!(add_key(&db,"two",&json!({"publicKey":key}),true).is_err());
125 let keys = account(&db,"one").unwrap()["signingKeys"].clone();
126 assert_eq!(keys.as_array().unwrap().len(),1);
127 assert_eq!(keys[0]["label"],"first");
128 assert_eq!(keys[0]["publicKey"],key);
129 for key in ["garbage", "ssh-ed25519 AAAA", "-----BEGIN OPENSSH PRIVATE KEY-----", "command=\"sh\" ssh-ed25519 AAAA"] {
130 assert!(add_key(&db,"one",&json!({"publicKey":key}),true).is_err());
131 }
132 }
133}
dashboard/src/main.rs+9-1
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1mod ai;1mod ai;
2mod apps;2mod apps;
3mod auth;3mod auth;
4mod identity;
4mod cache;5mod cache;
5mod core;6mod core;
6mod deploys;7mod deploys;
...@@ -451,6 +452,12 @@ async fn main() -> std::result::Result<(), Box<dyn std::error::Error>> {...@@ -451,6 +452,12 @@ async fn main() -> std::result::Result<(), Box<dyn std::error::Error>> {
451 )452 )
452 .map_err(|error| std::io::Error::other(error.message))?;453 .map_err(|error| std::io::Error::other(error.message))?;
453 if let Some(path) = std::env::args().skip(1).next() {454 if let Some(path) = std::env::args().skip(1).next() {
455 if path == "--import-commit-identity" {
456 let input = serde_json::from_reader(std::io::stdin())?;
457 let output = identity::import(&auth, &input).map_err(|error| std::io::Error::other(error.message))?;
458 println!("{output}");
459 return Ok(());
460 }
454 if path == "--guest-provider" {461 if path == "--guest-provider" {
455 let input = serde_json::from_reader(std::io::stdin())?;462 let input = serde_json::from_reader(std::io::stdin())?;
456 let output = guest::provision(&auth, input)463 let output = guest::provision(&auth, input)
...@@ -578,7 +585,8 @@ async fn main() -> std::result::Result<(), Box<dyn std::error::Error>> {...@@ -578,7 +585,8 @@ async fn main() -> std::result::Result<(), Box<dyn std::error::Error>> {
578 return next.run(request).await;585 return next.run(request).await;
579 }586 }
580 let path = request.uri().path().to_owned();587 let path = request.uri().path().to_owned();
581 let asset = path.starts_with("/assets/")588 let asset = path.starts_with("/api/account/pictures/")
589 || path.starts_with("/assets/")
582 || path.starts_with("/fonts/")590 || path.starts_with("/fonts/")
583 || path == "/snowflake.svg";591 || path == "/snowflake.svg";
584 if app.auth.ready()592 if app.auth.ready()
dashboard/src/openai.svg created+1
...@@ -0,0 +1 @@
1<svg width="721" height="721" viewBox="118.557 119.958 484.139 479.818" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M304.246 294.611V249.028C304.246 245.189 305.687 242.309 309.044 240.392L400.692 187.612C413.167 180.415 428.042 177.058 443.394 177.058C500.971 177.058 537.44 221.682 537.44 269.182C537.44 272.54 537.44 276.379 536.959 280.218L441.954 224.558C436.197 221.201 430.437 221.201 424.68 224.558L304.246 294.611ZM518.245 472.145V363.224C518.245 356.505 515.364 351.707 509.608 348.349L389.174 278.296L428.519 255.743C431.877 253.826 434.757 253.826 438.115 255.743L529.762 308.523C556.154 323.879 573.905 356.505 573.905 388.171C573.905 424.636 552.315 458.225 518.245 472.141V472.145ZM275.937 376.182L236.592 353.152C233.235 351.235 231.794 348.354 231.794 344.515V238.956C231.794 187.617 271.139 148.749 324.4 148.749C344.555 148.749 363.264 155.468 379.102 167.463L284.578 222.164C278.822 225.521 275.942 230.319 275.942 237.039V376.186L275.937 376.182ZM360.626 425.122L304.246 393.455V326.283L360.626 294.616L417.002 326.283V393.455L360.626 425.122ZM396.852 570.989C376.698 570.989 357.989 564.27 342.151 552.276L436.674 497.574C442.431 494.217 445.311 489.419 445.311 482.699V343.552L485.138 366.582C488.495 368.499 489.936 371.379 489.936 375.219V480.778C489.936 532.117 450.109 570.985 396.852 570.985V570.989ZM283.134 463.99L191.486 411.211C165.094 395.854 147.343 363.229 147.343 331.562C147.343 294.616 169.415 261.509 203.48 247.593V356.991C203.48 363.71 206.361 368.508 212.117 371.866L332.074 441.437L292.729 463.99C289.372 465.907 286.491 465.907 283.134 463.99ZM277.859 542.68C223.639 542.68 183.813 501.895 183.813 451.514C183.813 447.675 184.294 443.836 184.771 439.997L279.295 494.698C285.051 498.056 290.812 498.056 296.568 494.698L417.002 425.127V470.71C417.002 474.549 415.562 477.429 412.204 479.346L320.557 532.126C308.081 539.323 293.206 542.68 277.854 542.68H277.859ZM396.852 599.776C454.911 599.776 503.37 558.513 514.41 503.812C568.149 489.896 602.696 439.515 602.696 388.176C602.696 354.587 588.303 321.962 562.392 298.45C564.791 288.373 566.231 278.296 566.231 268.224C566.231 199.611 510.571 148.267 446.274 148.267C433.322 148.267 420.846 150.184 408.37 154.505C386.775 133.392 357.026 119.958 324.4 119.958C266.342 119.958 217.883 161.22 206.843 215.921C153.104 229.837 118.557 280.218 118.557 331.557C118.557 365.146 132.95 397.771 158.861 421.283C156.462 431.36 155.022 441.437 155.022 451.51C155.022 520.123 210.682 571.466 274.978 571.466C287.931 571.466 300.407 569.549 312.883 565.228C334.473 586.341 364.222 599.776 396.852 599.776Z" fill="currentColor"/></svg>
dashboard/src/shale.rs+120-197
...@@ -2,55 +2,16 @@ use crate::*;...@@ -2,55 +2,16 @@ use crate::*;
2use rmcp::{2use rmcp::{
3 ErrorData, RoleServer, ServerHandler,3 ErrorData, RoleServer, ServerHandler,
4 model::{4 model::{
5 CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, ListToolsResult,5 CacheScope, CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock,
6 PaginatedRequestParams, ServerCapabilities, ServerConfig, Tool, ToolAnnotations,6 ListToolsResult, PaginatedRequestParams, ServerCapabilities, ServerConfig, Tool,
7 ToolAnnotations,
7 },8 },
8 service::RequestContext,9 service::RequestContext,
9};10};
10use scraper::{Html, Selector};11use scraper::{Html, Selector};
1112
12/// r1758 rotates CSRF tokens per deletion form; the final token also works on r1763.13const MODEL_PATTERN: &str = r"^[a-z0-9][a-z0-9._-]{0,127}$";
13fn issue_csrf(document: &Html) -> Result<Option<String>> {
14 let forms = Selector::parse("ul.timeline li.comment form[method=post]").unwrap();
15 let kind = Selector::parse("input[name=t]").unwrap();
16 let id = Selector::parse("input[name=id]").unwrap();
17 let token =
18 Selector::parse("input[type=hidden][name=csrf_token], input[hidden][name=csrf_token]")
19 .unwrap();
20 let last = document
21 .select(&forms)
22 .filter(|form| {
23 form.select(&kind)
24 .any(|input| input.attr("value") == Some("delete"))
25 && form.select(&id).any(|input| {
26 input
27 .attr("value")
28 .is_some_and(|value| value.parse::<u64>().is_ok_and(|id| id > 0))
29 })
30 })
31 .last();
32 let Some(form) = last else { return Ok(None) };
33 let tokens: Vec<_> = form.select(&token).collect();
34 match tokens.as_slice() {
35 [input] => input
36 .attr("value")
37 .filter(|value| !value.is_empty())
38 .map(|value| Some(value.to_owned()))
39 .ok_or_else(|| {
40 Error::new(
41 502,
42 "Shale's issue form changed. Open the issue to edit it.",
43 )
44 }),
45 _ => Err(Error::new(
46 502,
47 "Shale's issue form changed. Open the issue to edit it.",
48 )),
49 }
50}
5114
52/// The verified r1616 build predates tokenized forms. Its cookie mutations are
53/// protected by the service's exact-Origin gate; never infer this from a missing token alone.
54fn tokenless_r1616(document: &Html) -> bool {15fn tokenless_r1616(document: &Html) -> bool {
55 if document16 if document
56 .select(&Selector::parse("input[name=csrf_token]").unwrap())17 .select(&Selector::parse("input[name=csrf_token]").unwrap())
...@@ -63,24 +24,6 @@ fn tokenless_r1616(document: &Html) -> bool {...@@ -63,24 +24,6 @@ fn tokenless_r1616(document: &Html) -> bool {
63 matches!(links.as_slice(), [link] if text(*link) == "shale r1616-ga87d2f5.zig.0.16.0")24 matches!(links.as_slice(), [link] if text(*link) == "shale r1616-ga87d2f5.zig.0.16.0")
64}25}
6526
66fn prepare_issue_csrf(document: &Html, fields: &mut HashMap<String, String>) -> Result<()> {
67 if tokenless_r1616(document) && !fields.contains_key("csrf_token") {
68 return Ok(());
69 }
70 if fields
71 .get("csrf_token")
72 .is_none_or(|token| token.is_empty())
73 {
74 return Err(Error::new(
75 502,
76 "Shale's issue form changed. Open the issue to edit it.",
77 ));
78 }
79 if let Some(token) = issue_csrf(document)? {
80 fields.insert("csrf_token".to_owned(), token);
81 }
82 Ok(())
83}
8427
85pub struct Backend {28pub struct Backend {
86 pub(crate) origin: url::Url,29 pub(crate) origin: url::Url,
...@@ -373,7 +316,7 @@ fn issue(html: &str, repository: &str, id: Option<u64>) -> Result<Value> {...@@ -373,7 +316,7 @@ fn issue(html: &str, repository: &str, id: Option<u64>) -> Result<Value> {
373 )316 )
374 })?;317 })?;
375 let author = comment318 let author = comment
376 .select(&Selector::parse(".n-card__header a[href^='/~'], .n-card__header a[href^='https://github.com/'], .n-card__header a:not([href]).va-middle-childs").unwrap())319 .select(&Selector::parse(".n-card__header span.usa-link.va-middle-childs, .n-card__header a[href^='/~'], .n-card__header a[href^='https://github.com/'], .n-card__header a:not([href]).va-middle-childs").unwrap())
377 .next()320 .next()
378 .map(text);321 .map(text);
379 let time = comment322 let time = comment
...@@ -392,6 +335,34 @@ fn issue(html: &str, repository: &str, id: Option<u64>) -> Result<Value> {...@@ -392,6 +335,34 @@ fn issue(html: &str, repository: &str, id: Option<u64>) -> Result<Value> {
392 json!({"repository":repository,"id":issue_id,"title":title.unwrap(),"status":status,"labels":labels,"comments":comments}),335 json!({"repository":repository,"id":issue_id,"title":title.unwrap(),"status":status,"labels":labels,"comments":comments}),
393 )336 )
394}337}
338fn labels(html: &str, repository: &str) -> Result<Value> {
339 let document = document(html, "page-labels", Some(repository))?;
340 let mut labels = Vec::new();
341 for row in document.select(&Selector::parse(".grid-container table tbody tr").unwrap()) {
342 let cells: Vec<_> = row.select(&Selector::parse("td").unwrap()).collect();
343 let label = row
344 .select(&Selector::parse("a.issuelabel[data-label-id][data-color]").unwrap())
345 .next();
346 if cells.len() != 3 || label.is_none() {
347 return Err(Error::new(
348 502,
349 "Shale's label list changed. Open its labels page.",
350 ));
351 }
352 let label = label.unwrap();
353 let id = label
354 .attr("data-label-id")
355 .and_then(|s| s.parse::<u64>().ok())
356 .filter(|id| *id > 0)
357 .ok_or_else(|| Error::new(502, "Shale's label IDs changed. Open its labels page."))?;
358 let open = text(cells[2])
359 .parse::<u64>()
360 .map_err(|_| Error::new(502, "Shale's label counts changed. Open its labels page."))?;
361 labels.push(json!({"id":id,"name":text(label),"description":text(cells[1]),"color":label.attr("data-color"),"openIssues":open}));
362 }
363 Ok(json!({"repository":repository,"labels":labels}))
364}
365
395fn current(app: &App, grant: &Value, credential: &str) -> Result<()> {366fn current(app: &App, grant: &Value, credential: &str) -> Result<()> {
396 let db = app.mcp.db.lock().unwrap();367 let db = app.mcp.db.lock().unwrap();
397 if mcp::get(&db, &format!("grant:{}", string(&grant["id"])))? != *grant368 if mcp::get(&db, &format!("grant:{}", string(&grant["id"])))? != *grant
...@@ -411,6 +382,7 @@ struct Shale(Arc<App>);...@@ -411,6 +382,7 @@ struct Shale(Arc<App>);
411impl ServerHandler for Shale {382impl ServerHandler for Shale {
412 fn get_info(&self) -> ServerConfig {383 fn get_info(&self) -> ServerConfig {
413 ServerConfig::new(ServerCapabilities::builder().enable_tools().build())384 ServerConfig::new(ServerCapabilities::builder().enable_tools().build())
385 .with_instructions("Every write requires the actual model identifier in model and uses a durable ai-[owner]-[model] account on behalf of the connection owner. Never invent a model or supply a different owner. Label writes require the repository owner’s connection. Status writes require the owner’s connection or an explicit repository delegation. Issue text automatically receives an Assisted-by trailer matching model. Create confirmed tasks with status todo; use triage for unanswered questions (the default). Never retry an uncertain write without reading its outcome.")
414 }386 }
415 async fn list_tools(387 async fn list_tools(
416 &self,388 &self,
...@@ -420,17 +392,30 @@ impl ServerHandler for Shale {...@@ -420,17 +392,30 @@ impl ServerHandler for Shale {
420 let tools = [392 let tools = [
421 ("list_repositories", "List repositories granted to this connection.", json!({}), json!([]), true),393 ("list_repositories", "List repositories granted to this connection.", json!({}), json!([]), true),
422 ("list_issues", "List issues in one granted repository.", json!({"repository":{"type":"string"},"q":{"type":"string","maxLength":4096,"description":"Shale filters using is, status, sort, limit, label, or author prefixes, such as is:open. Plain text search is unsupported."}}), json!(["repository"]), true),394 ("list_issues", "List issues in one granted repository.", json!({"repository":{"type":"string"},"q":{"type":"string","maxLength":4096,"description":"Shale filters using is, status, sort, limit, label, or author prefixes, such as is:open. Plain text search is unsupported."}}), json!(["repository"]), true),
395 ("list_labels", "List repository label IDs, names, descriptions, colors, and open issue counts.", json!({"repository":{"type":"string"}}), json!(["repository"]), true),
396 ("create_label", "Create a repository label using its owner's connection.", json!({"repository":{"type":"string"},"name":{"type":"string","minLength":1,"maxLength":256},"description":{"type":"string","maxLength":4096},"color":{"type":"string","pattern":"^#[0-9a-fA-F]{6}$"}}), json!(["repository","name","description","color"]), false),
397 ("update_label", "Replace a label's name, description, and color using its owner's connection.", json!({"repository":{"type":"string"},"label_id":{"type":"integer","minimum":1},"name":{"type":"string","minLength":1,"maxLength":256},"description":{"type":"string","maxLength":4096},"color":{"type":"string","pattern":"^#[0-9a-fA-F]{6}$"}}), json!(["repository","label_id","name","description","color"]), false),
398 ("delete_label", "Delete a repository label using its owner's connection. Labels referenced by issue history cannot be deleted.", json!({"repository":{"type":"string"},"label_id":{"type":"integer","minimum":1}}), json!(["repository","label_id"]), false),
399 ("set_issue_labels", "Replace an issue's labels with repository label IDs using its owner's connection. An empty array clears them.", json!({"repository":{"type":"string"},"id":{"type":"integer","minimum":1},"labels":{"type":"array","items":{"type":"integer","minimum":1},"maxItems":100,"uniqueItems":true}}), json!(["repository","id","labels"]), false),
423 ("get_issue", "Read an issue with rendered comment text and labels.", json!({"repository":{"type":"string"},"id":{"type":"integer","minimum":1}}), json!(["repository","id"]), true),400 ("get_issue", "Read an issue with rendered comment text and labels.", json!({"repository":{"type":"string"},"id":{"type":"integer","minimum":1}}), json!(["repository","id"]), true),
424 ("create_issue", "Create an issue. If the outcome is unknown, inspect the repository before retrying.", json!({"repository":{"type":"string"},"title":{"type":"string","minLength":1,"maxLength":4096},"description":{"type":"string","maxLength":262144}}), json!(["repository","title"]), false),401 ("create_issue", "Create an issue. If the outcome is unknown, inspect the repository before retrying.", json!({"repository":{"type":"string"},"title":{"type":"string","minLength":1,"maxLength":4096},"description":{"type":"string","maxLength":262144},"status":{"type":"string","enum":["todo","triage"],"default":"triage","description":"todo for confirmed tasks; triage for unanswered questions."}}), json!(["repository","title"]), false),
425 ("comment_issue", "Add a comment. If the outcome is unknown, inspect the issue before retrying.", json!({"repository":{"type":"string"},"id":{"type":"integer","minimum":1},"comment":{"type":"string","minLength":1,"maxLength":262144}}), json!(["repository","id","comment"]), false),402 ("comment_issue", "Add a comment. If the outcome is unknown, inspect the issue before retrying.", json!({"repository":{"type":"string"},"id":{"type":"integer","minimum":1},"comment":{"type":"string","minLength":1,"maxLength":262144}}), json!(["repository","id","comment"]), false),
426 ("set_issue_status", "Change issue status using an available Shale status.", json!({"repository":{"type":"string"},"id":{"type":"integer","minimum":1},"status":{"type":"string","maxLength":64}}), json!(["repository","id","status"]), false),403 ("set_issue_status", "Change issue status using an available Shale status.", json!({"repository":{"type":"string"},"id":{"type":"integer","minimum":1},"status":{"type":"string","maxLength":64}}), json!(["repository","id","status"]), false),
427 ("set_issue_title", "Change an issue title.", json!({"repository":{"type":"string"},"id":{"type":"integer","minimum":1},"title":{"type":"string","minLength":1,"maxLength":4096}}), json!(["repository","id","title"]), false),404 ("set_issue_title", "Change an issue title.", json!({"repository":{"type":"string"},"id":{"type":"integer","minimum":1},"title":{"type":"string","minLength":1,"maxLength":4096}}), json!(["repository","id","title"]), false),
428 ].into_iter().map(|(name, description, properties, required, read)| {405 ].into_iter().map(|(name, description, properties, required, read)| {
406 let mut properties = properties;
407 let mut required = required;
408 if !read {
409 properties["model"] = json!({"type":"string","pattern":MODEL_PATTERN,"description":"Actual model identifier; determines the durable author account."});
410 required.as_array_mut().unwrap().push(json!("model"));
411 }
429 Tool::new(name, description, json!({"type":"object","properties":properties,"required":required,"additionalProperties":false}).as_object().unwrap().clone())412 Tool::new(name, description, json!({"type":"object","properties":properties,"required":required,"additionalProperties":false}).as_object().unwrap().clone())
430 .with_annotations(ToolAnnotations::new().read_only(read).idempotent(read))413 .with_annotations(ToolAnnotations::new().read_only(read).idempotent(read))
431 }).collect();414 }).collect();
432 Ok(ListToolsResult {415 Ok(ListToolsResult {
433 tools,416 tools,
417 ttl_ms: Some(0),
418 cache_scope: Some(CacheScope::Private),
434 ..Default::default()419 ..Default::default()
435 })420 })
436 }421 }
...@@ -448,17 +433,28 @@ impl ServerHandler for Shale {...@@ -448,17 +433,28 @@ impl ServerHandler for Shale {
448 let arguments = request.arguments.unwrap_or_default();433 let arguments = request.arguments.unwrap_or_default();
449 let allowed: &[&str] = match name {434 let allowed: &[&str] = match name {
450 "list_repositories" => &[], "list_issues" => &["repository", "q"], "get_issue" => &["repository", "id"],435 "list_repositories" => &[], "list_issues" => &["repository", "q"], "get_issue" => &["repository", "id"],
451 "create_issue" => &["repository", "title", "description"], "comment_issue" => &["repository", "id", "comment"],436 "list_labels" => &["repository"], "create_label" => &["repository", "name", "description", "color"],
437 "update_label" => &["repository", "label_id", "name", "description", "color"], "delete_label" => &["repository", "label_id"],
438 "set_issue_labels" => &["repository", "id", "labels"],
439 "create_issue" => &["repository", "title", "description", "status"], "comment_issue" => &["repository", "id", "comment"],
452 "set_issue_status" => &["repository", "id", "status"], "set_issue_title" => &["repository", "id", "title"],440 "set_issue_status" => &["repository", "id", "status"], "set_issue_title" => &["repository", "id", "title"],
453 _ => return Err(Error::new(404, "No tool with that name.")),441 _ => return Err(Error::new(404, "No tool with that name.")),
454 };442 };
455 if arguments.keys().any(|key| !allowed.contains(&key.as_str())) {443 if arguments.keys().any(|key| key != "model" && !allowed.contains(&key.as_str())) {
456 return Err(Error::new(400, "Use the fields listed for this tool."));444 return Err(Error::new(400, "Use the fields listed for this tool."));
457 }445 }
458 let write = !matches!(name, "list_repositories" | "list_issues" | "get_issue");446 let write = !matches!(name, "list_repositories" | "list_issues" | "list_labels" | "get_issue");
459 if !array(&grant["scopes"]).iter().any(|s| s == if write {"shale:write"} else {"shale:read"}) {447 if !array(&grant["scopes"]).iter().any(|s| s == if write {"shale:write"} else {"shale:read"}) {
460 return Err(Error::new(403, "This connection allows reads only. Connect again to request issue editing."));448 return Err(Error::new(403, "This connection allows reads only. Connect again to request issue editing."));
461 }449 }
450 let model = if write {
451 Some(arguments.get("model").and_then(Value::as_str)
452 .filter(|id| regex::Regex::new(MODEL_PATTERN).unwrap().is_match(id))
453 .ok_or_else(|| Error::new(400, "Supply the actual model identifier in model."))?)
454 } else {
455 if arguments.contains_key("model") { return Err(Error::new(400, "Read tools do not need a model.")); }
456 None
457 };
462 let credential = session(app, string(&grant["user"]))?;458 let credential = session(app, string(&grant["user"]))?;
463 current(app, grant, &credential)?;459 current(app, grant, &credential)?;
464 if name == "list_repositories" {460 if name == "list_repositories" {
...@@ -470,6 +466,20 @@ impl ServerHandler for Shale {...@@ -470,6 +466,20 @@ impl ServerHandler for Shale {
470 let repository = arguments.get("repository").and_then(Value::as_str)466 let repository = arguments.get("repository").and_then(Value::as_str)
471 .filter(|r| grant["resources"] == "all" || array(&grant["resources"]).iter().any(|id| id == *r))467 .filter(|r| grant["resources"] == "all" || array(&grant["resources"]).iter().any(|id| id == *r))
472 .ok_or_else(|| Error::new(403, "Choose a repository granted to this connection."))?;468 .ok_or_else(|| Error::new(403, "Choose a repository granted to this connection."))?;
469 if matches!(name, "create_label" | "update_label" | "delete_label" | "set_issue_labels") {
470 let mut fields = arguments.clone();
471 fields.remove("repository");
472 current(app, grant, &credential)?;
473 let result = host::call(json!({"operation":"shale.write","grant":grant["id"],"repository":repository,"action":name,"arguments":fields})).await.map_err(|error| if error.status >= 500 { Error::new(502, "The label write outcome is unknown. Check the labels in Shale before retrying.") } else { error })?;
474 current(app, grant, &credential).map_err(|_| Error::new(502, "The label write outcome is unknown. Check the labels in Shale before retrying."))?;
475 return Ok(result);
476 }
477 if name == "list_labels" {
478 let target = repository_path(&app.shale.origin, repository, &["issues", "labels"])?;
479 let result = labels(&app.shale.page(&target, &credential).await?, repository)?;
480 current(app, grant, &credential)?;
481 return Ok(result);
482 }
473 let mut target = repository_path(&app.shale.origin, repository, &["issues", ""])?;483 let mut target = repository_path(&app.shale.origin, repository, &["issues", ""])?;
474 let issue_id = if matches!(name, "list_issues" | "create_issue") { None } else {484 let issue_id = if matches!(name, "list_issues" | "create_issue") { None } else {
475 Some(arguments.get("id").and_then(Value::as_u64).filter(|n| *n > 0)485 Some(arguments.get("id").and_then(Value::as_u64).filter(|n| *n > 0)
...@@ -483,7 +493,7 @@ impl ServerHandler for Shale {...@@ -483,7 +493,7 @@ impl ServerHandler for Shale {
483 }493 }
484 let mut fields = HashMap::new();494 let mut fields = HashMap::new();
485 for (key, max) in [("title", 4096), ("description", 262144), ("comment", 262144), ("status", 64)] {495 for (key, max) in [("title", 4096), ("description", 262144), ("comment", 262144), ("status", 64)] {
486 if allowed.contains(&key) {496 if allowed.contains(&key) && !(key == "status" && name == "create_issue") {
487 let value = arguments.get(key).and_then(Value::as_str)497 let value = arguments.get(key).and_then(Value::as_str)
488 .filter(|v| v.len() <= max && (key == "description" || !v.trim().is_empty()))498 .filter(|v| v.len() <= max && (key == "description" || !v.trim().is_empty()))
489 .or_else(|| (key == "description" && !arguments.contains_key(key)).then_some(""))499 .or_else(|| (key == "description" && !arguments.contains_key(key)).then_some(""))
...@@ -491,6 +501,18 @@ impl ServerHandler for Shale {...@@ -491,6 +501,18 @@ impl ServerHandler for Shale {
491 fields.insert(key.to_owned(), value.to_owned());501 fields.insert(key.to_owned(), value.to_owned());
492 }502 }
493 }503 }
504 if matches!(name, "create_issue" | "comment_issue") {
505 let model = model.unwrap();
506 let field = if name == "create_issue" { "description" } else { "comment" };
507 let body = fields.get_mut(field).unwrap();
508 if let Some(existing) = crate::shale_page::assisted_model(body) {
509 if existing != model { return Err(Error::new(400, "The model and Assisted-by trailer differ. Use the actual model identifier in both.")); }
510 } else {
511 *body = format!("{}\n\nAssisted-by: {model}", body.trim_end());
512 if crate::shale_page::assisted_model(body) != Some(model) { return Err(Error::new(400, "Close the code block before adding the model trailer.")); }
513 if body.len() > 262144 { return Err(Error::new(400, "Shorten the comment to leave room for its Assisted-by trailer.")); }
514 }
515 }
494 username(&app.shale.page(&app.shale.origin.join("/-/settings")?, &credential).await?)?;516 username(&app.shale.page(&app.shale.origin.join("/-/settings")?, &credential).await?)?;
495 let body = app.shale.page(&target, &credential).await?;517 let body = app.shale.page(&target, &credential).await?;
496 if name == "list_issues" {518 if name == "list_issues" {
...@@ -517,66 +539,27 @@ impl ServerHandler for Shale {...@@ -517,66 +539,27 @@ impl ServerHandler for Shale {
517 current(app, grant, &credential)?;539 current(app, grant, &credential)?;
518 return Ok(json!({"issue":issue}));540 return Ok(json!({"issue":issue}));
519 }541 }
520 let form_body = if name == "set_issue_title" {542 if name == "create_issue" {
521 issue(&body, repository, issue_id)?;543 document(&body, "page-new-issue", Some(repository))?;
522 let mut edit = target.clone(); edit.query_pairs_mut().append_pair("edit", "title");544 let status = match arguments.get("status").and_then(Value::as_str) {
523 app.shale.page(&edit, &credential).await?545 None if !arguments.contains_key("status") => "needs_triage",
524 } else { body };546 Some("triage") => "needs_triage",
525 let post_target = {547 Some("todo") => "todo",
526 let document = if name == "set_issue_title" { Html::parse_fragment(&form_body) } else {548 _ => return Err(Error::new(400, "Choose todo or triage for a new issue.")),
527 document(&form_body, if name == "create_issue" {"page-new-issue"} else {"page-issue"}, Some(repository))?
528 };549 };
529 let forms: Vec<_> = document.select(&Selector::parse("form[method=post]").unwrap())550 fields.insert("status".into(), status.into());
530 .filter(|form| if name == "create_issue" { form.select(&Selector::parse("input[name=title]").unwrap()).next().is_some() }551 } else { issue(&body, repository, issue_id)?; }
531 else { form.select(&Selector::parse("input[name=t]").unwrap()).any(|input| input.attr("value") == Some(match name {552 let mut fields = serde_json::to_value(fields)?;
532 "comment_issue" => "comment", "set_issue_status" => "status", _ => "title",553 if let Some(id) = issue_id { fields["id"] = json!(id); }
533 })) }).collect();554 fields["model"] = json!(model.unwrap());
534 if forms.len() != 1 { return Err(Error::new(502, "Shale's issue form changed. Open the issue to edit it.")); }
535 let form = forms[0];
536 let post_target = target.join(form.attr("action").unwrap_or_default())?;
537 if post_target != target || form.attr("enctype").is_some_and(|s| s != "application/x-www-form-urlencoded") {
538 return Err(Error::new(502, "Shale's form destination changed. Open the issue to edit it."));
539 }
540 if name == "set_issue_status" && !form.select(&Selector::parse("select[name=status] option").unwrap())
541 .any(|option| option.attr("value") == fields.get("status").map(String::as_str)) {
542 return Err(Error::new(400, "Choose a status available on this issue in Shale."));
543 }
544 for field in form.select(&Selector::parse("input[type=hidden], input[hidden]").unwrap()) {
545 if let Some(name) = field.attr("name") {
546 if fields.insert(name.to_owned(), field.attr("value").unwrap_or_default().to_owned()).is_some() {
547 return Err(Error::new(502, "Shale's form fields changed. Open the issue to edit it."));
548 }
549 }
550 }
551 if matches!(name, "comment_issue" | "set_issue_status") {
552 prepare_issue_csrf(&document, &mut fields)?;
553 }
554 fields.insert("timezone".to_owned(), "UTC".to_owned());
555 fields.insert("tzoffset".to_owned(), "+00:00".to_owned());
556 post_target
557 };
558 current(app, grant, &credential)?;555 current(app, grant, &credential)?;
559 let outcome: Result<Value> = async {556 let result = host::call(json!({"operation":"shale.write","grant":grant["id"],"repository":repository,"action":name,"arguments":fields})).await
560 let (status, headers, body) = app.shale.request(Method::POST, post_target.clone(), Some(&credential), Some(&fields)).await?;557 .map_err(|error| if error.status >= 500 { Error::new(502, "The write outcome is unknown. Check Shale before retrying.") } else { error })?;
561 let body = if matches!(status, StatusCode::SEE_OTHER | StatusCode::FOUND) {558 let id = result["id"].as_u64().ok_or_else(|| Error::new(502, "The write outcome is unknown. Check Shale before retrying."))?;
562 let target = headers.get("location").and_then(|v| v.to_str().ok())559 let target = repository_path(&app.shale.origin, repository, &["issues", &id.to_string()])?;
563 .and_then(|location| post_target.join(location).ok())560 let result = issue(&app.shale.page(&target, &credential).await?, repository, Some(id))?;
564 .ok_or_else(|| Error::new(502, "Shale didn't return an issue destination."))?;561 current(app, grant, &credential).map_err(|_| Error::new(502, "The write outcome is unknown. Check Shale before retrying."))?;
565 let prefix = repository_path(&app.shale.origin, repository, &["issues", ""])?;562 Ok(json!({"issue":result}))
566 if target.origin() != app.shale.origin.origin() || !target.path().starts_with(prefix.path())
567 || target.query().is_some() || target.fragment().is_some()
568 || target.path()[prefix.path().len()..].parse::<u64>().ok().is_none_or(|n| n == 0)
569 || !target.username().is_empty() || target.password().is_some()
570 { return Err(Error::new(502, "Shale returned a different destination.")); }
571 app.shale.page(&target, &credential).await?
572 } else if status == StatusCode::OK { body } else {
573 return Err(Error::new(502, "Shale didn't confirm the issue change."));
574 };
575 let result = issue(&body, repository, issue_id)?;
576 current(app, grant, &credential)?;
577 Ok(json!({"issue":result}))
578 }.await;
579 outcome.map_err(|_| Error::new(502, "The write outcome is unknown. Check the issue in Shale before retrying."))
580 }.await;563 }.await;
581 Ok(match result {564 Ok(match result {
582 Ok(value) => CallToolResult::structured(value),565 Ok(value) => CallToolResult::structured(value),
...@@ -881,80 +864,18 @@ pub async fn oauth(app: Arc<App>, request: Request) -> Response {...@@ -881,80 +864,18 @@ pub async fn oauth(app: Arc<App>, request: Request) -> Response {
881mod tests {864mod tests {
882 use super::*;865 use super::*;
883 #[test]866 #[test]
884 fn issue_csrf_uses_last_deletion_token_and_refuses_ambiguous_markup() {867 fn label_catalog_keeps_native_ids_and_rejects_wrong_pages() {
885 let initial = "<form method=post><input type=hidden name=t value=status><input type=hidden name=csrf_token value=old></form><form method=post><input type=hidden name=t value=comment><input type=hidden name=csrf_token value=old></form>";868 let page = "<meta name='astheno.shale.repo.name' content='chat'><body id=page-labels><div class=grid-container><table><tbody><tr><td><a class=issuelabel data-label-id=75 data-color='#123456'>Bug &amp; fix</a></td><td>Description</td><td>2</td></tr></tbody></table></div></body>";
886 assert_eq!(issue_csrf(&Html::parse_document(initial)).unwrap(), None);869 let catalog = labels(page, "chat").unwrap();
887 let deletion = |value: &str| {
888 format!(
889 "<ul class=timeline><li class=comment><form method=post><input type=hidden name=t value=delete><input type=hidden name=id value=1><input type=hidden name=csrf_token value={value}></form></li></ul>"
890 )
891 };
892 let page = format!(
893 "{initial}{}{}<form method=post><input type=hidden name=csrf_token value=unrelated></form>",
894 deletion("first"),
895 deletion("latest")
896 );
897 assert_eq!(870 assert_eq!(
898 issue_csrf(&Html::parse_document(&page)).unwrap().as_deref(),871 catalog["labels"][0],
899 Some("latest")872 json!({"id":75,"name":"Bug & fix","description":"Description","color":"#123456","openIssues":2})
900 );873 );
901 for bad in [874 assert!(labels(page, "other").is_err());
902 "<input type=hidden name=csrf_token value=''>",
903 "",
904 "<input type=hidden name=csrf_token value=a><input type=hidden name=csrf_token value=b>",
905 ] {
906 let page = format!(
907 "{initial}{}<ul class=timeline><li class=comment><form method=post><input type=hidden name=t value=delete><input type=hidden name=id value=1>{bad}</form></li></ul>",
908 deletion("older")
909 );
910 assert!(issue_csrf(&Html::parse_document(&page)).is_err());
911 }
912 }
913 #[test]
914 fn tokenless_issue_forms_require_verified_r1616_footer_and_no_tokens_anywhere() {
915 const FOOTER: &str = "<footer class='usa-footer usa-footer--slim'><div class=usa-footer__secondary-section><div>generated by <a href='https://astheno.software/shale/' class=usa-link>shale r1616-ga87d2f5.zig.0.16.0</a> (git 2.54.0)</div></div></footer>";
916 const FORMS: &str = "<form method=post><input type=hidden name=t value=status></form><form method=post><input type=hidden name=t value=comment></form><ul class=timeline><li class=comment><form method=post><input type=hidden name=t value=delete><input type=hidden name=id value=1></form></li></ul>";
917 let page = format!("<body id=page-issue>{FORMS}{FOOTER}</body>");
918 let mut fields = HashMap::from([
919 ("t".to_owned(), "status".to_owned()),
920 ("status".to_owned(), "done".to_owned()),
921 ]);
922 let original = fields.clone();
923 prepare_issue_csrf(&Html::parse_document(&page), &mut fields).unwrap();
924 assert_eq!(fields, original);
925 for bad in [
926 page.replace("r1616-ga87d2f5.zig.0.16.0", "r1758-new"),
927 page.replace("r1616-ga87d2f5.zig.0.16.0", "r1616-other-build"),
928 page.replace(
929 "https://astheno.software/shale/",
930 "https://other.test/shale/",
931 ),
932 format!("<body id=page-issue>{FORMS}<main class=markdown>{FOOTER}</main></body>"),
933 format!("<body id=page-issue>{FORMS}{FOOTER}{FOOTER}</body>"),
934 format!(
935 "<body id=page-issue>{FORMS}{FOOTER}<input name=csrf_token value=mixed></body>"
936 ),
937 format!("<body id=page-issue>{FORMS}{FOOTER}<input name=csrf_token value=''></body>"),
938 ] {
939 assert!(!tokenless_r1616(&Html::parse_document(&bad)));
940 assert!(
941 prepare_issue_csrf(&Html::parse_document(&bad), &mut original.clone()).is_err()
942 );
943 assert!(
944 prepare_issue_csrf(
945 &Html::parse_document(&bad),
946 &mut HashMap::from([("csrf_token".to_owned(), "selected".to_owned())])
947 )
948 .is_err()
949 );
950 }
951 // A newer or mixed page must also reject a missing/empty selected form token.
952 let newer = Html::parse_document(&page.replace("r1616-ga87d2f5.zig.0.16.0", "r1758-new"));
953 assert!(prepare_issue_csrf(&newer, &mut HashMap::new()).is_err());
954 assert!(875 assert!(
955 prepare_issue_csrf(876 labels(
956 &newer,877 &page.replace("data-label-id=75", "data-label-id=bad"),
957 &mut HashMap::from([("csrf_token".to_owned(), String::new())])878 "chat"
958 )879 )
959 .is_err()880 .is_err()
960 );881 );
...@@ -1050,7 +971,9 @@ mod tests {...@@ -1050,7 +971,9 @@ mod tests {
1050 #[test]971 #[test]
1051 fn issue_comment_authors_include_external_guest_profiles() {972 fn issue_comment_authors_include_external_guest_profiles() {
1052 let page = "<head><meta name='astheno.shale.repo.name' content='owned'></head><body id=page-issue><h1><span>#3</span><span>Title</span></h1><dl class=sidebar><dd><span class=issuestatus-done>Done</span></dd></dl><ul><li class=comment id=c1><div class=n-card__header><a href='https://github.com/paperclover'>paperclover</a></div><div class=markdown>Comment</div></li><li class=comment id=c2><div class=n-card__header><a class='usa-link va-middle-childs'>Astheno user</a></div><div class=markdown>Comment</div></li></ul></body>";973 let page = "<head><meta name='astheno.shale.repo.name' content='owned'></head><body id=page-issue><h1><span>#3</span><span>Title</span></h1><dl class=sidebar><dd><span class=issuestatus-done>Done</span></dd></dl><ul><li class=comment id=c1><div class=n-card__header><a href='https://github.com/paperclover'>paperclover</a></div><div class=markdown>Comment</div></li><li class=comment id=c2><div class=n-card__header><a class='usa-link va-middle-childs'>Astheno user</a></div><div class=markdown>Comment</div></li></ul></body>";
1053 let parsed = issue(page, "owned", Some(3)).unwrap();974 let page = page.replace("</ul>", "<li class=comment id=c3><div class=n-card__header><span class='usa-link va-middle-childs'><span>GPT-6.1 Sol</span> <small>(on behalf of <a class='usa-link va-middle-childs' href='/~clover'>~clover</a>)</small></span></div><div class=markdown>Comment</div></li></ul>");
975 let parsed = issue(&page, "owned", Some(3)).unwrap();
976 assert_eq!(parsed["comments"][2]["author"], "GPT-6.1 Sol (on behalf of ~clover)");
1054 assert_eq!(parsed["comments"][0]["author"], "paperclover");977 assert_eq!(parsed["comments"][0]["author"], "paperclover");
1055 assert_eq!(parsed["comments"][1]["author"], "Astheno user");978 assert_eq!(parsed["comments"][1]["author"], "Astheno user");
1056 }979 }
dashboard/src/shale_page.rs+366-15
...@@ -1,4 +1,6 @@...@@ -1,4 +1,6 @@
1use crate::*;1use crate::*;
2use rusqlite::OptionalExtension;
3use std::collections::HashSet;
2use axum::body::Body;4use axum::body::Body;
3use lol_html::{RewriteStrSettings, element, html_content::ContentType, text};5use lol_html::{RewriteStrSettings, element, html_content::ContentType, text};
46
...@@ -7,6 +9,7 @@ struct Profile {...@@ -7,6 +9,7 @@ struct Profile {
7 url: Option<String>,9 url: Option<String>,
8 icon: &'static str,10 icon: &'static str,
9 picture: Option<String>,11 picture: Option<String>,
12 owner: Option<(String, Option<String>)>,
10}13}
1114
12fn profiles(auth: &auth::Store) -> Result<HashMap<String, Profile>> {15fn profiles(auth: &auth::Store) -> Result<HashMap<String, Profile>> {
...@@ -41,6 +44,7 @@ fn profiles(auth: &auth::Store) -> Result<HashMap<String, Profile>> {...@@ -41,6 +44,7 @@ fn profiles(auth: &auth::Store) -> Result<HashMap<String, Profile>> {
41 name,44 name,
42 url,45 url,
43 icon,46 icon,
47 owner: None,
44 picture: if provider == "github" {48 picture: if provider == "github" {
45 Some(format!(49 Some(format!(
46 "https://avatars.githubusercontent.com/u/{subject}?s=64"50 "https://avatars.githubusercontent.com/u/{subject}?s=64"
...@@ -51,27 +55,204 @@ fn profiles(auth: &auth::Store) -> Result<HashMap<String, Profile>> {...@@ -51,27 +55,204 @@ fn profiles(auth: &auth::Store) -> Result<HashMap<String, Profile>> {
51 },55 },
52 );56 );
53 }57 }
58 let config: Option<String> = db.query_row("SELECT config FROM oidc_clients WHERE id='shale'", [], |r| r.get(0)).optional()?;
59 let config: Value = config.map(|config| serde_json::from_str(&config)).transpose()?.unwrap_or(Value::Null);
60 let mut query = db.prepare("SELECT u.username,json_extract(u.profile,'$.attributes.picture[0]'),i.identity FROM users u JOIN (
61 SELECT id AS user_id,'email:'||lower(json_extract(profile,'$.email')) AS identity FROM users WHERE json_extract(profile,'$.emailVerified')=1 AND json_extract(profile,'$.email') IS NOT NULL
62 UNION SELECT user_id,'email:'||email FROM commit_emails WHERE verified=1
63 UNION SELECT user_id,'key:'||fingerprint FROM signing_keys WHERE verified=1
64 ) i ON i.user_id=u.id WHERE json_extract(u.profile,'$.enabled')=1 AND coalesce(json_extract(u.profile,'$.kind'),'user') NOT IN ('ai','guest')")?;
65 for row in query.query_map([], |r| Ok((r.get::<_,String>(0)?,r.get::<_,Option<String>>(1)?,r.get::<_,String>(2)?)))? {
66 let (username,picture,identity) = row?;
67 let name = config["usernameAliases"][&username].as_str().unwrap_or(&username);
68 let mut url = url::Url::parse("https://shale.invalid/")?;
69 url.path_segments_mut().unwrap().pop_if_empty().push(&format!("~{name}"));
70 profiles.insert(identity, Profile { name: format!("~{name}"),url: Some(url.path().into()),icon:"",picture,owner:None });
71 }
54 Ok(profiles)72 Ok(profiles)
55}73}
5674
75pub(crate) fn assisted_model(body: &str) -> Option<&str> {
76 let line = body.trim_end().lines().last()?;
77 let model = line.strip_prefix("Assisted-by: ")?;
78 if model.is_empty() || model.len() > 128 || !model.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_')) { return None; }
79 let before = &body[..body.trim_end().len() - line.len()];
80 let mut fence = None;
81 for line in before.lines() {
82 let line = line.trim_start();
83 if line.starts_with("```") || line.starts_with("~~~") {
84 let marker = line.as_bytes()[0];
85 let count = line.bytes().take_while(|&b| b == marker).count();
86 match fence {
87 None => fence = Some((marker, count)),
88 Some((open, length)) if marker == open && count >= length && line[count..].trim().is_empty() => fence = None,
89 _ => (),
90 }
91 }
92 }
93 fence.is_none().then_some(model)
94}
95
96fn ai_profile(model: Option<&str>) -> Profile {
97 let (name, icon) = match model {
98 None => ("ai".into(), include_str!("bot.svg")),
99 Some(id) => {
100 let normalize_version = id.starts_with("claude-") || id.starts_with("gpt-");
101 let (id, icon, gpt) = if let Some(rest) = id.strip_prefix("claude-") {
102 let rest = rest.rsplit_once('-').filter(|(_, tail)| tail.len() == 8 && tail.bytes().all(|b| b.is_ascii_digit())).map(|(rest, _)| rest).unwrap_or(rest);
103 (rest, include_str!("claude.svg"), false)
104 } else if let Some(rest) = id.strip_prefix("gpt-") {
105 (rest, include_str!("openai.svg"), true)
106 } else {
107 (id, include_str!("bot.svg"), false)
108 };
109 let mut words: Vec<String> = Vec::new();
110 for part in id.split('-').filter(|part| !part.is_empty()) {
111 let number = part.find(|c: char| c.is_ascii_digit()).unwrap_or(part.len());
112 let (word, version) = part.split_at(number);
113 if !word.is_empty() {
114 let mut letters = word.chars();
115 words.push(letters.next().unwrap().to_uppercase().collect::<String>() + letters.as_str());
116 }
117 if !version.is_empty() {
118 let numeric = |value: &str| value.bytes().all(|b| b.is_ascii_digit() || b == b'.');
119 if normalize_version && numeric(version) && words.last().is_some_and(|word| numeric(word)) {
120 let previous = words.last_mut().unwrap();
121 previous.push('.');
122 previous.push_str(version);
123 } else {
124 words.push(version.into());
125 }
126 }
127 }
128 let name = words.join(" ");
129 (if gpt { format!("GPT-{name}") } else { name }, icon)
130 }
131 };
132 Profile { name, url: None, icon, picture: None, owner: None }
133}
134
57fn account_path(path: &str) -> Option<&str> {135fn account_path(path: &str) -> Option<&str> {
58 let name = path.strip_prefix("/~")?;136 let name = path.strip_prefix("/~")?;
59 let name = name.strip_suffix('/').unwrap_or(name);137 let name = name.strip_suffix('/').unwrap_or(name);
60 (name.starts_with("guest-") && !name.contains('/')).then_some(name)138 (!name.is_empty() && !name.contains('/')).then_some(name)
139}
140
141fn avatar(picture: &str) -> String {
142 let picture = picture.replace('&', "&amp;").replace('"', "&quot;").replace('<', "&lt;");
143 format!("<img src=\"{picture}\" alt=\"\" height=\"16\" referrerpolicy=\"no-referrer\">")
61}144}
62145
63fn rewrite(html: &str, origin: &url::Url, profiles: &HashMap<String, Profile>) -> Result<String> {146fn rewrite(html: &str, origin: &url::Url, profiles: &HashMap<String, Profile>, avatars: &HashMap<String, String>, open_count: Option<u64>) -> Result<String> {
64 use std::{cell::Cell, rc::Rc};147 use std::{cell::Cell, rc::Rc};
148 let markdown = Rc::new(Cell::new(0));
149 let bodies = markdown.clone();
65 let active = Rc::new(Cell::new(None::<bool>));150 let active = Rc::new(Cell::new(None::<bool>));
66 let images = active.clone();151 let images = active.clone();
67 let labels = active.clone();152 let labels = active.clone();
153 let compact = Rc::new(Cell::new(false));
154 let author_column = compact.clone();
155 let commit_author = Rc::new(std::cell::RefCell::new(None::<(String,Option<String>)>));
156 let commit_images = commit_author.clone();
157 let commit_names = commit_author.clone();
158 let signature = Rc::new(std::cell::RefCell::new(String::new()));
68 Ok(lol_html::rewrite_str(html, RewriteStrSettings::new()159 Ok(lol_html::rewrite_str(html, RewriteStrSettings::new()
160 .append_element_content_handler(text!("header .usa-nav__primary a[href]", |text| {
161 if markdown.get() == 0 && let Some(count) = open_count && let Some(start) = text.as_str().find("issues (")
162 && let Some(end) = text.as_str()[start..].find(')')
163 {
164 text.replace(&format!("{}issues ({count}){}", &text.as_str()[..start], &text.as_str()[start + end + 1..]), ContentType::Text);
165 }
166 Ok(())
167 }))
168 .append_element_content_handler(element!("#m-metainfo td", |element| {
169 *commit_author.borrow_mut() = None;
170 let closing = commit_author.clone();
171 element.on_end_tag(lol_html::end_tag!(move |_| { *closing.borrow_mut() = None; Ok(()) }))?;
172 Ok(())
173 }))
174 .append_element_content_handler(element!("#page-log table tbody td[title]", |element| {
175 if markdown.get() != 0 { return Ok(()); }
176 let Some(profile) = element.get_attribute("title").and_then(|email| profiles.get(&format!("email:{}", email.trim().to_lowercase()))) else { return Ok(()); };
177 let Some(url) = &profile.url else { return Ok(()); };
178 *commit_author.borrow_mut() = Some((url.clone(),profile.picture.clone()));
179 let closing = commit_author.clone();
180 element.on_end_tag(lol_html::end_tag!(move |_| { *closing.borrow_mut() = None; Ok(()) }))?;
181 element.prepend(&format!("<a class=\"usa-link\" href=\"{url}\">"),ContentType::Html);
182 element.append("</a>",ContentType::Html);
183 Ok(())
184 }))
185 .append_element_content_handler(element!("#page-log td[title] img, #m-metainfo td img[alt]", |element| {
186 if markdown.get() != 0 { return Ok(()); }
187 let profile = element.get_attribute("alt").and_then(|alt| alt.strip_prefix("gravatar for ").and_then(|email| profiles.get(&format!("email:{}",email.trim().to_lowercase()))));
188 if element.get_attribute("alt").is_some() && commit_images.borrow().is_none() {
189 *commit_images.borrow_mut() = profile.and_then(|profile| profile.url.as_ref().map(|url| (url.clone(),profile.picture.clone())));
190 }
191 if let Some((_,Some(picture))) = &*commit_images.borrow() {
192 element.set_attribute("src",picture)?;
193 element.set_attribute("width", "14")?;
194 element.set_attribute("height", "14")?;
195 element.set_attribute("alt", "")?;
196 element.set_attribute("referrerpolicy", "no-referrer")?;
197 }
198 Ok(())
199 }))
200 .append_element_content_handler(element!("#m-metainfo td span", |element| {
201 if markdown.get() != 0 { return Ok(()); }
202 if let Some((url,_)) = commit_names.borrow_mut().take() {
203 element.prepend(&format!("<a class=\"usa-link\" href=\"{url}\">"),ContentType::Html);
204 element.append("</a>",ContentType::Html);
205 }
206 Ok(())
207 }))
208 .append_element_content_handler(text!("#m-metainfo td span", |chunk| {
209 if markdown.get() != 0 { return Ok(()); }
210 signature.borrow_mut().push_str(chunk.as_str());
211 if !chunk.last_in_text_node() { chunk.remove(); return Ok(()); }
212 let value = std::mem::take(&mut *signature.borrow_mut());
213 let profile = value.trim().strip_prefix("Signed by SSH key ").and_then(|fingerprint| profiles.get(&format!("key:{fingerprint}")));
214 if let Some(profile) = profile {
215 let name = profile.name.replace('&',"&amp;").replace('<',"&lt;").replace('>',"&gt;");
216 let original = &value;
217 chunk.replace(&format!("{original} (<a class=\"usa-link\" href=\"{}\">{name}</a>)",profile.url.as_deref().unwrap_or("/")),ContentType::Html);
218 } else { chunk.replace(&value,ContentType::Html); }
219 Ok(())
220 }))
221 .append_element_content_handler(element!(".markdown", move |element| {
222 bodies.set(bodies.get() + 1);
223 let bodies = bodies.clone();
224 element.on_end_tag(lol_html::end_tag!(move |_| { bodies.set(bodies.get() - 1); Ok(()) }))?;
225 Ok(())
226 }))
227 .append_element_content_handler(element!(".timeline > li.spanspacer, .timeline .n-card__header > .spanspacer", |element| {
228 let style = element.get_attribute("style").unwrap_or_default();
229 element.set_attribute("style", &format!("{style};flex-wrap:wrap"))?;
230 Ok(())
231 }))
232 .append_element_content_handler(element!("#page-issues table td:nth-child(4)", move |element| {
233 author_column.set(true);
234 let author_column = author_column.clone();
235 element.on_end_tag(lol_html::end_tag!(move |_| { author_column.set(false); Ok(()) }))?;
236 Ok(())
237 }))
238 .append_element_content_handler(element!("img[src]", |element| {
239 if markdown.get() == 0
240 && let Some(url) = element.get_attribute("src").and_then(|src| url::Url::parse(&src).ok())
241 && url.host_str() == Some("api.dicebear.com")
242 && let Some(picture) = url.query_pairs().find(|(key, _)| key == "seed").and_then(|(_, seed)| avatars.get(seed.as_ref()))
243 {
244 element.set_attribute("src", picture)?;
245 element.set_attribute("alt", "")?;
246 element.set_attribute("referrerpolicy", "no-referrer")?;
247 }
248 Ok(())
249 }))
69 .append_element_content_handler(element!("a[href]", |element| {250 .append_element_content_handler(element!("a[href]", |element| {
70 let profile = element.get_attribute("href")251 let target = element.get_attribute("href").and_then(|href| origin.join(&href).ok()).filter(|target| target.origin() == origin.origin());
71 .and_then(|href| origin.join(&href).ok())252 let ai_profile = target.as_ref().filter(|target| markdown.get() == 0 && target.path().trim_end_matches('/') == "/~ai")
72 .filter(|target| target.origin() == origin.origin())253 .map(|_| ai_profile(None));
73 .and_then(|target| account_path(target.path()).and_then(|name| profiles.get(name)));254 let profile = ai_profile.as_ref().or_else(|| target.and_then(|target| account_path(target.path()).and_then(|name| profiles.get(name))).filter(|profile| profile.owner.is_none() || markdown.get() == 0));
74 active.set(profile.map(|profile| profile.picture.is_some()));255 active.set(profile.map(|profile| profile.picture.is_some() || ai_profile.is_some() || profile.owner.is_some()));
75 let Some(profile) = profile else { return Ok(()); };256 let Some(profile) = profile else { return Ok(()); };
76 let closing = active.clone();257 let closing = active.clone();
77 element.on_end_tag(lol_html::end_tag!(move |_| { closing.set(None); Ok(()) }))?;258 element.on_end_tag(lol_html::end_tag!(move |_| { closing.set(None); Ok(()) }))?;
...@@ -82,21 +263,40 @@ fn rewrite(html: &str, origin: &url::Url, profiles: &HashMap<String, Profile>) -...@@ -82,21 +263,40 @@ fn rewrite(html: &str, origin: &url::Url, profiles: &HashMap<String, Profile>) -
82 } else {263 } else {
83 for attribute in ["href", "target", "rel", "tabindex"] { element.remove_attribute(attribute); }264 for attribute in ["href", "target", "rel", "tabindex"] { element.remove_attribute(attribute); }
84 }265 }
266 if profile.owner.is_some() || ai_profile.is_some() { element.set_tag_name("span")?; }
85 let style = element.get_attribute("style").unwrap_or_default();267 let style = element.get_attribute("style").unwrap_or_default();
86 let cursor = if profile.url.is_some() { "" } else { ";cursor:default" };268 let cursor = if profile.url.is_some() { "" } else { ";cursor:default" };
87 element.set_attribute("style", &format!("{style};text-decoration:none{cursor}"))?;269 let spacing = if profile.owner.is_some() { format!(";display:inline-flex;align-items:baseline;flex-wrap:{};gap:.25em;margin-right:.25em;max-width:100%", if compact.get() { "nowrap" } else { "wrap" }) } else { String::new() };
270 element.set_attribute("style", &format!("{style};text-decoration:none{cursor}{spacing}"))?;
88 if let Some(picture) = &profile.picture {271 if let Some(picture) = &profile.picture {
89 let picture = picture.replace('&', "&amp;").replace('"', "&quot;").replace('<', "&lt;");272 element.prepend(&avatar(picture), ContentType::Html);
90 element.prepend(&format!("<img src=\"{picture}\" alt=\"\" width=\"16\" height=\"16\" referrerpolicy=\"no-referrer\" style=\"width:1em;height:1em;object-fit:cover;vertical-align:-.125em;margin-right:.3em\">"), ContentType::Html);
91 }273 }
92 let icon = profile.icon.trim().replace("<svg ", "<svg aria-hidden=\"true\" focusable=\"false\" style=\"color:inherit;stroke:currentColor;width:.85em;height:.85em;vertical-align:-.1em;margin-right:.05em\" ");274 if profile.owner.is_some() { element.append("<span class=\"va-middle-childs\" style=\"white-space:nowrap\">", ContentType::Html); }
275 let dimensions = if ai_profile.is_some() || profile.owner.is_some() { "width:1em;height:1em;margin-right:.25em" } else { "width:.85em;height:.85em;vertical-align:-.1em;margin-right:.05em" };
276 let icon = profile.icon.trim().replace("<svg ", &format!("<svg aria-hidden=\"true\" focusable=\"false\" style=\"color:inherit;stroke:currentColor;{dimensions}\" "));
93 let icon = if profile.url.is_some() { icon.replace("<path ", "<path style=\"fill:currentColor;stroke:none\" ") } else { icon };277 let icon = if profile.url.is_some() { icon.replace("<path ", "<path style=\"fill:currentColor;stroke:none\" ") } else { icon };
94 element.append(&icon, ContentType::Html);278 element.append(&icon, ContentType::Html);
95 if profile.url.is_some() {279 if profile.url.is_some() {
96 element.append("<span style=\"text-decoration:underline;text-decoration-color:currentColor\">", ContentType::Html);280 element.append("<span style=\"text-decoration:underline;text-decoration-color:currentColor\">", ContentType::Html);
97 }281 }
98 element.append(&profile.name, ContentType::Text);282 let name = if compact.get() && profile.owner.is_some() {
283 let number = profile.name.find(|c: char| c.is_ascii_digit()).unwrap_or(profile.name.len());
284 profile.name[..number].trim_end_matches([' ', '-'])
285 } else { &profile.name };
286 element.append(name, ContentType::Text);
99 if profile.url.is_some() { element.append("</span>", ContentType::Html); }287 if profile.url.is_some() { element.append("</span>", ContentType::Html); }
288 if let Some((owner, picture)) = &profile.owner {
289 element.append("</span> <small style=\"font-size:.8em;white-space:nowrap\">(", ContentType::Html);
290 if !compact.get() { element.append("on behalf of ", ContentType::Text); }
291 let mut owner_url = origin.clone();
292 owner_url.set_path(&format!("/~{owner}")); owner_url.set_query(None); owner_url.set_fragment(None);
293 let href = owner_url.path().replace('&', "&amp;").replace('"', "&quot;").replace('<', "&lt;");
294 element.append(&format!("<a class=\"usa-link va-middle-childs\" href=\"{href}\">"), ContentType::Html);
295 if let Some(picture) = picture { element.append(&avatar(picture), ContentType::Html); }
296 element.append("<span>", ContentType::Html);
297 element.append(&format!("~{owner}"), ContentType::Text);
298 element.append("</span></a>)</small> ", ContentType::Html);
299 }
100 Ok(())300 Ok(())
101 }))301 }))
102 .append_element_content_handler(element!("a[href] img, a[href] span", move |child| {302 .append_element_content_handler(element!("a[href] img, a[href] span", move |child| {
...@@ -149,7 +349,7 @@ pub async fn proxy(app: Arc<App>, request: Request) -> Result<Response> {...@@ -149,7 +349,7 @@ pub async fn proxy(app: Arc<App>, request: Request) -> Result<Response> {
149 .and_then(|v| v.to_str().ok())349 .and_then(|v| v.to_str().ok())
150 .unwrap_or_default();350 .unwrap_or_default();
151 let domain = env("STUDIO_DOMAIN", "studio.test");351 let domain = env("STUDIO_DOMAIN", "studio.test");
152 let service = host.strip_suffix(&format!(".{domain}")).unwrap_or_default();352 let service = host.strip_suffix(&format!(".{domain}")).unwrap_or_default().to_owned();
153 if service != "shale"353 if service != "shale"
154 && !(service354 && !(service
155 .strip_prefix("shale-preview-")355 .strip_prefix("shale-preview-")
...@@ -246,7 +446,37 @@ pub async fn proxy(app: Arc<App>, request: Request) -> Result<Response> {...@@ -246,7 +446,37 @@ pub async fn proxy(app: Arc<App>, request: Request) -> Result<Response> {
246 if prefix.len() <= 8 * 1024 * 1024446 if prefix.len() <= 8 * 1024 * 1024
247 && let Ok(text) = std::str::from_utf8(&prefix)447 && let Ok(text) = std::str::from_utf8(&prefix)
248 {448 {
249 let rewritten = rewrite(text, &origin, &profiles)?;449 let metadata = if service == "shale" && status.is_success() {
450 let (repository, accounts) = {
451 let document = scraper::Html::parse_document(text);
452 let repository = document.select(&scraper::Selector::parse("meta[name='astheno.shale.repo.name']").unwrap()).next().and_then(|e| e.attr("content")).map(str::to_owned);
453 let mut accounts: HashSet<String> = document.select(&scraper::Selector::parse("a[href]").unwrap()).filter_map(|element| {
454 let url = origin.join(element.attr("href")?).ok()?;
455 if url.origin() != origin.origin() { return None; }
456 account_path(url.path()).map(str::to_owned)
457 }).take(1024).collect();
458 if accounts.len() < 1024 && let Some(name) = account_path(origin.path()) { accounts.insert(name.to_owned()); }
459 (repository, accounts)
460 };
461 match tokio::time::timeout(Duration::from_secs(3), host::call(json!({"operation":"shale.metadata","repository":repository,"accounts":accounts}))).await {
462 Ok(Ok(value)) => value,
463 _ => Value::Null,
464 }
465 } else { Value::Null };
466 let mut profiles = profiles;
467 let mut avatars = HashMap::new();
468 if let Some(accounts) = metadata["accounts"].as_object() {
469 for (name, account) in accounts {
470 if let (Some(seed), Some(picture)) = (account["avatar"].as_str(), account["picture"].as_str()) {
471 avatars.insert(seed.to_owned(), picture.to_owned());
472 }
473 let Some(owner) = account["owner"].as_str() else { continue; };
474 let mut profile = ai_profile(account["model"].as_str());
475 profile.owner = Some((owner.into(), account["picture"].as_str().map(str::to_owned)));
476 profiles.insert(name.clone(), profile);
477 }
478 }
479 let rewritten = rewrite(text, &origin, &profiles, &avatars, metadata["openIssues"].as_u64())?;
250 for name in [480 for name in [
251 "content-length",481 "content-length",
252 "etag",482 "etag",
...@@ -272,6 +502,125 @@ mod tests {...@@ -272,6 +502,125 @@ mod tests {
272 use super::*;502 use super::*;
273 use scraper::{Html, Selector};503 use scraper::{Html, Selector};
274504
505 #[test]
506 fn commit_identity_keeps_entities_and_cannot_escape_its_metadata_cell() {
507 let origin = url::Url::parse("https://shale.test/repo/commit/abc").unwrap();
508 let profile = || Profile { name: "~clover".into(), url: Some("/~clover".into()), icon:"",picture:Some("https://snow.test/picture".into()),owner:None };
509 let profiles = HashMap::from([("email:known@example.test".into(),profile()),("key:SHA256:known".into(),profile())]);
510 let html = "<body id='page-commit'><table id=m-metainfo><tr><td><img src=old alt='gravatar for known@example.test'></td></tr><tr><td><img src=unknown alt='gravatar for other@example.test'><span>John &amp; Jane &LT;other@example.test&GT;</span></td></tr><tr><td><span> Signed by SSH key SHA256:known</span></td></tr></table><div class=markdown><table id=m-metainfo><tr><td><img src=example alt='gravatar for known@example.test'><span> Signed by SSH key SHA256:known</span></td></tr></table></div></body>";
511 let output = rewrite(html,&origin,&profiles,&HashMap::new(),None).unwrap();
512 let document = Html::parse_document(&output);
513 assert!(!output.contains("&amp;amp;"));
514 assert!(output.contains("John &amp; Jane &LT;other@example.test&GT;"));
515 assert_eq!(document.select(&Selector::parse("a[href='/~clover']").unwrap()).count(),1);
516 assert!(output.contains("src=unknown"));
517 assert!(output.contains("src=example"));
518 assert_eq!(document.select(&Selector::parse(".markdown a").unwrap()).count(),0);
519 let html = "<body id=page-log><table><tbody><tr><td title='known@example.test'><img src=old><span>Author &amp; name</span></td></tr><tr><td title='other@example.test'><img src=unknown><span>Other</span></td></tr></tbody></table></body>";
520 let output = rewrite(html,&origin,&profiles,&HashMap::new(),None).unwrap();
521 assert!(output.contains("Author &amp; name"));
522 let document = Html::parse_document(&output);
523 assert_eq!(document.select(&Selector::parse("a[href='/~clover']").unwrap()).count(),1);
524 assert_eq!(document.select(&Selector::parse("img[src='https://snow.test/picture']").unwrap()).count(),1);
525 assert_eq!(document.select(&Selector::parse("img[src='https://snow.test/picture'][width='14'][height='14']").unwrap()).count(),1);
526 }
527
528 #[test]
529 fn navigation_uses_the_current_repository_count() {
530 let html = "<header><ul class='usa-nav__primary'><li><a href='/chat/issues/'>issues (9)</a></li></ul></header><div class='markdown'><a href='/chat/issues/'>issues (9)</a></div>";
531 let origin = url::Url::parse("https://shale.paperclover.net/chat/issues/25").unwrap();
532 let output = rewrite(html, &origin, &HashMap::new(), &HashMap::new(), Some(8)).unwrap();
533 assert!(output.contains(">issues (8)</a>"));
534 assert!(output.contains("<div class='markdown'><a href='/chat/issues/'>issues (9)</a></div>"));
535 }
536
537 #[test]
538 fn reconstructed_markdown_anchors_cannot_relabel_humans() {
539 let html = "<div class='markdown'><b><a href='https://example.com'>external</b>again</a></div><a href='/~ai'>~ai</a><a href='/~clo'>~clo</a>";
540 let origin = url::Url::parse("https://shale.paperclover.net/chat/issues/25").unwrap();
541 let output = rewrite(html, &origin, &HashMap::new(), &HashMap::new(), None).unwrap();
542 assert!(output.contains("<a href='/~clo'>~clo</a>"));
543 assert!(output.contains("</svg>ai"));
544 }
545
546 #[test]
547 fn terminal_trailers_ignore_examples_and_unclosed_fences() {
548 assert_eq!(assisted_model("Body\n\nAssisted-by: gpt-6-astra\n"), Some("gpt-6-astra"));
549 for body in ["Assisted-by: <model-id>", "> Assisted-by: claude-opus-5.5", " Assisted-by: gpt-6-astra", "```\nAssisted-by: gpt-6-astra", "~~~~\n```\nAssisted-by: gpt-6-astra", "Assisted-by: gpt-6-astra\nMore text", "Assisted-by: gpt-6-astra extra"] {
550 assert_eq!(assisted_model(body), None, "{body}");
551 }
552 assert_eq!(assisted_model("```\nexample\n```\n\nAssisted-by: claude-opus-5.5"), Some("claude-opus-5.5"));
553 }
554
555 #[test]
556 fn ai_identity_is_durable_and_never_reads_comment_trailers() {
557 let origin = url::Url::parse("https://shale.paperclover.net/chat/issues/25").unwrap();
558 let mut opus = ai_profile(Some("claude-opus-5.5"));
559 opus.owner = Some(("clover".into(), Some("https://example.com/picture?a=1&b=2".into())));
560 let mut sol = ai_profile(Some("gpt-6.1-sol"));
561 sol.owner = Some(("clover".into(), None));
562 let profiles = HashMap::from([("ai-clover-claude-opus-5.5".into(), opus), ("ai-clover-gpt-6.1-sol".into(), sol)]);
563 let html = "<a href='/~ai-clover-claude-opus-5.5'><img src='old'><span>ai account</span></a><a href='/~ai-clover-gpt-6.1-sol'>ai account</a><div class='markdown'><a href='/~ai-clover-claude-opus-5.5'>Mention</a><p>Assisted-by: unknown-model</p></div><a href='/~clover'>~clover</a>";
564 let output = rewrite(html, &origin, &profiles, &HashMap::new(), None).unwrap();
565 let document = Html::parse_document(&output);
566 assert!(output.contains("Opus 5.5</span> <small"));
567 assert!(output.contains(">(on behalf of <a class="));
568 assert!(output.contains("GPT-6.1 Sol</span> <small"));
569 assert!(!output.contains("src=\"old\""));
570 assert!(output.contains("<a href='/~clover'>~clover</a>"));
571 assert!(output.contains(">Mention</a>"));
572 assert_eq!(document.select(&Selector::parse("a[href='/~clover']").unwrap()).count(), 3);
573 assert!(document.select(&Selector::parse("svg").unwrap()).all(|e| e.attr("aria-hidden") == Some("true") && e.attr("focusable") == Some("false")));
574 assert_eq!(ai_profile(Some("gpt-6.1-sol")).name, "GPT-6.1 Sol");
575 assert_eq!(ai_profile(Some("claude-opus-5.5")).name, "Opus 5.5");
576 assert_eq!(ai_profile(Some("claude-haiku-5-5")).name, "Haiku 5.5");
577 assert_eq!(ai_profile(Some("claude-haiku-5.5")).name, "Haiku 5.5");
578 assert_eq!(ai_profile(Some("claude-sonnet-4-5-20250929")).name, "Sonnet 4.5");
579 for (id, name) in [
580 ("claude-new-variant-5-5", "New Variant 5.5"),
581 ("gpt-6-1-new-variant", "GPT-6.1 New Variant"),
582 ("qwen-3.8-27b", "Qwen 3.8 27b"),
583 ("qwen3.8-27b", "Qwen 3.8 27b"),
584 ("big-pickle-2", "Big Pickle 2"),
585 ("llama-3-70", "Llama 3 70"),
586 ] { assert_eq!(ai_profile(Some(id)).name, name, "{id}"); }
587 }
588
589 #[test]
590 fn compact_authors_keep_native_owner_links_and_avatars() {
591 let origin = url::Url::parse("https://shale.paperclover.net/chat/issues/").unwrap();
592 let mut profile = ai_profile(Some("gpt-6.1-sol"));
593 profile.owner = Some(("clover".into(), Some("https://snowglobe.test/picture".into())));
594 let profiles = HashMap::from([("ai-clover-gpt-6.1-sol".into(), profile)]);
595 let avatars = HashMap::from([("native-clover".into(), "https://snowglobe.test/picture".into())]);
596 let html = "<body id='page-issues'><table><tr><td>1</td><td>Task</td><td>Todo</td><td><a class='usa-link va-middle-childs' href='/~ai-clover-gpt-6.1-sol'>ai</a></td></tr></table><a class='usa-link va-middle-childs' href='/~clover'><img height='16' src='https://api.dicebear.com/10.x/line-face/svg?seed=native-clover'><span>~clover</span></a><div class='markdown'><img src='https://api.dicebear.com/10.x/line-face/svg?seed=native-clover'></div>";
597 let output = rewrite(html, &origin, &profiles, &avatars, None).unwrap();
598 let document = Html::parse_document(&output);
599 let author = document.select(&Selector::parse("td:nth-child(4)").unwrap()).next().unwrap();
600 assert_eq!(author.text().collect::<String>().trim(), "GPT (~clover)");
601 assert!(!output.contains("on behalf of"));
602 let owners: Vec<_> = document.select(&Selector::parse("a[href='/~clover']").unwrap()).collect();
603 assert_eq!(owners.len(), 2);
604 for owner in owners {
605 assert_eq!(owner.attr("class"), Some("usa-link va-middle-childs"));
606 let image = owner.select(&Selector::parse("img").unwrap()).next().unwrap();
607 assert_eq!(image.attr("src"), Some("https://snowglobe.test/picture"));
608 assert_eq!(image.attr("height"), Some("16"));
609 assert_eq!(image.attr("style"), None);
610 assert_eq!(image.attr("alt"), Some(""));
611 }
612 assert!(output.contains("<div class='markdown'><img src='https://api.dicebear.com/10.x/line-face/svg?seed=native-clover'>"));
613 for (model, short) in [("qwen3.8-27b", "Qwen"), ("big-pickle-2", "Big Pickle"), ("claude-new-variant-5-5", "New Variant")] {
614 let mut profile = ai_profile(Some(model));
615 profile.owner = Some(("clover".into(), None));
616 let profiles = HashMap::from([("ai-model".into(), profile)]);
617 let html = "<body id='page-issues'><div class='grid-container'><table><tr><td>1</td><td>Task</td><td>Todo</td><td><a href='/~ai-model'>ai</a></td></tr></table></div></body>";
618 let output = rewrite(html, &origin, &profiles, &HashMap::new(), None).unwrap();
619 let document = Html::parse_document(&output);
620 assert_eq!(document.select(&Selector::parse("td:nth-child(4)").unwrap()).next().unwrap().text().collect::<String>().trim(), format!("{short} (~clover)"));
621 }
622 }
623
275 #[test]624 #[test]
276 fn rewrites_only_known_local_account_links_and_escapes_provider_names() {625 fn rewrites_only_known_local_account_links_and_escapes_provider_names() {
277 let origin = url::Url::parse("https://shale.paperclover.net/").unwrap();626 let origin = url::Url::parse("https://shale.paperclover.net/").unwrap();
...@@ -282,6 +631,7 @@ mod tests {...@@ -282,6 +631,7 @@ mod tests {
282 name: "<&\"clover".into(),631 name: "<&\"clover".into(),
283 url: Some("https://github.com/clover".into()),632 url: Some("https://github.com/clover".into()),
284 icon: include_str!("../web/sso/github.svg"),633 icon: include_str!("../web/sso/github.svg"),
634 owner: None,
285 picture: Some("https://avatars.githubusercontent.com/u/123?s=64".into()),635 picture: Some("https://avatars.githubusercontent.com/u/123?s=64".into()),
286 },636 },
287 ),637 ),
...@@ -291,12 +641,13 @@ mod tests {...@@ -291,12 +641,13 @@ mod tests {
291 name: "Astheno user".into(),641 name: "Astheno user".into(),
292 url: None,642 url: None,
293 icon: include_str!("astheno.svg"),643 icon: include_str!("astheno.svg"),
644 owner: None,
294 picture: None,645 picture: None,
295 },646 },
296 ),647 ),
297 ]);648 ]);
298 let html = r#"<a class="usa-nav-link" href="/~guest-github-123"><img src="avatar"><span>~guest-github-123</span></a><a href="https://shale.paperclover.net/~guest-astheno-abc/"><img src="astheno-avatar" alt="avatar">guest</a><a href="/~guest-github-unknown">unknown</a><a href="https://other.example/~guest-github-123">external</a><a href="/~guest-github-123/repo">repo</a><pre>~guest-github-123</pre>"#;649 let html = r#"<a class="usa-nav-link" href="/~guest-github-123"><img src="avatar"><span>~guest-github-123</span></a><a href="https://shale.paperclover.net/~guest-astheno-abc/"><img src="astheno-avatar" alt="avatar">guest</a><a href="/~guest-github-unknown">unknown</a><a href="https://other.example/~guest-github-123">external</a><a href="/~guest-github-123/repo">repo</a><pre>~guest-github-123</pre>"#;
299 let rewritten = rewrite(html, &origin, &profiles).unwrap();650 let rewritten = rewrite(html, &origin, &profiles, &HashMap::new(), None).unwrap();
300 let page = Html::parse_document(&rewritten);651 let page = Html::parse_document(&rewritten);
301 let links: Vec<_> = page.select(&Selector::parse("body > a").unwrap()).collect();652 let links: Vec<_> = page.select(&Selector::parse("body > a").unwrap()).collect();
302 assert_eq!(links[0].attr("target"), Some("_blank"));653 assert_eq!(links[0].attr("target"), Some("_blank"));
dashboard/src/users.rs+72-2
...@@ -35,6 +35,9 @@ fn profile(body: &Value, full: bool) -> Result<Value> {...@@ -35,6 +35,9 @@ fn profile(body: &Value, full: bool) -> Result<Value> {
35 let value = match key {35 let value = match key {
36 "username" => {36 "username" => {
37 let v = string(value).trim().to_lowercase();37 let v = string(value).trim().to_lowercase();
38 if v.starts_with("ai-") {
39 return Err(Error::new(400, "Usernames beginning with ai- are reserved for model accounts."));
40 }
38 if v.len() > 254 || !username_pattern.is_match(&v) {41 if v.len() > 254 || !username_pattern.is_match(&v) {
39 return Err(Error::new(42 return Err(Error::new(
40 400,43 400,
...@@ -155,7 +158,7 @@ pub async fn route(...@@ -155,7 +158,7 @@ pub async fn route(
155 for id in &users {158 for id in &users {
156 uuid(id)?;159 uuid(id)?;
157 let user = auth::user(&transaction, id)?;160 let user = auth::user(&transaction, id)?;
158 if guest::is_guest(&user) {161 if guest::is_guest(&user) || user["kind"] == "ai" {
159 return Err(Error::new(162 return Err(Error::new(
160 400,163 400,
161 "Guests can use Shale only. Select regular accounts to edit groups.",164 "Guests can use Shale only. Select regular accounts to edit groups.",
...@@ -196,7 +199,7 @@ pub async fn route(...@@ -196,7 +199,7 @@ pub async fn route(
196 }199 }
197 if parts.is_empty() && method == Method::GET {200 if parts.is_empty() && method == Method::GET {
198 let db = app.auth.db.lock().unwrap();201 let db = app.auth.db.lock().unwrap();
199 let mut statement = db.prepare("SELECT id FROM users ORDER BY username")?;202 let mut statement = db.prepare("SELECT id FROM users WHERE coalesce(json_extract(profile,'$.kind'),'')!='ai' ORDER BY username")?;
200 let ids = statement203 let ids = statement
201 .query_map([], |r| r.get::<_, String>(0))?204 .query_map([], |r| r.get::<_, String>(0))?
202 .collect::<std::result::Result<Vec<_>, _>>()?;205 .collect::<std::result::Result<Vec<_>, _>>()?;
...@@ -274,6 +277,9 @@ pub async fn route(...@@ -274,6 +277,9 @@ pub async fn route(
274 .first()277 .first()
275 .ok_or_else(|| Error::new(404, "No user here."))?;278 .ok_or_else(|| Error::new(404, "No user here."))?;
276 uuid(id)?;279 uuid(id)?;
280 if auth::user(&app.auth.db.lock().unwrap(), id)?["kind"] == "ai" {
281 return Err(Error::new(404, "No user here."));
282 }
277 if *parts == [*id, "setup-link"] && method == Method::POST {283 if *parts == [*id, "setup-link"] && method == Method::POST {
278 return Ok(axum::Json(json!({"url":app.auth.setup_link(id)?})).into_response());284 return Ok(axum::Json(json!({"url":app.auth.setup_link(id)?})).into_response());
279 }285 }
...@@ -355,6 +361,19 @@ pub async fn route(...@@ -355,6 +361,19 @@ pub async fn route(
355 }361 }
356 Value::Null362 Value::Null
357 }363 }
364 [_, "commit-identity"] if method == Method::GET => identity::account(&transaction, id)?,
365 [_, "commit-emails", email] if method == Method::PUT => {
366 let email = identity::email(&json!(percent_encoding::percent_decode_str(email).decode_utf8().map_err(|_| Error::new(400,"Enter a full email address."))?))?;
367 let exists: bool = transaction.query_row("SELECT EXISTS(SELECT 1 FROM commit_emails WHERE user_id=? AND email=?)",sql![id,email],|r|r.get(0))?;
368 if !exists { return Err(Error::new(404, "This account has not added that email.")); }
369 identity::add_email(&transaction,id,&json!(email),true)?;
370 Value::Null
371 }
372 [_, "signing-keys", fingerprint] if method == Method::PUT => {
373 let fingerprint = percent_encoding::percent_decode_str(fingerprint).decode_utf8().map_err(|_| Error::new(400,"Choose a signing key."))?;
374 identity::confirm_key(&transaction,id,&fingerprint)?;
375 Value::Null
376 }
358 [_, "credentials"] if method == Method::GET => auth::credentials(&transaction, id)?,377 [_, "credentials"] if method == Method::GET => auth::credentials(&transaction, id)?,
359 [_, "logout"] if method == Method::POST => {378 [_, "logout"] if method == Method::POST => {
360 transaction.execute("DELETE FROM sessions WHERE user_id=?", [id])?;379 transaction.execute("DELETE FROM sessions WHERE user_id=?", [id])?;
...@@ -457,6 +476,7 @@ pub async fn account(...@@ -457,6 +476,7 @@ pub async fn account(
457 let id = string(&user["id"]).to_owned();476 let id = string(&user["id"]).to_owned();
458 let value = match parts {477 let value = match parts {
459 [] if method == Method::GET => {478 [] if method == Method::GET => {
479 user["commitIdentity"] = identity::account(&app.auth.db.lock().unwrap(), &id)?;
460 user["picture"] = user["attributes"]["picture"][0].clone();480 user["picture"] = user["attributes"]["picture"][0].clone();
461 user["credentials"] = auth::credentials(&app.auth.db.lock().unwrap(), &id)?;481 user["credentials"] = auth::credentials(&app.auth.db.lock().unwrap(), &id)?;
462 user.as_object_mut().unwrap().remove("attributes");482 user.as_object_mut().unwrap().remove("attributes");
...@@ -485,6 +505,56 @@ pub async fn account(...@@ -485,6 +505,56 @@ pub async fn account(
485 auth::save_user(&app.auth.db.lock().unwrap(), &id, user)?;505 auth::save_user(&app.auth.db.lock().unwrap(), &id, user)?;
486 Value::Null506 Value::Null
487 }507 }
508 ["commit-emails"] if method == Method::POST => {
509 app.auth.recent(request.headers())?;
510 let body: Value = serde_json::from_slice(&axum::body::to_bytes(request.into_body(), 8192).await?)?;
511 let mut db = app.auth.db.lock().unwrap();
512 let tx = db.transaction()?;
513 identity::add_email(&tx, &id, &body["email"], false)?;
514 tx.commit()?;
515 Value::Null
516 }
517 ["commit-emails", email] if method == Method::PUT || method == Method::DELETE => {
518 app.auth.recent(request.headers())?;
519 let email = identity::email(&json!(percent_encoding::percent_decode_str(email).decode_utf8().map_err(|_| Error::new(400,"Enter a full email address."))?))?;
520 let mut db = app.auth.db.lock().unwrap();
521 let tx = db.transaction()?;
522 if method == Method::PUT {
523 if !array(&user["groups"]).iter().any(|group| group["name"] == "infra-admin") {
524 return Err(Error::new(403, "Ask an admin to confirm ownership of this email."));
525 }
526 let exists: bool = tx.query_row("SELECT EXISTS(SELECT 1 FROM commit_emails WHERE user_id=? AND email=?)",sql![id,email],|r|r.get(0))?;
527 if !exists { return Err(Error::new(404, "Add this email before confirming ownership.")); }
528 identity::add_email(&tx, &id, &json!(email), true)?;
529 } else {
530 tx.execute("DELETE FROM commit_emails WHERE user_id=? AND email=?",sql![id,email])?;
531 }
532 tx.commit()?;
533 Value::Null
534 }
535 ["signing-keys"] if method == Method::POST => {
536 app.auth.recent(request.headers())?;
537 let body: Value = serde_json::from_slice(&axum::body::to_bytes(request.into_body(), 32768).await?)?;
538 let mut db = app.auth.db.lock().unwrap();
539 let tx = db.transaction()?;
540 identity::add_key(&tx, &id, &body, false)?;
541 tx.commit()?;
542 Value::Null
543 }
544 ["signing-keys", fingerprint] if method == Method::PUT || method == Method::DELETE => {
545 let fingerprint = percent_encoding::percent_decode_str(fingerprint).decode_utf8().map_err(|_| Error::new(400,"Choose a signing key."))?;
546 app.auth.recent(request.headers())?;
547 let mut db = app.auth.db.lock().unwrap();
548 let tx = db.transaction()?;
549 if method == Method::PUT {
550 if !array(&user["groups"]).iter().any(|group| group["name"] == "infra-admin") {
551 return Err(Error::new(403, "Ask an admin to confirm ownership of this signing key."));
552 }
553 identity::confirm_key(&tx,&id,&fingerprint)?;
554 } else { tx.execute("DELETE FROM signing_keys WHERE user_id=? AND fingerprint=?",sql![id,fingerprint.as_ref()])?; }
555 tx.commit()?;
556 Value::Null
557 }
488 ["credentials", credential] if method == Method::DELETE => {558 ["credentials", credential] if method == Method::DELETE => {
489 app.auth.recent(request.headers())?;559 app.auth.recent(request.headers())?;
490 let mut db = app.auth.db.lock().unwrap();560 let mut db = app.auth.db.lock().unwrap();
dashboard/web/api.contract.ts+24-1
...@@ -229,6 +229,15 @@ export type Api = Hono<{}, {...@@ -229,6 +229,15 @@ export type Api = Hono<{}, {
229 $put: Endpoint<null, { param: { id: string; group: string; }; }, 204, "body">;229 $put: Endpoint<null, { param: { id: string; group: string; }; }, 204, "body">;
230 $delete: Endpoint<null, { param: { id: string; group: string; }; }, 204, "body">;230 $delete: Endpoint<null, { param: { id: string; group: string; }; }, 204, "body">;
231 };231 };
232 "/users/:id/commit-identity": {
233 $get: Endpoint<import("./types/users.ts").CommitIdentity, { param: { id: string } }>;
234 };
235 "/users/:id/commit-emails/:email": {
236 $put: Endpoint<null, { param: { id: string; email: string } }, 204, "body">;
237 };
238 "/users/:id/signing-keys/:fingerprint": {
239 $put: Endpoint<null, { param: { id: string; fingerprint: string } }, 204, "body">;
240 };
232 "/users/:id/credentials": {241 "/users/:id/credentials": {
233 $get: Endpoint<Credential[], { param: { id: string; }; }>;242 $get: Endpoint<Credential[], { param: { id: string; }; }>;
234 };243 };
...@@ -302,9 +311,23 @@ export type Api = Hono<{}, {...@@ -302,9 +311,23 @@ export type Api = Hono<{}, {
302 $post: Endpoint<null, { param: { name: string; action: "start" | "destroy" | "shutdown" | "reboot" | "resume"; }; }, 204, "body">;311 $post: Endpoint<null, { param: { name: string; action: "start" | "destroy" | "shutdown" | "reboot" | "resume"; }; }, 204, "body">;
303 };312 };
304 "/account": {313 "/account": {
305 $get: Endpoint<User & {picture: string | null; credentials:Credential[]}>;314 $get: Endpoint<User & {picture: string | null; credentials:Credential[]; commitIdentity: import("./types/users.ts").CommitIdentity}>;
306 $patch: Endpoint<null, { json: { firstName?: string | undefined; lastName?: string | undefined; email?: string | undefined; }; }, 204, "body">;315 $patch: Endpoint<null, { json: { firstName?: string | undefined; lastName?: string | undefined; email?: string | undefined; }; }, 204, "body">;
307 };316 };
317 "/account/commit-emails": {
318 $post: Endpoint<null, { json: { email: string } }, 204, "body">;
319 };
320 "/account/commit-emails/:email": {
321 $put: Endpoint<null, { param: { email: string } }, 204, "body">;
322 $delete: Endpoint<null, { param: { email: string } }, 204, "body">;
323 };
324 "/account/signing-keys": {
325 $post: Endpoint<null, { json: { publicKey: string; label: string } }, 204, "body">;
326 };
327 "/account/signing-keys/:fingerprint": {
328 $put: Endpoint<null, { param: { fingerprint: string } }, 204, "body">;
329 $delete: Endpoint<null, { param: { fingerprint: string } }, 204, "body">;
330 };
308 "/account/picture": {331 "/account/picture": {
309 $put: Endpoint<{ picture: string; }, { form: { picture: File; }; }>;332 $put: Endpoint<{ picture: string; }, { form: { picture: File; }; }>;
310 $delete: Endpoint<null, {}, 204, "body">;333 $delete: Endpoint<null, {}, 204, "body">;
dashboard/web/pages/Account.css+12
...@@ -22,3 +22,15 @@...@@ -22,3 +22,15 @@
22.credentials > .button { justify-self: end; }22.credentials > .button { justify-self: end; }
23.credentials .passkey { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }23.credentials .passkey { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
24.picture-row .button:has(:focus-visible) { outline: 2px solid var(--focus); outline-offset: 1px; }24.picture-row .button:has(:focus-visible) { outline: 2px solid var(--focus); outline-offset: 1px; }
25
26.commit-identity .credentials { grid-template-columns: max-content minmax(0, 1fr) auto; }
27.commit-key code { display: block; overflow-wrap: anywhere; font-size: 11px; }
28.commit-email { overflow-wrap: anywhere; }
29.commit-identity .actions { display: flex; flex-wrap: wrap; gap: 4px; justify-content: end; }
30.commit-identity { container-type: inline-size; }
31@container (max-width: 360px) {
32 .commit-identity .credentials { grid-template-columns: minmax(0, 1fr) auto; }
33 .commit-identity .credentials > span:empty { display: none; }
34 .commit-identity .commit-email, .commit-identity .commit-key { grid-column: 1 / -1; }
35 .commit-identity .actions { grid-column: 2; }
36}
dashboard/web/pages/Account.tsx+84-1
...@@ -1,7 +1,7 @@...@@ -1,7 +1,7 @@
1import { useSearchParams } from "@solidjs/router";1import { useSearchParams } from "@solidjs/router";
2import { createResource, createSignal, For, Show } from "solid-js";2import { createResource, createSignal, For, Show } from "solid-js";
3import { parseResponse } from "hono/client";3import { parseResponse } from "hono/client";
4import type { User } from "../types/users.ts";4import type { User, CommitIdentity as Identity } from "../types/users.ts";
5import { api, reason } from "../api.ts";5import { api, reason } from "../api.ts";
6import { Ago } from "../components/Ago.tsx";6import { Ago } from "../components/Ago.tsx";
7import { lastGood, Loaded } from "../components/Loaded.tsx";7import { lastGood, Loaded } from "../components/Loaded.tsx";
...@@ -73,6 +73,7 @@ export function Account() {...@@ -73,6 +73,7 @@ export function Account() {
73 <Show when={user().requiredActions.length}><section class="card"><p class="muted">Finish your profile and change any temporary password to open Snowglobe and Files.</p></section></Show>73 <Show when={user().requiredActions.length}><section class="card"><p class="muted">Finish your profile and change any temporary password to open Snowglobe and Files.</p></section></Show>
74 <Profile user={user()} />74 <Profile user={user()} />
75 <SignIn user={user()} />75 <SignIn user={user()} />
76 <CommitIdentity user={user()} identity={user().commitIdentity} refetch={refetchAccount} />
76 </div>77 </div>
77 )}78 )}
78 </Loaded>79 </Loaded>
...@@ -227,3 +228,85 @@ function SignIn(props: { user: Self }) {...@@ -227,3 +228,85 @@ function SignIn(props: { user: Self }) {
227 <Show when={error()}><p class="error" role="alert">{error()}</p></Show>228 <Show when={error()}><p class="error" role="alert">{error()}</p></Show>
228 </section>;229 </section>;
229}230}
231
232export function CommitIdentity(props: { user: User; identity: Identity; refetch: () => unknown; admin?: boolean }) {
233 const [busy, setBusy] = createSignal(false);
234 const [error, setError] = createSignal("");
235 const admin = () => props.admin || props.user.groups.some(group => group.name === "infra-admin");
236 const run = async (work: () => Promise<unknown>) => {
237 if (busy()) return;
238 setBusy(true); setError("");
239 try { await work(); await props.refetch(); }
240 catch (failure) { setError(reason(failure)); throw failure; }
241 finally { setBusy(false); }
242 };
243 const addEmail = () => showConfirmDialog({
244 title: "Add commit email", confirmLabel: "add email",
245 description: "An admin must confirm ownership before commits link to your profile.",
246 body: <label class="field">email<input class="search" name="email" type="email" required maxLength={254} autofocus /></label>,
247 onConfirm: async form => {
248 const count = props.identity.emails.length;
249 await run(() => parseResponse(api.account["commit-emails"].$post({json:{email:String(form.get("email"))}})));
250 toast(props.identity.emails.length === count ? "This email is already added" : "Added commit email");
251 },
252 });
253 const addKey = () => showConfirmDialog({
254 title: "Add signing key", confirmLabel: "add key",
255 description: "An admin must confirm key ownership before signatures link to your profile.",
256 body: <>
257 <label class="field">label<input class="search" name="label" maxLength={128} autofocus /></label>
258 <label class="field">SSH public key<textarea class="search" name="publicKey" required maxLength={16384} rows={4} spellcheck={false} /></label>
259 </>,
260 onConfirm: async form => {
261 const count = props.identity.signingKeys.length;
262 await run(() => parseResponse(api.account["signing-keys"].$post({json:{label:String(form.get("label")),publicKey:String(form.get("publicKey"))}})));
263 toast(props.identity.signingKeys.length === count ? "This key is already added; its label stays the same" : "Added signing key");
264 },
265 });
266 return <section class="card commit-identity">
267 <h2 class="card-title">commit identity</h2>
268 <p class="muted">Emails link commit authors to your Shale profile. Signing keys identify signers.</p>
269 <div class="credentials">
270 <span class="label">emails</span><span /><Show when={!props.admin} fallback={<span />}><button class="button small" disabled={busy()} onClick={addEmail}>add email</button></Show>
271 <Show when={props.user.email && props.user.emailVerified}><span /><span class="commit-email">{props.user.email} <span class="muted">primary</span></span><span /></Show>
272 <For each={props.identity.emails}>{email => <>
273 <span /><span class="commit-email">{email.email} <span class="muted">{email.verified ? "confirmed" : "awaiting confirmation"}</span></span>
274 <div class="actions">
275 <Show when={!email.verified && admin()}><button class="button small" aria-label={`Confirm email ${email.email}`} disabled={busy()} onClick={() => showConfirmDialog({
276 title: "Confirm email ownership", confirmLabel: "confirm ownership",
277 description: <>Confirm that {email.email} belongs to this account. Its commits will link to this account's Shale profile.</>,
278 onConfirm: () => run(() => parseResponse(props.admin
279 ? api.users[":id"]["commit-emails"][":email"].$put({param:{id:props.user.id,email:email.email}})
280 : api.account["commit-emails"][":email"].$put({param:{email:email.email}}))),
281 })}>confirm</button></Show>
282 <Show when={!props.admin}><button class="button small" disabled={busy()} aria-label={`Remove email ${email.email}`} onClick={() => showConfirmDialog({
283 title: "Remove commit email", confirmLabel: "remove email", destructive:true,
284 description: <>Commits using {email.email} will lose this profile link.</>,
285 onConfirm: () => run(() => parseResponse(api.account["commit-emails"][":email"].$delete({param:{email:email.email}}))),
286 })}>remove</button></Show>
287 </div>
288 </>}</For>
289 <Show when={!props.identity.emails.length && !(props.user.email && props.user.emailVerified)}><span /><span class="muted">No confirmed emails</span><span /></Show>
290 <span class="label">signing keys</span><span /><Show when={!props.admin} fallback={<span />}><button class="button small" disabled={busy()} onClick={addKey}>add key</button></Show>
291 <For each={props.identity.signingKeys}>{key => <>
292 <span /><span class="commit-key">{key.label || "unnamed key"} <span class="muted">{key.verified ? "confirmed" : "awaiting confirmation"}</span><code class="muted">{key.fingerprint}</code></span>
293 <div class="actions">
294 <Show when={!key.verified && admin()}><button class="button small" aria-label={`Confirm signing key ${key.label || key.fingerprint}`} disabled={busy()} onClick={() => showConfirmDialog({
295 title: "Confirm key ownership", confirmLabel: "confirm ownership",
296 description: <>Confirm that {key.label || key.fingerprint} belongs to this account. Its signatures will link to this account's Shale profile.</>,
297 onConfirm: () => run(() => parseResponse(props.admin
298 ? api.users[":id"]["signing-keys"][":fingerprint"].$put({param:{id:props.user.id,fingerprint:key.fingerprint}})
299 : api.account["signing-keys"][":fingerprint"].$put({param:{fingerprint:key.fingerprint}}))),
300 })}>confirm</button></Show>
301 <Show when={!props.admin}><button class="button small" disabled={busy()} aria-label={`Remove signing key ${key.label || key.fingerprint}`} onClick={() => showConfirmDialog({
302 title:"Remove signing key", confirmLabel:"remove key", destructive:true,
303 description: <>Signatures using {key.label || key.fingerprint} will lose this profile link.</>,
304 onConfirm: () => run(() => parseResponse(api.account["signing-keys"][":fingerprint"].$delete({param:{fingerprint:key.fingerprint}}))),
305 })}>remove</button></Show>
306 </div>
307 </>}</For>
308 <Show when={!props.identity.signingKeys.length}><span /><span class="muted">No signing keys</span><span /></Show>
309 </div>
310 <Show when={error()}><p class="error" role="alert">{error()}</p></Show>
311 </section>;
312}
dashboard/web/pages/Users.tsx+9
...@@ -22,6 +22,7 @@ import { TabBar } from "../components/TabBar.tsx";...@@ -22,6 +22,7 @@ import { TabBar } from "../components/TabBar.tsx";
22import { toast } from "../components/Toast.tsx";22import { toast } from "../components/Toast.tsx";
23import { ago, count, date, datetime, plural } from "../format.ts";23import { ago, count, date, datetime, plural } from "../format.ts";
24import "./Users.css";24import "./Users.css";
25import { CommitIdentity } from "./Account.tsx";
2526
26const STEPS: [string, string][] = [["SETUP", "finish setup"], ["UPDATE_PASSWORD", "new password"], ["UPDATE_PROFILE", "check profile"]];27const STEPS: [string, string][] = [["SETUP", "finish setup"], ["UPDATE_PASSWORD", "new password"], ["UPDATE_PROFILE", "check profile"]];
2728
...@@ -33,6 +34,7 @@ const load = () => Promise.all([parseResponse(api.users.$get()), parseResponse(a...@@ -33,6 +34,7 @@ const load = () => Promise.all([parseResponse(api.users.$get()), parseResponse(a
33 .then(([{ users, groups }, services]) => ({ users, groups, services }));34 .then(([{ users, groups }, services]) => ({ users, groups, services }));
34type Data = Awaited<ReturnType<typeof load>>;35type Data = Awaited<ReturnType<typeof load>>;
35export const directory = query("users", load);36export const directory = query("users", load);
37const identityOf = query("commit-identity", (id: string) => parseResponse(api.users[":id"]["commit-identity"].$get({ param: { id } })));
36const credentialsOf = query("credentials", (id: string) => parseResponse(api.users[":id"].credentials.$get({ param: { id } })));38const credentialsOf = query("credentials", (id: string) => parseResponse(api.users[":id"].credentials.$get({ param: { id } })));
37type User = Data["users"][number];39type User = Data["users"][number];
3840
...@@ -466,6 +468,7 @@ function Profile(props: { user: User; data: Data; refetch: () => unknown }) {...@@ -466,6 +468,7 @@ function Profile(props: { user: User; data: Data; refetch: () => unknown }) {
466 const back = useBack();468 const back = useBack();
467 const param = () => ({ id: props.user.id });469 const param = () => ({ id: props.user.id });
468 const [credentials, credentialActions] = credentialsOf.use(() => props.user.id);470 const [credentials, credentialActions] = credentialsOf.use(() => props.user.id);
471 const [identity, identityActions] = identityOf.use(() => props.user.id);
469 const [busy, setBusy] = createSignal<string>();472 const [busy, setBusy] = createSignal<string>();
470473
471 const patch = async (json: Parameters<typeof api.users[":id"]["$patch"]>[0]["json"]) => {474 const patch = async (json: Parameters<typeof api.users[":id"]["$patch"]>[0]["json"]) => {
...@@ -644,6 +647,12 @@ function Profile(props: { user: User; data: Data; refetch: () => unknown }) {...@@ -644,6 +647,12 @@ function Profile(props: { user: User; data: Data; refetch: () => unknown }) {
644 </div>647 </div>
645 </section>648 </section>
646649
650 <Show when={props.user.kind !== "guest"}>
651 <Loaded data={identity} what="commit identity" retry={identityActions.refetch} skeleton={<div class="skeleton" style={{height:"100px"}} />}>
652 {data => <CommitIdentity user={props.user} identity={data()} refetch={identityActions.refetch} admin />}
653 </Loaded>
654 </Show>
655
647 <section class="card">656 <section class="card">
648 <h2 class="card-title">657 <h2 class="card-title">
649 sessions658 sessions
dashboard/web/types/users.ts+5
...@@ -33,3 +33,8 @@ export interface Session {...@@ -33,3 +33,8 @@ export interface Session {
33 lastAccess: number;33 lastAccess: number;
34 clients: Record<string, string>;34 clients: Record<string, string>;
35}35}
36
37export interface CommitIdentity {
38 emails: { email: string; verified: boolean }[];
39 signingKeys: { fingerprint: string; label: string; publicKey: string; createdDate: number; verified: boolean }[];
40}
nixos/configuration.nix+5-3
...@@ -6,7 +6,7 @@ let...@@ -6,7 +6,7 @@ let
6 proxyToken = "/var/lib/studio/dashboard-proxy.token";6 proxyToken = "/var/lib/studio/dashboard-proxy.token";
7 hostTools = lib.fileset.toSource {7 hostTools = lib.fileset.toSource {
8 root = ../.;8 root = ../.;
9 fileset = lib.fileset.unions [ ../tools/dashboard-host.py ../tools/vms.py ../tools/dashboard-run.py ../tools/release.py ../guest/protocol.json ];9 fileset = lib.fileset.unions [ ../tools/dashboard-host.py ../tools/shale-labels.py ../config/shale.json ../dashboard/src/host-protocol.json ../tools/vms.py ../tools/dashboard-run.py ../tools/release.py ../guest/protocol.json ];
10 };10 };
11 nativePkl = pkgs.callPackage ./pkl.nix { };11 nativePkl = pkgs.callPackage ./pkl.nix { };
12 screenPython = pkgs.python3.withPackages (packages: [ packages.pygobject3 packages.gst-python ]);12 screenPython = pkgs.python3.withPackages (packages: [ packages.pygobject3 packages.gst-python ]);
...@@ -16,6 +16,7 @@ let...@@ -16,6 +16,7 @@ let
16 msVarsTemplate = true;16 msVarsTemplate = true;
17 tpmSupport = true;17 tpmSupport = true;
18 };18 };
19
19in20in
20{21{
21 nixpkgs.config.allowUnfreePredicate = pkg: builtins.elem (lib.getName pkg) [ "nomad" "nvidia-x11" "nvidia-kernel-modules" ];22 nixpkgs.config.allowUnfreePredicate = pkg: builtins.elem (lib.getName pkg) [ "nomad" "nvidia-x11" "nvidia-kernel-modules" ];
...@@ -175,11 +176,11 @@ in...@@ -175,11 +176,11 @@ in
175 RuntimeDirectoryPreserve = "yes";176 RuntimeDirectoryPreserve = "yes";
176 UMask = "0077";177 UMask = "0077";
177 ProtectSystem = "strict";178 ProtectSystem = "strict";
178 ReadWritePaths = [ "/srv/vm" ] ++ lib.optionals (config.networking.hostName != "clover-demo") [ "/srv/clover/Media/vm" ];179 ReadWritePaths = [ "/srv/vm" "-/srv/prod/shale/data" "/var/lib/studio/dashboard" ] ++ lib.optionals (config.networking.hostName != "clover-demo") [ "/srv/clover/Media/vm" ];
179 ProtectHome = true;180 ProtectHome = true;
180 PrivateTmp = true;181 PrivateTmp = true;
181 NoNewPrivileges = true;182 NoNewPrivileges = true;
182 CapabilityBoundingSet = [ "CAP_SYS_ADMIN" "CAP_DAC_READ_SEARCH" ];183 CapabilityBoundingSet = [ "CAP_SYS_ADMIN" "CAP_DAC_READ_SEARCH" "CAP_DAC_OVERRIDE" ];
183 MemoryMax = "256M";184 MemoryMax = "256M";
184 TasksMax = 64;185 TasksMax = 64;
185 DevicePolicy = "closed";186 DevicePolicy = "closed";
...@@ -291,6 +292,7 @@ in...@@ -291,6 +292,7 @@ in
291292
292 systemd.tmpfiles.rules = [293 systemd.tmpfiles.rules = [
293 "d /srv/prod 0755 root root -"294 "d /srv/prod 0755 root root -"
295 "A+ /srv/prod/shale/data - - - - u:root:rwX,d:u:root:rwx"
294 "d /srv/staging 0755 root root -"296 "d /srv/staging 0755 root root -"
295 "d /srv/vm 0755 root root -"297 "d /srv/vm 0755 root root -"
296 "d /srv/logs 0770 clo chloe -"298 "d /srv/logs 0770 clo chloe -"
service/shale/service.pkl+2-2
...@@ -18,7 +18,7 @@ requirements {...@@ -18,7 +18,7 @@ requirements {
18prepare = "prepare.py"18prepare = "prepare.py"
1919
20container {20container {
21 image = "docker.io/astheno/shale@sha256:0c507bb2d2f0d6390b8411f644d5c7c7579a384d14ccae2cf82eb51c2eb6466e"21 image = "docker.io/astheno/shale@sha256:547a126d829c81be5d2435223a093337951e2c6ae71a114adf69938485fb3fa8"
22 extraHosts { "\(sso.hostname):host-gateway" }22 extraHosts { "\(sso.hostname):host-gateway" }
2323
24 http {24 http {
...@@ -31,7 +31,7 @@ container {...@@ -31,7 +31,7 @@ container {
31 }31 }
32 headHtml {32 headHtml {
33 ["/*"] = """33 ["/*"] = """
34 <script defer src="/-/studio-readme/markdown-it.min.js"></script><script defer src="/-/studio-readme/purify.min.js"></script><script defer src="/-/studio-readme/readme.js"></script>34 <script defer src="/-/studio-readme/markdown-it.min.js"></script><script defer src="/-/studio-readme/purify.min.js"></script><script defer src="/-/studio-readme/readme.js?h=\(read("readme/readme.js").bytes.sha256)"></script>
35 """35 """
36 }36 }
37 }37 }
tools/dashboard-auth-test.py+36-1
...@@ -12,6 +12,7 @@ import subprocess...@@ -12,6 +12,7 @@ import subprocess
12import tempfile12import tempfile
13import time13import time
14import uuid14import uuid
15from urllib.parse import quote
15from cryptography.hazmat.primitives import hashes16from cryptography.hazmat.primitives import hashes
16from cryptography.hazmat.primitives.asymmetric import ec17from cryptography.hazmat.primitives.asymmetric import ec
17from cryptography.hazmat.primitives.kdf.argon2 import Argon2id18from cryptography.hazmat.primitives.kdf.argon2 import Argon2id
...@@ -112,6 +113,11 @@ def main():...@@ -112,6 +113,11 @@ def main():
112 cookies = {}113 cookies = {}
113 start()114 start()
114 try:115 try:
116 (data / 'pictures').mkdir(exist_ok=True)
117 (data / 'pictures' / actor).write_bytes(base64.b64decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Wl6qWQAAAAASUVORK5CYII='))
118 picture_headers = request('/api/account/pictures/' + actor, include_headers=True)
119 assert picture_headers['content-type'] == 'image/png'
120 request('/api/account/pictures/missing', status=404)
115 request('/api/me', status=401, extra={'User-Name': name, 'User-Groups': 'infra-admin'})121 request('/api/me', status=401, extra={'User-Name': name, 'User-Groups': 'infra-admin'})
116 request('/auth/status', status=403, extra={'Studio-Proxy-Token': 'wrong'})122 request('/auth/status', status=403, extra={'Studio-Proxy-Token': 'wrong'})
117 csrf = request('/auth/status', cookies=cookies)['csrf']123 csrf = request('/auth/status', cookies=cookies)['csrf']
...@@ -188,6 +194,35 @@ def main():...@@ -188,6 +194,35 @@ def main():
188 request('/auth/setup', 'POST', {'csrf': new_csrf, 'setup': setup, 'email': 'new@example.invalid', 'password': 'another-password'}, newcomer)194 request('/auth/setup', 'POST', {'csrf': new_csrf, 'setup': setup, 'email': 'new@example.invalid', 'password': 'another-password'}, newcomer)
189 request('/auth/setup', 'POST', {'csrf': new_csrf, 'setup': setup, 'email': 'new@example.invalid', 'password': 'another-password'}, newcomer, 410)195 request('/auth/setup', 'POST', {'csrf': new_csrf, 'setup': setup, 'email': 'new@example.invalid', 'password': 'another-password'}, newcomer, 410)
190 request('/api/users', cookies=newcomer, status=403)196 request('/api/users', cookies=newcomer, status=403)
197 key = {'publicKey': Path('config/admin.pub').read_text().strip(), 'label': 'identity fixture'}
198 request('/api/account/signing-keys', 'POST', key, newcomer, 204)
199 pending_key = request('/api/account', cookies=newcomer)['commitIdentity']['signingKeys'][0]
200 assert pending_key['verified'] is False
201 fingerprint = quote(pending_key['fingerprint'], safe='')
202 request('/api/account/signing-keys/' + fingerprint, 'PUT', {}, newcomer, 403)
203 request('/api/account/commit-emails', 'POST', {'email': 'Alias@example.invalid'}, newcomer, 204)
204 request('/api/account/commit-emails/alias@example.invalid', 'PUT', {}, newcomer, 403)
205 request('/api/users/' + invitation['id'] + '/commit-emails/alias@example.invalid', 'PUT', {}, cookies, 204)
206 request('/api/users/' + invitation['id'] + '/signing-keys/' + fingerprint, 'PUT', {}, cookies, 204)
207 identity = request('/api/account', cookies=newcomer)['commitIdentity']
208 assert identity['emails'] == [{'email': 'alias@example.invalid', 'verified': True}]
209 assert identity['signingKeys'][0]['verified'] is True
210 request('/api/account/signing-keys', 'POST', key, cookies, 409)
211 request('/api/account/signing-keys', 'POST', {'publicKey': 'not a public key'}, newcomer, 400)
212 request('/api/account/commit-emails', 'POST', {'email':'auth-test@example.invalid'}, newcomer, 409)
213 request('/api/account/signing-keys/' + fingerprint, 'DELETE', {}, cookies, 204)
214 assert len(request('/api/account', cookies=newcomer)['commitIdentity']['signingKeys']) == 1
215 request('/api/account/signing-keys/' + fingerprint, 'DELETE', {}, newcomer, 204)
216 request('/api/account/commit-emails/alias@example.invalid', 'DELETE', {}, newcomer, 204)
217 assert request('/api/account', cookies=newcomer)['commitIdentity'] == {'emails': [], 'signingKeys': []}
218
219 synthetic_id = str(uuid.uuid4())
220 with sqlite3.connect(data / 'accounts.sqlite') as db:
221 db.execute('INSERT INTO users(id,profile) VALUES (?,?)', (synthetic_id, json.dumps({'username': 'ai-auth-test-gpt-6.1-sol', 'kind': 'ai', 'owner': actor, 'model': 'gpt-6.1-sol', 'enabled': True, 'requiredActions': []})))
222 assert synthetic_id not in {u['id'] for u in request('/api/users', cookies=cookies)['users']}
223 for path, method, body in [('', 'GET', None), ('/setup-link', 'POST', {}), ('/password', 'PUT', {'password': 'cannot-login', 'temporary': False}), ('', 'PATCH', {'enabled': False})]:
224 request('/api/users/' + synthetic_id + path, method, body, cookies, 404)
225 request('/api/users', 'POST', {'profile': {'username': 'ai-impostor', 'email': '', 'firstName': '', 'lastName': ''}, 'groups': [], 'setup': {'kind': 'invite'}}, cookies, 400)
191 directory = request('/api/users', cookies=cookies)226 directory = request('/api/users', cookies=cookies)
192 groups = {g['name']: g['id'] for g in directory['groups']}227 groups = {g['name']: g['id'] for g in directory['groups']}
193 assert 'ai' in groups228 assert 'ai' in groups
...@@ -242,7 +277,7 @@ def main():...@@ -242,7 +277,7 @@ def main():
242 request('/api/users/' + actor + '/logout', 'POST', {}, cookies, 204)277 request('/api/users/' + actor + '/logout', 'POST', {}, cookies, 204)
243 request('/api/me', cookies=cookies, status=401)278 request('/api/me', cookies=cookies, status=401)
244 request('/auth/file/check', cookies=file_cookies, status=401, host=file)279 request('/auth/file/check', cookies=file_cookies, status=401, host=file)
245 print(json.dumps({'import': 'passed', 'password': 'passed', 'signed_legacy_passkey': 'passed', 'username_free_passkey': 'passed', 'remember_me': 'passed', 'registration': 'passed', 'csrf_and_header_forgery': 'passed', 'ai_group_access': 'passed', 'bulk_group_atomicity': 'passed', 'file_handoff_replay_and_binding': 'passed', 'invitation_one_use_and_revocation': 'passed', 'restart_and_logout': 'passed', 'shale_logout_and_cookie_replay': 'passed'}))280 print(json.dumps({'commit_identity': 'passed', 'import': 'passed', 'password': 'passed', 'signed_legacy_passkey': 'passed', 'username_free_passkey': 'passed', 'remember_me': 'passed', 'registration': 'passed', 'csrf_and_header_forgery': 'passed', 'ai_group_access': 'passed', 'bulk_group_atomicity': 'passed', 'file_handoff_replay_and_binding': 'passed', 'invitation_one_use_and_revocation': 'passed', 'restart_and_logout': 'passed', 'shale_logout_and_cookie_replay': 'passed'}))
246 finally:281 finally:
247 if server and server.poll() is None: stop()282 if server and server.poll() is None: stop()
248 log.close()283 log.close()
tools/dashboard-host-test.py+38
...@@ -4,6 +4,10 @@ import json...@@ -4,6 +4,10 @@ import json
4from pathlib import Path4from pathlib import Path
5import sys5import sys
6import subprocess6import subprocess
7import os
8import socket
9import struct
10import threading
7import unittest11import unittest
8from unittest.mock import patch12from unittest.mock import patch
913
...@@ -17,6 +21,40 @@ class Boundary(unittest.TestCase):...@@ -17,6 +21,40 @@ class Boundary(unittest.TestCase):
17 def setUp(self):21 def setUp(self):
18 self.host = host_module.Host("studio-demo")22 self.host = host_module.Host("studio-demo")
1923
24 @unittest.skipUnless(sys.platform == "linux", "Linux Unix peer credentials")
25 def test_large_json_header_preserves_stream_bytes_and_enforces_limit(self):
26 def exchange(payload, expected=None):
27 client, server = socket.socketpair()
28 client.settimeout(5)
29 handler = unittest.mock.Mock()
30 handler.handle.return_value = True
31 def stream_console(connection, host, request):
32 self.assertEqual(connection.recv(len(expected)), expected)
33 host_module.send_response(connection, {"value": True})
34 with patch.object(host_module, "stream_console", side_effect=stream_console):
35 worker = threading.Thread(target=host_module.serve_connection, args=(server, handler, os.getuid()), daemon=True)
36 worker.start()
37 with client:
38 client.sendall(payload)
39 length = struct.unpack('>I', client.recv(4))[0]
40 response = bytearray()
41 while len(response) < length:
42 response.extend(client.recv(length - len(response)))
43 worker.join(timeout=5)
44 self.assertFalse(worker.is_alive())
45 return json.loads(response), handler
46 message = {"operation": "shale.write", "arguments": {"comment": "\x01" * 240000}}
47 response, handler = exchange(json.dumps(message).encode() + b"\n")
48 self.assertEqual(response, {"value": True})
49 handler.handle.assert_called_once_with(message)
50 tail = b"stream bytes\nnot JSON"
51 response, handler = exchange(b'{"operation":"vm.serial"}\n' + tail, tail)
52 self.assertEqual(response, {"value": True})
53 handler.handle.assert_not_called()
54 response, handler = exchange(b'x' * (host_module.MAX_REQUEST + 1))
55 self.assertEqual(response['status'], 400)
56 handler.handle.assert_not_called()
57
20 def test_rejects_before_executing(self):58 def test_rejects_before_executing(self):
21 requests = [None, [], {}, {"operation": []},59 requests = [None, [], {}, {"operation": []},
22 {"operation": "command", "argv": ["sh", "-c", "id"]},60 {"operation": "command", "argv": ["sh", "-c", "id"]},
tools/dashboard-host.py+17-7
...@@ -22,6 +22,7 @@ import vms...@@ -22,6 +22,7 @@ import vms
22import tty22import tty
2323
24dashboard_runs = importlib.import_module("dashboard-run")24dashboard_runs = importlib.import_module("dashboard-run")
25shale_labels = importlib.import_module("shale-labels")
2526
2627
27FIELDS = (28FIELDS = (
...@@ -30,8 +31,9 @@ FIELDS = (...@@ -30,8 +31,9 @@ FIELDS = (
30 "quota", "origin", "mounted",31 "quota", "origin", "mounted",
31)32)
32TEXT_FIELDS = {"name", "compression", "mountpoint", "origin", "mounted"}33TEXT_FIELDS = {"name", "compression", "mountpoint", "origin", "mounted"}
33MAX_REQUEST = 6553634PROTOCOL = json.loads((Path(__file__).resolve().parent.parent / "dashboard/src/host-protocol.json").read_text())
34MAX_RESPONSE = 16 * 1024 * 102435MAX_REQUEST = PROTOCOL['request']
36MAX_RESPONSE = PROTOCOL['response']
35STREAM_SLOTS = threading.BoundedSemaphore(4)37STREAM_SLOTS = threading.BoundedSemaphore(4)
36GUEST_SLOTS = threading.BoundedSemaphore(4)38GUEST_SLOTS = threading.BoundedSemaphore(4)
37VM_PREPARATION_SLOT = threading.BoundedSemaphore(1)39VM_PREPARATION_SLOT = threading.BoundedSemaphore(1)
...@@ -84,6 +86,8 @@ class Host:...@@ -84,6 +86,8 @@ class Host:
84 "index.snapshots": {"dataset"},86 "index.snapshots": {"dataset"},
85 "index.diff": {"dataset", "from", "to"},87 "index.diff": {"dataset", "from", "to"},
86 "index.discard": {"dataset", "snapshot"},88 "index.discard": {"dataset", "snapshot"},
89 "shale.metadata": {"repository", "accounts"},
90 "shale.write": {"grant", "repository", "action", "arguments"},
87 "host.sample": set(),91 "host.sample": set(),
88 "host.usage": {"refresh"},92 "host.usage": {"refresh"},
89 **dashboard_runs.FIELDS,93 **dashboard_runs.FIELDS,
...@@ -93,6 +97,10 @@ class Host:...@@ -93,6 +97,10 @@ class Host:
93 raise Rejected("Choose a supported host operation.")97 raise Rejected("Choose a supported host operation.")
94 if set(request) != parameters[operation] | {"operation"}:98 if set(request) != parameters[operation] | {"operation"}:
95 raise Rejected("Use only the fields required by this host operation.")99 raise Rejected("Use only the fields required by this host operation.")
100 if operation == "shale.metadata":
101 return shale_labels.metadata(request)
102 if operation == "shale.write":
103 return shale_labels.handle(request)
96 if operation in dashboard_runs.FIELDS:104 if operation in dashboard_runs.FIELDS:
97 return dashboard_runs.handle(request)105 return dashboard_runs.handle(request)
98 if operation == "host.sample":106 if operation == "host.sample":
...@@ -421,14 +429,16 @@ def serve_connection(connection, host, allowed_uid):...@@ -421,14 +429,16 @@ def serve_connection(connection, host, allowed_uid):
421 if uid != allowed_uid:429 if uid != allowed_uid:
422 return430 return
423 try:431 try:
424 # No buffered reader: bytes after the header belong to the stream.432 # Leave stream payload bytes on the socket after the JSON header.
425 line = bytearray()433 line = bytearray()
426 while len(line) <= MAX_REQUEST:434 while len(line) <= MAX_REQUEST:
427 byte = connection.recv(1)435 chunk = connection.recv(min(65536, MAX_REQUEST + 1 - len(line)), socket.MSG_PEEK)
428 if not byte:436 if not chunk:
429 break437 break
430 line.extend(byte)438 end = chunk.find(b"\n")
431 if byte == b"\n":439 chunk = connection.recv(len(chunk) if end < 0 else end + 1)
440 line.extend(chunk)
441 if line.endswith(b"\n"):
432 break442 break
433 if len(line) > MAX_REQUEST or not line.endswith(b"\n"):443 if len(line) > MAX_REQUEST or not line.endswith(b"\n"):
434 raise Rejected("The host request is too large or incomplete.")444 raise Rejected("The host request is too large or incomplete.")
tools/dashboard-shale-attribution-test.py created+224
...@@ -0,0 +1,224 @@
1#!/usr/bin/env python3
2import argparse
3import hashlib
4import html
5import http.client
6import http.server
7import json
8import os
9from pathlib import Path
10import socket
11import sqlite3
12import ssl
13import struct
14import subprocess
15import tempfile
16import threading
17import time
18import urllib.parse
19
20
21def main():
22 parser = argparse.ArgumentParser()
23 parser.add_argument('--binary', type=Path, required=True)
24 args = parser.parse_args()
25 with tempfile.TemporaryDirectory(prefix='shale-attribution-') as temporary:
26 root = Path(temporary).resolve()
27 (root / 'data').mkdir()
28 cert, key = root / 'cert.pem', root / 'key.pem'
29 subprocess.run(['openssl', 'req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-keyout', str(key),
30 '-out', str(cert), '-days', '1', '-subj', '/CN=localhost',
31 '-addext', 'subjectAltName=DNS:localhost,IP:127.0.0.1', '-addext', 'basicConstraints=critical,CA:FALSE'],
32 check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
33 state = {'caller_write': True, 'status': 'todo', 'posts': [], 'comments': []}
34
35 def page(body, kind='page-issue'):
36 return "<meta name='astheno.shale.repo.name' content='chat'><body id='" + kind + "'>" + body + '</body>'
37
38 def issue():
39 comments = ''.join("<li class=comment id=c%d><div class=n-card__header><a href=/~%s>~%s</a></div><div class=markdown>%s</div></li>" %
40 (i, author, author, html.escape(comment)) for i, (author, comment) in enumerate(state['comments'], 1))
41 return '<h1><span>#1</span><span>Fixture</span></h1><dl class=sidebar><dd><span class=issuestatus-' + state['status'] + '>' + state['status'] + '</span></dd></dl><ul class=timeline>' + comments + '</ul>'
42
43 class Backend(http.server.BaseHTTPRequestHandler):
44 def log_message(self, *args):
45 pass
46
47 def reply(self, code, body='', **headers):
48 encoded = body.encode()
49 self.send_response(code)
50 for name, value in headers.items():
51 self.send_header(name, value)
52 self.send_header('Content-Length', str(len(encoded)))
53 self.end_headers()
54 self.wfile.write(encoded)
55
56 def do_GET(self):
57 actor = False
58 if self.path == '/-/settings':
59 self.reply(200, '<body id=page-user-settings><kbd>' + 'human' + '</kbd></body>')
60 return
61 if self.path == '/':
62 self.reply(200, '<body id=page-index><div class=grid-container><table><tbody><tr><td><a href=/chat/>chat</a></td><td>Fixture</td><td></td></tr><tr><td><a href=/other/>other</a></td><td>Other</td><td></td></tr></tbody></table></div></body>')
63 return
64 permitted = state['caller_write']
65 csrf = 'actor-token' if actor else 'caller-token'
66 if self.path == '/chat/issues/labels':
67 self.reply(200, page("<div class=grid-container><table><tbody><tr><td><a class=issuelabel data-label-id=75 data-color='#123456'>Bug</a></td><td>Fixture</td><td>2</td></tr></tbody></table></div>", 'page-labels'))
68 return
69 if self.path == '/chat/issues/new':
70 form = '<form method=post><input name=title><input type=hidden name=csrf_token value=' + csrf + '></form>' if permitted else ''
71 self.reply(200, page(form, 'page-new-issue'))
72 return
73 if self.path == '/chat/issues/1':
74 form = '<form method=post><input type=hidden name=t value=comment><input type=hidden name=csrf_token value=' + csrf + '></form>' if permitted else ''
75 self.reply(200, page(issue() + form))
76 return
77 self.reply(404)
78
79 def do_POST(self):
80 state['posts'].append(self.path)
81 self.reply(500, 'Native HTTP writes must not be used')
82
83 backend = http.server.ThreadingHTTPServer(('127.0.0.1', 0), Backend)
84 context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
85 context.load_cert_chain(cert, key)
86 backend.socket = context.wrap_socket(backend.socket, server_side=True)
87 thread = threading.Thread(target=backend.serve_forever, daemon=True)
88 thread.start()
89 with socket.socket() as available:
90 available.bind(('127.0.0.1', 0))
91 port = available.getsockname()[1]
92 host_calls = []
93 host_socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
94 host_socket.bind(str(root / 'host.sock'))
95 host_socket.listen()
96 host_socket.settimeout(.1)
97 host_done = threading.Event()
98
99 def host_requests():
100 while not host_done.is_set():
101 try:
102 connection, _ = host_socket.accept()
103 except socket.timeout:
104 continue
105 with connection:
106 request = json.loads(connection.makefile('rb').readline())
107 if request['operation'] != 'shale.write':
108 result = json.dumps({'value': {}}).encode()
109 try:
110 connection.sendall(struct.pack('>I', len(result)) + result)
111 except BrokenPipeError:
112 pass
113 continue
114 host_calls.append(request)
115 fields = request['arguments']
116 if request['action'] == 'set_issue_status':
117 state['status'] = fields['status']
118 if request['action'] == 'create_issue':
119 state['status'] = fields['status']
120 if request['action'] in ('create_issue', 'comment_issue'):
121 state['comments'].append(('ai-human-' + fields['model'], fields.get('description', fields.get('comment', ''))))
122 result = json.dumps({'value': {'actor': '~ai-human-' + fields['model'], 'id': 1}}).encode()
123 try:
124 connection.sendall(struct.pack('>I', len(result)) + result)
125 except BrokenPipeError:
126 pass
127
128 host_thread = threading.Thread(target=host_requests, daemon=True)
129 host_thread.start()
130 origin = 'https://globe.studio.test'
131 environment = {**os.environ, 'PORT': str(port), 'STUDIO_LISTEN_ADDRESS': '127.0.0.1',
132 'STUDIO_DATA_DIR': str(root / 'data'), 'STUDIO_PUBLIC_ORIGIN': origin,
133 'STUDIO_AUTH_RP_ID': 'studio.test', 'STUDIO_FILE_ORIGIN': 'https://file.studio.test',
134 'STUDIO_SHALE_URL': 'https://localhost:' + str(backend.server_port),
135 'STUDIO_CA_BUNDLE': str(cert), 'STUDIO_REPO': str(Path(__file__).resolve().parent.parent),
136 'STUDIO_AUTH_REQUIRED': '0', 'STUDIO_HOST_SOCKET': str(root / 'host.sock')}
137 for name in ('STUDIO_PROXY_TOKEN_FILE', 'STUDIO_INTERNAL_URL', 'STUDIO_YT_STATE', 'STUDIO_INDEX_POOL'):
138 environment.pop(name, None)
139 with (root / 'server.log').open('wb') as log:
140 server = subprocess.Popen([str(args.binary.resolve())], env=environment, stdout=log, stderr=log)
141 try:
142 deadline = time.monotonic() + 20
143 while True:
144 try:
145 with socket.create_connection(('127.0.0.1', port), timeout=.1):
146 break
147 except OSError:
148 assert server.poll() is None, (root / 'server.log').read_text()[-2000:]
149 assert time.monotonic() < deadline
150 time.sleep(.05)
151 with sqlite3.connect(root / 'data/accounts.sqlite') as db:
152 for name in ('human', 'ai'):
153 db.execute('INSERT INTO users(id,profile) VALUES (?,?)', (name, json.dumps({'username': name, 'enabled': True, 'requiredActions': []})))
154 resource = origin + '/mcp/shale'
155 grant = {'id': 'fixture', 'user': 'human', 'resource': resource, 'scopes': ['shale:read', 'shale:write'], 'resources': ['chat']}
156 with sqlite3.connect(root / 'data/connections.sqlite') as db:
157 for name, value in [('grant:fixture', grant), ('access:' + hashlib.sha256(b'fixture').hexdigest(), {'grant': 'fixture', 'resource': resource}),
158 ('shale-session:human', {'origin': 'https://localhost:' + str(backend.server_port) + '/', 'session': 'human'}),
159 ('shale-session:ai', {'origin': 'https://localhost:' + str(backend.server_port) + '/', 'session': 'actor'})]:
160 db.execute('INSERT INTO records VALUES (?,?,0)', (name, json.dumps(value)))
161
162 def call(name, fields, error=False):
163 connection = http.client.HTTPConnection('127.0.0.1', port, timeout=15)
164 connection.request('POST', '/mcp/shale', json.dumps({'jsonrpc': '2.0', 'id': 1, 'method': 'tools/call', 'params': {'name': name, 'arguments': fields}}),
165 {'Host': 'globe.studio.test', 'Authorization': 'Bearer fixture', 'Accept': 'application/json, text/event-stream', 'Content-Type': 'application/json'})
166 response = connection.getresponse()
167 body = response.read()
168 connection.close()
169 assert response.status == 200, (response.status, body)
170 result = json.loads(body)['result']
171 assert bool(result.get('isError', False)) == error, result
172 return result
173
174 created = call('create_issue', {'repository': 'chat', 'title': 'Fixture', 'description': 'Description', 'model': 'claude-opus-5.5', 'status': 'todo'})
175 assert created['structuredContent']['issue']['comments'][0]['author'] == '~ai-human-claude-opus-5.5'
176 assert created['structuredContent']['issue']['status'] == 'todo'
177 commented = call('comment_issue', {'repository': 'chat', 'id': 1, 'comment': 'Comment', 'model': 'gpt-6.1-sol'})
178 assert commented['structuredContent']['issue']['comments'][-1]['author'] == '~ai-human-gpt-6.1-sol'
179 assert host_calls[0]['arguments']['description'] == 'Description\n\nAssisted-by: claude-opus-5.5'
180 assert host_calls[1]['arguments']['comment'] == 'Comment\n\nAssisted-by: gpt-6.1-sol'
181 for body in ('x' * 200000, '\x01' * 240000, '😀' * 60000):
182 call('comment_issue', {'repository': 'chat', 'id': 1, 'comment': body, 'model': 'gpt-6.1-sol'})
183 assert host_calls[-1]['arguments']['comment'].startswith(body)
184 call('comment_issue', {'repository': 'chat', 'id': 1, 'comment': 'x' * 262144, 'model': 'gpt-6.1-sol'}, error=True)
185 call('create_issue', {'repository': 'chat', 'title': 'Question', 'model': 'gpt-6.1-sol'})
186 assert state['status'] == 'needs_triage'
187 for body, model in [('Body\n\nAssisted-by: claude-opus-5.5', 'gpt-6.1-sol'), ('```\nBody', 'gpt-6.1-sol'), ('Body', '<model-id>')]:
188 call('comment_issue', {'repository': 'chat', 'id': 1, 'comment': body, 'model': model}, error=True)
189 for fields in ({'title': 'Missing model'}, {'title': 'Invalid state', 'model': 'gpt-6.1-sol', 'status': 'done'}):
190 call('create_issue', {'repository': 'chat', **fields}, error=True)
191 state['caller_write'] = False
192 changed = call('set_issue_status', {'repository': 'chat', 'id': 1, 'status': 'done', 'model': 'gpt-6.1-sol'})
193 assert changed['structuredContent']['issue']['status'] == 'done'
194 assert host_calls[-1] == {'operation': 'shale.write', 'grant': 'fixture', 'repository': 'chat', 'action': 'set_issue_status', 'arguments': {'id': 1, 'status': 'done', 'model': 'gpt-6.1-sol'}}
195 catalog = call('list_labels', {'repository': 'chat'})['structuredContent']
196 assert catalog['labels'][0]['id'] == 75 and catalog['labels'][0]['openIssues'] == 2
197 for name, fields in [('create_label', {'name': 'Bug', 'description': '', 'color': '#123456'}),
198 ('update_label', {'label_id': 75, 'name': 'Defect', 'description': '', 'color': '#abcdef'}),
199 ('set_issue_labels', {'id': 1, 'labels': [75]}), ('delete_label', {'label_id': 75})]:
200 call(name, {'repository': 'chat', 'model': 'gpt-6.1-sol', **fields})
201 assert host_calls[-1] == {'operation': 'shale.write', 'grant': 'fixture', 'repository': 'chat', 'action': name, 'arguments': {'model': 'gpt-6.1-sol', **fields}}
202 call(name, {'repository': 'chat', **fields}, error=True)
203 count = len(host_calls)
204 call('create_label', {'repository': 'other', 'model': 'gpt-6.1-sol', 'name': 'REFUSED', 'description': '', 'color': '#123456'}, error=True)
205 with sqlite3.connect(root / 'data/connections.sqlite') as db:
206 db.execute('UPDATE records SET value=? WHERE key=?', (json.dumps({**grant, 'scopes': ['shale:read']}), 'grant:fixture'))
207 call('set_issue_labels', {'repository': 'chat', 'id': 1, 'labels': [], 'model': 'gpt-6.1-sol'}, error=True)
208 call('set_issue_status', {'repository': 'chat', 'id': 1, 'status': 'todo', 'model': 'gpt-6.1-sol'}, error=True)
209 assert len(host_calls) == count
210 assert state['posts'] == [], 'A bridge write used a native user session'
211 print(json.dumps({'durable_model_arguments': True, 'triage_default_and_todo': True, 'trailers_match_model': True, 'missing_models_refused': True, 'scope_and_readonly_enforced': True, 'no_native_session_write': True}))
212
213 finally:
214 server.terminate()
215 server.wait(timeout=10)
216 backend.shutdown()
217 backend.server_close()
218 host_done.set()
219 host_thread.join(timeout=1)
220 host_socket.close()
221
222
223if __name__ == '__main__':
224 main()
tools/dashboard-shale-link-test.py+30-33
...@@ -47,7 +47,8 @@ def main():...@@ -47,7 +47,8 @@ def main():
47 origin = 'https://globe.studio.test'47 origin = 'https://globe.studio.test'
48 proof = args.proof_file.read_text().strip()48 proof = args.proof_file.read_text().strip()
49 marker = 'shale-link-' + uuid.uuid4().hex49 marker = 'shale-link-' + uuid.uuid4().hex
50 accounts = [(marker + '-one', uuid.uuid4().hex + 'A1!'), (marker + '-two', uuid.uuid4().hex + 'A1!')]50 accounts = [(marker + '-one', uuid.uuid4().hex + 'A1!'), (marker + '-two', uuid.uuid4().hex + 'A1!'),
51 ('ai', uuid.uuid4().hex + 'A1!')]
51 ids = []52 ids = []
52 sessions = {name: uuid.uuid4().hex + uuid.uuid4().hex for name, _ in accounts}53 sessions = {name: uuid.uuid4().hex + uuid.uuid4().hex for name, _ in accounts}
5354
...@@ -176,7 +177,7 @@ def main():...@@ -176,7 +177,7 @@ def main():
176 pending = urllib.parse.urlsplit(headers['Location']).path.removeprefix('/connect/')177 pending = urllib.parse.urlsplit(headers['Location']).path.removeprefix('/connect/')
177 details = api(accounts[index][0], path='/api/mcp/consent/' + pending)178 details = api(accounts[index][0], path='/api/mcp/consent/' + pending)
178 assert details['client'] == marker179 assert details['client'] == marker
179 api(accounts[1-index][0], path='/api/mcp/consent/' + pending, status=403)180 api(accounts[1 if index == 0 else 0][0], path='/api/mcp/consent/' + pending, status=403)
180 return pending, verifier, details181 return pending, verifier, details
181182
182 def consent(client, index, repository, scope):183 def consent(client, index, repository, scope):
...@@ -239,6 +240,8 @@ def main():...@@ -239,6 +240,8 @@ def main():
239 identity = db.execute('SELECT id FROM users WHERE snowflake=?', (ids[index],)).fetchone()[0]240 identity = db.execute('SELECT id FROM users WHERE snowflake=?', (ids[index],)).fetchone()[0]
240 assert db.execute('UPDATE repositories SET owner=? WHERE name=?', (identity, name)).rowcount == 1241 assert db.execute('UPDATE repositories SET owner=? WHERE name=?', (identity, name)).rowcount == 1
241242
243 for name, _ in accounts:
244 assert not keycloak.request('/admin/realms/master/users?username=' + name + '&exact=true'), 'Fixture account already exists: ' + name
242 try:245 try:
243 for name, credential in accounts:246 for name, credential in accounts:
244 keycloak.request('/admin/realms/master/users', 'POST', {'username': name, 'firstName': name,247 keycloak.request('/admin/realms/master/users', 'POST', {'username': name, 'firstName': name,
...@@ -290,11 +293,19 @@ def main():...@@ -290,11 +293,19 @@ def main():
290 other = consent(oauth_client, 1, 'foreign', 'shale:read shale:write')293 other = consent(oauth_client, 1, 'foreign', 'shale:read shale:write')
291 read = readonly['access_token']294 read = readonly['access_token']
292 write = writing['access_token']295 write = writing['access_token']
293 assert rpc(read, 'initialize', {'protocolVersion': '2025-11-25', 'capabilities': {},296 initialized = rpc(read, 'initialize', {'protocolVersion': '2025-11-25', 'capabilities': {},
294 'clientInfo': {'name': marker, 'version': '1'}})['capabilities']['tools'] == {}297 'clientInfo': {'name': marker, 'version': '1'}})
295 tools = rpc(read, 'tools/list')['tools']298 assert initialized['capabilities']['tools'] == {}
299 assert 'Assisted-by:' in initialized['instructions'] and 'model' in initialized['instructions']
300 listed = rpc(read, 'tools/list')
301 assert listed["ttlMs"] == 0 and listed["cacheScope"] == "private", listed
302 tools = listed["tools"]
296 assert {tool['name'] for tool in tools} == {'list_repositories', 'list_issues', 'get_issue', 'create_issue',303 assert {tool['name'] for tool in tools} == {'list_repositories', 'list_issues', 'get_issue', 'create_issue',
297 'comment_issue', 'set_issue_status', 'set_issue_title'}304 'comment_issue', 'set_issue_status', 'set_issue_title', 'list_labels', 'create_label', 'update_label', 'delete_label', 'set_issue_labels'}
305 assert all('model' in tool['inputSchema']['required'] for tool in tools
306 if not tool['annotations']['readOnlyHint'])
307 create_schema = next(tool['inputSchema'] for tool in tools if tool['name'] == 'create_issue')
308 assert create_schema['properties']['status']['enum'] == ['todo', 'triage']
298 assert all(tool['annotations']['readOnlyHint'] == tool['name'].startswith(('list_', 'get_')) for tool in tools)309 assert all(tool['annotations']['readOnlyHint'] == tool['name'].startswith(('list_', 'get_')) for tool in tools)
299 assert {repo['id'] for repo in call(read, 'list_repositories')['repositories']} == {'alpha'}310 assert {repo['id'] for repo in call(read, 'list_repositories')['repositories']} == {'alpha'}
300 grant = next(value for value in records('grant:').values() if value['scopes'] == ['shale:read', 'offline_access'] and value['user'] == ids[0])311 grant = next(value for value in records('grant:').values() if value['scopes'] == ['shale:read', 'offline_access'] and value['user'] == ids[0])
...@@ -335,9 +346,12 @@ def main():...@@ -335,9 +346,12 @@ def main():
335 call(read, 'create_issue', {'repository': 'alpha', 'title': 'REFUSED'}, error=True)346 call(read, 'create_issue', {'repository': 'alpha', 'title': 'REFUSED'}, error=True)
336 call(write, 'create_issue', {'repository': 'alpha', 'title': 'REFUSED', 'url': 'https://other.invalid'}, error=True)347 call(write, 'create_issue', {'repository': 'alpha', 'title': 'REFUSED', 'url': 'https://other.invalid'}, error=True)
337 public('/mcp/observability', 'POST', {'jsonrpc': '2.0', 'id': 1, 'method': 'tools/list'}, token=read, status=401)348 public('/mcp/observability', 'POST', {'jsonrpc': '2.0', 'id': 1, 'method': 'tools/list'}, token=read, status=401)
349 call(write, 'create_issue', {'repository': 'alpha', 'title': 'REFUSED'}, error=True)
338 title = 'MCP <angle> & Unicode ☃'350 title = 'MCP <angle> & Unicode ☃'
339 created = call(write, 'create_issue', {'repository': 'alpha', 'title': title,351 status, _, _ = submit_backend(0, '/alpha/issues/new', {'title': title,
340 'description': 'Owned body <script>fixture</script> & Unicode ☃'})['issue']352 'description': 'Owned body <script>fixture</script> & Unicode ☃'})
353 assert status == 303
354 created = call(read, 'get_issue', {'repository': 'alpha', 'id': 1})['issue']
341 assert created['title'] == title and created['id'] == 1355 assert created['title'] == title and created['id'] == 1
342 assert len(created['comments']) == 1 and 'Unicode ☃' in created['comments'][0]['text']356 assert len(created['comments']) == 1 and 'Unicode ☃' in created['comments'][0]['text']
343 assert '<script>' not in json.dumps(created)357 assert '<script>' not in json.dumps(created)
...@@ -346,13 +360,6 @@ def main():...@@ -346,13 +360,6 @@ def main():
346 rejected = call(read, 'list_issues', {'repository': 'alpha', 'q': 'Unicode'}, error=True)360 rejected = call(read, 'list_issues', {'repository': 'alpha', 'q': 'Unicode'}, error=True)
347 assert 'filter syntax' in rejected['content'][0]['text'], rejected361 assert 'filter syntax' in rejected['content'][0]['text'], rejected
348 issue_fields = {'repository': 'alpha', 'id': created['id']}362 issue_fields = {'repository': 'alpha', 'id': created['id']}
349 commented = call(write, 'comment_issue', {**issue_fields, 'comment': 'Owned comment & Unicode ☃'})['issue']
350 assert len(commented['comments']) == 2 and commented['comments'][-1]['text'] == 'Owned comment & Unicode ☃'
351 assert call(write, 'set_issue_status', {**issue_fields, 'status': 'done'})['issue']['status'] == 'done'
352 call(write, 'set_issue_status', {**issue_fields, 'status': 'outside-status'}, error=True)
353 changed = call(write, 'set_issue_title', {**issue_fields, 'title': 'Changed ☃'})['issue']
354 assert changed['title'] == 'Changed ☃'
355 assert call(read, 'get_issue', issue_fields)['issue'] == changed
356 status, _, _ = submit_backend(0, '/alpha/issues/labels', {'name': 'Owned ☃', 'description': 'Fixture label', 'color': '#ff40ff'})363 status, _, _ = submit_backend(0, '/alpha/issues/labels', {'name': 'Owned ☃', 'description': 'Fixture label', 'color': '#ff40ff'})
357 assert status == 303364 assert status == 303
358 status, _, _ = submit_backend(0, '/alpha/issues/1', {'t': 'labels', 'labels': '1'})365 status, _, _ = submit_backend(0, '/alpha/issues/1', {'t': 'labels', 'labels': '1'})
...@@ -363,23 +370,15 @@ def main():...@@ -363,23 +370,15 @@ def main():
363 rows = db.execute('SELECT id FROM users WHERE snowflake=?', (ids[1],)).fetchall()370 rows = db.execute('SELECT id FROM users WHERE snowflake=?', (ids[1],)).fetchall()
364 assert len(rows) == 1371 assert len(rows) == 1
365 original_owner = db.execute("SELECT owner FROM repositories WHERE name='alpha'").fetchone()[0]372 original_owner = db.execute("SELECT owner FROM repositories WHERE name='alpha'").fetchone()[0]
366 db.execute("UPDATE repositories SET owner=? WHERE name='alpha'", (rows[0][0],))373 db.execute("UPDATE repositories SET owner=?,access_git_webui='private',access_issues='private' WHERE name='alpha'", (rows[0][0],))
367 try:374 try:
368 call(read, 'get_issue', issue_fields, error=True)375 call(read, 'get_issue', issue_fields, error=True)
369 call(write, 'create_issue', {'repository': 'alpha', 'title': 'REFUSED'}, error=True)376 call(write, 'create_issue', {'repository': 'alpha', 'title': 'REFUSED'}, error=True)
370 finally:377 finally:
371 with sqlite3.connect(args.shale_database) as db:378 with sqlite3.connect(args.shale_database) as db:
372 db.execute("UPDATE repositories SET owner=? WHERE name='alpha'", (original_owner,))379 db.execute("UPDATE repositories SET owner=?,access_git_webui='public',access_issues='public' WHERE name='alpha'", (original_owner,))
373 with sqlite3.connect(args.shale_database) as db:380 with sqlite3.connect(args.shale_database) as db:
374 assert db.execute("SELECT count(*) FROM issues WHERE title='REFUSED'").fetchone()[0] == 0381 assert db.execute("SELECT count(*) FROM issues WHERE title='REFUSED'").fetchone()[0] == 0
375 drop = args.shale_database.parent.parent / 'drop-next-write'
376 drop.write_text('/alpha/issues/new')
377 unknown = call(write, 'create_issue', {'repository': 'alpha', 'title': 'Committed with lost response'}, error=True)
378 assert 'outcome is unknown' in unknown['content'][0]['text'], unknown
379 assert not drop.exists(), 'fixture did not drop the committed write response'
380 with sqlite3.connect(args.shale_database) as db:
381 assert db.execute("SELECT count(*) FROM issues WHERE title='Committed with lost response'").fetchone()[0] == 1
382 assert sum(issue['title'] == 'Committed with lost response' for issue in call(read, 'list_issues', {'repository': 'alpha'})['issues']) == 1
383 subprocess.run(['systemctl', 'restart', args.restart_unit], check=True, capture_output=True, timeout=30)382 subprocess.run(['systemctl', 'restart', args.restart_unit], check=True, capture_output=True, timeout=30)
384 assert session(0) == new_first and session(1) == second383 assert session(0) == new_first and session(1) == second
385 assert math.isclose(api(accounts[0][0])['shale']['linkedAt'], new_first['linkedAt'], rel_tol=0, abs_tol=1e-6)384 assert math.isclose(api(accounts[0][0])['shale']['linkedAt'], new_first['linkedAt'], rel_tol=0, abs_tol=1e-6)
...@@ -419,6 +418,8 @@ def main():...@@ -419,6 +418,8 @@ def main():
419 api(accounts[1][0], 'DELETE', '/api/mcp/shale', 204)418 api(accounts[1][0], 'DELETE', '/api/mcp/shale', 204)
420 assert session(1) is None and session_count(1) == 0419 assert session(1) is None and session_count(1) == 0
421 backend_session(second, 303)420 backend_session(second, 303)
421 api(accounts[2][0], 'DELETE', '/api/mcp/shale', 204)
422 assert session(2) is None
422 assert not records('shale-link:') and not records('shale-session:')423 assert not records('shale-link:') and not records('shale-session:')
423 result = {'two_user_oidc_linking': True, 'same_realm_ordinary_users': True, 'host_only_callback_cookie': True,424 result = {'two_user_oidc_linking': True, 'same_realm_ordinary_users': True, 'host_only_callback_cookie': True,
424 'single_use_state_and_expiry': True, 'account_mismatch_refused_and_session_removed': True,425 'single_use_state_and_expiry': True, 'account_mismatch_refused_and_session_removed': True,
...@@ -427,25 +428,21 @@ def main():...@@ -427,25 +428,21 @@ def main():
427 'unlink_cancels_inflight_callback': True, 'unlink_revokes_backend_session': True,428 'unlink_cancels_inflight_callback': True, 'unlink_revokes_backend_session': True,
428 'oauth_link_returns_to_consent': True, 'sdk_catalog_and_repository_grants': True,429 'oauth_link_returns_to_consent': True, 'sdk_catalog_and_repository_grants': True,
429 'cross_repository_and_cross_user_tools_refused': True, 'read_only_and_audience_enforced': True,430 'cross_repository_and_cross_user_tools_refused': True, 'read_only_and_audience_enforced': True,
430 'native_issue_create_read_comment_status_title': True, 'unicode_and_rendered_text': True,431 'native_issue_read': True, 'unicode_and_rendered_text': True,
431 'native_issue_labels_read': True,432 'native_issue_labels_read': True,
432 'committed_write_lost_response_reported_without_replay': True,
433 'backend_permission_changes_enforced': True, 'expired_backend_session_refused': True,433 'backend_permission_changes_enforced': True, 'expired_backend_session_refused': True,
434 'all_repository_approval_and_dynamic_access': True, 'nested_repository_paths': True, 'existing_token_resource_edits': True,434 'all_repository_approval_and_dynamic_access': True, 'nested_repository_paths': True, 'existing_token_resource_edits': True,
435 'restart_preserves_mcp_access': True, 'unlink_revokes_mcp_access_and_refresh': True}435 'restart_preserves_mcp_access': True, 'unlink_revokes_mcp_access_and_refresh': True}
436 finally:436 finally:
437 keycloak = Keycloak('keycloak.studio.test', importlib.import_module('dashboard-run').secret('get', 'keycloak', 'password'), attempts=1)437 keycloak = Keycloak('keycloak.studio.test', importlib.import_module('dashboard-run').secret('get', 'keycloak', 'password'), attempts=1)
438 cleanup_errors = []438 cleanup_errors = []
439 for name, _ in accounts:439 for (name, _), identity in zip(accounts, ids):
440 try:440 try:
441 if keycloak.request('/admin/realms/master/users?username=' + name + '&exact=true'):441 api(name, 'DELETE', '/api/mcp/shale', 204)
442 api(name, 'DELETE', '/api/mcp/shale', 204)
443 except Exception as error:442 except Exception as error:
444 cleanup_errors.append(error)443 cleanup_errors.append(error)
445 try:444 try:
446 for user in keycloak.request('/admin/realms/master/users?username=' + name + '&exact=true'):445 keycloak.request('/admin/realms/master/users/' + identity, 'DELETE')
447 assert user['username'] == name
448 keycloak.request('/admin/realms/master/users/' + user['id'], 'DELETE')
449 except Exception as error:446 except Exception as error:
450 cleanup_errors.append(error)447 cleanup_errors.append(error)
451 with sqlite3.connect(args.data_dir / 'accounts.sqlite') as db:448 with sqlite3.connect(args.data_dir / 'accounts.sqlite') as db:
tools/dashboard-shale-page-test.py+2-1
...@@ -103,8 +103,9 @@ def main():...@@ -103,8 +103,9 @@ def main():
103 token = root / 'proxy.token'103 token = root / 'proxy.token'
104 token.write_text(proof)104 token.write_text(proof)
105 certificate, gateway_key = root / 'gateway.pem', root / 'gateway.key'105 certificate, gateway_key = root / 'gateway.pem', root / 'gateway.key'
106 subprocess.run(['openssl', 'req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-days', '1', '-subj', '/CN=localhost', '-addext', 'subjectAltName=DNS:localhost', '-keyout', str(gateway_key), '-out', str(certificate)], check=True, capture_output=True)106 subprocess.run(['openssl', 'req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-days', '1', '-subj', '/CN=localhost', '-addext', 'subjectAltName=DNS:localhost', '-addext', 'basicConstraints=CA:FALSE', '-keyout', str(gateway_key), '-out', str(certificate)], check=True, capture_output=True)
107 data = root / 'data'107 data = root / 'data'
108 data.mkdir()
108 environment = {**os.environ, 'PORT': str(dashboard_port), 'STUDIO_DOMAIN': 'studio.test', 'STUDIO_DATA_DIR': str(data), 'STUDIO_PROXY_TOKEN_FILE': str(token), 'STUDIO_INTERNAL_URL': f'https://localhost:{internal_port}', 'STUDIO_CA_BUNDLE': str(certificate), 'STUDIO_REPO': str(repo), 'STUDIO_WEB_DIR': str(repo / 'dashboard/dist')}109 environment = {**os.environ, 'PORT': str(dashboard_port), 'STUDIO_DOMAIN': 'studio.test', 'STUDIO_DATA_DIR': str(data), 'STUDIO_PROXY_TOKEN_FILE': str(token), 'STUDIO_INTERNAL_URL': f'https://localhost:{internal_port}', 'STUDIO_CA_BUNDLE': str(certificate), 'STUDIO_REPO': str(repo), 'STUDIO_WEB_DIR': str(repo / 'dashboard/dist')}
109 for key in ['STUDIO_INDEX_POOL', 'STUDIO_AUTH_REQUIRED', 'STUDIO_YT_STATE']:110 for key in ['STUDIO_INDEX_POOL', 'STUDIO_AUTH_REQUIRED', 'STUDIO_YT_STATE']:
110 environment.pop(key, None)111 environment.pop(key, None)
tools/shale-labels-test.py created+189
...@@ -0,0 +1,189 @@
1#!/usr/bin/env python3
2import importlib
3import json
4import os
5from pathlib import Path
6import sqlite3
7import tempfile
8import unittest
9from unittest.mock import patch
10
11shale = importlib.import_module('shale-labels')
12
13
14class ShaleTests(unittest.TestCase):
15 def setUp(self):
16 self.temp = tempfile.TemporaryDirectory()
17 self.root = Path(self.temp.name)
18 self.env = patch.dict(os.environ, {'STUDIO_SHALE_DATABASE': str(self.root / 'shale.sqlite'),
19 'STUDIO_DASHBOARD_DATA': str(self.root), 'STUDIO_DOMAIN': 'studio.test', 'STUDIO_SHALE_POLICY': str(self.root / 'policy.json')})
20 self.env.start()
21 (self.root / 'policy.json').write_text(json.dumps({'actorOwners': {'ai': 'owner'}, 'statusDelegates': {'ai': ['chat']}}))
22 with sqlite3.connect(self.root / 'accounts.sqlite') as db:
23 db.execute("CREATE TABLE users(id TEXT PRIMARY KEY,profile TEXT,username TEXT GENERATED ALWAYS AS (json_extract(profile,'$.username')) STORED UNIQUE)")
24 for name in ('owner', 'other', 'ai'):
25 db.execute('INSERT INTO users(id,profile) VALUES(?,?)', (name, json.dumps({'username': name, 'enabled': True})))
26 self.grant = {'id': 'fixture', 'user': 'owner', 'resource': 'https://snowglobe.studio.test/mcp/shale', 'scopes': ['shale:write'], 'resources': ['chat']}
27 with sqlite3.connect(self.root / 'connections.sqlite') as db:
28 db.execute('CREATE TABLE records(key TEXT PRIMARY KEY,value TEXT,last_updated INTEGER)')
29 db.execute('INSERT INTO records VALUES(?,?,0)', ('grant:fixture', json.dumps(self.grant)))
30 for name in ('owner', 'other', 'ai'):
31 db.execute('INSERT INTO records VALUES(?,?,0)', ('shale-session:' + name, json.dumps({'origin': 'https://shale.studio.test/'})))
32 self.db = sqlite3.connect(self.root / 'shale.sqlite')
33 self.db.executescript('''
34 CREATE TABLE users(id INTEGER PRIMARY KEY,uuid TEXT,name TEXT,provider TEXT,snowflake TEXT,joined_on TEXT,last_updated TEXT);
35 CREATE TABLE repositories(id INTEGER PRIMARY KEY,name TEXT,owner INTEGER,last_updated TEXT,open_issues INTEGER DEFAULT 0,access_issues TEXT DEFAULT 'public',access_issues_submit TEXT DEFAULT 'public',access_issues_comment TEXT DEFAULT 'public');
36 CREATE TABLE issues(id INTEGER PRIMARY KEY,uuid TEXT,repo INTEGER,number INTEGER,status TEXT,last_updated TEXT,author INTEGER DEFAULT 3,title TEXT DEFAULT 'Title');
37 CREATE TABLE issue_labels(id INTEGER PRIMARY KEY,uuid TEXT,repo INTEGER REFERENCES repositories(id),name TEXT,description TEXT,color TEXT,open_issues INTEGER DEFAULT 0,last_updated TEXT);
38 CREATE TABLE issues__labels(id INTEGER PRIMARY KEY,uuid TEXT,issue INTEGER REFERENCES issues(id),label INTEGER REFERENCES issue_labels(id));
39 CREATE TABLE issue_actions(id INTEGER PRIMARY KEY,uuid TEXT,issue INTEGER REFERENCES issues(id),actor INTEGER REFERENCES users(id),kind TEXT,payload TEXT,last_updated TEXT,added_on TEXT,deleted_on TEXT);
40 INSERT INTO users(id,name,provider,snowflake) VALUES(1,'owner','snowglobe.studio.test','owner'),(2,'other','snowglobe.studio.test','other'),(3,'ai','snowglobe.studio.test','ai');
41 INSERT INTO repositories(id,name,owner,last_updated) VALUES(1,'chat',1,''),(2,'other',2,'');
42 INSERT INTO issues(id,repo,number,status,last_updated) VALUES(1,1,1,'todo',''),(2,1,2,'done',''),(3,2,1,'todo','');
43 ''')
44 self.db.commit()
45
46 def tearDown(self):
47 self.db.close()
48 self.env.stop()
49 self.temp.cleanup()
50
51 def call(self, action, **args):
52 return shale.handle({'action': action, 'arguments': {'model': 'gpt-6.1-sol', **args}, 'grant': 'fixture', 'repository': 'chat'})
53
54 def grant_as(self, **change):
55 with sqlite3.connect(self.root / 'connections.sqlite') as db:
56 db.execute('UPDATE records SET value=? WHERE key=?', (json.dumps({**self.grant, **change}), 'grant:fixture'))
57
58 def create_label(self, name='bug'):
59 return self.call('create_label', name=name, description='Description', color='#123456')['label']['id']
60
61 def test_lifecycle_counts_history_and_protected_deletion(self):
62 a, b = self.create_label(), self.create_label('feature')
63 self.call('set_issue_labels', id=1, labels=[a, b])
64 self.call('set_issue_labels', id=2, labels=[a])
65 self.call('set_issue_labels', id=1, labels=[a, b])
66 self.assertEqual(self.db.execute('SELECT count(*) FROM issue_actions').fetchone(), (3,))
67 self.assertEqual(self.call('update_label', label_id=a, name='defect', description='', color='#abcdef')['label']['openIssues'], 1)
68 with self.assertRaises(shale.Error):
69 self.call('delete_label', label_id=a)
70 self.call('set_issue_labels', id=1, labels=[b])
71 self.call('set_issue_labels', id=2, labels=[])
72 with self.assertRaises(shale.Error):
73 self.call('delete_label', label_id=a)
74 unused = self.create_label('unused')
75 self.call('delete_label', label_id=unused)
76 self.assertEqual(self.db.execute('SELECT label FROM issues__labels').fetchall(), [(b,)])
77 self.assertEqual(self.db.execute('SELECT DISTINCT u.name FROM issue_actions a JOIN users u ON a.actor=u.id').fetchall(), [('ai-owner-gpt-6.1-sol',)])
78 self.assertEqual(self.db.execute('PRAGMA foreign_key_check').fetchall(), [])
79
80 def test_models_and_owners_are_distinct_durable_oauth_subjects(self):
81 created = self.call('create_issue', title='Question', description='Body', status='needs_triage')
82 number = created['id']
83 self.call('comment_issue', id=number, comment='Claude', model='claude-opus-5.5')
84 self.call('set_issue_title', id=number, title='Updated')
85 self.grant_as(user='other')
86 self.call('comment_issue', id=number, comment='Other owner')
87 self.assertEqual(self.db.execute('SELECT u.name FROM issue_actions a JOIN users u ON a.actor=u.id ORDER BY a.id').fetchall(),
88 [('ai-owner-gpt-6.1-sol',), ('ai-owner-claude-opus-5.5',), ('ai-owner-gpt-6.1-sol',), ('ai-other-gpt-6.1-sol',)])
89 with self.assertRaises(shale.Error):
90 self.call('set_issue_title', id=number, title='Cannot take ownership')
91 self.assertEqual(self.db.execute('SELECT status,title FROM issues WHERE number=? AND repo=1', (number,)).fetchone(), ('needs_triage', 'Updated'))
92 with sqlite3.connect(self.root / 'accounts.sqlite') as db:
93 rows = db.execute("SELECT id,profile FROM users WHERE json_extract(profile,'$.kind')='ai'").fetchall()
94 self.assertEqual(len(rows), 3)
95 self.assertEqual(len({uid for uid, _ in rows}), 3)
96 self.assertTrue(all(json.loads(profile)['owner'] in ('owner', 'other') for _, profile in rows))
97
98 def test_explicit_legacy_delegation_keeps_owner_model_and_status_counts(self):
99 label = self.create_label()
100 self.call('set_issue_labels', id=1, labels=[label])
101 self.call('set_issue_status', id=1, status='done')
102 self.grant_as(user='ai')
103 self.call('set_issue_status', id=1, status='todo', model='claude-opus-5.5')
104 self.call('set_issue_status', id=1, status='todo', model='claude-opus-5.5')
105 self.assertEqual(self.db.execute("SELECT u.name,a.payload FROM issue_actions a JOIN users u ON a.actor=u.id WHERE kind='update_status' ORDER BY a.id").fetchall(), [('ai-owner-gpt-6.1-sol', 'done'), ('ai-owner-claude-opus-5.5', 'todo')])
106 self.assertEqual(self.db.execute('SELECT open_issues FROM issue_labels WHERE id=?', (label,)).fetchone(), (1,))
107 self.db.execute('UPDATE repositories SET open_issues=99 WHERE id=1'); self.db.commit()
108 self.assertEqual(shale.metadata({'repository': 'chat', 'accounts': []})['openIssues'], 1)
109 with self.assertRaises(shale.Error):
110 self.create_label('owner-only')
111 (self.root / 'policy.json').write_text('{}')
112 with self.assertRaises(shale.Error):
113 self.call('set_issue_status', id=1, status='done')
114
115 def test_metadata_never_reads_trailers_and_only_returns_requested_accounts(self):
116 result = self.call('create_issue', title='Task', description='Assisted-by: wrong-model', status='todo')
117 name = result['actor'][1:]
118 value = shale.metadata({'repository': 'chat', 'accounts': [name, 'owner', 'ai']})
119 self.assertEqual(value['accounts'], {name: {'model': 'gpt-6.1-sol', 'owner': 'owner', 'picture': None}})
120 subject = self.db.execute('SELECT snowflake FROM users WHERE name=?', (name,)).fetchone()[0]
121 self.db.execute("INSERT INTO users(name,provider,snowflake) VALUES('foreign-owner','foreign.test','owner'),('ai-foreign-human','foreign.test',?)", (subject,))
122 self.db.commit()
123 self.assertEqual(shale.metadata({'repository': 'chat', 'accounts': ['ai-foreign-human']})['accounts'], {})
124 self.assertEqual(shale.metadata({'repository': None, 'accounts': []}), {'accounts': {}, 'openIssues': None})
125 for names in ([True], ['x'] * 1025):
126 with self.assertRaises(shale.Error):
127 shale.metadata({'repository': 'chat', 'accounts': names})
128
129 def test_native_avatars_use_the_same_owner_picture_as_model_credits(self):
130 name = self.call('create_issue', title='Task', description='', status='todo')['actor'][1:]
131 with sqlite3.connect(self.root / 'accounts.sqlite') as db:
132 row = db.execute("SELECT profile FROM users WHERE id='owner'").fetchone()
133 profile = json.loads(row[0]); profile['attributes'] = {'picture': ['https://snowglobe.test/picture']}
134 db.execute("UPDATE users SET profile=? WHERE id='owner'", (json.dumps(profile),))
135 self.db.execute("UPDATE users SET uuid='native-owner' WHERE id=1"); self.db.commit()
136 metadata = shale.metadata({'repository': 'chat', 'accounts': [name, 'owner']})['accounts']
137 self.assertEqual(metadata['owner'], {'avatar': 'native-owner', 'picture': 'https://snowglobe.test/picture'})
138 self.assertEqual(metadata[name]['picture'], metadata['owner']['picture'])
139 self.assertEqual(shale.metadata({'repository': None, 'accounts': []})['accounts'], {})
140
141 def test_scope_revocation_and_private_permissions(self):
142 for change in ({'user': 'other'}, {'resources': ['other']}, {'scopes': ['shale:read']}, {'resource': 'https://other.test/mcp/shale'}):
143 self.grant_as(**change)
144 with self.assertRaises(shale.Error):
145 self.create_label()
146 self.grant_as(user='other')
147 for column in ('access_issues', 'access_issues_submit', 'access_issues_comment'):
148 self.db.execute('UPDATE repositories SET ' + column + "='private' WHERE id=1"); self.db.commit()
149 with self.assertRaises(shale.Error):
150 self.call('create_issue' if column != 'access_issues_comment' else 'comment_issue', **({'title': 'Denied', 'description': '', 'status': 'todo'} if column != 'access_issues_comment' else {'id': 1, 'comment': 'Denied'}))
151 self.db.execute('UPDATE repositories SET ' + column + "='public' WHERE id=1"); self.db.commit()
152 self.grant_as()
153 with sqlite3.connect(self.root / 'connections.sqlite') as db:
154 db.execute("DELETE FROM records WHERE key='shale-session:owner'")
155 with self.assertRaises(shale.Error):
156 self.create_label()
157 self.assertEqual(self.db.execute('SELECT count(*) FROM issue_labels').fetchone(), (0,))
158
159 def test_missing_invalid_or_disabled_model_and_owner_fail_closed(self):
160 for model in (None, '', 'GPT 6', '../other', 'gpt-6\n', '<model-id>'):
161 with self.assertRaises(shale.Error):
162 self.call('create_label', name='bug', description='', color='#123456', model=model)
163 self.create_label()
164 with sqlite3.connect(self.root / 'accounts.sqlite') as db:
165 db.execute("UPDATE users SET profile=json_set(profile,'$.enabled',json('false')) WHERE username='ai-owner-gpt-6.1-sol'")
166 with self.assertRaises(shale.Error):
167 self.call('set_issue_status', id=1, status='done')
168 with sqlite3.connect(self.root / 'accounts.sqlite') as db:
169 db.execute("UPDATE users SET profile=json_set(profile,'$.enabled',json('false')) WHERE id='owner'")
170 with self.assertRaises(shale.Error):
171 self.call('create_issue', title='No fallback', description='', status='todo', model='claude-opus-5.5')
172 self.assertEqual(self.db.execute('SELECT count(*) FROM issue_actions').fetchone(), (0,))
173
174 def test_bad_ids_status_labels_and_rollback(self):
175 label = self.create_label()
176 for ids in ([label, label], [label, 999], [True], [-1]):
177 with self.assertRaises(shale.Error):
178 self.call('set_issue_labels', id=1, labels=ids)
179 for status in ('done', 'triage', None):
180 with self.assertRaises(shale.Error):
181 self.call('create_issue', title='Bad', description='', status=status)
182 with self.assertRaises(shale.Error):
183 self.call('set_issue_status', id=1, status='invented')
184 self.assertEqual(self.db.execute('SELECT count(*) FROM issue_actions').fetchone(), (0,))
185 self.assertEqual(self.db.execute('SELECT count(*) FROM issues').fetchone(), (3,))
186
187
188if __name__ == '__main__':
189 unittest.main()
tools/shale-labels.py created+238
...@@ -0,0 +1,238 @@
1import json
2import os
3from pathlib import Path
4import re
5import secrets
6import sqlite3
7from datetime import datetime, timezone
8import time
9import uuid
10from contextlib import closing
11
12from importlib import import_module
13
14Error = import_module('dashboard-run').Error
15
16
17def database(mode, timeout):
18 path = Path(os.environ.get('STUDIO_SHALE_DATABASE', '/srv/prod/shale/data/astheno.shale.db'))
19 return sqlite3.connect(path.as_uri() + '?mode=' + mode, uri=True, timeout=timeout)
20
21
22def identifier():
23 value = (int((time.time() - 1577836800) * 1000) << 80) | secrets.randbits(80)
24 alphabet = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'
25 return ''.join(alphabet[(value >> (5 * i)) & 31] for i in range(25, -1, -1))
26
27
28def update_label_counts(db, labels, now):
29 for label in labels:
30 db.execute("UPDATE issue_labels SET open_issues=(SELECT count(*) FROM issues__labels il JOIN issues i ON i.id=il.issue WHERE il.label=issue_labels.id AND i.status NOT IN ('done','not_planned','duplicate','invalid')),last_updated=? WHERE id=?", (now, label))
31
32
33def metadata(request):
34 names = request['accounts']
35 if (not isinstance(names, list) or len(names) > 1024
36 or any(not isinstance(n, str) or len(n) > 254 for n in names)
37 or request['repository'] is not None and not isinstance(request['repository'], str)):
38 raise Error(400, 'Choose up to 1024 account names.')
39 accounts = Path(os.environ.get('STUDIO_DASHBOARD_DATA', '/var/lib/studio/dashboard'))
40 with closing(database('ro', 2)) as db:
41 db.execute('ATTACH DATABASE ? AS auth', ((accounts / 'accounts.sqlite').as_uri() + '?mode=ro',))
42 result = {}
43 for name, model, owner, picture in db.execute(
44 "SELECT u.name,json_extract(a.profile,'$.model'),o.name,json_extract(h.profile,'$.attributes.picture[0]') "
45 "FROM users u JOIN auth.users a ON a.id=u.snowflake "
46 "JOIN auth.users h ON h.id=json_extract(a.profile,'$.owner') "
47 "JOIN users o ON o.snowflake=h.id AND o.provider=u.provider "
48 "WHERE u.provider=? AND json_extract(a.profile,'$.kind')='ai' AND u.name IN (" + ','.join('?' for _ in names) + ")",
49 ['snowglobe.' + os.environ.get('STUDIO_DOMAIN', 'studio.test'), *names]):
50 result[name] = {'model': model, 'owner': owner, 'picture': picture}
51 for name, seed, picture in db.execute(
52 "SELECT u.name,u.uuid,json_extract(a.profile,'$.attributes.picture[0]') "
53 "FROM users u JOIN auth.users a ON a.id=u.snowflake "
54 "WHERE u.provider=? AND coalesce(json_extract(a.profile,'$.kind'),'user') NOT IN ('ai','guest') "
55 "AND u.name IN (" + ','.join('?' for _ in names) + ")",
56 ['snowglobe.' + os.environ.get('STUDIO_DOMAIN', 'studio.test'), *names]):
57 if picture:
58 result[name] = {'avatar': seed, 'picture': picture}
59 row = db.execute("SELECT (SELECT count(*) FROM issues WHERE repo=r.id AND status NOT IN ('done','not_planned','duplicate','invalid')) FROM repositories r WHERE name=?", (request['repository'],)).fetchone()
60 return {'accounts': result, 'openIssues': row[0] if row else None}
61
62
63def actor_account(db, owner, model, domain, now):
64 if not isinstance(model, str) or not re.fullmatch(r'[a-z0-9][a-z0-9._-]{0,127}', model):
65 raise Error(400, 'Supply the actual model identifier in model.')
66 human = db.execute('SELECT a.profile,u.name FROM auth.users a JOIN users u ON u.snowflake=a.id WHERE a.id=? AND u.provider=?',
67 (owner, 'snowglobe.' + domain)).fetchone()
68 if not human or json.loads(human[0]).get('enabled') is not True or json.loads(human[0]).get('kind') in ('ai', 'guest'):
69 raise Error(403, 'The account on whose behalf this model writes is unavailable.')
70 name = 'ai-' + human[1] + '-' + model
71 if len(name) > 254:
72 raise Error(400, 'Use a shorter model identifier.')
73 matches = db.execute("SELECT id,username,profile FROM auth.users WHERE json_extract(profile,'$.kind')='ai' AND json_extract(profile,'$.owner')=? AND json_extract(profile,'$.model')=?", (owner, model)).fetchall()
74 if len(matches) > 1:
75 raise Error(409, 'This model has multiple accounts. Ask the server owner to repair them.')
76 if matches:
77 subject, name, profile = matches[0]
78 if json.loads(profile).get('enabled') is not True:
79 raise Error(403, 'This model account is disabled. Ask the server owner to enable it.')
80 else:
81 if db.execute('SELECT 1 FROM auth.users WHERE username=?', (name,)).fetchone():
82 raise Error(409, 'This model account name is already taken. Ask the server owner to repair it.')
83 subject = str(uuid.uuid4())
84 db.execute('INSERT INTO auth.users(id,profile) VALUES(?,?)',
85 (subject, json.dumps({'kind': 'ai', 'username': name, 'owner': owner, 'model': model, 'enabled': True, 'requiredActions': []})))
86 actor = db.execute('SELECT id FROM users WHERE provider=? AND snowflake=?', ('snowglobe.' + domain, subject)).fetchone()
87 if not actor:
88 actor = (db.execute('INSERT INTO users(uuid,provider,snowflake,name,joined_on,last_updated) VALUES(?,?,?,?,?,?)',
89 (identifier(), 'snowglobe.' + domain, subject, name, now, now)).lastrowid,)
90 return actor[0], name
91
92
93def handle(request):
94 action, args = request['action'], request['arguments']
95 fields = {'create_label': {'name', 'description', 'color'}, 'update_label': {'label_id', 'name', 'description', 'color'},
96 'delete_label': {'label_id'}, 'set_issue_labels': {'id', 'labels'}, 'set_issue_status': {'id', 'status'}, 'create_issue': {'title', 'description', 'status'},
97 'comment_issue': {'id', 'comment'}, 'set_issue_title': {'id', 'title'}}
98 if action not in fields or not isinstance(args, dict) or set(args) != fields[action] | {'model'}:
99 raise Error(400, 'Use the fields listed for this tool.')
100 for key in ('id', 'label_id'):
101 if key in args and (type(args[key]) is not int or args[key] <= 0):
102 raise Error(400, 'Choose a positive issue or label ID.')
103 if action in ('create_label', 'update_label'):
104 if (not isinstance(args['name'], str) or not args['name'].strip() or len(args['name']) > 256
105 or not isinstance(args['description'], str) or len(args['description']) > 4096
106 or not isinstance(args['color'], str) or not re.fullmatch(r'#[0-9a-fA-F]{6}', args['color'])):
107 raise Error(400, 'Enter a label name, description, and six-digit hex color within the tool limits.')
108 if action == 'set_issue_status' and args['status'] not in ('needs_triage', 'incomplete', 'todo', 'done', 'not_planned', 'duplicate', 'invalid'):
109 raise Error(400, 'Choose a status available in Shale.')
110 if action == 'set_issue_labels':
111 labels = args['labels']
112 if (not isinstance(labels, list) or len(labels) > 100 or any(type(i) is not int or i <= 0 for i in labels)
113 or len(set(labels)) != len(labels)):
114 raise Error(400, 'Choose up to 100 distinct positive label IDs. An empty list clears the labels.')
115 for field, maximum in (('title', 4096), ('description', 262144), ('comment', 262144)):
116 if field in args and (not isinstance(args[field], str) or len(args[field].encode()) > maximum or field != 'description' and not args[field].strip()):
117 raise Error(400, 'Supply issue text within the tool limits.')
118 if action == 'create_issue' and args['status'] not in ('needs_triage', 'todo'):
119 raise Error(400, 'Choose triage or todo for a new issue.')
120 accounts = Path(os.environ.get('STUDIO_DASHBOARD_DATA', '/var/lib/studio/dashboard'))
121 domain = os.environ.get('STUDIO_DOMAIN', 'studio.test')
122 with closing(database('rw', 10)) as db, db:
123 db.row_factory = sqlite3.Row
124 db.execute('PRAGMA foreign_keys=ON')
125 db.execute('ATTACH DATABASE ? AS auth', ((accounts / 'accounts.sqlite').as_uri() + '?mode=rw',))
126 db.execute('ATTACH DATABASE ? AS mcp', ((accounts / 'connections.sqlite').as_uri() + '?mode=ro',))
127 db.execute('BEGIN IMMEDIATE')
128 row = db.execute('SELECT value FROM mcp.records WHERE key=?', ('grant:' + request['grant'],)).fetchone()
129 grant = json.loads(row[0]) if row else {}
130 repository = request['repository']
131 if (grant.get('resource') != 'https://snowglobe.' + domain + '/mcp/shale'
132 or 'shale:write' not in grant.get('scopes', [])
133 or (grant.get('resources') != 'all' and repository not in grant.get('resources', []))):
134 raise Error(403, 'This connection cannot edit this repository. Update its repository selection.')
135 caller = db.execute('SELECT username,profile FROM auth.users WHERE id=?', (grant.get('user'),)).fetchone()
136 if not caller or json.loads(caller[1]).get('enabled') is not True or json.loads(caller[1]).get('kind') in ('guest', 'ai'):
137 raise Error(401, 'This connection is unavailable. Connect again.')
138 repo = db.execute('SELECT id,owner,access_issues,access_issues_submit,access_issues_comment FROM repositories WHERE name=?', (repository,)).fetchone()
139 native_caller = db.execute('SELECT id FROM users WHERE provider=? AND snowflake=?', ('snowglobe.' + domain, grant['user'])).fetchone()
140 if not repo or not native_caller or repo[2] == 'off':
141 raise Error(403, 'This Shale connection cannot edit this repository.')
142 policy = json.loads(Path(os.environ.get('STUDIO_SHALE_POLICY', str(Path(__file__).resolve().parent.parent / 'config/shale.json'))).read_text())
143 owner = policy.get('actorOwners', {}).get(caller[0], grant['user'])
144 issue = None if action == 'create_issue' or 'id' not in args else db.execute('SELECT id,status,author,title FROM issues WHERE repo=? AND number=?', (repo[0], args['id'])).fetchone()
145 if 'id' in args and not issue:
146 raise Error(404, 'That issue is unavailable. List this repository’s issues again.')
147 owns_repo = repo[1] == native_caller[0]
148 if not owns_repo:
149 if action == 'create_issue':
150 permitted = repo[2] == 'public' and repo[3] == 'public'
151 elif action == 'comment_issue':
152 permitted = repo[2] == 'public' and repo[4] == 'public'
153 elif action == 'set_issue_title':
154 author = db.execute("SELECT u.snowflake,json_extract(a.profile,'$.owner') FROM users u LEFT JOIN auth.users a ON a.id=u.snowflake WHERE u.id=? AND u.provider=?", (issue[2], 'snowglobe.' + domain)).fetchone()
155 permitted = repo[2] == 'public' and author and owner in author
156 else:
157 permitted = action == 'set_issue_status' and repository in policy.get('statusDelegates', {}).get(caller[0], [])
158 if not permitted:
159 raise Error(403, 'This connection cannot make that change in this repository.')
160 linked = db.execute('SELECT value FROM mcp.records WHERE key=?', ('shale-session:' + grant['user'],)).fetchone()
161 if not linked or json.loads(linked[0]).get('origin') != 'https://shale.' + domain + '/':
162 raise Error(401, 'This Shale connection is unavailable. Connect again.')
163 now = datetime.now(timezone.utc).isoformat(timespec='milliseconds')
164 actor, actor_name = actor_account(db, owner, args['model'], domain, now)
165 repo = repo[0]
166 if action in ('create_issue', 'comment_issue', 'set_issue_title'):
167 if action == 'create_issue':
168 number = db.execute('SELECT coalesce(max(number),0)+1 FROM issues WHERE repo=?', (repo,)).fetchone()[0]
169 issue_id = db.execute('INSERT INTO issues(uuid,repo,number,author,last_updated,title,status) VALUES(?,?,?,?,?,?,?)',
170 (identifier(), repo, number, actor, now, args['title'], args['status'])).lastrowid
171 kind, payload = 'comment', args['description']
172 else:
173 issue_id, number = issue[0], args['id']
174 kind, payload = ('comment', args['comment']) if action == 'comment_issue' else ('update_title', args['title'])
175 if action == 'set_issue_title' and issue[3] == payload:
176 return {'repository': repository, 'id': number, 'actor': '~' + actor_name}
177 if action == 'set_issue_title':
178 db.execute('UPDATE issues SET title=? WHERE id=?', (payload, issue_id))
179 db.execute('UPDATE issues SET last_updated=? WHERE id=?', (now, issue_id))
180 db.execute('INSERT INTO issue_actions(uuid,issue,actor,kind,payload,last_updated,added_on) VALUES(?,?,?,?,?,?,?)',
181 (identifier(), issue_id, actor, kind, payload, now, now))
182 db.execute("UPDATE repositories SET open_issues=(SELECT count(*) FROM issues WHERE repo=repositories.id AND status NOT IN ('done','not_planned','duplicate','invalid')),last_updated=? WHERE id=?", (now, repo))
183 return {'repository': repository, 'id': number, 'actor': '~' + actor_name}
184 if action in ('update_label', 'delete_label'):
185 if not db.execute('SELECT 1 FROM issue_labels WHERE repo=? AND id=?', (repo, args['label_id'])).fetchone():
186 raise Error(404, 'That label is unavailable in this repository. List its labels again.')
187 if action == 'delete_label' and (db.execute('SELECT 1 FROM issues__labels WHERE label=? LIMIT 1', (args['label_id'],)).fetchone()
188 or db.execute("SELECT 1 FROM issue_actions a JOIN issues i ON a.issue=i.id WHERE i.repo=? AND a.kind IN ('add_label','remove_label') AND a.payload=? LIMIT 1", (repo, str(args['label_id']))).fetchone()):
189 raise Error(409, 'This label is referenced by issue history. Rename it or clear its assignments instead.')
190 if action == 'set_issue_status':
191 if issue[1] != args['status']:
192 db.execute('INSERT INTO issue_actions(uuid,issue,actor,kind,payload,last_updated,added_on) VALUES(?,?,?,?,?,?,?)',
193 (identifier(), issue[0], actor, 'update_status', args['status'], now, now))
194 db.execute('UPDATE issues SET status=?,last_updated=? WHERE id=?', (args['status'], now, issue[0]))
195 update_label_counts(db, (row[0] for row in db.execute('SELECT label FROM issues__labels WHERE issue=?', (issue[0],))), now)
196 db.execute("UPDATE repositories SET open_issues=(SELECT count(*) FROM issues WHERE repo=repositories.id AND status NOT IN ('done','not_planned','duplicate','invalid')),last_updated=? WHERE id=?", (now, repo))
197 return {'repository': repository, 'id': args['id'], 'status': args['status'], 'actor': '~' + actor_name}
198 if action in ('create_label', 'update_label'):
199 if db.execute('SELECT 1 FROM issue_labels WHERE repo=? AND name=? AND id!=?', (repo, args['name'], args.get('label_id', 0))).fetchone():
200 raise Error(409, 'A label already has that name. Choose another name.')
201 if action == 'create_label':
202 label_id = db.execute('INSERT INTO issue_labels(uuid,repo,name,description,color,last_updated) VALUES(?,?,?,?,?,?)',
203 (identifier(), repo, args['name'], args['description'], args['color'], now)).lastrowid
204 else:
205 label_id = args['label_id']
206 db.execute('UPDATE issue_labels SET name=?,description=?,color=?,last_updated=? WHERE id=?',
207 (args['name'], args['description'], args['color'], now, label_id))
208 elif action == 'delete_label':
209 db.execute('DELETE FROM issue_labels WHERE id=?', (args['label_id'],))
210 else:
211 desired = set(args['labels'])
212 available = {r[0] for r in db.execute('SELECT id FROM issue_labels WHERE repo=?', (repo,))}
213 if not desired <= available:
214 raise Error(400, 'Choose labels belonging to this repository. List its labels again.')
215 issue_id = issue[0]
216 current = {r[0] for r in db.execute('SELECT label FROM issues__labels WHERE issue=?', (issue_id,))}
217 affected = current ^ desired
218 for kind, labels in (('remove_label', current - desired), ('add_label', desired - current)):
219 for label in sorted(labels):
220 if kind == 'add_label':
221 db.execute('INSERT INTO issues__labels(uuid,issue,label) VALUES(?,?,?)', (identifier(), issue_id, label))
222 else:
223 db.execute('DELETE FROM issues__labels WHERE issue=? AND label=?', (issue_id, label))
224 db.execute('INSERT INTO issue_actions(uuid,issue,actor,kind,payload,last_updated,added_on) VALUES(?,?,?,?,?,?,?)',
225 (identifier(), issue_id, actor, kind, str(label), now, now))
226 if current != desired:
227 db.execute('UPDATE issues SET last_updated=? WHERE id=?', (now, issue_id))
228 update_label_counts(db, affected, now)
229 if action != 'set_issue_labels' or affected:
230 db.execute('UPDATE repositories SET last_updated=? WHERE id=?', (now, repo))
231 result = {'repository': repository, 'actor': '~' + actor_name}
232 if action == 'set_issue_labels':
233 result.update(id=args['id'], labels=args['labels'])
234 elif action == 'delete_label':
235 result.update(deleted=args['label_id'])
236 else:
237 result['label'] = dict(db.execute('SELECT id,name,description,color,open_issues AS openIssues FROM issue_labels WHERE id=?', (label_id,)).fetchone())
238 return result