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

Attribute Shale writes to model accounts and link verified commit identities


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

config/shale.json created+1
......@@ -0,0 +1 @@
1{"actorOwners":{"ai":"8c909fab-98b0-4472-9171-9606ccebe9d5"},"statusDelegates":{"ai":["chat","snowbound"]}}
dashboard/Cargo.lock+363-2
......@@ -156,6 +156,12 @@ dependencies = [
156156 "tracing",
157157]
158158
159[[package]]
160name = "base16ct"
161version = "0.2.0"
162source = "registry+https://github.com/rust-lang/crates.io-index"
163checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
164
159165[[package]]
160166name = "base64"
161167version = "0.21.7"
......@@ -284,6 +290,22 @@ dependencies = [
284290 "windows-link",
285291]
286292
293[[package]]
294name = "cipher"
295version = "0.4.4"
296source = "registry+https://github.com/rust-lang/crates.io-index"
297checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
298dependencies = [
299 "crypto-common",
300 "inout",
301]
302
303[[package]]
304name = "const-oid"
305version = "0.9.6"
306source = "registry+https://github.com/rust-lang/crates.io-index"
307checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
308
287309[[package]]
288310name = "core-foundation-sys"
289311version = "0.8.7"
......@@ -320,6 +342,18 @@ version = "0.2.4"
320342source = "registry+https://github.com/rust-lang/crates.io-index"
321343checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
322344
345[[package]]
346name = "crypto-bigint"
347version = "0.5.5"
348source = "registry+https://github.com/rust-lang/crates.io-index"
349checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
350dependencies = [
351 "generic-array",
352 "rand_core 0.6.4",
353 "subtle",
354 "zeroize",
355]
356
323357[[package]]
324358name = "crypto-common"
325359version = "0.1.7"
......@@ -382,6 +416,16 @@ version = "2.11.1"
382416source = "registry+https://github.com/rust-lang/crates.io-index"
383417checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
384418
419[[package]]
420name = "der"
421version = "0.7.10"
422source = "registry+https://github.com/rust-lang/crates.io-index"
423checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
424dependencies = [
425 "const-oid",
426 "zeroize",
427]
428
385429[[package]]
386430name = "der-parser"
387431version = "9.0.0"
......@@ -430,6 +474,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
430474checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
431475dependencies = [
432476 "block-buffer",
477 "const-oid",
433478 "crypto-common",
434479 "subtle",
435480]
......@@ -466,12 +511,45 @@ version = "1.0.20"
466511source = "registry+https://github.com/rust-lang/crates.io-index"
467512checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
468513
514[[package]]
515name = "ecdsa"
516version = "0.16.9"
517source = "registry+https://github.com/rust-lang/crates.io-index"
518checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
519dependencies = [
520 "der",
521 "digest",
522 "elliptic-curve",
523 "rfc6979",
524 "signature",
525 "spki",
526]
527
469528[[package]]
470529name = "ego-tree"
471530version = "0.11.0"
472531source = "registry+https://github.com/rust-lang/crates.io-index"
473532checksum = "b04dc5a38e4f151a79d9f2451ae6037fb6eaf5cba34771f44781f80e508498e3"
474533
534[[package]]
535name = "elliptic-curve"
536version = "0.13.8"
537source = "registry+https://github.com/rust-lang/crates.io-index"
538checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
539dependencies = [
540 "base16ct",
541 "crypto-bigint",
542 "digest",
543 "ff",
544 "generic-array",
545 "group",
546 "pkcs8",
547 "rand_core 0.6.4",
548 "sec1",
549 "subtle",
550 "zeroize",
551]
552
475553[[package]]
476554name = "encoding_rs"
477555version = "0.8.42"
......@@ -520,6 +598,16 @@ version = "2.5.0"
520598source = "registry+https://github.com/rust-lang/crates.io-index"
521599checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
522600
601[[package]]
602name = "ff"
603version = "0.13.1"
604source = "registry+https://github.com/rust-lang/crates.io-index"
605checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393"
606dependencies = [
607 "rand_core 0.6.4",
608 "subtle",
609]
610
523611[[package]]
524612name = "find-msvc-tools"
525613version = "0.1.14"
......@@ -658,6 +746,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
658746dependencies = [
659747 "typenum",
660748 "version_check",
749 "zeroize",
661750]
662751
663752[[package]]
......@@ -712,6 +801,17 @@ dependencies = [
712801 "regex-syntax",
713802]
714803
804[[package]]
805name = "group"
806version = "0.13.0"
807source = "registry+https://github.com/rust-lang/crates.io-index"
808checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
809dependencies = [
810 "ff",
811 "rand_core 0.6.4",
812 "subtle",
813]
814
715815[[package]]
716816name = "half"
717817version = "2.7.1"
......@@ -758,6 +858,15 @@ version = "0.4.3"
758858source = "registry+https://github.com/rust-lang/crates.io-index"
759859checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
760860
861[[package]]
862name = "hmac"
863version = "0.12.1"
864source = "registry+https://github.com/rust-lang/crates.io-index"
865checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
866dependencies = [
867 "digest",
868]
869
761870[[package]]
762871name = "home-dashboard"
763872version = "0.1.0"
......@@ -771,6 +880,7 @@ dependencies = [
771880 "globset",
772881 "lol_html",
773882 "openssl",
883 "percent-encoding",
774884 "rand 0.9.5",
775885 "regex",
776886 "reqwest",
......@@ -782,6 +892,7 @@ dependencies = [
782892 "serde_json",
783893 "sha1",
784894 "sha2",
895 "ssh-key",
785896 "subtle",
786897 "tokio",
787898 "tokio-stream",
......@@ -1054,6 +1165,15 @@ dependencies = [
10541165 "serde_core",
10551166]
10561167
1168[[package]]
1169name = "inout"
1170version = "0.1.4"
1171source = "registry+https://github.com/rust-lang/crates.io-index"
1172checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
1173dependencies = [
1174 "generic-array",
1175]
1176
10571177[[package]]
10581178name = "ipnet"
10591179version = "2.12.2"
......@@ -1082,6 +1202,9 @@ name = "lazy_static"
10821202version = "1.5.1"
10831203source = "registry+https://github.com/rust-lang/crates.io-index"
10841204checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb"
1205dependencies = [
1206 "spin",
1207]
10851208
10861209[[package]]
10871210name = "libc"
......@@ -1089,6 +1212,12 @@ version = "0.2.189"
10891212source = "registry+https://github.com/rust-lang/crates.io-index"
10901213checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
10911214
1215[[package]]
1216name = "libm"
1217version = "0.2.16"
1218source = "registry+https://github.com/rust-lang/crates.io-index"
1219checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
1220
10921221[[package]]
10931222name = "libsqlite3-sys"
10941223version = "0.35.0"
......@@ -1251,6 +1380,22 @@ dependencies = [
12511380 "num-traits",
12521381]
12531382
1383[[package]]
1384name = "num-bigint-dig"
1385version = "0.8.6"
1386source = "registry+https://github.com/rust-lang/crates.io-index"
1387checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7"
1388dependencies = [
1389 "lazy_static",
1390 "libm",
1391 "num-integer",
1392 "num-iter",
1393 "num-traits",
1394 "rand 0.8.8",
1395 "smallvec",
1396 "zeroize",
1397]
1398
12541399[[package]]
12551400name = "num-conv"
12561401version = "0.2.2"
......@@ -1266,6 +1411,16 @@ dependencies = [
12661411 "num-traits",
12671412]
12681413
1414[[package]]
1415name = "num-iter"
1416version = "0.1.46"
1417source = "registry+https://github.com/rust-lang/crates.io-index"
1418checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b"
1419dependencies = [
1420 "num-integer",
1421 "num-traits",
1422]
1423
12691424[[package]]
12701425name = "num-traits"
12711426version = "0.2.19"
......@@ -1273,6 +1428,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
12731428checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
12741429dependencies = [
12751430 "autocfg",
1431 "libm",
12761432]
12771433
12781434[[package]]
......@@ -1327,6 +1483,44 @@ dependencies = [
13271483 "vcpkg",
13281484]
13291485
1486[[package]]
1487name = "p256"
1488version = "0.13.2"
1489source = "registry+https://github.com/rust-lang/crates.io-index"
1490checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b"
1491dependencies = [
1492 "ecdsa",
1493 "elliptic-curve",
1494 "primeorder",
1495 "sha2",
1496]
1497
1498[[package]]
1499name = "p384"
1500version = "0.13.1"
1501source = "registry+https://github.com/rust-lang/crates.io-index"
1502checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6"
1503dependencies = [
1504 "ecdsa",
1505 "elliptic-curve",
1506 "primeorder",
1507 "sha2",
1508]
1509
1510[[package]]
1511name = "p521"
1512version = "0.13.3"
1513source = "registry+https://github.com/rust-lang/crates.io-index"
1514checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2"
1515dependencies = [
1516 "base16ct",
1517 "ecdsa",
1518 "elliptic-curve",
1519 "primeorder",
1520 "rand_core 0.6.4",
1521 "sha2",
1522]
1523
13301524[[package]]
13311525name = "parking_lot"
13321526version = "0.12.5"
......@@ -1373,6 +1567,15 @@ version = "0.2.3"
13731567source = "registry+https://github.com/rust-lang/crates.io-index"
13741568checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
13751569
1570[[package]]
1571name = "pem-rfc7468"
1572version = "0.7.0"
1573source = "registry+https://github.com/rust-lang/crates.io-index"
1574checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412"
1575dependencies = [
1576 "base64ct",
1577]
1578
13761579[[package]]
13771580name = "percent-encoding"
13781581version = "2.3.2"
......@@ -1438,6 +1641,27 @@ version = "0.2.17"
14381641source = "registry+https://github.com/rust-lang/crates.io-index"
14391642checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
14401643
1644[[package]]
1645name = "pkcs1"
1646version = "0.7.5"
1647source = "registry+https://github.com/rust-lang/crates.io-index"
1648checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f"
1649dependencies = [
1650 "der",
1651 "pkcs8",
1652 "spki",
1653]
1654
1655[[package]]
1656name = "pkcs8"
1657version = "0.10.2"
1658source = "registry+https://github.com/rust-lang/crates.io-index"
1659checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
1660dependencies = [
1661 "der",
1662 "spki",
1663]
1664
14411665[[package]]
14421666name = "pkg-config"
14431667version = "0.3.34"
......@@ -1474,6 +1698,15 @@ version = "0.1.1"
14741698source = "registry+https://github.com/rust-lang/crates.io-index"
14751699checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
14761700
1701[[package]]
1702name = "primeorder"
1703version = "0.13.6"
1704source = "registry+https://github.com/rust-lang/crates.io-index"
1705checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6"
1706dependencies = [
1707 "elliptic-curve",
1708]
1709
14771710[[package]]
14781711name = "proc-macro2"
14791712version = "1.0.107"
......@@ -1560,13 +1793,23 @@ version = "6.0.0"
15601793source = "registry+https://github.com/rust-lang/crates.io-index"
15611794checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
15621795
1796[[package]]
1797name = "rand"
1798version = "0.8.8"
1799source = "registry+https://github.com/rust-lang/crates.io-index"
1800checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"
1801dependencies = [
1802 "rand_chacha 0.3.1",
1803 "rand_core 0.6.4",
1804]
1805
15631806[[package]]
15641807name = "rand"
15651808version = "0.9.5"
15661809source = "registry+https://github.com/rust-lang/crates.io-index"
15671810checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
15681811dependencies = [
1569 "rand_chacha",
1812 "rand_chacha 0.9.0",
15701813 "rand_core 0.9.5",
15711814]
15721815
......@@ -1581,6 +1824,16 @@ dependencies = [
15811824 "rand_core 0.10.1",
15821825]
15831826
1827[[package]]
1828name = "rand_chacha"
1829version = "0.3.1"
1830source = "registry+https://github.com/rust-lang/crates.io-index"
1831checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
1832dependencies = [
1833 "ppv-lite86",
1834 "rand_core 0.6.4",
1835]
1836
15841837[[package]]
15851838name = "rand_chacha"
15861839version = "0.9.0"
......@@ -1596,6 +1849,9 @@ name = "rand_core"
15961849version = "0.6.4"
15971850source = "registry+https://github.com/rust-lang/crates.io-index"
15981851checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
1852dependencies = [
1853 "getrandom 0.2.17",
1854]
15991855
16001856[[package]]
16011857name = "rand_core"
......@@ -1721,6 +1977,16 @@ dependencies = [
17211977 "webpki-roots",
17221978]
17231979
1980[[package]]
1981name = "rfc6979"
1982version = "0.4.0"
1983source = "registry+https://github.com/rust-lang/crates.io-index"
1984checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2"
1985dependencies = [
1986 "hmac",
1987 "subtle",
1988]
1989
17241990[[package]]
17251991name = "ring"
17261992version = "0.17.14"
......@@ -1766,6 +2032,27 @@ dependencies = [
17662032 "uuid",
17672033]
17682034
2035[[package]]
2036name = "rsa"
2037version = "0.9.10"
2038source = "registry+https://github.com/rust-lang/crates.io-index"
2039checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d"
2040dependencies = [
2041 "const-oid",
2042 "digest",
2043 "num-bigint-dig",
2044 "num-integer",
2045 "num-traits",
2046 "pkcs1",
2047 "pkcs8",
2048 "rand_core 0.6.4",
2049 "sha2",
2050 "signature",
2051 "spki",
2052 "subtle",
2053 "zeroize",
2054]
2055
17692056[[package]]
17702057name = "rusqlite"
17712058version = "0.37.0"
......@@ -1906,6 +2193,20 @@ dependencies = [
19062193 "tendril",
19072194]
19082195
2196[[package]]
2197name = "sec1"
2198version = "0.7.3"
2199source = "registry+https://github.com/rust-lang/crates.io-index"
2200checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
2201dependencies = [
2202 "base16ct",
2203 "der",
2204 "generic-array",
2205 "pkcs8",
2206 "subtle",
2207 "zeroize",
2208]
2209
19092210[[package]]
19102211name = "selectors"
19112212version = "0.37.0"
......@@ -2084,6 +2385,16 @@ dependencies = [
20842385 "libc",
20852386]
20862387
2388[[package]]
2389name = "signature"
2390version = "2.2.0"
2391source = "registry+https://github.com/rust-lang/crates.io-index"
2392checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
2393dependencies = [
2394 "digest",
2395 "rand_core 0.6.4",
2396]
2397
20872398[[package]]
20882399name = "simdutf8"
20892400version = "0.1.5"
......@@ -2124,6 +2435,16 @@ version = "0.9.9"
21242435source = "registry+https://github.com/rust-lang/crates.io-index"
21252436checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e"
21262437
2438[[package]]
2439name = "spki"
2440version = "0.7.3"
2441source = "registry+https://github.com/rust-lang/crates.io-index"
2442checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
2443dependencies = [
2444 "base64ct",
2445 "der",
2446]
2447
21272448[[package]]
21282449name = "sse-stream"
21292450version = "0.2.6"
......@@ -2137,6 +2458,46 @@ dependencies = [
21372458 "pin-project-lite",
21382459]
21392460
2461[[package]]
2462name = "ssh-cipher"
2463version = "0.2.0"
2464source = "registry+https://github.com/rust-lang/crates.io-index"
2465checksum = "caac132742f0d33c3af65bfcde7f6aa8f62f0e991d80db99149eb9d44708784f"
2466dependencies = [
2467 "cipher",
2468 "ssh-encoding",
2469]
2470
2471[[package]]
2472name = "ssh-encoding"
2473version = "0.2.0"
2474source = "registry+https://github.com/rust-lang/crates.io-index"
2475checksum = "eb9242b9ef4108a78e8cd1a2c98e193ef372437f8c22be363075233321dd4a15"
2476dependencies = [
2477 "base64ct",
2478 "pem-rfc7468",
2479 "sha2",
2480]
2481
2482[[package]]
2483name = "ssh-key"
2484version = "0.6.7"
2485source = "registry+https://github.com/rust-lang/crates.io-index"
2486checksum = "3b86f5297f0f04d08cabaa0f6bff7cb6aec4d9c3b49d87990d63da9d9156a8c3"
2487dependencies = [
2488 "p256",
2489 "p384",
2490 "p521",
2491 "rsa",
2492 "sec1",
2493 "sha2",
2494 "signature",
2495 "ssh-cipher",
2496 "ssh-encoding",
2497 "subtle",
2498 "zeroize",
2499]
2500
21402501[[package]]
21412502name = "stable_deref_trait"
21422503version = "1.2.1"
......@@ -2750,7 +3111,7 @@ dependencies = [
27503111 "openssl",
27513112 "openssl-sys",
27523113 "rand 0.9.5",
2753 "rand_chacha",
3114 "rand_chacha 0.9.0",
27543115 "serde",
27553116 "serde_cbor_2",
27563117 "serde_json",
dashboard/Cargo.toml+2
......@@ -12,6 +12,7 @@ chrono = "0.4"
1212futures = "0.3"
1313globset = "0.4"
1414openssl = "0.10"
15percent-encoding = "2"
1516rand = "0.9"
1617lol_html = "3"
1718regex = "1"
......@@ -24,6 +25,7 @@ scraper = { version = "0.27", default-features = false }
2425serde_json = "1"
2526sha1 = "0.10"
2627sha2 = "0.10.9"
28ssh-key = { version = "0.6", default-features = false, features = ["std", "ecdsa"] }
2729subtle = "2.6"
2830tokio = { version = "1", features = ["full"] }
2931tokio-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<()> {
115115 ] {
116116 profile.as_object_mut().unwrap().remove(key);
117117 }
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 }
118122 db.execute(
119123 "UPDATE users SET profile=? WHERE id=?",
120124 sql![profile.to_string(), id],
......@@ -188,6 +192,7 @@ impl Store {
188192 CREATE TABLE IF NOT EXISTS pending (hash TEXT PRIMARY KEY,kind TEXT NOT NULL,data TEXT NOT NULL,expires INTEGER NOT NULL);
189193 CREATE TABLE IF NOT EXISTS migration (digest TEXT PRIMARY KEY);
190194 CREATE TABLE IF NOT EXISTS attempts (key TEXT PRIMARY KEY,count INTEGER NOT NULL,expires INTEGER NOT NULL);")?;
195 identity::initialise(&db)?;
191196 oidc::initialise(&db)?;
192197 guest::initialise(&db)?;
193198 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 @@
11use crate::*;
22use tokio::io::{AsyncReadExt, AsyncWriteExt};
33
4#[derive(serde::Deserialize)]
5struct Limits { request: usize, response: usize }
6static LIMITS: std::sync::LazyLock<Limits> = std::sync::LazyLock::new(|| serde_json::from_str(include_str!("host-protocol.json")).expect("host protocol limits"));
7
48static SLOTS: tokio::sync::Semaphore = tokio::sync::Semaphore::const_new(4);
59
610pub async fn sample(app: Arc<App>) -> Result<Arc<Document>> {
......@@ -48,7 +52,7 @@ pub async fn open(request: Value) -> Result<(Value, tokio::net::UnixStream)> {
4852 }
4953 let mut message = serde_json::to_vec(&request)?;
5054 message.push(b'\n');
51 if message.len() > 65536 {
55 if message.len() > LIMITS.request {
5256 return Err(Error::new(
5357 400,
5458 "The host request is too large. Narrow the selection.",
......@@ -64,7 +68,7 @@ pub async fn open(request: Value) -> Result<(Value, tokio::net::UnixStream)> {
6468
6569pub async fn response(socket: &mut tokio::net::UnixStream) -> Result<Value> {
6670 let length = socket.read_u32().await? as usize;
67 if length > 16 * 1024 * 1024 {
71 if length > LIMITS.response {
6872 return Err(Error::new(
6973 502,
7074 "The host response is too large. Narrow the selection.",
dashboard/src/identity.rs created+133
......@@ -0,0 +1,133 @@
1use crate::*;
2use rusqlite::{Connection, OptionalExtension, params as sql};
3use ssh_key::{HashAlg, PublicKey};
4
5pub fn initialise(db: &Connection) -> Result<()> {
6 db.execute_batch("CREATE TABLE IF NOT EXISTS commit_emails (email TEXT NOT NULL,user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,verified INTEGER NOT NULL DEFAULT 0,PRIMARY KEY(user_id,email));
7 CREATE UNIQUE INDEX IF NOT EXISTS confirmed_commit_email ON commit_emails(email) WHERE verified=1;
8 CREATE TABLE IF NOT EXISTS signing_keys (fingerprint TEXT NOT NULL,user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,label TEXT NOT NULL,public_key TEXT NOT NULL,created INTEGER NOT NULL,verified INTEGER NOT NULL DEFAULT 0,PRIMARY KEY(user_id,fingerprint));
9 CREATE UNIQUE INDEX IF NOT EXISTS confirmed_signing_key ON signing_keys(fingerprint) WHERE verified=1;")?;
10 Ok(())
11}
12
13pub fn email(value: &Value) -> Result<String> {
14 let email = string(value).trim().to_lowercase();
15 if email.len() > 254 || email.is_empty() || email.chars().any(char::is_whitespace)
16 || email.chars().any(char::is_control) || email.split('@').count() != 2
17 || email.starts_with('@') || email.ends_with('@') {
18 return Err(Error::new(400, "Enter a full email address."));
19 }
20 Ok(email)
21}
22
23pub fn add_email(db: &Connection, id: &str, value: &Value, verified: bool) -> Result<()> {
24 let email = email(value)?;
25 let primary: Option<String> = db.query_row("SELECT id FROM users WHERE lower(json_extract(profile,'$.email'))=? AND json_extract(profile,'$.emailVerified')=1", [&email], |r| r.get(0)).optional()?;
26 let owner: Option<String> = db.query_row("SELECT user_id FROM commit_emails WHERE email=? AND verified=1", [&email], |r| r.get(0)).optional()?;
27 let exists: bool = db.query_row("SELECT EXISTS(SELECT 1 FROM commit_emails WHERE email=? AND user_id=?)", sql![email,id], |r| r.get(0))?;
28 if primary.as_deref().is_some_and(|owner| owner != id) || owner.as_deref().is_some_and(|owner| owner != id) {
29 return Err(Error::new(409, "This email belongs to another account. Ask an admin to check its owner."));
30 }
31 let count: i64 = db.query_row("SELECT count(*) FROM commit_emails WHERE user_id=?", [id], |r| r.get(0))?;
32 if !exists && count >= 64 { return Err(Error::new(400, "Remove an email before adding another.")); }
33 db.execute("INSERT INTO commit_emails VALUES (?,?,?) ON CONFLICT(user_id,email) DO UPDATE SET verified=max(verified,excluded.verified)", sql![email,id,verified])?;
34 Ok(())
35}
36
37pub fn add_key(db: &Connection, id: &str, body: &Value, verified: bool) -> Result<()> {
38 let source = string(&body["publicKey"]).trim();
39 let label = string(&body["label"]).trim();
40 if source.len() > 16384 || source.contains(['\n','\r']) || label.len() > 128 || label.chars().any(char::is_control) {
41 return Err(Error::new(400, "Paste one SSH public key and use a label under 128 characters."));
42 }
43 let mut key = PublicKey::from_openssh(source).map_err(|_| Error::new(400, "Paste an SSH public key, such as an ssh-ed25519 key."))?;
44 if key.algorithm().as_str() == "ssh-dss" { return Err(Error::new(400, "DSA keys are obsolete. Use an Ed25519, RSA, or ECDSA public key.")); }
45 let fingerprint = key.fingerprint(HashAlg::Sha256).to_string();
46 let label = if label.is_empty() { key.comment() } else { label }.to_owned();
47 if label.len() > 128 || label.chars().any(char::is_control) { return Err(Error::new(400, "Use a key label under 128 characters.")); }
48 key.set_comment("");
49 let public_key = key.to_openssh().map_err(|_| Error::new(400, "Paste an SSH public key."))?;
50 let owner: Option<String> = db.query_row("SELECT user_id FROM signing_keys WHERE fingerprint=? AND verified=1", [&fingerprint], |r| r.get(0)).optional()?;
51 if owner.as_deref().is_some_and(|owner| owner != id) { return Err(Error::new(409, "This key belongs to another account. Ask an admin to check its owner.")); }
52 let count: i64 = db.query_row("SELECT count(*) FROM signing_keys WHERE user_id=?", [id], |r| r.get(0))?;
53 let exists: bool = db.query_row("SELECT EXISTS(SELECT 1 FROM signing_keys WHERE user_id=? AND fingerprint=?)",sql![id,fingerprint],|r|r.get(0))?;
54 if !exists && count >= 64 { return Err(Error::new(400, "Remove a signing key before adding another.")); }
55 db.execute("INSERT INTO signing_keys VALUES (?,?,?,?,?,?) ON CONFLICT(user_id,fingerprint) DO UPDATE SET verified=max(verified,excluded.verified)", sql![fingerprint,id,label,public_key,(now()*1000.0) as i64,verified])?;
56 Ok(())
57}
58
59pub fn confirm_key(db: &Connection, id: &str, fingerprint: &str) -> Result<()> {
60 let body: Option<Value> = db.query_row("SELECT public_key,label FROM signing_keys WHERE user_id=? AND fingerprint=?",sql![id,fingerprint],|r| Ok(json!({"publicKey":r.get::<_,String>(0)?,"label":r.get::<_,String>(1)?}))).optional()?;
61 add_key(db,id,&body.ok_or_else(|| Error::new(404,"Add this signing key before confirming ownership."))?,true)
62}
63
64pub fn account(db: &Connection, id: &str) -> Result<Value> {
65 let mut emails = db.prepare("SELECT email,verified FROM commit_emails WHERE user_id=? ORDER BY email")?;
66 let emails = emails.query_map([id], |r| Ok(json!({"email":r.get::<_,String>(0)?,"verified":r.get::<_,bool>(1)?})))?.collect::<std::result::Result<Vec<_>,_>>()?;
67 let mut keys = db.prepare("SELECT fingerprint,label,public_key,created,verified FROM signing_keys WHERE user_id=? ORDER BY created,fingerprint")?;
68 let keys = keys.query_map([id], |r| Ok(json!({"fingerprint":r.get::<_,String>(0)?,"label":r.get::<_,String>(1)?,"publicKey":r.get::<_,String>(2)?,"createdDate":r.get::<_,i64>(3)?,"verified":r.get::<_,bool>(4)?})))?.collect::<std::result::Result<Vec<_>,_>>()?;
69 Ok(json!({"emails":emails,"signingKeys":keys}))
70}
71
72pub fn import(auth: &auth::Store, input: &Value) -> Result<Value> {
73 let mut db = auth.db.lock().unwrap();
74 let tx = db.transaction()?;
75 let id: String = tx.query_row("SELECT id FROM users WHERE username=? AND json_extract(profile,'$.enabled')=1", [string(&input["username"])], |r| r.get(0)).optional()?.ok_or_else(|| Error::new(404,"Choose an enabled account."))?;
76 if !input["emails"].is_array() || !input["signingKeys"].is_array() { return Err(Error::new(400,"Supply email and signing key lists.")); }
77 for email in array(&input["emails"]) { add_email(&tx,&id,email,true)?; }
78 for key in array(&input["signingKeys"]) { add_key(&tx,&id,key,true)?; }
79 let result = account(&tx,&id)?;
80 tx.commit()?;
81 Ok(result)
82}
83
84#[cfg(test)]
85mod tests {
86 use super::*;
87 fn store() -> Connection {
88 let db = Connection::open_in_memory().unwrap();
89 db.execute_batch("PRAGMA foreign_keys=ON; CREATE TABLE users(id TEXT PRIMARY KEY,profile TEXT); INSERT INTO users VALUES('one','{\"email\":\"primary@example.test\",\"emailVerified\":true}'),('two','{}');").unwrap();
90 initialise(&db).unwrap();
91 db
92 }
93 #[test]
94 fn unconfirmed_key_claim_cannot_block_its_owner() {
95 let db = store();
96 let body = json!({"publicKey":"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMpxNpkRLTUijhd3HSaOvKYn2MWXEY+YEFdsPNZhBROn"});
97 add_key(&db,"two",&body,false).unwrap();
98 assert_eq!(account(&db,"two").unwrap()["signingKeys"][0]["verified"],false);
99 add_key(&db,"one",&body,true).unwrap();
100 let fingerprint = account(&db,"two").unwrap()["signingKeys"][0]["fingerprint"].as_str().unwrap().to_owned();
101 assert!(confirm_key(&db,"two",&fingerprint).is_err());
102 assert_eq!(account(&db,"two").unwrap()["signingKeys"][0]["verified"],false);
103 }
104
105 #[test]
106 fn confirmation_cannot_steal_an_alias_or_verified_primary() {
107 let db = store();
108 add_email(&db,"one",&json!(" Alias@Example.test "),false).unwrap();
109 add_email(&db,"two",&json!("alias@example.test"),false).unwrap();
110 add_email(&db,"one",&json!("alias@example.test"),true).unwrap();
111 assert!(add_email(&db,"two",&json!("alias@example.test"),true).is_err());
112 assert!(add_email(&db,"two",&json!("primary@example.test"),false).is_err());
113 assert_eq!(account(&db,"one").unwrap()["emails"][0],json!({"email":"alias@example.test","verified":true}));
114 for value in ["@host", "name@", "a@b@c", "a b@c", "a\nb@c"] { assert!(email(&json!(value)).is_err()); }
115 db.execute("DELETE FROM users WHERE id='one'",[]).unwrap();
116 add_email(&db,"two",&json!("alias@example.test"),true).unwrap();
117 }
118 #[test]
119 fn signing_keys_deduplicate_material_and_cannot_change_owner() {
120 let db = store();
121 let key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMpxNpkRLTUijhd3HSaOvKYn2MWXEY+YEFdsPNZhBROn";
122 add_key(&db,"one",&json!({"publicKey":format!("{key} device"),"label":"first"}),true).unwrap();
123 add_key(&db,"one",&json!({"publicKey":format!("{key} different comment"),"label":"second"}),true).unwrap();
124 assert!(add_key(&db,"two",&json!({"publicKey":key}),true).is_err());
125 let keys = account(&db,"one").unwrap()["signingKeys"].clone();
126 assert_eq!(keys.as_array().unwrap().len(),1);
127 assert_eq!(keys[0]["label"],"first");
128 assert_eq!(keys[0]["publicKey"],key);
129 for key in ["garbage", "ssh-ed25519 AAAA", "-----BEGIN OPENSSH PRIVATE KEY-----", "command=\"sh\" ssh-ed25519 AAAA"] {
130 assert!(add_key(&db,"one",&json!({"publicKey":key}),true).is_err());
131 }
132 }
133}
dashboard/src/main.rs+9-1
......@@ -1,6 +1,7 @@
11mod ai;
22mod apps;
33mod auth;
4mod identity;
45mod cache;
56mod core;
67mod deploys;
......@@ -451,6 +452,12 @@ async fn main() -> std::result::Result<(), Box<dyn std::error::Error>> {
451452 )
452453 .map_err(|error| std::io::Error::other(error.message))?;
453454 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 }
454461 if path == "--guest-provider" {
455462 let input = serde_json::from_reader(std::io::stdin())?;
456463 let output = guest::provision(&auth, input)
......@@ -578,7 +585,8 @@ async fn main() -> std::result::Result<(), Box<dyn std::error::Error>> {
578585 return next.run(request).await;
579586 }
580587 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/")
582590 || path.starts_with("/fonts/")
583591 || path == "/snowflake.svg";
584592 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::*;
22use rmcp::{
33 ErrorData, RoleServer, ServerHandler,
44 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,
78 },
89 service::RequestContext,
910};
1011use scraper::{Html, Selector};
1112
12/// r1758 rotates CSRF tokens per deletion form; the final token also works on r1763.
13fn issue_csrf(document: &Html) -> Result<Option<String>> {
14 let forms = Selector::parse("ul.timeline li.comment form[method=post]").unwrap();
15 let kind = Selector::parse("input[name=t]").unwrap();
16 let id = Selector::parse("input[name=id]").unwrap();
17 let token =
18 Selector::parse("input[type=hidden][name=csrf_token], input[hidden][name=csrf_token]")
19 .unwrap();
20 let last = document
21 .select(&forms)
22 .filter(|form| {
23 form.select(&kind)
24 .any(|input| input.attr("value") == Some("delete"))
25 && form.select(&id).any(|input| {
26 input
27 .attr("value")
28 .is_some_and(|value| value.parse::<u64>().is_ok_and(|id| id > 0))
29 })
30 })
31 .last();
32 let Some(form) = last else { return Ok(None) };
33 let tokens: Vec<_> = form.select(&token).collect();
34 match tokens.as_slice() {
35 [input] => input
36 .attr("value")
37 .filter(|value| !value.is_empty())
38 .map(|value| Some(value.to_owned()))
39 .ok_or_else(|| {
40 Error::new(
41 502,
42 "Shale's issue form changed. Open the issue to edit it.",
43 )
44 }),
45 _ => Err(Error::new(
46 502,
47 "Shale's issue form changed. Open the issue to edit it.",
48 )),
49 }
50}
13const MODEL_PATTERN: &str = r"^[a-z0-9][a-z0-9._-]{0,127}$";
5114
52/// The verified r1616 build predates tokenized forms. Its cookie mutations are
53/// protected by the service's exact-Origin gate; never infer this from a missing token alone.
5415fn tokenless_r1616(document: &Html) -> bool {
5516 if document
5617 .select(&Selector::parse("input[name=csrf_token]").unwrap())
......@@ -63,24 +24,6 @@ fn tokenless_r1616(document: &Html) -> bool {
6324 matches!(links.as_slice(), [link] if text(*link) == "shale r1616-ga87d2f5.zig.0.16.0")
6425}
6526
66fn prepare_issue_csrf(document: &Html, fields: &mut HashMap<String, String>) -> Result<()> {
67 if tokenless_r1616(document) && !fields.contains_key("csrf_token") {
68 return Ok(());
69 }
70 if fields
71 .get("csrf_token")
72 .is_none_or(|token| token.is_empty())
73 {
74 return Err(Error::new(
75 502,
76 "Shale's issue form changed. Open the issue to edit it.",
77 ));
78 }
79 if let Some(token) = issue_csrf(document)? {
80 fields.insert("csrf_token".to_owned(), token);
81 }
82 Ok(())
83}
8427
8528pub struct Backend {
8629 pub(crate) origin: url::Url,
......@@ -373,7 +316,7 @@ fn issue(html: &str, repository: &str, id: Option<u64>) -> Result<Value> {
373316 )
374317 })?;
375318 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())
377320 .next()
378321 .map(text);
379322 let time = comment
......@@ -392,6 +335,34 @@ fn issue(html: &str, repository: &str, id: Option<u64>) -> Result<Value> {
392335 json!({"repository":repository,"id":issue_id,"title":title.unwrap(),"status":status,"labels":labels,"comments":comments}),
393336 )
394337}
338fn labels(html: &str, repository: &str) -> Result<Value> {
339 let document = document(html, "page-labels", Some(repository))?;
340 let mut labels = Vec::new();
341 for row in document.select(&Selector::parse(".grid-container table tbody tr").unwrap()) {
342 let cells: Vec<_> = row.select(&Selector::parse("td").unwrap()).collect();
343 let label = row
344 .select(&Selector::parse("a.issuelabel[data-label-id][data-color]").unwrap())
345 .next();
346 if cells.len() != 3 || label.is_none() {
347 return Err(Error::new(
348 502,
349 "Shale's label list changed. Open its labels page.",
350 ));
351 }
352 let label = label.unwrap();
353 let id = label
354 .attr("data-label-id")
355 .and_then(|s| s.parse::<u64>().ok())
356 .filter(|id| *id > 0)
357 .ok_or_else(|| Error::new(502, "Shale's label IDs changed. Open its labels page."))?;
358 let open = text(cells[2])
359 .parse::<u64>()
360 .map_err(|_| Error::new(502, "Shale's label counts changed. Open its labels page."))?;
361 labels.push(json!({"id":id,"name":text(label),"description":text(cells[1]),"color":label.attr("data-color"),"openIssues":open}));
362 }
363 Ok(json!({"repository":repository,"labels":labels}))
364}
365
395366fn current(app: &App, grant: &Value, credential: &str) -> Result<()> {
396367 let db = app.mcp.db.lock().unwrap();
397368 if mcp::get(&db, &format!("grant:{}", string(&grant["id"])))? != *grant
......@@ -411,6 +382,7 @@ struct Shale(Arc<App>);
411382impl ServerHandler for Shale {
412383 fn get_info(&self) -> ServerConfig {
413384 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.")
414386 }
415387 async fn list_tools(
416388 &self,
......@@ -420,17 +392,30 @@ impl ServerHandler for Shale {
420392 let tools = [
421393 ("list_repositories", "List repositories granted to this connection.", json!({}), json!([]), true),
422394 ("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),
423400 ("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),
425402 ("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),
426403 ("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),
427404 ("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),
428405 ].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 }
429412 Tool::new(name, description, json!({"type":"object","properties":properties,"required":required,"additionalProperties":false}).as_object().unwrap().clone())
430413 .with_annotations(ToolAnnotations::new().read_only(read).idempotent(read))
431414 }).collect();
432415 Ok(ListToolsResult {
433416 tools,
417 ttl_ms: Some(0),
418 cache_scope: Some(CacheScope::Private),
434419 ..Default::default()
435420 })
436421 }
......@@ -448,17 +433,28 @@ impl ServerHandler for Shale {
448433 let arguments = request.arguments.unwrap_or_default();
449434 let allowed: &[&str] = match name {
450435 "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"],
452440 "set_issue_status" => &["repository", "id", "status"], "set_issue_title" => &["repository", "id", "title"],
453441 _ => return Err(Error::new(404, "No tool with that name.")),
454442 };
455 if arguments.keys().any(|key| !allowed.contains(&key.as_str())) {
443 if arguments.keys().any(|key| key != "model" && !allowed.contains(&key.as_str())) {
456444 return Err(Error::new(400, "Use the fields listed for this tool."));
457445 }
458 let write = !matches!(name, "list_repositories" | "list_issues" | "get_issue");
446 let write = !matches!(name, "list_repositories" | "list_issues" | "list_labels" | "get_issue");
459447 if !array(&grant["scopes"]).iter().any(|s| s == if write {"shale:write"} else {"shale:read"}) {
460448 return Err(Error::new(403, "This connection allows reads only. Connect again to request issue editing."));
461449 }
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 };
462458 let credential = session(app, string(&grant["user"]))?;
463459 current(app, grant, &credential)?;
464460 if name == "list_repositories" {
......@@ -470,6 +466,20 @@ impl ServerHandler for Shale {
470466 let repository = arguments.get("repository").and_then(Value::as_str)
471467 .filter(|r| grant["resources"] == "all" || array(&grant["resources"]).iter().any(|id| id == *r))
472468 .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 }
473483 let mut target = repository_path(&app.shale.origin, repository, &["issues", ""])?;
474484 let issue_id = if matches!(name, "list_issues" | "create_issue") { None } else {
475485 Some(arguments.get("id").and_then(Value::as_u64).filter(|n| *n > 0)
......@@ -483,7 +493,7 @@ impl ServerHandler for Shale {
483493 }
484494 let mut fields = HashMap::new();
485495 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") {
487497 let value = arguments.get(key).and_then(Value::as_str)
488498 .filter(|v| v.len() <= max && (key == "description" || !v.trim().is_empty()))
489499 .or_else(|| (key == "description" && !arguments.contains_key(key)).then_some(""))
......@@ -491,6 +501,18 @@ impl ServerHandler for Shale {
491501 fields.insert(key.to_owned(), value.to_owned());
492502 }
493503 }
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 }
494516 username(&app.shale.page(&app.shale.origin.join("/-/settings")?, &credential).await?)?;
495517 let body = app.shale.page(&target, &credential).await?;
496518 if name == "list_issues" {
......@@ -517,66 +539,27 @@ impl ServerHandler for Shale {
517539 current(app, grant, &credential)?;
518540 return Ok(json!({"issue":issue}));
519541 }
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.")),
528549 };
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());
558555 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}))
580563 }.await;
581564 Ok(match result {
582565 Ok(value) => CallToolResult::structured(value),
......@@ -881,80 +864,18 @@ pub async fn oauth(app: Arc<App>, request: Request) -> Response {
881864mod tests {
882865 use super::*;
883866 #[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();
897870 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})
900873 );
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());
954875 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"
958879 )
959880 .is_err()
960881 );
......@@ -1050,7 +971,9 @@ mod tests {
1050971 #[test]
1051972 fn issue_comment_authors_include_external_guest_profiles() {
1052973 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)");
1054977 assert_eq!(parsed["comments"][0]["author"], "paperclover");
1055978 assert_eq!(parsed["comments"][1]["author"], "Astheno user");
1056979 }
dashboard/src/shale_page.rs+366-15
......@@ -1,4 +1,6 @@
11use crate::*;
2use rusqlite::OptionalExtension;
3use std::collections::HashSet;
24use axum::body::Body;
35use lol_html::{RewriteStrSettings, element, html_content::ContentType, text};
46
......@@ -7,6 +9,7 @@ struct Profile {
79 url: Option<String>,
810 icon: &'static str,
911 picture: Option<String>,
12 owner: Option<(String, Option<String>)>,
1013}
1114
1215fn profiles(auth: &auth::Store) -> Result<HashMap<String, Profile>> {
......@@ -41,6 +44,7 @@ fn profiles(auth: &auth::Store) -> Result<HashMap<String, Profile>> {
4144 name,
4245 url,
4346 icon,
47 owner: None,
4448 picture: if provider == "github" {
4549 Some(format!(
4650 "https://avatars.githubusercontent.com/u/{subject}?s=64"
......@@ -51,27 +55,204 @@ fn profiles(auth: &auth::Store) -> Result<HashMap<String, Profile>> {
5155 },
5256 );
5357 }
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 }
5472 Ok(profiles)
5573}
5674
75pub(crate) fn assisted_model(body: &str) -> Option<&str> {
76 let line = body.trim_end().lines().last()?;
77 let model = line.strip_prefix("Assisted-by: ")?;
78 if model.is_empty() || model.len() > 128 || !model.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_')) { return None; }
79 let before = &body[..body.trim_end().len() - line.len()];
80 let mut fence = None;
81 for line in before.lines() {
82 let line = line.trim_start();
83 if line.starts_with("```") || line.starts_with("~~~") {
84 let marker = line.as_bytes()[0];
85 let count = line.bytes().take_while(|&b| b == marker).count();
86 match fence {
87 None => fence = Some((marker, count)),
88 Some((open, length)) if marker == open && count >= length && line[count..].trim().is_empty() => fence = None,
89 _ => (),
90 }
91 }
92 }
93 fence.is_none().then_some(model)
94}
95
96fn ai_profile(model: Option<&str>) -> Profile {
97 let (name, icon) = match model {
98 None => ("ai".into(), include_str!("bot.svg")),
99 Some(id) => {
100 let normalize_version = id.starts_with("claude-") || id.starts_with("gpt-");
101 let (id, icon, gpt) = if let Some(rest) = id.strip_prefix("claude-") {
102 let rest = rest.rsplit_once('-').filter(|(_, tail)| tail.len() == 8 && tail.bytes().all(|b| b.is_ascii_digit())).map(|(rest, _)| rest).unwrap_or(rest);
103 (rest, include_str!("claude.svg"), false)
104 } else if let Some(rest) = id.strip_prefix("gpt-") {
105 (rest, include_str!("openai.svg"), true)
106 } else {
107 (id, include_str!("bot.svg"), false)
108 };
109 let mut words: Vec<String> = Vec::new();
110 for part in id.split('-').filter(|part| !part.is_empty()) {
111 let number = part.find(|c: char| c.is_ascii_digit()).unwrap_or(part.len());
112 let (word, version) = part.split_at(number);
113 if !word.is_empty() {
114 let mut letters = word.chars();
115 words.push(letters.next().unwrap().to_uppercase().collect::<String>() + letters.as_str());
116 }
117 if !version.is_empty() {
118 let numeric = |value: &str| value.bytes().all(|b| b.is_ascii_digit() || b == b'.');
119 if normalize_version && numeric(version) && words.last().is_some_and(|word| numeric(word)) {
120 let previous = words.last_mut().unwrap();
121 previous.push('.');
122 previous.push_str(version);
123 } else {
124 words.push(version.into());
125 }
126 }
127 }
128 let name = words.join(" ");
129 (if gpt { format!("GPT-{name}") } else { name }, icon)
130 }
131 };
132 Profile { name, url: None, icon, picture: None, owner: None }
133}
134
57135fn account_path(path: &str) -> Option<&str> {
58136 let name = path.strip_prefix("/~")?;
59137 let name = name.strip_suffix('/').unwrap_or(name);
60 (name.starts_with("guest-") && !name.contains('/')).then_some(name)
138 (!name.is_empty() && !name.contains('/')).then_some(name)
139}
140
141fn avatar(picture: &str) -> String {
142 let picture = picture.replace('&', "&amp;").replace('"', "&quot;").replace('<', "&lt;");
143 format!("<img src=\"{picture}\" alt=\"\" height=\"16\" referrerpolicy=\"no-referrer\">")
61144}
62145
63fn rewrite(html: &str, origin: &url::Url, profiles: &HashMap<String, Profile>) -> Result<String> {
146fn rewrite(html: &str, origin: &url::Url, profiles: &HashMap<String, Profile>, avatars: &HashMap<String, String>, open_count: Option<u64>) -> Result<String> {
64147 use std::{cell::Cell, rc::Rc};
148 let markdown = Rc::new(Cell::new(0));
149 let bodies = markdown.clone();
65150 let active = Rc::new(Cell::new(None::<bool>));
66151 let images = active.clone();
67152 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()));
68159 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 }))
69250 .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()));
75256 let Some(profile) = profile else { return Ok(()); };
76257 let closing = active.clone();
77258 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>) -
82263 } else {
83264 for attribute in ["href", "target", "rel", "tabindex"] { element.remove_attribute(attribute); }
84265 }
266 if profile.owner.is_some() || ai_profile.is_some() { element.set_tag_name("span")?; }
85267 let style = element.get_attribute("style").unwrap_or_default();
86268 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}"))?;
88271 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);
91273 }
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}\" "));
93277 let icon = if profile.url.is_some() { icon.replace("<path ", "<path style=\"fill:currentColor;stroke:none\" ") } else { icon };
94278 element.append(&icon, ContentType::Html);
95279 if profile.url.is_some() {
96280 element.append("<span style=\"text-decoration:underline;text-decoration-color:currentColor\">", ContentType::Html);
97281 }
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);
99287 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 }
100300 Ok(())
101301 }))
102302 .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> {
149349 .and_then(|v| v.to_str().ok())
150350 .unwrap_or_default();
151351 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();
153353 if service != "shale"
154354 && !(service
155355 .strip_prefix("shale-preview-")
......@@ -246,7 +446,37 @@ pub async fn proxy(app: Arc<App>, request: Request) -> Result<Response> {
246446 if prefix.len() <= 8 * 1024 * 1024
247447 && let Ok(text) = std::str::from_utf8(&prefix)
248448 {
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())?;
250480 for name in [
251481 "content-length",
252482 "etag",
......@@ -272,6 +502,125 @@ mod tests {
272502 use super::*;
273503 use scraper::{Html, Selector};
274504
505 #[test]
506 fn commit_identity_keeps_entities_and_cannot_escape_its_metadata_cell() {
507 let origin = url::Url::parse("https://shale.test/repo/commit/abc").unwrap();
508 let profile = || Profile { name: "~clover".into(), url: Some("/~clover".into()), icon:"",picture:Some("https://snow.test/picture".into()),owner:None };
509 let profiles = HashMap::from([("email:known@example.test".into(),profile()),("key:SHA256:known".into(),profile())]);
510 let html = "<body id='page-commit'><table id=m-metainfo><tr><td><img src=old alt='gravatar for known@example.test'></td></tr><tr><td><img src=unknown alt='gravatar for other@example.test'><span>John &amp; Jane &LT;other@example.test&GT;</span></td></tr><tr><td><span> Signed by SSH key SHA256:known</span></td></tr></table><div class=markdown><table id=m-metainfo><tr><td><img src=example alt='gravatar for known@example.test'><span> Signed by SSH key SHA256:known</span></td></tr></table></div></body>";
511 let output = rewrite(html,&origin,&profiles,&HashMap::new(),None).unwrap();
512 let document = Html::parse_document(&output);
513 assert!(!output.contains("&amp;amp;"));
514 assert!(output.contains("John &amp; Jane &LT;other@example.test&GT;"));
515 assert_eq!(document.select(&Selector::parse("a[href='/~clover']").unwrap()).count(),1);
516 assert!(output.contains("src=unknown"));
517 assert!(output.contains("src=example"));
518 assert_eq!(document.select(&Selector::parse(".markdown a").unwrap()).count(),0);
519 let html = "<body id=page-log><table><tbody><tr><td title='known@example.test'><img src=old><span>Author &amp; name</span></td></tr><tr><td title='other@example.test'><img src=unknown><span>Other</span></td></tr></tbody></table></body>";
520 let output = rewrite(html,&origin,&profiles,&HashMap::new(),None).unwrap();
521 assert!(output.contains("Author &amp; name"));
522 let document = Html::parse_document(&output);
523 assert_eq!(document.select(&Selector::parse("a[href='/~clover']").unwrap()).count(),1);
524 assert_eq!(document.select(&Selector::parse("img[src='https://snow.test/picture']").unwrap()).count(),1);
525 assert_eq!(document.select(&Selector::parse("img[src='https://snow.test/picture'][width='14'][height='14']").unwrap()).count(),1);
526 }
527
528 #[test]
529 fn navigation_uses_the_current_repository_count() {
530 let html = "<header><ul class='usa-nav__primary'><li><a href='/chat/issues/'>issues (9)</a></li></ul></header><div class='markdown'><a href='/chat/issues/'>issues (9)</a></div>";
531 let origin = url::Url::parse("https://shale.paperclover.net/chat/issues/25").unwrap();
532 let output = rewrite(html, &origin, &HashMap::new(), &HashMap::new(), Some(8)).unwrap();
533 assert!(output.contains(">issues (8)</a>"));
534 assert!(output.contains("<div class='markdown'><a href='/chat/issues/'>issues (9)</a></div>"));
535 }
536
537 #[test]
538 fn reconstructed_markdown_anchors_cannot_relabel_humans() {
539 let html = "<div class='markdown'><b><a href='https://example.com'>external</b>again</a></div><a href='/~ai'>~ai</a><a href='/~clo'>~clo</a>";
540 let origin = url::Url::parse("https://shale.paperclover.net/chat/issues/25").unwrap();
541 let output = rewrite(html, &origin, &HashMap::new(), &HashMap::new(), None).unwrap();
542 assert!(output.contains("<a href='/~clo'>~clo</a>"));
543 assert!(output.contains("</svg>ai"));
544 }
545
546 #[test]
547 fn terminal_trailers_ignore_examples_and_unclosed_fences() {
548 assert_eq!(assisted_model("Body\n\nAssisted-by: gpt-6-astra\n"), Some("gpt-6-astra"));
549 for body in ["Assisted-by: <model-id>", "> Assisted-by: claude-opus-5.5", " Assisted-by: gpt-6-astra", "```\nAssisted-by: gpt-6-astra", "~~~~\n```\nAssisted-by: gpt-6-astra", "Assisted-by: gpt-6-astra\nMore text", "Assisted-by: gpt-6-astra extra"] {
550 assert_eq!(assisted_model(body), None, "{body}");
551 }
552 assert_eq!(assisted_model("```\nexample\n```\n\nAssisted-by: claude-opus-5.5"), Some("claude-opus-5.5"));
553 }
554
555 #[test]
556 fn ai_identity_is_durable_and_never_reads_comment_trailers() {
557 let origin = url::Url::parse("https://shale.paperclover.net/chat/issues/25").unwrap();
558 let mut opus = ai_profile(Some("claude-opus-5.5"));
559 opus.owner = Some(("clover".into(), Some("https://example.com/picture?a=1&b=2".into())));
560 let mut sol = ai_profile(Some("gpt-6.1-sol"));
561 sol.owner = Some(("clover".into(), None));
562 let profiles = HashMap::from([("ai-clover-claude-opus-5.5".into(), opus), ("ai-clover-gpt-6.1-sol".into(), sol)]);
563 let html = "<a href='/~ai-clover-claude-opus-5.5'><img src='old'><span>ai account</span></a><a href='/~ai-clover-gpt-6.1-sol'>ai account</a><div class='markdown'><a href='/~ai-clover-claude-opus-5.5'>Mention</a><p>Assisted-by: unknown-model</p></div><a href='/~clover'>~clover</a>";
564 let output = rewrite(html, &origin, &profiles, &HashMap::new(), None).unwrap();
565 let document = Html::parse_document(&output);
566 assert!(output.contains("Opus 5.5</span> <small"));
567 assert!(output.contains(">(on behalf of <a class="));
568 assert!(output.contains("GPT-6.1 Sol</span> <small"));
569 assert!(!output.contains("src=\"old\""));
570 assert!(output.contains("<a href='/~clover'>~clover</a>"));
571 assert!(output.contains(">Mention</a>"));
572 assert_eq!(document.select(&Selector::parse("a[href='/~clover']").unwrap()).count(), 3);
573 assert!(document.select(&Selector::parse("svg").unwrap()).all(|e| e.attr("aria-hidden") == Some("true") && e.attr("focusable") == Some("false")));
574 assert_eq!(ai_profile(Some("gpt-6.1-sol")).name, "GPT-6.1 Sol");
575 assert_eq!(ai_profile(Some("claude-opus-5.5")).name, "Opus 5.5");
576 assert_eq!(ai_profile(Some("claude-haiku-5-5")).name, "Haiku 5.5");
577 assert_eq!(ai_profile(Some("claude-haiku-5.5")).name, "Haiku 5.5");
578 assert_eq!(ai_profile(Some("claude-sonnet-4-5-20250929")).name, "Sonnet 4.5");
579 for (id, name) in [
580 ("claude-new-variant-5-5", "New Variant 5.5"),
581 ("gpt-6-1-new-variant", "GPT-6.1 New Variant"),
582 ("qwen-3.8-27b", "Qwen 3.8 27b"),
583 ("qwen3.8-27b", "Qwen 3.8 27b"),
584 ("big-pickle-2", "Big Pickle 2"),
585 ("llama-3-70", "Llama 3 70"),
586 ] { assert_eq!(ai_profile(Some(id)).name, name, "{id}"); }
587 }
588
589 #[test]
590 fn compact_authors_keep_native_owner_links_and_avatars() {
591 let origin = url::Url::parse("https://shale.paperclover.net/chat/issues/").unwrap();
592 let mut profile = ai_profile(Some("gpt-6.1-sol"));
593 profile.owner = Some(("clover".into(), Some("https://snowglobe.test/picture".into())));
594 let profiles = HashMap::from([("ai-clover-gpt-6.1-sol".into(), profile)]);
595 let avatars = HashMap::from([("native-clover".into(), "https://snowglobe.test/picture".into())]);
596 let html = "<body id='page-issues'><table><tr><td>1</td><td>Task</td><td>Todo</td><td><a class='usa-link va-middle-childs' href='/~ai-clover-gpt-6.1-sol'>ai</a></td></tr></table><a class='usa-link va-middle-childs' href='/~clover'><img height='16' src='https://api.dicebear.com/10.x/line-face/svg?seed=native-clover'><span>~clover</span></a><div class='markdown'><img src='https://api.dicebear.com/10.x/line-face/svg?seed=native-clover'></div>";
597 let output = rewrite(html, &origin, &profiles, &avatars, None).unwrap();
598 let document = Html::parse_document(&output);
599 let author = document.select(&Selector::parse("td:nth-child(4)").unwrap()).next().unwrap();
600 assert_eq!(author.text().collect::<String>().trim(), "GPT (~clover)");
601 assert!(!output.contains("on behalf of"));
602 let owners: Vec<_> = document.select(&Selector::parse("a[href='/~clover']").unwrap()).collect();
603 assert_eq!(owners.len(), 2);
604 for owner in owners {
605 assert_eq!(owner.attr("class"), Some("usa-link va-middle-childs"));
606 let image = owner.select(&Selector::parse("img").unwrap()).next().unwrap();
607 assert_eq!(image.attr("src"), Some("https://snowglobe.test/picture"));
608 assert_eq!(image.attr("height"), Some("16"));
609 assert_eq!(image.attr("style"), None);
610 assert_eq!(image.attr("alt"), Some(""));
611 }
612 assert!(output.contains("<div class='markdown'><img src='https://api.dicebear.com/10.x/line-face/svg?seed=native-clover'>"));
613 for (model, short) in [("qwen3.8-27b", "Qwen"), ("big-pickle-2", "Big Pickle"), ("claude-new-variant-5-5", "New Variant")] {
614 let mut profile = ai_profile(Some(model));
615 profile.owner = Some(("clover".into(), None));
616 let profiles = HashMap::from([("ai-model".into(), profile)]);
617 let html = "<body id='page-issues'><div class='grid-container'><table><tr><td>1</td><td>Task</td><td>Todo</td><td><a href='/~ai-model'>ai</a></td></tr></table></div></body>";
618 let output = rewrite(html, &origin, &profiles, &HashMap::new(), None).unwrap();
619 let document = Html::parse_document(&output);
620 assert_eq!(document.select(&Selector::parse("td:nth-child(4)").unwrap()).next().unwrap().text().collect::<String>().trim(), format!("{short} (~clover)"));
621 }
622 }
623
275624 #[test]
276625 fn rewrites_only_known_local_account_links_and_escapes_provider_names() {
277626 let origin = url::Url::parse("https://shale.paperclover.net/").unwrap();
......@@ -282,6 +631,7 @@ mod tests {
282631 name: "<&\"clover".into(),
283632 url: Some("https://github.com/clover".into()),
284633 icon: include_str!("../web/sso/github.svg"),
634 owner: None,
285635 picture: Some("https://avatars.githubusercontent.com/u/123?s=64".into()),
286636 },
287637 ),
......@@ -291,12 +641,13 @@ mod tests {
291641 name: "Astheno user".into(),
292642 url: None,
293643 icon: include_str!("astheno.svg"),
644 owner: None,
294645 picture: None,
295646 },
296647 ),
297648 ]);
298649 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();
300651 let page = Html::parse_document(&rewritten);
301652 let links: Vec<_> = page.select(&Selector::parse("body > a").unwrap()).collect();
302653 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> {
3535 let value = match key {
3636 "username" => {
3737 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 }
3841 if v.len() > 254 || !username_pattern.is_match(&v) {
3942 return Err(Error::new(
4043 400,
......@@ -155,7 +158,7 @@ pub async fn route(
155158 for id in &users {
156159 uuid(id)?;
157160 let user = auth::user(&transaction, id)?;
158 if guest::is_guest(&user) {
161 if guest::is_guest(&user) || user["kind"] == "ai" {
159162 return Err(Error::new(
160163 400,
161164 "Guests can use Shale only. Select regular accounts to edit groups.",
......@@ -196,7 +199,7 @@ pub async fn route(
196199 }
197200 if parts.is_empty() && method == Method::GET {
198201 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")?;
200203 let ids = statement
201204 .query_map([], |r| r.get::<_, String>(0))?
202205 .collect::<std::result::Result<Vec<_>, _>>()?;
......@@ -274,6 +277,9 @@ pub async fn route(
274277 .first()
275278 .ok_or_else(|| Error::new(404, "No user here."))?;
276279 uuid(id)?;
280 if auth::user(&app.auth.db.lock().unwrap(), id)?["kind"] == "ai" {
281 return Err(Error::new(404, "No user here."));
282 }
277283 if *parts == [*id, "setup-link"] && method == Method::POST {
278284 return Ok(axum::Json(json!({"url":app.auth.setup_link(id)?})).into_response());
279285 }
......@@ -355,6 +361,19 @@ pub async fn route(
355361 }
356362 Value::Null
357363 }
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 }
358377 [_, "credentials"] if method == Method::GET => auth::credentials(&transaction, id)?,
359378 [_, "logout"] if method == Method::POST => {
360379 transaction.execute("DELETE FROM sessions WHERE user_id=?", [id])?;
......@@ -457,6 +476,7 @@ pub async fn account(
457476 let id = string(&user["id"]).to_owned();
458477 let value = match parts {
459478 [] if method == Method::GET => {
479 user["commitIdentity"] = identity::account(&app.auth.db.lock().unwrap(), &id)?;
460480 user["picture"] = user["attributes"]["picture"][0].clone();
461481 user["credentials"] = auth::credentials(&app.auth.db.lock().unwrap(), &id)?;
462482 user.as_object_mut().unwrap().remove("attributes");
......@@ -485,6 +505,56 @@ pub async fn account(
485505 auth::save_user(&app.auth.db.lock().unwrap(), &id, user)?;
486506 Value::Null
487507 }
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 }
488558 ["credentials", credential] if method == Method::DELETE => {
489559 app.auth.recent(request.headers())?;
490560 let mut db = app.auth.db.lock().unwrap();
dashboard/web/api.contract.ts+24-1
......@@ -229,6 +229,15 @@ export type Api = Hono<{}, {
229229 $put: Endpoint<null, { param: { id: string; group: string; }; }, 204, "body">;
230230 $delete: Endpoint<null, { param: { id: string; group: string; }; }, 204, "body">;
231231 };
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 };
232241 "/users/:id/credentials": {
233242 $get: Endpoint<Credential[], { param: { id: string; }; }>;
234243 };
......@@ -302,9 +311,23 @@ export type Api = Hono<{}, {
302311 $post: Endpoint<null, { param: { name: string; action: "start" | "destroy" | "shutdown" | "reboot" | "resume"; }; }, 204, "body">;
303312 };
304313 "/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}>;
306315 $patch: Endpoint<null, { json: { firstName?: string | undefined; lastName?: string | undefined; email?: string | undefined; }; }, 204, "body">;
307316 };
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 };
308331 "/account/picture": {
309332 $put: Endpoint<{ picture: string; }, { form: { picture: File; }; }>;
310333 $delete: Endpoint<null, {}, 204, "body">;
dashboard/web/pages/Account.css+12
......@@ -22,3 +22,15 @@
2222.credentials > .button { justify-self: end; }
2323.credentials .passkey { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
2424.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 @@
11import { useSearchParams } from "@solidjs/router";
22import { createResource, createSignal, For, Show } from "solid-js";
33import { parseResponse } from "hono/client";
4import type { User } from "../types/users.ts";
4import type { User, CommitIdentity as Identity } from "../types/users.ts";
55import { api, reason } from "../api.ts";
66import { Ago } from "../components/Ago.tsx";
77import { lastGood, Loaded } from "../components/Loaded.tsx";
......@@ -73,6 +73,7 @@ export function Account() {
7373 <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>
7474 <Profile user={user()} />
7575 <SignIn user={user()} />
76 <CommitIdentity user={user()} identity={user().commitIdentity} refetch={refetchAccount} />
7677 </div>
7778 )}
7879 </Loaded>
......@@ -227,3 +228,85 @@ function SignIn(props: { user: Self }) {
227228 <Show when={error()}><p class="error" role="alert">{error()}</p></Show>
228229 </section>;
229230}
231
232export function CommitIdentity(props: { user: User; identity: Identity; refetch: () => unknown; admin?: boolean }) {
233 const [busy, setBusy] = createSignal(false);
234 const [error, setError] = createSignal("");
235 const admin = () => props.admin || props.user.groups.some(group => group.name === "infra-admin");
236 const run = async (work: () => Promise<unknown>) => {
237 if (busy()) return;
238 setBusy(true); setError("");
239 try { await work(); await props.refetch(); }
240 catch (failure) { setError(reason(failure)); throw failure; }
241 finally { setBusy(false); }
242 };
243 const addEmail = () => showConfirmDialog({
244 title: "Add commit email", confirmLabel: "add email",
245 description: "An admin must confirm ownership before commits link to your profile.",
246 body: <label class="field">email<input class="search" name="email" type="email" required maxLength={254} autofocus /></label>,
247 onConfirm: async form => {
248 const count = props.identity.emails.length;
249 await run(() => parseResponse(api.account["commit-emails"].$post({json:{email:String(form.get("email"))}})));
250 toast(props.identity.emails.length === count ? "This email is already added" : "Added commit email");
251 },
252 });
253 const addKey = () => showConfirmDialog({
254 title: "Add signing key", confirmLabel: "add key",
255 description: "An admin must confirm key ownership before signatures link to your profile.",
256 body: <>
257 <label class="field">label<input class="search" name="label" maxLength={128} autofocus /></label>
258 <label class="field">SSH public key<textarea class="search" name="publicKey" required maxLength={16384} rows={4} spellcheck={false} /></label>
259 </>,
260 onConfirm: async form => {
261 const count = props.identity.signingKeys.length;
262 await run(() => parseResponse(api.account["signing-keys"].$post({json:{label:String(form.get("label")),publicKey:String(form.get("publicKey"))}})));
263 toast(props.identity.signingKeys.length === count ? "This key is already added; its label stays the same" : "Added signing key");
264 },
265 });
266 return <section class="card commit-identity">
267 <h2 class="card-title">commit identity</h2>
268 <p class="muted">Emails link commit authors to your Shale profile. Signing keys identify signers.</p>
269 <div class="credentials">
270 <span class="label">emails</span><span /><Show when={!props.admin} fallback={<span />}><button class="button small" disabled={busy()} onClick={addEmail}>add email</button></Show>
271 <Show when={props.user.email && props.user.emailVerified}><span /><span class="commit-email">{props.user.email} <span class="muted">primary</span></span><span /></Show>
272 <For each={props.identity.emails}>{email => <>
273 <span /><span class="commit-email">{email.email} <span class="muted">{email.verified ? "confirmed" : "awaiting confirmation"}</span></span>
274 <div class="actions">
275 <Show when={!email.verified && admin()}><button class="button small" aria-label={`Confirm email ${email.email}`} disabled={busy()} onClick={() => showConfirmDialog({
276 title: "Confirm email ownership", confirmLabel: "confirm ownership",
277 description: <>Confirm that {email.email} belongs to this account. Its commits will link to this account's Shale profile.</>,
278 onConfirm: () => run(() => parseResponse(props.admin
279 ? api.users[":id"]["commit-emails"][":email"].$put({param:{id:props.user.id,email:email.email}})
280 : api.account["commit-emails"][":email"].$put({param:{email:email.email}}))),
281 })}>confirm</button></Show>
282 <Show when={!props.admin}><button class="button small" disabled={busy()} aria-label={`Remove email ${email.email}`} onClick={() => showConfirmDialog({
283 title: "Remove commit email", confirmLabel: "remove email", destructive:true,
284 description: <>Commits using {email.email} will lose this profile link.</>,
285 onConfirm: () => run(() => parseResponse(api.account["commit-emails"][":email"].$delete({param:{email:email.email}}))),
286 })}>remove</button></Show>
287 </div>
288 </>}</For>
289 <Show when={!props.identity.emails.length && !(props.user.email && props.user.emailVerified)}><span /><span class="muted">No confirmed emails</span><span /></Show>
290 <span class="label">signing keys</span><span /><Show when={!props.admin} fallback={<span />}><button class="button small" disabled={busy()} onClick={addKey}>add key</button></Show>
291 <For each={props.identity.signingKeys}>{key => <>
292 <span /><span class="commit-key">{key.label || "unnamed key"} <span class="muted">{key.verified ? "confirmed" : "awaiting confirmation"}</span><code class="muted">{key.fingerprint}</code></span>
293 <div class="actions">
294 <Show when={!key.verified && admin()}><button class="button small" aria-label={`Confirm signing key ${key.label || key.fingerprint}`} disabled={busy()} onClick={() => showConfirmDialog({
295 title: "Confirm key ownership", confirmLabel: "confirm ownership",
296 description: <>Confirm that {key.label || key.fingerprint} belongs to this account. Its signatures will link to this account's Shale profile.</>,
297 onConfirm: () => run(() => parseResponse(props.admin
298 ? api.users[":id"]["signing-keys"][":fingerprint"].$put({param:{id:props.user.id,fingerprint:key.fingerprint}})
299 : api.account["signing-keys"][":fingerprint"].$put({param:{fingerprint:key.fingerprint}}))),
300 })}>confirm</button></Show>
301 <Show when={!props.admin}><button class="button small" disabled={busy()} aria-label={`Remove signing key ${key.label || key.fingerprint}`} onClick={() => showConfirmDialog({
302 title:"Remove signing key", confirmLabel:"remove key", destructive:true,
303 description: <>Signatures using {key.label || key.fingerprint} will lose this profile link.</>,
304 onConfirm: () => run(() => parseResponse(api.account["signing-keys"][":fingerprint"].$delete({param:{fingerprint:key.fingerprint}}))),
305 })}>remove</button></Show>
306 </div>
307 </>}</For>
308 <Show when={!props.identity.signingKeys.length}><span /><span class="muted">No signing keys</span><span /></Show>
309 </div>
310 <Show when={error()}><p class="error" role="alert">{error()}</p></Show>
311 </section>;
312}
dashboard/web/pages/Users.tsx+9
......@@ -22,6 +22,7 @@ import { TabBar } from "../components/TabBar.tsx";
2222import { toast } from "../components/Toast.tsx";
2323import { ago, count, date, datetime, plural } from "../format.ts";
2424import "./Users.css";
25import { CommitIdentity } from "./Account.tsx";
2526
2627const STEPS: [string, string][] = [["SETUP", "finish setup"], ["UPDATE_PASSWORD", "new password"], ["UPDATE_PROFILE", "check profile"]];
2728
......@@ -33,6 +34,7 @@ const load = () => Promise.all([parseResponse(api.users.$get()), parseResponse(a
3334 .then(([{ users, groups }, services]) => ({ users, groups, services }));
3435type Data = Awaited<ReturnType<typeof load>>;
3536export const directory = query("users", load);
37const identityOf = query("commit-identity", (id: string) => parseResponse(api.users[":id"]["commit-identity"].$get({ param: { id } })));
3638const credentialsOf = query("credentials", (id: string) => parseResponse(api.users[":id"].credentials.$get({ param: { id } })));
3739type User = Data["users"][number];
3840
......@@ -466,6 +468,7 @@ function Profile(props: { user: User; data: Data; refetch: () => unknown }) {
466468 const back = useBack();
467469 const param = () => ({ id: props.user.id });
468470 const [credentials, credentialActions] = credentialsOf.use(() => props.user.id);
471 const [identity, identityActions] = identityOf.use(() => props.user.id);
469472 const [busy, setBusy] = createSignal<string>();
470473
471474 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 }) {
644647 </div>
645648 </section>
646649
650 <Show when={props.user.kind !== "guest"}>
651 <Loaded data={identity} what="commit identity" retry={identityActions.refetch} skeleton={<div class="skeleton" style={{height:"100px"}} />}>
652 {data => <CommitIdentity user={props.user} identity={data()} refetch={identityActions.refetch} admin />}
653 </Loaded>
654 </Show>
655
647656 <section class="card">
648657 <h2 class="card-title">
649658 sessions
dashboard/web/types/users.ts+5
......@@ -33,3 +33,8 @@ export interface Session {
3333 lastAccess: number;
3434 clients: Record<string, string>;
3535}
36
37export interface CommitIdentity {
38 emails: { email: string; verified: boolean }[];
39 signingKeys: { fingerprint: string; label: string; publicKey: string; createdDate: number; verified: boolean }[];
40}
nixos/configuration.nix+5-3
......@@ -6,7 +6,7 @@ let
66 proxyToken = "/var/lib/studio/dashboard-proxy.token";
77 hostTools = lib.fileset.toSource {
88 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 ];
1010 };
1111 nativePkl = pkgs.callPackage ./pkl.nix { };
1212 screenPython = pkgs.python3.withPackages (packages: [ packages.pygobject3 packages.gst-python ]);
......@@ -16,6 +16,7 @@ let
1616 msVarsTemplate = true;
1717 tpmSupport = true;
1818 };
19
1920in
2021{
2122 nixpkgs.config.allowUnfreePredicate = pkg: builtins.elem (lib.getName pkg) [ "nomad" "nvidia-x11" "nvidia-kernel-modules" ];
......@@ -175,11 +176,11 @@ in
175176 RuntimeDirectoryPreserve = "yes";
176177 UMask = "0077";
177178 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" ];
179180 ProtectHome = true;
180181 PrivateTmp = true;
181182 NoNewPrivileges = true;
182 CapabilityBoundingSet = [ "CAP_SYS_ADMIN" "CAP_DAC_READ_SEARCH" ];
183 CapabilityBoundingSet = [ "CAP_SYS_ADMIN" "CAP_DAC_READ_SEARCH" "CAP_DAC_OVERRIDE" ];
183184 MemoryMax = "256M";
184185 TasksMax = 64;
185186 DevicePolicy = "closed";
......@@ -291,6 +292,7 @@ in
291292
292293 systemd.tmpfiles.rules = [
293294 "d /srv/prod 0755 root root -"
295 "A+ /srv/prod/shale/data - - - - u:root:rwX,d:u:root:rwx"
294296 "d /srv/staging 0755 root root -"
295297 "d /srv/vm 0755 root root -"
296298 "d /srv/logs 0770 clo chloe -"
service/shale/service.pkl+2-2
......@@ -18,7 +18,7 @@ requirements {
1818prepare = "prepare.py"
1919
2020container {
21 image = "docker.io/astheno/shale@sha256:0c507bb2d2f0d6390b8411f644d5c7c7579a384d14ccae2cf82eb51c2eb6466e"
21 image = "docker.io/astheno/shale@sha256:547a126d829c81be5d2435223a093337951e2c6ae71a114adf69938485fb3fa8"
2222 extraHosts { "\(sso.hostname):host-gateway" }
2323
2424 http {
......@@ -31,7 +31,7 @@ container {
3131 }
3232 headHtml {
3333 ["/*"] = """
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>
3535 """
3636 }
3737 }
tools/dashboard-auth-test.py+36-1
......@@ -12,6 +12,7 @@ import subprocess
1212import tempfile
1313import time
1414import uuid
15from urllib.parse import quote
1516from cryptography.hazmat.primitives import hashes
1617from cryptography.hazmat.primitives.asymmetric import ec
1718from cryptography.hazmat.primitives.kdf.argon2 import Argon2id
......@@ -112,6 +113,11 @@ def main():
112113 cookies = {}
113114 start()
114115 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)
115121 request('/api/me', status=401, extra={'User-Name': name, 'User-Groups': 'infra-admin'})
116122 request('/auth/status', status=403, extra={'Studio-Proxy-Token': 'wrong'})
117123 csrf = request('/auth/status', cookies=cookies)['csrf']
......@@ -188,6 +194,35 @@ def main():
188194 request('/auth/setup', 'POST', {'csrf': new_csrf, 'setup': setup, 'email': 'new@example.invalid', 'password': 'another-password'}, newcomer)
189195 request('/auth/setup', 'POST', {'csrf': new_csrf, 'setup': setup, 'email': 'new@example.invalid', 'password': 'another-password'}, newcomer, 410)
190196 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)
191226 directory = request('/api/users', cookies=cookies)
192227 groups = {g['name']: g['id'] for g in directory['groups']}
193228 assert 'ai' in groups
......@@ -242,7 +277,7 @@ def main():
242277 request('/api/users/' + actor + '/logout', 'POST', {}, cookies, 204)
243278 request('/api/me', cookies=cookies, status=401)
244279 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'}))
246281 finally:
247282 if server and server.poll() is None: stop()
248283 log.close()
tools/dashboard-host-test.py+38
......@@ -4,6 +4,10 @@ import json
44from pathlib import Path
55import sys
66import subprocess
7import os
8import socket
9import struct
10import threading
711import unittest
812from unittest.mock import patch
913
......@@ -17,6 +21,40 @@ class Boundary(unittest.TestCase):
1721 def setUp(self):
1822 self.host = host_module.Host("studio-demo")
1923
24 @unittest.skipUnless(sys.platform == "linux", "Linux Unix peer credentials")
25 def test_large_json_header_preserves_stream_bytes_and_enforces_limit(self):
26 def exchange(payload, expected=None):
27 client, server = socket.socketpair()
28 client.settimeout(5)
29 handler = unittest.mock.Mock()
30 handler.handle.return_value = True
31 def stream_console(connection, host, request):
32 self.assertEqual(connection.recv(len(expected)), expected)
33 host_module.send_response(connection, {"value": True})
34 with patch.object(host_module, "stream_console", side_effect=stream_console):
35 worker = threading.Thread(target=host_module.serve_connection, args=(server, handler, os.getuid()), daemon=True)
36 worker.start()
37 with client:
38 client.sendall(payload)
39 length = struct.unpack('>I', client.recv(4))[0]
40 response = bytearray()
41 while len(response) < length:
42 response.extend(client.recv(length - len(response)))
43 worker.join(timeout=5)
44 self.assertFalse(worker.is_alive())
45 return json.loads(response), handler
46 message = {"operation": "shale.write", "arguments": {"comment": "\x01" * 240000}}
47 response, handler = exchange(json.dumps(message).encode() + b"\n")
48 self.assertEqual(response, {"value": True})
49 handler.handle.assert_called_once_with(message)
50 tail = b"stream bytes\nnot JSON"
51 response, handler = exchange(b'{"operation":"vm.serial"}\n' + tail, tail)
52 self.assertEqual(response, {"value": True})
53 handler.handle.assert_not_called()
54 response, handler = exchange(b'x' * (host_module.MAX_REQUEST + 1))
55 self.assertEqual(response['status'], 400)
56 handler.handle.assert_not_called()
57
2058 def test_rejects_before_executing(self):
2159 requests = [None, [], {}, {"operation": []},
2260 {"operation": "command", "argv": ["sh", "-c", "id"]},
tools/dashboard-host.py+17-7
......@@ -22,6 +22,7 @@ import vms
2222import tty
2323
2424dashboard_runs = importlib.import_module("dashboard-run")
25shale_labels = importlib.import_module("shale-labels")
2526
2627
2728FIELDS = (
......@@ -30,8 +31,9 @@ FIELDS = (
3031 "quota", "origin", "mounted",
3132)
3233TEXT_FIELDS = {"name", "compression", "mountpoint", "origin", "mounted"}
33MAX_REQUEST = 65536
34MAX_RESPONSE = 16 * 1024 * 1024
34PROTOCOL = json.loads((Path(__file__).resolve().parent.parent / "dashboard/src/host-protocol.json").read_text())
35MAX_REQUEST = PROTOCOL['request']
36MAX_RESPONSE = PROTOCOL['response']
3537STREAM_SLOTS = threading.BoundedSemaphore(4)
3638GUEST_SLOTS = threading.BoundedSemaphore(4)
3739VM_PREPARATION_SLOT = threading.BoundedSemaphore(1)
......@@ -84,6 +86,8 @@ class Host:
8486 "index.snapshots": {"dataset"},
8587 "index.diff": {"dataset", "from", "to"},
8688 "index.discard": {"dataset", "snapshot"},
89 "shale.metadata": {"repository", "accounts"},
90 "shale.write": {"grant", "repository", "action", "arguments"},
8791 "host.sample": set(),
8892 "host.usage": {"refresh"},
8993 **dashboard_runs.FIELDS,
......@@ -93,6 +97,10 @@ class Host:
9397 raise Rejected("Choose a supported host operation.")
9498 if set(request) != parameters[operation] | {"operation"}:
9599 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)
96104 if operation in dashboard_runs.FIELDS:
97105 return dashboard_runs.handle(request)
98106 if operation == "host.sample":
......@@ -421,14 +429,16 @@ def serve_connection(connection, host, allowed_uid):
421429 if uid != allowed_uid:
422430 return
423431 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.
425433 line = bytearray()
426434 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:
429437 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"):
432442 break
433443 if len(line) > MAX_REQUEST or not line.endswith(b"\n"):
434444 raise Rejected("The host request is too large or incomplete.")
tools/dashboard-shale-attribution-test.py created+224
......@@ -0,0 +1,224 @@
1#!/usr/bin/env python3
2import argparse
3import hashlib
4import html
5import http.client
6import http.server
7import json
8import os
9from pathlib import Path
10import socket
11import sqlite3
12import ssl
13import struct
14import subprocess
15import tempfile
16import threading
17import time
18import urllib.parse
19
20
21def main():
22 parser = argparse.ArgumentParser()
23 parser.add_argument('--binary', type=Path, required=True)
24 args = parser.parse_args()
25 with tempfile.TemporaryDirectory(prefix='shale-attribution-') as temporary:
26 root = Path(temporary).resolve()
27 (root / 'data').mkdir()
28 cert, key = root / 'cert.pem', root / 'key.pem'
29 subprocess.run(['openssl', 'req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-keyout', str(key),
30 '-out', str(cert), '-days', '1', '-subj', '/CN=localhost',
31 '-addext', 'subjectAltName=DNS:localhost,IP:127.0.0.1', '-addext', 'basicConstraints=critical,CA:FALSE'],
32 check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
33 state = {'caller_write': True, 'status': 'todo', 'posts': [], 'comments': []}
34
35 def page(body, kind='page-issue'):
36 return "<meta name='astheno.shale.repo.name' content='chat'><body id='" + kind + "'>" + body + '</body>'
37
38 def issue():
39 comments = ''.join("<li class=comment id=c%d><div class=n-card__header><a href=/~%s>~%s</a></div><div class=markdown>%s</div></li>" %
40 (i, author, author, html.escape(comment)) for i, (author, comment) in enumerate(state['comments'], 1))
41 return '<h1><span>#1</span><span>Fixture</span></h1><dl class=sidebar><dd><span class=issuestatus-' + state['status'] + '>' + state['status'] + '</span></dd></dl><ul class=timeline>' + comments + '</ul>'
42
43 class Backend(http.server.BaseHTTPRequestHandler):
44 def log_message(self, *args):
45 pass
46
47 def reply(self, code, body='', **headers):
48 encoded = body.encode()
49 self.send_response(code)
50 for name, value in headers.items():
51 self.send_header(name, value)
52 self.send_header('Content-Length', str(len(encoded)))
53 self.end_headers()
54 self.wfile.write(encoded)
55
56 def do_GET(self):
57 actor = False
58 if self.path == '/-/settings':
59 self.reply(200, '<body id=page-user-settings><kbd>' + 'human' + '</kbd></body>')
60 return
61 if self.path == '/':
62 self.reply(200, '<body id=page-index><div class=grid-container><table><tbody><tr><td><a href=/chat/>chat</a></td><td>Fixture</td><td></td></tr><tr><td><a href=/other/>other</a></td><td>Other</td><td></td></tr></tbody></table></div></body>')
63 return
64 permitted = state['caller_write']
65 csrf = 'actor-token' if actor else 'caller-token'
66 if self.path == '/chat/issues/labels':
67 self.reply(200, page("<div class=grid-container><table><tbody><tr><td><a class=issuelabel data-label-id=75 data-color='#123456'>Bug</a></td><td>Fixture</td><td>2</td></tr></tbody></table></div>", 'page-labels'))
68 return
69 if self.path == '/chat/issues/new':
70 form = '<form method=post><input name=title><input type=hidden name=csrf_token value=' + csrf + '></form>' if permitted else ''
71 self.reply(200, page(form, 'page-new-issue'))
72 return
73 if self.path == '/chat/issues/1':
74 form = '<form method=post><input type=hidden name=t value=comment><input type=hidden name=csrf_token value=' + csrf + '></form>' if permitted else ''
75 self.reply(200, page(issue() + form))
76 return
77 self.reply(404)
78
79 def do_POST(self):
80 state['posts'].append(self.path)
81 self.reply(500, 'Native HTTP writes must not be used')
82
83 backend = http.server.ThreadingHTTPServer(('127.0.0.1', 0), Backend)
84 context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
85 context.load_cert_chain(cert, key)
86 backend.socket = context.wrap_socket(backend.socket, server_side=True)
87 thread = threading.Thread(target=backend.serve_forever, daemon=True)
88 thread.start()
89 with socket.socket() as available:
90 available.bind(('127.0.0.1', 0))
91 port = available.getsockname()[1]
92 host_calls = []
93 host_socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
94 host_socket.bind(str(root / 'host.sock'))
95 host_socket.listen()
96 host_socket.settimeout(.1)
97 host_done = threading.Event()
98
99 def host_requests():
100 while not host_done.is_set():
101 try:
102 connection, _ = host_socket.accept()
103 except socket.timeout:
104 continue
105 with connection:
106 request = json.loads(connection.makefile('rb').readline())
107 if request['operation'] != 'shale.write':
108 result = json.dumps({'value': {}}).encode()
109 try:
110 connection.sendall(struct.pack('>I', len(result)) + result)
111 except BrokenPipeError:
112 pass
113 continue
114 host_calls.append(request)
115 fields = request['arguments']
116 if request['action'] == 'set_issue_status':
117 state['status'] = fields['status']
118 if request['action'] == 'create_issue':
119 state['status'] = fields['status']
120 if request['action'] in ('create_issue', 'comment_issue'):
121 state['comments'].append(('ai-human-' + fields['model'], fields.get('description', fields.get('comment', ''))))
122 result = json.dumps({'value': {'actor': '~ai-human-' + fields['model'], 'id': 1}}).encode()
123 try:
124 connection.sendall(struct.pack('>I', len(result)) + result)
125 except BrokenPipeError:
126 pass
127
128 host_thread = threading.Thread(target=host_requests, daemon=True)
129 host_thread.start()
130 origin = 'https://globe.studio.test'
131 environment = {**os.environ, 'PORT': str(port), 'STUDIO_LISTEN_ADDRESS': '127.0.0.1',
132 'STUDIO_DATA_DIR': str(root / 'data'), 'STUDIO_PUBLIC_ORIGIN': origin,
133 'STUDIO_AUTH_RP_ID': 'studio.test', 'STUDIO_FILE_ORIGIN': 'https://file.studio.test',
134 'STUDIO_SHALE_URL': 'https://localhost:' + str(backend.server_port),
135 'STUDIO_CA_BUNDLE': str(cert), 'STUDIO_REPO': str(Path(__file__).resolve().parent.parent),
136 'STUDIO_AUTH_REQUIRED': '0', 'STUDIO_HOST_SOCKET': str(root / 'host.sock')}
137 for name in ('STUDIO_PROXY_TOKEN_FILE', 'STUDIO_INTERNAL_URL', 'STUDIO_YT_STATE', 'STUDIO_INDEX_POOL'):
138 environment.pop(name, None)
139 with (root / 'server.log').open('wb') as log:
140 server = subprocess.Popen([str(args.binary.resolve())], env=environment, stdout=log, stderr=log)
141 try:
142 deadline = time.monotonic() + 20
143 while True:
144 try:
145 with socket.create_connection(('127.0.0.1', port), timeout=.1):
146 break
147 except OSError:
148 assert server.poll() is None, (root / 'server.log').read_text()[-2000:]
149 assert time.monotonic() < deadline
150 time.sleep(.05)
151 with sqlite3.connect(root / 'data/accounts.sqlite') as db:
152 for name in ('human', 'ai'):
153 db.execute('INSERT INTO users(id,profile) VALUES (?,?)', (name, json.dumps({'username': name, 'enabled': True, 'requiredActions': []})))
154 resource = origin + '/mcp/shale'
155 grant = {'id': 'fixture', 'user': 'human', 'resource': resource, 'scopes': ['shale:read', 'shale:write'], 'resources': ['chat']}
156 with sqlite3.connect(root / 'data/connections.sqlite') as db:
157 for name, value in [('grant:fixture', grant), ('access:' + hashlib.sha256(b'fixture').hexdigest(), {'grant': 'fixture', 'resource': resource}),
158 ('shale-session:human', {'origin': 'https://localhost:' + str(backend.server_port) + '/', 'session': 'human'}),
159 ('shale-session:ai', {'origin': 'https://localhost:' + str(backend.server_port) + '/', 'session': 'actor'})]:
160 db.execute('INSERT INTO records VALUES (?,?,0)', (name, json.dumps(value)))
161
162 def call(name, fields, error=False):
163 connection = http.client.HTTPConnection('127.0.0.1', port, timeout=15)
164 connection.request('POST', '/mcp/shale', json.dumps({'jsonrpc': '2.0', 'id': 1, 'method': 'tools/call', 'params': {'name': name, 'arguments': fields}}),
165 {'Host': 'globe.studio.test', 'Authorization': 'Bearer fixture', 'Accept': 'application/json, text/event-stream', 'Content-Type': 'application/json'})
166 response = connection.getresponse()
167 body = response.read()
168 connection.close()
169 assert response.status == 200, (response.status, body)
170 result = json.loads(body)['result']
171 assert bool(result.get('isError', False)) == error, result
172 return result
173
174 created = call('create_issue', {'repository': 'chat', 'title': 'Fixture', 'description': 'Description', 'model': 'claude-opus-5.5', 'status': 'todo'})
175 assert created['structuredContent']['issue']['comments'][0]['author'] == '~ai-human-claude-opus-5.5'
176 assert created['structuredContent']['issue']['status'] == 'todo'
177 commented = call('comment_issue', {'repository': 'chat', 'id': 1, 'comment': 'Comment', 'model': 'gpt-6.1-sol'})
178 assert commented['structuredContent']['issue']['comments'][-1]['author'] == '~ai-human-gpt-6.1-sol'
179 assert host_calls[0]['arguments']['description'] == 'Description\n\nAssisted-by: claude-opus-5.5'
180 assert host_calls[1]['arguments']['comment'] == 'Comment\n\nAssisted-by: gpt-6.1-sol'
181 for body in ('x' * 200000, '\x01' * 240000, '😀' * 60000):
182 call('comment_issue', {'repository': 'chat', 'id': 1, 'comment': body, 'model': 'gpt-6.1-sol'})
183 assert host_calls[-1]['arguments']['comment'].startswith(body)
184 call('comment_issue', {'repository': 'chat', 'id': 1, 'comment': 'x' * 262144, 'model': 'gpt-6.1-sol'}, error=True)
185 call('create_issue', {'repository': 'chat', 'title': 'Question', 'model': 'gpt-6.1-sol'})
186 assert state['status'] == 'needs_triage'
187 for body, model in [('Body\n\nAssisted-by: claude-opus-5.5', 'gpt-6.1-sol'), ('```\nBody', 'gpt-6.1-sol'), ('Body', '<model-id>')]:
188 call('comment_issue', {'repository': 'chat', 'id': 1, 'comment': body, 'model': model}, error=True)
189 for fields in ({'title': 'Missing model'}, {'title': 'Invalid state', 'model': 'gpt-6.1-sol', 'status': 'done'}):
190 call('create_issue', {'repository': 'chat', **fields}, error=True)
191 state['caller_write'] = False
192 changed = call('set_issue_status', {'repository': 'chat', 'id': 1, 'status': 'done', 'model': 'gpt-6.1-sol'})
193 assert changed['structuredContent']['issue']['status'] == 'done'
194 assert host_calls[-1] == {'operation': 'shale.write', 'grant': 'fixture', 'repository': 'chat', 'action': 'set_issue_status', 'arguments': {'id': 1, 'status': 'done', 'model': 'gpt-6.1-sol'}}
195 catalog = call('list_labels', {'repository': 'chat'})['structuredContent']
196 assert catalog['labels'][0]['id'] == 75 and catalog['labels'][0]['openIssues'] == 2
197 for name, fields in [('create_label', {'name': 'Bug', 'description': '', 'color': '#123456'}),
198 ('update_label', {'label_id': 75, 'name': 'Defect', 'description': '', 'color': '#abcdef'}),
199 ('set_issue_labels', {'id': 1, 'labels': [75]}), ('delete_label', {'label_id': 75})]:
200 call(name, {'repository': 'chat', 'model': 'gpt-6.1-sol', **fields})
201 assert host_calls[-1] == {'operation': 'shale.write', 'grant': 'fixture', 'repository': 'chat', 'action': name, 'arguments': {'model': 'gpt-6.1-sol', **fields}}
202 call(name, {'repository': 'chat', **fields}, error=True)
203 count = len(host_calls)
204 call('create_label', {'repository': 'other', 'model': 'gpt-6.1-sol', 'name': 'REFUSED', 'description': '', 'color': '#123456'}, error=True)
205 with sqlite3.connect(root / 'data/connections.sqlite') as db:
206 db.execute('UPDATE records SET value=? WHERE key=?', (json.dumps({**grant, 'scopes': ['shale:read']}), 'grant:fixture'))
207 call('set_issue_labels', {'repository': 'chat', 'id': 1, 'labels': [], 'model': 'gpt-6.1-sol'}, error=True)
208 call('set_issue_status', {'repository': 'chat', 'id': 1, 'status': 'todo', 'model': 'gpt-6.1-sol'}, error=True)
209 assert len(host_calls) == count
210 assert state['posts'] == [], 'A bridge write used a native user session'
211 print(json.dumps({'durable_model_arguments': True, 'triage_default_and_todo': True, 'trailers_match_model': True, 'missing_models_refused': True, 'scope_and_readonly_enforced': True, 'no_native_session_write': True}))
212
213 finally:
214 server.terminate()
215 server.wait(timeout=10)
216 backend.shutdown()
217 backend.server_close()
218 host_done.set()
219 host_thread.join(timeout=1)
220 host_socket.close()
221
222
223if __name__ == '__main__':
224 main()
tools/dashboard-shale-link-test.py+30-33
......@@ -47,7 +47,8 @@ def main():
4747 origin = 'https://globe.studio.test'
4848 proof = args.proof_file.read_text().strip()
4949 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!')]
5152 ids = []
5253 sessions = {name: uuid.uuid4().hex + uuid.uuid4().hex for name, _ in accounts}
5354
......@@ -176,7 +177,7 @@ def main():
176177 pending = urllib.parse.urlsplit(headers['Location']).path.removeprefix('/connect/')
177178 details = api(accounts[index][0], path='/api/mcp/consent/' + pending)
178179 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)
180181 return pending, verifier, details
181182
182183 def consent(client, index, repository, scope):
......@@ -239,6 +240,8 @@ def main():
239240 identity = db.execute('SELECT id FROM users WHERE snowflake=?', (ids[index],)).fetchone()[0]
240241 assert db.execute('UPDATE repositories SET owner=? WHERE name=?', (identity, name)).rowcount == 1
241242
243 for name, _ in accounts:
244 assert not keycloak.request('/admin/realms/master/users?username=' + name + '&exact=true'), 'Fixture account already exists: ' + name
242245 try:
243246 for name, credential in accounts:
244247 keycloak.request('/admin/realms/master/users', 'POST', {'username': name, 'firstName': name,
......@@ -290,11 +293,19 @@ def main():
290293 other = consent(oauth_client, 1, 'foreign', 'shale:read shale:write')
291294 read = readonly['access_token']
292295 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"]
296303 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']
298309 assert all(tool['annotations']['readOnlyHint'] == tool['name'].startswith(('list_', 'get_')) for tool in tools)
299310 assert {repo['id'] for repo in call(read, 'list_repositories')['repositories']} == {'alpha'}
300311 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():
335346 call(read, 'create_issue', {'repository': 'alpha', 'title': 'REFUSED'}, error=True)
336347 call(write, 'create_issue', {'repository': 'alpha', 'title': 'REFUSED', 'url': 'https://other.invalid'}, error=True)
337348 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)
338350 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']
341355 assert created['title'] == title and created['id'] == 1
342356 assert len(created['comments']) == 1 and 'Unicode ☃' in created['comments'][0]['text']
343357 assert '<script>' not in json.dumps(created)
......@@ -346,13 +360,6 @@ def main():
346360 rejected = call(read, 'list_issues', {'repository': 'alpha', 'q': 'Unicode'}, error=True)
347361 assert 'filter syntax' in rejected['content'][0]['text'], rejected
348362 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
356363 status, _, _ = submit_backend(0, '/alpha/issues/labels', {'name': 'Owned ☃', 'description': 'Fixture label', 'color': '#ff40ff'})
357364 assert status == 303
358365 status, _, _ = submit_backend(0, '/alpha/issues/1', {'t': 'labels', 'labels': '1'})
......@@ -363,23 +370,15 @@ def main():
363370 rows = db.execute('SELECT id FROM users WHERE snowflake=?', (ids[1],)).fetchall()
364371 assert len(rows) == 1
365372 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],))
367374 try:
368375 call(read, 'get_issue', issue_fields, error=True)
369376 call(write, 'create_issue', {'repository': 'alpha', 'title': 'REFUSED'}, error=True)
370377 finally:
371378 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,))
373380 with sqlite3.connect(args.shale_database) as db:
374381 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
383382 subprocess.run(['systemctl', 'restart', args.restart_unit], check=True, capture_output=True, timeout=30)
384383 assert session(0) == new_first and session(1) == second
385384 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():
419418 api(accounts[1][0], 'DELETE', '/api/mcp/shale', 204)
420419 assert session(1) is None and session_count(1) == 0
421420 backend_session(second, 303)
421 api(accounts[2][0], 'DELETE', '/api/mcp/shale', 204)
422 assert session(2) is None
422423 assert not records('shale-link:') and not records('shale-session:')
423424 result = {'two_user_oidc_linking': True, 'same_realm_ordinary_users': True, 'host_only_callback_cookie': True,
424425 'single_use_state_and_expiry': True, 'account_mismatch_refused_and_session_removed': True,
......@@ -427,25 +428,21 @@ def main():
427428 'unlink_cancels_inflight_callback': True, 'unlink_revokes_backend_session': True,
428429 'oauth_link_returns_to_consent': True, 'sdk_catalog_and_repository_grants': True,
429430 '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,
431432 'native_issue_labels_read': True,
432 'committed_write_lost_response_reported_without_replay': True,
433433 'backend_permission_changes_enforced': True, 'expired_backend_session_refused': True,
434434 'all_repository_approval_and_dynamic_access': True, 'nested_repository_paths': True, 'existing_token_resource_edits': True,
435435 'restart_preserves_mcp_access': True, 'unlink_revokes_mcp_access_and_refresh': True}
436436 finally:
437437 keycloak = Keycloak('keycloak.studio.test', importlib.import_module('dashboard-run').secret('get', 'keycloak', 'password'), attempts=1)
438438 cleanup_errors = []
439 for name, _ in accounts:
439 for (name, _), identity in zip(accounts, ids):
440440 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)
443442 except Exception as error:
444443 cleanup_errors.append(error)
445444 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')
449446 except Exception as error:
450447 cleanup_errors.append(error)
451448 with sqlite3.connect(args.data_dir / 'accounts.sqlite') as db:
tools/dashboard-shale-page-test.py+2-1
......@@ -103,8 +103,9 @@ def main():
103103 token = root / 'proxy.token'
104104 token.write_text(proof)
105105 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)
107107 data = root / 'data'
108 data.mkdir()
108109 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')}
109110 for key in ['STUDIO_INDEX_POOL', 'STUDIO_AUTH_REQUIRED', 'STUDIO_YT_STATE']:
110111 environment.pop(key, None)
tools/shale-labels-test.py created+189
......@@ -0,0 +1,189 @@
1#!/usr/bin/env python3
2import importlib
3import json
4import os
5from pathlib import Path
6import sqlite3
7import tempfile
8import unittest
9from unittest.mock import patch
10
11shale = importlib.import_module('shale-labels')
12
13
14class ShaleTests(unittest.TestCase):
15 def setUp(self):
16 self.temp = tempfile.TemporaryDirectory()
17 self.root = Path(self.temp.name)
18 self.env = patch.dict(os.environ, {'STUDIO_SHALE_DATABASE': str(self.root / 'shale.sqlite'),
19 'STUDIO_DASHBOARD_DATA': str(self.root), 'STUDIO_DOMAIN': 'studio.test', 'STUDIO_SHALE_POLICY': str(self.root / 'policy.json')})
20 self.env.start()
21 (self.root / 'policy.json').write_text(json.dumps({'actorOwners': {'ai': 'owner'}, 'statusDelegates': {'ai': ['chat']}}))
22 with sqlite3.connect(self.root / 'accounts.sqlite') as db:
23 db.execute("CREATE TABLE users(id TEXT PRIMARY KEY,profile TEXT,username TEXT GENERATED ALWAYS AS (json_extract(profile,'$.username')) STORED UNIQUE)")
24 for name in ('owner', 'other', 'ai'):
25 db.execute('INSERT INTO users(id,profile) VALUES(?,?)', (name, json.dumps({'username': name, 'enabled': True})))
26 self.grant = {'id': 'fixture', 'user': 'owner', 'resource': 'https://snowglobe.studio.test/mcp/shale', 'scopes': ['shale:write'], 'resources': ['chat']}
27 with sqlite3.connect(self.root / 'connections.sqlite') as db:
28 db.execute('CREATE TABLE records(key TEXT PRIMARY KEY,value TEXT,last_updated INTEGER)')
29 db.execute('INSERT INTO records VALUES(?,?,0)', ('grant:fixture', json.dumps(self.grant)))
30 for name in ('owner', 'other', 'ai'):
31 db.execute('INSERT INTO records VALUES(?,?,0)', ('shale-session:' + name, json.dumps({'origin': 'https://shale.studio.test/'})))
32 self.db = sqlite3.connect(self.root / 'shale.sqlite')
33 self.db.executescript('''
34 CREATE TABLE users(id INTEGER PRIMARY KEY,uuid TEXT,name TEXT,provider TEXT,snowflake TEXT,joined_on TEXT,last_updated TEXT);
35 CREATE TABLE repositories(id INTEGER PRIMARY KEY,name TEXT,owner INTEGER,last_updated TEXT,open_issues INTEGER DEFAULT 0,access_issues TEXT DEFAULT 'public',access_issues_submit TEXT DEFAULT 'public',access_issues_comment TEXT DEFAULT 'public');
36 CREATE TABLE issues(id INTEGER PRIMARY KEY,uuid TEXT,repo INTEGER,number INTEGER,status TEXT,last_updated TEXT,author INTEGER DEFAULT 3,title TEXT DEFAULT 'Title');
37 CREATE TABLE issue_labels(id INTEGER PRIMARY KEY,uuid TEXT,repo INTEGER REFERENCES repositories(id),name TEXT,description TEXT,color TEXT,open_issues INTEGER DEFAULT 0,last_updated TEXT);
38 CREATE TABLE issues__labels(id INTEGER PRIMARY KEY,uuid TEXT,issue INTEGER REFERENCES issues(id),label INTEGER REFERENCES issue_labels(id));
39 CREATE TABLE issue_actions(id INTEGER PRIMARY KEY,uuid TEXT,issue INTEGER REFERENCES issues(id),actor INTEGER REFERENCES users(id),kind TEXT,payload TEXT,last_updated TEXT,added_on TEXT,deleted_on TEXT);
40 INSERT INTO users(id,name,provider,snowflake) VALUES(1,'owner','snowglobe.studio.test','owner'),(2,'other','snowglobe.studio.test','other'),(3,'ai','snowglobe.studio.test','ai');
41 INSERT INTO repositories(id,name,owner,last_updated) VALUES(1,'chat',1,''),(2,'other',2,'');
42 INSERT INTO issues(id,repo,number,status,last_updated) VALUES(1,1,1,'todo',''),(2,1,2,'done',''),(3,2,1,'todo','');
43 ''')
44 self.db.commit()
45
46 def tearDown(self):
47 self.db.close()
48 self.env.stop()
49 self.temp.cleanup()
50
51 def call(self, action, **args):
52 return shale.handle({'action': action, 'arguments': {'model': 'gpt-6.1-sol', **args}, 'grant': 'fixture', 'repository': 'chat'})
53
54 def grant_as(self, **change):
55 with sqlite3.connect(self.root / 'connections.sqlite') as db:
56 db.execute('UPDATE records SET value=? WHERE key=?', (json.dumps({**self.grant, **change}), 'grant:fixture'))
57
58 def create_label(self, name='bug'):
59 return self.call('create_label', name=name, description='Description', color='#123456')['label']['id']
60
61 def test_lifecycle_counts_history_and_protected_deletion(self):
62 a, b = self.create_label(), self.create_label('feature')
63 self.call('set_issue_labels', id=1, labels=[a, b])
64 self.call('set_issue_labels', id=2, labels=[a])
65 self.call('set_issue_labels', id=1, labels=[a, b])
66 self.assertEqual(self.db.execute('SELECT count(*) FROM issue_actions').fetchone(), (3,))
67 self.assertEqual(self.call('update_label', label_id=a, name='defect', description='', color='#abcdef')['label']['openIssues'], 1)
68 with self.assertRaises(shale.Error):
69 self.call('delete_label', label_id=a)
70 self.call('set_issue_labels', id=1, labels=[b])
71 self.call('set_issue_labels', id=2, labels=[])
72 with self.assertRaises(shale.Error):
73 self.call('delete_label', label_id=a)
74 unused = self.create_label('unused')
75 self.call('delete_label', label_id=unused)
76 self.assertEqual(self.db.execute('SELECT label FROM issues__labels').fetchall(), [(b,)])
77 self.assertEqual(self.db.execute('SELECT DISTINCT u.name FROM issue_actions a JOIN users u ON a.actor=u.id').fetchall(), [('ai-owner-gpt-6.1-sol',)])
78 self.assertEqual(self.db.execute('PRAGMA foreign_key_check').fetchall(), [])
79
80 def test_models_and_owners_are_distinct_durable_oauth_subjects(self):
81 created = self.call('create_issue', title='Question', description='Body', status='needs_triage')
82 number = created['id']
83 self.call('comment_issue', id=number, comment='Claude', model='claude-opus-5.5')
84 self.call('set_issue_title', id=number, title='Updated')
85 self.grant_as(user='other')
86 self.call('comment_issue', id=number, comment='Other owner')
87 self.assertEqual(self.db.execute('SELECT u.name FROM issue_actions a JOIN users u ON a.actor=u.id ORDER BY a.id').fetchall(),
88 [('ai-owner-gpt-6.1-sol',), ('ai-owner-claude-opus-5.5',), ('ai-owner-gpt-6.1-sol',), ('ai-other-gpt-6.1-sol',)])
89 with self.assertRaises(shale.Error):
90 self.call('set_issue_title', id=number, title='Cannot take ownership')
91 self.assertEqual(self.db.execute('SELECT status,title FROM issues WHERE number=? AND repo=1', (number,)).fetchone(), ('needs_triage', 'Updated'))
92 with sqlite3.connect(self.root / 'accounts.sqlite') as db:
93 rows = db.execute("SELECT id,profile FROM users WHERE json_extract(profile,'$.kind')='ai'").fetchall()
94 self.assertEqual(len(rows), 3)
95 self.assertEqual(len({uid for uid, _ in rows}), 3)
96 self.assertTrue(all(json.loads(profile)['owner'] in ('owner', 'other') for _, profile in rows))
97
98 def test_explicit_legacy_delegation_keeps_owner_model_and_status_counts(self):
99 label = self.create_label()
100 self.call('set_issue_labels', id=1, labels=[label])
101 self.call('set_issue_status', id=1, status='done')
102 self.grant_as(user='ai')
103 self.call('set_issue_status', id=1, status='todo', model='claude-opus-5.5')
104 self.call('set_issue_status', id=1, status='todo', model='claude-opus-5.5')
105 self.assertEqual(self.db.execute("SELECT u.name,a.payload FROM issue_actions a JOIN users u ON a.actor=u.id WHERE kind='update_status' ORDER BY a.id").fetchall(), [('ai-owner-gpt-6.1-sol', 'done'), ('ai-owner-claude-opus-5.5', 'todo')])
106 self.assertEqual(self.db.execute('SELECT open_issues FROM issue_labels WHERE id=?', (label,)).fetchone(), (1,))
107 self.db.execute('UPDATE repositories SET open_issues=99 WHERE id=1'); self.db.commit()
108 self.assertEqual(shale.metadata({'repository': 'chat', 'accounts': []})['openIssues'], 1)
109 with self.assertRaises(shale.Error):
110 self.create_label('owner-only')
111 (self.root / 'policy.json').write_text('{}')
112 with self.assertRaises(shale.Error):
113 self.call('set_issue_status', id=1, status='done')
114
115 def test_metadata_never_reads_trailers_and_only_returns_requested_accounts(self):
116 result = self.call('create_issue', title='Task', description='Assisted-by: wrong-model', status='todo')
117 name = result['actor'][1:]
118 value = shale.metadata({'repository': 'chat', 'accounts': [name, 'owner', 'ai']})
119 self.assertEqual(value['accounts'], {name: {'model': 'gpt-6.1-sol', 'owner': 'owner', 'picture': None}})
120 subject = self.db.execute('SELECT snowflake FROM users WHERE name=?', (name,)).fetchone()[0]
121 self.db.execute("INSERT INTO users(name,provider,snowflake) VALUES('foreign-owner','foreign.test','owner'),('ai-foreign-human','foreign.test',?)", (subject,))
122 self.db.commit()
123 self.assertEqual(shale.metadata({'repository': 'chat', 'accounts': ['ai-foreign-human']})['accounts'], {})
124 self.assertEqual(shale.metadata({'repository': None, 'accounts': []}), {'accounts': {}, 'openIssues': None})
125 for names in ([True], ['x'] * 1025):
126 with self.assertRaises(shale.Error):
127 shale.metadata({'repository': 'chat', 'accounts': names})
128
129 def test_native_avatars_use_the_same_owner_picture_as_model_credits(self):
130 name = self.call('create_issue', title='Task', description='', status='todo')['actor'][1:]
131 with sqlite3.connect(self.root / 'accounts.sqlite') as db:
132 row = db.execute("SELECT profile FROM users WHERE id='owner'").fetchone()
133 profile = json.loads(row[0]); profile['attributes'] = {'picture': ['https://snowglobe.test/picture']}
134 db.execute("UPDATE users SET profile=? WHERE id='owner'", (json.dumps(profile),))
135 self.db.execute("UPDATE users SET uuid='native-owner' WHERE id=1"); self.db.commit()
136 metadata = shale.metadata({'repository': 'chat', 'accounts': [name, 'owner']})['accounts']
137 self.assertEqual(metadata['owner'], {'avatar': 'native-owner', 'picture': 'https://snowglobe.test/picture'})
138 self.assertEqual(metadata[name]['picture'], metadata['owner']['picture'])
139 self.assertEqual(shale.metadata({'repository': None, 'accounts': []})['accounts'], {})
140
141 def test_scope_revocation_and_private_permissions(self):
142 for change in ({'user': 'other'}, {'resources': ['other']}, {'scopes': ['shale:read']}, {'resource': 'https://other.test/mcp/shale'}):
143 self.grant_as(**change)
144 with self.assertRaises(shale.Error):
145 self.create_label()
146 self.grant_as(user='other')
147 for column in ('access_issues', 'access_issues_submit', 'access_issues_comment'):
148 self.db.execute('UPDATE repositories SET ' + column + "='private' WHERE id=1"); self.db.commit()
149 with self.assertRaises(shale.Error):
150 self.call('create_issue' if column != 'access_issues_comment' else 'comment_issue', **({'title': 'Denied', 'description': '', 'status': 'todo'} if column != 'access_issues_comment' else {'id': 1, 'comment': 'Denied'}))
151 self.db.execute('UPDATE repositories SET ' + column + "='public' WHERE id=1"); self.db.commit()
152 self.grant_as()
153 with sqlite3.connect(self.root / 'connections.sqlite') as db:
154 db.execute("DELETE FROM records WHERE key='shale-session:owner'")
155 with self.assertRaises(shale.Error):
156 self.create_label()
157 self.assertEqual(self.db.execute('SELECT count(*) FROM issue_labels').fetchone(), (0,))
158
159 def test_missing_invalid_or_disabled_model_and_owner_fail_closed(self):
160 for model in (None, '', 'GPT 6', '../other', 'gpt-6\n', '<model-id>'):
161 with self.assertRaises(shale.Error):
162 self.call('create_label', name='bug', description='', color='#123456', model=model)
163 self.create_label()
164 with sqlite3.connect(self.root / 'accounts.sqlite') as db:
165 db.execute("UPDATE users SET profile=json_set(profile,'$.enabled',json('false')) WHERE username='ai-owner-gpt-6.1-sol'")
166 with self.assertRaises(shale.Error):
167 self.call('set_issue_status', id=1, status='done')
168 with sqlite3.connect(self.root / 'accounts.sqlite') as db:
169 db.execute("UPDATE users SET profile=json_set(profile,'$.enabled',json('false')) WHERE id='owner'")
170 with self.assertRaises(shale.Error):
171 self.call('create_issue', title='No fallback', description='', status='todo', model='claude-opus-5.5')
172 self.assertEqual(self.db.execute('SELECT count(*) FROM issue_actions').fetchone(), (0,))
173
174 def test_bad_ids_status_labels_and_rollback(self):
175 label = self.create_label()
176 for ids in ([label, label], [label, 999], [True], [-1]):
177 with self.assertRaises(shale.Error):
178 self.call('set_issue_labels', id=1, labels=ids)
179 for status in ('done', 'triage', None):
180 with self.assertRaises(shale.Error):
181 self.call('create_issue', title='Bad', description='', status=status)
182 with self.assertRaises(shale.Error):
183 self.call('set_issue_status', id=1, status='invented')
184 self.assertEqual(self.db.execute('SELECT count(*) FROM issue_actions').fetchone(), (0,))
185 self.assertEqual(self.db.execute('SELECT count(*) FROM issues').fetchone(), (3,))
186
187
188if __name__ == '__main__':
189 unittest.main()
tools/shale-labels.py created+238
......@@ -0,0 +1,238 @@
1import json
2import os
3from pathlib import Path
4import re
5import secrets
6import sqlite3
7from datetime import datetime, timezone
8import time
9import uuid
10from contextlib import closing
11
12from importlib import import_module
13
14Error = import_module('dashboard-run').Error
15
16
17def database(mode, timeout):
18 path = Path(os.environ.get('STUDIO_SHALE_DATABASE', '/srv/prod/shale/data/astheno.shale.db'))
19 return sqlite3.connect(path.as_uri() + '?mode=' + mode, uri=True, timeout=timeout)
20
21
22def identifier():
23 value = (int((time.time() - 1577836800) * 1000) << 80) | secrets.randbits(80)
24 alphabet = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'
25 return ''.join(alphabet[(value >> (5 * i)) & 31] for i in range(25, -1, -1))
26
27
28def update_label_counts(db, labels, now):
29 for label in labels:
30 db.execute("UPDATE issue_labels SET open_issues=(SELECT count(*) FROM issues__labels il JOIN issues i ON i.id=il.issue WHERE il.label=issue_labels.id AND i.status NOT IN ('done','not_planned','duplicate','invalid')),last_updated=? WHERE id=?", (now, label))
31
32
33def metadata(request):
34 names = request['accounts']
35 if (not isinstance(names, list) or len(names) > 1024
36 or any(not isinstance(n, str) or len(n) > 254 for n in names)
37 or request['repository'] is not None and not isinstance(request['repository'], str)):
38 raise Error(400, 'Choose up to 1024 account names.')
39 accounts = Path(os.environ.get('STUDIO_DASHBOARD_DATA', '/var/lib/studio/dashboard'))
40 with closing(database('ro', 2)) as db:
41 db.execute('ATTACH DATABASE ? AS auth', ((accounts / 'accounts.sqlite').as_uri() + '?mode=ro',))
42 result = {}
43 for name, model, owner, picture in db.execute(
44 "SELECT u.name,json_extract(a.profile,'$.model'),o.name,json_extract(h.profile,'$.attributes.picture[0]') "
45 "FROM users u JOIN auth.users a ON a.id=u.snowflake "
46 "JOIN auth.users h ON h.id=json_extract(a.profile,'$.owner') "
47 "JOIN users o ON o.snowflake=h.id AND o.provider=u.provider "
48 "WHERE u.provider=? AND json_extract(a.profile,'$.kind')='ai' AND u.name IN (" + ','.join('?' for _ in names) + ")",
49 ['snowglobe.' + os.environ.get('STUDIO_DOMAIN', 'studio.test'), *names]):
50 result[name] = {'model': model, 'owner': owner, 'picture': picture}
51 for name, seed, picture in db.execute(
52 "SELECT u.name,u.uuid,json_extract(a.profile,'$.attributes.picture[0]') "
53 "FROM users u JOIN auth.users a ON a.id=u.snowflake "
54 "WHERE u.provider=? AND coalesce(json_extract(a.profile,'$.kind'),'user') NOT IN ('ai','guest') "
55 "AND u.name IN (" + ','.join('?' for _ in names) + ")",
56 ['snowglobe.' + os.environ.get('STUDIO_DOMAIN', 'studio.test'), *names]):
57 if picture:
58 result[name] = {'avatar': seed, 'picture': picture}
59 row = db.execute("SELECT (SELECT count(*) FROM issues WHERE repo=r.id AND status NOT IN ('done','not_planned','duplicate','invalid')) FROM repositories r WHERE name=?", (request['repository'],)).fetchone()
60 return {'accounts': result, 'openIssues': row[0] if row else None}
61
62
63def actor_account(db, owner, model, domain, now):
64 if not isinstance(model, str) or not re.fullmatch(r'[a-z0-9][a-z0-9._-]{0,127}', model):
65 raise Error(400, 'Supply the actual model identifier in model.')
66 human = db.execute('SELECT a.profile,u.name FROM auth.users a JOIN users u ON u.snowflake=a.id WHERE a.id=? AND u.provider=?',
67 (owner, 'snowglobe.' + domain)).fetchone()
68 if not human or json.loads(human[0]).get('enabled') is not True or json.loads(human[0]).get('kind') in ('ai', 'guest'):
69 raise Error(403, 'The account on whose behalf this model writes is unavailable.')
70 name = 'ai-' + human[1] + '-' + model
71 if len(name) > 254:
72 raise Error(400, 'Use a shorter model identifier.')
73 matches = db.execute("SELECT id,username,profile FROM auth.users WHERE json_extract(profile,'$.kind')='ai' AND json_extract(profile,'$.owner')=? AND json_extract(profile,'$.model')=?", (owner, model)).fetchall()
74 if len(matches) > 1:
75 raise Error(409, 'This model has multiple accounts. Ask the server owner to repair them.')
76 if matches:
77 subject, name, profile = matches[0]
78 if json.loads(profile).get('enabled') is not True:
79 raise Error(403, 'This model account is disabled. Ask the server owner to enable it.')
80 else:
81 if db.execute('SELECT 1 FROM auth.users WHERE username=?', (name,)).fetchone():
82 raise Error(409, 'This model account name is already taken. Ask the server owner to repair it.')
83 subject = str(uuid.uuid4())
84 db.execute('INSERT INTO auth.users(id,profile) VALUES(?,?)',
85 (subject, json.dumps({'kind': 'ai', 'username': name, 'owner': owner, 'model': model, 'enabled': True, 'requiredActions': []})))
86 actor = db.execute('SELECT id FROM users WHERE provider=? AND snowflake=?', ('snowglobe.' + domain, subject)).fetchone()
87 if not actor:
88 actor = (db.execute('INSERT INTO users(uuid,provider,snowflake,name,joined_on,last_updated) VALUES(?,?,?,?,?,?)',
89 (identifier(), 'snowglobe.' + domain, subject, name, now, now)).lastrowid,)
90 return actor[0], name
91
92
93def handle(request):
94 action, args = request['action'], request['arguments']
95 fields = {'create_label': {'name', 'description', 'color'}, 'update_label': {'label_id', 'name', 'description', 'color'},
96 'delete_label': {'label_id'}, 'set_issue_labels': {'id', 'labels'}, 'set_issue_status': {'id', 'status'}, 'create_issue': {'title', 'description', 'status'},
97 'comment_issue': {'id', 'comment'}, 'set_issue_title': {'id', 'title'}}
98 if action not in fields or not isinstance(args, dict) or set(args) != fields[action] | {'model'}:
99 raise Error(400, 'Use the fields listed for this tool.')
100 for key in ('id', 'label_id'):
101 if key in args and (type(args[key]) is not int or args[key] <= 0):
102 raise Error(400, 'Choose a positive issue or label ID.')
103 if action in ('create_label', 'update_label'):
104 if (not isinstance(args['name'], str) or not args['name'].strip() or len(args['name']) > 256
105 or not isinstance(args['description'], str) or len(args['description']) > 4096
106 or not isinstance(args['color'], str) or not re.fullmatch(r'#[0-9a-fA-F]{6}', args['color'])):
107 raise Error(400, 'Enter a label name, description, and six-digit hex color within the tool limits.')
108 if action == 'set_issue_status' and args['status'] not in ('needs_triage', 'incomplete', 'todo', 'done', 'not_planned', 'duplicate', 'invalid'):
109 raise Error(400, 'Choose a status available in Shale.')
110 if action == 'set_issue_labels':
111 labels = args['labels']
112 if (not isinstance(labels, list) or len(labels) > 100 or any(type(i) is not int or i <= 0 for i in labels)
113 or len(set(labels)) != len(labels)):
114 raise Error(400, 'Choose up to 100 distinct positive label IDs. An empty list clears the labels.')
115 for field, maximum in (('title', 4096), ('description', 262144), ('comment', 262144)):
116 if field in args and (not isinstance(args[field], str) or len(args[field].encode()) > maximum or field != 'description' and not args[field].strip()):
117 raise Error(400, 'Supply issue text within the tool limits.')
118 if action == 'create_issue' and args['status'] not in ('needs_triage', 'todo'):
119 raise Error(400, 'Choose triage or todo for a new issue.')
120 accounts = Path(os.environ.get('STUDIO_DASHBOARD_DATA', '/var/lib/studio/dashboard'))
121 domain = os.environ.get('STUDIO_DOMAIN', 'studio.test')
122 with closing(database('rw', 10)) as db, db:
123 db.row_factory = sqlite3.Row
124 db.execute('PRAGMA foreign_keys=ON')
125 db.execute('ATTACH DATABASE ? AS auth', ((accounts / 'accounts.sqlite').as_uri() + '?mode=rw',))
126 db.execute('ATTACH DATABASE ? AS mcp', ((accounts / 'connections.sqlite').as_uri() + '?mode=ro',))
127 db.execute('BEGIN IMMEDIATE')
128 row = db.execute('SELECT value FROM mcp.records WHERE key=?', ('grant:' + request['grant'],)).fetchone()
129 grant = json.loads(row[0]) if row else {}
130 repository = request['repository']
131 if (grant.get('resource') != 'https://snowglobe.' + domain + '/mcp/shale'
132 or 'shale:write' not in grant.get('scopes', [])
133 or (grant.get('resources') != 'all' and repository not in grant.get('resources', []))):
134 raise Error(403, 'This connection cannot edit this repository. Update its repository selection.')
135 caller = db.execute('SELECT username,profile FROM auth.users WHERE id=?', (grant.get('user'),)).fetchone()
136 if not caller or json.loads(caller[1]).get('enabled') is not True or json.loads(caller[1]).get('kind') in ('guest', 'ai'):
137 raise Error(401, 'This connection is unavailable. Connect again.')
138 repo = db.execute('SELECT id,owner,access_issues,access_issues_submit,access_issues_comment FROM repositories WHERE name=?', (repository,)).fetchone()
139 native_caller = db.execute('SELECT id FROM users WHERE provider=? AND snowflake=?', ('snowglobe.' + domain, grant['user'])).fetchone()
140 if not repo or not native_caller or repo[2] == 'off':
141 raise Error(403, 'This Shale connection cannot edit this repository.')
142 policy = json.loads(Path(os.environ.get('STUDIO_SHALE_POLICY', str(Path(__file__).resolve().parent.parent / 'config/shale.json'))).read_text())
143 owner = policy.get('actorOwners', {}).get(caller[0], grant['user'])
144 issue = None if action == 'create_issue' or 'id' not in args else db.execute('SELECT id,status,author,title FROM issues WHERE repo=? AND number=?', (repo[0], args['id'])).fetchone()
145 if 'id' in args and not issue:
146 raise Error(404, 'That issue is unavailable. List this repository’s issues again.')
147 owns_repo = repo[1] == native_caller[0]
148 if not owns_repo:
149 if action == 'create_issue':
150 permitted = repo[2] == 'public' and repo[3] == 'public'
151 elif action == 'comment_issue':
152 permitted = repo[2] == 'public' and repo[4] == 'public'
153 elif action == 'set_issue_title':
154 author = db.execute("SELECT u.snowflake,json_extract(a.profile,'$.owner') FROM users u LEFT JOIN auth.users a ON a.id=u.snowflake WHERE u.id=? AND u.provider=?", (issue[2], 'snowglobe.' + domain)).fetchone()
155 permitted = repo[2] == 'public' and author and owner in author
156 else:
157 permitted = action == 'set_issue_status' and repository in policy.get('statusDelegates', {}).get(caller[0], [])
158 if not permitted:
159 raise Error(403, 'This connection cannot make that change in this repository.')
160 linked = db.execute('SELECT value FROM mcp.records WHERE key=?', ('shale-session:' + grant['user'],)).fetchone()
161 if not linked or json.loads(linked[0]).get('origin') != 'https://shale.' + domain + '/':
162 raise Error(401, 'This Shale connection is unavailable. Connect again.')
163 now = datetime.now(timezone.utc).isoformat(timespec='milliseconds')
164 actor, actor_name = actor_account(db, owner, args['model'], domain, now)
165 repo = repo[0]
166 if action in ('create_issue', 'comment_issue', 'set_issue_title'):
167 if action == 'create_issue':
168 number = db.execute('SELECT coalesce(max(number),0)+1 FROM issues WHERE repo=?', (repo,)).fetchone()[0]
169 issue_id = db.execute('INSERT INTO issues(uuid,repo,number,author,last_updated,title,status) VALUES(?,?,?,?,?,?,?)',
170 (identifier(), repo, number, actor, now, args['title'], args['status'])).lastrowid
171 kind, payload = 'comment', args['description']
172 else:
173 issue_id, number = issue[0], args['id']
174 kind, payload = ('comment', args['comment']) if action == 'comment_issue' else ('update_title', args['title'])
175 if action == 'set_issue_title' and issue[3] == payload:
176 return {'repository': repository, 'id': number, 'actor': '~' + actor_name}
177 if action == 'set_issue_title':
178 db.execute('UPDATE issues SET title=? WHERE id=?', (payload, issue_id))
179 db.execute('UPDATE issues SET last_updated=? WHERE id=?', (now, issue_id))
180 db.execute('INSERT INTO issue_actions(uuid,issue,actor,kind,payload,last_updated,added_on) VALUES(?,?,?,?,?,?,?)',
181 (identifier(), issue_id, actor, kind, payload, now, now))
182 db.execute("UPDATE repositories SET open_issues=(SELECT count(*) FROM issues WHERE repo=repositories.id AND status NOT IN ('done','not_planned','duplicate','invalid')),last_updated=? WHERE id=?", (now, repo))
183 return {'repository': repository, 'id': number, 'actor': '~' + actor_name}
184 if action in ('update_label', 'delete_label'):
185 if not db.execute('SELECT 1 FROM issue_labels WHERE repo=? AND id=?', (repo, args['label_id'])).fetchone():
186 raise Error(404, 'That label is unavailable in this repository. List its labels again.')
187 if action == 'delete_label' and (db.execute('SELECT 1 FROM issues__labels WHERE label=? LIMIT 1', (args['label_id'],)).fetchone()
188 or db.execute("SELECT 1 FROM issue_actions a JOIN issues i ON a.issue=i.id WHERE i.repo=? AND a.kind IN ('add_label','remove_label') AND a.payload=? LIMIT 1", (repo, str(args['label_id']))).fetchone()):
189 raise Error(409, 'This label is referenced by issue history. Rename it or clear its assignments instead.')
190 if action == 'set_issue_status':
191 if issue[1] != args['status']:
192 db.execute('INSERT INTO issue_actions(uuid,issue,actor,kind,payload,last_updated,added_on) VALUES(?,?,?,?,?,?,?)',
193 (identifier(), issue[0], actor, 'update_status', args['status'], now, now))
194 db.execute('UPDATE issues SET status=?,last_updated=? WHERE id=?', (args['status'], now, issue[0]))
195 update_label_counts(db, (row[0] for row in db.execute('SELECT label FROM issues__labels WHERE issue=?', (issue[0],))), now)
196 db.execute("UPDATE repositories SET open_issues=(SELECT count(*) FROM issues WHERE repo=repositories.id AND status NOT IN ('done','not_planned','duplicate','invalid')),last_updated=? WHERE id=?", (now, repo))
197 return {'repository': repository, 'id': args['id'], 'status': args['status'], 'actor': '~' + actor_name}
198 if action in ('create_label', 'update_label'):
199 if db.execute('SELECT 1 FROM issue_labels WHERE repo=? AND name=? AND id!=?', (repo, args['name'], args.get('label_id', 0))).fetchone():
200 raise Error(409, 'A label already has that name. Choose another name.')
201 if action == 'create_label':
202 label_id = db.execute('INSERT INTO issue_labels(uuid,repo,name,description,color,last_updated) VALUES(?,?,?,?,?,?)',
203 (identifier(), repo, args['name'], args['description'], args['color'], now)).lastrowid
204 else:
205 label_id = args['label_id']
206 db.execute('UPDATE issue_labels SET name=?,description=?,color=?,last_updated=? WHERE id=?',
207 (args['name'], args['description'], args['color'], now, label_id))
208 elif action == 'delete_label':
209 db.execute('DELETE FROM issue_labels WHERE id=?', (args['label_id'],))
210 else:
211 desired = set(args['labels'])
212 available = {r[0] for r in db.execute('SELECT id FROM issue_labels WHERE repo=?', (repo,))}
213 if not desired <= available:
214 raise Error(400, 'Choose labels belonging to this repository. List its labels again.')
215 issue_id = issue[0]
216 current = {r[0] for r in db.execute('SELECT label FROM issues__labels WHERE issue=?', (issue_id,))}
217 affected = current ^ desired
218 for kind, labels in (('remove_label', current - desired), ('add_label', desired - current)):
219 for label in sorted(labels):
220 if kind == 'add_label':
221 db.execute('INSERT INTO issues__labels(uuid,issue,label) VALUES(?,?,?)', (identifier(), issue_id, label))
222 else:
223 db.execute('DELETE FROM issues__labels WHERE issue=? AND label=?', (issue_id, label))
224 db.execute('INSERT INTO issue_actions(uuid,issue,actor,kind,payload,last_updated,added_on) VALUES(?,?,?,?,?,?,?)',
225 (identifier(), issue_id, actor, kind, str(label), now, now))
226 if current != desired:
227 db.execute('UPDATE issues SET last_updated=? WHERE id=?', (now, issue_id))
228 update_label_counts(db, affected, now)
229 if action != 'set_issue_labels' or affected:
230 db.execute('UPDATE repositories SET last_updated=? WHERE id=?', (now, repo))
231 result = {'repository': repository, 'actor': '~' + actor_name}
232 if action == 'set_issue_labels':
233 result.update(id=args['id'], labels=args['labels'])
234 elif action == 'delete_label':
235 result.update(deleted=args['label_id'])
236 else:
237 result['label'] = dict(db.execute('SELECT id,name,description,color,open_issues AS openIssues FROM issue_labels WHERE id=?', (label_id,)).fetchone())
238 return result