From 9c3463012eb3fe07f9628e4e35abce6c80fd5355 Mon Sep 17 00:00:00 2001 From: clover caruso Date: Tue, 6 Oct 2026 23:11:12 -0700 Subject: [PATCH] Attribute Shale writes to model accounts and link verified commit identities --- config/shale.json | 1 + dashboard/Cargo.lock | 365 ++++++++++++++++++++- dashboard/Cargo.toml | 2 + dashboard/src/auth.rs | 5 + dashboard/src/bot.svg | 1 + dashboard/src/claude.svg | 1 + dashboard/src/host-protocol.json | 1 + dashboard/src/host.rs | 8 +- dashboard/src/identity.rs | 133 ++++++++ dashboard/src/main.rs | 10 +- dashboard/src/openai.svg | 1 + dashboard/src/shale.rs | 317 +++++++----------- dashboard/src/shale_page.rs | 381 +++++++++++++++++++++- dashboard/src/users.rs | 74 ++++- dashboard/web/api.contract.ts | 25 +- dashboard/web/pages/Account.css | 12 + dashboard/web/pages/Account.tsx | 85 ++++- dashboard/web/pages/Users.tsx | 9 + dashboard/web/types/users.ts | 5 + nixos/configuration.nix | 8 +- service/shale/service.pkl | 4 +- tools/dashboard-auth-test.py | 37 ++- tools/dashboard-host-test.py | 38 +++ tools/dashboard-host.py | 24 +- tools/dashboard-shale-attribution-test.py | 224 +++++++++++++ tools/dashboard-shale-link-test.py | 63 ++-- tools/dashboard-shale-page-test.py | 3 +- tools/shale-labels-test.py | 189 +++++++++++ tools/shale-labels.py | 238 ++++++++++++++ 29 files changed, 1996 insertions(+), 268 deletions(-) create mode 100644 config/shale.json create mode 100644 dashboard/src/bot.svg create mode 100644 dashboard/src/claude.svg create mode 100644 dashboard/src/host-protocol.json create mode 100644 dashboard/src/identity.rs create mode 100644 dashboard/src/openai.svg create mode 100644 tools/dashboard-shale-attribution-test.py create mode 100644 tools/shale-labels-test.py create mode 100644 tools/shale-labels.py diff --git a/config/shale.json b/config/shale.json new file mode 100644 index 0000000000000000000000000000000000000000..8669e03b5264e833420068132ba418e91f9287cc --- /dev/null +++ b/config/shale.json @@ -0,0 +1 @@ +{"actorOwners":{"ai":"8c909fab-98b0-4472-9171-9606ccebe9d5"},"statusDelegates":{"ai":["chat","snowbound"]}} diff --git a/dashboard/Cargo.lock b/dashboard/Cargo.lock index 9500fd93e90dd7b9b479cec57fd5bb74fa9d0ff4..9d75672dd53f3feee77ed3ceb86f8f43046a1d50 100644 --- a/dashboard/Cargo.lock +++ b/dashboard/Cargo.lock @@ -156,6 +156,12 @@ dependencies = [ "tracing", ] +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64" version = "0.21.7" @@ -284,6 +290,22 @@ dependencies = [ "windows-link", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -320,6 +342,18 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -382,6 +416,16 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + [[package]] name = "der-parser" version = "9.0.0" @@ -430,6 +474,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", + "const-oid", "crypto-common", "subtle", ] @@ -466,12 +511,45 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + [[package]] name = "ego-tree" version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b04dc5a38e4f151a79d9f2451ae6037fb6eaf5cba34771f44781f80e508498e3" +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "encoding_rs" version = "0.8.42" @@ -520,6 +598,16 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "find-msvc-tools" version = "0.1.14" @@ -658,6 +746,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -712,6 +801,17 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "half" version = "2.7.1" @@ -758,6 +858,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "home-dashboard" version = "0.1.0" @@ -771,6 +880,7 @@ dependencies = [ "globset", "lol_html", "openssl", + "percent-encoding", "rand 0.9.5", "regex", "reqwest", @@ -782,6 +892,7 @@ dependencies = [ "serde_json", "sha1", "sha2", + "ssh-key", "subtle", "tokio", "tokio-stream", @@ -1054,6 +1165,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "ipnet" version = "2.12.2" @@ -1082,6 +1202,9 @@ name = "lazy_static" version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" +dependencies = [ + "spin", +] [[package]] name = "libc" @@ -1089,6 +1212,12 @@ version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "libsqlite3-sys" version = "0.35.0" @@ -1251,6 +1380,22 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.8", + "smallvec", + "zeroize", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -1266,6 +1411,16 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1273,6 +1428,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -1327,6 +1483,44 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p521" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2" +dependencies = [ + "base16ct", + "ecdsa", + "elliptic-curve", + "primeorder", + "rand_core 0.6.4", + "sha2", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -1373,6 +1567,15 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1438,6 +1641,27 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.34" @@ -1474,6 +1698,15 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -1560,13 +1793,23 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + [[package]] name = "rand" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ - "rand_chacha", + "rand_chacha 0.9.0", "rand_core 0.9.5", ] @@ -1581,6 +1824,16 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + [[package]] name = "rand_chacha" version = "0.9.0" @@ -1596,6 +1849,9 @@ name = "rand_core" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] [[package]] name = "rand_core" @@ -1721,6 +1977,16 @@ dependencies = [ "webpki-roots", ] +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + [[package]] name = "ring" version = "0.17.14" @@ -1766,6 +2032,27 @@ dependencies = [ "uuid", ] +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "sha2", + "signature", + "spki", + "subtle", + "zeroize", +] + [[package]] name = "rusqlite" version = "0.37.0" @@ -1906,6 +2193,20 @@ dependencies = [ "tendril", ] +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + [[package]] name = "selectors" version = "0.37.0" @@ -2084,6 +2385,16 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + [[package]] name = "simdutf8" version = "0.1.5" @@ -2124,6 +2435,16 @@ version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "sse-stream" version = "0.2.6" @@ -2137,6 +2458,46 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "ssh-cipher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caac132742f0d33c3af65bfcde7f6aa8f62f0e991d80db99149eb9d44708784f" +dependencies = [ + "cipher", + "ssh-encoding", +] + +[[package]] +name = "ssh-encoding" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb9242b9ef4108a78e8cd1a2c98e193ef372437f8c22be363075233321dd4a15" +dependencies = [ + "base64ct", + "pem-rfc7468", + "sha2", +] + +[[package]] +name = "ssh-key" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b86f5297f0f04d08cabaa0f6bff7cb6aec4d9c3b49d87990d63da9d9156a8c3" +dependencies = [ + "p256", + "p384", + "p521", + "rsa", + "sec1", + "sha2", + "signature", + "ssh-cipher", + "ssh-encoding", + "subtle", + "zeroize", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -2750,7 +3111,7 @@ dependencies = [ "openssl", "openssl-sys", "rand 0.9.5", - "rand_chacha", + "rand_chacha 0.9.0", "serde", "serde_cbor_2", "serde_json", diff --git a/dashboard/Cargo.toml b/dashboard/Cargo.toml index 14be074d6b72e34aa060aba83fb4fb170f3e2713..71de4bf47d1af4d90566169b91afb2623f2ace12 100644 --- a/dashboard/Cargo.toml +++ b/dashboard/Cargo.toml @@ -12,6 +12,7 @@ chrono = "0.4" futures = "0.3" globset = "0.4" openssl = "0.10" +percent-encoding = "2" rand = "0.9" lol_html = "3" regex = "1" @@ -24,6 +25,7 @@ scraper = { version = "0.27", default-features = false } serde_json = "1" sha1 = "0.10" sha2 = "0.10.9" +ssh-key = { version = "0.6", default-features = false, features = ["std", "ecdsa"] } subtle = "2.6" tokio = { version = "1", features = ["full"] } tokio-stream = { version = "0.1", features = ["sync"] } diff --git a/dashboard/src/auth.rs b/dashboard/src/auth.rs index 8a7209c54e9f689e59f26bfabf0e4240dcddb874..71f5b04d9839d0acbfa52afdf1bbe25e81371bc3 100644 --- a/dashboard/src/auth.rs +++ b/dashboard/src/auth.rs @@ -115,6 +115,10 @@ pub fn save_user(db: &Connection, id: &str, mut profile: Value) -> Result<()> { ] { profile.as_object_mut().unwrap().remove(key); } + if profile["emailVerified"] == true { + 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))?; + if conflict { return Err(Error::new(409, "This email belongs to another account. Ask an admin to check its owner.")); } + } db.execute( "UPDATE users SET profile=? WHERE id=?", sql![profile.to_string(), id], @@ -188,6 +192,7 @@ impl Store { CREATE TABLE IF NOT EXISTS pending (hash TEXT PRIMARY KEY,kind TEXT NOT NULL,data TEXT NOT NULL,expires INTEGER NOT NULL); CREATE TABLE IF NOT EXISTS migration (digest TEXT PRIMARY KEY); CREATE TABLE IF NOT EXISTS attempts (key TEXT PRIMARY KEY,count INTEGER NOT NULL,expires INTEGER NOT NULL);")?; + identity::initialise(&db)?; oidc::initialise(&db)?; guest::initialise(&db)?; for name in GROUPS { diff --git a/dashboard/src/bot.svg b/dashboard/src/bot.svg new file mode 100644 index 0000000000000000000000000000000000000000..a4b5884276aae8e02a397f1421d856b94c88cfa4 --- /dev/null +++ b/dashboard/src/bot.svg @@ -0,0 +1 @@ + diff --git a/dashboard/src/claude.svg b/dashboard/src/claude.svg new file mode 100644 index 0000000000000000000000000000000000000000..82c8cada422d89069521b4ce444d273054b170a4 --- /dev/null +++ b/dashboard/src/claude.svg @@ -0,0 +1 @@ + diff --git a/dashboard/src/host-protocol.json b/dashboard/src/host-protocol.json new file mode 100644 index 0000000000000000000000000000000000000000..b7460d92de0bbd6802c1da79f6228bdb25e29085 --- /dev/null +++ b/dashboard/src/host-protocol.json @@ -0,0 +1 @@ +{"request":2097152,"response":16777216} diff --git a/dashboard/src/host.rs b/dashboard/src/host.rs index b7a31e59659b59c36b62e38932d42dca1fb29e11..2dca2204abcca8d6365d8644db0ad282a147ccd0 100644 --- a/dashboard/src/host.rs +++ b/dashboard/src/host.rs @@ -1,6 +1,10 @@ use crate::*; use tokio::io::{AsyncReadExt, AsyncWriteExt}; +#[derive(serde::Deserialize)] +struct Limits { request: usize, response: usize } +static LIMITS: std::sync::LazyLock = std::sync::LazyLock::new(|| serde_json::from_str(include_str!("host-protocol.json")).expect("host protocol limits")); + static SLOTS: tokio::sync::Semaphore = tokio::sync::Semaphore::const_new(4); pub async fn sample(app: Arc) -> Result> { @@ -48,7 +52,7 @@ pub async fn open(request: Value) -> Result<(Value, tokio::net::UnixStream)> { } let mut message = serde_json::to_vec(&request)?; message.push(b'\n'); - if message.len() > 65536 { + if message.len() > LIMITS.request { return Err(Error::new( 400, "The host request is too large. Narrow the selection.", @@ -64,7 +68,7 @@ pub async fn open(request: Value) -> Result<(Value, tokio::net::UnixStream)> { pub async fn response(socket: &mut tokio::net::UnixStream) -> Result { let length = socket.read_u32().await? as usize; - if length > 16 * 1024 * 1024 { + if length > LIMITS.response { return Err(Error::new( 502, "The host response is too large. Narrow the selection.", diff --git a/dashboard/src/identity.rs b/dashboard/src/identity.rs new file mode 100644 index 0000000000000000000000000000000000000000..9a9aab97052a71dac149d27107cd121534754ebb --- /dev/null +++ b/dashboard/src/identity.rs @@ -0,0 +1,133 @@ +use crate::*; +use rusqlite::{Connection, OptionalExtension, params as sql}; +use ssh_key::{HashAlg, PublicKey}; + +pub fn initialise(db: &Connection) -> Result<()> { + 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)); + CREATE UNIQUE INDEX IF NOT EXISTS confirmed_commit_email ON commit_emails(email) WHERE verified=1; + 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)); + CREATE UNIQUE INDEX IF NOT EXISTS confirmed_signing_key ON signing_keys(fingerprint) WHERE verified=1;")?; + Ok(()) +} + +pub fn email(value: &Value) -> Result { + let email = string(value).trim().to_lowercase(); + if email.len() > 254 || email.is_empty() || email.chars().any(char::is_whitespace) + || email.chars().any(char::is_control) || email.split('@').count() != 2 + || email.starts_with('@') || email.ends_with('@') { + return Err(Error::new(400, "Enter a full email address.")); + } + Ok(email) +} + +pub fn add_email(db: &Connection, id: &str, value: &Value, verified: bool) -> Result<()> { + let email = email(value)?; + let primary: Option = 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()?; + let owner: Option = db.query_row("SELECT user_id FROM commit_emails WHERE email=? AND verified=1", [&email], |r| r.get(0)).optional()?; + 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))?; + if primary.as_deref().is_some_and(|owner| owner != id) || owner.as_deref().is_some_and(|owner| owner != id) { + return Err(Error::new(409, "This email belongs to another account. Ask an admin to check its owner.")); + } + let count: i64 = db.query_row("SELECT count(*) FROM commit_emails WHERE user_id=?", [id], |r| r.get(0))?; + if !exists && count >= 64 { return Err(Error::new(400, "Remove an email before adding another.")); } + db.execute("INSERT INTO commit_emails VALUES (?,?,?) ON CONFLICT(user_id,email) DO UPDATE SET verified=max(verified,excluded.verified)", sql![email,id,verified])?; + Ok(()) +} + +pub fn add_key(db: &Connection, id: &str, body: &Value, verified: bool) -> Result<()> { + let source = string(&body["publicKey"]).trim(); + let label = string(&body["label"]).trim(); + if source.len() > 16384 || source.contains(['\n','\r']) || label.len() > 128 || label.chars().any(char::is_control) { + return Err(Error::new(400, "Paste one SSH public key and use a label under 128 characters.")); + } + let mut key = PublicKey::from_openssh(source).map_err(|_| Error::new(400, "Paste an SSH public key, such as an ssh-ed25519 key."))?; + if key.algorithm().as_str() == "ssh-dss" { return Err(Error::new(400, "DSA keys are obsolete. Use an Ed25519, RSA, or ECDSA public key.")); } + let fingerprint = key.fingerprint(HashAlg::Sha256).to_string(); + let label = if label.is_empty() { key.comment() } else { label }.to_owned(); + if label.len() > 128 || label.chars().any(char::is_control) { return Err(Error::new(400, "Use a key label under 128 characters.")); } + key.set_comment(""); + let public_key = key.to_openssh().map_err(|_| Error::new(400, "Paste an SSH public key."))?; + let owner: Option = db.query_row("SELECT user_id FROM signing_keys WHERE fingerprint=? AND verified=1", [&fingerprint], |r| r.get(0)).optional()?; + 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.")); } + let count: i64 = db.query_row("SELECT count(*) FROM signing_keys WHERE user_id=?", [id], |r| r.get(0))?; + 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))?; + if !exists && count >= 64 { return Err(Error::new(400, "Remove a signing key before adding another.")); } + 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])?; + Ok(()) +} + +pub fn confirm_key(db: &Connection, id: &str, fingerprint: &str) -> Result<()> { + let body: Option = 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()?; + add_key(db,id,&body.ok_or_else(|| Error::new(404,"Add this signing key before confirming ownership."))?,true) +} + +pub fn account(db: &Connection, id: &str) -> Result { + let mut emails = db.prepare("SELECT email,verified FROM commit_emails WHERE user_id=? ORDER BY email")?; + let emails = emails.query_map([id], |r| Ok(json!({"email":r.get::<_,String>(0)?,"verified":r.get::<_,bool>(1)?})))?.collect::,_>>()?; + let mut keys = db.prepare("SELECT fingerprint,label,public_key,created,verified FROM signing_keys WHERE user_id=? ORDER BY created,fingerprint")?; + 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::,_>>()?; + Ok(json!({"emails":emails,"signingKeys":keys})) +} + +pub fn import(auth: &auth::Store, input: &Value) -> Result { + let mut db = auth.db.lock().unwrap(); + let tx = db.transaction()?; + 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."))?; + if !input["emails"].is_array() || !input["signingKeys"].is_array() { return Err(Error::new(400,"Supply email and signing key lists.")); } + for email in array(&input["emails"]) { add_email(&tx,&id,email,true)?; } + for key in array(&input["signingKeys"]) { add_key(&tx,&id,key,true)?; } + let result = account(&tx,&id)?; + tx.commit()?; + Ok(result) +} + +#[cfg(test)] +mod tests { + use super::*; + fn store() -> Connection { + let db = Connection::open_in_memory().unwrap(); + 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(); + initialise(&db).unwrap(); + db + } + #[test] + fn unconfirmed_key_claim_cannot_block_its_owner() { + let db = store(); + let body = json!({"publicKey":"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMpxNpkRLTUijhd3HSaOvKYn2MWXEY+YEFdsPNZhBROn"}); + add_key(&db,"two",&body,false).unwrap(); + assert_eq!(account(&db,"two").unwrap()["signingKeys"][0]["verified"],false); + add_key(&db,"one",&body,true).unwrap(); + let fingerprint = account(&db,"two").unwrap()["signingKeys"][0]["fingerprint"].as_str().unwrap().to_owned(); + assert!(confirm_key(&db,"two",&fingerprint).is_err()); + assert_eq!(account(&db,"two").unwrap()["signingKeys"][0]["verified"],false); + } + + #[test] + fn confirmation_cannot_steal_an_alias_or_verified_primary() { + let db = store(); + add_email(&db,"one",&json!(" Alias@Example.test "),false).unwrap(); + add_email(&db,"two",&json!("alias@example.test"),false).unwrap(); + add_email(&db,"one",&json!("alias@example.test"),true).unwrap(); + assert!(add_email(&db,"two",&json!("alias@example.test"),true).is_err()); + assert!(add_email(&db,"two",&json!("primary@example.test"),false).is_err()); + assert_eq!(account(&db,"one").unwrap()["emails"][0],json!({"email":"alias@example.test","verified":true})); + for value in ["@host", "name@", "a@b@c", "a b@c", "a\nb@c"] { assert!(email(&json!(value)).is_err()); } + db.execute("DELETE FROM users WHERE id='one'",[]).unwrap(); + add_email(&db,"two",&json!("alias@example.test"),true).unwrap(); + } + #[test] + fn signing_keys_deduplicate_material_and_cannot_change_owner() { + let db = store(); + let key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMpxNpkRLTUijhd3HSaOvKYn2MWXEY+YEFdsPNZhBROn"; + add_key(&db,"one",&json!({"publicKey":format!("{key} device"),"label":"first"}),true).unwrap(); + add_key(&db,"one",&json!({"publicKey":format!("{key} different comment"),"label":"second"}),true).unwrap(); + assert!(add_key(&db,"two",&json!({"publicKey":key}),true).is_err()); + let keys = account(&db,"one").unwrap()["signingKeys"].clone(); + assert_eq!(keys.as_array().unwrap().len(),1); + assert_eq!(keys[0]["label"],"first"); + assert_eq!(keys[0]["publicKey"],key); + for key in ["garbage", "ssh-ed25519 AAAA", "-----BEGIN OPENSSH PRIVATE KEY-----", "command=\"sh\" ssh-ed25519 AAAA"] { + assert!(add_key(&db,"one",&json!({"publicKey":key}),true).is_err()); + } + } +} diff --git a/dashboard/src/main.rs b/dashboard/src/main.rs index 83b5de2fd3396d4ead4003d11e9190dc276ad751..d7ee80ea169098f0a93b4cc7723b3d60d21a07f9 100644 --- a/dashboard/src/main.rs +++ b/dashboard/src/main.rs @@ -1,6 +1,7 @@ mod ai; mod apps; mod auth; +mod identity; mod cache; mod core; mod deploys; @@ -451,6 +452,12 @@ async fn main() -> std::result::Result<(), Box> { ) .map_err(|error| std::io::Error::other(error.message))?; if let Some(path) = std::env::args().skip(1).next() { + if path == "--import-commit-identity" { + let input = serde_json::from_reader(std::io::stdin())?; + let output = identity::import(&auth, &input).map_err(|error| std::io::Error::other(error.message))?; + println!("{output}"); + return Ok(()); + } if path == "--guest-provider" { let input = serde_json::from_reader(std::io::stdin())?; let output = guest::provision(&auth, input) @@ -578,7 +585,8 @@ async fn main() -> std::result::Result<(), Box> { return next.run(request).await; } let path = request.uri().path().to_owned(); - let asset = path.starts_with("/assets/") + let asset = path.starts_with("/api/account/pictures/") + || path.starts_with("/assets/") || path.starts_with("/fonts/") || path == "/snowflake.svg"; if app.auth.ready() diff --git a/dashboard/src/openai.svg b/dashboard/src/openai.svg new file mode 100644 index 0000000000000000000000000000000000000000..83fb09492271102a13f9956797139bb2a7944b3e --- /dev/null +++ b/dashboard/src/openai.svg @@ -0,0 +1 @@ + diff --git a/dashboard/src/shale.rs b/dashboard/src/shale.rs index 304e3424be9de055fe2a21a932fa2642e437a633..ea74720598d1c52c21384084477a2e86f722de9b 100644 --- a/dashboard/src/shale.rs +++ b/dashboard/src/shale.rs @@ -2,55 +2,16 @@ use crate::*; use rmcp::{ ErrorData, RoleServer, ServerHandler, model::{ - CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, ListToolsResult, - PaginatedRequestParams, ServerCapabilities, ServerConfig, Tool, ToolAnnotations, + CacheScope, CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, + ListToolsResult, PaginatedRequestParams, ServerCapabilities, ServerConfig, Tool, + ToolAnnotations, }, service::RequestContext, }; use scraper::{Html, Selector}; -/// r1758 rotates CSRF tokens per deletion form; the final token also works on r1763. -fn issue_csrf(document: &Html) -> Result> { - let forms = Selector::parse("ul.timeline li.comment form[method=post]").unwrap(); - let kind = Selector::parse("input[name=t]").unwrap(); - let id = Selector::parse("input[name=id]").unwrap(); - let token = - Selector::parse("input[type=hidden][name=csrf_token], input[hidden][name=csrf_token]") - .unwrap(); - let last = document - .select(&forms) - .filter(|form| { - form.select(&kind) - .any(|input| input.attr("value") == Some("delete")) - && form.select(&id).any(|input| { - input - .attr("value") - .is_some_and(|value| value.parse::().is_ok_and(|id| id > 0)) - }) - }) - .last(); - let Some(form) = last else { return Ok(None) }; - let tokens: Vec<_> = form.select(&token).collect(); - match tokens.as_slice() { - [input] => input - .attr("value") - .filter(|value| !value.is_empty()) - .map(|value| Some(value.to_owned())) - .ok_or_else(|| { - Error::new( - 502, - "Shale's issue form changed. Open the issue to edit it.", - ) - }), - _ => Err(Error::new( - 502, - "Shale's issue form changed. Open the issue to edit it.", - )), - } -} +const MODEL_PATTERN: &str = r"^[a-z0-9][a-z0-9._-]{0,127}$"; -/// The verified r1616 build predates tokenized forms. Its cookie mutations are -/// protected by the service's exact-Origin gate; never infer this from a missing token alone. fn tokenless_r1616(document: &Html) -> bool { if document .select(&Selector::parse("input[name=csrf_token]").unwrap()) @@ -63,24 +24,6 @@ fn tokenless_r1616(document: &Html) -> bool { matches!(links.as_slice(), [link] if text(*link) == "shale r1616-ga87d2f5.zig.0.16.0") } -fn prepare_issue_csrf(document: &Html, fields: &mut HashMap) -> Result<()> { - if tokenless_r1616(document) && !fields.contains_key("csrf_token") { - return Ok(()); - } - if fields - .get("csrf_token") - .is_none_or(|token| token.is_empty()) - { - return Err(Error::new( - 502, - "Shale's issue form changed. Open the issue to edit it.", - )); - } - if let Some(token) = issue_csrf(document)? { - fields.insert("csrf_token".to_owned(), token); - } - Ok(()) -} pub struct Backend { pub(crate) origin: url::Url, @@ -373,7 +316,7 @@ fn issue(html: &str, repository: &str, id: Option) -> Result { ) })?; let author = comment - .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()) + .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()) .next() .map(text); let time = comment @@ -392,6 +335,34 @@ fn issue(html: &str, repository: &str, id: Option) -> Result { json!({"repository":repository,"id":issue_id,"title":title.unwrap(),"status":status,"labels":labels,"comments":comments}), ) } +fn labels(html: &str, repository: &str) -> Result { + let document = document(html, "page-labels", Some(repository))?; + let mut labels = Vec::new(); + for row in document.select(&Selector::parse(".grid-container table tbody tr").unwrap()) { + let cells: Vec<_> = row.select(&Selector::parse("td").unwrap()).collect(); + let label = row + .select(&Selector::parse("a.issuelabel[data-label-id][data-color]").unwrap()) + .next(); + if cells.len() != 3 || label.is_none() { + return Err(Error::new( + 502, + "Shale's label list changed. Open its labels page.", + )); + } + let label = label.unwrap(); + let id = label + .attr("data-label-id") + .and_then(|s| s.parse::().ok()) + .filter(|id| *id > 0) + .ok_or_else(|| Error::new(502, "Shale's label IDs changed. Open its labels page."))?; + let open = text(cells[2]) + .parse::() + .map_err(|_| Error::new(502, "Shale's label counts changed. Open its labels page."))?; + labels.push(json!({"id":id,"name":text(label),"description":text(cells[1]),"color":label.attr("data-color"),"openIssues":open})); + } + Ok(json!({"repository":repository,"labels":labels})) +} + fn current(app: &App, grant: &Value, credential: &str) -> Result<()> { let db = app.mcp.db.lock().unwrap(); if mcp::get(&db, &format!("grant:{}", string(&grant["id"])))? != *grant @@ -411,6 +382,7 @@ struct Shale(Arc); impl ServerHandler for Shale { fn get_info(&self) -> ServerConfig { ServerConfig::new(ServerCapabilities::builder().enable_tools().build()) + .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.") } async fn list_tools( &self, @@ -420,17 +392,30 @@ impl ServerHandler for Shale { let tools = [ ("list_repositories", "List repositories granted to this connection.", json!({}), json!([]), true), ("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), + ("list_labels", "List repository label IDs, names, descriptions, colors, and open issue counts.", json!({"repository":{"type":"string"}}), json!(["repository"]), true), + ("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), + ("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), + ("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), + ("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), ("get_issue", "Read an issue with rendered comment text and labels.", json!({"repository":{"type":"string"},"id":{"type":"integer","minimum":1}}), json!(["repository","id"]), true), - ("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), + ("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), ("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), ("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), ("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), ].into_iter().map(|(name, description, properties, required, read)| { + let mut properties = properties; + let mut required = required; + if !read { + properties["model"] = json!({"type":"string","pattern":MODEL_PATTERN,"description":"Actual model identifier; determines the durable author account."}); + required.as_array_mut().unwrap().push(json!("model")); + } Tool::new(name, description, json!({"type":"object","properties":properties,"required":required,"additionalProperties":false}).as_object().unwrap().clone()) .with_annotations(ToolAnnotations::new().read_only(read).idempotent(read)) }).collect(); Ok(ListToolsResult { tools, + ttl_ms: Some(0), + cache_scope: Some(CacheScope::Private), ..Default::default() }) } @@ -448,17 +433,28 @@ impl ServerHandler for Shale { let arguments = request.arguments.unwrap_or_default(); let allowed: &[&str] = match name { "list_repositories" => &[], "list_issues" => &["repository", "q"], "get_issue" => &["repository", "id"], - "create_issue" => &["repository", "title", "description"], "comment_issue" => &["repository", "id", "comment"], + "list_labels" => &["repository"], "create_label" => &["repository", "name", "description", "color"], + "update_label" => &["repository", "label_id", "name", "description", "color"], "delete_label" => &["repository", "label_id"], + "set_issue_labels" => &["repository", "id", "labels"], + "create_issue" => &["repository", "title", "description", "status"], "comment_issue" => &["repository", "id", "comment"], "set_issue_status" => &["repository", "id", "status"], "set_issue_title" => &["repository", "id", "title"], _ => return Err(Error::new(404, "No tool with that name.")), }; - if arguments.keys().any(|key| !allowed.contains(&key.as_str())) { + if arguments.keys().any(|key| key != "model" && !allowed.contains(&key.as_str())) { return Err(Error::new(400, "Use the fields listed for this tool.")); } - let write = !matches!(name, "list_repositories" | "list_issues" | "get_issue"); + let write = !matches!(name, "list_repositories" | "list_issues" | "list_labels" | "get_issue"); if !array(&grant["scopes"]).iter().any(|s| s == if write {"shale:write"} else {"shale:read"}) { return Err(Error::new(403, "This connection allows reads only. Connect again to request issue editing.")); } + let model = if write { + Some(arguments.get("model").and_then(Value::as_str) + .filter(|id| regex::Regex::new(MODEL_PATTERN).unwrap().is_match(id)) + .ok_or_else(|| Error::new(400, "Supply the actual model identifier in model."))?) + } else { + if arguments.contains_key("model") { return Err(Error::new(400, "Read tools do not need a model.")); } + None + }; let credential = session(app, string(&grant["user"]))?; current(app, grant, &credential)?; if name == "list_repositories" { @@ -470,6 +466,20 @@ impl ServerHandler for Shale { let repository = arguments.get("repository").and_then(Value::as_str) .filter(|r| grant["resources"] == "all" || array(&grant["resources"]).iter().any(|id| id == *r)) .ok_or_else(|| Error::new(403, "Choose a repository granted to this connection."))?; + if matches!(name, "create_label" | "update_label" | "delete_label" | "set_issue_labels") { + let mut fields = arguments.clone(); + fields.remove("repository"); + current(app, grant, &credential)?; + 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 })?; + current(app, grant, &credential).map_err(|_| Error::new(502, "The label write outcome is unknown. Check the labels in Shale before retrying."))?; + return Ok(result); + } + if name == "list_labels" { + let target = repository_path(&app.shale.origin, repository, &["issues", "labels"])?; + let result = labels(&app.shale.page(&target, &credential).await?, repository)?; + current(app, grant, &credential)?; + return Ok(result); + } let mut target = repository_path(&app.shale.origin, repository, &["issues", ""])?; let issue_id = if matches!(name, "list_issues" | "create_issue") { None } else { Some(arguments.get("id").and_then(Value::as_u64).filter(|n| *n > 0) @@ -483,7 +493,7 @@ impl ServerHandler for Shale { } let mut fields = HashMap::new(); for (key, max) in [("title", 4096), ("description", 262144), ("comment", 262144), ("status", 64)] { - if allowed.contains(&key) { + if allowed.contains(&key) && !(key == "status" && name == "create_issue") { let value = arguments.get(key).and_then(Value::as_str) .filter(|v| v.len() <= max && (key == "description" || !v.trim().is_empty())) .or_else(|| (key == "description" && !arguments.contains_key(key)).then_some("")) @@ -491,6 +501,18 @@ impl ServerHandler for Shale { fields.insert(key.to_owned(), value.to_owned()); } } + if matches!(name, "create_issue" | "comment_issue") { + let model = model.unwrap(); + let field = if name == "create_issue" { "description" } else { "comment" }; + let body = fields.get_mut(field).unwrap(); + if let Some(existing) = crate::shale_page::assisted_model(body) { + if existing != model { return Err(Error::new(400, "The model and Assisted-by trailer differ. Use the actual model identifier in both.")); } + } else { + *body = format!("{}\n\nAssisted-by: {model}", body.trim_end()); + if crate::shale_page::assisted_model(body) != Some(model) { return Err(Error::new(400, "Close the code block before adding the model trailer.")); } + if body.len() > 262144 { return Err(Error::new(400, "Shorten the comment to leave room for its Assisted-by trailer.")); } + } + } username(&app.shale.page(&app.shale.origin.join("/-/settings")?, &credential).await?)?; let body = app.shale.page(&target, &credential).await?; if name == "list_issues" { @@ -517,66 +539,27 @@ impl ServerHandler for Shale { current(app, grant, &credential)?; return Ok(json!({"issue":issue})); } - let form_body = if name == "set_issue_title" { - issue(&body, repository, issue_id)?; - let mut edit = target.clone(); edit.query_pairs_mut().append_pair("edit", "title"); - app.shale.page(&edit, &credential).await? - } else { body }; - let post_target = { - let document = if name == "set_issue_title" { Html::parse_fragment(&form_body) } else { - document(&form_body, if name == "create_issue" {"page-new-issue"} else {"page-issue"}, Some(repository))? + if name == "create_issue" { + document(&body, "page-new-issue", Some(repository))?; + let status = match arguments.get("status").and_then(Value::as_str) { + None if !arguments.contains_key("status") => "needs_triage", + Some("triage") => "needs_triage", + Some("todo") => "todo", + _ => return Err(Error::new(400, "Choose todo or triage for a new issue.")), }; - let forms: Vec<_> = document.select(&Selector::parse("form[method=post]").unwrap()) - .filter(|form| if name == "create_issue" { form.select(&Selector::parse("input[name=title]").unwrap()).next().is_some() } - else { form.select(&Selector::parse("input[name=t]").unwrap()).any(|input| input.attr("value") == Some(match name { - "comment_issue" => "comment", "set_issue_status" => "status", _ => "title", - })) }).collect(); - if forms.len() != 1 { return Err(Error::new(502, "Shale's issue form changed. Open the issue to edit it.")); } - let form = forms[0]; - let post_target = target.join(form.attr("action").unwrap_or_default())?; - if post_target != target || form.attr("enctype").is_some_and(|s| s != "application/x-www-form-urlencoded") { - return Err(Error::new(502, "Shale's form destination changed. Open the issue to edit it.")); - } - if name == "set_issue_status" && !form.select(&Selector::parse("select[name=status] option").unwrap()) - .any(|option| option.attr("value") == fields.get("status").map(String::as_str)) { - return Err(Error::new(400, "Choose a status available on this issue in Shale.")); - } - for field in form.select(&Selector::parse("input[type=hidden], input[hidden]").unwrap()) { - if let Some(name) = field.attr("name") { - if fields.insert(name.to_owned(), field.attr("value").unwrap_or_default().to_owned()).is_some() { - return Err(Error::new(502, "Shale's form fields changed. Open the issue to edit it.")); - } - } - } - if matches!(name, "comment_issue" | "set_issue_status") { - prepare_issue_csrf(&document, &mut fields)?; - } - fields.insert("timezone".to_owned(), "UTC".to_owned()); - fields.insert("tzoffset".to_owned(), "+00:00".to_owned()); - post_target - }; + fields.insert("status".into(), status.into()); + } else { issue(&body, repository, issue_id)?; } + let mut fields = serde_json::to_value(fields)?; + if let Some(id) = issue_id { fields["id"] = json!(id); } + fields["model"] = json!(model.unwrap()); current(app, grant, &credential)?; - let outcome: Result = async { - let (status, headers, body) = app.shale.request(Method::POST, post_target.clone(), Some(&credential), Some(&fields)).await?; - let body = if matches!(status, StatusCode::SEE_OTHER | StatusCode::FOUND) { - let target = headers.get("location").and_then(|v| v.to_str().ok()) - .and_then(|location| post_target.join(location).ok()) - .ok_or_else(|| Error::new(502, "Shale didn't return an issue destination."))?; - let prefix = repository_path(&app.shale.origin, repository, &["issues", ""])?; - if target.origin() != app.shale.origin.origin() || !target.path().starts_with(prefix.path()) - || target.query().is_some() || target.fragment().is_some() - || target.path()[prefix.path().len()..].parse::().ok().is_none_or(|n| n == 0) - || !target.username().is_empty() || target.password().is_some() - { return Err(Error::new(502, "Shale returned a different destination.")); } - app.shale.page(&target, &credential).await? - } else if status == StatusCode::OK { body } else { - return Err(Error::new(502, "Shale didn't confirm the issue change.")); - }; - let result = issue(&body, repository, issue_id)?; - current(app, grant, &credential)?; - Ok(json!({"issue":result})) - }.await; - outcome.map_err(|_| Error::new(502, "The write outcome is unknown. Check the issue in Shale before retrying.")) + 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 write outcome is unknown. Check Shale before retrying.") } else { error })?; + let id = result["id"].as_u64().ok_or_else(|| Error::new(502, "The write outcome is unknown. Check Shale before retrying."))?; + let target = repository_path(&app.shale.origin, repository, &["issues", &id.to_string()])?; + let result = issue(&app.shale.page(&target, &credential).await?, repository, Some(id))?; + current(app, grant, &credential).map_err(|_| Error::new(502, "The write outcome is unknown. Check Shale before retrying."))?; + Ok(json!({"issue":result})) }.await; Ok(match result { Ok(value) => CallToolResult::structured(value), @@ -881,80 +864,18 @@ pub async fn oauth(app: Arc, request: Request) -> Response { mod tests { use super::*; #[test] - fn issue_csrf_uses_last_deletion_token_and_refuses_ambiguous_markup() { - let initial = "
"; - assert_eq!(issue_csrf(&Html::parse_document(initial)).unwrap(), None); - let deletion = |value: &str| { - format!( - "
" - ) - }; - let page = format!( - "{initial}{}{}
", - deletion("first"), - deletion("latest") - ); + fn label_catalog_keeps_native_ids_and_rejects_wrong_pages() { + let page = "
Bug & fixDescription2
"; + let catalog = labels(page, "chat").unwrap(); assert_eq!( - issue_csrf(&Html::parse_document(&page)).unwrap().as_deref(), - Some("latest") + catalog["labels"][0], + json!({"id":75,"name":"Bug & fix","description":"Description","color":"#123456","openIssues":2}) ); - for bad in [ - "", - "", - "", - ] { - let page = format!( - "{initial}{}
  • {bad}
", - deletion("older") - ); - assert!(issue_csrf(&Html::parse_document(&page)).is_err()); - } - } - #[test] - fn tokenless_issue_forms_require_verified_r1616_footer_and_no_tokens_anywhere() { - const FOOTER: &str = ""; - const FORMS: &str = "
"; - let page = format!("{FORMS}{FOOTER}"); - let mut fields = HashMap::from([ - ("t".to_owned(), "status".to_owned()), - ("status".to_owned(), "done".to_owned()), - ]); - let original = fields.clone(); - prepare_issue_csrf(&Html::parse_document(&page), &mut fields).unwrap(); - assert_eq!(fields, original); - for bad in [ - page.replace("r1616-ga87d2f5.zig.0.16.0", "r1758-new"), - page.replace("r1616-ga87d2f5.zig.0.16.0", "r1616-other-build"), - page.replace( - "https://astheno.software/shale/", - "https://other.test/shale/", - ), - format!("{FORMS}
{FOOTER}
"), - format!("{FORMS}{FOOTER}{FOOTER}"), - format!( - "{FORMS}{FOOTER}" - ), - format!("{FORMS}{FOOTER}"), - ] { - assert!(!tokenless_r1616(&Html::parse_document(&bad))); - assert!( - prepare_issue_csrf(&Html::parse_document(&bad), &mut original.clone()).is_err() - ); - assert!( - prepare_issue_csrf( - &Html::parse_document(&bad), - &mut HashMap::from([("csrf_token".to_owned(), "selected".to_owned())]) - ) - .is_err() - ); - } - // A newer or mixed page must also reject a missing/empty selected form token. - let newer = Html::parse_document(&page.replace("r1616-ga87d2f5.zig.0.16.0", "r1758-new")); - assert!(prepare_issue_csrf(&newer, &mut HashMap::new()).is_err()); + assert!(labels(page, "other").is_err()); assert!( - prepare_issue_csrf( - &newer, - &mut HashMap::from([("csrf_token".to_owned(), String::new())]) + labels( + &page.replace("data-label-id=75", "data-label-id=bad"), + "chat" ) .is_err() ); @@ -1050,7 +971,9 @@ mod tests { #[test] fn issue_comment_authors_include_external_guest_profiles() { let page = "

#3Title

"; - let parsed = issue(page, "owned", Some(3)).unwrap(); + let page = page.replace("", "
  • GPT-6.1 Sol (on behalf of ~clover)
    Comment
  • "); + let parsed = issue(&page, "owned", Some(3)).unwrap(); + assert_eq!(parsed["comments"][2]["author"], "GPT-6.1 Sol (on behalf of ~clover)"); assert_eq!(parsed["comments"][0]["author"], "paperclover"); assert_eq!(parsed["comments"][1]["author"], "Astheno user"); } diff --git a/dashboard/src/shale_page.rs b/dashboard/src/shale_page.rs index b06e222c693f5651352679c78428b8e984e95bc4..453e9a204c4bfd92ff056ea3c181d86e94b92414 100644 --- a/dashboard/src/shale_page.rs +++ b/dashboard/src/shale_page.rs @@ -1,4 +1,6 @@ use crate::*; +use rusqlite::OptionalExtension; +use std::collections::HashSet; use axum::body::Body; use lol_html::{RewriteStrSettings, element, html_content::ContentType, text}; @@ -7,6 +9,7 @@ struct Profile { url: Option, icon: &'static str, picture: Option, + owner: Option<(String, Option)>, } fn profiles(auth: &auth::Store) -> Result> { @@ -41,6 +44,7 @@ fn profiles(auth: &auth::Store) -> Result> { name, url, icon, + owner: None, picture: if provider == "github" { Some(format!( "https://avatars.githubusercontent.com/u/{subject}?s=64" @@ -51,27 +55,204 @@ fn profiles(auth: &auth::Store) -> Result> { }, ); } + let config: Option = db.query_row("SELECT config FROM oidc_clients WHERE id='shale'", [], |r| r.get(0)).optional()?; + let config: Value = config.map(|config| serde_json::from_str(&config)).transpose()?.unwrap_or(Value::Null); + let mut query = db.prepare("SELECT u.username,json_extract(u.profile,'$.attributes.picture[0]'),i.identity FROM users u JOIN ( + 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 + UNION SELECT user_id,'email:'||email FROM commit_emails WHERE verified=1 + UNION SELECT user_id,'key:'||fingerprint FROM signing_keys WHERE verified=1 + ) 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')")?; + for row in query.query_map([], |r| Ok((r.get::<_,String>(0)?,r.get::<_,Option>(1)?,r.get::<_,String>(2)?)))? { + let (username,picture,identity) = row?; + let name = config["usernameAliases"][&username].as_str().unwrap_or(&username); + let mut url = url::Url::parse("https://shale.invalid/")?; + url.path_segments_mut().unwrap().pop_if_empty().push(&format!("~{name}")); + profiles.insert(identity, Profile { name: format!("~{name}"),url: Some(url.path().into()),icon:"",picture,owner:None }); + } Ok(profiles) } +pub(crate) fn assisted_model(body: &str) -> Option<&str> { + let line = body.trim_end().lines().last()?; + let model = line.strip_prefix("Assisted-by: ")?; + if model.is_empty() || model.len() > 128 || !model.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_')) { return None; } + let before = &body[..body.trim_end().len() - line.len()]; + let mut fence = None; + for line in before.lines() { + let line = line.trim_start(); + if line.starts_with("```") || line.starts_with("~~~") { + let marker = line.as_bytes()[0]; + let count = line.bytes().take_while(|&b| b == marker).count(); + match fence { + None => fence = Some((marker, count)), + Some((open, length)) if marker == open && count >= length && line[count..].trim().is_empty() => fence = None, + _ => (), + } + } + } + fence.is_none().then_some(model) +} + +fn ai_profile(model: Option<&str>) -> Profile { + let (name, icon) = match model { + None => ("ai".into(), include_str!("bot.svg")), + Some(id) => { + let normalize_version = id.starts_with("claude-") || id.starts_with("gpt-"); + let (id, icon, gpt) = if let Some(rest) = id.strip_prefix("claude-") { + let rest = rest.rsplit_once('-').filter(|(_, tail)| tail.len() == 8 && tail.bytes().all(|b| b.is_ascii_digit())).map(|(rest, _)| rest).unwrap_or(rest); + (rest, include_str!("claude.svg"), false) + } else if let Some(rest) = id.strip_prefix("gpt-") { + (rest, include_str!("openai.svg"), true) + } else { + (id, include_str!("bot.svg"), false) + }; + let mut words: Vec = Vec::new(); + for part in id.split('-').filter(|part| !part.is_empty()) { + let number = part.find(|c: char| c.is_ascii_digit()).unwrap_or(part.len()); + let (word, version) = part.split_at(number); + if !word.is_empty() { + let mut letters = word.chars(); + words.push(letters.next().unwrap().to_uppercase().collect::() + letters.as_str()); + } + if !version.is_empty() { + let numeric = |value: &str| value.bytes().all(|b| b.is_ascii_digit() || b == b'.'); + if normalize_version && numeric(version) && words.last().is_some_and(|word| numeric(word)) { + let previous = words.last_mut().unwrap(); + previous.push('.'); + previous.push_str(version); + } else { + words.push(version.into()); + } + } + } + let name = words.join(" "); + (if gpt { format!("GPT-{name}") } else { name }, icon) + } + }; + Profile { name, url: None, icon, picture: None, owner: None } +} + fn account_path(path: &str) -> Option<&str> { let name = path.strip_prefix("/~")?; let name = name.strip_suffix('/').unwrap_or(name); - (name.starts_with("guest-") && !name.contains('/')).then_some(name) + (!name.is_empty() && !name.contains('/')).then_some(name) } -fn rewrite(html: &str, origin: &url::Url, profiles: &HashMap) -> Result { +fn avatar(picture: &str) -> String { + let picture = picture.replace('&', "&").replace('"', """).replace('<', "<"); + format!("\"\"") +} + +fn rewrite(html: &str, origin: &url::Url, profiles: &HashMap, avatars: &HashMap, open_count: Option) -> Result { use std::{cell::Cell, rc::Rc}; + let markdown = Rc::new(Cell::new(0)); + let bodies = markdown.clone(); let active = Rc::new(Cell::new(None::)); let images = active.clone(); let labels = active.clone(); + let compact = Rc::new(Cell::new(false)); + let author_column = compact.clone(); + let commit_author = Rc::new(std::cell::RefCell::new(None::<(String,Option)>)); + let commit_images = commit_author.clone(); + let commit_names = commit_author.clone(); + let signature = Rc::new(std::cell::RefCell::new(String::new())); Ok(lol_html::rewrite_str(html, RewriteStrSettings::new() + .append_element_content_handler(text!("header .usa-nav__primary a[href]", |text| { + if markdown.get() == 0 && let Some(count) = open_count && let Some(start) = text.as_str().find("issues (") + && let Some(end) = text.as_str()[start..].find(')') + { + text.replace(&format!("{}issues ({count}){}", &text.as_str()[..start], &text.as_str()[start + end + 1..]), ContentType::Text); + } + Ok(()) + })) + .append_element_content_handler(element!("#m-metainfo td", |element| { + *commit_author.borrow_mut() = None; + let closing = commit_author.clone(); + element.on_end_tag(lol_html::end_tag!(move |_| { *closing.borrow_mut() = None; Ok(()) }))?; + Ok(()) + })) + .append_element_content_handler(element!("#page-log table tbody td[title]", |element| { + if markdown.get() != 0 { return Ok(()); } + let Some(profile) = element.get_attribute("title").and_then(|email| profiles.get(&format!("email:{}", email.trim().to_lowercase()))) else { return Ok(()); }; + let Some(url) = &profile.url else { return Ok(()); }; + *commit_author.borrow_mut() = Some((url.clone(),profile.picture.clone())); + let closing = commit_author.clone(); + element.on_end_tag(lol_html::end_tag!(move |_| { *closing.borrow_mut() = None; Ok(()) }))?; + element.prepend(&format!(""),ContentType::Html); + element.append("",ContentType::Html); + Ok(()) + })) + .append_element_content_handler(element!("#page-log td[title] img, #m-metainfo td img[alt]", |element| { + if markdown.get() != 0 { return Ok(()); } + 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())))); + if element.get_attribute("alt").is_some() && commit_images.borrow().is_none() { + *commit_images.borrow_mut() = profile.and_then(|profile| profile.url.as_ref().map(|url| (url.clone(),profile.picture.clone()))); + } + if let Some((_,Some(picture))) = &*commit_images.borrow() { + element.set_attribute("src",picture)?; + element.set_attribute("width", "14")?; + element.set_attribute("height", "14")?; + element.set_attribute("alt", "")?; + element.set_attribute("referrerpolicy", "no-referrer")?; + } + Ok(()) + })) + .append_element_content_handler(element!("#m-metainfo td span", |element| { + if markdown.get() != 0 { return Ok(()); } + if let Some((url,_)) = commit_names.borrow_mut().take() { + element.prepend(&format!(""),ContentType::Html); + element.append("",ContentType::Html); + } + Ok(()) + })) + .append_element_content_handler(text!("#m-metainfo td span", |chunk| { + if markdown.get() != 0 { return Ok(()); } + signature.borrow_mut().push_str(chunk.as_str()); + if !chunk.last_in_text_node() { chunk.remove(); return Ok(()); } + let value = std::mem::take(&mut *signature.borrow_mut()); + let profile = value.trim().strip_prefix("Signed by SSH key ").and_then(|fingerprint| profiles.get(&format!("key:{fingerprint}"))); + if let Some(profile) = profile { + let name = profile.name.replace('&',"&").replace('<',"<").replace('>',">"); + let original = &value; + chunk.replace(&format!("{original} ({name})",profile.url.as_deref().unwrap_or("/")),ContentType::Html); + } else { chunk.replace(&value,ContentType::Html); } + Ok(()) + })) + .append_element_content_handler(element!(".markdown", move |element| { + bodies.set(bodies.get() + 1); + let bodies = bodies.clone(); + element.on_end_tag(lol_html::end_tag!(move |_| { bodies.set(bodies.get() - 1); Ok(()) }))?; + Ok(()) + })) + .append_element_content_handler(element!(".timeline > li.spanspacer, .timeline .n-card__header > .spanspacer", |element| { + let style = element.get_attribute("style").unwrap_or_default(); + element.set_attribute("style", &format!("{style};flex-wrap:wrap"))?; + Ok(()) + })) + .append_element_content_handler(element!("#page-issues table td:nth-child(4)", move |element| { + author_column.set(true); + let author_column = author_column.clone(); + element.on_end_tag(lol_html::end_tag!(move |_| { author_column.set(false); Ok(()) }))?; + Ok(()) + })) + .append_element_content_handler(element!("img[src]", |element| { + if markdown.get() == 0 + && let Some(url) = element.get_attribute("src").and_then(|src| url::Url::parse(&src).ok()) + && url.host_str() == Some("api.dicebear.com") + && let Some(picture) = url.query_pairs().find(|(key, _)| key == "seed").and_then(|(_, seed)| avatars.get(seed.as_ref())) + { + element.set_attribute("src", picture)?; + element.set_attribute("alt", "")?; + element.set_attribute("referrerpolicy", "no-referrer")?; + } + Ok(()) + })) .append_element_content_handler(element!("a[href]", |element| { - let profile = element.get_attribute("href") - .and_then(|href| origin.join(&href).ok()) - .filter(|target| target.origin() == origin.origin()) - .and_then(|target| account_path(target.path()).and_then(|name| profiles.get(name))); - active.set(profile.map(|profile| profile.picture.is_some())); + let target = element.get_attribute("href").and_then(|href| origin.join(&href).ok()).filter(|target| target.origin() == origin.origin()); + let ai_profile = target.as_ref().filter(|target| markdown.get() == 0 && target.path().trim_end_matches('/') == "/~ai") + .map(|_| ai_profile(None)); + 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)); + active.set(profile.map(|profile| profile.picture.is_some() || ai_profile.is_some() || profile.owner.is_some())); let Some(profile) = profile else { return Ok(()); }; let closing = active.clone(); 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) - } else { for attribute in ["href", "target", "rel", "tabindex"] { element.remove_attribute(attribute); } } + if profile.owner.is_some() || ai_profile.is_some() { element.set_tag_name("span")?; } let style = element.get_attribute("style").unwrap_or_default(); let cursor = if profile.url.is_some() { "" } else { ";cursor:default" }; - element.set_attribute("style", &format!("{style};text-decoration:none{cursor}"))?; + 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() }; + element.set_attribute("style", &format!("{style};text-decoration:none{cursor}{spacing}"))?; if let Some(picture) = &profile.picture { - let picture = picture.replace('&', "&").replace('"', """).replace('<', "<"); - element.prepend(&format!("\"\""), ContentType::Html); + element.prepend(&avatar(picture), ContentType::Html); } - let icon = profile.icon.trim().replace("", ContentType::Html); } + 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" }; + let icon = profile.icon.trim().replace("", ContentType::Html); } - element.append(&profile.name, ContentType::Text); + let name = if compact.get() && profile.owner.is_some() { + let number = profile.name.find(|c: char| c.is_ascii_digit()).unwrap_or(profile.name.len()); + profile.name[..number].trim_end_matches([' ', '-']) + } else { &profile.name }; + element.append(name, ContentType::Text); if profile.url.is_some() { element.append("", ContentType::Html); } + if let Some((owner, picture)) = &profile.owner { + element.append(" (", ContentType::Html); + if !compact.get() { element.append("on behalf of ", ContentType::Text); } + let mut owner_url = origin.clone(); + owner_url.set_path(&format!("/~{owner}")); owner_url.set_query(None); owner_url.set_fragment(None); + let href = owner_url.path().replace('&', "&").replace('"', """).replace('<', "<"); + element.append(&format!(""), ContentType::Html); + if let Some(picture) = picture { element.append(&avatar(picture), ContentType::Html); } + element.append("", ContentType::Html); + element.append(&format!("~{owner}"), ContentType::Text); + element.append(") ", ContentType::Html); + } Ok(()) })) .append_element_content_handler(element!("a[href] img, a[href] span", move |child| { @@ -149,7 +349,7 @@ pub async fn proxy(app: Arc, request: Request) -> Result { .and_then(|v| v.to_str().ok()) .unwrap_or_default(); let domain = env("STUDIO_DOMAIN", "studio.test"); - let service = host.strip_suffix(&format!(".{domain}")).unwrap_or_default(); + let service = host.strip_suffix(&format!(".{domain}")).unwrap_or_default().to_owned(); if service != "shale" && !(service .strip_prefix("shale-preview-") @@ -246,7 +446,37 @@ pub async fn proxy(app: Arc, request: Request) -> Result { if prefix.len() <= 8 * 1024 * 1024 && let Ok(text) = std::str::from_utf8(&prefix) { - let rewritten = rewrite(text, &origin, &profiles)?; + let metadata = if service == "shale" && status.is_success() { + let (repository, accounts) = { + let document = scraper::Html::parse_document(text); + 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); + let mut accounts: HashSet = document.select(&scraper::Selector::parse("a[href]").unwrap()).filter_map(|element| { + let url = origin.join(element.attr("href")?).ok()?; + if url.origin() != origin.origin() { return None; } + account_path(url.path()).map(str::to_owned) + }).take(1024).collect(); + if accounts.len() < 1024 && let Some(name) = account_path(origin.path()) { accounts.insert(name.to_owned()); } + (repository, accounts) + }; + match tokio::time::timeout(Duration::from_secs(3), host::call(json!({"operation":"shale.metadata","repository":repository,"accounts":accounts}))).await { + Ok(Ok(value)) => value, + _ => Value::Null, + } + } else { Value::Null }; + let mut profiles = profiles; + let mut avatars = HashMap::new(); + if let Some(accounts) = metadata["accounts"].as_object() { + for (name, account) in accounts { + if let (Some(seed), Some(picture)) = (account["avatar"].as_str(), account["picture"].as_str()) { + avatars.insert(seed.to_owned(), picture.to_owned()); + } + let Some(owner) = account["owner"].as_str() else { continue; }; + let mut profile = ai_profile(account["model"].as_str()); + profile.owner = Some((owner.into(), account["picture"].as_str().map(str::to_owned))); + profiles.insert(name.clone(), profile); + } + } + let rewritten = rewrite(text, &origin, &profiles, &avatars, metadata["openIssues"].as_u64())?; for name in [ "content-length", "etag", @@ -272,6 +502,125 @@ mod tests { use super::*; use scraper::{Html, Selector}; + #[test] + fn commit_identity_keeps_entities_and_cannot_escape_its_metadata_cell() { + let origin = url::Url::parse("https://shale.test/repo/commit/abc").unwrap(); + let profile = || Profile { name: "~clover".into(), url: Some("/~clover".into()), icon:"",picture:Some("https://snow.test/picture".into()),owner:None }; + let profiles = HashMap::from([("email:known@example.test".into(),profile()),("key:SHA256:known".into(),profile())]); + let html = "
    gravatar for known@example.test
    gravatar for other@example.testJohn & Jane <other@example.test>
    Signed by SSH key SHA256:known
    gravatar for known@example.test Signed by SSH key SHA256:known
    "; + let output = rewrite(html,&origin,&profiles,&HashMap::new(),None).unwrap(); + let document = Html::parse_document(&output); + assert!(!output.contains("&amp;")); + assert!(output.contains("John & Jane <other@example.test>")); + assert_eq!(document.select(&Selector::parse("a[href='/~clover']").unwrap()).count(),1); + assert!(output.contains("src=unknown")); + assert!(output.contains("src=example")); + assert_eq!(document.select(&Selector::parse(".markdown a").unwrap()).count(),0); + let html = "
    Author & name
    Other
    "; + let output = rewrite(html,&origin,&profiles,&HashMap::new(),None).unwrap(); + assert!(output.contains("Author & name")); + let document = Html::parse_document(&output); + assert_eq!(document.select(&Selector::parse("a[href='/~clover']").unwrap()).count(),1); + assert_eq!(document.select(&Selector::parse("img[src='https://snow.test/picture']").unwrap()).count(),1); + assert_eq!(document.select(&Selector::parse("img[src='https://snow.test/picture'][width='14'][height='14']").unwrap()).count(),1); + } + + #[test] + fn navigation_uses_the_current_repository_count() { + let html = "
    "; + let origin = url::Url::parse("https://shale.paperclover.net/chat/issues/25").unwrap(); + let output = rewrite(html, &origin, &HashMap::new(), &HashMap::new(), Some(8)).unwrap(); + assert!(output.contains(">issues (8)")); + assert!(output.contains("")); + } + + #[test] + fn reconstructed_markdown_anchors_cannot_relabel_humans() { + let html = "~ai~clo"; + let origin = url::Url::parse("https://shale.paperclover.net/chat/issues/25").unwrap(); + let output = rewrite(html, &origin, &HashMap::new(), &HashMap::new(), None).unwrap(); + assert!(output.contains("~clo")); + assert!(output.contains("ai")); + } + + #[test] + fn terminal_trailers_ignore_examples_and_unclosed_fences() { + assert_eq!(assisted_model("Body\n\nAssisted-by: gpt-6-astra\n"), Some("gpt-6-astra")); + for body in ["Assisted-by: ", "> 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"] { + assert_eq!(assisted_model(body), None, "{body}"); + } + assert_eq!(assisted_model("```\nexample\n```\n\nAssisted-by: claude-opus-5.5"), Some("claude-opus-5.5")); + } + + #[test] + fn ai_identity_is_durable_and_never_reads_comment_trailers() { + let origin = url::Url::parse("https://shale.paperclover.net/chat/issues/25").unwrap(); + let mut opus = ai_profile(Some("claude-opus-5.5")); + opus.owner = Some(("clover".into(), Some("https://example.com/picture?a=1&b=2".into()))); + let mut sol = ai_profile(Some("gpt-6.1-sol")); + sol.owner = Some(("clover".into(), None)); + let profiles = HashMap::from([("ai-clover-claude-opus-5.5".into(), opus), ("ai-clover-gpt-6.1-sol".into(), sol)]); + let html = "ai accountai account
    Mention

    Assisted-by: unknown-model

    ~clover"; + let output = rewrite(html, &origin, &profiles, &HashMap::new(), None).unwrap(); + let document = Html::parse_document(&output); + assert!(output.contains("Opus 5.5 (on behalf of ~clover")); + assert!(output.contains(">Mention")); + assert_eq!(document.select(&Selector::parse("a[href='/~clover']").unwrap()).count(), 3); + assert!(document.select(&Selector::parse("svg").unwrap()).all(|e| e.attr("aria-hidden") == Some("true") && e.attr("focusable") == Some("false"))); + assert_eq!(ai_profile(Some("gpt-6.1-sol")).name, "GPT-6.1 Sol"); + assert_eq!(ai_profile(Some("claude-opus-5.5")).name, "Opus 5.5"); + assert_eq!(ai_profile(Some("claude-haiku-5-5")).name, "Haiku 5.5"); + assert_eq!(ai_profile(Some("claude-haiku-5.5")).name, "Haiku 5.5"); + assert_eq!(ai_profile(Some("claude-sonnet-4-5-20250929")).name, "Sonnet 4.5"); + for (id, name) in [ + ("claude-new-variant-5-5", "New Variant 5.5"), + ("gpt-6-1-new-variant", "GPT-6.1 New Variant"), + ("qwen-3.8-27b", "Qwen 3.8 27b"), + ("qwen3.8-27b", "Qwen 3.8 27b"), + ("big-pickle-2", "Big Pickle 2"), + ("llama-3-70", "Llama 3 70"), + ] { assert_eq!(ai_profile(Some(id)).name, name, "{id}"); } + } + + #[test] + fn compact_authors_keep_native_owner_links_and_avatars() { + let origin = url::Url::parse("https://shale.paperclover.net/chat/issues/").unwrap(); + let mut profile = ai_profile(Some("gpt-6.1-sol")); + profile.owner = Some(("clover".into(), Some("https://snowglobe.test/picture".into()))); + let profiles = HashMap::from([("ai-clover-gpt-6.1-sol".into(), profile)]); + let avatars = HashMap::from([("native-clover".into(), "https://snowglobe.test/picture".into())]); + let html = "
    1TaskTodoai
    ~clover
    "; + let output = rewrite(html, &origin, &profiles, &avatars, None).unwrap(); + let document = Html::parse_document(&output); + let author = document.select(&Selector::parse("td:nth-child(4)").unwrap()).next().unwrap(); + assert_eq!(author.text().collect::().trim(), "GPT (~clover)"); + assert!(!output.contains("on behalf of")); + let owners: Vec<_> = document.select(&Selector::parse("a[href='/~clover']").unwrap()).collect(); + assert_eq!(owners.len(), 2); + for owner in owners { + assert_eq!(owner.attr("class"), Some("usa-link va-middle-childs")); + let image = owner.select(&Selector::parse("img").unwrap()).next().unwrap(); + assert_eq!(image.attr("src"), Some("https://snowglobe.test/picture")); + assert_eq!(image.attr("height"), Some("16")); + assert_eq!(image.attr("style"), None); + assert_eq!(image.attr("alt"), Some("")); + } + assert!(output.contains("
    ")); + for (model, short) in [("qwen3.8-27b", "Qwen"), ("big-pickle-2", "Big Pickle"), ("claude-new-variant-5-5", "New Variant")] { + let mut profile = ai_profile(Some(model)); + profile.owner = Some(("clover".into(), None)); + let profiles = HashMap::from([("ai-model".into(), profile)]); + let html = "
    1TaskTodoai
    "; + let output = rewrite(html, &origin, &profiles, &HashMap::new(), None).unwrap(); + let document = Html::parse_document(&output); + assert_eq!(document.select(&Selector::parse("td:nth-child(4)").unwrap()).next().unwrap().text().collect::().trim(), format!("{short} (~clover)")); + } + } + #[test] fn rewrites_only_known_local_account_links_and_escapes_provider_names() { let origin = url::Url::parse("https://shale.paperclover.net/").unwrap(); @@ -282,6 +631,7 @@ mod tests { name: "<&\"clover".into(), url: Some("https://github.com/clover".into()), icon: include_str!("../web/sso/github.svg"), + owner: None, picture: Some("https://avatars.githubusercontent.com/u/123?s=64".into()), }, ), @@ -291,12 +641,13 @@ mod tests { name: "Astheno user".into(), url: None, icon: include_str!("astheno.svg"), + owner: None, picture: None, }, ), ]); let html = r#"~guest-github-123avatarguestunknownexternalrepo
    ~guest-github-123
    "#; - let rewritten = rewrite(html, &origin, &profiles).unwrap(); + let rewritten = rewrite(html, &origin, &profiles, &HashMap::new(), None).unwrap(); let page = Html::parse_document(&rewritten); let links: Vec<_> = page.select(&Selector::parse("body > a").unwrap()).collect(); assert_eq!(links[0].attr("target"), Some("_blank")); diff --git a/dashboard/src/users.rs b/dashboard/src/users.rs index e0a76c3ddd65cca667ad4534af3a0f225bfce856..34fb405396ddfaaee8182132a7f7310212407238 100644 --- a/dashboard/src/users.rs +++ b/dashboard/src/users.rs @@ -35,6 +35,9 @@ fn profile(body: &Value, full: bool) -> Result { let value = match key { "username" => { let v = string(value).trim().to_lowercase(); + if v.starts_with("ai-") { + return Err(Error::new(400, "Usernames beginning with ai- are reserved for model accounts.")); + } if v.len() > 254 || !username_pattern.is_match(&v) { return Err(Error::new( 400, @@ -155,7 +158,7 @@ pub async fn route( for id in &users { uuid(id)?; let user = auth::user(&transaction, id)?; - if guest::is_guest(&user) { + if guest::is_guest(&user) || user["kind"] == "ai" { return Err(Error::new( 400, "Guests can use Shale only. Select regular accounts to edit groups.", @@ -196,7 +199,7 @@ pub async fn route( } if parts.is_empty() && method == Method::GET { let db = app.auth.db.lock().unwrap(); - let mut statement = db.prepare("SELECT id FROM users ORDER BY username")?; + let mut statement = db.prepare("SELECT id FROM users WHERE coalesce(json_extract(profile,'$.kind'),'')!='ai' ORDER BY username")?; let ids = statement .query_map([], |r| r.get::<_, String>(0))? .collect::, _>>()?; @@ -274,6 +277,9 @@ pub async fn route( .first() .ok_or_else(|| Error::new(404, "No user here."))?; uuid(id)?; + if auth::user(&app.auth.db.lock().unwrap(), id)?["kind"] == "ai" { + return Err(Error::new(404, "No user here.")); + } if *parts == [*id, "setup-link"] && method == Method::POST { return Ok(axum::Json(json!({"url":app.auth.setup_link(id)?})).into_response()); } @@ -355,6 +361,19 @@ pub async fn route( } Value::Null } + [_, "commit-identity"] if method == Method::GET => identity::account(&transaction, id)?, + [_, "commit-emails", email] if method == Method::PUT => { + let email = identity::email(&json!(percent_encoding::percent_decode_str(email).decode_utf8().map_err(|_| Error::new(400,"Enter a full email address."))?))?; + 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))?; + if !exists { return Err(Error::new(404, "This account has not added that email.")); } + identity::add_email(&transaction,id,&json!(email),true)?; + Value::Null + } + [_, "signing-keys", fingerprint] if method == Method::PUT => { + let fingerprint = percent_encoding::percent_decode_str(fingerprint).decode_utf8().map_err(|_| Error::new(400,"Choose a signing key."))?; + identity::confirm_key(&transaction,id,&fingerprint)?; + Value::Null + } [_, "credentials"] if method == Method::GET => auth::credentials(&transaction, id)?, [_, "logout"] if method == Method::POST => { transaction.execute("DELETE FROM sessions WHERE user_id=?", [id])?; @@ -457,6 +476,7 @@ pub async fn account( let id = string(&user["id"]).to_owned(); let value = match parts { [] if method == Method::GET => { + user["commitIdentity"] = identity::account(&app.auth.db.lock().unwrap(), &id)?; user["picture"] = user["attributes"]["picture"][0].clone(); user["credentials"] = auth::credentials(&app.auth.db.lock().unwrap(), &id)?; user.as_object_mut().unwrap().remove("attributes"); @@ -485,6 +505,56 @@ pub async fn account( auth::save_user(&app.auth.db.lock().unwrap(), &id, user)?; Value::Null } + ["commit-emails"] if method == Method::POST => { + app.auth.recent(request.headers())?; + let body: Value = serde_json::from_slice(&axum::body::to_bytes(request.into_body(), 8192).await?)?; + let mut db = app.auth.db.lock().unwrap(); + let tx = db.transaction()?; + identity::add_email(&tx, &id, &body["email"], false)?; + tx.commit()?; + Value::Null + } + ["commit-emails", email] if method == Method::PUT || method == Method::DELETE => { + app.auth.recent(request.headers())?; + let email = identity::email(&json!(percent_encoding::percent_decode_str(email).decode_utf8().map_err(|_| Error::new(400,"Enter a full email address."))?))?; + let mut db = app.auth.db.lock().unwrap(); + let tx = db.transaction()?; + if method == Method::PUT { + if !array(&user["groups"]).iter().any(|group| group["name"] == "infra-admin") { + return Err(Error::new(403, "Ask an admin to confirm ownership of this email.")); + } + 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))?; + if !exists { return Err(Error::new(404, "Add this email before confirming ownership.")); } + identity::add_email(&tx, &id, &json!(email), true)?; + } else { + tx.execute("DELETE FROM commit_emails WHERE user_id=? AND email=?",sql![id,email])?; + } + tx.commit()?; + Value::Null + } + ["signing-keys"] if method == Method::POST => { + app.auth.recent(request.headers())?; + let body: Value = serde_json::from_slice(&axum::body::to_bytes(request.into_body(), 32768).await?)?; + let mut db = app.auth.db.lock().unwrap(); + let tx = db.transaction()?; + identity::add_key(&tx, &id, &body, false)?; + tx.commit()?; + Value::Null + } + ["signing-keys", fingerprint] if method == Method::PUT || method == Method::DELETE => { + let fingerprint = percent_encoding::percent_decode_str(fingerprint).decode_utf8().map_err(|_| Error::new(400,"Choose a signing key."))?; + app.auth.recent(request.headers())?; + let mut db = app.auth.db.lock().unwrap(); + let tx = db.transaction()?; + if method == Method::PUT { + if !array(&user["groups"]).iter().any(|group| group["name"] == "infra-admin") { + return Err(Error::new(403, "Ask an admin to confirm ownership of this signing key.")); + } + identity::confirm_key(&tx,&id,&fingerprint)?; + } else { tx.execute("DELETE FROM signing_keys WHERE user_id=? AND fingerprint=?",sql![id,fingerprint.as_ref()])?; } + tx.commit()?; + Value::Null + } ["credentials", credential] if method == Method::DELETE => { app.auth.recent(request.headers())?; let mut db = app.auth.db.lock().unwrap(); diff --git a/dashboard/web/api.contract.ts b/dashboard/web/api.contract.ts index d5a026d243f5204d4bdd35d2fc5d720572a14aec..935e3a5bf6be34f59cc95bc83abb944637d1c087 100644 --- a/dashboard/web/api.contract.ts +++ b/dashboard/web/api.contract.ts @@ -229,6 +229,15 @@ export type Api = Hono<{}, { $put: Endpoint; $delete: Endpoint; }; + "/users/:id/commit-identity": { + $get: Endpoint; + }; + "/users/:id/commit-emails/:email": { + $put: Endpoint; + }; + "/users/:id/signing-keys/:fingerprint": { + $put: Endpoint; + }; "/users/:id/credentials": { $get: Endpoint; }; @@ -302,9 +311,23 @@ export type Api = Hono<{}, { $post: Endpoint; }; "/account": { - $get: Endpoint; + $get: Endpoint; $patch: Endpoint; }; + "/account/commit-emails": { + $post: Endpoint; + }; + "/account/commit-emails/:email": { + $put: Endpoint; + $delete: Endpoint; + }; + "/account/signing-keys": { + $post: Endpoint; + }; + "/account/signing-keys/:fingerprint": { + $put: Endpoint; + $delete: Endpoint; + }; "/account/picture": { $put: Endpoint<{ picture: string; }, { form: { picture: File; }; }>; $delete: Endpoint; diff --git a/dashboard/web/pages/Account.css b/dashboard/web/pages/Account.css index 736fc2af07445cfc86bd6f93bd2779c49dbe170f..6122c69b57501e2326a162534125bbb7f4d3abe8 100644 --- a/dashboard/web/pages/Account.css +++ b/dashboard/web/pages/Account.css @@ -22,3 +22,15 @@ .credentials > .button { justify-self: end; } .credentials .passkey { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .picture-row .button:has(:focus-visible) { outline: 2px solid var(--focus); outline-offset: 1px; } + +.commit-identity .credentials { grid-template-columns: max-content minmax(0, 1fr) auto; } +.commit-key code { display: block; overflow-wrap: anywhere; font-size: 11px; } +.commit-email { overflow-wrap: anywhere; } +.commit-identity .actions { display: flex; flex-wrap: wrap; gap: 4px; justify-content: end; } +.commit-identity { container-type: inline-size; } +@container (max-width: 360px) { + .commit-identity .credentials { grid-template-columns: minmax(0, 1fr) auto; } + .commit-identity .credentials > span:empty { display: none; } + .commit-identity .commit-email, .commit-identity .commit-key { grid-column: 1 / -1; } + .commit-identity .actions { grid-column: 2; } +} diff --git a/dashboard/web/pages/Account.tsx b/dashboard/web/pages/Account.tsx index dddbc65d78df415f9df12c5b1a5107f6e12f781a..263a1c54291d88ba48321f4357de0a638526249c 100644 --- a/dashboard/web/pages/Account.tsx +++ b/dashboard/web/pages/Account.tsx @@ -1,7 +1,7 @@ import { useSearchParams } from "@solidjs/router"; import { createResource, createSignal, For, Show } from "solid-js"; import { parseResponse } from "hono/client"; -import type { User } from "../types/users.ts"; +import type { User, CommitIdentity as Identity } from "../types/users.ts"; import { api, reason } from "../api.ts"; import { Ago } from "../components/Ago.tsx"; import { lastGood, Loaded } from "../components/Loaded.tsx"; @@ -73,6 +73,7 @@ export function Account() {

    Finish your profile and change any temporary password to open Snowglobe and Files.

    +
    )} @@ -227,3 +228,85 @@ function SignIn(props: { user: Self }) { ; } + +export function CommitIdentity(props: { user: User; identity: Identity; refetch: () => unknown; admin?: boolean }) { + const [busy, setBusy] = createSignal(false); + const [error, setError] = createSignal(""); + const admin = () => props.admin || props.user.groups.some(group => group.name === "infra-admin"); + const run = async (work: () => Promise) => { + if (busy()) return; + setBusy(true); setError(""); + try { await work(); await props.refetch(); } + catch (failure) { setError(reason(failure)); throw failure; } + finally { setBusy(false); } + }; + const addEmail = () => showConfirmDialog({ + title: "Add commit email", confirmLabel: "add email", + description: "An admin must confirm ownership before commits link to your profile.", + body: , + onConfirm: async form => { + const count = props.identity.emails.length; + await run(() => parseResponse(api.account["commit-emails"].$post({json:{email:String(form.get("email"))}}))); + toast(props.identity.emails.length === count ? "This email is already added" : "Added commit email"); + }, + }); + const addKey = () => showConfirmDialog({ + title: "Add signing key", confirmLabel: "add key", + description: "An admin must confirm key ownership before signatures link to your profile.", + body: <> + +