| author | |
| committer | |
| log | 9c3463012eb3fe07f9628e4e35abce6c80fd5355 |
| tree | d939c4d43e5cbad85271a7edf6d9aa8fb1f49ac4 |
| parent | 2547169c7c14c468d646a4529194116b1adfb1ee |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU (~clover) |
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 | 156 | "tracing", |
| 157 | 157 | ] |
| 158 | 158 | |
| 159 | [[package]] | |
| 160 | name = "base16ct" | |
| 161 | version = "0.2.0" | |
| 162 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 163 | checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" | |
| 164 | ||
| 159 | 165 | [[package]] |
| 160 | 166 | name = "base64" |
| 161 | 167 | version = "0.21.7" |
| ... | ... | @@ -284,6 +290,22 @@ dependencies = [ |
| 284 | 290 | "windows-link", |
| 285 | 291 | ] |
| 286 | 292 | |
| 293 | [[package]] | |
| 294 | name = "cipher" | |
| 295 | version = "0.4.4" | |
| 296 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 297 | checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" | |
| 298 | dependencies = [ | |
| 299 | "crypto-common", | |
| 300 | "inout", | |
| 301 | ] | |
| 302 | ||
| 303 | [[package]] | |
| 304 | name = "const-oid" | |
| 305 | version = "0.9.6" | |
| 306 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 307 | checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" | |
| 308 | ||
| 287 | 309 | [[package]] |
| 288 | 310 | name = "core-foundation-sys" |
| 289 | 311 | version = "0.8.7" |
| ... | ... | @@ -320,6 +342,18 @@ version = "0.2.4" |
| 320 | 342 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 321 | 343 | checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" |
| 322 | 344 | |
| 345 | [[package]] | |
| 346 | name = "crypto-bigint" | |
| 347 | version = "0.5.5" | |
| 348 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 349 | checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" | |
| 350 | dependencies = [ | |
| 351 | "generic-array", | |
| 352 | "rand_core 0.6.4", | |
| 353 | "subtle", | |
| 354 | "zeroize", | |
| 355 | ] | |
| 356 | ||
| 323 | 357 | [[package]] |
| 324 | 358 | name = "crypto-common" |
| 325 | 359 | version = "0.1.7" |
| ... | ... | @@ -382,6 +416,16 @@ version = "2.11.1" |
| 382 | 416 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 383 | 417 | checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" |
| 384 | 418 | |
| 419 | [[package]] | |
| 420 | name = "der" | |
| 421 | version = "0.7.10" | |
| 422 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 423 | checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" | |
| 424 | dependencies = [ | |
| 425 | "const-oid", | |
| 426 | "zeroize", | |
| 427 | ] | |
| 428 | ||
| 385 | 429 | [[package]] |
| 386 | 430 | name = "der-parser" |
| 387 | 431 | version = "9.0.0" |
| ... | ... | @@ -430,6 +474,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" |
| 430 | 474 | checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" |
| 431 | 475 | dependencies = [ |
| 432 | 476 | "block-buffer", |
| 477 | "const-oid", | |
| 433 | 478 | "crypto-common", |
| 434 | 479 | "subtle", |
| 435 | 480 | ] |
| ... | ... | @@ -466,12 +511,45 @@ version = "1.0.20" |
| 466 | 511 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 467 | 512 | checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" |
| 468 | 513 | |
| 514 | [[package]] | |
| 515 | name = "ecdsa" | |
| 516 | version = "0.16.9" | |
| 517 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 518 | checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" | |
| 519 | dependencies = [ | |
| 520 | "der", | |
| 521 | "digest", | |
| 522 | "elliptic-curve", | |
| 523 | "rfc6979", | |
| 524 | "signature", | |
| 525 | "spki", | |
| 526 | ] | |
| 527 | ||
| 469 | 528 | [[package]] |
| 470 | 529 | name = "ego-tree" |
| 471 | 530 | version = "0.11.0" |
| 472 | 531 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 473 | 532 | checksum = "b04dc5a38e4f151a79d9f2451ae6037fb6eaf5cba34771f44781f80e508498e3" |
| 474 | 533 | |
| 534 | [[package]] | |
| 535 | name = "elliptic-curve" | |
| 536 | version = "0.13.8" | |
| 537 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 538 | checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" | |
| 539 | dependencies = [ | |
| 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 | 553 | [[package]] |
| 476 | 554 | name = "encoding_rs" |
| 477 | 555 | version = "0.8.42" |
| ... | ... | @@ -520,6 +598,16 @@ version = "2.5.0" |
| 520 | 598 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 521 | 599 | checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" |
| 522 | 600 | |
| 601 | [[package]] | |
| 602 | name = "ff" | |
| 603 | version = "0.13.1" | |
| 604 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 605 | checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" | |
| 606 | dependencies = [ | |
| 607 | "rand_core 0.6.4", | |
| 608 | "subtle", | |
| 609 | ] | |
| 610 | ||
| 523 | 611 | [[package]] |
| 524 | 612 | name = "find-msvc-tools" |
| 525 | 613 | version = "0.1.14" |
| ... | ... | @@ -658,6 +746,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" |
| 658 | 746 | dependencies = [ |
| 659 | 747 | "typenum", |
| 660 | 748 | "version_check", |
| 749 | "zeroize", | |
| 661 | 750 | ] |
| 662 | 751 | |
| 663 | 752 | [[package]] |
| ... | ... | @@ -712,6 +801,17 @@ dependencies = [ |
| 712 | 801 | "regex-syntax", |
| 713 | 802 | ] |
| 714 | 803 | |
| 804 | [[package]] | |
| 805 | name = "group" | |
| 806 | version = "0.13.0" | |
| 807 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 808 | checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" | |
| 809 | dependencies = [ | |
| 810 | "ff", | |
| 811 | "rand_core 0.6.4", | |
| 812 | "subtle", | |
| 813 | ] | |
| 814 | ||
| 715 | 815 | [[package]] |
| 716 | 816 | name = "half" |
| 717 | 817 | version = "2.7.1" |
| ... | ... | @@ -758,6 +858,15 @@ version = "0.4.3" |
| 758 | 858 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 759 | 859 | checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" |
| 760 | 860 | |
| 861 | [[package]] | |
| 862 | name = "hmac" | |
| 863 | version = "0.12.1" | |
| 864 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 865 | checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" | |
| 866 | dependencies = [ | |
| 867 | "digest", | |
| 868 | ] | |
| 869 | ||
| 761 | 870 | [[package]] |
| 762 | 871 | name = "home-dashboard" |
| 763 | 872 | version = "0.1.0" |
| ... | ... | @@ -771,6 +880,7 @@ dependencies = [ |
| 771 | 880 | "globset", |
| 772 | 881 | "lol_html", |
| 773 | 882 | "openssl", |
| 883 | "percent-encoding", | |
| 774 | 884 | "rand 0.9.5", |
| 775 | 885 | "regex", |
| 776 | 886 | "reqwest", |
| ... | ... | @@ -782,6 +892,7 @@ dependencies = [ |
| 782 | 892 | "serde_json", |
| 783 | 893 | "sha1", |
| 784 | 894 | "sha2", |
| 895 | "ssh-key", | |
| 785 | 896 | "subtle", |
| 786 | 897 | "tokio", |
| 787 | 898 | "tokio-stream", |
| ... | ... | @@ -1054,6 +1165,15 @@ dependencies = [ |
| 1054 | 1165 | "serde_core", |
| 1055 | 1166 | ] |
| 1056 | 1167 | |
| 1168 | [[package]] | |
| 1169 | name = "inout" | |
| 1170 | version = "0.1.4" | |
| 1171 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1172 | checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" | |
| 1173 | dependencies = [ | |
| 1174 | "generic-array", | |
| 1175 | ] | |
| 1176 | ||
| 1057 | 1177 | [[package]] |
| 1058 | 1178 | name = "ipnet" |
| 1059 | 1179 | version = "2.12.2" |
| ... | ... | @@ -1082,6 +1202,9 @@ name = "lazy_static" |
| 1082 | 1202 | version = "1.5.1" |
| 1083 | 1203 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 1084 | 1204 | checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" |
| 1205 | dependencies = [ | |
| 1206 | "spin", | |
| 1207 | ] | |
| 1085 | 1208 | |
| 1086 | 1209 | [[package]] |
| 1087 | 1210 | name = "libc" |
| ... | ... | @@ -1089,6 +1212,12 @@ version = "0.2.189" |
| 1089 | 1212 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 1090 | 1213 | checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" |
| 1091 | 1214 | |
| 1215 | [[package]] | |
| 1216 | name = "libm" | |
| 1217 | version = "0.2.16" | |
| 1218 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1219 | checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" | |
| 1220 | ||
| 1092 | 1221 | [[package]] |
| 1093 | 1222 | name = "libsqlite3-sys" |
| 1094 | 1223 | version = "0.35.0" |
| ... | ... | @@ -1251,6 +1380,22 @@ dependencies = [ |
| 1251 | 1380 | "num-traits", |
| 1252 | 1381 | ] |
| 1253 | 1382 | |
| 1383 | [[package]] | |
| 1384 | name = "num-bigint-dig" | |
| 1385 | version = "0.8.6" | |
| 1386 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1387 | checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" | |
| 1388 | dependencies = [ | |
| 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 | 1399 | [[package]] |
| 1255 | 1400 | name = "num-conv" |
| 1256 | 1401 | version = "0.2.2" |
| ... | ... | @@ -1266,6 +1411,16 @@ dependencies = [ |
| 1266 | 1411 | "num-traits", |
| 1267 | 1412 | ] |
| 1268 | 1413 | |
| 1414 | [[package]] | |
| 1415 | name = "num-iter" | |
| 1416 | version = "0.1.46" | |
| 1417 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1418 | checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" | |
| 1419 | dependencies = [ | |
| 1420 | "num-integer", | |
| 1421 | "num-traits", | |
| 1422 | ] | |
| 1423 | ||
| 1269 | 1424 | [[package]] |
| 1270 | 1425 | name = "num-traits" |
| 1271 | 1426 | version = "0.2.19" |
| ... | ... | @@ -1273,6 +1428,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" |
| 1273 | 1428 | checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" |
| 1274 | 1429 | dependencies = [ |
| 1275 | 1430 | "autocfg", |
| 1431 | "libm", | |
| 1276 | 1432 | ] |
| 1277 | 1433 | |
| 1278 | 1434 | [[package]] |
| ... | ... | @@ -1327,6 +1483,44 @@ dependencies = [ |
| 1327 | 1483 | "vcpkg", |
| 1328 | 1484 | ] |
| 1329 | 1485 | |
| 1486 | [[package]] | |
| 1487 | name = "p256" | |
| 1488 | version = "0.13.2" | |
| 1489 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1490 | checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" | |
| 1491 | dependencies = [ | |
| 1492 | "ecdsa", | |
| 1493 | "elliptic-curve", | |
| 1494 | "primeorder", | |
| 1495 | "sha2", | |
| 1496 | ] | |
| 1497 | ||
| 1498 | [[package]] | |
| 1499 | name = "p384" | |
| 1500 | version = "0.13.1" | |
| 1501 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1502 | checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" | |
| 1503 | dependencies = [ | |
| 1504 | "ecdsa", | |
| 1505 | "elliptic-curve", | |
| 1506 | "primeorder", | |
| 1507 | "sha2", | |
| 1508 | ] | |
| 1509 | ||
| 1510 | [[package]] | |
| 1511 | name = "p521" | |
| 1512 | version = "0.13.3" | |
| 1513 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1514 | checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2" | |
| 1515 | dependencies = [ | |
| 1516 | "base16ct", | |
| 1517 | "ecdsa", | |
| 1518 | "elliptic-curve", | |
| 1519 | "primeorder", | |
| 1520 | "rand_core 0.6.4", | |
| 1521 | "sha2", | |
| 1522 | ] | |
| 1523 | ||
| 1330 | 1524 | [[package]] |
| 1331 | 1525 | name = "parking_lot" |
| 1332 | 1526 | version = "0.12.5" |
| ... | ... | @@ -1373,6 +1567,15 @@ version = "0.2.3" |
| 1373 | 1567 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 1374 | 1568 | checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" |
| 1375 | 1569 | |
| 1570 | [[package]] | |
| 1571 | name = "pem-rfc7468" | |
| 1572 | version = "0.7.0" | |
| 1573 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1574 | checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" | |
| 1575 | dependencies = [ | |
| 1576 | "base64ct", | |
| 1577 | ] | |
| 1578 | ||
| 1376 | 1579 | [[package]] |
| 1377 | 1580 | name = "percent-encoding" |
| 1378 | 1581 | version = "2.3.2" |
| ... | ... | @@ -1438,6 +1641,27 @@ version = "0.2.17" |
| 1438 | 1641 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 1439 | 1642 | checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" |
| 1440 | 1643 | |
| 1644 | [[package]] | |
| 1645 | name = "pkcs1" | |
| 1646 | version = "0.7.5" | |
| 1647 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1648 | checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" | |
| 1649 | dependencies = [ | |
| 1650 | "der", | |
| 1651 | "pkcs8", | |
| 1652 | "spki", | |
| 1653 | ] | |
| 1654 | ||
| 1655 | [[package]] | |
| 1656 | name = "pkcs8" | |
| 1657 | version = "0.10.2" | |
| 1658 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1659 | checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" | |
| 1660 | dependencies = [ | |
| 1661 | "der", | |
| 1662 | "spki", | |
| 1663 | ] | |
| 1664 | ||
| 1441 | 1665 | [[package]] |
| 1442 | 1666 | name = "pkg-config" |
| 1443 | 1667 | version = "0.3.34" |
| ... | ... | @@ -1474,6 +1698,15 @@ version = "0.1.1" |
| 1474 | 1698 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 1475 | 1699 | checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" |
| 1476 | 1700 | |
| 1701 | [[package]] | |
| 1702 | name = "primeorder" | |
| 1703 | version = "0.13.6" | |
| 1704 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1705 | checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" | |
| 1706 | dependencies = [ | |
| 1707 | "elliptic-curve", | |
| 1708 | ] | |
| 1709 | ||
| 1477 | 1710 | [[package]] |
| 1478 | 1711 | name = "proc-macro2" |
| 1479 | 1712 | version = "1.0.107" |
| ... | ... | @@ -1560,13 +1793,23 @@ version = "6.0.0" |
| 1560 | 1793 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 1561 | 1794 | checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" |
| 1562 | 1795 | |
| 1796 | [[package]] | |
| 1797 | name = "rand" | |
| 1798 | version = "0.8.8" | |
| 1799 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1800 | checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" | |
| 1801 | dependencies = [ | |
| 1802 | "rand_chacha 0.3.1", | |
| 1803 | "rand_core 0.6.4", | |
| 1804 | ] | |
| 1805 | ||
| 1563 | 1806 | [[package]] |
| 1564 | 1807 | name = "rand" |
| 1565 | 1808 | version = "0.9.5" |
| 1566 | 1809 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 1567 | 1810 | checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" |
| 1568 | 1811 | dependencies = [ |
| 1569 | "rand_chacha", | |
| 1812 | "rand_chacha 0.9.0", | |
| 1570 | 1813 | "rand_core 0.9.5", |
| 1571 | 1814 | ] |
| 1572 | 1815 | |
| ... | ... | @@ -1581,6 +1824,16 @@ dependencies = [ |
| 1581 | 1824 | "rand_core 0.10.1", |
| 1582 | 1825 | ] |
| 1583 | 1826 | |
| 1827 | [[package]] | |
| 1828 | name = "rand_chacha" | |
| 1829 | version = "0.3.1" | |
| 1830 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1831 | checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" | |
| 1832 | dependencies = [ | |
| 1833 | "ppv-lite86", | |
| 1834 | "rand_core 0.6.4", | |
| 1835 | ] | |
| 1836 | ||
| 1584 | 1837 | [[package]] |
| 1585 | 1838 | name = "rand_chacha" |
| 1586 | 1839 | version = "0.9.0" |
| ... | ... | @@ -1596,6 +1849,9 @@ name = "rand_core" |
| 1596 | 1849 | version = "0.6.4" |
| 1597 | 1850 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 1598 | 1851 | checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" |
| 1852 | dependencies = [ | |
| 1853 | "getrandom 0.2.17", | |
| 1854 | ] | |
| 1599 | 1855 | |
| 1600 | 1856 | [[package]] |
| 1601 | 1857 | name = "rand_core" |
| ... | ... | @@ -1721,6 +1977,16 @@ dependencies = [ |
| 1721 | 1977 | "webpki-roots", |
| 1722 | 1978 | ] |
| 1723 | 1979 | |
| 1980 | [[package]] | |
| 1981 | name = "rfc6979" | |
| 1982 | version = "0.4.0" | |
| 1983 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1984 | checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" | |
| 1985 | dependencies = [ | |
| 1986 | "hmac", | |
| 1987 | "subtle", | |
| 1988 | ] | |
| 1989 | ||
| 1724 | 1990 | [[package]] |
| 1725 | 1991 | name = "ring" |
| 1726 | 1992 | version = "0.17.14" |
| ... | ... | @@ -1766,6 +2032,27 @@ dependencies = [ |
| 1766 | 2032 | "uuid", |
| 1767 | 2033 | ] |
| 1768 | 2034 | |
| 2035 | [[package]] | |
| 2036 | name = "rsa" | |
| 2037 | version = "0.9.10" | |
| 2038 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2039 | checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" | |
| 2040 | dependencies = [ | |
| 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 | 2056 | [[package]] |
| 1770 | 2057 | name = "rusqlite" |
| 1771 | 2058 | version = "0.37.0" |
| ... | ... | @@ -1906,6 +2193,20 @@ dependencies = [ |
| 1906 | 2193 | "tendril", |
| 1907 | 2194 | ] |
| 1908 | 2195 | |
| 2196 | [[package]] | |
| 2197 | name = "sec1" | |
| 2198 | version = "0.7.3" | |
| 2199 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2200 | checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" | |
| 2201 | dependencies = [ | |
| 2202 | "base16ct", | |
| 2203 | "der", | |
| 2204 | "generic-array", | |
| 2205 | "pkcs8", | |
| 2206 | "subtle", | |
| 2207 | "zeroize", | |
| 2208 | ] | |
| 2209 | ||
| 1909 | 2210 | [[package]] |
| 1910 | 2211 | name = "selectors" |
| 1911 | 2212 | version = "0.37.0" |
| ... | ... | @@ -2084,6 +2385,16 @@ dependencies = [ |
| 2084 | 2385 | "libc", |
| 2085 | 2386 | ] |
| 2086 | 2387 | |
| 2388 | [[package]] | |
| 2389 | name = "signature" | |
| 2390 | version = "2.2.0" | |
| 2391 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2392 | checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" | |
| 2393 | dependencies = [ | |
| 2394 | "digest", | |
| 2395 | "rand_core 0.6.4", | |
| 2396 | ] | |
| 2397 | ||
| 2087 | 2398 | [[package]] |
| 2088 | 2399 | name = "simdutf8" |
| 2089 | 2400 | version = "0.1.5" |
| ... | ... | @@ -2124,6 +2435,16 @@ version = "0.9.9" |
| 2124 | 2435 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 2125 | 2436 | checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" |
| 2126 | 2437 | |
| 2438 | [[package]] | |
| 2439 | name = "spki" | |
| 2440 | version = "0.7.3" | |
| 2441 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2442 | checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" | |
| 2443 | dependencies = [ | |
| 2444 | "base64ct", | |
| 2445 | "der", | |
| 2446 | ] | |
| 2447 | ||
| 2127 | 2448 | [[package]] |
| 2128 | 2449 | name = "sse-stream" |
| 2129 | 2450 | version = "0.2.6" |
| ... | ... | @@ -2137,6 +2458,46 @@ dependencies = [ |
| 2137 | 2458 | "pin-project-lite", |
| 2138 | 2459 | ] |
| 2139 | 2460 | |
| 2461 | [[package]] | |
| 2462 | name = "ssh-cipher" | |
| 2463 | version = "0.2.0" | |
| 2464 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2465 | checksum = "caac132742f0d33c3af65bfcde7f6aa8f62f0e991d80db99149eb9d44708784f" | |
| 2466 | dependencies = [ | |
| 2467 | "cipher", | |
| 2468 | "ssh-encoding", | |
| 2469 | ] | |
| 2470 | ||
| 2471 | [[package]] | |
| 2472 | name = "ssh-encoding" | |
| 2473 | version = "0.2.0" | |
| 2474 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2475 | checksum = "eb9242b9ef4108a78e8cd1a2c98e193ef372437f8c22be363075233321dd4a15" | |
| 2476 | dependencies = [ | |
| 2477 | "base64ct", | |
| 2478 | "pem-rfc7468", | |
| 2479 | "sha2", | |
| 2480 | ] | |
| 2481 | ||
| 2482 | [[package]] | |
| 2483 | name = "ssh-key" | |
| 2484 | version = "0.6.7" | |
| 2485 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2486 | checksum = "3b86f5297f0f04d08cabaa0f6bff7cb6aec4d9c3b49d87990d63da9d9156a8c3" | |
| 2487 | dependencies = [ | |
| 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 | 2501 | [[package]] |
| 2141 | 2502 | name = "stable_deref_trait" |
| 2142 | 2503 | version = "1.2.1" |
| ... | ... | @@ -2750,7 +3111,7 @@ dependencies = [ |
| 2750 | 3111 | "openssl", |
| 2751 | 3112 | "openssl-sys", |
| 2752 | 3113 | "rand 0.9.5", |
| 2753 | "rand_chacha", | |
| 3114 | "rand_chacha 0.9.0", | |
| 2754 | 3115 | "serde", |
| 2755 | 3116 | "serde_cbor_2", |
| 2756 | 3117 | "serde_json", |
dashboard/Cargo.toml+2| ... | ... | @@ -12,6 +12,7 @@ chrono = "0.4" |
| 12 | 12 | futures = "0.3" |
| 13 | 13 | globset = "0.4" |
| 14 | 14 | openssl = "0.10" |
| 15 | percent-encoding = "2" | |
| 15 | 16 | rand = "0.9" |
| 16 | 17 | lol_html = "3" |
| 17 | 18 | regex = "1" |
| ... | ... | @@ -24,6 +25,7 @@ scraper = { version = "0.27", default-features = false } |
| 24 | 25 | serde_json = "1" |
| 25 | 26 | sha1 = "0.10" |
| 26 | 27 | sha2 = "0.10.9" |
| 28 | ssh-key = { version = "0.6", default-features = false, features = ["std", "ecdsa"] } | |
| 27 | 29 | subtle = "2.6" |
| 28 | 30 | tokio = { version = "1", features = ["full"] } |
| 29 | 31 | tokio-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 | 115 | ] { |
| 116 | 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 | 122 | db.execute( |
| 119 | 123 | "UPDATE users SET profile=? WHERE id=?", |
| 120 | 124 | sql![profile.to_string(), id], |
| ... | ... | @@ -188,6 +192,7 @@ impl Store { |
| 188 | 192 | CREATE TABLE IF NOT EXISTS pending (hash TEXT PRIMARY KEY,kind TEXT NOT NULL,data TEXT NOT NULL,expires INTEGER NOT NULL); |
| 189 | 193 | CREATE TABLE IF NOT EXISTS migration (digest TEXT PRIMARY KEY); |
| 190 | 194 | CREATE TABLE IF NOT EXISTS attempts (key TEXT PRIMARY KEY,count INTEGER NOT NULL,expires INTEGER NOT NULL);")?; |
| 195 | identity::initialise(&db)?; | |
| 191 | 196 | oidc::initialise(&db)?; |
| 192 | 197 | guest::initialise(&db)?; |
| 193 | 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 | 1 | use crate::*; |
| 2 | 2 | use tokio::io::{AsyncReadExt, AsyncWriteExt}; |
| 3 | 3 | |
| 4 | #[derive(serde::Deserialize)] | |
| 5 | struct Limits { request: usize, response: usize } | |
| 6 | static LIMITS: std::sync::LazyLock<Limits> = std::sync::LazyLock::new(|| serde_json::from_str(include_str!("host-protocol.json")).expect("host protocol limits")); | |
| 7 | ||
| 4 | 8 | static SLOTS: tokio::sync::Semaphore = tokio::sync::Semaphore::const_new(4); |
| 5 | 9 | |
| 6 | 10 | pub 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 | 52 | } |
| 49 | 53 | let mut message = serde_json::to_vec(&request)?; |
| 50 | 54 | message.push(b'\n'); |
| 51 | if message.len() > 65536 { | |
| 55 | if message.len() > LIMITS.request { | |
| 52 | 56 | return Err(Error::new( |
| 53 | 57 | 400, |
| 54 | 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 | 68 | |
| 65 | 69 | pub async fn response(socket: &mut tokio::net::UnixStream) -> Result<Value> { |
| 66 | 70 | let length = socket.read_u32().await? as usize; |
| 67 | if length > 16 * 1024 * 1024 { | |
| 71 | if length > LIMITS.response { | |
| 68 | 72 | return Err(Error::new( |
| 69 | 73 | 502, |
| 70 | 74 | "The host response is too large. Narrow the selection.", |
dashboard/src/identity.rs created+133| ... | ... | @@ -0,0 +1,133 @@ |
| 1 | use crate::*; | |
| 2 | use rusqlite::{Connection, OptionalExtension, params as sql}; | |
| 3 | use ssh_key::{HashAlg, PublicKey}; | |
| 4 | ||
| 5 | pub 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 | ||
| 13 | pub 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 | ||
| 23 | pub 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 | ||
| 37 | pub 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 | ||
| 59 | pub 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 | ||
| 64 | pub 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 | ||
| 72 | pub 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)] | |
| 85 | mod 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 | 1 | mod ai; |
| 2 | 2 | mod apps; |
| 3 | 3 | mod auth; |
| 4 | mod identity; | |
| 4 | 5 | mod cache; |
| 5 | 6 | mod core; |
| 6 | 7 | mod deploys; |
| ... | ... | @@ -451,6 +452,12 @@ async fn main() -> std::result::Result<(), Box<dyn std::error::Error>> { |
| 451 | 452 | ) |
| 452 | 453 | .map_err(|error| std::io::Error::other(error.message))?; |
| 453 | 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 | 461 | if path == "--guest-provider" { |
| 455 | 462 | let input = serde_json::from_reader(std::io::stdin())?; |
| 456 | 463 | let output = guest::provision(&auth, input) |
| ... | ... | @@ -578,7 +585,8 @@ async fn main() -> std::result::Result<(), Box<dyn std::error::Error>> { |
| 578 | 585 | return next.run(request).await; |
| 579 | 586 | } |
| 580 | 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 | 590 | || path.starts_with("/fonts/") |
| 583 | 591 | || path == "/snowflake.svg"; |
| 584 | 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 | 2 | use rmcp::{ |
| 3 | 3 | ErrorData, RoleServer, ServerHandler, |
| 4 | 4 | model::{ |
| 5 | CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, ListToolsResult, | |
| 6 | PaginatedRequestParams, ServerCapabilities, ServerConfig, Tool, ToolAnnotations, | |
| 5 | CacheScope, CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, | |
| 6 | ListToolsResult, PaginatedRequestParams, ServerCapabilities, ServerConfig, Tool, | |
| 7 | ToolAnnotations, | |
| 7 | 8 | }, |
| 8 | 9 | service::RequestContext, |
| 9 | 10 | }; |
| 10 | 11 | use scraper::{Html, Selector}; |
| 11 | 12 | |
| 12 | /// r1758 rotates CSRF tokens per deletion form; the final token also works on r1763. | |
| 13 | fn 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 | } | |
| 13 | const MODEL_PATTERN: &str = r"^[a-z0-9][a-z0-9._-]{0,127}$"; | |
| 51 | 14 | |
| 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. | |
| 54 | 15 | fn tokenless_r1616(document: &Html) -> bool { |
| 55 | 16 | if document |
| 56 | 17 | .select(&Selector::parse("input[name=csrf_token]").unwrap()) |
| ... | ... | @@ -63,24 +24,6 @@ fn tokenless_r1616(document: &Html) -> bool { |
| 63 | 24 | matches!(links.as_slice(), [link] if text(*link) == "shale r1616-ga87d2f5.zig.0.16.0") |
| 64 | 25 | } |
| 65 | 26 | |
| 66 | fn 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 | } | |
| 84 | 27 | |
| 85 | 28 | pub struct Backend { |
| 86 | 29 | pub(crate) origin: url::Url, |
| ... | ... | @@ -373,7 +316,7 @@ fn issue(html: &str, repository: &str, id: Option<u64>) -> Result<Value> { |
| 373 | 316 | ) |
| 374 | 317 | })?; |
| 375 | 318 | 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 | 320 | .next() |
| 378 | 321 | .map(text); |
| 379 | 322 | let time = comment |
| ... | ... | @@ -392,6 +335,34 @@ fn issue(html: &str, repository: &str, id: Option<u64>) -> Result<Value> { |
| 392 | 335 | json!({"repository":repository,"id":issue_id,"title":title.unwrap(),"status":status,"labels":labels,"comments":comments}), |
| 393 | 336 | ) |
| 394 | 337 | } |
| 338 | fn 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 | ||
| 395 | 366 | fn current(app: &App, grant: &Value, credential: &str) -> Result<()> { |
| 396 | 367 | let db = app.mcp.db.lock().unwrap(); |
| 397 | 368 | if mcp::get(&db, &format!("grant:{}", string(&grant["id"])))? != *grant |
| ... | ... | @@ -411,6 +382,7 @@ struct Shale(Arc<App>); |
| 411 | 382 | impl ServerHandler for Shale { |
| 412 | 383 | fn get_info(&self) -> ServerConfig { |
| 413 | 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 | 387 | async fn list_tools( |
| 416 | 388 | &self, |
| ... | ... | @@ -420,17 +392,30 @@ impl ServerHandler for Shale { |
| 420 | 392 | let tools = [ |
| 421 | 393 | ("list_repositories", "List repositories granted to this connection.", json!({}), json!([]), true), |
| 422 | 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 | 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 | 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 | 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 | 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 | 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 | 412 | Tool::new(name, description, json!({"type":"object","properties":properties,"required":required,"additionalProperties":false}).as_object().unwrap().clone()) |
| 430 | 413 | .with_annotations(ToolAnnotations::new().read_only(read).idempotent(read)) |
| 431 | 414 | }).collect(); |
| 432 | 415 | Ok(ListToolsResult { |
| 433 | 416 | tools, |
| 417 | ttl_ms: Some(0), | |
| 418 | cache_scope: Some(CacheScope::Private), | |
| 434 | 419 | ..Default::default() |
| 435 | 420 | }) |
| 436 | 421 | } |
| ... | ... | @@ -448,17 +433,28 @@ impl ServerHandler for Shale { |
| 448 | 433 | let arguments = request.arguments.unwrap_or_default(); |
| 449 | 434 | let allowed: &[&str] = match name { |
| 450 | 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 | 440 | "set_issue_status" => &["repository", "id", "status"], "set_issue_title" => &["repository", "id", "title"], |
| 453 | 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 | 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 | 447 | if !array(&grant["scopes"]).iter().any(|s| s == if write {"shale:write"} else {"shale:read"}) { |
| 460 | 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 | 458 | let credential = session(app, string(&grant["user"]))?; |
| 463 | 459 | current(app, grant, &credential)?; |
| 464 | 460 | if name == "list_repositories" { |
| ... | ... | @@ -470,6 +466,20 @@ impl ServerHandler for Shale { |
| 470 | 466 | let repository = arguments.get("repository").and_then(Value::as_str) |
| 471 | 467 | .filter(|r| grant["resources"] == "all" || array(&grant["resources"]).iter().any(|id| id == *r)) |
| 472 | 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 | 483 | let mut target = repository_path(&app.shale.origin, repository, &["issues", ""])?; |
| 474 | 484 | let issue_id = if matches!(name, "list_issues" | "create_issue") { None } else { |
| 475 | 485 | Some(arguments.get("id").and_then(Value::as_u64).filter(|n| *n > 0) |
| ... | ... | @@ -483,7 +493,7 @@ impl ServerHandler for Shale { |
| 483 | 493 | } |
| 484 | 494 | let mut fields = HashMap::new(); |
| 485 | 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 | 497 | let value = arguments.get(key).and_then(Value::as_str) |
| 488 | 498 | .filter(|v| v.len() <= max && (key == "description" || !v.trim().is_empty())) |
| 489 | 499 | .or_else(|| (key == "description" && !arguments.contains_key(key)).then_some("")) |
| ... | ... | @@ -491,6 +501,18 @@ impl ServerHandler for Shale { |
| 491 | 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 | 516 | username(&app.shale.page(&app.shale.origin.join("/-/settings")?, &credential).await?)?; |
| 495 | 517 | let body = app.shale.page(&target, &credential).await?; |
| 496 | 518 | if name == "list_issues" { |
| ... | ... | @@ -517,66 +539,27 @@ impl ServerHandler for Shale { |
| 517 | 539 | current(app, grant, &credential)?; |
| 518 | 540 | return Ok(json!({"issue":issue})); |
| 519 | 541 | } |
| 520 | let form_body = if name == "set_issue_title" { | |
| 521 | issue(&body, repository, issue_id)?; | |
| 522 | let mut edit = target.clone(); edit.query_pairs_mut().append_pair("edit", "title"); | |
| 523 | app.shale.page(&edit, &credential).await? | |
| 524 | } else { body }; | |
| 525 | let post_target = { | |
| 526 | let document = if name == "set_issue_title" { Html::parse_fragment(&form_body) } else { | |
| 527 | document(&form_body, if name == "create_issue" {"page-new-issue"} else {"page-issue"}, Some(repository))? | |
| 542 | if name == "create_issue" { | |
| 543 | document(&body, "page-new-issue", Some(repository))?; | |
| 544 | let status = match arguments.get("status").and_then(Value::as_str) { | |
| 545 | None if !arguments.contains_key("status") => "needs_triage", | |
| 546 | Some("triage") => "needs_triage", | |
| 547 | Some("todo") => "todo", | |
| 548 | _ => return Err(Error::new(400, "Choose todo or triage for a new issue.")), | |
| 528 | 549 | }; |
| 529 | let forms: Vec<_> = document.select(&Selector::parse("form[method=post]").unwrap()) | |
| 530 | .filter(|form| if name == "create_issue" { form.select(&Selector::parse("input[name=title]").unwrap()).next().is_some() } | |
| 531 | else { form.select(&Selector::parse("input[name=t]").unwrap()).any(|input| input.attr("value") == Some(match name { | |
| 532 | "comment_issue" => "comment", "set_issue_status" => "status", _ => "title", | |
| 533 | })) }).collect(); | |
| 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 | }; | |
| 550 | fields.insert("status".into(), status.into()); | |
| 551 | } else { issue(&body, repository, issue_id)?; } | |
| 552 | let mut fields = serde_json::to_value(fields)?; | |
| 553 | if let Some(id) = issue_id { fields["id"] = json!(id); } | |
| 554 | fields["model"] = json!(model.unwrap()); | |
| 558 | 555 | current(app, grant, &credential)?; |
| 559 | let outcome: Result<Value> = async { | |
| 560 | let (status, headers, body) = app.shale.request(Method::POST, post_target.clone(), Some(&credential), Some(&fields)).await?; | |
| 561 | let body = if matches!(status, StatusCode::SEE_OTHER | StatusCode::FOUND) { | |
| 562 | let target = headers.get("location").and_then(|v| v.to_str().ok()) | |
| 563 | .and_then(|location| post_target.join(location).ok()) | |
| 564 | .ok_or_else(|| Error::new(502, "Shale didn't return an issue destination."))?; | |
| 565 | let prefix = repository_path(&app.shale.origin, repository, &["issues", ""])?; | |
| 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.")) | |
| 556 | let result = host::call(json!({"operation":"shale.write","grant":grant["id"],"repository":repository,"action":name,"arguments":fields})).await | |
| 557 | .map_err(|error| if error.status >= 500 { Error::new(502, "The write outcome is unknown. Check Shale before retrying.") } else { error })?; | |
| 558 | let id = result["id"].as_u64().ok_or_else(|| Error::new(502, "The write outcome is unknown. Check Shale before retrying."))?; | |
| 559 | let target = repository_path(&app.shale.origin, repository, &["issues", &id.to_string()])?; | |
| 560 | let result = issue(&app.shale.page(&target, &credential).await?, repository, Some(id))?; | |
| 561 | current(app, grant, &credential).map_err(|_| Error::new(502, "The write outcome is unknown. Check Shale before retrying."))?; | |
| 562 | Ok(json!({"issue":result})) | |
| 580 | 563 | }.await; |
| 581 | 564 | Ok(match result { |
| 582 | 565 | Ok(value) => CallToolResult::structured(value), |
| ... | ... | @@ -881,80 +864,18 @@ pub async fn oauth(app: Arc<App>, request: Request) -> Response { |
| 881 | 864 | mod tests { |
| 882 | 865 | use super::*; |
| 883 | 866 | #[test] |
| 884 | fn issue_csrf_uses_last_deletion_token_and_refuses_ambiguous_markup() { | |
| 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>"; | |
| 886 | assert_eq!(issue_csrf(&Html::parse_document(initial)).unwrap(), None); | |
| 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 | ); | |
| 867 | fn label_catalog_keeps_native_ids_and_rejects_wrong_pages() { | |
| 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>"; | |
| 869 | let catalog = labels(page, "chat").unwrap(); | |
| 897 | 870 | assert_eq!( |
| 898 | issue_csrf(&Html::parse_document(&page)).unwrap().as_deref(), | |
| 899 | Some("latest") | |
| 871 | catalog["labels"][0], | |
| 872 | json!({"id":75,"name":"Bug & fix","description":"Description","color":"#123456","openIssues":2}) | |
| 900 | 873 | ); |
| 901 | for bad in [ | |
| 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()); | |
| 874 | assert!(labels(page, "other").is_err()); | |
| 954 | 875 | assert!( |
| 955 | prepare_issue_csrf( | |
| 956 | &newer, | |
| 957 | &mut HashMap::from([("csrf_token".to_owned(), String::new())]) | |
| 876 | labels( | |
| 877 | &page.replace("data-label-id=75", "data-label-id=bad"), | |
| 878 | "chat" | |
| 958 | 879 | ) |
| 959 | 880 | .is_err() |
| 960 | 881 | ); |
| ... | ... | @@ -1050,7 +971,9 @@ mod tests { |
| 1050 | 971 | #[test] |
| 1051 | 972 | fn issue_comment_authors_include_external_guest_profiles() { |
| 1052 | 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 | 977 | assert_eq!(parsed["comments"][0]["author"], "paperclover"); |
| 1055 | 978 | assert_eq!(parsed["comments"][1]["author"], "Astheno user"); |
| 1056 | 979 | } |
dashboard/src/shale_page.rs+366-15| ... | ... | @@ -1,4 +1,6 @@ |
| 1 | 1 | use crate::*; |
| 2 | use rusqlite::OptionalExtension; | |
| 3 | use std::collections::HashSet; | |
| 2 | 4 | use axum::body::Body; |
| 3 | 5 | use lol_html::{RewriteStrSettings, element, html_content::ContentType, text}; |
| 4 | 6 | |
| ... | ... | @@ -7,6 +9,7 @@ struct Profile { |
| 7 | 9 | url: Option<String>, |
| 8 | 10 | icon: &'static str, |
| 9 | 11 | picture: Option<String>, |
| 12 | owner: Option<(String, Option<String>)>, | |
| 10 | 13 | } |
| 11 | 14 | |
| 12 | 15 | fn profiles(auth: &auth::Store) -> Result<HashMap<String, Profile>> { |
| ... | ... | @@ -41,6 +44,7 @@ fn profiles(auth: &auth::Store) -> Result<HashMap<String, Profile>> { |
| 41 | 44 | name, |
| 42 | 45 | url, |
| 43 | 46 | icon, |
| 47 | owner: None, | |
| 44 | 48 | picture: if provider == "github" { |
| 45 | 49 | Some(format!( |
| 46 | 50 | "https://avatars.githubusercontent.com/u/{subject}?s=64" |
| ... | ... | @@ -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 | 72 | Ok(profiles) |
| 55 | 73 | } |
| 56 | 74 | |
| 75 | pub(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 | ||
| 96 | fn 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 | ||
| 57 | 135 | fn account_path(path: &str) -> Option<&str> { |
| 58 | 136 | let name = path.strip_prefix("/~")?; |
| 59 | 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 | ||
| 141 | fn 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 | } |
| 62 | 145 | |
| 63 | fn rewrite(html: &str, origin: &url::Url, profiles: &HashMap<String, Profile>) -> Result<String> { | |
| 146 | fn rewrite(html: &str, origin: &url::Url, profiles: &HashMap<String, Profile>, avatars: &HashMap<String, String>, open_count: Option<u64>) -> Result<String> { | |
| 64 | 147 | use std::{cell::Cell, rc::Rc}; |
| 148 | let markdown = Rc::new(Cell::new(0)); | |
| 149 | let bodies = markdown.clone(); | |
| 65 | 150 | let active = Rc::new(Cell::new(None::<bool>)); |
| 66 | 151 | let images = active.clone(); |
| 67 | 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 | 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 | 250 | .append_element_content_handler(element!("a[href]", |element| { |
| 70 | let profile = element.get_attribute("href") | |
| 71 | .and_then(|href| origin.join(&href).ok()) | |
| 72 | .filter(|target| target.origin() == origin.origin()) | |
| 73 | .and_then(|target| account_path(target.path()).and_then(|name| profiles.get(name))); | |
| 74 | active.set(profile.map(|profile| profile.picture.is_some())); | |
| 251 | let target = element.get_attribute("href").and_then(|href| origin.join(&href).ok()).filter(|target| target.origin() == origin.origin()); | |
| 252 | let ai_profile = target.as_ref().filter(|target| markdown.get() == 0 && target.path().trim_end_matches('/') == "/~ai") | |
| 253 | .map(|_| ai_profile(None)); | |
| 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)); | |
| 255 | active.set(profile.map(|profile| profile.picture.is_some() || ai_profile.is_some() || profile.owner.is_some())); | |
| 75 | 256 | let Some(profile) = profile else { return Ok(()); }; |
| 76 | 257 | let closing = active.clone(); |
| 77 | 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 | 263 | } else { |
| 83 | 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 | 267 | let style = element.get_attribute("style").unwrap_or_default(); |
| 86 | 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 | 271 | if let Some(picture) = &profile.picture { |
| 89 | let picture = picture.replace('&', "&amp;").replace('"', "&quot;").replace('<', "&lt;"); | |
| 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); | |
| 272 | element.prepend(&avatar(picture), 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 | 277 | let icon = if profile.url.is_some() { icon.replace("<path ", "<path style=\"fill:currentColor;stroke:none\" ") } else { icon }; |
| 94 | 278 | element.append(&icon, ContentType::Html); |
| 95 | 279 | if profile.url.is_some() { |
| 96 | 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 | 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 | 300 | Ok(()) |
| 101 | 301 | })) |
| 102 | 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 | 349 | .and_then(|v| v.to_str().ok()) |
| 150 | 350 | .unwrap_or_default(); |
| 151 | 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 | 353 | if service != "shale" |
| 154 | 354 | && !(service |
| 155 | 355 | .strip_prefix("shale-preview-") |
| ... | ... | @@ -246,7 +446,37 @@ pub async fn proxy(app: Arc<App>, request: Request) -> Result<Response> { |
| 246 | 446 | if prefix.len() <= 8 * 1024 * 1024 |
| 247 | 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 | 480 | for name in [ |
| 251 | 481 | "content-length", |
| 252 | 482 | "etag", |
| ... | ... | @@ -272,6 +502,125 @@ mod tests { |
| 272 | 502 | use super::*; |
| 273 | 503 | use scraper::{Html, Selector}; |
| 274 | 504 | |
| 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 | 624 | #[test] |
| 276 | 625 | fn rewrites_only_known_local_account_links_and_escapes_provider_names() { |
| 277 | 626 | let origin = url::Url::parse("https://shale.paperclover.net/").unwrap(); |
| ... | ... | @@ -282,6 +631,7 @@ mod tests { |
| 282 | 631 | name: "<&\"clover".into(), |
| 283 | 632 | url: Some("https://github.com/clover".into()), |
| 284 | 633 | icon: include_str!("../web/sso/github.svg"), |
| 634 | owner: None, | |
| 285 | 635 | picture: Some("https://avatars.githubusercontent.com/u/123?s=64".into()), |
| 286 | 636 | }, |
| 287 | 637 | ), |
| ... | ... | @@ -291,12 +641,13 @@ mod tests { |
| 291 | 641 | name: "Astheno user".into(), |
| 292 | 642 | url: None, |
| 293 | 643 | icon: include_str!("astheno.svg"), |
| 644 | owner: None, | |
| 294 | 645 | picture: None, |
| 295 | 646 | }, |
| 296 | 647 | ), |
| 297 | 648 | ]); |
| 298 | 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 | 651 | let page = Html::parse_document(&rewritten); |
| 301 | 652 | let links: Vec<_> = page.select(&Selector::parse("body > a").unwrap()).collect(); |
| 302 | 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 | 35 | let value = match key { |
| 36 | 36 | "username" => { |
| 37 | 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 | 41 | if v.len() > 254 || !username_pattern.is_match(&v) { |
| 39 | 42 | return Err(Error::new( |
| 40 | 43 | 400, |
| ... | ... | @@ -155,7 +158,7 @@ pub async fn route( |
| 155 | 158 | for id in &users { |
| 156 | 159 | uuid(id)?; |
| 157 | 160 | let user = auth::user(&transaction, id)?; |
| 158 | if guest::is_guest(&user) { | |
| 161 | if guest::is_guest(&user) || user["kind"] == "ai" { | |
| 159 | 162 | return Err(Error::new( |
| 160 | 163 | 400, |
| 161 | 164 | "Guests can use Shale only. Select regular accounts to edit groups.", |
| ... | ... | @@ -196,7 +199,7 @@ pub async fn route( |
| 196 | 199 | } |
| 197 | 200 | if parts.is_empty() && method == Method::GET { |
| 198 | 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 | 203 | let ids = statement |
| 201 | 204 | .query_map([], |r| r.get::<_, String>(0))? |
| 202 | 205 | .collect::<std::result::Result<Vec<_>, _>>()?; |
| ... | ... | @@ -274,6 +277,9 @@ pub async fn route( |
| 274 | 277 | .first() |
| 275 | 278 | .ok_or_else(|| Error::new(404, "No user here."))?; |
| 276 | 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 | 283 | if *parts == [*id, "setup-link"] && method == Method::POST { |
| 278 | 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 | 361 | } |
| 356 | 362 | 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 | 377 | [_, "credentials"] if method == Method::GET => auth::credentials(&transaction, id)?, |
| 359 | 378 | [_, "logout"] if method == Method::POST => { |
| 360 | 379 | transaction.execute("DELETE FROM sessions WHERE user_id=?", [id])?; |
| ... | ... | @@ -457,6 +476,7 @@ pub async fn account( |
| 457 | 476 | let id = string(&user["id"]).to_owned(); |
| 458 | 477 | let value = match parts { |
| 459 | 478 | [] if method == Method::GET => { |
| 479 | user["commitIdentity"] = identity::account(&app.auth.db.lock().unwrap(), &id)?; | |
| 460 | 480 | user["picture"] = user["attributes"]["picture"][0].clone(); |
| 461 | 481 | user["credentials"] = auth::credentials(&app.auth.db.lock().unwrap(), &id)?; |
| 462 | 482 | user.as_object_mut().unwrap().remove("attributes"); |
| ... | ... | @@ -485,6 +505,56 @@ pub async fn account( |
| 485 | 505 | auth::save_user(&app.auth.db.lock().unwrap(), &id, user)?; |
| 486 | 506 | 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 | 558 | ["credentials", credential] if method == Method::DELETE => { |
| 489 | 559 | app.auth.recent(request.headers())?; |
| 490 | 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 | 229 | $put: Endpoint<null, { param: { id: string; group: string; }; }, 204, "body">; |
| 230 | 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 | 241 | "/users/:id/credentials": { |
| 233 | 242 | $get: Endpoint<Credential[], { param: { id: string; }; }>; |
| 234 | 243 | }; |
| ... | ... | @@ -302,9 +311,23 @@ export type Api = Hono<{}, { |
| 302 | 311 | $post: Endpoint<null, { param: { name: string; action: "start" | "destroy" | "shutdown" | "reboot" | "resume"; }; }, 204, "body">; |
| 303 | 312 | }; |
| 304 | 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 | 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 | 331 | "/account/picture": { |
| 309 | 332 | $put: Endpoint<{ picture: string; }, { form: { picture: File; }; }>; |
| 310 | 333 | $delete: Endpoint<null, {}, 204, "body">; |
dashboard/web/pages/Account.css+12| ... | ... | @@ -22,3 +22,15 @@ |
| 22 | 22 | .credentials > .button { justify-self: end; } |
| 23 | 23 | .credentials .passkey { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } |
| 24 | 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 | 1 | import { useSearchParams } from "@solidjs/router"; |
| 2 | 2 | import { createResource, createSignal, For, Show } from "solid-js"; |
| 3 | 3 | import { parseResponse } from "hono/client"; |
| 4 | import type { User } from "../types/users.ts"; | |
| 4 | import type { User, CommitIdentity as Identity } from "../types/users.ts"; | |
| 5 | 5 | import { api, reason } from "../api.ts"; |
| 6 | 6 | import { Ago } from "../components/Ago.tsx"; |
| 7 | 7 | import { lastGood, Loaded } from "../components/Loaded.tsx"; |
| ... | ... | @@ -73,6 +73,7 @@ export function Account() { |
| 73 | 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 | 74 | <Profile user={user()} /> |
| 75 | 75 | <SignIn user={user()} /> |
| 76 | <CommitIdentity user={user()} identity={user().commitIdentity} refetch={refetchAccount} /> | |
| 76 | 77 | </div> |
| 77 | 78 | )} |
| 78 | 79 | </Loaded> |
| ... | ... | @@ -227,3 +228,85 @@ function SignIn(props: { user: Self }) { |
| 227 | 228 | <Show when={error()}><p class="error" role="alert">{error()}</p></Show> |
| 228 | 229 | </section>; |
| 229 | 230 | } |
| 231 | ||
| 232 | export 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 | 22 | import { toast } from "../components/Toast.tsx"; |
| 23 | 23 | import { ago, count, date, datetime, plural } from "../format.ts"; |
| 24 | 24 | import "./Users.css"; |
| 25 | import { CommitIdentity } from "./Account.tsx"; | |
| 25 | 26 | |
| 26 | 27 | const STEPS: [string, string][] = [["SETUP", "finish setup"], ["UPDATE_PASSWORD", "new password"], ["UPDATE_PROFILE", "check profile"]]; |
| 27 | 28 | |
| ... | ... | @@ -33,6 +34,7 @@ const load = () => Promise.all([parseResponse(api.users.$get()), parseResponse(a |
| 33 | 34 | .then(([{ users, groups }, services]) => ({ users, groups, services })); |
| 34 | 35 | type Data = Awaited<ReturnType<typeof load>>; |
| 35 | 36 | export const directory = query("users", load); |
| 37 | const identityOf = query("commit-identity", (id: string) => parseResponse(api.users[":id"]["commit-identity"].$get({ param: { id } }))); | |
| 36 | 38 | const credentialsOf = query("credentials", (id: string) => parseResponse(api.users[":id"].credentials.$get({ param: { id } }))); |
| 37 | 39 | type User = Data["users"][number]; |
| 38 | 40 | |
| ... | ... | @@ -466,6 +468,7 @@ function Profile(props: { user: User; data: Data; refetch: () => unknown }) { |
| 466 | 468 | const back = useBack(); |
| 467 | 469 | const param = () => ({ id: props.user.id }); |
| 468 | 470 | const [credentials, credentialActions] = credentialsOf.use(() => props.user.id); |
| 471 | const [identity, identityActions] = identityOf.use(() => props.user.id); | |
| 469 | 472 | const [busy, setBusy] = createSignal<string>(); |
| 470 | 473 | |
| 471 | 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 | 647 | </div> |
| 645 | 648 | </section> |
| 646 | 649 | |
| 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 | 656 | <section class="card"> |
| 648 | 657 | <h2 class="card-title"> |
| 649 | 658 | sessions |
dashboard/web/types/users.ts+5| ... | ... | @@ -33,3 +33,8 @@ export interface Session { |
| 33 | 33 | lastAccess: number; |
| 34 | 34 | clients: Record<string, string>; |
| 35 | 35 | } |
| 36 | ||
| 37 | export 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 | 6 | proxyToken = "/var/lib/studio/dashboard-proxy.token"; |
| 7 | 7 | hostTools = lib.fileset.toSource { |
| 8 | 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 | 11 | nativePkl = pkgs.callPackage ./pkl.nix { }; |
| 12 | 12 | screenPython = pkgs.python3.withPackages (packages: [ packages.pygobject3 packages.gst-python ]); |
| ... | ... | @@ -16,6 +16,7 @@ let |
| 16 | 16 | msVarsTemplate = true; |
| 17 | 17 | tpmSupport = true; |
| 18 | 18 | }; |
| 19 | ||
| 19 | 20 | in |
| 20 | 21 | { |
| 21 | 22 | nixpkgs.config.allowUnfreePredicate = pkg: builtins.elem (lib.getName pkg) [ "nomad" "nvidia-x11" "nvidia-kernel-modules" ]; |
| ... | ... | @@ -175,11 +176,11 @@ in |
| 175 | 176 | RuntimeDirectoryPreserve = "yes"; |
| 176 | 177 | UMask = "0077"; |
| 177 | 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 | 180 | ProtectHome = true; |
| 180 | 181 | PrivateTmp = true; |
| 181 | 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 | 184 | MemoryMax = "256M"; |
| 184 | 185 | TasksMax = 64; |
| 185 | 186 | DevicePolicy = "closed"; |
| ... | ... | @@ -291,6 +292,7 @@ in |
| 291 | 292 | |
| 292 | 293 | systemd.tmpfiles.rules = [ |
| 293 | 294 | "d /srv/prod 0755 root root -" |
| 295 | "A+ /srv/prod/shale/data - - - - u:root:rwX,d:u:root:rwx" | |
| 294 | 296 | "d /srv/staging 0755 root root -" |
| 295 | 297 | "d /srv/vm 0755 root root -" |
| 296 | 298 | "d /srv/logs 0770 clo chloe -" |
service/shale/service.pkl+2-2| ... | ... | @@ -18,7 +18,7 @@ requirements { |
| 18 | 18 | prepare = "prepare.py" |
| 19 | 19 | |
| 20 | 20 | container { |
| 21 | image = "docker.io/astheno/shale@sha256:0c507bb2d2f0d6390b8411f644d5c7c7579a384d14ccae2cf82eb51c2eb6466e" | |
| 21 | image = "docker.io/astheno/shale@sha256:547a126d829c81be5d2435223a093337951e2c6ae71a114adf69938485fb3fa8" | |
| 22 | 22 | extraHosts { "\(sso.hostname):host-gateway" } |
| 23 | 23 | |
| 24 | 24 | http { |
| ... | ... | @@ -31,7 +31,7 @@ container { |
| 31 | 31 | } |
| 32 | 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 | 12 | import tempfile |
| 13 | 13 | import time |
| 14 | 14 | import uuid |
| 15 | from urllib.parse import quote | |
| 15 | 16 | from cryptography.hazmat.primitives import hashes |
| 16 | 17 | from cryptography.hazmat.primitives.asymmetric import ec |
| 17 | 18 | from cryptography.hazmat.primitives.kdf.argon2 import Argon2id |
| ... | ... | @@ -112,6 +113,11 @@ def main(): |
| 112 | 113 | cookies = {} |
| 113 | 114 | start() |
| 114 | 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 | 121 | request('/api/me', status=401, extra={'User-Name': name, 'User-Groups': 'infra-admin'}) |
| 116 | 122 | request('/auth/status', status=403, extra={'Studio-Proxy-Token': 'wrong'}) |
| 117 | 123 | csrf = request('/auth/status', cookies=cookies)['csrf'] |
| ... | ... | @@ -188,6 +194,35 @@ def main(): |
| 188 | 194 | request('/auth/setup', 'POST', {'csrf': new_csrf, 'setup': setup, 'email': 'new@example.invalid', 'password': 'another-password'}, newcomer) |
| 189 | 195 | request('/auth/setup', 'POST', {'csrf': new_csrf, 'setup': setup, 'email': 'new@example.invalid', 'password': 'another-password'}, newcomer, 410) |
| 190 | 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 | 226 | directory = request('/api/users', cookies=cookies) |
| 192 | 227 | groups = {g['name']: g['id'] for g in directory['groups']} |
| 193 | 228 | assert 'ai' in groups |
| ... | ... | @@ -242,7 +277,7 @@ def main(): |
| 242 | 277 | request('/api/users/' + actor + '/logout', 'POST', {}, cookies, 204) |
| 243 | 278 | request('/api/me', cookies=cookies, status=401) |
| 244 | 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 | 281 | finally: |
| 247 | 282 | if server and server.poll() is None: stop() |
| 248 | 283 | log.close() |
tools/dashboard-host-test.py+38| ... | ... | @@ -4,6 +4,10 @@ import json |
| 4 | 4 | from pathlib import Path |
| 5 | 5 | import sys |
| 6 | 6 | import subprocess |
| 7 | import os | |
| 8 | import socket | |
| 9 | import struct | |
| 10 | import threading | |
| 7 | 11 | import unittest |
| 8 | 12 | from unittest.mock import patch |
| 9 | 13 | |
| ... | ... | @@ -17,6 +21,40 @@ class Boundary(unittest.TestCase): |
| 17 | 21 | def setUp(self): |
| 18 | 22 | self.host = host_module.Host("studio-demo") |
| 19 | 23 | |
| 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 | 58 | def test_rejects_before_executing(self): |
| 21 | 59 | requests = [None, [], {}, {"operation": []}, |
| 22 | 60 | {"operation": "command", "argv": ["sh", "-c", "id"]}, |
tools/dashboard-host.py+17-7| ... | ... | @@ -22,6 +22,7 @@ import vms |
| 22 | 22 | import tty |
| 23 | 23 | |
| 24 | 24 | dashboard_runs = importlib.import_module("dashboard-run") |
| 25 | shale_labels = importlib.import_module("shale-labels") | |
| 25 | 26 | |
| 26 | 27 | |
| 27 | 28 | FIELDS = ( |
| ... | ... | @@ -30,8 +31,9 @@ FIELDS = ( |
| 30 | 31 | "quota", "origin", "mounted", |
| 31 | 32 | ) |
| 32 | 33 | TEXT_FIELDS = {"name", "compression", "mountpoint", "origin", "mounted"} |
| 33 | MAX_REQUEST = 65536 | |
| 34 | MAX_RESPONSE = 16 * 1024 * 1024 | |
| 34 | PROTOCOL = json.loads((Path(__file__).resolve().parent.parent / "dashboard/src/host-protocol.json").read_text()) | |
| 35 | MAX_REQUEST = PROTOCOL['request'] | |
| 36 | MAX_RESPONSE = PROTOCOL['response'] | |
| 35 | 37 | STREAM_SLOTS = threading.BoundedSemaphore(4) |
| 36 | 38 | GUEST_SLOTS = threading.BoundedSemaphore(4) |
| 37 | 39 | VM_PREPARATION_SLOT = threading.BoundedSemaphore(1) |
| ... | ... | @@ -84,6 +86,8 @@ class Host: |
| 84 | 86 | "index.snapshots": {"dataset"}, |
| 85 | 87 | "index.diff": {"dataset", "from", "to"}, |
| 86 | 88 | "index.discard": {"dataset", "snapshot"}, |
| 89 | "shale.metadata": {"repository", "accounts"}, | |
| 90 | "shale.write": {"grant", "repository", "action", "arguments"}, | |
| 87 | 91 | "host.sample": set(), |
| 88 | 92 | "host.usage": {"refresh"}, |
| 89 | 93 | **dashboard_runs.FIELDS, |
| ... | ... | @@ -93,6 +97,10 @@ class Host: |
| 93 | 97 | raise Rejected("Choose a supported host operation.") |
| 94 | 98 | if set(request) != parameters[operation] | {"operation"}: |
| 95 | 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 | 104 | if operation in dashboard_runs.FIELDS: |
| 97 | 105 | return dashboard_runs.handle(request) |
| 98 | 106 | if operation == "host.sample": |
| ... | ... | @@ -421,14 +429,16 @@ def serve_connection(connection, host, allowed_uid): |
| 421 | 429 | if uid != allowed_uid: |
| 422 | 430 | return |
| 423 | 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 | 433 | line = bytearray() |
| 426 | 434 | while len(line) <= MAX_REQUEST: |
| 427 | byte = connection.recv(1) | |
| 428 | if not byte: | |
| 435 | chunk = connection.recv(min(65536, MAX_REQUEST + 1 - len(line)), socket.MSG_PEEK) | |
| 436 | if not chunk: | |
| 429 | 437 | break |
| 430 | line.extend(byte) | |
| 431 | if byte == b"\n": | |
| 438 | end = chunk.find(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 | 442 | break |
| 433 | 443 | if len(line) > MAX_REQUEST or not line.endswith(b"\n"): |
| 434 | 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 | |
| 2 | import argparse | |
| 3 | import hashlib | |
| 4 | import html | |
| 5 | import http.client | |
| 6 | import http.server | |
| 7 | import json | |
| 8 | import os | |
| 9 | from pathlib import Path | |
| 10 | import socket | |
| 11 | import sqlite3 | |
| 12 | import ssl | |
| 13 | import struct | |
| 14 | import subprocess | |
| 15 | import tempfile | |
| 16 | import threading | |
| 17 | import time | |
| 18 | import urllib.parse | |
| 19 | ||
| 20 | ||
| 21 | def 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 | ||
| 223 | if __name__ == '__main__': | |
| 224 | main() |
tools/dashboard-shale-link-test.py+30-33| ... | ... | @@ -47,7 +47,8 @@ def main(): |
| 47 | 47 | origin = 'https://globe.studio.test' |
| 48 | 48 | proof = args.proof_file.read_text().strip() |
| 49 | 49 | 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 | 52 | ids = [] |
| 52 | 53 | sessions = {name: uuid.uuid4().hex + uuid.uuid4().hex for name, _ in accounts} |
| 53 | 54 | |
| ... | ... | @@ -176,7 +177,7 @@ def main(): |
| 176 | 177 | pending = urllib.parse.urlsplit(headers['Location']).path.removeprefix('/connect/') |
| 177 | 178 | details = api(accounts[index][0], path='/api/mcp/consent/' + pending) |
| 178 | 179 | 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 | 181 | return pending, verifier, details |
| 181 | 182 | |
| 182 | 183 | def consent(client, index, repository, scope): |
| ... | ... | @@ -239,6 +240,8 @@ def main(): |
| 239 | 240 | identity = db.execute('SELECT id FROM users WHERE snowflake=?', (ids[index],)).fetchone()[0] |
| 240 | 241 | assert db.execute('UPDATE repositories SET owner=? WHERE name=?', (identity, name)).rowcount == 1 |
| 241 | 242 | |
| 243 | for name, _ in accounts: | |
| 244 | assert not keycloak.request('/admin/realms/master/users?username=' + name + '&exact=true'), 'Fixture account already exists: ' + name | |
| 242 | 245 | try: |
| 243 | 246 | for name, credential in accounts: |
| 244 | 247 | keycloak.request('/admin/realms/master/users', 'POST', {'username': name, 'firstName': name, |
| ... | ... | @@ -290,11 +293,19 @@ def main(): |
| 290 | 293 | other = consent(oauth_client, 1, 'foreign', 'shale:read shale:write') |
| 291 | 294 | read = readonly['access_token'] |
| 292 | 295 | write = writing['access_token'] |
| 293 | assert rpc(read, 'initialize', {'protocolVersion': '2025-11-25', 'capabilities': {}, | |
| 294 | 'clientInfo': {'name': marker, 'version': '1'}})['capabilities']['tools'] == {} | |
| 295 | tools = rpc(read, 'tools/list')['tools'] | |
| 296 | initialized = rpc(read, 'initialize', {'protocolVersion': '2025-11-25', 'capabilities': {}, | |
| 297 | 'clientInfo': {'name': marker, 'version': '1'}}) | |
| 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 | 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 | 309 | assert all(tool['annotations']['readOnlyHint'] == tool['name'].startswith(('list_', 'get_')) for tool in tools) |
| 299 | 310 | assert {repo['id'] for repo in call(read, 'list_repositories')['repositories']} == {'alpha'} |
| 300 | 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 | 346 | call(read, 'create_issue', {'repository': 'alpha', 'title': 'REFUSED'}, error=True) |
| 336 | 347 | call(write, 'create_issue', {'repository': 'alpha', 'title': 'REFUSED', 'url': 'https://other.invalid'}, error=True) |
| 337 | 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 | 350 | title = 'MCP <angle> & Unicode ☃' |
| 339 | created = call(write, 'create_issue', {'repository': 'alpha', 'title': title, | |
| 340 | 'description': 'Owned body <script>fixture</script> & Unicode ☃'})['issue'] | |
| 351 | status, _, _ = submit_backend(0, '/alpha/issues/new', {'title': title, | |
| 352 | 'description': 'Owned body <script>fixture</script> & Unicode ☃'}) | |
| 353 | assert status == 303 | |
| 354 | created = call(read, 'get_issue', {'repository': 'alpha', 'id': 1})['issue'] | |
| 341 | 355 | assert created['title'] == title and created['id'] == 1 |
| 342 | 356 | assert len(created['comments']) == 1 and 'Unicode ☃' in created['comments'][0]['text'] |
| 343 | 357 | assert '<script>' not in json.dumps(created) |
| ... | ... | @@ -346,13 +360,6 @@ def main(): |
| 346 | 360 | rejected = call(read, 'list_issues', {'repository': 'alpha', 'q': 'Unicode'}, error=True) |
| 347 | 361 | assert 'filter syntax' in rejected['content'][0]['text'], rejected |
| 348 | 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 | 363 | status, _, _ = submit_backend(0, '/alpha/issues/labels', {'name': 'Owned ☃', 'description': 'Fixture label', 'color': '#ff40ff'}) |
| 357 | 364 | assert status == 303 |
| 358 | 365 | status, _, _ = submit_backend(0, '/alpha/issues/1', {'t': 'labels', 'labels': '1'}) |
| ... | ... | @@ -363,23 +370,15 @@ def main(): |
| 363 | 370 | rows = db.execute('SELECT id FROM users WHERE snowflake=?', (ids[1],)).fetchall() |
| 364 | 371 | assert len(rows) == 1 |
| 365 | 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 | 374 | try: |
| 368 | 375 | call(read, 'get_issue', issue_fields, error=True) |
| 369 | 376 | call(write, 'create_issue', {'repository': 'alpha', 'title': 'REFUSED'}, error=True) |
| 370 | 377 | finally: |
| 371 | 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 | 380 | with sqlite3.connect(args.shale_database) as db: |
| 374 | 381 | 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 | 382 | subprocess.run(['systemctl', 'restart', args.restart_unit], check=True, capture_output=True, timeout=30) |
| 384 | 383 | assert session(0) == new_first and session(1) == second |
| 385 | 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 | 418 | api(accounts[1][0], 'DELETE', '/api/mcp/shale', 204) |
| 420 | 419 | assert session(1) is None and session_count(1) == 0 |
| 421 | 420 | backend_session(second, 303) |
| 421 | api(accounts[2][0], 'DELETE', '/api/mcp/shale', 204) | |
| 422 | assert session(2) is None | |
| 422 | 423 | assert not records('shale-link:') and not records('shale-session:') |
| 423 | 424 | result = {'two_user_oidc_linking': True, 'same_realm_ordinary_users': True, 'host_only_callback_cookie': True, |
| 424 | 425 | 'single_use_state_and_expiry': True, 'account_mismatch_refused_and_session_removed': True, |
| ... | ... | @@ -427,25 +428,21 @@ def main(): |
| 427 | 428 | 'unlink_cancels_inflight_callback': True, 'unlink_revokes_backend_session': True, |
| 428 | 429 | 'oauth_link_returns_to_consent': True, 'sdk_catalog_and_repository_grants': True, |
| 429 | 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 | 432 | 'native_issue_labels_read': True, |
| 432 | 'committed_write_lost_response_reported_without_replay': True, | |
| 433 | 433 | 'backend_permission_changes_enforced': True, 'expired_backend_session_refused': True, |
| 434 | 434 | 'all_repository_approval_and_dynamic_access': True, 'nested_repository_paths': True, 'existing_token_resource_edits': True, |
| 435 | 435 | 'restart_preserves_mcp_access': True, 'unlink_revokes_mcp_access_and_refresh': True} |
| 436 | 436 | finally: |
| 437 | 437 | keycloak = Keycloak('keycloak.studio.test', importlib.import_module('dashboard-run').secret('get', 'keycloak', 'password'), attempts=1) |
| 438 | 438 | cleanup_errors = [] |
| 439 | for name, _ in accounts: | |
| 439 | for (name, _), identity in zip(accounts, ids): | |
| 440 | 440 | try: |
| 441 | if keycloak.request('/admin/realms/master/users?username=' + name + '&exact=true'): | |
| 442 | api(name, 'DELETE', '/api/mcp/shale', 204) | |
| 441 | api(name, 'DELETE', '/api/mcp/shale', 204) | |
| 443 | 442 | except Exception as error: |
| 444 | 443 | cleanup_errors.append(error) |
| 445 | 444 | try: |
| 446 | for user in keycloak.request('/admin/realms/master/users?username=' + name + '&exact=true'): | |
| 447 | assert user['username'] == name | |
| 448 | keycloak.request('/admin/realms/master/users/' + user['id'], 'DELETE') | |
| 445 | keycloak.request('/admin/realms/master/users/' + identity, 'DELETE') | |
| 449 | 446 | except Exception as error: |
| 450 | 447 | cleanup_errors.append(error) |
| 451 | 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 | 103 | token = root / 'proxy.token' |
| 104 | 104 | token.write_text(proof) |
| 105 | 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 | 107 | data = root / 'data' |
| 108 | data.mkdir() | |
| 108 | 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 | 110 | for key in ['STUDIO_INDEX_POOL', 'STUDIO_AUTH_REQUIRED', 'STUDIO_YT_STATE']: |
| 110 | 111 | environment.pop(key, None) |
tools/shale-labels-test.py created+189| ... | ... | @@ -0,0 +1,189 @@ |
| 1 | #!/usr/bin/env python3 | |
| 2 | import importlib | |
| 3 | import json | |
| 4 | import os | |
| 5 | from pathlib import Path | |
| 6 | import sqlite3 | |
| 7 | import tempfile | |
| 8 | import unittest | |
| 9 | from unittest.mock import patch | |
| 10 | ||
| 11 | shale = importlib.import_module('shale-labels') | |
| 12 | ||
| 13 | ||
| 14 | class 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 | ||
| 188 | if __name__ == '__main__': | |
| 189 | unittest.main() |
tools/shale-labels.py created+238| ... | ... | @@ -0,0 +1,238 @@ |
| 1 | import json | |
| 2 | import os | |
| 3 | from pathlib import Path | |
| 4 | import re | |
| 5 | import secrets | |
| 6 | import sqlite3 | |
| 7 | from datetime import datetime, timezone | |
| 8 | import time | |
| 9 | import uuid | |
| 10 | from contextlib import closing | |
| 11 | ||
| 12 | from importlib import import_module | |
| 13 | ||
| 14 | Error = import_module('dashboard-run').Error | |
| 15 | ||
| 16 | ||
| 17 | def 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 | ||
| 22 | def 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 | ||
| 28 | def 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 | ||
| 33 | def 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 | ||
| 63 | def 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 | ||
| 93 | def 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 |