Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68eeb7841c | ||
|
|
4dc11cfe6b | ||
|
|
a2e691da2b | ||
|
|
eee2167502 | ||
|
|
09fe308720 | ||
|
|
666babfd58 | ||
|
|
1c43daa564 | ||
|
|
3d8c364689 | ||
|
|
0f7ea7a936 | ||
|
|
bd4067cf83 | ||
|
|
49c14af5c7 | ||
|
|
e340674c12 | ||
|
|
ceb62385f1 | ||
|
|
a38df08a31 | ||
|
|
37e1d401cf | ||
|
|
03dbf976a5 | ||
|
|
5043b2924c | ||
|
|
be8f1fac85 | ||
|
|
eac354629a | ||
|
|
2efebcc278 | ||
|
|
82fcec6bb6 | ||
|
|
6e66e69451 | ||
|
|
5f58f9a45f | ||
|
|
aaef6b1c49 | ||
|
|
193dc2f085 | ||
|
|
48f5182286 | ||
|
|
5869106089 | ||
|
|
089246614d | ||
|
|
271489a10c | ||
|
|
729a30a10e | ||
|
|
b6607d219d | ||
|
|
c58cb067f2 | ||
|
|
7c7ac1021a | ||
|
|
b5590fd008 | ||
|
|
67b63e9844 | ||
|
|
6e80d0de08 | ||
|
|
5939aec69f | ||
|
|
1f6c5ac66b | ||
|
|
6861c41580 | ||
|
|
cc8fe2f6e3 | ||
|
|
8161c1253d | ||
|
|
8c2705b42a | ||
|
|
b1a4ed13b0 | ||
|
|
145f4fd2b4 | ||
|
|
1284b14a13 | ||
|
|
a75dd4a231 | ||
|
|
0deeba58ba | ||
|
|
562bc4dedc | ||
|
|
3706d756f3 | ||
|
|
eb79b76432 | ||
|
|
41a928f21b | ||
|
|
93249a14b0 | ||
|
|
b10e6bc870 | ||
|
|
552bc15067 | ||
|
|
e678ff753f | ||
|
|
87fc675ccb | ||
|
|
bfbbc9b479 | ||
|
|
fb8a744cae | ||
|
|
49bf73b228 | ||
|
|
43ed3f7148 | ||
|
|
3eed73e11e | ||
|
|
f19265742c | ||
|
|
1c2ec288dd | ||
|
|
b55c970ec5 | ||
|
|
ffb8fd16bc | ||
|
|
1feb2ae7d5 | ||
|
|
8c4ea484a9 | ||
|
|
a91321d1d8 | ||
|
|
0dd67143f1 | ||
|
|
6e1df4ddc6 | ||
|
|
f93755e7a4 | ||
|
|
398892cced | ||
|
|
cbf8103cba | ||
|
|
9bd11fd6e7 | ||
|
|
39848a168b | ||
|
|
b45611e9c5 | ||
|
|
3e33cf6439 | ||
|
|
8a0b79bc84 | ||
|
|
26d040d116 | ||
|
|
b396252af6 | ||
|
|
7f80d33210 | ||
|
|
eae8797e08 | ||
|
|
202df9dcde | ||
|
|
fbb52a73a0 | ||
|
|
1161040efe | ||
|
|
3d00c85636 | ||
|
|
77b92020c0 | ||
|
|
648900612e | ||
|
|
100fd72e7a | ||
|
|
12652581cb | ||
|
|
116ade4630 | ||
|
|
96d59d2ff9 | ||
|
|
eb5727abda | ||
|
|
ec3b8b42ae | ||
|
|
41d2767b30 | ||
|
|
4f03f5ccdd | ||
|
|
6fd52cf317 | ||
|
|
c639daa7a2 | ||
|
|
83a7965a28 | ||
|
|
ba6da17a92 | ||
|
|
84d54d9853 | ||
|
|
42ef4b6502 | ||
|
|
0e27a574b7 | ||
|
|
56cd8add18 | ||
|
|
4aabc068ed | ||
|
|
85bc18b7d8 | ||
|
|
6419bf2cdf | ||
|
|
b30f02a3f0 | ||
|
|
33ae982968 | ||
|
|
b424996a9c | ||
|
|
63aa15d155 | ||
|
|
d38fb4bc46 | ||
|
|
844dbf6a22 | ||
|
|
dfbc94f929 | ||
|
|
2f6d339073 | ||
|
|
63aac10341 | ||
|
|
db8c0b3dac | ||
|
|
708ac19f28 | ||
|
|
83f602e6d1 | ||
|
|
f549058369 | ||
|
|
38bfa85ad0 | ||
|
|
ab7145de83 | ||
|
|
9e91a172e7 | ||
|
|
04e7441d78 | ||
|
|
89b04675e2 | ||
|
|
1453458519 | ||
|
|
04f8aa1833 | ||
|
|
d1309f3bd6 | ||
|
|
b409b20940 | ||
|
|
ade755b184 | ||
|
|
40e1d6e876 | ||
|
|
558d6ddf24 | ||
|
|
34efabdec8 | ||
|
|
43dc346255 | ||
|
|
5fba1735c2 | ||
|
|
d7a6cadbe2 | ||
|
|
d8f5c04340 | ||
|
|
ee8c049f9e | ||
|
|
815bdf57d5 | ||
|
|
347b193af8 | ||
|
|
3b9c601498 | ||
|
|
1571d8bba0 | ||
|
|
3a741edbf1 | ||
|
|
8af1f85665 | ||
|
|
a54d34149a | ||
|
|
46999debdb | ||
|
|
f29c827e6e | ||
|
|
10e797a1aa | ||
|
|
2ef496f622 | ||
|
|
7d76fca21c | ||
|
|
28dbd5346c | ||
|
|
a880b17624 | ||
|
|
61d62a3cb8 | ||
|
|
4ec53a296a | ||
|
|
13f0171ce8 | ||
|
|
35b9509da3 | ||
|
|
f0b172020e | ||
|
|
933c461f38 | ||
|
|
02ff2e3cb0 | ||
|
|
a72ccf6484 | ||
|
|
667b739af4 | ||
|
|
19e62208d2 | ||
|
|
2e34bde07a | ||
|
|
3da4edb442 | ||
|
|
578f56ab92 | ||
|
|
9dd9bb8a3f | ||
|
|
86d3af743a | ||
|
|
076607c9b9 | ||
|
|
56123e052b | ||
|
|
f6f86c4b34 | ||
|
|
29e19f326a | ||
|
|
55c7a4a546 | ||
|
|
319ba50a44 | ||
|
|
4baf168df0 | ||
|
|
535d05c142 | ||
|
|
126e91e8b9 | ||
|
|
77614e9feb | ||
|
|
a8a5d6f56e | ||
|
|
ada30aa039 | ||
|
|
290d398900 | ||
|
|
d9e6071528 | ||
|
|
c5bc39de88 | ||
|
|
0c37943267 | ||
|
|
fefac05ab1 | ||
|
|
49885ff9e7 | ||
|
|
4907b16d9b | ||
|
|
0e538ecd29 | ||
|
|
9964e9f3fb | ||
|
|
b9c382006e | ||
|
|
96a874c604 | ||
|
|
7f94c43a45 | ||
|
|
fc8efca49d | ||
|
|
063e7114e3 | ||
|
|
6e38d3f2ef | ||
|
|
9aa2445ec7 | ||
|
|
133948cc78 | ||
|
|
992866e167 | ||
|
|
a096e872f5 | ||
|
|
6f001af2a3 | ||
|
|
51857c9008 | ||
|
|
a714915afe | ||
|
|
2625e97f11 | ||
|
|
12696a05ae | ||
|
|
2fa4d50115 | ||
|
|
d2f6e8068d | ||
|
|
33bf975597 | ||
|
|
3abb735200 | ||
|
|
b3da01efd5 | ||
|
|
c4fcebd9c7 | ||
|
|
68da800dcb | ||
|
|
bf2a1365f6 | ||
|
|
4ca3b38667 | ||
|
|
ea1079e1df | ||
|
|
12fd8b6570 | ||
|
|
bde5f6adb3 | ||
|
|
d25a860f71 | ||
|
|
db1596f3b4 | ||
|
|
694647375c | ||
|
|
1d99429ba0 | ||
|
|
ed7956cbd3 | ||
|
|
8e2b9baf19 | ||
|
|
8a10f271f7 | ||
|
|
9960d55a41 | ||
|
|
6153c5ed68 | ||
|
|
aa5e3f6884 | ||
|
|
fc220f760f | ||
|
|
2d94e38d23 | ||
|
|
03ddc5d2c4 | ||
|
|
3175d7ca21 | ||
|
|
d818117d4c | ||
|
|
6b2618dab4 | ||
|
|
0051023056 | ||
|
|
8783f12c4c | ||
|
|
82c09dd768 | ||
|
|
c3bf32d1b1 | ||
|
|
df3864bd15 | ||
|
|
ffeb7d8c97 | ||
|
|
a6b7a7bc60 | ||
|
|
0cc1814658 | ||
|
|
8051e25c65 | ||
|
|
0ad5cd783b | ||
|
|
953305a5af | ||
|
|
15a3b71802 | ||
|
|
e68d0448b8 | ||
|
|
db3a5c17b0 | ||
|
|
f39c87561b | ||
|
|
f7df069ca1 | ||
|
|
eacb99f963 | ||
|
|
fb1341b629 | ||
|
|
19b6cbac12 | ||
|
|
258fe455eb | ||
|
|
667c663668 | ||
|
|
0283216e67 | ||
|
|
77313170c6 | ||
|
|
0d25dfb5f4 | ||
|
|
6df0f5e6df | ||
|
|
382d49d887 | ||
|
|
0d88c9989e | ||
|
|
5462e36d10 | ||
|
|
5b0c7442ee | ||
|
|
441905bc13 | ||
|
|
24be3f3ca8 | ||
|
|
cf60a14d74 | ||
|
|
bd0845e081 | ||
|
|
dc3421c34e | ||
|
|
1f743970dd | ||
|
|
c9361262df | ||
|
|
0e6cbd8315 | ||
|
|
5c6bd0fc2b | ||
|
|
57abe69173 | ||
|
|
583df3dd6a | ||
|
|
70103d8719 | ||
|
|
8450cee02a | ||
|
|
1ecd113808 | ||
|
|
4d1829add0 | ||
|
|
5fd71f68e8 | ||
|
|
0ec8415f0e | ||
|
|
fd37ccebae | ||
|
|
abbc073429 | ||
|
|
a5282e9748 | ||
|
|
35c40bce75 | ||
|
|
77b63ed942 | ||
|
|
1f2a06facd | ||
|
|
69d6fe44b3 | ||
|
|
7af168fa59 | ||
|
|
7ce2db2771 | ||
|
|
cb114d6e87 | ||
|
|
50a486b608 | ||
|
|
b6843c7621 | ||
|
|
b54468291e | ||
|
|
66c9349ee3 | ||
|
|
c3fd969965 | ||
|
|
ce7f5dbbdd | ||
|
|
b2291fad49 | ||
|
|
56656de5bc | ||
|
|
54a221b367 | ||
|
|
4771d80eb2 | ||
|
|
80de69ebb0 | ||
|
|
4019283eba | ||
|
|
97f855b40d | ||
|
|
5ee30cc144 | ||
|
|
1adf21a7e5 | ||
|
|
e73545f64f | ||
|
|
34918d9921 | ||
|
|
e392be0d65 | ||
|
|
dc6711b3c5 | ||
|
|
1a763b0539 | ||
|
|
5452bc96b1 | ||
|
|
9c68ceafeb | ||
|
|
a8395b4e4c | ||
|
|
adde94e430 | ||
|
|
1507d140b8 | ||
|
|
7504fedb17 | ||
|
|
257f7ee7b2 | ||
|
|
8852c7ea4a | ||
|
|
c39d8db12a | ||
|
|
3505a5ff27 | ||
|
|
926a4c59cb | ||
|
|
65751186bd | ||
|
|
83aa35b83e | ||
|
|
a3b3dbafde | ||
|
|
f62d6ea3d7 | ||
|
|
311f226d44 | ||
|
|
44e0259163 | ||
|
|
aa420e2060 | ||
|
|
a04553013d | ||
|
|
a901992d03 | ||
|
|
b29c200801 | ||
|
|
77320b617f | ||
|
|
35fa022e2e | ||
|
|
3137ee4946 | ||
|
|
203c4d83df | ||
|
|
975fd42e32 | ||
|
|
8c376d2b0e | ||
|
|
1fda906407 | ||
|
|
37f5635f8f | ||
|
|
e72b9a8a95 | ||
|
|
87babb58d5 | ||
|
|
258e6fc0d4 | ||
|
|
42ae905df7 | ||
|
|
cc6e43da44 | ||
|
|
f2f437f4a8 | ||
|
|
76a7685105 | ||
|
|
69ab350919 | ||
|
|
0db441b191 | ||
|
|
f96edfe5ca | ||
|
|
b89141679f | ||
|
|
f667667da3 | ||
|
|
6b7dd4ea28 | ||
|
|
5b87e69221 | ||
|
|
7b43fa9372 | ||
|
|
c12ae79c42 | ||
|
|
16f7feee0a | ||
|
|
c7ddfd7dd2 | ||
|
|
21b6f9344e | ||
|
|
37356d8e3e | ||
|
|
c0c1ceb36d | ||
|
|
3799dc102f | ||
|
|
a493fb49b0 | ||
|
|
18a445ba22 | ||
|
|
4e769d537c | ||
|
|
06a3468967 | ||
|
|
dc8a882f1f | ||
|
|
ff93a6c63b | ||
|
|
15a2662119 | ||
|
|
6063fc51e0 | ||
|
|
aeabd0e7f2 | ||
|
|
028a39b42c | ||
|
|
68efa8ee53 | ||
|
|
096468a29f | ||
|
|
6bfe824eb4 | ||
|
|
5b12bf3f55 | ||
|
|
2e2da2aefe | ||
|
|
2b2943a7b7 | ||
|
|
5236a62de1 | ||
|
|
521848da75 | ||
|
|
c075abce5d | ||
|
|
de92bbe47a | ||
|
|
fd89d098a1 | ||
|
|
5029c8570e | ||
|
|
bd2fa82c1e | ||
|
|
3c6ae3713e | ||
|
|
934bca9e48 | ||
|
|
119075f368 | ||
|
|
c3228f8b59 | ||
|
|
537b4bcff7 | ||
|
|
34c81e5b16 | ||
|
|
d0c925f6c8 | ||
|
|
cfb7ec1c71 | ||
|
|
aa0a9e8b5a | ||
|
|
c85da91964 | ||
|
|
eddb9ce6db | ||
|
|
6c15dc7d33 | ||
|
|
4cec31d988 | ||
|
|
064c1ace91 | ||
|
|
db10c8d95b | ||
|
|
8e87d3002b | ||
|
|
f975279b26 | ||
|
|
8efd7b3df6 | ||
|
|
4709bb022e | ||
|
|
7f97ab3032 | ||
|
|
d73c0a13f4 | ||
|
|
e084dc993e | ||
|
|
fb0f8484d7 | ||
|
|
51cf63009e | ||
|
|
3885f9fa90 | ||
|
|
f8e3bfeaff | ||
|
|
e8dea7d456 | ||
|
|
2e961cee93 | ||
|
|
033e7a8f0d | ||
|
|
4bbffbfb05 | ||
|
|
6255852bef | ||
|
|
ff8b9e9ab6 | ||
|
|
d92d6b5e67 | ||
|
|
100772e3c6 | ||
|
|
f45c3cc90f | ||
|
|
7cc8fdb2f5 | ||
|
|
8115cb01a2 | ||
|
|
dac64f20d9 | ||
|
|
8ad436d25a | ||
|
|
bd78e1d5c2 | ||
|
|
62f06ab740 | ||
|
|
78747b56ca | ||
|
|
996a2027dd | ||
|
|
514050d098 | ||
|
|
4307cac966 | ||
|
|
67517eaed1 | ||
|
|
4e440a9fd5 | ||
|
|
d06b6d87d3 | ||
|
|
0b0be3c339 | ||
|
|
6ea8fec896 | ||
|
|
e249fa4557 | ||
|
|
8f0518c0ae | ||
|
|
280c29d572 | ||
|
|
b5747e3979 | ||
|
|
323f027865 | ||
|
|
4218bfe71e | ||
|
|
12ba535c7d | ||
|
|
493c815371 | ||
|
|
cd247ed107 | ||
|
|
9389cabed0 | ||
|
|
da3876c168 | ||
|
|
431b98525b | ||
|
|
6c654fb0ef | ||
|
|
a8a34bd22a | ||
|
|
f6b0dcbe58 | ||
|
|
9d0a18a5b5 | ||
|
|
ccc0b9ab0c | ||
|
|
5268a546bc | ||
|
|
cd4f496cb4 | ||
|
|
65b5d65059 | ||
|
|
d46c406bd8 | ||
|
|
e129378014 | ||
|
|
eda99360d1 | ||
|
|
acfdcf346c | ||
|
|
5607db85d4 | ||
|
|
97528be0f4 | ||
|
|
e2ba068cbc | ||
|
|
1a7b90623c | ||
|
|
0f3280ee05 | ||
|
|
05fb48e9d5 | ||
|
|
19a4f823a4 | ||
|
|
c90a7a19a5 | ||
|
|
77611f0491 | ||
|
|
3d109cbaca | ||
|
|
6fca7e86b7 | ||
|
|
8b3c0d8ad4 | ||
|
|
8455b88643 | ||
|
|
517aa593e0 | ||
|
|
bd3204fe96 | ||
|
|
385c3c3cf3 | ||
|
|
25dcb1b10f | ||
|
|
32efeeb3a2 | ||
|
|
c99193041a | ||
|
|
ffb77d7ff2 | ||
|
|
66cd44b66d | ||
|
|
fca8d68aba | ||
|
|
f691537472 | ||
|
|
90878c380e | ||
|
|
d1d047dd11 | ||
|
|
e58e4a185d | ||
|
|
9a1893760d | ||
|
|
966b53df77 | ||
|
|
bdc99d746a | ||
|
|
3319310942 | ||
|
|
247df16e82 | ||
|
|
1882ad68ea | ||
|
|
35ba56fa0c | ||
|
|
5645cce6d0 | ||
|
|
a857d2016d | ||
|
|
649cacfa05 | ||
|
|
cb3d86608c | ||
|
|
e73f3edc06 | ||
|
|
f13d897093 | ||
|
|
2b39412355 | ||
|
|
7669696bb0 | ||
|
|
15c7cb58e7 | ||
|
|
634c16a019 | ||
|
|
48d3b7abab | ||
|
|
9d8eebfa63 | ||
|
|
c303a29670 | ||
|
|
a327df6936 | ||
|
|
54ac4a74fb | ||
|
|
bc00a9fc7f | ||
|
|
6776c7d691 | ||
|
|
2d6b777799 | ||
|
|
360bc83a66 | ||
|
|
1cc2e90ac0 | ||
|
|
eff762cdd9 | ||
|
|
a2f6183c4a | ||
|
|
e152a339d1 | ||
|
|
00f16d66a3 | ||
|
|
f58fbc8b85 | ||
|
|
610968f91e | ||
|
|
0e31c38be0 | ||
|
|
63a947d246 | ||
|
|
c1df31fda5 | ||
|
|
55fa223e4d | ||
|
|
cc40a3263e | ||
|
|
f4aef0dcf7 | ||
|
|
000bd6d1ab | ||
|
|
4a84a895a0 | ||
|
|
1007703223 | ||
|
|
290cd7f1cd | ||
|
|
7448b88652 | ||
|
|
b3599d84f7 | ||
|
|
fd04ad353d | ||
|
|
8e918dfdbb | ||
|
|
f65c89e02e | ||
|
|
784e60fc66 | ||
|
|
54ecfa39cf | ||
|
|
3f6d630b56 | ||
|
|
aba15e7b6d | ||
|
|
5ebe9ee67a | ||
|
|
d44f40b724 | ||
|
|
1c9623a81d | ||
|
|
da97f1b9ad | ||
|
|
50b81622e0 | ||
|
|
6a78b02976 | ||
|
|
d60ff44c1b | ||
|
|
7187118aa6 | ||
|
|
564e1ae3ff | ||
|
|
e84411b86e | ||
|
|
1ecff0ff8c | ||
|
|
6cdf3951f7 | ||
|
|
96618b01c0 | ||
|
|
cb13d09029 | ||
|
|
1494a0b7ee | ||
|
|
c0466274ed | ||
|
|
ab0a480f30 | ||
|
|
cb6f6b65ea | ||
|
|
cd53ad01e8 | ||
|
|
81109b85d3 | ||
|
|
cd0c5fec03 | ||
|
|
ed946d8e61 | ||
|
|
1ff8669199 | ||
|
|
7f9afe75e2 | ||
|
|
b7477d063a | ||
|
|
59516ec126 | ||
|
|
637c7511a2 | ||
|
|
4a112175e2 | ||
|
|
eda0f1258a | ||
|
|
d5c7e3d3e4 | ||
|
|
3959eec602 | ||
|
|
5a5e0e9823 | ||
|
|
3c1e0edea3 | ||
|
|
2d7d7b2412 | ||
|
|
e5cae37d15 | ||
|
|
5f2509d6a8 | ||
|
|
7242431335 | ||
|
|
2e0b384d72 | ||
|
|
b3765c7b63 | ||
|
|
b041e53c0b | ||
|
|
0ae30211d8 | ||
|
|
6873b60721 | ||
|
|
c1cb6f0d55 | ||
|
|
664acf73ee | ||
|
|
7ef7791ac8 | ||
|
|
3224cd2ec7 | ||
|
|
49ae46001c | ||
|
|
d2bad10781 | ||
|
|
7b9ef95b60 | ||
|
|
a8d9a180d9 | ||
|
|
d42e6a7acc | ||
|
|
5dd5847d4b | ||
|
|
7d10fb6260 | ||
|
|
b70ae56ffa | ||
|
|
491a8a5480 | ||
|
|
26483661da | ||
|
|
7268c49992 | ||
|
|
cd6041477c | ||
|
|
a96593a99b | ||
|
|
7a830e504d | ||
|
|
63d93ff211 | ||
|
|
5da662441c | ||
|
|
d885c70462 | ||
|
|
3ef88fc7ff | ||
|
|
e03491664a | ||
|
|
360500315c | ||
|
|
ad445a1b30 | ||
|
|
a4c2a6990a | ||
|
|
471ee494f0 | ||
|
|
7a3871fc95 | ||
|
|
b4b1d00cc5 | ||
|
|
afcdfd289d | ||
|
|
62c60fc484 | ||
|
|
853576273a | ||
|
|
21b40195b7 | ||
|
|
0aea1736ba | ||
|
|
70a71f603c | ||
|
|
11c2931efb | ||
|
|
a51a1fc4fc | ||
|
|
35f11f2edc | ||
|
|
47a6b510e1 | ||
|
|
5e47e69e99 | ||
|
|
9b1acf6612 | ||
|
|
f853a3fc67 | ||
|
|
e7d61c724f | ||
|
|
42380a8693 | ||
|
|
4bbf82c2ab | ||
|
|
370fe6b501 | ||
|
|
74dedcad37 | ||
|
|
7711e14f90 | ||
|
|
033852ab14 | ||
|
|
f2d55f8726 | ||
|
|
743c074b2e | ||
|
|
e5b927597e | ||
|
|
15822e91ff | ||
|
|
4b72dd407b | ||
|
|
39cec53284 | ||
|
|
448401a0fc | ||
|
|
b2e8d692a4 | ||
|
|
d36896c5f7 | ||
|
|
758a1824c7 | ||
|
|
e1102585bf | ||
|
|
92a81480f7 | ||
|
|
7be4ece224 | ||
|
|
00320972dc | ||
|
|
3c6b084f08 | ||
|
|
766ddcaa99 | ||
|
|
508fabcb3b | ||
|
|
c38932e6c6 | ||
|
|
07d92556a3 | ||
|
|
6ad617931d | ||
|
|
04fd963394 | ||
|
|
1eff46579a | ||
|
|
171c29dcf3 | ||
|
|
9e8de43f25 | ||
|
|
353795f0dc | ||
|
|
26858560c8 | ||
|
|
5ed9b74cd0 | ||
|
|
6a2f0d5904 | ||
|
|
5c390d6b3e | ||
|
|
fd2ea71cec | ||
|
|
5de7afd696 | ||
|
|
9955f5bc95 | ||
|
|
16d6484492 | ||
|
|
3884f2b8b7 | ||
|
|
df7d32c70c | ||
|
|
2f87dbcfbc | ||
|
|
5b1e56407b | ||
|
|
92c2392fd6 | ||
|
|
dea917b23f | ||
|
|
c9c6b919ff | ||
|
|
be260f43e8 | ||
|
|
e77d87fa80 | ||
|
|
8874a11baf | ||
|
|
b175acd827 | ||
|
|
ca6907239c | ||
|
|
8f93d44917 | ||
|
|
14e8cffa41 | ||
|
|
aad43a050b | ||
|
|
3cbfa98c30 | ||
|
|
8253422832 | ||
|
|
493c536199 | ||
|
|
0888a3b3e6 | ||
|
|
ead7c01822 | ||
|
|
30594b4e0b | ||
|
|
f1817fd560 | ||
|
|
b998c52dd0 | ||
|
|
6872679f31 | ||
|
|
ee62acd3f1 | ||
|
|
ace470b437 | ||
|
|
e846269d61 | ||
|
|
cb8a0b268d | ||
|
|
7e7e441fec | ||
|
|
93d3cc49c2 | ||
|
|
2c4b8b57dd | ||
|
|
4dbc0fe73a | ||
|
|
0be870f837 | ||
|
|
0532fed989 | ||
|
|
ec43ba83dd | ||
|
|
09acf955f1 | ||
|
|
5c142ec34a | ||
|
|
c5bbac55c4 | ||
|
|
c43c995bd2 | ||
|
|
d6c4b70507 | ||
|
|
4c0aadbb5e | ||
|
|
2537b80f88 | ||
|
|
0a7de1fdf4 | ||
|
|
8218421733 | ||
|
|
7023468cea | ||
|
|
8df5ed2a96 | ||
|
|
a4349f4b29 | ||
|
|
17fe738659 | ||
|
|
791fd4711b | ||
|
|
490c8e1b91 | ||
|
|
c6bb4d16bb | ||
|
|
26364cc691 | ||
|
|
b8fefe1a82 | ||
|
|
24f73bf539 | ||
|
|
3aa3f0fbc0 | ||
|
|
440373b96d | ||
|
|
ff81a22285 | ||
|
|
91d3511580 | ||
|
|
32e7cec362 | ||
|
|
791939014c | ||
|
|
4e79ddcfb7 | ||
|
|
a66f241e21 | ||
|
|
1ce00b5dea | ||
|
|
864e7ad558 | ||
|
|
411cb872cc | ||
|
|
99ad456adf | ||
|
|
b4a1d88beb | ||
|
|
83bab67641 | ||
|
|
c97375343d | ||
|
|
c953c078e5 | ||
|
|
058d32451c | ||
|
|
415d115b17 | ||
|
|
577f2cfc18 | ||
|
|
178befddd7 | ||
|
|
6c68631c26 | ||
|
|
4a04068818 |
@@ -10,16 +10,6 @@ dist/
|
|||||||
build/
|
build/
|
||||||
.env
|
.env
|
||||||
.env.bak.*
|
.env.bak.*
|
||||||
# Secrets: keep plaintext and every transient secrets.env variant out of
|
|
||||||
# the build context. If an encrypted secrets.env is used, it is mounted
|
|
||||||
# at runtime — never baked into the image. Mirrored in .gitignore.
|
|
||||||
secrets.env
|
|
||||||
secrets.env.*
|
|
||||||
secrets.env~
|
|
||||||
.secrets.env.swp
|
|
||||||
.secrets.env.swo
|
|
||||||
**/#secrets.env#
|
|
||||||
!secrets.env.example
|
|
||||||
/data/
|
/data/
|
||||||
/logs/
|
/logs/
|
||||||
.git/
|
.git/
|
||||||
|
|||||||
@@ -27,16 +27,6 @@ LLM_HOST=localhost
|
|||||||
# Research service LLM endpoint
|
# Research service LLM endpoint
|
||||||
# RESEARCH_LLM_ENDPOINT=http://localhost:8000/v1/chat/completions
|
# RESEARCH_LLM_ENDPOINT=http://localhost:8000/v1/chat/completions
|
||||||
|
|
||||||
# Extra CA bundle for LLM providers whose TLS chain isn't in the default
|
|
||||||
# trust store. Layered ON TOP of the system / certifi bundle — verification
|
|
||||||
# stays on for every host, the trust set just gets larger. Useful for:
|
|
||||||
# - GigaChat / Sber (Russian Trusted Root CA): without this the endpoint
|
|
||||||
# shows offline with CERTIFICATE_VERIFY_FAILED — self-signed certificate
|
|
||||||
# in certificate chain.
|
|
||||||
# - On-premise / corporate LLM gateways with an internal CA.
|
|
||||||
# Point at a PEM file containing the missing root(s).
|
|
||||||
# LLM_CA_BUNDLE=/etc/odysseus/ca/extra-roots.pem
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Search & Web
|
# Search & Web
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -56,13 +46,6 @@ SEARXNG_INSTANCE=http://localhost:8080
|
|||||||
# SQLite database path (default: sqlite:///./data/app.db)
|
# SQLite database path (default: sqlite:///./data/app.db)
|
||||||
# DATABASE_URL=sqlite:///./data/app.db
|
# DATABASE_URL=sqlite:///./data/app.db
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# Data directory
|
|
||||||
# ============================================================
|
|
||||||
# Move everything that lives under data/ - settings, sessions, database, auth,
|
|
||||||
# cache, uploads, etc. - to another path:
|
|
||||||
# ODYSSEUS_DATA_DIR=C:\path\to\dir
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Auth & Security
|
# Auth & Security
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -119,9 +102,6 @@ SEARXNG_INSTANCE=http://localhost:8080
|
|||||||
# Default: http://{LLM_HOST}:11434/v1/embeddings (ollama)
|
# Default: http://{LLM_HOST}:11434/v1/embeddings (ollama)
|
||||||
# EMBEDDING_URL=http://localhost:11434/v1/embeddings
|
# EMBEDDING_URL=http://localhost:11434/v1/embeddings
|
||||||
|
|
||||||
# Embedding API key (if there's one)
|
|
||||||
# EMBEDDING_API_KEY=embedding_api_key_here
|
|
||||||
|
|
||||||
# Embedding model name (must be available at the endpoint above)
|
# Embedding model name (must be available at the endpoint above)
|
||||||
# EMBEDDING_MODEL=all-minilm:l6-v2
|
# EMBEDDING_MODEL=all-minilm:l6-v2
|
||||||
|
|
||||||
@@ -130,27 +110,6 @@ SEARXNG_INSTANCE=http://localhost:8080
|
|||||||
# FASTEMBED_MODEL=sentence-transformers/all-MiniLM-L6-v2
|
# FASTEMBED_MODEL=sentence-transformers/all-MiniLM-L6-v2
|
||||||
# FASTEMBED_CACHE_PATH= # defaults to ~/.cache/fastembed
|
# FASTEMBED_CACHE_PATH= # defaults to ~/.cache/fastembed
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# Google OAuth2 (Google Workspace / .edu email accounts)
|
|
||||||
# ============================================================
|
|
||||||
# Required to use the "Connect with Google" OAuth flow in email account setup.
|
|
||||||
# Create credentials at: console.cloud.google.com → APIs & Services → Credentials
|
|
||||||
# 1. Enable the Gmail API for your project.
|
|
||||||
# 2. Configure the OAuth consent screen (User Type: Internal for Workspace orgs).
|
|
||||||
# Add scopes: https://mail.google.com/ and email.
|
|
||||||
# 3. Create an OAuth 2.0 Client ID (type: Web application).
|
|
||||||
# Add your redirect URI: http://localhost:7000/api/email/oauth/google/callback
|
|
||||||
# (replace host/port for hosted installs).
|
|
||||||
# 4. Copy the Client ID and Client Secret below.
|
|
||||||
#
|
|
||||||
# GOOGLE_OAUTH_CLIENT_ID=your-client-id.apps.googleusercontent.com
|
|
||||||
# GOOGLE_OAUTH_CLIENT_SECRET=replace-with-client-secret
|
|
||||||
#
|
|
||||||
# Set this explicitly for HTTPS, reverse-proxy, or hosted deployments. The
|
|
||||||
# value must exactly match an authorized redirect URI in the Google client.
|
|
||||||
# Local HTTP setups may use the callback URL inferred by the application.
|
|
||||||
# GOOGLE_OAUTH_REDIRECT_URI=https://your-domain.com/api/email/oauth/google/callback
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Misc
|
# Misc
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -175,41 +134,6 @@ SEARXNG_INSTANCE=http://localhost:8080
|
|||||||
# if you intentionally want scheduled scripts to run remotely.
|
# if you intentionally want scheduled scripts to run remotely.
|
||||||
# ODYSSEUS_SCRIPT_HOST=localhost
|
# ODYSSEUS_SCRIPT_HOST=localhost
|
||||||
|
|
||||||
# Chat / agent attachment size cap in bytes (default: 10 MB).
|
|
||||||
# Raise this for local installs that need larger PDFs or text documents.
|
|
||||||
# Example: 52428800 = 50 MB.
|
|
||||||
# ODYSSEUS_CHAT_UPLOAD_MAX_BYTES=10485760
|
|
||||||
|
|
||||||
# Other per-feature upload size caps in bytes. All are validated and optional;
|
|
||||||
# defaults shown. An invalid value (non-integer or < 1) fails fast at startup.
|
|
||||||
# ODYSSEUS_GALLERY_UPLOAD_MAX_BYTES=104857600 # gallery image upload (100 MB)
|
|
||||||
# ODYSSEUS_GALLERY_TRANSFORM_UPLOAD_MAX_BYTES=26214400 # gallery transform input (25 MB)
|
|
||||||
# ODYSSEUS_MEMORY_IMPORT_MAX_BYTES=10485760 # memory import file (10 MB)
|
|
||||||
# ODYSSEUS_PERSONAL_UPLOAD_MAX_BYTES=26214400 # personal document upload (25 MB)
|
|
||||||
# ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES=26214400 # email compose attachment (25 MB)
|
|
||||||
# ODYSSEUS_STT_MAX_AUDIO_BYTES=26214400 # speech-to-text audio (25 MB)
|
|
||||||
# ODYSSEUS_ICS_MAX_BYTES=10485760 # calendar .ics import (10 MB)
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# Host Docker access (explicit opt-in)
|
|
||||||
# ============================================================
|
|
||||||
# Default Docker Compose does not mount /var/run/docker.sock. Existing
|
|
||||||
# Ollama, vLLM, and other OpenAI-compatible endpoints remain usable without it.
|
|
||||||
#
|
|
||||||
# Enable this only for intentional Cookbook/local Docker-daemon management.
|
|
||||||
# Raw socket access is high-trust and can grant broad control over the host
|
|
||||||
# Docker daemon. Set DOCKER_GID to the host docker group's numeric GID.
|
|
||||||
# Put these values in .env, or export them before running docker compose.
|
|
||||||
# COMPOSE_FILE=docker-compose.yml:docker/host-docker.yml
|
|
||||||
# DOCKER_GID=963
|
|
||||||
# docker/host-docker.yml sets this inside the container. Keep it paired
|
|
||||||
# with the socket overlay; setting it alone is not sufficient.
|
|
||||||
# ODYSSEUS_ENABLE_HOST_DOCKER=true
|
|
||||||
#
|
|
||||||
# Host Docker access can be combined with one GPU overlay:
|
|
||||||
# COMPOSE_FILE=docker-compose.yml:docker/gpu.nvidia.yml:docker/host-docker.yml
|
|
||||||
# COMPOSE_FILE=docker-compose.yml:docker/gpu.amd.yml:docker/host-docker.yml
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# GPU support (Docker Compose)
|
# GPU support (Docker Compose)
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -231,10 +155,3 @@ SEARXNG_INSTANCE=http://localhost:8080
|
|||||||
# These overlays only expose the GPU devices. The slim Odysseus image
|
# These overlays only expose the GPU devices. The slim Odysseus image
|
||||||
# still needs CUDA/ROCm userspace via Cookbook -> Dependencies (vLLM,
|
# still needs CUDA/ROCm userspace via Cookbook -> Dependencies (vLLM,
|
||||||
# llama-cpp-python, etc.) before models can actually serve on GPU.
|
# llama-cpp-python, etc.) before models can actually serve on GPU.
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# Storage Paths (Docker Compose)
|
|
||||||
# ============================================================
|
|
||||||
|
|
||||||
# APP_DATA_DIR=./data
|
|
||||||
# APP_LOGS_DIR=./logs
|
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
# Code owners.
|
|
||||||
#
|
|
||||||
# Intentionally empty for now. The catch-all rule that mapped every path to a
|
|
||||||
# single owner froze all merges the moment "Require review from Code Owners"
|
|
||||||
# was enabled, because no other maintainer's approval could satisfy the gate.
|
|
||||||
# A per-area ownership map (security/auth, CI, frontend, agent internals, with
|
|
||||||
# multiple named owners per line) is being worked out in issue #593; once
|
|
||||||
# agreed it replaces this file. Until then, required reviews and the security
|
|
||||||
# CI gate (docs/security-ci.md) remain in force via branch protection.
|
|
||||||
@@ -6,24 +6,24 @@ body:
|
|||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
value: |
|
value: |
|
||||||
**Before submitting:** search [open issues](https://github.com/odysseus-dev/odysseus/issues)
|
**Before submitting:** search [open issues](https://github.com/pewdiepie-archdaemon/odysseus/issues)
|
||||||
and [discussions](https://github.com/odysseus-dev/odysseus/discussions) first.
|
and [discussions](https://github.com/pewdiepie-archdaemon/odysseus/discussions) first.
|
||||||
Duplicate reports slow things down.
|
Duplicate reports slow things down.
|
||||||
|
|
||||||
For security vulnerabilities, **do not open a public issue** —
|
For security vulnerabilities, **do not open a public issue** —
|
||||||
use [GitHub Security Advisories](https://github.com/odysseus-dev/odysseus/security/advisories/new)
|
use [GitHub Security Advisories](https://github.com/pewdiepie-archdaemon/odysseus/security/advisories/new)
|
||||||
and read [SECURITY.md](https://github.com/odysseus-dev/odysseus/blob/main/SECURITY.md) first.
|
and read [SECURITY.md](https://github.com/pewdiepie-archdaemon/odysseus/blob/main/SECURITY.md) first.
|
||||||
|
|
||||||
- type: checkboxes
|
- type: checkboxes
|
||||||
id: prerequisites
|
id: prerequisites
|
||||||
attributes:
|
attributes:
|
||||||
label: Prerequisites
|
label: Prerequisites
|
||||||
options:
|
options:
|
||||||
- label: I searched [open issues](https://github.com/odysseus-dev/odysseus/issues?q=is%3Aissue+is%3Aopen) and [discussions](https://github.com/odysseus-dev/odysseus/discussions) and did not find an existing report of this bug.
|
- label: I searched [open issues](https://github.com/pewdiepie-archdaemon/odysseus/issues?q=is%3Aissue+is%3Aopen) and [discussions](https://github.com/pewdiepie-archdaemon/odysseus/discussions) and did not find an existing report of this bug.
|
||||||
required: true
|
required: true
|
||||||
- label: This is **not** a security vulnerability. (Vulnerabilities go to [GitHub Security Advisories](https://github.com/odysseus-dev/odysseus/security/advisories/new) — see [SECURITY.md](https://github.com/odysseus-dev/odysseus/blob/main/SECURITY.md).)
|
- label: This is **not** a security vulnerability. (Vulnerabilities go to [GitHub Security Advisories](https://github.com/pewdiepie-archdaemon/odysseus/security/advisories/new) — see [SECURITY.md](https://github.com/pewdiepie-archdaemon/odysseus/blob/main/SECURITY.md).)
|
||||||
required: true
|
required: true
|
||||||
- label: I am running the latest code from the `dev` branch (the default branch you get on clone, where fixes land first) and the bug still reproduces there. Please `git pull` the latest `dev` before filing.
|
- label: I am running the latest code from `main`.
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
blank_issues_enabled: false
|
blank_issues_enabled: false
|
||||||
contact_links:
|
contact_links:
|
||||||
- name: Question / Need Help
|
- name: Question / Need Help
|
||||||
url: https://github.com/odysseus-dev/odysseus/discussions/categories/q-a
|
url: https://github.com/pewdiepie-archdaemon/odysseus/discussions/categories/q-a
|
||||||
about: Ask how-to questions, setup help, and model configuration questions here. Issues are for confirmed bugs and concrete proposals only.
|
about: Ask how-to questions, setup help, and model configuration questions here. Issues are for confirmed bugs and concrete proposals only.
|
||||||
|
|
||||||
- name: Idea or Suggestion
|
- name: Idea or Suggestion
|
||||||
url: https://github.com/odysseus-dev/odysseus/discussions/categories/ideas
|
url: https://github.com/pewdiepie-archdaemon/odysseus/discussions/categories/ideas
|
||||||
about: Discuss ideas and gauge interest before opening a formal feature request. If there is already a discussion, link it in your feature request.
|
about: Discuss ideas and gauge interest before opening a formal feature request. If there is already a discussion, link it in your feature request.
|
||||||
|
|
||||||
- name: Security Vulnerability
|
- name: Security Vulnerability
|
||||||
url: https://github.com/odysseus-dev/odysseus/security/advisories/new
|
url: https://github.com/pewdiepie-archdaemon/odysseus/security/advisories/new
|
||||||
about: Report vulnerabilities privately via GitHub Security Advisories — never as a public issue. Read SECURITY.md before reporting.
|
about: Report vulnerabilities privately via GitHub Security Advisories — never as a public issue. Read SECURITY.md before reporting.
|
||||||
|
|||||||
@@ -6,22 +6,22 @@ body:
|
|||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
value: |
|
value: |
|
||||||
**Before submitting:** search [open issues](https://github.com/odysseus-dev/odysseus/issues)
|
**Before submitting:** search [open issues](https://github.com/pewdiepie-archdaemon/odysseus/issues)
|
||||||
and [discussions](https://github.com/odysseus-dev/odysseus/discussions) first.
|
and [discussions](https://github.com/pewdiepie-archdaemon/odysseus/discussions) first.
|
||||||
The [roadmap](https://github.com/odysseus-dev/odysseus/blob/main/ROADMAP.md) is directional rather than a complete backlog.
|
Feature requests that duplicate [ROADMAP.md](https://github.com/pewdiepie-archdaemon/odysseus/blob/main/ROADMAP.md)
|
||||||
Feature requests that duplicate an existing issue or accepted proposal may be closed as duplicates.
|
or an existing open issue will be closed as duplicates.
|
||||||
|
|
||||||
If your idea needs community input before it becomes a concrete proposal,
|
If your idea needs community input before it becomes a concrete proposal,
|
||||||
start a [discussion](https://github.com/odysseus-dev/odysseus/discussions/categories/ideas) instead.
|
start a [discussion](https://github.com/pewdiepie-archdaemon/odysseus/discussions/categories/ideas) instead.
|
||||||
|
|
||||||
- type: checkboxes
|
- type: checkboxes
|
||||||
id: prerequisites
|
id: prerequisites
|
||||||
attributes:
|
attributes:
|
||||||
label: Prerequisites
|
label: Prerequisites
|
||||||
options:
|
options:
|
||||||
- label: I searched [open issues](https://github.com/odysseus-dev/odysseus/issues?q=is%3Aissue+is%3Aopen) and this has not already been proposed.
|
- label: I searched [open issues](https://github.com/pewdiepie-archdaemon/odysseus/issues?q=is%3Aissue+is%3Aopen) and this has not already been proposed.
|
||||||
required: true
|
required: true
|
||||||
- label: I searched [discussions](https://github.com/odysseus-dev/odysseus/discussions) and this is not already being debated there.
|
- label: I searched [discussions](https://github.com/pewdiepie-archdaemon/odysseus/discussions) and this is not already being debated there.
|
||||||
required: true
|
required: true
|
||||||
- label: This is a concrete, actionable proposal — not a vague "it would be nice if..." request.
|
- label: This is a concrete, actionable proposal — not a vague "it would be nice if..." request.
|
||||||
required: true
|
required: true
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
# Dependabot keeps dependencies and pinned action versions current.
|
|
||||||
#
|
|
||||||
# Why this matters for security: every workflow in this repo pins its GitHub
|
|
||||||
# Actions to an exact commit (a SHA), which is safe but freezes them in time.
|
|
||||||
# Dependabot opens a small, reviewable pull request whenever a newer version
|
|
||||||
# exists -- for Python packages, npm packages, the Docker base image, and the
|
|
||||||
# pinned Actions themselves -- so staying patched does not require manual work.
|
|
||||||
# Updates are grouped so a week's bumps arrive as one PR per ecosystem, not a
|
|
||||||
# flood of separate ones.
|
|
||||||
|
|
||||||
version: 2
|
|
||||||
updates:
|
|
||||||
# Python dependencies (requirements.txt + requirements-optional.txt).
|
|
||||||
- package-ecosystem: pip
|
|
||||||
directory: "/"
|
|
||||||
schedule:
|
|
||||||
interval: weekly
|
|
||||||
open-pull-requests-limit: 5
|
|
||||||
groups:
|
|
||||||
python:
|
|
||||||
patterns: ["*"]
|
|
||||||
|
|
||||||
# Frontend / tooling npm packages (package.json).
|
|
||||||
- package-ecosystem: npm
|
|
||||||
directory: "/"
|
|
||||||
schedule:
|
|
||||||
interval: weekly
|
|
||||||
open-pull-requests-limit: 5
|
|
||||||
groups:
|
|
||||||
npm:
|
|
||||||
patterns: ["*"]
|
|
||||||
|
|
||||||
# The pinned action SHAs used across .github/workflows.
|
|
||||||
- package-ecosystem: github-actions
|
|
||||||
directory: "/"
|
|
||||||
schedule:
|
|
||||||
interval: weekly
|
|
||||||
open-pull-requests-limit: 5
|
|
||||||
groups:
|
|
||||||
actions:
|
|
||||||
patterns: ["*"]
|
|
||||||
|
|
||||||
# The Docker base image in the Dockerfile.
|
|
||||||
- package-ecosystem: docker
|
|
||||||
directory: "/"
|
|
||||||
schedule:
|
|
||||||
interval: weekly
|
|
||||||
open-pull-requests-limit: 5
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
# Pull Request Review Template
|
|
||||||
|
|
||||||
Use this shape as a copyable reference for substantive PR reviews; GitHub does
|
|
||||||
not auto-apply this file to review comments. Omit sections that do not add
|
|
||||||
useful signal. Lead with confirmed findings; keep speculative notes out of the
|
|
||||||
public review unless they are framed as a concrete open question.
|
|
||||||
|
|
||||||
## Small PR Path
|
|
||||||
|
|
||||||
For narrow docs, typo, test-only, or obvious local fixes, a short review is
|
|
||||||
enough:
|
|
||||||
|
|
||||||
```md
|
|
||||||
LGTM after checking:
|
|
||||||
- scope:
|
|
||||||
- validation:
|
|
||||||
- residual risk:
|
|
||||||
```
|
|
||||||
|
|
||||||
Use the fuller structure below for larger, risky, multi-finding, or
|
|
||||||
security-sensitive reviews.
|
|
||||||
|
|
||||||
## Findings
|
|
||||||
|
|
||||||
**<sub><sub></sub></sub> issue (test): Short issue title**
|
|
||||||
|
|
||||||
- **Problem:** Concrete broken flow, contract, input, or risk.
|
|
||||||
|
|
||||||
- **Impact:** Why this matters to users, CI, maintainers, data, security, or scale.
|
|
||||||
|
|
||||||
- **Ask:** Smallest practical correction or decision the author should make.
|
|
||||||
|
|
||||||
- **Location:** `path:line`
|
|
||||||
|
|
||||||
## Open Questions
|
|
||||||
|
|
||||||
- **question (scope, non-blocking): Short author question** Ask the concrete
|
|
||||||
intent, scope, or tradeoff question.
|
|
||||||
|
|
||||||
## Validation
|
|
||||||
|
|
||||||
- Ran:
|
|
||||||
- Not run:
|
|
||||||
- Residual risk:
|
|
||||||
|
|
||||||
## PR Hygiene
|
|
||||||
|
|
||||||
- Target/template/checks:
|
|
||||||
- Related, duplicate, or superseding context:
|
|
||||||
|
|
||||||
## No Findings Variant
|
|
||||||
|
|
||||||
```md
|
|
||||||
## Findings
|
|
||||||
|
|
||||||
none confirmed
|
|
||||||
|
|
||||||
## Validation
|
|
||||||
|
|
||||||
- Ran:
|
|
||||||
- Not run:
|
|
||||||
- Residual risk:
|
|
||||||
```
|
|
||||||
|
|
||||||
## Legend
|
|
||||||
|
|
||||||
- **Findings:** Verified, author-actionable issues that should be fixed or
|
|
||||||
consciously accepted before merge.
|
|
||||||
- **Priority badges:** The shields.io badges below are optional formatting for
|
|
||||||
priority labels. Plain `P0`, `P1`, `P2`, or `P3` text is also acceptable when
|
|
||||||
an external image dependency is undesirable or may not render.
|
|
||||||
- **P0:** `` -
|
|
||||||
release-blocking or actively dangerous.
|
|
||||||
- **P1:** `` -
|
|
||||||
serious bug, security risk, data-loss risk, or broken primary flow.
|
|
||||||
- **P2:** `` -
|
|
||||||
meaningful correctness, test, maintainability, or edge-case issue.
|
|
||||||
- **P3:** `` -
|
|
||||||
minor polish or low-risk cleanup.
|
|
||||||
- **Intent labels:**
|
|
||||||
- **`issue`:** A confirmed defect, regression, broken contract, or concrete
|
|
||||||
risk.
|
|
||||||
- **`suggestion`:** A non-blocking improvement that would make the PR clearer,
|
|
||||||
safer, or easier to maintain.
|
|
||||||
- **`nit`:** A tiny, non-blocking cleanup or style note. Use it only when the
|
|
||||||
author can safely ignore it without changing the review outcome.
|
|
||||||
- **`question`:** A real author-facing clarification about intent, scope, or
|
|
||||||
tradeoffs. Do not use questions to hide an issue that should be stated
|
|
||||||
directly.
|
|
||||||
- **`LGTM`:** "Looks good to me." Use only when the review found no blocking
|
|
||||||
issues, or when any remaining notes are clearly optional.
|
|
||||||
- **Decorations:** Optional labels in parentheses that clarify the finding type,
|
|
||||||
scope, or merge impact.
|
|
||||||
- **`security`:** Auth, authorization, ownership, secrets, SSRF, injection,
|
|
||||||
unsafe external input, or other trust-boundary concerns.
|
|
||||||
- **`test`:** Missing, failing, misleading, brittle, or insufficient tests.
|
|
||||||
- **`scope`:** PR scope, feature boundaries, unrelated churn, or work that
|
|
||||||
should be split into a separate issue or PR.
|
|
||||||
- **`ci`:** CI configuration, workflow failures, flaky checks, or validation
|
|
||||||
signal quality.
|
|
||||||
- **`api`:** Route, request/response, public function, schema, persistence, or
|
|
||||||
integration contract changes.
|
|
||||||
- **`docs`:** User-facing docs, contributor docs, examples, or comments that
|
|
||||||
need to change with the code.
|
|
||||||
- **`non-blocking`:** Useful feedback that should not prevent merge by
|
|
||||||
itself.
|
|
||||||
- **Finding fields:**
|
|
||||||
- **Problem:** What is wrong, what contract is ambiguous, or what risk the PR
|
|
||||||
introduces.
|
|
||||||
- **Impact:** Why the problem matters in practical terms.
|
|
||||||
- **Ask:** The smallest concrete fix, test, or decision requested from the PR
|
|
||||||
author.
|
|
||||||
- **Location:** The most useful repo-relative file and line reference for the
|
|
||||||
finding, using `path:line`.
|
|
||||||
- **Optional sections:**
|
|
||||||
- **Open Questions:** Genuine scope or intent questions; omit when there are
|
|
||||||
no real questions.
|
|
||||||
- **Validation:** What the reviewer ran, what was intentionally not run, and
|
|
||||||
what risk remains after review.
|
|
||||||
- **PR Hygiene:** Target-branch, template, CI/check, duplicate, related-work,
|
|
||||||
or superseding-PR notes.
|
|
||||||
- **`none confirmed`:** Use only when no review-worthy findings were confirmed;
|
|
||||||
still list validation gaps or residual risk when relevant.
|
|
||||||
@@ -2,10 +2,6 @@
|
|||||||
|
|
||||||
<!-- One paragraph: what changed and why. "Fixed bug" and "Added feature" are not summaries. -->
|
<!-- One paragraph: what changed and why. "Fixed bug" and "Added feature" are not summaries. -->
|
||||||
|
|
||||||
## Target branch
|
|
||||||
|
|
||||||
- [ ] This PR targets **`dev`**, not `main`. All PRs land in `dev`; `main` is curated by the maintainer at each release. If your PR is on `main` by accident, click "Edit" on this PR and change the base.
|
|
||||||
|
|
||||||
## Linked Issue
|
## Linked Issue
|
||||||
|
|
||||||
<!-- Every PR should be linked to an issue.
|
<!-- Every PR should be linked to an issue.
|
||||||
@@ -24,8 +20,8 @@ Fixes #
|
|||||||
|
|
||||||
## Checklist
|
## Checklist
|
||||||
|
|
||||||
- [ ] I searched [open issues](https://github.com/odysseus-dev/odysseus/issues) and [open PRs](https://github.com/odysseus-dev/odysseus/pulls) — this is not a duplicate.
|
- [ ] I searched [open issues](https://github.com/pewdiepie-archdaemon/odysseus/issues) and [open PRs](https://github.com/pewdiepie-archdaemon/odysseus/pulls) — this is not a duplicate.
|
||||||
- [ ] This PR targets `dev`
|
- [ ] This PR targets `main`
|
||||||
- [ ] My changes are limited to the scope described above — no unrelated refactors or whitespace changes mixed in.
|
- [ ] My changes are limited to the scope described above — no unrelated refactors or whitespace changes mixed in.
|
||||||
- [ ] I actually ran the app (`docker compose up` or `uvicorn app:app`) and verified the change works end-to-end. Type-checks and unit tests are not enough.
|
- [ ] I actually ran the app (`docker compose up` or `uvicorn app:app`) and verified the change works end-to-end. Type-checks and unit tests are not enough.
|
||||||
|
|
||||||
|
|||||||
@@ -103,21 +103,14 @@ module.exports = async ({ github, context, core }) => {
|
|||||||
|
|
||||||
async function swapLabel(num, add, remove) {
|
async function swapLabel(num, add, remove) {
|
||||||
if (await labelExists(add)) {
|
if (await labelExists(add)) {
|
||||||
try {
|
await github.rest.issues.addLabels({ owner, repo, issue_number: num, labels: [add] });
|
||||||
await github.rest.issues.addLabels({ owner, repo, issue_number: num, labels: [add] });
|
|
||||||
} catch (e) {
|
|
||||||
// Fail soft on a token that can't write labels so a label permission
|
|
||||||
// problem never masks the actual description verdict.
|
|
||||||
if (e.status !== 403) throw e;
|
|
||||||
core.warning(`Could not add "${add}" — token lacks label write here; skipping.`);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
core.warning(`Label "${add}" does not exist in the repo — skipping. Create it once to enable labelling.`);
|
core.warning(`Label "${add}" does not exist in the repo — skipping. Create it once to enable labelling.`);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await github.rest.issues.removeLabel({ owner, repo, issue_number: num, name: remove });
|
await github.rest.issues.removeLabel({ owner, repo, issue_number: num, name: remove });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.status !== 404 && e.status !== 410 && e.status !== 403) throw e;
|
if (e.status !== 404 && e.status !== 410) throw e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,124 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Report focused pytest guidance for changed paths under tests/."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import os
|
|
||||||
import shlex
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
from collections.abc import Iterable
|
|
||||||
from pathlib import PurePosixPath
|
|
||||||
|
|
||||||
|
|
||||||
def parse_paths(raw_paths: bytes) -> list[str]:
|
|
||||||
"""Decode the NUL-delimited output of ``git diff --name-only -z``."""
|
|
||||||
return [os.fsdecode(path) for path in raw_paths.split(b"\0") if path]
|
|
||||||
|
|
||||||
|
|
||||||
def changed_paths_from_merge_base(base_sha: str, head_sha: str) -> list[str]:
|
|
||||||
"""Return changed ``tests/`` paths using GitHub PR three-dot semantics.
|
|
||||||
|
|
||||||
GitHub PR changed files are based on the merge base and the PR head, not a
|
|
||||||
direct endpoint diff between the current base branch tip and the PR head.
|
|
||||||
Using the direct endpoint diff can include files changed only on the base
|
|
||||||
branch when the PR branch is stale.
|
|
||||||
"""
|
|
||||||
merge_base = subprocess.check_output(
|
|
||||||
["git", "merge-base", base_sha, head_sha],
|
|
||||||
stderr=subprocess.DEVNULL,
|
|
||||||
).strip()
|
|
||||||
raw_paths = subprocess.check_output(
|
|
||||||
[
|
|
||||||
"git",
|
|
||||||
"diff",
|
|
||||||
"--name-only",
|
|
||||||
"--diff-filter=ACMRT",
|
|
||||||
"-z",
|
|
||||||
os.fsdecode(merge_base),
|
|
||||||
head_sha,
|
|
||||||
"--",
|
|
||||||
"tests/",
|
|
||||||
],
|
|
||||||
)
|
|
||||||
return parse_paths(raw_paths)
|
|
||||||
|
|
||||||
|
|
||||||
def select_test_paths(paths: Iterable[str]) -> list[str]:
|
|
||||||
"""Return unique, repository-relative paths contained by tests/."""
|
|
||||||
selected: set[str] = set()
|
|
||||||
for raw_path in paths:
|
|
||||||
path = PurePosixPath(raw_path)
|
|
||||||
if path.is_absolute() or ".." in path.parts:
|
|
||||||
continue
|
|
||||||
parts = tuple(part for part in path.parts if part != ".")
|
|
||||||
if len(parts) >= 2 and parts[0] == "tests":
|
|
||||||
selected.add(PurePosixPath(*parts).as_posix())
|
|
||||||
return sorted(selected)
|
|
||||||
|
|
||||||
|
|
||||||
def is_pytest_file(path: str) -> bool:
|
|
||||||
"""Return whether a changed path follows this repository's pytest naming."""
|
|
||||||
name = PurePosixPath(path).name
|
|
||||||
return name.endswith(".py") and (
|
|
||||||
name.startswith("test_") or name.endswith("_test.py")
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def pytest_command(paths: Iterable[str]) -> str:
|
|
||||||
"""Build a copyable pytest command for changed runnable test files."""
|
|
||||||
command = ["python3", "-m", "pytest", "-q", *paths]
|
|
||||||
return shlex.join(command)
|
|
||||||
|
|
||||||
|
|
||||||
def format_report(paths: Iterable[str]) -> str:
|
|
||||||
"""Format focused guidance for CI logs and the workflow summary."""
|
|
||||||
changed_paths = select_test_paths(paths)
|
|
||||||
runnable_paths = [path for path in changed_paths if is_pytest_file(path)]
|
|
||||||
lines = ["## Focused test guidance (report-only)", ""]
|
|
||||||
if not changed_paths:
|
|
||||||
lines.append("No changed paths under `tests/`.")
|
|
||||||
else:
|
|
||||||
lines.extend(["Changed paths under `tests/`:", ""])
|
|
||||||
lines.extend(f"- `{path}`" for path in changed_paths)
|
|
||||||
lines.extend(["", "Suggested focused validation:", ""])
|
|
||||||
if runnable_paths:
|
|
||||||
lines.append(f"```sh\n{pytest_command(runnable_paths)}\n```")
|
|
||||||
else:
|
|
||||||
lines.append("No directly runnable pytest files changed.")
|
|
||||||
lines.extend(
|
|
||||||
[
|
|
||||||
"",
|
|
||||||
"This guidance does not infer tests from source changes. "
|
|
||||||
"Existing blocking CI remains the source of truth.",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_args(argv: list[str]) -> argparse.Namespace:
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
description="Report focused pytest guidance for changed tests/ paths.",
|
|
||||||
)
|
|
||||||
parser.add_argument("--base-sha", help="Pull request base commit SHA.")
|
|
||||||
parser.add_argument("--head-sha", help="Pull request head commit SHA.")
|
|
||||||
return parser.parse_args(argv)
|
|
||||||
|
|
||||||
|
|
||||||
def main(argv: list[str] | None = None) -> int:
|
|
||||||
args = _parse_args(sys.argv[1:] if argv is None else argv)
|
|
||||||
if bool(args.base_sha) != bool(args.head_sha):
|
|
||||||
raise SystemExit("--base-sha and --head-sha must be provided together")
|
|
||||||
|
|
||||||
if args.base_sha and args.head_sha:
|
|
||||||
paths = changed_paths_from_merge_base(args.base_sha, args.head_sha)
|
|
||||||
else:
|
|
||||||
paths = parse_paths(sys.stdin.buffer.read())
|
|
||||||
|
|
||||||
print(format_report(paths))
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(main())
|
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
name: CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
pull_request:
|
|
||||||
|
|
||||||
# Least privilege: none of the jobs write to the repo.
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
# Cancel superseded runs on the same ref to save Actions minutes.
|
|
||||||
concurrency:
|
|
||||||
group: ci-${{ github.ref }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
focused-test-guidance:
|
|
||||||
name: Focused test guidance (report-only)
|
|
||||||
if: github.event_name == 'pull_request'
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
continue-on-error: true
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
persist-credentials: false
|
|
||||||
- name: Report changed test paths
|
|
||||||
env:
|
|
||||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
|
||||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
|
||||||
run: |
|
|
||||||
report_file="$RUNNER_TEMP/focused-test-guidance.md"
|
|
||||||
publish_report() {
|
|
||||||
cat "$report_file"
|
|
||||||
if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
|
|
||||||
cat "$report_file" >> "$GITHUB_STEP_SUMMARY" || true
|
|
||||||
fi
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
report_unavailable() {
|
|
||||||
{
|
|
||||||
printf '%s\n\n' '## Focused test guidance unavailable (report-only)'
|
|
||||||
printf '%s\n\n' "$1"
|
|
||||||
printf '%s\n' 'Existing blocking CI remains the source of truth.'
|
|
||||||
} > "$report_file"
|
|
||||||
publish_report
|
|
||||||
exit 0
|
|
||||||
}
|
|
||||||
|
|
||||||
if [ -z "$BASE_SHA" ] || [ -z "$HEAD_SHA" ]; then
|
|
||||||
report_unavailable "Pull request base/head metadata is missing."
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! git cat-file -e "${BASE_SHA}^{commit}" 2>/dev/null; then
|
|
||||||
report_unavailable "The pull request base commit is unavailable locally."
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! git cat-file -e "${HEAD_SHA}^{commit}" 2>/dev/null; then
|
|
||||||
report_unavailable "The pull request head commit is unavailable locally."
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! python3 .github/scripts/focused_test_guidance.py \
|
|
||||||
--base-sha "$BASE_SHA" \
|
|
||||||
--head-sha "$HEAD_SHA" > "$report_file"; then
|
|
||||||
report_unavailable "The focused test guidance helper could not produce a report."
|
|
||||||
fi
|
|
||||||
|
|
||||||
publish_report
|
|
||||||
|
|
||||||
python-syntax:
|
|
||||||
name: Python syntax (compileall)
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
|
||||||
with:
|
|
||||||
python-version: "3.11"
|
|
||||||
# Byte-compile sources — catches syntax errors without installing deps.
|
|
||||||
- run: python -m compileall -q app.py core routes src services scripts tests
|
|
||||||
|
|
||||||
node-syntax:
|
|
||||||
name: JS syntax (node --check)
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
|
||||||
with:
|
|
||||||
node-version: "20"
|
|
||||||
# Syntax-check our own JS (skip vendored libs in static/lib).
|
|
||||||
- name: node --check
|
|
||||||
run: |
|
|
||||||
shopt -s globstar nullglob
|
|
||||||
for f in static/app.js static/js/**/*.js; do
|
|
||||||
node --check "$f"
|
|
||||||
done
|
|
||||||
|
|
||||||
python-tests:
|
|
||||||
name: Python tests (pytest)
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
# Informational for now: the suite has known flaky / environment-dependent
|
|
||||||
# failures (test isolation + embedding-model assertions). Tracked under the
|
|
||||||
# ROADMAP "fresh install smoke tests" item; make this required once green.
|
|
||||||
continue-on-error: true
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
# Detect whether this PR only touches documentation files.
|
|
||||||
# If so, skip the expensive pytest run while still reporting a passing check.
|
|
||||||
- name: Check for docs-only changes
|
|
||||||
id: docs-check
|
|
||||||
run: |
|
|
||||||
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
|
||||||
BASE="${{ github.event.pull_request.base.sha }}"
|
|
||||||
HEAD="${{ github.event.pull_request.head.sha }}"
|
|
||||||
else
|
|
||||||
BASE="${{ github.event.before }}"
|
|
||||||
HEAD="${{ github.sha }}"
|
|
||||||
fi
|
|
||||||
# List all changed files; if every file matches docs/markdown patterns, skip pytest.
|
|
||||||
changed=$(git diff --name-only "$BASE" "$HEAD" 2>/dev/null || git diff --name-only HEAD~1 HEAD)
|
|
||||||
non_docs=$(echo "$changed" | grep -Ev '^(docs/|.*\.md$|\.github/[^/]+\.md$)' || true)
|
|
||||||
if [ -z "$non_docs" ]; then
|
|
||||||
echo "docs_only=true" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "Docs-only change detected — skipping pytest."
|
|
||||||
else
|
|
||||||
echo "docs_only=false" >> "$GITHUB_OUTPUT"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
|
||||||
if: steps.docs-check.outputs.docs_only != 'true'
|
|
||||||
with:
|
|
||||||
python-version: "3.11"
|
|
||||||
cache: pip
|
|
||||||
- run: pip install -r requirements.txt
|
|
||||||
if: steps.docs-check.outputs.docs_only != 'true'
|
|
||||||
- run: mkdir -p data # sqlite DB lives at ./data/app.db
|
|
||||||
if: steps.docs-check.outputs.docs_only != 'true'
|
|
||||||
- run: python -m pytest -q
|
|
||||||
if: steps.docs-check.outputs.docs_only != 'true'
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
name: CodeQL
|
|
||||||
|
|
||||||
# Advanced setup so CodeQL also runs on pull requests (including from forks),
|
|
||||||
# surfacing findings before merge instead of only after a change lands on dev.
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [dev, main]
|
|
||||||
pull_request:
|
|
||||||
branches: [dev]
|
|
||||||
schedule:
|
|
||||||
- cron: "17 3 * * 1"
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
analyze:
|
|
||||||
name: Analyze (${{ matrix.language }})
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
security-events: write
|
|
||||||
actions: read
|
|
||||||
contents: read
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
language: [actions, javascript-typescript, python]
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
- name: Initialize CodeQL
|
|
||||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
|
||||||
with:
|
|
||||||
languages: ${{ matrix.language }}
|
|
||||||
build-mode: none
|
|
||||||
- name: Perform CodeQL Analysis
|
|
||||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
|
||||||
with:
|
|
||||||
category: "/language:${{ matrix.language }}"
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
# Container security: Dockerfile lint
|
|
||||||
#
|
|
||||||
# Purpose: the Docker image is how most people run Odysseus, so it is part of
|
|
||||||
# the attack surface. hadolint lints the Dockerfile for mistakes and insecure
|
|
||||||
# patterns (running as root longer than needed, unpinned base image, bad apt
|
|
||||||
# usage). Blocking.
|
|
||||||
#
|
|
||||||
# The image vulnerability scan (Trivy, advisory) lives in its own file,
|
|
||||||
# container-trivy.yml. Keeping it separate lets that advisory scan be
|
|
||||||
# path-filtered and held to a read-only token on pull requests without
|
|
||||||
# weakening this blocking gate, which must always report so a required check
|
|
||||||
# never hangs.
|
|
||||||
#
|
|
||||||
# Note: a separate open PR (#120) proposes a local `scripts/scan_image.py`.
|
|
||||||
# This job is complementary -- it is a CI gate, not a script a contributor has
|
|
||||||
# to remember to run.
|
|
||||||
|
|
||||||
name: Container scan
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
permissions: {}
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: container-scan-${{ github.workflow }}-${{ github.ref }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
hadolint:
|
|
||||||
name: hadolint (Dockerfile lint)
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: Lint Dockerfile
|
|
||||||
uses: hadolint/hadolint-action@2332a7b74a6de0dda2e2221d575162eba76ba5e5 # v3.3.0
|
|
||||||
with:
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
# DL3008: pinning apt package versions is impractical on a -slim base
|
|
||||||
# image. Debian purges old package versions from its repos, so a
|
|
||||||
# pinned version breaks future rebuilds. The base image itself is
|
|
||||||
# what should be pinned (tracked by Dependabot's docker ecosystem).
|
|
||||||
ignore: DL3008
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
# Container image vulnerability scan (advisory)
|
|
||||||
#
|
|
||||||
# Trivy builds the application image and scans it for known-vulnerable OS and
|
|
||||||
# Python packages. Advisory only -- it reports findings to the repo's Security
|
|
||||||
# tab without blocking a merge, because the image inevitably contains
|
|
||||||
# already-known CVEs in upstream packages that are not this project's bug.
|
|
||||||
#
|
|
||||||
# Split from the Dockerfile lint (container-scan.yml) for two reasons:
|
|
||||||
#
|
|
||||||
# - Least privilege. The image build runs Dockerfile instructions, which on a
|
|
||||||
# pull request are attacker-influenceable. That path (the `scan` job) is
|
|
||||||
# held to a read-only token and never publishes results. Only `publish`,
|
|
||||||
# which runs on push to main (curated, fast-forwarded from reviewed dev),
|
|
||||||
# gets security-events:write to upload SARIF.
|
|
||||||
# - Cost. Docs-only changes do not rebuild the image (paths-ignore below),
|
|
||||||
# matching docker-publish.yml. hadolint stays on the broad trigger in
|
|
||||||
# container-scan.yml so the blocking gate always reports.
|
|
||||||
|
|
||||||
name: Container scan (Trivy)
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
paths-ignore:
|
|
||||||
- '**.md'
|
|
||||||
- 'docs/**'
|
|
||||||
- '.github/ISSUE_TEMPLATE/**'
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
paths-ignore:
|
|
||||||
- '**.md'
|
|
||||||
- 'docs/**'
|
|
||||||
- '.github/ISSUE_TEMPLATE/**'
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
permissions: {}
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: container-trivy-${{ github.workflow }}-${{ github.ref }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
# Pull requests and manual runs: build and scan under a read-only token.
|
|
||||||
# The build executes PR-supplied Dockerfile instructions, so this job must
|
|
||||||
# not hold any write scope, and it does not upload to the Security tab.
|
|
||||||
scan:
|
|
||||||
name: Trivy (image scan, advisory)
|
|
||||||
if: github.event_name != 'push'
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
# Advisory: a CVE in an upstream package must not block a PR.
|
|
||||||
continue-on-error: true
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: Set up Buildx
|
|
||||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
|
||||||
|
|
||||||
# Build without pushing so a broken Dockerfile is caught here, and the
|
|
||||||
# exact image we ship is what gets scanned.
|
|
||||||
- name: Build image
|
|
||||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
push: false
|
|
||||||
load: true
|
|
||||||
tags: odysseus:ci
|
|
||||||
|
|
||||||
- name: Scan image with Trivy
|
|
||||||
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
|
|
||||||
with:
|
|
||||||
image-ref: odysseus:ci
|
|
||||||
format: table
|
|
||||||
ignore-unfixed: true
|
|
||||||
env:
|
|
||||||
# Pin the vuln DB source to GHCR to avoid rate-limited Docker Hub
|
|
||||||
# mirrors that flake on shared runners.
|
|
||||||
TRIVY_DB_REPOSITORY: ghcr.io/aquasecurity/trivy-db:2
|
|
||||||
|
|
||||||
# Push to main only: build, scan, and publish SARIF to the Security tab.
|
|
||||||
# This is the only path that runs trusted code, so it is the only one granted
|
|
||||||
# security-events:write.
|
|
||||||
publish:
|
|
||||||
name: Trivy (image scan + SARIF upload)
|
|
||||||
if: github.event_name == 'push'
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
continue-on-error: true
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
security-events: write # upload SARIF to the Security tab
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: Set up Buildx
|
|
||||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
|
||||||
|
|
||||||
- name: Build image
|
|
||||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
push: false
|
|
||||||
load: true
|
|
||||||
tags: odysseus:ci
|
|
||||||
|
|
||||||
- name: Scan image with Trivy
|
|
||||||
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
|
|
||||||
with:
|
|
||||||
image-ref: odysseus:ci
|
|
||||||
format: sarif
|
|
||||||
output: trivy-results.sarif
|
|
||||||
ignore-unfixed: true
|
|
||||||
env:
|
|
||||||
TRIVY_DB_REPOSITORY: ghcr.io/aquasecurity/trivy-db:2
|
|
||||||
|
|
||||||
- name: Upload Trivy results
|
|
||||||
uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
|
||||||
with:
|
|
||||||
sarif_file: trivy-results.sarif
|
|
||||||
category: trivy-image
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
# Supply-chain review
|
|
||||||
#
|
|
||||||
# Purpose: defend against "side-chain" / supply-chain attacks -- a pull request
|
|
||||||
# that adds (or bumps) a dependency to a version with a known vulnerability or a
|
|
||||||
# disallowed license. Two layers:
|
|
||||||
#
|
|
||||||
# - dependency-review: runs ONLY on pull requests. It compares the
|
|
||||||
# dependencies before and after the PR and blocks the merge if the change
|
|
||||||
# pulls in a package with a known security advisory. This is the gate.
|
|
||||||
# - pip-audit: scans the project's current Python requirements against the
|
|
||||||
# advisory database. Advisory only (it never blocks a merge), because it can
|
|
||||||
# flag a pre-existing issue in an already-shipped dependency.
|
|
||||||
|
|
||||||
name: Dependency review
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
# Default-deny token; jobs grant only read access.
|
|
||||||
permissions: {}
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: dependency-review-${{ github.workflow }}-${{ github.ref }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
dependency-review:
|
|
||||||
name: dependency-review (PR gate)
|
|
||||||
# Only meaningful on a pull request -- it needs a base..head diff to review.
|
|
||||||
if: github.event_name == 'pull_request'
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: Review dependency changes
|
|
||||||
uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
|
|
||||||
with:
|
|
||||||
# Fail the PR on any newly introduced moderate-or-worse advisory.
|
|
||||||
fail-on-severity: moderate
|
|
||||||
|
|
||||||
pip-audit:
|
|
||||||
name: pip-audit (advisory)
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
# Advisory: report known-vulnerable Python deps without blocking the merge.
|
|
||||||
continue-on-error: true
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: Set up Python
|
|
||||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
|
||||||
with:
|
|
||||||
python-version: '3.12'
|
|
||||||
|
|
||||||
- name: Run pip-audit on requirements
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
pip install pip-audit==2.10.0
|
|
||||||
pip-audit -r requirements.txt -r requirements-optional.txt --strict
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
name: ci / docker publish
|
|
||||||
|
|
||||||
# Build the Odysseus image and publish to GHCR.
|
|
||||||
# push to main -> :latest, :X.Y.Z (curated release; main is fast-forwarded at releases)
|
|
||||||
# push to dev -> :dev, :X.Y.Z-dev.<sha> (rolling dev + an immutable, traceable pin)
|
|
||||||
# Multi-arch (linux/amd64 + linux/arm64): each arch builds on its own native
|
|
||||||
# runner and pushes by digest, then a merge job stitches the digests into one
|
|
||||||
# manifest list and applies the tags (faster + cleaner than QEMU emulation).
|
|
||||||
# Registry: ghcr.io/<owner>/<repo>.
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [dev, main]
|
|
||||||
paths-ignore:
|
|
||||||
- '**.md'
|
|
||||||
- 'docs/**'
|
|
||||||
- '.github/ISSUE_TEMPLATE/**'
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: docker-publish-${{ github.ref }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
env:
|
|
||||||
REGISTRY: ghcr.io
|
|
||||||
IMAGE_NAME: ${{ github.repository }}
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
name: build (${{ matrix.arch }})
|
|
||||||
runs-on: ${{ matrix.runner }}
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- platform: linux/amd64
|
|
||||||
arch: amd64
|
|
||||||
runner: ubuntu-latest
|
|
||||||
- platform: linux/arm64
|
|
||||||
arch: arm64
|
|
||||||
runner: ubuntu-24.04-arm
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
- name: Set up Buildx
|
|
||||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
|
||||||
- name: Log in to GHCR
|
|
||||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
|
||||||
with:
|
|
||||||
registry: ${{ env.REGISTRY }}
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
- name: Build and push by digest
|
|
||||||
id: build
|
|
||||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
platforms: ${{ matrix.platform }}
|
|
||||||
outputs: type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
|
|
||||||
cache-from: type=gha,scope=${{ matrix.arch }}
|
|
||||||
cache-to: type=gha,mode=max,scope=${{ matrix.arch }}
|
|
||||||
- name: Export digest
|
|
||||||
run: |
|
|
||||||
mkdir -p /tmp/digests
|
|
||||||
digest="${{ steps.build.outputs.digest }}"
|
|
||||||
touch "/tmp/digests/${digest#sha256:}"
|
|
||||||
- name: Upload digest
|
|
||||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
||||||
with:
|
|
||||||
name: digest-${{ matrix.arch }}
|
|
||||||
path: /tmp/digests/*
|
|
||||||
if-no-files-found: error
|
|
||||||
retention-days: 1
|
|
||||||
|
|
||||||
merge:
|
|
||||||
name: merge manifest + tag
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: build
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
- name: Read APP_VERSION + short sha
|
|
||||||
id: ver
|
|
||||||
run: |
|
|
||||||
v=$(grep -E '^APP_VERSION' src/constants.py | head -1 | sed -E 's/.*"([^"]+)".*/\1/')
|
|
||||||
[ -n "$v" ] || { echo "APP_VERSION not found"; exit 1; }
|
|
||||||
echo "version=$v" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "short=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
|
|
||||||
- name: Download digests
|
|
||||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
|
||||||
with:
|
|
||||||
path: /tmp/digests
|
|
||||||
pattern: digest-*
|
|
||||||
merge-multiple: true
|
|
||||||
- name: Set up Buildx
|
|
||||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
|
||||||
- name: Log in to GHCR
|
|
||||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
|
||||||
with:
|
|
||||||
registry: ${{ env.REGISTRY }}
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
- name: Compute tags
|
|
||||||
id: meta
|
|
||||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
|
||||||
with:
|
|
||||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
|
||||||
tags: |
|
|
||||||
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
|
|
||||||
type=raw,value=${{ steps.ver.outputs.version }},enable=${{ github.ref == 'refs/heads/main' }}
|
|
||||||
type=raw,value=dev,enable=${{ github.ref == 'refs/heads/dev' }}
|
|
||||||
type=raw,value=${{ steps.ver.outputs.version }}-dev.${{ steps.ver.outputs.short }},enable=${{ github.ref == 'refs/heads/dev' }}
|
|
||||||
- name: Create manifest list + push tags
|
|
||||||
working-directory: /tmp/digests
|
|
||||||
run: |
|
|
||||||
tags=$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON")
|
|
||||||
digests=$(printf "${REGISTRY}/${IMAGE_NAME}@sha256:%s " *)
|
|
||||||
# word-splitting is intended: $tags and $digests each expand to multiple args
|
|
||||||
# shellcheck disable=SC2086
|
|
||||||
docker buildx imagetools create $tags $digests
|
|
||||||
env:
|
|
||||||
REGISTRY: ${{ env.REGISTRY }}
|
|
||||||
IMAGE_NAME: ${{ env.IMAGE_NAME }}
|
|
||||||
- name: Inspect
|
|
||||||
run: |
|
|
||||||
if [ "$GITHUB_REF" = "refs/heads/main" ]; then ref=latest; else ref=dev; fi
|
|
||||||
docker buildx imagetools inspect "${REGISTRY}/${IMAGE_NAME}:${ref}"
|
|
||||||
env:
|
|
||||||
REGISTRY: ${{ env.REGISTRY }}
|
|
||||||
IMAGE_NAME: ${{ env.IMAGE_NAME }}
|
|
||||||
@@ -14,11 +14,10 @@ jobs:
|
|||||||
# Skip bots (Dependabot, release-drafter, etc.)
|
# Skip bots (Dependabot, release-drafter, etc.)
|
||||||
if: ${{ github.event.issue.user.type != 'Bot' }}
|
if: ${{ github.event.issue.user.type != 'Bot' }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
sparse-checkout: .github/scripts
|
sparse-checkout: .github/scripts
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
- uses: actions/github-script@v7
|
||||||
with:
|
with:
|
||||||
script: return require('./.github/scripts/check-issue-description.js')({github, context, core})
|
script: return require('./.github/scripts/check-issue-description.js')({github, context, core})
|
||||||
|
|||||||
@@ -1,109 +1,28 @@
|
|||||||
name: ci / PR checks
|
name: ci / PR description check
|
||||||
|
|
||||||
on:
|
on:
|
||||||
# pull_request_target runs in the base-repo context (has secrets) so the check
|
pull_request_target:
|
||||||
# works on fork PRs. Safe here: the checkout pins to the base branch (no fork
|
types: [opened, edited, synchronize, reopened]
|
||||||
# code runs) and the scripts only read context.payload and call the GitHub API.
|
|
||||||
pull_request_target: # zizmor: ignore[dangerous-triggers]
|
|
||||||
types: [opened, edited, synchronize, reopened, ready_for_review]
|
|
||||||
|
|
||||||
# Default-deny at the workflow level; each job opts into only the scopes it needs.
|
# pull_request_target runs in the base-repo context (has secrets).
|
||||||
# Note: modifying a PR's labels/comments needs pull-requests:write even though the
|
# The checkout below pins to the base branch so no fork code is executed.
|
||||||
# REST path is under /issues/{n}/...; issues:write alone returns 403 on PRs.
|
# The script only reads context.payload and calls the GitHub API.
|
||||||
permissions: {}
|
permissions:
|
||||||
|
issues: write
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
check-description:
|
check-description:
|
||||||
name: Check PR description
|
name: Check PR description
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
# Skip bots — they open PRs programmatically and have their own process.
|
||||||
contents: read
|
|
||||||
pull-requests: write
|
|
||||||
issues: write
|
|
||||||
# Skip bots: they open PRs programmatically and have their own process.
|
|
||||||
if: github.event.pull_request.user.type != 'Bot'
|
if: github.event.pull_request.user.type != 'Bot'
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.base_ref }}
|
ref: ${{ github.base_ref }}
|
||||||
sparse-checkout: .github/scripts
|
sparse-checkout: .github/scripts
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
- uses: actions/github-script@v7
|
||||||
with:
|
with:
|
||||||
script: return require('./.github/scripts/check-pr-description.js')({github, context, core})
|
script: return require('./.github/scripts/check-pr-description.js')({github, context, core})
|
||||||
|
|
||||||
check-title:
|
|
||||||
name: Check PR title (Conventional Commits)
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions: {}
|
|
||||||
# Skip bots: they open PRs programmatically and have their own process.
|
|
||||||
if: github.event.pull_request.user.type != 'Bot'
|
|
||||||
steps:
|
|
||||||
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
|
||||||
with:
|
|
||||||
script: |
|
|
||||||
const title = context.payload.pull_request.title || "";
|
|
||||||
// Conventional Commits: type(optional-scope)(optional !): summary
|
|
||||||
const re = /^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([\w .\/-]+\))?!?: .+/;
|
|
||||||
if (!re.test(title)) {
|
|
||||||
core.setFailed(
|
|
||||||
`PR title is not in Conventional Commits format:\n "${title}"\n\n` +
|
|
||||||
`Expected: type(scope): summary\n` +
|
|
||||||
`Example: fix(search): handle empty query\n` +
|
|
||||||
`Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert.`
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
core.info(`PR title OK: ${title}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
check-mergeable:
|
|
||||||
name: Flag unmergeable PRs
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
pull-requests: write
|
|
||||||
issues: write
|
|
||||||
# Skip bots: they open PRs programmatically and have their own process.
|
|
||||||
if: github.event.pull_request.user.type != 'Bot'
|
|
||||||
steps:
|
|
||||||
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
|
||||||
with:
|
|
||||||
script: |
|
|
||||||
const repo = { owner: context.repo.owner, repo: context.repo.repo };
|
|
||||||
const number = context.payload.pull_request.number;
|
|
||||||
const READY = "ready for review";
|
|
||||||
const CONFLICT = "merge conflict";
|
|
||||||
|
|
||||||
// Ensure the conflict label exists (red). Ignore if already present.
|
|
||||||
try {
|
|
||||||
await github.rest.issues.getLabel({ ...repo, name: CONFLICT });
|
|
||||||
} catch {
|
|
||||||
await github.rest.issues.createLabel({
|
|
||||||
...repo, name: CONFLICT, color: "B60205",
|
|
||||||
description: "Conflicts with the base branch; needs a rebase before review.",
|
|
||||||
}).catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
// mergeable is computed asynchronously and is often null right after
|
|
||||||
// an event, so poll a few times until GitHub has resolved it.
|
|
||||||
let pr = null;
|
|
||||||
for (let i = 0; i < 5; i++) {
|
|
||||||
const { data } = await github.rest.pulls.get({ ...repo, pull_number: number });
|
|
||||||
if (data.mergeable !== null) { pr = data; break; }
|
|
||||||
await new Promise(r => setTimeout(r, 3000));
|
|
||||||
}
|
|
||||||
if (!pr || pr.draft) return;
|
|
||||||
const labels = pr.labels.map(l => l.name);
|
|
||||||
|
|
||||||
if (pr.mergeable === false) {
|
|
||||||
if (labels.includes(READY)) {
|
|
||||||
await github.rest.issues.removeLabel({ ...repo, issue_number: number, name: READY }).catch(() => {});
|
|
||||||
}
|
|
||||||
if (!labels.includes(CONFLICT)) {
|
|
||||||
await github.rest.issues.addLabels({ ...repo, issue_number: number, labels: [CONFLICT] });
|
|
||||||
}
|
|
||||||
} else if (pr.mergeable === true) {
|
|
||||||
if (labels.includes(CONFLICT)) {
|
|
||||||
await github.rest.issues.removeLabel({ ...repo, issue_number: number, name: CONFLICT }).catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
# Secret scanning
|
|
||||||
#
|
|
||||||
# Purpose: stop credentials (API keys, tokens, passwords, private keys) from
|
|
||||||
# ever living in the Git history. Odysseus deliberately keeps real secrets in
|
|
||||||
# files that are gitignored (.env, data/), but a slip in a future commit -- or a
|
|
||||||
# malicious pull request that sneaks one in -- would otherwise go unnoticed.
|
|
||||||
# This job reads the repository and the full commit history and fails if it
|
|
||||||
# finds anything that looks like a secret.
|
|
||||||
#
|
|
||||||
# It runs the official gitleaks BINARY directly (pinned to an exact version and
|
|
||||||
# verified against the project's published SHA-256 checksum) rather than the
|
|
||||||
# gitleaks GitHub Action, because the Action asks for a paid license on
|
|
||||||
# organization-owned repos. The binary is free and behaves identically.
|
|
||||||
|
|
||||||
name: Secret scan
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
# Start with zero permissions; the single job opts back in to read-only.
|
|
||||||
permissions: {}
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: secret-scan-${{ github.workflow }}-${{ github.ref }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
gitleaks:
|
|
||||||
name: gitleaks
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
|
||||||
with:
|
|
||||||
# Full history so a secret committed in an earlier commit (and later
|
|
||||||
# deleted) is still caught -- deletion does not remove it from Git.
|
|
||||||
fetch-depth: 0
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
# Pinned version + checksum so a tampered release binary cannot run here.
|
|
||||||
# Bump VERSION/SHA256 together; the checksum comes from the matching
|
|
||||||
# gitleaks_<version>_checksums.txt on the GitHub release.
|
|
||||||
- name: Run gitleaks (pinned, checksum-verified)
|
|
||||||
env:
|
|
||||||
GITLEAKS_VERSION: 8.30.1
|
|
||||||
GITLEAKS_SHA256: 551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
TARBALL="gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz"
|
|
||||||
curl -fsSL -o "${TARBALL}" \
|
|
||||||
"https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/${TARBALL}"
|
|
||||||
echo "${GITLEAKS_SHA256} ${TARBALL}" | sha256sum -c -
|
|
||||||
tar -xzf "${TARBALL}" gitleaks
|
|
||||||
# Scan the whole history. Findings print to the log and fail the job.
|
|
||||||
./gitleaks git --no-banner --redact --verbose .
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
# Workflow security (CI that audits the CI)
|
|
||||||
#
|
|
||||||
# Purpose: the GitHub Actions workflows themselves are an attack surface. A
|
|
||||||
# poorly written workflow can leak the repository token, run attacker-supplied
|
|
||||||
# code from a pull request, or pull in a tampered third-party action. These two
|
|
||||||
# tools check every workflow file in this repo for those mistakes:
|
|
||||||
#
|
|
||||||
# - actionlint: catches workflow syntax errors and shell-script bugs inside
|
|
||||||
# `run:` steps before they reach main.
|
|
||||||
# - zizmor: a security linter for Actions. Flags template-injection holes,
|
|
||||||
# unpinned actions, credential persistence, and over-broad token
|
|
||||||
# permissions -- exactly the patterns the rest of this CI is built to avoid.
|
|
||||||
#
|
|
||||||
# Add this early: it then audits every workflow added after it.
|
|
||||||
|
|
||||||
name: Workflow security
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
# Default-deny token; each job grants only read access to the code.
|
|
||||||
permissions: {}
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: workflow-security-${{ github.workflow }}-${{ github.ref }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
actionlint:
|
|
||||||
name: actionlint
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
# Pinned version + checksum so a tampered binary cannot run here.
|
|
||||||
- name: Run actionlint (pinned, checksum-verified)
|
|
||||||
env:
|
|
||||||
ACTIONLINT_VERSION: 1.7.12
|
|
||||||
ACTIONLINT_SHA256: 8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
TARBALL="actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz"
|
|
||||||
curl -fsSL -o "${TARBALL}" \
|
|
||||||
"https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/${TARBALL}"
|
|
||||||
echo "${ACTIONLINT_SHA256} ${TARBALL}" | sha256sum -c -
|
|
||||||
tar -xzf "${TARBALL}" actionlint
|
|
||||||
./actionlint -color
|
|
||||||
|
|
||||||
zizmor:
|
|
||||||
name: zizmor (Actions SAST)
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: Set up Python
|
|
||||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
|
||||||
with:
|
|
||||||
python-version: '3.12'
|
|
||||||
|
|
||||||
# Pinned zizmor release. --offline keeps the audit hermetic (no network
|
|
||||||
# calls about the actions it inspects); --min-severity=low surfaces
|
|
||||||
# everything so nothing slips through under the gate.
|
|
||||||
- name: Run zizmor
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
pip install zizmor==1.25.2
|
|
||||||
zizmor --offline --min-severity=low .github/workflows/
|
|
||||||
@@ -14,15 +14,6 @@ venv/
|
|||||||
.env
|
.env
|
||||||
.env.bak.*
|
.env.bak.*
|
||||||
!.env.example
|
!.env.example
|
||||||
# Local uv lockfile (optional, per-platform — see "Faster installs with uv" in README)
|
|
||||||
requirements.lock
|
|
||||||
|
|
||||||
# SOPS workflow — encrypted `secrets.env` is intentionally committable,
|
|
||||||
# but every variant (plaintext, manual decrypt copy, editor backup)
|
|
||||||
# must stay out of git. Mirrored in .dockerignore so the same artifacts
|
|
||||||
# also cannot enter image build layers.
|
|
||||||
secrets.env.*
|
|
||||||
!secrets.env.example
|
|
||||||
|
|
||||||
# Data — all user data stays local
|
# Data — all user data stays local
|
||||||
data/
|
data/
|
||||||
@@ -70,9 +61,6 @@ output.txt.txt
|
|||||||
*.tiff
|
*.tiff
|
||||||
*.pdf
|
*.pdf
|
||||||
|
|
||||||
# …except shipped static assets
|
|
||||||
!static/icons/*.png
|
|
||||||
|
|
||||||
# …except shipped demo assets in docs/ that the README links to.
|
# …except shipped demo assets in docs/ that the README links to.
|
||||||
!docs/*.jpg
|
!docs/*.jpg
|
||||||
!docs/*.jpeg
|
!docs/*.jpeg
|
||||||
@@ -101,4 +89,3 @@ docs/windows-port/
|
|||||||
compound.config.json
|
compound.config.json
|
||||||
*.error.log
|
*.error.log
|
||||||
_scratch/
|
_scratch/
|
||||||
/odysseus/
|
|
||||||
|
|||||||
@@ -86,7 +86,6 @@ Bundled in `static/fonts/`:
|
|||||||
| [Fira Code](https://github.com/tonsky/FiraCode) | SIL Open Font License 1.1 | Nikita Prokopov & contributors |
|
| [Fira Code](https://github.com/tonsky/FiraCode) | SIL Open Font License 1.1 | Nikita Prokopov & contributors |
|
||||||
| [Inter](https://github.com/rsms/inter) | SIL Open Font License 1.1 | Rasmus Andersson |
|
| [Inter](https://github.com/rsms/inter) | SIL Open Font License 1.1 | Rasmus Andersson |
|
||||||
| [GohuFont](https://font.gohu.org/) (`fonts/custom/GohuFont.ttf`) | WTFPL | Hugo Chargois |
|
| [GohuFont](https://font.gohu.org/) (`fonts/custom/GohuFont.ttf`) | WTFPL | Hugo Chargois |
|
||||||
| [OpenDyslexic](https://opendyslexic.org/) (`fonts/OpenDyslexic-{Regular,Bold}.woff2`) | SIL Open Font License 1.1 ([`licenses/OpenDyslexic-OFL.txt`](licenses/OpenDyslexic-OFL.txt)) | Abbie Gonzalez |
|
|
||||||
|
|
||||||
## Python dependencies
|
## Python dependencies
|
||||||
|
|
||||||
|
|||||||
@@ -2,17 +2,6 @@
|
|||||||
|
|
||||||
Thanks for helping. The project is moving quickly, so the best contributions are focused, easy to review, and easy to test.
|
Thanks for helping. The project is moving quickly, so the best contributions are focused, easy to review, and easy to test.
|
||||||
|
|
||||||
## Branch model
|
|
||||||
|
|
||||||
Odysseus has two branches:
|
|
||||||
|
|
||||||
- **`dev`** — where all PRs land. Things can be in flux here; the merge button gets used freely.
|
|
||||||
- **`main`** — what users run. Curated and tested by the maintainer. Fast-forwarded to a stable `dev` commit at each release.
|
|
||||||
|
|
||||||
**Open your PR against `dev`, not `main`.** The GitHub "base" dropdown defaults to `dev`. If you opened a PR against `main` by accident, click "Edit" on the PR and change the base — no rebase needed.
|
|
||||||
|
|
||||||
End-users cloning the repo will land on `dev` by default. To run the curated/stable version: `git checkout main` after clone.
|
|
||||||
|
|
||||||
## Before You Start
|
## Before You Start
|
||||||
|
|
||||||
- Search existing issues and pull requests before opening a new one.
|
- Search existing issues and pull requests before opening a new one.
|
||||||
@@ -25,7 +14,7 @@ End-users cloning the repo will land on `dev` by default. To run the curated/sta
|
|||||||
Docker is the recommended path for normal testing:
|
Docker is the recommended path for normal testing:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/odysseus-dev/odysseus.git
|
git clone https://github.com/pewdiepie-archdaemon/odysseus.git
|
||||||
cd odysseus
|
cd odysseus
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
@@ -37,7 +26,7 @@ Manual development uses Python 3.11+:
|
|||||||
python3 -m venv venv
|
python3 -m venv venv
|
||||||
source venv/bin/activate
|
source venv/bin/activate
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
python -m uvicorn app:app --host 127.0.0.1 --port 7000
|
python -m uvicorn app:app --host 0.0.0.0 --port 7000
|
||||||
```
|
```
|
||||||
|
|
||||||
Windows is not actively tested. Docker on Linux or a Linux/macOS manual install is the safer path for now.
|
Windows is not actively tested. Docker on Linux or a Linux/macOS manual install is the safer path for now.
|
||||||
@@ -94,18 +83,6 @@ Before submitting any change that affects what the app looks like — buttons, i
|
|||||||
|
|
||||||
If you are unsure whether a change is "visual," it is. Default to attaching a screenshot.
|
If you are unsure whether a change is "visual," it is. Default to attaching a screenshot.
|
||||||
|
|
||||||
## Code conventions
|
|
||||||
|
|
||||||
Don't hardcode values that the project already exposes through a constant or a helper. Hardcoded literals drift out of sync, break on non-default deployments, and reintroduce bugs we've already fixed.
|
|
||||||
|
|
||||||
- **Filesystem paths:** never build writable paths from `Path(__file__)...` into the source tree, hardcode `/app/...`, or use a relative `"data/..."` string. Every persisted file and directory has a named constant in `src/constants.py` (for example `AUTH_FILE`, `USER_PREFS_FILE`, `SETTINGS_FILE`, `TTS_CACHE_DIR`, `CHROMA_DIR`). Import and use that named constant; do not re-derive the path locally with `os.path.join(DATA_DIR, "x.json")` or `DATA_DIR / "x.json"`. `DATA_DIR` is the single place that reads `ODYSSEUS_DATA_DIR`, so use it directly only for dynamic paths that have no fixed name (for example per-owner files). If a data file or directory has no constant yet, add one to `src/constants.py`. The source tree is read-only in Docker and `/app/...` does not exist on native runs; guard directory creation so an unwritable path degrades gracefully instead of crashing at import.
|
|
||||||
- **Internal API / loopback URLs:** don't hardcode `http://localhost:7000`. Use `internal_api_base()` from `src.constants` (it honors `ODYSSEUS_INTERNAL_BASE` / `APP_PORT`).
|
|
||||||
- **Ports, limits, model lists, and similar:** reuse the existing constant if one exists; if it doesn't and the value is used in more than one place, add a constant rather than copying the literal.
|
|
||||||
|
|
||||||
If you need a value that has no constant or helper yet, add it to `src/constants.py` (the single source of truth for paths and config; `core/constants.py` only re-exports it for backward compatibility) and import it, rather than repeating a literal across files.
|
|
||||||
|
|
||||||
**Commits:** use [Conventional Commits](https://www.conventionalcommits.org), `type(scope): summary` (e.g. `fix(search): ...`, `feat(notes): ...`, `docs(contributing): ...`). Common types: `fix`, `feat`, `refactor`, `docs`, `test`, `chore`, `ci`. Keep the subject short and imperative; put the "why" in the body when it isn't obvious.
|
|
||||||
|
|
||||||
## Issue Reports
|
## Issue Reports
|
||||||
|
|
||||||
For bugs, include:
|
For bugs, include:
|
||||||
|
|||||||
@@ -1,23 +1,11 @@
|
|||||||
# ---- builder: patch + build wheels for Real-ESRGAN's broken-on-3.14 deps ----
|
FROM python:3.12-slim
|
||||||
# basicsr/gfpgan/facexlib read their version via exec()+locals()['__version__'],
|
|
||||||
# which raises KeyError on Python 3.13+ (PEP 667). Build patched wheels here so
|
|
||||||
# the final image / Cookbook never has to compile the broken sdists. See
|
|
||||||
# docker/build-realesrgan-wheels.sh for the full rationale.
|
|
||||||
FROM python:3.14-slim AS realesrgan-wheels
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends curl \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
COPY docker/build-realesrgan-wheels.sh /usr/local/bin/build-realesrgan-wheels.sh
|
|
||||||
RUN bash /usr/local/bin/build-realesrgan-wheels.sh /wheels
|
|
||||||
|
|
||||||
FROM python:3.14-slim
|
|
||||||
|
|
||||||
# System deps. tmux is required by Cookbook for background downloads/serves.
|
# System deps. tmux is required by Cookbook for background downloads/serves.
|
||||||
# openssh-client is required for Cookbook remote server tests, setup, probes,
|
# openssh-client is required for Cookbook remote server tests, setup, probes,
|
||||||
# downloads, and serves from Docker installs.
|
# downloads, and serves from Docker installs.
|
||||||
# git/cmake are required when Cookbook builds llama.cpp on first llama.cpp
|
# git/cmake are required when Cookbook builds llama.cpp on first llama.cpp
|
||||||
# launch inside Docker.
|
# launch inside Docker.
|
||||||
# nodejs/npm provide npx for the built-in Browser MCP server.
|
# nodejs/npm provide npx for the optional built-in Browser MCP server.
|
||||||
# chromium provides the actual browser binary used by that MCP server.
|
|
||||||
# gosu lets the entrypoint drop privileges cleanly so signals still reach
|
# gosu lets the entrypoint drop privileges cleanly so signals still reach
|
||||||
# uvicorn directly (no extra shell layer like `su`/`sudo` would add).
|
# uvicorn directly (no extra shell layer like `su`/`sudo` would add).
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
@@ -27,70 +15,16 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
git \
|
git \
|
||||||
nodejs \
|
nodejs \
|
||||||
npm \
|
npm \
|
||||||
chromium \
|
|
||||||
tmux \
|
tmux \
|
||||||
openssh-client \
|
openssh-client \
|
||||||
gosu \
|
gosu \
|
||||||
libgl1 \
|
|
||||||
libglib2.0-0t64 \
|
|
||||||
libxcb1 \
|
|
||||||
libmagic1 \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# libgl1/libglib2.0-0t64/libxcb1 are runtime shared libs (libGL.so.1,
|
|
||||||
# libglib-2.0/libgthread, libxcb.so.1) that opencv-python (cv2) loads. The
|
|
||||||
# slim base omits them, so the Cookbook "install realesrgan" path imports cv2
|
|
||||||
# and dies with `libxcb.so.1: cannot open shared object file` despite a clean
|
|
||||||
# pip install. Using full opencv-python (not -headless) because basicsr/gfpgan/
|
|
||||||
# facexlib/realesrgan all depend on the `opencv-python` distribution by name.
|
|
||||||
#
|
|
||||||
# libmagic1 is the shared lib (libmagic.so.1) that python-magic dlopens for
|
|
||||||
# content-based MIME sniffing in src/upload_handler.py. We install both here
|
|
||||||
# (libmagic1 + the python-magic wrapper, below) rather than in requirements.txt
|
|
||||||
# because python-magic resolves libmagic at import time: where the lib is
|
|
||||||
# absent the import can block or raise, so keeping it image-only avoids
|
|
||||||
# regressing pip/venv installs on hosts without libmagic. Debian always has the
|
|
||||||
# lib here, so the import is instant and detection actually works.
|
|
||||||
|
|
||||||
# Docker CLI (client only — daemon stays on the host via the
|
|
||||||
# /var/run/docker.sock mount). The Debian `docker.io` package ships
|
|
||||||
# dockerd but not the client binary on slim, so grab the static client
|
|
||||||
# tarball from download.docker.com instead.
|
|
||||||
ARG DOCKER_CLI_VERSION=29.6.2
|
|
||||||
RUN ARCH="$(dpkg --print-architecture)" \
|
|
||||||
&& case "$ARCH" in \
|
|
||||||
amd64) DARCH=x86_64 ;; \
|
|
||||||
arm64) DARCH=aarch64 ;; \
|
|
||||||
*) echo "unsupported arch $ARCH"; exit 1 ;; \
|
|
||||||
esac \
|
|
||||||
&& curl -fsSL "https://download.docker.com/linux/static/stable/${DARCH}/docker-${DOCKER_CLI_VERSION}.tgz" \
|
|
||||||
-o /tmp/docker.tgz \
|
|
||||||
&& tar -xzf /tmp/docker.tgz -C /tmp \
|
|
||||||
&& install -m 0755 /tmp/docker/docker /usr/local/bin/docker \
|
|
||||||
&& rm -rf /tmp/docker /tmp/docker.tgz
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install Python deps first (layer cache). Optional extras (PyMuPDF AGPL, etc.)
|
# Install Python deps first (layer cache)
|
||||||
# are opt-in so the default image stays MIT-core; see requirements-optional.txt.
|
COPY requirements.txt .
|
||||||
ARG INSTALL_OPTIONAL=false
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
COPY requirements.txt requirements-optional.txt ./
|
|
||||||
RUN pip install --no-cache-dir -r requirements.txt \
|
|
||||||
&& if [ "$INSTALL_OPTIONAL" = "true" ]; then pip install --no-cache-dir -r requirements-optional.txt; fi
|
|
||||||
|
|
||||||
# python-magic powers content-based MIME sniffing in src/upload_handler.py.
|
|
||||||
# Image-only (not in requirements.txt) because it needs the libmagic1 system
|
|
||||||
# lib installed above; see the apt note near the top of this stage.
|
|
||||||
RUN pip install --no-cache-dir python-magic==0.4.27
|
|
||||||
|
|
||||||
# Pre-install the patched basicsr/gfpgan/facexlib wheels built in the
|
|
||||||
# realesrgan-wheels stage (--no-deps keeps the image lean — torch & friends are
|
|
||||||
# pulled only when realesrgan is actually installed). With these dists already
|
|
||||||
# satisfied, the Cookbook's plain `pip install realesrgan` resolves them from
|
|
||||||
# wheels instead of rebuilding the sdists that fail on Python 3.14.
|
|
||||||
COPY --from=realesrgan-wheels /wheels/ /tmp/odysseus-wheels/
|
|
||||||
RUN pip install --no-cache-dir --no-deps /tmp/odysseus-wheels/*.whl \
|
|
||||||
&& rm -rf /tmp/odysseus-wheels
|
|
||||||
|
|
||||||
# Copy app code
|
# Copy app code
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|||||||
@@ -1,235 +1,21 @@
|
|||||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
MIT License
|
||||||
Version 3, 19 November 2007
|
|
||||||
|
Copyright (c) 2025 Odysseus Contributors
|
||||||
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed.
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
Preamble
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
The GNU Affero General Public License is a free, copyleft license for software and other kinds of works, specifically designed to ensure cooperation with the community in the case of network server software.
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, our General Public Licenses are intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users.
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things.
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
Developers that use our General Public Licenses protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License which gives you legal permission to copy, distribute and/or modify the software.
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
A secondary benefit of defending all users' freedom is that improvements made in alternate versions of the program, if they receive widespread use, become available for other developers to incorporate. Many developers of free software are heartened and encouraged by the resulting cooperation. However, in the case of software used on network servers, this result may fail to come about. The GNU General Public License permits making a modified version and letting the public access it on a server without ever releasing its source code to the public.
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
The GNU Affero General Public License is designed specifically to ensure that, in such cases, the modified source code becomes available to the community. It requires the operator of a network server to provide the source code of the modified version running there to the users of that server. Therefore, public use of a modified version, on a publicly accessible server, gives the public access to the source code of the modified version.
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
An older license, called the Affero General Public License and published by Affero, was designed to accomplish similar goals. This is a different license, not a version of the Affero GPL, but Affero has released a new version of the Affero GPL which permits relicensing under this license.
|
|
||||||
|
|
||||||
The precise terms and conditions for copying, distribution and modification follow.
|
|
||||||
|
|
||||||
TERMS AND CONDITIONS
|
|
||||||
|
|
||||||
0. Definitions.
|
|
||||||
|
|
||||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
|
||||||
|
|
||||||
"Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks.
|
|
||||||
|
|
||||||
"The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations.
|
|
||||||
|
|
||||||
To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work.
|
|
||||||
|
|
||||||
A "covered work" means either the unmodified Program or a work based on the Program.
|
|
||||||
|
|
||||||
To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well.
|
|
||||||
|
|
||||||
To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying.
|
|
||||||
|
|
||||||
An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion.
|
|
||||||
|
|
||||||
1. Source Code.
|
|
||||||
The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work.
|
|
||||||
|
|
||||||
A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language.
|
|
||||||
|
|
||||||
The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it.
|
|
||||||
|
|
||||||
The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those
|
|
||||||
subprograms and other parts of the work.
|
|
||||||
|
|
||||||
The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source.
|
|
||||||
|
|
||||||
The Corresponding Source for a work in source code form is that same work.
|
|
||||||
|
|
||||||
2. Basic Permissions.
|
|
||||||
All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law.
|
|
||||||
|
|
||||||
You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you.
|
|
||||||
|
|
||||||
Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary.
|
|
||||||
|
|
||||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
|
||||||
No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures.
|
|
||||||
|
|
||||||
When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures.
|
|
||||||
|
|
||||||
4. Conveying Verbatim Copies.
|
|
||||||
You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program.
|
|
||||||
|
|
||||||
You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee.
|
|
||||||
|
|
||||||
5. Conveying Modified Source Versions.
|
|
||||||
You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions:
|
|
||||||
|
|
||||||
a) The work must carry prominent notices stating that you modified it, and giving a relevant date.
|
|
||||||
|
|
||||||
b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices".
|
|
||||||
|
|
||||||
c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it.
|
|
||||||
|
|
||||||
d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so.
|
|
||||||
|
|
||||||
A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate.
|
|
||||||
|
|
||||||
6. Conveying Non-Source Forms.
|
|
||||||
You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways:
|
|
||||||
|
|
||||||
a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange.
|
|
||||||
|
|
||||||
b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge.
|
|
||||||
|
|
||||||
c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b.
|
|
||||||
|
|
||||||
d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements.
|
|
||||||
|
|
||||||
e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d.
|
|
||||||
|
|
||||||
A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work.
|
|
||||||
|
|
||||||
A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product.
|
|
||||||
|
|
||||||
"Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made.
|
|
||||||
|
|
||||||
If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM).
|
|
||||||
|
|
||||||
The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network.
|
|
||||||
|
|
||||||
Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying.
|
|
||||||
|
|
||||||
7. Additional Terms.
|
|
||||||
"Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions.
|
|
||||||
|
|
||||||
When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission.
|
|
||||||
|
|
||||||
Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms:
|
|
||||||
|
|
||||||
a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or
|
|
||||||
|
|
||||||
b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or
|
|
||||||
|
|
||||||
c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or
|
|
||||||
|
|
||||||
d) Limiting the use for publicity purposes of names of licensors or authors of the material; or
|
|
||||||
|
|
||||||
e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or
|
|
||||||
|
|
||||||
f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors.
|
|
||||||
|
|
||||||
All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying.
|
|
||||||
|
|
||||||
If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms.
|
|
||||||
|
|
||||||
Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way.
|
|
||||||
|
|
||||||
8. Termination.
|
|
||||||
|
|
||||||
You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11).
|
|
||||||
|
|
||||||
However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation.
|
|
||||||
|
|
||||||
Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice.
|
|
||||||
|
|
||||||
Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10.
|
|
||||||
|
|
||||||
9. Acceptance Not Required for Having Copies.
|
|
||||||
|
|
||||||
You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so.
|
|
||||||
|
|
||||||
10. Automatic Licensing of Downstream Recipients.
|
|
||||||
|
|
||||||
Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License.
|
|
||||||
|
|
||||||
An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts.
|
|
||||||
|
|
||||||
You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it.
|
|
||||||
|
|
||||||
11. Patents.
|
|
||||||
|
|
||||||
A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version".
|
|
||||||
|
|
||||||
A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License.
|
|
||||||
|
|
||||||
Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version.
|
|
||||||
|
|
||||||
In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party.
|
|
||||||
|
|
||||||
If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent
|
|
||||||
license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid.
|
|
||||||
|
|
||||||
If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it.
|
|
||||||
|
|
||||||
A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007.
|
|
||||||
|
|
||||||
Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law.
|
|
||||||
|
|
||||||
12. No Surrender of Others' Freedom.
|
|
||||||
|
|
||||||
If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may
|
|
||||||
not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program.
|
|
||||||
|
|
||||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
|
||||||
|
|
||||||
Notwithstanding any other provision of this License, if you modify the Program, your modified version must prominently offer all users interacting with it remotely through a computer network (if your version supports such interaction) an opportunity to receive the Corresponding Source of your version by providing access to the Corresponding Source from a network server at no charge, through some standard or customary means of facilitating copying of software. This Corresponding Source shall include the Corresponding Source for any work covered by version 3 of the GNU General Public License that is incorporated pursuant to the following paragraph.
|
|
||||||
|
|
||||||
Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the work with which it is combined will remain governed by version 3 of the GNU General Public License.
|
|
||||||
|
|
||||||
14. Revised Versions of this License.
|
|
||||||
|
|
||||||
The Free Software Foundation may publish revised and/or new versions of the GNU Affero General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns.
|
|
||||||
|
|
||||||
Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU Affero General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU Affero General Public License, you may choose any version ever published by the Free Software Foundation.
|
|
||||||
|
|
||||||
If the Program specifies that a proxy can decide which future versions of the GNU Affero General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program.
|
|
||||||
|
|
||||||
Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version.
|
|
||||||
|
|
||||||
15. Disclaimer of Warranty.
|
|
||||||
|
|
||||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
|
||||||
|
|
||||||
16. Limitation of Liability.
|
|
||||||
|
|
||||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
|
|
||||||
|
|
||||||
17. Interpretation of Sections 15 and 16.
|
|
||||||
|
|
||||||
If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee.
|
|
||||||
|
|
||||||
END OF TERMS AND CONDITIONS
|
|
||||||
|
|
||||||
How to Apply These Terms to Your New Programs
|
|
||||||
|
|
||||||
If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms.
|
|
||||||
|
|
||||||
To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found.
|
|
||||||
|
|
||||||
<one line to give the program's name and a brief idea of what it does.>
|
|
||||||
Copyright (C) <year> <name of author>
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
Also add information on how to contact you by electronic and paper mail.
|
|
||||||
|
|
||||||
If your software can interact with users remotely through a computer network, you should also make sure that it provides a way for users to get its source. For example, if your program is a web application, its interface could display a "Source" link that leads users to an archive of the code. There are many ways you could offer source, and different solutions will be better for different programs; see section 13 for the specific requirements.
|
|
||||||
|
|
||||||
You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU AGPL, see <http://www.gnu.org/licenses/>.
|
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
# -*- mode: python ; coding: utf-8 -*-
|
|
||||||
|
|
||||||
|
|
||||||
a = Analysis(
|
|
||||||
['launcher.py'],
|
|
||||||
pathex=[],
|
|
||||||
binaries=[],
|
|
||||||
datas=[('static', 'static'), ('scripts', 'scripts'), ('mcp_servers', 'mcp_servers'), ('services/hwfit/data', 'services/hwfit/data'), ('config', 'config'), ('.env.example', '.env.example')],
|
|
||||||
hiddenimports=[],
|
|
||||||
hookspath=[],
|
|
||||||
hooksconfig={},
|
|
||||||
runtime_hooks=[],
|
|
||||||
excludes=[],
|
|
||||||
noarchive=False,
|
|
||||||
optimize=0,
|
|
||||||
)
|
|
||||||
pyz = PYZ(a.pure)
|
|
||||||
|
|
||||||
exe = EXE(
|
|
||||||
pyz,
|
|
||||||
a.scripts,
|
|
||||||
[],
|
|
||||||
exclude_binaries=True,
|
|
||||||
name='Odysseus',
|
|
||||||
debug=False,
|
|
||||||
bootloader_ignore_signals=False,
|
|
||||||
strip=False,
|
|
||||||
upx=True,
|
|
||||||
console=False,
|
|
||||||
disable_windowed_traceback=False,
|
|
||||||
argv_emulation=False,
|
|
||||||
target_arch=None,
|
|
||||||
codesign_identity=None,
|
|
||||||
entitlements_file=None,
|
|
||||||
icon=['static\\icon.ico'],
|
|
||||||
)
|
|
||||||
coll = COLLECT(
|
|
||||||
exe,
|
|
||||||
a.binaries,
|
|
||||||
a.datas,
|
|
||||||
strip=False,
|
|
||||||
upx=True,
|
|
||||||
upx_exclude=[],
|
|
||||||
name='Odysseus',
|
|
||||||
)
|
|
||||||
@@ -1,80 +1,449 @@
|
|||||||
# Odysseus
|
# Odysseus
|
||||||
|
|
||||||
<p align="center">
|
```
|
||||||
<img src="docs/odysseus-wordmark.png" alt="Odysseus" width="238">
|
───────────────────────────────────────────────
|
||||||
</p>
|
⊹ ࣪ ˖ ૮( ˶ᵔ ᵕ ᵔ˶ )っ Odysseus vers. 1.0
|
||||||
|
───────────────────────────────────────────────
|
||||||
|
```
|
||||||
|
|
||||||
<p align="center">
|

|
||||||
A self-hosted AI workspace for chat, agents, research, documents, email, notes, calendar, and local model workflows.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p align="center">
|
A self-hosted AI workspace -- meant to be the self-hosted version of the UI experience you get from ChatGPT and Claude. But with more jank and fun. Running on your own hardware, with your own data -- local-first, privacy-first, and no trojan.
|
||||||
<a href="#quick-start">Quick Start</a> ·
|
|
||||||
<a href="docs/setup.md">Setup Guide</a> ·
|
|
||||||
<a href="docs/ARCHITECTURE.md">Architecture</a> ·
|
|
||||||
<a href="SECURITY.md">Security</a> ·
|
|
||||||
<a href="CONTRIBUTING.md">Contributing</a> ·
|
|
||||||
<a href="ROADMAP.md">Roadmap</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p align="center">
|
## Features
|
||||||
<a href="https://repology.org/project/odysseus-ai/versions"><img src="https://repology.org/badge/vertical-allrepos/odysseus-ai.svg" alt="Packaging status"></a>
|
- **Chat** -- chat with any local model or API; adding them is super simple.<br> <sub>vLLM · llama.cpp · Ollama · OpenRouter · OpenAI</sub>
|
||||||
</p>
|
- **Agent** -- hand it tools and let it run the whole task itself.<br> <sub>built on [opencode](https://github.com/anomalyco/opencode) · MCP · web · files · shell · skills · memory</sub>
|
||||||
|
- **Cookbook** -- Scans your hardware, recommends models, click to download and serve.. easy!<br> <sub>built on [llmfit](https://github.com/AlexsJones/llmfit) · VRAM-aware · GGUF / FP8 / AWQ · fit scoring · vLLM / llama.cpp serving</sub>
|
||||||
|
- **Deep Research** -- multi-step runs that gather, read, and synthesize sources into a nice visual report.<br> <sub>adapted from [Tongyi DeepResearch](https://github.com/Alibaba-NLP/DeepResearch)</sub>
|
||||||
|
- **Compare** -- a fun tool to compare models side by side. Test completely blind, no bias!<br> <sub>multi-model · blind test · synthesis</sub>
|
||||||
|
- **Documents** -- YOU write the text, AI is there to assist, not the opposite.<br> <sub>multi-tab editor · markdown · HTML · CSV · syntax highlighting · AI edits · suggestions</sub>
|
||||||
|
- **Memory / Skills** -- Persistent memory and skills, your agent evolves over time as it better understands you and your tasks!<br> <sub>ChromaDB · fastembed (ONNX) · vector + keyword retrieval · import/export</sub>
|
||||||
|
- **Email** -- IMAP/SMTP inbox with AI triage built in: urgency reminders, auto-tag, auto-summary, auto-reply drafts, auto-spam.<br> <sub>IMAP · SMTP · per-account routing · CalDAV-aware</sub>
|
||||||
|
- **Notes & Tasks** -- Quick notes with reminders, a todo list, and scheduled tasks the agent can act on.<br> <sub>note pings · checklist · cron-style tasks · ntfy / browser / email channels</sub>
|
||||||
|
- **Calendar** -- Local-first calendar with CalDAV sync to Radicale / Nextcloud / Apple / Fastmail.<br> <sub>CalDAV pull · .ics import/export · per-calendar colors · agent-aware</sub>
|
||||||
|
- **Works on mobile** -- looks and runs great on your phone, not just desktop.<br> <sub>responsive · installable (PWA) · touch gestures</sub>
|
||||||
|
- **Extras** -- more to explore, happy if you give it a go!<br> <sub>image editor · theme editor · file uploads (vision + PDF) · web search · presets · sessions · 2FA</sub>
|
||||||
|
|
||||||
<p align="center">
|
## Demo
|
||||||
<img src="docs/odysseus-browser.jpg" alt="Odysseus interface">
|
A full, hover-to-play tour lives on the landing page (`docs/index.html`).
|
||||||
</p>
|
|
||||||
|
|
||||||
---
|
<details>
|
||||||
|
<summary>Screenshots / clips</summary>
|
||||||
|
|
||||||
|
### Chat & Agents
|
||||||
|

|
||||||
|
### Deep Research
|
||||||
|

|
||||||
|
### Compare
|
||||||
|

|
||||||
|
### Documents
|
||||||
|

|
||||||
|
### Notes & Tasks
|
||||||
|

|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
> `dev` is the default branch and gets the newest changes first. Use [`main`](https://github.com/odysseus-dev/odysseus/tree/main) if you want the more curated branch.
|
Defaults work out of the box: clone, run, then configure models/search/email
|
||||||
|
inside **Settings**. Only edit `.env` for deployment-level overrides like
|
||||||
|
`APP_BIND`, `APP_PORT`, `AUTH_ENABLED`, `DATABASE_URL`, or a pre-seeded admin password.
|
||||||
|
|
||||||
|
On first setup, Odysseus creates an admin account (`admin` unless
|
||||||
|
`ODYSSEUS_ADMIN_USER` is set) and prints a temporary password in the terminal.
|
||||||
|
For Docker installs, the same line is in `docker compose logs odysseus`.
|
||||||
|
Use that for the first login, then change it in **Settings**.
|
||||||
|
|
||||||
|
Contributing? See [CONTRIBUTING.md](CONTRIBUTING.md) for setup, testing, and
|
||||||
|
pull request guidelines.
|
||||||
|
|
||||||
|
### Docker (recommended)
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/odysseus-dev/odysseus.git
|
git clone https://github.com/pewdiepie-archdaemon/odysseus.git
|
||||||
cd odysseus
|
cd odysseus
|
||||||
cp .env.example .env
|
cp .env.example .env # optional, but recommended for explicit defaults
|
||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
```
|
```
|
||||||
|
Open `http://localhost:7000` when the containers are healthy. Docker Compose
|
||||||
|
binds the web UI to `127.0.0.1` by default. If the port is taken, set
|
||||||
|
`APP_PORT=7001` in `.env` and recreate the container. Set `APP_BIND=0.0.0.0`
|
||||||
|
only when you intentionally want LAN/reverse-proxy access.
|
||||||
|
|
||||||
Open `http://localhost:7000` when the containers are healthy. The first admin password is printed in `docker compose logs odysseus`.
|
### Native Linux / macOS
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/pewdiepie-archdaemon/odysseus.git
|
||||||
|
cd odysseus
|
||||||
|
python3 -m venv venv
|
||||||
|
source venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
python setup.py
|
||||||
|
python -m uvicorn app:app --host 127.0.0.1 --port 7000
|
||||||
|
```
|
||||||
|
Requirements: Python 3.11+. Cookbook also needs `tmux` for background model
|
||||||
|
downloads and serves. The app itself is lightweight; local model serving is the
|
||||||
|
heavy part and depends on the model, runtime, GPU, and VRAM, so small hosts can
|
||||||
|
connect to API or remote model servers instead. Use `--host 0.0.0.0` only when you intentionally want LAN/reverse-proxy access.
|
||||||
|
|
||||||
Native installs, GPU notes, Windows/macOS instructions, HTTPS, and configuration live in the [setup guide](docs/setup.md).
|
### Apple Silicon
|
||||||
|
Docker on macOS cannot use the Metal GPU. For GPU-accelerated Cookbook on an
|
||||||
|
M-series Mac, run Odysseus natively:
|
||||||
|
|
||||||
## Features
|
```bash
|
||||||
|
git clone https://github.com/pewdiepie-archdaemon/odysseus.git
|
||||||
|
cd odysseus
|
||||||
|
./start-macos.sh
|
||||||
|
```
|
||||||
|
|
||||||
- **Chat + Agents** — local/API models, tools, MCP, files, shell, skills, and memory.
|
It launches at `http://127.0.0.1:7860`. To expose it to your phone over a trusted LAN/VPN such as Tailscale, bind all interfaces:
|
||||||
- **Cookbook** — hardware-aware model recommendations, downloads, and serving.
|
|
||||||
- **Deep Research** — multi-step web research with source reading and report generation.
|
|
||||||
- **Compare** — blind side-by-side model testing and synthesis.
|
|
||||||
- **Documents** — writing-first editor with AI edits, suggestions, Markdown, HTML, CSV, and syntax highlighting.
|
|
||||||
- **Email** — IMAP/SMTP inbox with triage, tags, summaries, reminders, and reply drafts.
|
|
||||||
- **Notes, Tasks + Calendar** — reminders, todos, scheduled agent tasks, and CalDAV sync.
|
|
||||||
- **Extras** — gallery/image editor, themes, uploads, web search, presets, sessions, and 2FA.
|
|
||||||
|
|
||||||
## Demo
|
```bash
|
||||||
|
ODYSSEUS_HOST=0.0.0.0 ./start-macos.sh
|
||||||
|
# then open http://<tailscale-ip>:7860
|
||||||
|
```
|
||||||
|
|
||||||
Explore the interface through the [interactive product tour](docs/index.html).
|
The script also reads `.env` at startup, so `APP_BIND=0.0.0.0` and `APP_PORT`
|
||||||
|
set there are picked up automatically without a command-line override each run.
|
||||||
|
|
||||||
|
Keep `AUTH_ENABLED=true` (the default) before binding outside loopback. Do not
|
||||||
|
expose this port directly to the public internet. To build a clickable app wrapper:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./build-macos-app.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Cookbook, GPU, Ollama, and troubleshooting notes</summary>
|
||||||
|
|
||||||
|
**Docker bundled services.** Compose starts Odysseus, ChromaDB, SearXNG, and
|
||||||
|
ntfy. Odysseus and the bundled service ports bind to `127.0.0.1` by default, so
|
||||||
|
they are reachable from the host but not exposed to your LAN/public internet
|
||||||
|
unless you opt in.
|
||||||
|
|
||||||
|
**Cookbook storage in Docker.** Downloads live in `./data/huggingface`
|
||||||
|
(`~/.cache/huggingface` in the container). Cookbook-installed Python CLIs and
|
||||||
|
serve engines live in `./data/local` (`~/.local` in the container), so they
|
||||||
|
survive container recreation.
|
||||||
|
|
||||||
|
**Remote servers.** In **Cookbook -> Settings -> Servers**, generate the
|
||||||
|
Odysseus SSH key and add the public key to the remote server's
|
||||||
|
`~/.ssh/authorized_keys`. From the host you can also run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh-copy-id -i data/ssh/id_ed25519.pub user@server
|
||||||
|
```
|
||||||
|
|
||||||
|
**Docker GPU overlays.** CPU-only users can skip this section. Cookbook can
|
||||||
|
only detect GPUs that Docker exposes to the container — if the host runtime or
|
||||||
|
device passthrough is not configured, Cookbook sees the iGPU, another card, or
|
||||||
|
CPU instead of your intended GPU.
|
||||||
|
|
||||||
|
For NVIDIA, `scripts/check-docker-gpu.sh` diagnoses GPU passthrough and can
|
||||||
|
optionally install the host runtime or update `.env`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Read-only diagnostic (default — installs nothing, never edits .env):
|
||||||
|
scripts/check-docker-gpu.sh
|
||||||
|
|
||||||
|
# Print OS-specific install commands without running them:
|
||||||
|
scripts/check-docker-gpu.sh --print-install-commands
|
||||||
|
|
||||||
|
# Install NVIDIA Container Toolkit on Ubuntu/Debian (requires sudo):
|
||||||
|
scripts/check-docker-gpu.sh --install-nvidia-toolkit
|
||||||
|
|
||||||
|
# Write COMPOSE_FILE to .env (only when GPU passthrough is confirmed working):
|
||||||
|
scripts/check-docker-gpu.sh --enable-nvidia-overlay
|
||||||
|
|
||||||
|
# Full assisted setup — install toolkit, then enable overlay if passthrough works:
|
||||||
|
scripts/check-docker-gpu.sh --install-nvidia-toolkit --enable-nvidia-overlay
|
||||||
|
```
|
||||||
|
|
||||||
|
Safety notes:
|
||||||
|
- The app never installs host GPU runtime automatically.
|
||||||
|
- The app never edits `.env` automatically.
|
||||||
|
- `.env` is only modified when `--enable-nvidia-overlay` is explicitly passed,
|
||||||
|
and only after GPU passthrough succeeds. `--yes` skips prompts but does not
|
||||||
|
bypass the passthrough gate.
|
||||||
|
- `.env.bak.*` backups created by `--enable-nvidia-overlay` are ignored by
|
||||||
|
Git and the Docker build context.
|
||||||
|
|
||||||
|
To enable manually without the script, add this to `.env`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
COMPOSE_FILE=docker-compose.yml:docker/gpu.nvidia.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
**AMD / ROCm.** AMD setup is read-only diagnostic plus manual `.env` edit. Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scripts/check-docker-amd-gpu.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Then add the reported values to `.env`, replacing `RENDER_GID` with your host's
|
||||||
|
numeric render group id:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
COMPOSE_FILE=docker-compose.yml:docker/gpu.amd.yml
|
||||||
|
RENDER_GID=989
|
||||||
|
```
|
||||||
|
|
||||||
|
For NVIDIA/AMD GPU support, also read the comments in the selected overlay file: docker/gpu.nvidia.yml or docker/gpu.amd.yml.
|
||||||
|
|
||||||
|
**Stack-management UIs (Portainer, Coolify, Dockhand, etc.).** These tools
|
||||||
|
often accept only a single Compose file and do not reliably honor `COMPOSE_FILE`
|
||||||
|
or multiple `-f` overlays. CLI users should keep using the `COMPOSE_FILE`
|
||||||
|
overlay workflow above. For stack UIs, point the stack at one of the standalone
|
||||||
|
files instead, which bundle the base stack plus the GPU settings:
|
||||||
|
|
||||||
|
- `docker-compose.gpu-nvidia.yml` — still requires the NVIDIA Container Toolkit
|
||||||
|
on the host.
|
||||||
|
- `docker-compose.gpu-amd.yml` — still requires host ROCm/kfd/DRI setup, the
|
||||||
|
`video`/`render` group membership, and `RENDER_GID` when needed.
|
||||||
|
|
||||||
|
The base `docker-compose.yml` plus the `docker/gpu.*.yml` overlays remain the
|
||||||
|
source of truth; the standalone files mirror them for single-file deployments.
|
||||||
|
|
||||||
|
Verify after enabling either overlay:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec odysseus nvidia-smi -L # NVIDIA
|
||||||
|
docker compose exec odysseus sh -lc 'test -e /dev/kfd && test -d /dev/dri && ls -l /dev/kfd /dev/dri/renderD*' # AMD
|
||||||
|
```
|
||||||
|
|
||||||
|
> **GPU passthrough ≠ llama.cpp CUDA.** `nvidia-smi` passing inside the
|
||||||
|
> container confirms Docker GPU access, but llama.cpp also needs `cudart` and
|
||||||
|
> the CUDA Toolkit at runtime. If Cookbook logs show `Unable to find cudart
|
||||||
|
> library`, `Could NOT find CUDAToolkit`, `CUDA Toolkit not found`, or
|
||||||
|
> tensors/layers assigned to CPU, that is a Cookbook/llama.cpp build issue —
|
||||||
|
> not a Docker passthrough failure. Re-install the serve engine via
|
||||||
|
> **Cookbook → Dependencies** to get a CUDA-enabled build.
|
||||||
|
>
|
||||||
|
> The same split applies to AMD/ROCm: seeing `/dev/kfd` and `/dev/dri` inside
|
||||||
|
> the container confirms device passthrough, not ROCm userspace or a
|
||||||
|
> ROCm-enabled vLLM/llama.cpp build. `rocm-smi` and `rocminfo` are not expected
|
||||||
|
> inside the slim Odysseus image.
|
||||||
|
|
||||||
|
**Ollama with Docker.** If Ollama runs on the host, add this endpoint in
|
||||||
|
Settings:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://host.docker.internal:11434/v1
|
||||||
|
```
|
||||||
|
|
||||||
|
Ollama must listen outside its own loopback interface:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
OLLAMA_HOST=0.0.0.0:11434 ollama serve
|
||||||
|
```
|
||||||
|
|
||||||
|
This connects Odysseus in Docker to an Ollama server that is already running on
|
||||||
|
your host machine; it does not start Ollama inside the container.
|
||||||
|
`host.docker.internal` is Docker's hostname for the host machine from inside the
|
||||||
|
container. Cookbook **Serve** is a separate workflow for serving downloaded
|
||||||
|
models through Odysseus/llama.cpp, so Windows users with an existing Ollama
|
||||||
|
install usually only need to add the endpoint in Settings.
|
||||||
|
|
||||||
|
**Useful checks.**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose ps
|
||||||
|
docker compose logs --tail=120 odysseus
|
||||||
|
docker compose logs odysseus | grep -E 'ChromaDB|MemoryVectorStore|DEGRADED'
|
||||||
|
```
|
||||||
|
|
||||||
|
**macOS details.** `start-macos.sh` installs Homebrew deps, creates the venv,
|
||||||
|
runs setup, and starts uvicorn on port `7860` because AirPlay often holds
|
||||||
|
`7000`. It uses llama.cpp/Ollama for Metal. vLLM/SGLang are CUDA/ROCm-only and
|
||||||
|
do not run on macOS. MLX-only models are not served by Odysseus.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
### Native Windows
|
||||||
|
|
||||||
|
**One-command launcher** (creates the venv, installs deps, runs setup, starts the
|
||||||
|
server; safe to re-run):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
git clone https://github.com/pewdiepie-archdaemon/odysseus.git
|
||||||
|
cd odysseus
|
||||||
|
powershell -ExecutionPolicy Bypass -File .\launch-windows.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
Or do it by hand:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
git clone https://github.com/pewdiepie-archdaemon/odysseus.git
|
||||||
|
cd odysseus
|
||||||
|
py -3.11 -m venv venv
|
||||||
|
venv\Scripts\Activate.ps1
|
||||||
|
pip install -r requirements.txt
|
||||||
|
python setup.py
|
||||||
|
python -m uvicorn app:app --host 127.0.0.1 --port 7000
|
||||||
|
```
|
||||||
|
|
||||||
|
If `python` points at an older interpreter, use `py -3.12` (or another installed
|
||||||
|
3.11+ version) for the venv step.
|
||||||
|
|
||||||
|
**Requirements:** Python 3.11+. The core app (chat, agent, memory, documents,
|
||||||
|
email, calendar, deep research) runs fully native. For full **Cookbook** background
|
||||||
|
model downloads and the agent shell tool, also install
|
||||||
|
[Git for Windows](https://git-scm.com/download/win) (provides `bash.exe`).
|
||||||
|
Local GPU *serving* of vLLM/SGLang needs Linux/WSL2; for a local model on Windows,
|
||||||
|
[Ollama](https://ollama.com/download) is the easiest path — point Odysseus at
|
||||||
|
`http://localhost:11434/v1` in Settings.
|
||||||
|
|
||||||
|
Open `http://localhost:7000`, log in with the generated admin password,
|
||||||
|
and configure everything else inside **Settings**.
|
||||||
|
|
||||||
|
## Troubleshooting & Advanced Setup
|
||||||
|
|
||||||
|
### `chromadb-client` conflicts with embedded ChromaDB
|
||||||
|
If `chromadb-client` (the lightweight HTTP-only package) is installed alongside the full `chromadb` package, Odysseus starts but ChromaDB silently falls back to HTTP-only mode and fails.
|
||||||
|
|
||||||
|
**Fix:** uninstall `chromadb-client` and force-reinstall the full package:
|
||||||
|
```bash
|
||||||
|
./venv/bin/pip uninstall chromadb-client -y
|
||||||
|
./venv/bin/pip install --force-reinstall chromadb
|
||||||
|
```
|
||||||
|
|
||||||
|
### HTTPS + LAN/Tailscale exposure
|
||||||
|
To expose Odysseus on a local network or Tailscale with HTTPS:
|
||||||
|
1. Change the bind address to `0.0.0.0` in `.env` (`APP_BIND=0.0.0.0` or `ODYSSEUS_HOST=0.0.0.0`).
|
||||||
|
2. Generate a locally-trusted cert for your LAN/Tailscale IPs using [mkcert](https://github.com/FiloSottile/mkcert):
|
||||||
|
```bash
|
||||||
|
mkcert -install
|
||||||
|
mkcert -cert-file cert.pem -key-file key.pem 192.168.1.100 tailscale-ip
|
||||||
|
```
|
||||||
|
3. Run `uvicorn` with the generated certs:
|
||||||
|
```bash
|
||||||
|
python -m uvicorn app:app --host 0.0.0.0 --port 7000 --ssl-certfile=cert.pem --ssl-keyfile=key.pem
|
||||||
|
```
|
||||||
|
4. Install the `mkcert` CA on any other device you want to access Odysseus from (e.g., for iOS, email the `rootCA.pem` to yourself, install the profile, and trust it in Certificate Trust Settings).
|
||||||
|
|
||||||
|
### Optional Dependencies
|
||||||
|
`requirements-optional.txt` contains packages that unlock extra features. It is not installed by default.
|
||||||
|
|
||||||
|
| Package | Feature unlocked |
|
||||||
|
|---------|-----------------|
|
||||||
|
| `faster-whisper` | Local speech-to-text (microphone -> text) via the "local" STT provider. |
|
||||||
|
| `duckduckgo-search` | DuckDuckGo as a search provider option. |
|
||||||
|
| `PyMuPDF` | PDF page rendering in the side viewer panel and form-filling. (Note: AGPL-3.0) |
|
||||||
|
| `markitdown` | Office/EPUB document text extraction (converts .docx/.xlsx/.pptx/.xls/.epub to Markdown). |
|
||||||
|
|
||||||
|
## Security Notes
|
||||||
|
Odysseus is a self-hosted workspace with powerful local tools: shell access, file uploads, model downloads, web research, email/calendar integrations, and API tokens. Treat it like an admin console.
|
||||||
|
|
||||||
|
- Keep `AUTH_ENABLED=true` for any network-accessible deployment.
|
||||||
|
- Keep `LOCALHOST_BYPASS=false` outside local development.
|
||||||
|
- Use `SECURE_COOKIES=true` when Odysseus is served through HTTPS by a trusted reverse proxy or private access gateway.
|
||||||
|
- Do not expose it directly to the public internet without HTTPS and a trusted reverse proxy or private access layer.
|
||||||
|
- Keep `.env`, `data/`, `logs/`, databases, uploads, generated media, backups, auth/session files, API keys, and model/provider tokens out of Git and private shares. They are ignored by default.
|
||||||
|
- Review `data/auth.json` after first boot: disable open signup unless you intentionally want it, make only your own account admin, and keep demo/test accounts non-admin.
|
||||||
|
- Non-admin users do not get shell/Python/file read/write by default, and admin-only routes/tools such as MCP management, API tokens, webhooks, model/cookbook serving, backup/vault, and app settings are admin-gated. Other features are controlled by per-user privileges, so review each user's privileges before exposing a deployment.
|
||||||
|
- Rotate any API keys or tokens that were ever pasted into a shared chat, demo, screenshot, or log.
|
||||||
|
- If you enable API tokens or webhooks, create separate tokens per integration and delete unused ones.
|
||||||
|
- Prefer binding manual development runs to `127.0.0.1`; bind to `0.0.0.0` only when you intentionally want LAN/reverse-proxy access.
|
||||||
|
- Keep ChromaDB, SearXNG, ntfy, Ollama, vLLM, llama.cpp, databases, and raw model/provider APIs internal-only. Expose only the authenticated Odysseus web/API entrypoint through your trusted proxy or private access layer.
|
||||||
|
- Before publishing a fork, run `git status --short` and confirm no private files from `.env`, `data/`, `logs/`, uploads, backups, or local databases are staged.
|
||||||
|
|
||||||
|
### Private or proxied deployments
|
||||||
|
Odysseus serves plain HTTP on its app port. Docker Compose binds Odysseus and the bundled services to `127.0.0.1` by default, so a typical production/private setup is:
|
||||||
|
|
||||||
|
1. Keep Odysseus on localhost, for example `127.0.0.1:7000`.
|
||||||
|
2. Terminate HTTPS at a trusted reverse proxy or private access gateway.
|
||||||
|
3. Put the authenticated Odysseus web/API entrypoint behind that layer.
|
||||||
|
4. Keep raw service and model ports internal-only.
|
||||||
|
|
||||||
|
Cloudflare Access, Tailscale, Caddy, nginx, and Traefik can all fit this pattern; none are required by Odysseus. If your access layer reaches Odysseus on the same host, proxy to `http://127.0.0.1:7000` and keep `AUTH_ENABLED=true`, `LOCALHOST_BYPASS=false`, and `SECURE_COOKIES=true`.
|
||||||
|
|
||||||
|
Common internal-only ports from the default docs/compose setup:
|
||||||
|
|
||||||
|
| Port | Service |
|
||||||
|
|---|---|
|
||||||
|
| `7000` | Odysseus raw app port |
|
||||||
|
| `8080` | SearXNG |
|
||||||
|
| `8091` | ntfy |
|
||||||
|
| `8100` | ChromaDB host port for manual/compose access |
|
||||||
|
| `11434` | Ollama |
|
||||||
|
| `8000-8020` | Common local model/provider APIs |
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
Help is welcome. The best entry points are fresh-install testing, provider setup
|
||||||
|
bugs, mobile/editor polish, docs, and small focused refactors. See
|
||||||
|
[ROADMAP.md](ROADMAP.md) for the current help-wanted list.
|
||||||
|
|
||||||
Help is welcome. The best entry points are fresh-install testing, provider setup bugs, mobile/editor polish, documentation, and small focused refactors. Read the [contributing guide](CONTRIBUTING.md), review the [public roadmap](ROADMAP.md), and browse the open [GitHub issues](https://github.com/odysseus-dev/odysseus/issues).
|
## Configuration
|
||||||
|
Most setup is done inside the app with `/setup` or **Settings**. Use `.env`
|
||||||
|
for deployment-level defaults and secrets you want present before first boot.
|
||||||
|
Key settings:
|
||||||
|
|
||||||
## Security
|
| Variable | Default | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `LLM_HOST` | `localhost` | Your LLM server (e.g. `llm-host.local:8000`) |
|
||||||
|
| `LLM_HOSTS` | -- | Comma-separated list for model discovery |
|
||||||
|
| `OPENAI_API_KEY` | -- | Optional OpenAI key. Prefer adding providers in the app unless pre-seeding. |
|
||||||
|
| `SEARXNG_INSTANCE` | `http://localhost:8080` | SearXNG URL. Docker overrides this to `http://searxng:8080`. |
|
||||||
|
| `SEARXNG_SECRET` | generated on first Docker boot | Optional SearXNG cookie/CSRF secret. Leave blank unless you need to pin it. |
|
||||||
|
| `APP_BIND` | `127.0.0.1` | Docker Compose host bind address for the web UI. Use `0.0.0.0` only for intentional LAN/reverse-proxy access. |
|
||||||
|
| `APP_PORT` | `7000` | Docker Compose host port for the web UI. |
|
||||||
|
| `AUTH_ENABLED` | `true` | Enable/disable login |
|
||||||
|
| `LOCALHOST_BYPASS` | `false` | Development-only auth bypass for loopback requests. Keep false for shared/network deployments. |
|
||||||
|
| `SECURE_COOKIES` | `false` | Set true when serving Odysseus through HTTPS at a trusted proxy or private access gateway. |
|
||||||
|
| `DATABASE_URL` | `sqlite:///./data/app.db` | Database connection string |
|
||||||
|
| `CHROMADB_HOST` | `localhost` | ChromaDB host for vector memory. Docker overrides this to `chromadb`. |
|
||||||
|
| `CHROMADB_PORT` | `8100` | ChromaDB port for manual host runs. Docker overrides this to `8000`. |
|
||||||
|
| `EMBEDDING_URL` | -- | OpenAI-compatible embeddings endpoint |
|
||||||
|
|
||||||
Odysseus is a self-hosted workspace with powerful local tools. Keep auth enabled, keep private data out of Git, and do not expose raw model or service ports publicly. Read the [security policy](SECURITY.md) and the [deployment security guidance](docs/setup.md#security-notes).
|
### Built-in MCP servers (optional setup)
|
||||||
|
|
||||||
|
Odysseus auto-registers a few built-in MCP servers at startup. The npx-based ones (currently the browser server, `@playwright/mcp`) only start when their npm package is already in the local npx cache. If a package isn't cached, that server is skipped with a startup log message explaining what to do, so a fresh install does not block on a multi-minute npm download or hang if Playwright system deps are missing.
|
||||||
|
|
||||||
|
To enable the browser MCP (page navigation, screenshots, vision), run once:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx -y @playwright/mcp@latest --version
|
||||||
|
```
|
||||||
|
|
||||||
|
That installs `@playwright/mcp` plus Playwright (~300MB total). Restart Odysseus and the server will register at startup.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
```
|
||||||
|
app.py # FastAPI entry point
|
||||||
|
core/ auth, database, middleware, constants
|
||||||
|
src/ llm_core, agent_loop, agent_tools, chat_processor, search/
|
||||||
|
routes/ chat, session, document, memory, model … endpoints
|
||||||
|
services/ docs, memory, search, hwfit (Cookbook) …
|
||||||
|
static/ index.html + app.js + style.css + js/ (modular front-end)
|
||||||
|
docs/ landing page (index.html) + preview clips
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data
|
||||||
|
All user data lives in `data/` (gitignored): `app.db` (sessions, messages, documents),
|
||||||
|
`memory.json`, `presets.json`, `uploads/`, `personal_docs/`, `chroma/`, `settings.json`.
|
||||||
|
|
||||||
## Star History
|
## Star History
|
||||||
|
|
||||||
<a href="https://www.star-history.com/?repos=odysseus-dev%2Fodysseus&type=date&legend=top-left">
|
<a href="https://www.star-history.com/?repos=pewdiepie-archdaemon%2Fodysseus&type=date&legend=top-left">
|
||||||
<picture>
|
<picture>
|
||||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=odysseus-dev/odysseus&type=date&theme=dark&legend=top-left" />
|
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=pewdiepie-archdaemon/odysseus&type=date&theme=dark&legend=top-left" />
|
||||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=odysseus-dev/odysseus&type=date&legend=top-left" />
|
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=pewdiepie-archdaemon/odysseus&type=date&legend=top-left" />
|
||||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=odysseus-dev/odysseus&type=date&legend=top-left" />
|
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=pewdiepie-archdaemon/odysseus&type=date&legend=top-left" />
|
||||||
</picture>
|
</picture>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
MIT -- see [LICENSE](LICENSE) and [ACKNOWLEDGMENTS.md](ACKNOWLEDGMENTS.md).
|
||||||
|
|
||||||
Licensed under AGPL-3.0-or-later. See the [license](LICENSE) and [acknowledgments](ACKNOWLEDGMENTS.md).
|
```
|
||||||
|
|
|
||||||
|
|||
|
||||||
|
|||||
|
||||||
|
| | | |||||||
|
||||||
|
)_) )_) )_) ~|~
|
||||||
|
)___))___))___)\ |
|
||||||
|
)____)____)_____)\\|
|
||||||
|
_____|____|____|_____\\\__
|
||||||
|
\ /
|
||||||
|
~^~^~~^~^~~^~^~~^~^~~^~^~~^~^~~^~^~~^~^~
|
||||||
|
~^~ all aboard! ~^~
|
||||||
|
~^~^~~^~^~~^~^~~^~^~~^~^~~^~^~~^~^~~^~^~
|
||||||
|
```
|
||||||
|
|||||||
@@ -1,55 +1,80 @@
|
|||||||
# Roadmap
|
# Roadmap / Help Wanted
|
||||||
|
|
||||||
This document provides a high-level view of the areas Odysseus is currently improving.
|
Odysseus is on a voyage, but not home yet. It works great for me (lol), but this ship is moving fast and feedback/help would be appreciated! (I don't know what I'm doing, help).
|
||||||
|
|
||||||
It is directional rather than exhaustive. Priorities may change as the project evolves, defects are discovered, and maintainers learn more from implementation work and user feedback.
|
If you see weird CSS, strange layout behavior, or a suspiciously murky corner of
|
||||||
|
the codebase, you are probably right to stay away.
|
||||||
|
|
||||||
For current implementation work, see the open [GitHub issues](https://github.com/odysseus-dev/odysseus/issues). Accepted behaviour should be documented in the repository alongside the code.
|
## High Priority
|
||||||
|
|
||||||
## Current priorities
|
- SQUASH BUGS
|
||||||
|
- Fresh install smoke tests on Linux, macOS, and Windows. Docker, native Python,
|
||||||
|
and WSL all need coverage.
|
||||||
|
|
||||||
### Reliability and setup
|
- Integration audit: do integrations even work? Confirm what works, what needs setup docs, and what should be removed or hidden.
|
||||||
|
- Self-host troubleshooting cookbook. Document the weird 30-second fixes that otherwise become 30-minute searches: Dovecot cleartext auth for local stacks, ntfy Android Instant Delivery for non-ntfy.sh servers, clipboard limits on plain-HTTP Tailscale URLs, Radicale collection URLs, and similar traps.
|
||||||
|
- Cookbook reliability on other computers. This is probably the area most likely to need work across different machines, GPUs, drivers, shells, and Python environments.
|
||||||
|
- Cookbook SGLang support across platforms. Make sure SGLang setup/serve works
|
||||||
|
predictably on Linux, Windows/WSL, macOS where possible, Docker, and common
|
||||||
|
NVIDIA/AMD hardware paths.
|
||||||
|
- Deep Research model presets by hardware. Recommend approved model/parameter
|
||||||
|
profiles for small, medium, and large local setups so people with different
|
||||||
|
hardware can use Deep Research without guessing. Surface this either in Deep
|
||||||
|
Research settings or as a Cookbook scan/dropdown suggestion.
|
||||||
|
- Cookbook model scan/download ranking. Prioritize newer architectures and
|
||||||
|
better hardware-fit models instead of scoring everything almost the same.
|
||||||
|
Ranking should account for architecture age, quant format, VRAM/RAM fit,
|
||||||
|
backend support, vision/mmproj requirements, and likely serve reliability.
|
||||||
|
- Cookbook error feedback and logging. Failed downloads, dependency installs,
|
||||||
|
preflights, and serve jobs should show the actual command/output/error in the
|
||||||
|
UI, with copyable logs and clear next steps instead of just "crashed".
|
||||||
|
- Agent prompt/context bloat. Agent mode is too heavy for smaller local models:
|
||||||
|
tool schemas, skills, memory, documents, and instructions can eat the context
|
||||||
|
before the user request really starts. We need slimmer prompts, better tool
|
||||||
|
selection, smaller default tool sets, and clearer guidance for models with
|
||||||
|
4k/8k/16k context windows.
|
||||||
|
- Skill/tool prompt-injection audit. User-editable skills, notes, documents,
|
||||||
|
fetched pages, and memories should be treated as untrusted data. Keep testing
|
||||||
|
whether models follow malicious instructions from those surfaces.
|
||||||
|
- Better degraded-state reporting for ChromaDB, SearXNG, email, ntfy, and provider probes.
|
||||||
|
- Email performance audit. Fetching, searching, opening, deleting, and sending
|
||||||
|
email can feel slow, especially over IMAP/SMTP providers with high latency.
|
||||||
|
Need someone who knows mail performance to profile the current flow, identify
|
||||||
|
whether the bottleneck is IMAP folder select/fetch, cache invalidation,
|
||||||
|
attachment/body loading, SMTP handshakes, or frontend refresh behavior, then
|
||||||
|
propose safer caching/prefetch/batching without breaking multi-account state.
|
||||||
|
- Provider setup/probing audit for Anthropic, Gemini, Groq, xAI, OpenRouter, OpenAI, and DeepSeek.
|
||||||
|
|
||||||
- Improve fresh-install and smoke-test coverage across supported environments.
|
## Refactor Targets
|
||||||
- Make provider setup, probing, and failure states more predictable.
|
- CSS cleanup. `static/style.css` basically Calypso's island atm.
|
||||||
- Improve Cookbook reliability across hardware, operating systems, drivers, shells, and serving backends.
|
- Tour core helper. The onboarding tours have too much copy-pasted scaffolding; promote a shared `tour-core.js` helper before adding more tours.
|
||||||
- Improve degraded-state reporting and recovery guidance when optional services are unavailable.
|
- Modal/window positioning cleanup. Some window controls have improved, but the
|
||||||
|
underlying popup/dropdown/fixed-position behavior is still too fragile.
|
||||||
|
- Mobile media override discoverability. A lot of "CSS did not move" bugs are mobile `@media` overrides of the same selector; comments or linting around desktop/mobile paired rules would help.
|
||||||
|
- Dead code pass for old routes, stale feature flags, and unused UI states.
|
||||||
|
|
||||||
### Local model workflows
|
## Frontend
|
||||||
|
|
||||||
- Improve hardware-aware model recommendations and compatibility guidance.
|
- Expand the Editor for quicker, more robust everyday use. Better file/document
|
||||||
- Evaluate serving optimizations, including speculative decoding, through reproducible benchmarks.
|
handling, smoother window behavior, clearer save/export flows, stronger image
|
||||||
- Improve installation, preflight checks, logging, and error reporting for local model serving.
|
editing affordances, and fewer brittle edge cases.
|
||||||
- Reduce prompt and context overhead for smaller local models.
|
- Better AI integration for Notes and Todos. Notes should be easier for the
|
||||||
|
agent to read, update, summarize, and turn into actions. Todos should be
|
||||||
|
assignable to an agent from the UI, possibly through a button, task action,
|
||||||
|
or dedicated skill/tool flow.
|
||||||
|
- Mobile gallery/editor polish. Easier to launch/download inpaint model or any missing pieces.
|
||||||
|
- Accessibility pass: keyboard navigation, focus states, contrast, reduced motion.
|
||||||
|
- Improve empty states and error messages on fresh installs.
|
||||||
|
- Tighten first-run setup, hints, and tours so they do not repeat or fight each other.
|
||||||
|
- Vendor CDN assets eventually for a more fully self-hosted/offline mode.
|
||||||
|
|
||||||
### Safety and resilience
|
## Backend
|
||||||
|
|
||||||
- Continue hardening tool execution, filesystem access, credentials, networking, and destructive operations.
|
- More tests around endpoint probing and provider setup.
|
||||||
- Treat content from documents, notes, memories, skills, and fetched pages as potentially untrusted.
|
- Better task scheduler defaults and visibility.
|
||||||
- Improve security-focused regression coverage and operational guidance.
|
- Backup/restore guide and helper flow for `data/`.
|
||||||
- Review integrations that expand access to sensitive data or privileged operations.
|
- Security hardening around admin-only tools and clear docs for their risk.
|
||||||
|
|
||||||
### Product usability
|
## Not The Focus Right Now
|
||||||
|
|
||||||
- Improve first-run setup, onboarding, hints, and tours.
|
I prob shouldnt add more themes.
|
||||||
- Improve accessibility, keyboard navigation, focus behaviour, contrast, and reduced-motion support.
|
|
||||||
- Improve empty states, error messages, and recovery paths.
|
|
||||||
- Strengthen Notes, Todos, Editor, mobile, and everyday workspace flows.
|
|
||||||
|
|
||||||
### Architecture and maintainability
|
|
||||||
|
|
||||||
- Reduce duplication and technical debt through focused, reviewable refactors.
|
|
||||||
- Improve subsystem documentation as behaviour and architecture become stable.
|
|
||||||
- Remove stale code, obsolete feature flags, and unsupported integrations.
|
|
||||||
- Keep implementation decisions grounded in current code and verified behaviour.
|
|
||||||
|
|
||||||
## Tracking work
|
|
||||||
|
|
||||||
Concrete implementation tasks, defects, proposals, and technical investigations are tracked in:
|
|
||||||
|
|
||||||
- [GitHub Issues](https://github.com/odysseus-dev/odysseus/issues)
|
|
||||||
- [Contributing Guide](CONTRIBUTING.md)
|
|
||||||
|
|
||||||
Maintainers may use additional private coordination tools for ownership, planning, and unresolved decisions.
|
|
||||||
|
|
||||||
This roadmap is not a complete backlog or a guarantee that a particular item will be delivered.
|
|
||||||
|
|||||||
@@ -37,6 +37,4 @@ Only `.env.example`, docs, source, tests, and static assets should be committed.
|
|||||||
|
|
||||||
## Reporting
|
## Reporting
|
||||||
|
|
||||||
Report security vulnerabilities privately through [GitHub Security Advisories](https://github.com/odysseus-dev/odysseus/security/advisories/new).
|
Please report vulnerabilities privately via GitHub security advisories if available, or by opening a minimal issue that does not disclose exploit details.
|
||||||
|
|
||||||
Do not open a public issue or discussion, and do not disclose exploit details publicly.
|
|
||||||
|
|||||||
@@ -68,14 +68,14 @@ External content that reaches the LLM is treated as untrusted via `src/prompt_se
|
|||||||
- `X-Content-Type-Options: nosniff` and `Referrer-Policy: no-referrer` everywhere.
|
- `X-Content-Type-Options: nosniff` and `Referrer-Policy: no-referrer` everywhere.
|
||||||
- **CSP:** nonce-based `script-src 'self' 'nonce-{nonce}' https://cdn.jsdelivr.net`. `style-src 'unsafe-inline'` is intentionally kept — `static/index.html` ships inline `<style>` blocks and JS modules set `style=""` attributes at runtime. Inline styles do not execute script so the risk is visual-only. Removing this requires templating the HTML files and auditing all JS-set style attributes.
|
- **CSP:** nonce-based `script-src 'self' 'nonce-{nonce}' https://cdn.jsdelivr.net`. `style-src 'unsafe-inline'` is intentionally kept — `static/index.html` ships inline `<style>` blocks and JS modules set `style=""` attributes at runtime. Inline styles do not execute script so the risk is visual-only. Removing this requires templating the HTML files and auditing all JS-set style attributes.
|
||||||
|
|
||||||
## Token-Supplied Model Endpoints
|
|
||||||
|
|
||||||
Direct `/api/v1/chat` requests with a token-supplied `base_url` must use a public HTTP(S) endpoint. This restriction applies only to untrusted direct values; administrator-configured endpoints may intentionally use local or LAN URLs for private model providers.
|
|
||||||
|
|
||||||
## Known Gaps
|
## Known Gaps
|
||||||
|
|
||||||
These are open, acknowledged, and contributor help is welcome:
|
These are open, acknowledged, and contributor help is welcome:
|
||||||
|
|
||||||
1. **No shell/filesystem sandbox.** The agent `bash` and `read_file`/`write_file` tools run as the app process user with no network egress filtering or filesystem confinement. A successful prompt-injection reaching a shell-enabled admin session can make outbound requests to internal services. See #1058 for the sandbox proposal.
|
1. **No shell/filesystem sandbox.** The agent `bash` and `read_file`/`write_file` tools run as the app process user with no network egress filtering or filesystem confinement. A successful prompt-injection reaching a shell-enabled admin session can make outbound requests to internal services. See #1058 for the sandbox proposal.
|
||||||
|
|
||||||
2. **Token scopes are coarse.** There is no way to grant a session a subset of the owning user's privileges. Companion/mobile tokens carry either `chat` or `admin` scope with no per-capability granularity.
|
2. **SSRF via `/api/v1/chat` `base_url` parameter.** A chat-scoped API token can supply an arbitrary `base_url`; the server forwards the LLM request to that host without validating the scheme or address. PR #1039 fixes this.
|
||||||
|
|
||||||
|
3. **`src/search/` partial consolidation.** `src.search.core` and `src.search.providers` correctly alias `services.search` via `sys.modules` replacement. `analytics`, `cache`, `content`, `query`, and `ranking` are still independent copies that can drift. The SSRF regression tests in `tests/test_webhook_ssrf_resilience.py` test `src.webhook_manager` directly (separate from search), so the safety net there is intact. See #1058.
|
||||||
|
|
||||||
|
4. **Token scopes are coarse.** There is no way to grant a session a subset of the owning user's privileges. Companion/mobile tokens carry either `chat` or `admin` scope with no per-capability granularity.
|
||||||
|
|||||||
@@ -1,18 +1,6 @@
|
|||||||
# app.py — slim orchestrator
|
# app.py — slim orchestrator
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import os
|
import os
|
||||||
import sys
|
|
||||||
import asyncio
|
|
||||||
import time
|
|
||||||
|
|
||||||
# On Windows, asyncio.create_subprocess_exec/shell require the ProactorEventLoop.
|
|
||||||
# When started via `python -m uvicorn` from a terminal, uvicorn sets this
|
|
||||||
# automatically. But the VS Code debugger (and other non-uvicorn entrypoints)
|
|
||||||
# use the default SelectorEventLoop, which raises NotImplementedError on any
|
|
||||||
# subprocess call. Force ProactorEventLoop here so the right loop is always
|
|
||||||
# used, regardless of how the process is launched.
|
|
||||||
if sys.platform == "win32":
|
|
||||||
asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
|
|
||||||
|
|
||||||
|
|
||||||
def register_static_mime_types() -> None:
|
def register_static_mime_types() -> None:
|
||||||
@@ -46,29 +34,29 @@ from dotenv import load_dotenv
|
|||||||
# is silently ignored and the user is unexpectedly forced to log in (issue #142).
|
# is silently ignored and the user is unexpectedly forced to log in (issue #142).
|
||||||
# utf-8-sig reads plain UTF-8 (no BOM) identically, so this is safe everywhere.
|
# utf-8-sig reads plain UTF-8 (no BOM) identically, so this is safe everywhere.
|
||||||
load_dotenv(encoding="utf-8-sig")
|
load_dotenv(encoding="utf-8-sig")
|
||||||
|
import uuid
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import secrets
|
import secrets
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime
|
||||||
from typing import Dict
|
from typing import Dict
|
||||||
|
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from fastapi import FastAPI, Request, HTTPException
|
from fastapi import FastAPI, Request, HTTPException
|
||||||
from fastapi.responses import JSONResponse, FileResponse
|
from fastapi.responses import JSONResponse, FileResponse, HTMLResponse
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from starlette.middleware.base import BaseHTTPMiddleware
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
from starlette.middleware.gzip import GZipMiddleware
|
|
||||||
|
|
||||||
# Core imports
|
# Core imports
|
||||||
from core.constants import (
|
from core.constants import (
|
||||||
BASE_DIR, STATIC_DIR, SESSIONS_FILE,
|
BASE_DIR, STATIC_DIR, SESSIONS_FILE,
|
||||||
REQUEST_TIMEOUT, OPENAI_API_KEY, AUTH_FILE,
|
REQUEST_TIMEOUT, OPENAI_API_KEY,
|
||||||
)
|
)
|
||||||
from core.database import SessionLocal, ApiToken
|
from core.database import SessionLocal, ApiToken
|
||||||
from core.middleware import SecurityHeadersMiddleware, is_cors_preflight
|
from core.middleware import SecurityHeadersMiddleware
|
||||||
from core.auth import AuthManager, normalize_known_username
|
from core.auth import AuthManager
|
||||||
from core.exceptions import (
|
from core.exceptions import (
|
||||||
SessionNotFoundError, InvalidFileUploadError,
|
SessionNotFoundError, InvalidFileUploadError,
|
||||||
LLMServiceError, WebSearchError,
|
LLMServiceError, WebSearchError,
|
||||||
@@ -76,42 +64,14 @@ from core.exceptions import (
|
|||||||
|
|
||||||
import bcrypt as _bcrypt
|
import bcrypt as _bcrypt
|
||||||
|
|
||||||
from src.app_helpers import abs_join, serve_html_with_nonce
|
from src.app_helpers import abs_join
|
||||||
from src.generated_images import GENERATED_IMAGE_HEADERS, resolve_generated_image_path
|
|
||||||
from starlette.responses import RedirectResponse
|
from starlette.responses import RedirectResponse
|
||||||
|
|
||||||
# ========= LOGGING =========
|
# ========= LOGGING =========
|
||||||
import logging.handlers
|
logging.basicConfig(
|
||||||
from core.constants import DATA_DIR
|
level=logging.INFO,
|
||||||
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||||
_root_logger = logging.getLogger()
|
)
|
||||||
_root_logger.setLevel(logging.INFO)
|
|
||||||
_formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
|
||||||
|
|
||||||
# Clear existing handlers to avoid duplicates
|
|
||||||
for _h in list(_root_logger.handlers):
|
|
||||||
_root_logger.removeHandler(_h)
|
|
||||||
|
|
||||||
_console_h = logging.StreamHandler()
|
|
||||||
_console_h.setFormatter(_formatter)
|
|
||||||
_root_logger.addHandler(_console_h)
|
|
||||||
|
|
||||||
try:
|
|
||||||
_log_dir = os.path.join(DATA_DIR, "logs")
|
|
||||||
os.makedirs(_log_dir, exist_ok=True)
|
|
||||||
_log_file = os.path.join(_log_dir, "app.log")
|
|
||||||
|
|
||||||
# RotatingFileHandler is not multi-process safe (e.g. if uvicorn is run with --workers N).
|
|
||||||
# Odysseus is single-process by convention, so this is acceptable, but be aware that
|
|
||||||
# concurrent log rotation issues can arise if multiple workers are configured.
|
|
||||||
_file_h = logging.handlers.RotatingFileHandler(
|
|
||||||
_log_file, maxBytes=5 * 1024 * 1024, backupCount=3, encoding="utf-8"
|
|
||||||
)
|
|
||||||
_file_h.setFormatter(_formatter)
|
|
||||||
_root_logger.addHandler(_file_h)
|
|
||||||
except Exception as e:
|
|
||||||
_root_logger.warning(f"Failed to initialize file logging handler (falling back to console-only): {e}")
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# ========= APP =========
|
# ========= APP =========
|
||||||
@@ -125,13 +85,12 @@ app = FastAPI(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# ========= CORS =========
|
# ========= CORS =========
|
||||||
CORS_ALLOW_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"]
|
|
||||||
allowed_origins = os.getenv("ALLOWED_ORIGINS", "http://localhost,http://127.0.0.1").split(",")
|
allowed_origins = os.getenv("ALLOWED_ORIGINS", "http://localhost,http://127.0.0.1").split(",")
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=allowed_origins,
|
allow_origins=allowed_origins,
|
||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
allow_methods=CORS_ALLOW_METHODS,
|
allow_methods=["GET", "POST", "PUT", "DELETE"],
|
||||||
allow_headers=[
|
allow_headers=[
|
||||||
"Accept",
|
"Accept",
|
||||||
"Authorization",
|
"Authorization",
|
||||||
@@ -145,16 +104,6 @@ app.add_middleware(
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
# ========= RESPONSE COMPRESSION (gzip) =========
|
|
||||||
# The frontend's text assets (style.css, index.html, the JS bundles) shipped
|
|
||||||
# uncompressed on every cold load. gzip cuts CSS/JS/HTML by ~75-85% on the wire
|
|
||||||
# with no behavioural change. Starlette's GZipMiddleware excludes
|
|
||||||
# `text/event-stream` by default, so the SSE streams (chat, shell, research,
|
|
||||||
# model-probe — all served with media_type="text/event-stream") are never
|
|
||||||
# compressed or buffered; only complete bodies over minimum_size are. The
|
|
||||||
# security-header middleware composes cleanly on top.
|
|
||||||
app.add_middleware(GZipMiddleware, minimum_size=1024, compresslevel=6)
|
|
||||||
|
|
||||||
# ========= SECURITY HEADERS MIDDLEWARE =========
|
# ========= SECURITY HEADERS MIDDLEWARE =========
|
||||||
app.add_middleware(SecurityHeadersMiddleware)
|
app.add_middleware(SecurityHeadersMiddleware)
|
||||||
|
|
||||||
@@ -180,7 +129,6 @@ _TIMEOUT_EXEMPT_PREFIXES = (
|
|||||||
"/api/cookbook/setup", # remote pacman/apt installs
|
"/api/cookbook/setup", # remote pacman/apt installs
|
||||||
"/api/upload", # large files
|
"/api/upload", # large files
|
||||||
"/api/image", # diffusion proxies (inpaint/harmonize/upscale/etc.) — own 120s httpx timeout
|
"/api/image", # diffusion proxies (inpaint/harmonize/upscale/etc.) — own 120s httpx timeout
|
||||||
"/api/memory/audit", # retains own 120s LLM inactivity timeout
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -198,50 +146,7 @@ class _RequestTimeoutMiddleware(_BaseHTTPMiddleware):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class _InteractiveActivityMiddleware(_BaseHTTPMiddleware):
|
|
||||||
async def dispatch(self, request, call_next):
|
|
||||||
from src.interactive_gate import should_track_interactive_request, track_interactive_request
|
|
||||||
|
|
||||||
path = request.url.path or ""
|
|
||||||
if not should_track_interactive_request(path, request.method):
|
|
||||||
return await call_next(request)
|
|
||||||
async def _stop_background():
|
|
||||||
try:
|
|
||||||
await task_scheduler.stop_background_tasks_for_foreground(reason=f"foreground request {request.method} {path}")
|
|
||||||
except Exception:
|
|
||||||
logging.getLogger("app.foreground_gate").debug("foreground task stop failed", exc_info=True)
|
|
||||||
asyncio.create_task(_stop_background())
|
|
||||||
async with track_interactive_request(path, request.method):
|
|
||||||
return await call_next(request)
|
|
||||||
|
|
||||||
|
|
||||||
class _SlowRequestLogMiddleware(_BaseHTTPMiddleware):
|
|
||||||
async def dispatch(self, request, call_next):
|
|
||||||
start = time.perf_counter()
|
|
||||||
status = 500
|
|
||||||
try:
|
|
||||||
response = await call_next(request)
|
|
||||||
status = getattr(response, "status_code", 0) or 0
|
|
||||||
return response
|
|
||||||
finally:
|
|
||||||
elapsed = time.perf_counter() - start
|
|
||||||
try:
|
|
||||||
threshold = float(os.getenv("ODYSSEUS_SLOW_REQUEST_LOG_SECONDS", "0.75") or "0.75")
|
|
||||||
except Exception:
|
|
||||||
threshold = 0.75
|
|
||||||
if elapsed >= threshold:
|
|
||||||
logging.getLogger("app.slow_request").warning(
|
|
||||||
"slow_request method=%s path=%s status=%s elapsed=%.3fs",
|
|
||||||
request.method,
|
|
||||||
request.url.path,
|
|
||||||
status,
|
|
||||||
elapsed,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
app.add_middleware(_RequestTimeoutMiddleware)
|
app.add_middleware(_RequestTimeoutMiddleware)
|
||||||
app.add_middleware(_InteractiveActivityMiddleware)
|
|
||||||
app.add_middleware(_SlowRequestLogMiddleware)
|
|
||||||
|
|
||||||
# ========= AUTH =========
|
# ========= AUTH =========
|
||||||
from routes.auth_routes import setup_auth_routes, SESSION_COOKIE
|
from routes.auth_routes import setup_auth_routes, SESSION_COOKIE
|
||||||
@@ -312,16 +217,8 @@ if AUTH_ENABLED:
|
|||||||
try:
|
try:
|
||||||
rows = db.query(ApiToken).filter(ApiToken.is_active == True).all()
|
rows = db.query(ApiToken).filter(ApiToken.is_active == True).all()
|
||||||
for r in rows:
|
for r in rows:
|
||||||
owner_key = normalize_known_username(auth_manager.users, getattr(r, "owner", None))
|
|
||||||
if not owner_key:
|
|
||||||
logger.warning(
|
|
||||||
"Ignoring active API token '%s' for unknown auth user '%s'",
|
|
||||||
getattr(r, "id", ""),
|
|
||||||
getattr(r, "owner", None),
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
scopes = [s.strip() for s in (getattr(r, "scopes", "") or "chat").split(",") if s.strip()]
|
scopes = [s.strip() for s in (getattr(r, "scopes", "") or "chat").split(",") if s.strip()]
|
||||||
new_map[r.token_prefix].append((r.id, r.token_hash, owner_key, scopes))
|
new_map[r.token_prefix].append((r.id, r.token_hash, getattr(r, "owner", None), scopes))
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
_token_cache.clear()
|
_token_cache.clear()
|
||||||
@@ -356,15 +253,6 @@ if AUTH_ENABLED:
|
|||||||
class AuthMiddleware(BaseHTTPMiddleware):
|
class AuthMiddleware(BaseHTTPMiddleware):
|
||||||
async def dispatch(self, request: Request, call_next):
|
async def dispatch(self, request: Request, call_next):
|
||||||
path = request.url.path
|
path = request.url.path
|
||||||
# A genuine CORS preflight (OPTIONS + Access-Control-Request-Method)
|
|
||||||
# carries no credentials by design and must reach CORSMiddleware to be
|
|
||||||
# answered. AuthMiddleware is the outermost middleware, so gating the
|
|
||||||
# preflight on auth 401s it before CORS can respond -- which blocks
|
|
||||||
# every cross-origin browser/WebView client before the real request
|
|
||||||
# is sent. Let real preflights through (only OPTIONS w/ the ACRM
|
|
||||||
# header; never a credentialed request).
|
|
||||||
if is_cors_preflight(request.method, request.headers):
|
|
||||||
return await call_next(request)
|
|
||||||
if _is_auth_exempt(path):
|
if _is_auth_exempt(path):
|
||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
# In-process internal-tool token bypass. Used by the agent
|
# In-process internal-tool token bypass. Used by the agent
|
||||||
@@ -372,7 +260,7 @@ if AUTH_ENABLED:
|
|||||||
# (no admin cookie available in that context). Restricted to
|
# (no admin cookie available in that context). Restricted to
|
||||||
# loopback clients + matching token to keep it locked down.
|
# loopback clients + matching token to keep it locked down.
|
||||||
try:
|
try:
|
||||||
from core.middleware import INTERNAL_TOOL_HEADER, INTERNAL_TOOL_TOKEN as _ITT, INTERNAL_TOOL_USER
|
from core.middleware import INTERNAL_TOOL_HEADER, INTERNAL_TOOL_TOKEN as _ITT
|
||||||
_hdr = request.headers.get(INTERNAL_TOOL_HEADER)
|
_hdr = request.headers.get(INTERNAL_TOOL_HEADER)
|
||||||
if _hdr and secrets.compare_digest(_hdr, _ITT) and _is_trusted_loopback(request):
|
if _hdr and secrets.compare_digest(_hdr, _ITT) and _is_trusted_loopback(request):
|
||||||
# Impersonation: when the agent's loopback call sets
|
# Impersonation: when the agent's loopback call sets
|
||||||
@@ -384,11 +272,11 @@ if AUTH_ENABLED:
|
|||||||
if _impersonate and _impersonate in getattr(_auth_mgr, "users", {}):
|
if _impersonate and _impersonate in getattr(_auth_mgr, "users", {}):
|
||||||
request.state.current_user = _impersonate
|
request.state.current_user = _impersonate
|
||||||
else:
|
else:
|
||||||
request.state.current_user = INTERNAL_TOOL_USER
|
request.state.current_user = "internal-tool"
|
||||||
request.state.api_token = False
|
request.state.api_token = False
|
||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
except Exception as _e:
|
except Exception:
|
||||||
logger.warning("Internal tool auth header check failed", exc_info=_e)
|
pass
|
||||||
# Allow DIRECT localhost requests (internal service calls from
|
# Allow DIRECT localhost requests (internal service calls from
|
||||||
# heartbeats etc.). Tunnel/proxy-forwarded requests are excluded by
|
# heartbeats etc.). Tunnel/proxy-forwarded requests are excluded by
|
||||||
# _is_trusted_loopback so LOCALHOST_BYPASS can't be abused over a
|
# _is_trusted_loopback so LOCALHOST_BYPASS can't be abused over a
|
||||||
@@ -441,10 +329,11 @@ if AUTH_ENABLED:
|
|||||||
_db.close()
|
_db.close()
|
||||||
try:
|
try:
|
||||||
await _asyncio.to_thread(_do)
|
await _asyncio.to_thread(_do)
|
||||||
except Exception as _e:
|
except Exception:
|
||||||
logger.debug("Failed to update token last_used_at", exc_info=_e)
|
pass
|
||||||
_asyncio.create_task(_touch_last_used(matched_id))
|
_asyncio.create_task(_touch_last_used(matched_id))
|
||||||
# Keep bearer-token callers out of normal cookie/user
|
# Keep bearer-token callers out of normal cookie/user
|
||||||
|
# routes. API-aware routes can read api_token_owner.
|
||||||
request.state.current_user = "api"
|
request.state.current_user = "api"
|
||||||
request.state.api_token = True
|
request.state.api_token = True
|
||||||
request.state.api_token_id = matched_id
|
request.state.api_token_id = matched_id
|
||||||
@@ -493,13 +382,19 @@ class _RevalidatingStatic(StaticFiles):
|
|||||||
return resp
|
return resp
|
||||||
|
|
||||||
|
|
||||||
app.mount("/static", _RevalidatingStatic(directory=STATIC_DIR), name="static")
|
app.mount("/static", _RevalidatingStatic(directory="static"), name="static")
|
||||||
|
|
||||||
# ========= GENERATED IMAGES =========
|
# ========= GENERATED IMAGES =========
|
||||||
@app.get("/api/generated-image/{filename}")
|
@app.get("/api/generated-image/{filename}")
|
||||||
async def serve_generated_image(filename: str, request: Request):
|
async def serve_generated_image(filename: str, request: Request):
|
||||||
"""Serve generated images from the data directory."""
|
"""Serve generated images from the data directory."""
|
||||||
img_path = resolve_generated_image_path(filename)
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
if not re.match(r'^[a-f0-9]{8,64}\.(png|jpg|jpeg|webp|gif|mp4|mov|webm|mkv|m4v)$', filename):
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid filename")
|
||||||
|
img_path = Path("data/generated_images") / filename
|
||||||
|
if not img_path.exists():
|
||||||
|
raise HTTPException(status_code=404, detail="Image not found")
|
||||||
# SECURITY: filename is the only key, so anyone who knows / guesses a
|
# SECURITY: filename is the only key, so anyone who knows / guesses a
|
||||||
# 12-hex content hash could pull another user's image bytes. Require
|
# 12-hex content hash could pull another user's image bytes. Require
|
||||||
# auth and verify ownership via the gallery row (when one exists).
|
# auth and verify ownership via the gallery row (when one exists).
|
||||||
@@ -519,8 +414,8 @@ async def serve_generated_image(filename: str, request: Request):
|
|||||||
_db.close()
|
_db.close()
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as _e:
|
except Exception:
|
||||||
logger.warning("Image ownership verification failed for %r", filename, exc_info=_e)
|
pass
|
||||||
ext = filename.rsplit('.', 1)[-1].lower()
|
ext = filename.rsplit('.', 1)[-1].lower()
|
||||||
mime = {
|
mime = {
|
||||||
"png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg",
|
"png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg",
|
||||||
@@ -535,7 +430,7 @@ async def serve_generated_image(filename: str, request: Request):
|
|||||||
return FileResponse(
|
return FileResponse(
|
||||||
str(img_path),
|
str(img_path),
|
||||||
media_type=mime,
|
media_type=mime,
|
||||||
headers=GENERATED_IMAGE_HEADERS,
|
headers={"Cache-Control": "public, max-age=31536000, immutable"},
|
||||||
)
|
)
|
||||||
|
|
||||||
# ========= YOUTUBE INIT =========
|
# ========= YOUTUBE INIT =========
|
||||||
@@ -574,21 +469,14 @@ components = initialize_managers(BASE_DIR, rag_manager)
|
|||||||
session_manager = components["session_manager"]
|
session_manager = components["session_manager"]
|
||||||
from src.assistant_log import set_session_manager as _set_asst_sm
|
from src.assistant_log import set_session_manager as _set_asst_sm
|
||||||
_set_asst_sm(session_manager)
|
_set_asst_sm(session_manager)
|
||||||
# Set the global session manager singleton (used by core.models.Session.add_message)
|
|
||||||
from core.models import set_session_manager_instance
|
|
||||||
set_session_manager_instance(session_manager)
|
|
||||||
app.state.session_manager = session_manager
|
|
||||||
memory_manager = components["memory_manager"]
|
memory_manager = components["memory_manager"]
|
||||||
memory_vector = components.get("memory_vector")
|
memory_vector = components.get("memory_vector")
|
||||||
upload_handler = components["upload_handler"]
|
upload_handler = components["upload_handler"]
|
||||||
app.state.upload_handler = upload_handler
|
|
||||||
personal_docs_mgr = components["personal_docs_manager"]
|
personal_docs_mgr = components["personal_docs_manager"]
|
||||||
app.state.personal_docs_manager = personal_docs_mgr
|
|
||||||
api_key_manager = components["api_key_manager"]
|
api_key_manager = components["api_key_manager"]
|
||||||
preset_manager = components["preset_manager"]
|
preset_manager = components["preset_manager"]
|
||||||
chat_processor = components["chat_processor"]
|
chat_processor = components["chat_processor"]
|
||||||
research_handler = components["research_handler"]
|
research_handler = components["research_handler"]
|
||||||
app.state.research_handler = research_handler
|
|
||||||
chat_handler = components["chat_handler"]
|
chat_handler = components["chat_handler"]
|
||||||
model_discovery = components["model_discovery"]
|
model_discovery = components["model_discovery"]
|
||||||
skills_manager = components["skills_manager"]
|
skills_manager = components["skills_manager"]
|
||||||
@@ -627,20 +515,6 @@ webhook_manager = WebhookManager(api_key_manager=api_key_manager)
|
|||||||
auth_router = setup_auth_routes(auth_manager)
|
auth_router = setup_auth_routes(auth_manager)
|
||||||
app.include_router(auth_router)
|
app.include_router(auth_router)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/activity/heartbeat")
|
|
||||||
async def activity_heartbeat():
|
|
||||||
from src.interactive_gate import mark_browser_activity
|
|
||||||
await mark_browser_activity()
|
|
||||||
async def _stop_background():
|
|
||||||
try:
|
|
||||||
await task_scheduler.stop_background_tasks_for_foreground(reason="browser heartbeat")
|
|
||||||
except Exception:
|
|
||||||
logging.getLogger("app.foreground_gate").debug("heartbeat task stop failed", exc_info=True)
|
|
||||||
asyncio.create_task(_stop_background())
|
|
||||||
return {"ok": True}
|
|
||||||
|
|
||||||
|
|
||||||
# Uploads
|
# Uploads
|
||||||
from routes.upload_routes import setup_upload_routes
|
from routes.upload_routes import setup_upload_routes
|
||||||
upload_router, upload_cleanup_func = setup_upload_routes(upload_handler)
|
upload_router, upload_cleanup_func = setup_upload_routes(upload_handler)
|
||||||
@@ -655,19 +529,14 @@ app.include_router(setup_emoji_routes())
|
|||||||
# Sessions
|
# Sessions
|
||||||
from routes.session_routes import setup_session_routes
|
from routes.session_routes import setup_session_routes
|
||||||
session_config = {"REQUEST_TIMEOUT": REQUEST_TIMEOUT, "OPENAI_API_KEY": OPENAI_API_KEY, "SESSIONS_FILE": SESSIONS_FILE}
|
session_config = {"REQUEST_TIMEOUT": REQUEST_TIMEOUT, "OPENAI_API_KEY": OPENAI_API_KEY, "SESSIONS_FILE": SESSIONS_FILE}
|
||||||
app.include_router(setup_session_routes(
|
app.include_router(setup_session_routes(session_manager, session_config, webhook_manager=webhook_manager))
|
||||||
session_manager,
|
|
||||||
session_config,
|
|
||||||
webhook_manager=webhook_manager,
|
|
||||||
upload_handler=upload_handler,
|
|
||||||
))
|
|
||||||
|
|
||||||
# Admin Danger Zone wipes (Settings → System → Danger Zone)
|
# Admin Danger Zone wipes (Settings → System → Danger Zone)
|
||||||
from routes.admin_wipe.admin_wipe_routes import setup_admin_wipe_routes
|
from routes.admin_wipe_routes import setup_admin_wipe_routes
|
||||||
app.include_router(setup_admin_wipe_routes(session_manager))
|
app.include_router(setup_admin_wipe_routes(session_manager))
|
||||||
|
|
||||||
# Memory
|
# Memory
|
||||||
from routes.memory.memory_routes import setup_memory_routes
|
from routes.memory_routes import setup_memory_routes
|
||||||
memory_router = setup_memory_routes(memory_manager, session_manager, memory_vector=memory_vector)
|
memory_router = setup_memory_routes(memory_manager, session_manager, memory_vector=memory_vector)
|
||||||
app.include_router(memory_router)
|
app.include_router(memory_router)
|
||||||
from routes.skills_routes import setup_skills_routes
|
from routes.skills_routes import setup_skills_routes
|
||||||
@@ -684,12 +553,12 @@ app.include_router(setup_chat_routes(
|
|||||||
))
|
))
|
||||||
|
|
||||||
# Research (background deep-research tasks)
|
# Research (background deep-research tasks)
|
||||||
from routes.research.research_routes import setup_research_routes
|
from routes.research_routes import setup_research_routes
|
||||||
app.include_router(setup_research_routes(research_handler, session_manager=session_manager))
|
app.include_router(setup_research_routes(research_handler, session_manager=session_manager))
|
||||||
|
|
||||||
# History
|
# History
|
||||||
from routes.history.history_routes import setup_history_routes
|
from routes.history_routes import setup_history_routes
|
||||||
app.include_router(setup_history_routes(session_manager, upload_handler=upload_handler))
|
app.include_router(setup_history_routes(session_manager))
|
||||||
|
|
||||||
# Search
|
# Search
|
||||||
from routes.search_routes import setup_search_routes
|
from routes.search_routes import setup_search_routes
|
||||||
@@ -701,10 +570,10 @@ app.include_router(setup_preset_routes(preset_manager))
|
|||||||
|
|
||||||
# Diagnostics
|
# Diagnostics
|
||||||
from routes.diagnostics_routes import setup_diagnostics_routes
|
from routes.diagnostics_routes import setup_diagnostics_routes
|
||||||
app.include_router(setup_diagnostics_routes(rag_manager, rag_available, research_handler, memory_vector))
|
app.include_router(setup_diagnostics_routes(rag_manager, rag_available, research_handler))
|
||||||
|
|
||||||
# Cleanup
|
# Cleanup
|
||||||
from routes.cleanup.cleanup_routes import setup_cleanup_routes
|
from routes.cleanup_routes import setup_cleanup_routes
|
||||||
app.include_router(setup_cleanup_routes(session_manager))
|
app.include_router(setup_cleanup_routes(session_manager))
|
||||||
|
|
||||||
# Personal docs
|
# Personal docs
|
||||||
@@ -719,14 +588,6 @@ app.include_router(setup_embedding_routes())
|
|||||||
from routes.model_routes import setup_model_routes
|
from routes.model_routes import setup_model_routes
|
||||||
app.include_router(setup_model_routes(model_discovery))
|
app.include_router(setup_model_routes(model_discovery))
|
||||||
|
|
||||||
# GitHub Copilot device-flow login
|
|
||||||
from routes.copilot_routes import setup_copilot_routes
|
|
||||||
app.include_router(setup_copilot_routes())
|
|
||||||
|
|
||||||
# ChatGPT Subscription device-flow login
|
|
||||||
from routes.chatgpt_subscription_routes import setup_chatgpt_subscription_routes
|
|
||||||
app.include_router(setup_chatgpt_subscription_routes())
|
|
||||||
|
|
||||||
# TTS
|
# TTS
|
||||||
from routes.tts_routes import setup_tts_routes
|
from routes.tts_routes import setup_tts_routes
|
||||||
app.include_router(setup_tts_routes(tts_service))
|
app.include_router(setup_tts_routes(tts_service))
|
||||||
@@ -748,7 +609,7 @@ from routes.signature_routes import setup_signature_routes
|
|||||||
app.include_router(setup_signature_routes())
|
app.include_router(setup_signature_routes())
|
||||||
|
|
||||||
# Gallery (image library)
|
# Gallery (image library)
|
||||||
from routes.gallery.gallery_routes import setup_gallery_routes
|
from routes.gallery_routes import setup_gallery_routes
|
||||||
app.include_router(setup_gallery_routes())
|
app.include_router(setup_gallery_routes())
|
||||||
|
|
||||||
# Persisted image-editor drafts (server-backed projects)
|
# Persisted image-editor drafts (server-backed projects)
|
||||||
@@ -768,7 +629,7 @@ app.include_router(setup_assistant_routes(task_scheduler))
|
|||||||
|
|
||||||
# Calendar (CalDAV)
|
# Calendar (CalDAV)
|
||||||
from routes.calendar_routes import setup_calendar_routes
|
from routes.calendar_routes import setup_calendar_routes
|
||||||
calendar_router = setup_calendar_routes(upload_handler=upload_handler)
|
calendar_router = setup_calendar_routes()
|
||||||
app.include_router(calendar_router)
|
app.include_router(calendar_router)
|
||||||
|
|
||||||
# Shell (user-facing command execution)
|
# Shell (user-facing command execution)
|
||||||
@@ -779,15 +640,12 @@ app.include_router(setup_shell_routes())
|
|||||||
from routes.cookbook_routes import setup_cookbook_routes
|
from routes.cookbook_routes import setup_cookbook_routes
|
||||||
app.include_router(setup_cookbook_routes())
|
app.include_router(setup_cookbook_routes())
|
||||||
|
|
||||||
from routes.workspace_routes import setup_workspace_routes
|
|
||||||
app.include_router(setup_workspace_routes())
|
|
||||||
|
|
||||||
# Hardware model fitting (cookbook "What Fits?" tab)
|
# Hardware model fitting (cookbook "What Fits?" tab)
|
||||||
from routes.hwfit_routes import setup_hwfit_routes
|
from routes.hwfit_routes import setup_hwfit_routes
|
||||||
app.include_router(setup_hwfit_routes())
|
app.include_router(setup_hwfit_routes())
|
||||||
|
|
||||||
# Model A/B Comparison
|
# Model A/B Comparison
|
||||||
from routes.compare.compare_routes import setup_compare_routes
|
from routes.compare_routes import setup_compare_routes
|
||||||
app.include_router(setup_compare_routes(session_manager))
|
app.include_router(setup_compare_routes(session_manager))
|
||||||
|
|
||||||
# User Preferences
|
# User Preferences
|
||||||
@@ -830,8 +688,8 @@ app.include_router(setup_api_token_routes())
|
|||||||
logger.info("Webhook & API token routes initialized")
|
logger.info("Webhook & API token routes initialized")
|
||||||
|
|
||||||
# Notes (Google Keep-style notes/todos)
|
# Notes (Google Keep-style notes/todos)
|
||||||
from routes.note.note_routes import setup_note_routes
|
from routes.note_routes import setup_note_routes
|
||||||
app.include_router(setup_note_routes(task_scheduler, upload_handler=upload_handler))
|
app.include_router(setup_note_routes(task_scheduler))
|
||||||
|
|
||||||
# Email
|
# Email
|
||||||
from routes.email_routes import setup_email_routes
|
from routes.email_routes import setup_email_routes
|
||||||
@@ -856,7 +714,7 @@ from routes.vault_routes import setup_vault_routes
|
|||||||
app.include_router(setup_vault_routes())
|
app.include_router(setup_vault_routes())
|
||||||
|
|
||||||
# Contacts (CardDAV)
|
# Contacts (CardDAV)
|
||||||
from routes.contacts.contacts_routes import setup_contacts_routes
|
from routes.contacts_routes import setup_contacts_routes
|
||||||
app.include_router(setup_contacts_routes())
|
app.include_router(setup_contacts_routes())
|
||||||
|
|
||||||
from companion import setup_companion_routes
|
from companion import setup_companion_routes
|
||||||
@@ -864,17 +722,23 @@ app.include_router(setup_companion_routes())
|
|||||||
|
|
||||||
# ========= ROUTES (kept in app.py) =========
|
# ========= ROUTES (kept in app.py) =========
|
||||||
|
|
||||||
|
def _serve_html_with_nonce(request: Request, file_path: str) -> HTMLResponse:
|
||||||
|
"""Read an HTML file and inject the CSP nonce into inline <script> tags."""
|
||||||
|
with open(file_path, "r", encoding="utf-8") as f:
|
||||||
|
html = f.read()
|
||||||
|
nonce = getattr(request.state, "csp_nonce", "")
|
||||||
|
html = html.replace("{{CSP_NONCE}}", nonce)
|
||||||
|
return HTMLResponse(html)
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
async def serve_index(request: Request):
|
async def serve_index(request: Request):
|
||||||
static_path = abs_join(BASE_DIR, "static/index.html")
|
static_path = abs_join(BASE_DIR, "static/index.html")
|
||||||
if os.path.exists(static_path):
|
if os.path.exists(static_path):
|
||||||
return serve_html_with_nonce(request, static_path)
|
return _serve_html_with_nonce(request, static_path)
|
||||||
# No static bundle — fall back to a root-level index.html if one is shipped.
|
root_path = abs_join(BASE_DIR, "index.html")
|
||||||
# If neither exists, serve_html_with_nonce logs it and returns a generic 500:
|
if os.path.exists(root_path):
|
||||||
# a missing index.html is a broken deployment (server fault), not a client
|
return _serve_html_with_nonce(request, root_path)
|
||||||
# "not found". This keeps the app-shell route consistent with the other
|
raise HTTPException(404, "index.html not found")
|
||||||
# bundled-template routes instead of mislabelling the fault as a 404.
|
|
||||||
return serve_html_with_nonce(request, abs_join(BASE_DIR, "index.html"))
|
|
||||||
|
|
||||||
@app.get("/notes")
|
@app.get("/notes")
|
||||||
async def serve_notes(request: Request):
|
async def serve_notes(request: Request):
|
||||||
@@ -915,13 +779,11 @@ async def serve_library(request: Request):
|
|||||||
@app.get("/backgrounds")
|
@app.get("/backgrounds")
|
||||||
async def serve_backgrounds(request: Request):
|
async def serve_backgrounds(request: Request):
|
||||||
"""Sandbox page for prototyping background effects. No auth required."""
|
"""Sandbox page for prototyping background effects. No auth required."""
|
||||||
return serve_html_with_nonce(request, abs_join(BASE_DIR, "static/backgrounds.html"))
|
return _serve_html_with_nonce(request, abs_join(BASE_DIR, "static/backgrounds.html"))
|
||||||
|
|
||||||
@app.get("/login")
|
@app.get("/login")
|
||||||
async def serve_login(request: Request):
|
async def serve_login(request: Request):
|
||||||
if not AUTH_ENABLED:
|
return _serve_html_with_nonce(request, abs_join(BASE_DIR, "static/login.html"))
|
||||||
return RedirectResponse(url="/", status_code=302)
|
|
||||||
return serve_html_with_nonce(request, abs_join(BASE_DIR, "static/login.html"))
|
|
||||||
|
|
||||||
@app.get("/api/version")
|
@app.get("/api/version")
|
||||||
async def get_version():
|
async def get_version():
|
||||||
@@ -930,35 +792,7 @@ async def get_version():
|
|||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
async def health_check() -> Dict[str, str]:
|
async def health_check() -> Dict[str, str]:
|
||||||
return {"status": "healthy", "timestamp": datetime.now(timezone.utc).isoformat()}
|
return {"status": "healthy", "timestamp": datetime.utcnow().isoformat()}
|
||||||
|
|
||||||
@app.post("/api/client-perf")
|
|
||||||
async def client_perf(request: Request):
|
|
||||||
"""Low-volume frontend timing reports for stalls that happen before SSE logs."""
|
|
||||||
try:
|
|
||||||
data = await request.json()
|
|
||||||
except Exception:
|
|
||||||
data = {}
|
|
||||||
try:
|
|
||||||
kind = str(data.get("type") or "client").replace("\n", " ")[:80]
|
|
||||||
total_ms = float(data.get("total_ms") or 0)
|
|
||||||
stages = data.get("stages") if isinstance(data.get("stages"), list) else []
|
|
||||||
stage_txt = " ".join(
|
|
||||||
f"{str(s.get('name') or '')[:40]}={float(s.get('delta_ms') or 0):.0f}ms"
|
|
||||||
for s in stages[:20]
|
|
||||||
if isinstance(s, dict)
|
|
||||||
)
|
|
||||||
extra = str(data.get("extra") or "").replace("\n", " ")[:200]
|
|
||||||
logging.getLogger("app.client_perf").warning(
|
|
||||||
"client_perf type=%s total=%.0fms %s%s",
|
|
||||||
kind,
|
|
||||||
total_ms,
|
|
||||||
stage_txt,
|
|
||||||
f" extra={extra}" if extra else "",
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
logging.getLogger("app.client_perf").debug("client_perf log failed", exc_info=True)
|
|
||||||
return {"ok": True}
|
|
||||||
|
|
||||||
@app.get("/api/ready")
|
@app.get("/api/ready")
|
||||||
async def readiness_check() -> JSONResponse:
|
async def readiness_check() -> JSONResponse:
|
||||||
@@ -1048,7 +882,7 @@ async def _startup_event():
|
|||||||
except BaseException as e:
|
except BaseException as e:
|
||||||
logger.warning(f"Built-in MCP registration failed (non-critical): {type(e).__name__}: {e}")
|
logger.warning(f"Built-in MCP registration failed (non-critical): {type(e).__name__}: {e}")
|
||||||
try:
|
try:
|
||||||
await mcp_manager.connect_all_enabled()
|
await asyncio.wait_for(mcp_manager.connect_all_enabled(), timeout=20)
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
logger.warning("User MCP startup timed out (non-critical)")
|
logger.warning("User MCP startup timed out (non-critical)")
|
||||||
except BaseException as e:
|
except BaseException as e:
|
||||||
@@ -1056,66 +890,59 @@ async def _startup_event():
|
|||||||
|
|
||||||
_startup_tasks.append(asyncio.create_task(_startup_mcp_connections()))
|
_startup_tasks.append(asyncio.create_task(_startup_mcp_connections()))
|
||||||
|
|
||||||
# Startup warmups are opt-in. They make later requests a little warmer, but
|
# Pre-warm the RAG tool index off the request path. Loading the local
|
||||||
# they also compete with the first seconds of real UI use on slow or busy
|
# embedding model + opening ChromaDB + indexing the built-in tools is a
|
||||||
# machines. Default to clear/idle startup and let requests warm what they use.
|
# one-time ~1-3s cost that otherwise lands on the user's FIRST message
|
||||||
_startup_warmups_enabled = str(os.getenv("ODYSSEUS_STARTUP_WARMUPS", "")).lower() in {"1", "true", "yes", "on"}
|
# (showing up as a big `tool_selection` time). Doing it here makes the
|
||||||
if _startup_warmups_enabled:
|
# first turn as fast as subsequent ones (warm embed ≈ a few ms).
|
||||||
async def _warmup_tool_index():
|
async def _warmup_tool_index():
|
||||||
try:
|
try:
|
||||||
from src.tool_index import get_tool_index
|
from src.tool_index import get_tool_index
|
||||||
idx = await asyncio.to_thread(get_tool_index)
|
idx = await asyncio.to_thread(get_tool_index)
|
||||||
if idx:
|
if idx:
|
||||||
await asyncio.to_thread(idx.get_tools_for_query, "warmup", 8)
|
await asyncio.to_thread(idx.get_tools_for_query, "warmup", 8)
|
||||||
logger.info("[startup] Tool index pre-warmed")
|
logger.info("[startup] Tool index pre-warmed")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Tool index warmup failed (non-critical): {type(e).__name__}: {e}")
|
logger.warning(f"Tool index warmup failed (non-critical): {type(e).__name__}: {e}")
|
||||||
|
|
||||||
_startup_tasks.append(asyncio.create_task(_warmup_tool_index()))
|
_startup_tasks.append(asyncio.create_task(_warmup_tool_index()))
|
||||||
|
# Warmup: ping all known LLM endpoints to prime connections
|
||||||
async def _warmup_endpoints():
|
async def _warmup_endpoints():
|
||||||
try:
|
try:
|
||||||
import httpx
|
import httpx
|
||||||
urls = (
|
endpoints = model_discovery.get_endpoints() if model_discovery else []
|
||||||
await asyncio.to_thread(model_discovery.warmup_ping_urls)
|
for ep in endpoints[:5]:
|
||||||
if model_discovery else []
|
url = ep.get("url", "").replace("/chat/completions", "/models")
|
||||||
)
|
if url:
|
||||||
for url in urls:
|
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||||
await client.get(url)
|
await client.get(url)
|
||||||
logger.info(f"Warmup ping OK: {url}")
|
logger.info(f"Warmup ping OK: {url}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"Warmup ping failed for endpoint: {e}")
|
logger.debug(f"Warmup ping failed for endpoint: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Warmup ping skipped: {e}")
|
||||||
|
|
||||||
|
_startup_tasks.append(asyncio.create_task(_warmup_endpoints()))
|
||||||
|
|
||||||
|
# Keep-alive: ping endpoints every 60 seconds to prevent cold starts
|
||||||
|
async def _keepalive_loop():
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(60)
|
||||||
|
await _warmup_endpoints()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"Warmup ping skipped: {e}")
|
logger.warning(f"Keepalive loop error: {e}")
|
||||||
|
await asyncio.sleep(300) # Back off on error
|
||||||
|
|
||||||
_startup_tasks.append(asyncio.create_task(_warmup_endpoints()))
|
_startup_tasks.append(asyncio.create_task(_keepalive_loop()))
|
||||||
else:
|
|
||||||
logger.info("Startup warmups disabled (set ODYSSEUS_STARTUP_WARMUPS=1 to enable)")
|
|
||||||
|
|
||||||
# Keep-alive is opt-in. The ping path performs model discovery, and when
|
|
||||||
# stale LAN endpoints are configured it can add periodic backend pressure
|
|
||||||
# that delays unrelated UI requests such as Notes/Documents.
|
|
||||||
_keepalive_enabled = str(os.getenv("ODYSSEUS_MODEL_KEEPALIVE", "")).lower() in {"1", "true", "yes", "on"}
|
|
||||||
if _keepalive_enabled:
|
|
||||||
async def _keepalive_loop():
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
await asyncio.sleep(60)
|
|
||||||
await _warmup_endpoints()
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Keepalive loop error: {e}")
|
|
||||||
await asyncio.sleep(300) # Back off on error
|
|
||||||
|
|
||||||
_startup_tasks.append(asyncio.create_task(_keepalive_loop()))
|
|
||||||
|
|
||||||
async def _ensure_default_tasks():
|
async def _ensure_default_tasks():
|
||||||
# Create/reconcile default automation tasks + personal assistant for every user.
|
# Create/reconcile default automation tasks + personal assistant for every user.
|
||||||
owners = set()
|
owners = set()
|
||||||
try:
|
try:
|
||||||
import json as _json
|
import json as _json
|
||||||
auth_path = AUTH_FILE
|
auth_path = "data/auth.json"
|
||||||
with open(auth_path, encoding="utf-8") as f:
|
with open(auth_path, encoding="utf-8") as f:
|
||||||
users = _json.load(f).get("users", {})
|
users = _json.load(f).get("users", {})
|
||||||
owners.update(users.keys())
|
owners.update(users.keys())
|
||||||
@@ -1162,7 +989,7 @@ async def _startup_event():
|
|||||||
# does not make an existing library look empty after auth/account changes.
|
# does not make an existing library look empty after auth/account changes.
|
||||||
try:
|
try:
|
||||||
import json as _json
|
import json as _json
|
||||||
auth_path = AUTH_FILE
|
auth_path = "data/auth.json"
|
||||||
with open(auth_path, encoding="utf-8") as f:
|
with open(auth_path, encoding="utf-8") as f:
|
||||||
users = _json.load(f).get("users", {})
|
users = _json.load(f).get("users", {})
|
||||||
primary_owner = None
|
primary_owner = None
|
||||||
@@ -1234,16 +1061,6 @@ async def _startup_event():
|
|||||||
logger.warning(f"Nightly skill audit failed: {e}")
|
logger.warning(f"Nightly skill audit failed: {e}")
|
||||||
|
|
||||||
_startup_tasks.append(asyncio.create_task(_skill_audit_nightly_loop()))
|
_startup_tasks.append(asyncio.create_task(_skill_audit_nightly_loop()))
|
||||||
|
|
||||||
# Cookbook serve lifecycle — kills scheduler-launched serves whose
|
|
||||||
# window-end has passed. Paired with the cookbook_serve builtin
|
|
||||||
# action; both are no-ops unless a scheduled task actually launches
|
|
||||||
# something with end_after_min set. Removing this line + the
|
|
||||||
# cookbook_serve entry in BUILTIN_ACTIONS + src/cookbook_serve_lifecycle.py
|
|
||||||
# removes the feature.
|
|
||||||
from src.cookbook_serve_lifecycle import cookbook_serve_lifecycle_loop
|
|
||||||
_startup_tasks.append(asyncio.create_task(cookbook_serve_lifecycle_loop()))
|
|
||||||
|
|
||||||
logger.info("Application startup complete")
|
logger.info("Application startup complete")
|
||||||
|
|
||||||
async def _shutdown_event():
|
async def _shutdown_event():
|
||||||
@@ -1270,12 +1087,3 @@ async def _shutdown_event():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"MCP shutdown error: {e}")
|
logger.warning(f"MCP shutdown error: {e}")
|
||||||
logger.info("Application shutdown complete")
|
logger.info("Application shutdown complete")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
import uvicorn
|
|
||||||
|
|
||||||
bind_host = os.getenv("APP_BIND", "127.0.0.1")
|
|
||||||
bind_port = int(os.getenv("APP_PORT", "7000"))
|
|
||||||
|
|
||||||
uvicorn.run(app, host=bind_host, port=bind_port, log_level="info")
|
|
||||||
|
|||||||
@@ -1,72 +0,0 @@
|
|||||||
#Requires -Version 5.1
|
|
||||||
<#
|
|
||||||
Build a portable Windows distribution for Odysseus.
|
|
||||||
|
|
||||||
Output layout:
|
|
||||||
dist\Odysseus\Odysseus.exe
|
|
||||||
dist\Odysseus\static\...
|
|
||||||
dist\Odysseus\scripts\...
|
|
||||||
dist\Odysseus\mcp_servers\...
|
|
||||||
dist\Odysseus\services\hwfit\data\...
|
|
||||||
|
|
||||||
The app then keeps using its normal filesystem layout when frozen.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
powershell -ExecutionPolicy Bypass -File .\build-windows-portable.ps1
|
|
||||||
#>
|
|
||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
|
||||||
Set-Location -Path $PSScriptRoot
|
|
||||||
|
|
||||||
function Write-Step($msg) { Write-Host ""; Write-Host ("==> " + $msg) -ForegroundColor Cyan }
|
|
||||||
function Fail($msg) {
|
|
||||||
Write-Host ""
|
|
||||||
Write-Host ("ERROR: " + $msg) -ForegroundColor Red
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Step "Checking for Python"
|
|
||||||
$pyExe = $null
|
|
||||||
if (Test-Path ".\.venv\Scripts\python.exe") {
|
|
||||||
$pyExe = (Resolve-Path ".\.venv\Scripts\python.exe").Path
|
|
||||||
} else {
|
|
||||||
foreach ($c in @("py", "python")) {
|
|
||||||
$cmd = Get-Command $c -ErrorAction SilentlyContinue
|
|
||||||
if ($cmd) { $pyExe = $cmd.Source; break }
|
|
||||||
}
|
|
||||||
if ($pyExe -like "*WindowsApps*python.exe") {
|
|
||||||
$pyCmd = Get-Command py -ErrorAction SilentlyContinue
|
|
||||||
if ($pyCmd) {
|
|
||||||
$pyExe = $pyCmd.Source
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (-not $pyExe) {
|
|
||||||
Fail "Python not found on PATH. Install Python 3.11+ first."
|
|
||||||
}
|
|
||||||
Write-Host ("Using Python: " + $pyExe)
|
|
||||||
|
|
||||||
Write-Step "Installing build dependencies"
|
|
||||||
& $pyExe -m pip install --upgrade pip --quiet
|
|
||||||
& $pyExe -m pip install -r requirements.txt pyinstaller pystray Pillow
|
|
||||||
if ($LASTEXITCODE -ne 0) { Fail "Dependency install failed." }
|
|
||||||
|
|
||||||
Write-Step "Building portable exe bundle"
|
|
||||||
Remove-Item -Recurse -Force build, dist -ErrorAction SilentlyContinue
|
|
||||||
|
|
||||||
$dataArgs = @(
|
|
||||||
"--add-data", "static;static",
|
|
||||||
"--add-data", "scripts;scripts",
|
|
||||||
"--add-data", "mcp_servers;mcp_servers",
|
|
||||||
"--add-data", "services/hwfit/data;services/hwfit/data",
|
|
||||||
"--add-data", "config;config",
|
|
||||||
"--add-data", ".env.example;.env.example"
|
|
||||||
)
|
|
||||||
|
|
||||||
& $pyExe -m PyInstaller --noconfirm --clean --onedir --noconsole --icon=static/icon.ico --name Odysseus @dataArgs launcher.py
|
|
||||||
if ($LASTEXITCODE -ne 0) { Fail "PyInstaller build failed." }
|
|
||||||
|
|
||||||
Write-Host ""
|
|
||||||
Write-Host "Build complete." -ForegroundColor Green
|
|
||||||
Write-Host "Portable app folder: $PSScriptRoot\dist\Odysseus" -ForegroundColor Green
|
|
||||||
Write-Host "Distribute the whole folder (or zip it) so static assets and scripts stay with the exe." -ForegroundColor Green
|
|
||||||
@@ -14,8 +14,6 @@ import uuid
|
|||||||
|
|
||||||
import bcrypt
|
import bcrypt
|
||||||
|
|
||||||
from src.constants import AUTH_FILE
|
|
||||||
|
|
||||||
PAIRING_VERSION = 1
|
PAIRING_VERSION = 1
|
||||||
COMPANION_SCOPE = "chat"
|
COMPANION_SCOPE = "chat"
|
||||||
|
|
||||||
@@ -63,7 +61,7 @@ def lan_ip_candidates() -> list[str]:
|
|||||||
def find_admin_user() -> str | None:
|
def find_admin_user() -> str | None:
|
||||||
"""Resolve an admin username from data/auth.json (schema uses is_admin),
|
"""Resolve an admin username from data/auth.json (schema uses is_admin),
|
||||||
falling back to the first user."""
|
falling back to the first user."""
|
||||||
auth_path = AUTH_FILE
|
auth_path = os.path.join("data", "auth.json")
|
||||||
try:
|
try:
|
||||||
with open(auth_path, "r", encoding="utf-8") as f:
|
with open(auth_path, "r", encoding="utf-8") as f:
|
||||||
data = json.load(f)
|
data = json.load(f)
|
||||||
|
|||||||
@@ -5,9 +5,8 @@ offers and pair to it, without duplicating any LLM logic.
|
|||||||
|
|
||||||
Auth is enforced globally by AuthMiddleware (app.py), so reaching a handler here
|
Auth is enforced globally by AuthMiddleware (app.py), so reaching a handler here
|
||||||
means the caller is authenticated by either a cookie session or a Bearer `ody_`
|
means the caller is authenticated by either a cookie session or a Bearer `ody_`
|
||||||
API token. Ping/info accept either credential type, models requires a chat-
|
API token. The read endpoints (ping/info/models) accept either; the pairing
|
||||||
scoped API token for bearer callers, and the pairing endpoints are admin-cookie
|
endpoints are admin-cookie only.
|
||||||
only.
|
|
||||||
|
|
||||||
Pairing CSRF posture: minting happens ONLY on POST. The session cookie is
|
Pairing CSRF posture: minting happens ONLY on POST. The session cookie is
|
||||||
SameSite=Lax (routes/auth_routes.py), which a browser does not send on a
|
SameSite=Lax (routes/auth_routes.py), which a browser does not send on a
|
||||||
@@ -19,7 +18,7 @@ on a GET would be unsafe (Lax cookies ride top-level GET navigations), so GET
|
|||||||
|
|
||||||
import html
|
import html
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Request
|
from fastapi import APIRouter, Request
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
|
|
||||||
from core.middleware import require_admin
|
from core.middleware import require_admin
|
||||||
@@ -53,18 +52,6 @@ def owner_can_see(row_owner, owner) -> bool:
|
|||||||
return row_owner is None or row_owner == owner
|
return row_owner is None or row_owner == owner
|
||||||
|
|
||||||
|
|
||||||
def require_models_scope(request: Request) -> None:
|
|
||||||
"""Require the companion chat scope for bearer-token model inventory."""
|
|
||||||
if not getattr(request.state, "api_token", False):
|
|
||||||
return
|
|
||||||
scopes = getattr(request.state, "api_token_scopes", None) or []
|
|
||||||
if isinstance(scopes, str):
|
|
||||||
scopes = [scope.strip() for scope in scopes.split(",")]
|
|
||||||
scope_set = {str(scope).strip() for scope in scopes if str(scope).strip()}
|
|
||||||
if _pairing.COMPANION_SCOPE not in scope_set:
|
|
||||||
raise HTTPException(403, "API token requires chat scope")
|
|
||||||
|
|
||||||
|
|
||||||
def mint_pairing_token(owner: str, invalidate=None) -> tuple[str, str]:
|
def mint_pairing_token(owner: str, invalidate=None) -> tuple[str, str]:
|
||||||
"""Mint a pairing token AND invalidate the auth middleware's in-memory token
|
"""Mint a pairing token AND invalidate the auth middleware's in-memory token
|
||||||
cache, so the new token is accepted on the very next request without a server
|
cache, so the new token is accepted on the very next request without a server
|
||||||
@@ -116,7 +103,6 @@ def setup_companion_routes() -> APIRouter:
|
|||||||
rows -- the same rule as owner_filter. Read-only; never returns api_key
|
rows -- the same rule as owner_filter. Read-only; never returns api_key
|
||||||
material.
|
material.
|
||||||
"""
|
"""
|
||||||
require_models_scope(request)
|
|
||||||
import json as _json
|
import json as _json
|
||||||
|
|
||||||
from core.database import SessionLocal, ModelEndpoint
|
from core.database import SessionLocal, ModelEndpoint
|
||||||
|
|||||||
@@ -34,8 +34,6 @@ def atomic_write_json(path: str, data: Any, *, indent: Optional[int] = None) ->
|
|||||||
|
|
||||||
|
|
||||||
def atomic_write_text(path: str, text: str) -> None:
|
def atomic_write_text(path: str, text: str) -> None:
|
||||||
if not isinstance(text, str):
|
|
||||||
raise TypeError("atomic_write_text expects a string")
|
|
||||||
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
||||||
tmp = f"{path}.tmp.{os.getpid()}"
|
tmp = f"{path}.tmp.{os.getpid()}"
|
||||||
with open(tmp, "w", encoding="utf-8") as f:
|
with open(tmp, "w", encoding="utf-8") as f:
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ Authentication module — multi-user password hashing, session tokens, config pe
|
|||||||
Config stored in data/auth.json. Uses bcrypt directly.
|
Config stored in data/auth.json. Uses bcrypt directly.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import enum
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
@@ -20,7 +19,6 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
from core.atomic_io import atomic_write_json as _atomic_write_json # noqa: E402
|
from core.atomic_io import atomic_write_json as _atomic_write_json # noqa: E402
|
||||||
from core.middleware import INTERNAL_TOOL_USER # noqa: E402
|
|
||||||
|
|
||||||
DEFAULT_PRIVILEGES = {
|
DEFAULT_PRIVILEGES = {
|
||||||
"can_use_agent": True,
|
"can_use_agent": True,
|
||||||
@@ -32,24 +30,14 @@ DEFAULT_PRIVILEGES = {
|
|||||||
"can_manage_memory": True,
|
"can_manage_memory": True,
|
||||||
"max_messages_per_day": 0,
|
"max_messages_per_day": 0,
|
||||||
"allowed_models": [],
|
"allowed_models": [],
|
||||||
"allowed_models_restricted": False,
|
|
||||||
# Explicit "block every model" sentinel. An empty `allowed_models` list is
|
|
||||||
# ambiguous — it's also what gets sent when the admin clicks "[All]" — so
|
|
||||||
# we need a dedicated flag to express "this user may use no models at all"
|
|
||||||
# distinctly from "this user has no restriction".
|
|
||||||
"block_all_models": False,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Admins get everything
|
# Admins get everything
|
||||||
ADMIN_PRIVILEGES = {k: (True if isinstance(v, bool) else (0 if isinstance(v, int) else [])) for k, v in DEFAULT_PRIVILEGES.items()}
|
ADMIN_PRIVILEGES = {k: (True if isinstance(v, bool) else (0 if isinstance(v, int) else [])) for k, v in DEFAULT_PRIVILEGES.items()}
|
||||||
ADMIN_PRIVILEGES["allowed_models_restricted"] = False
|
|
||||||
# Admins must never be blocked from using models — the generic dict
|
|
||||||
# comprehension above flips every boolean default to True, which would be
|
|
||||||
# backwards for this sentinel.
|
|
||||||
ADMIN_PRIVILEGES["block_all_models"] = False
|
|
||||||
|
|
||||||
from src.constants import AUTH_FILE, PASSWORD_MIN_LENGTH
|
DEFAULT_AUTH_PATH = os.path.join(
|
||||||
DEFAULT_AUTH_PATH = AUTH_FILE
|
Path(__file__).parent.parent, "data", "auth.json"
|
||||||
|
)
|
||||||
TOKEN_TTL = 60 * 60 * 24 * 7 # 7 days
|
TOKEN_TTL = 60 * 60 * 24 * 7 # 7 days
|
||||||
|
|
||||||
# Usernames the auth + middleware layer reserve as internal "synthetic owner"
|
# Usernames the auth + middleware layer reserve as internal "synthetic owner"
|
||||||
@@ -66,15 +54,7 @@ TOKEN_TTL = 60 * 60 * 24 * 7 # 7 days
|
|||||||
# of those names would be denied an assistant and inconsistently owner-scoped.
|
# of those names would be denied an assistant and inconsistently owner-scoped.
|
||||||
# Refuse to create or rename into any of them so the sentinels can't be
|
# Refuse to create or rename into any of them so the sentinels can't be
|
||||||
# impersonated. (Keep this in sync with that synthetic-owner set.)
|
# impersonated. (Keep this in sync with that synthetic-owner set.)
|
||||||
RESERVED_USERNAMES = frozenset({INTERNAL_TOOL_USER, "api", "demo", "system"})
|
RESERVED_USERNAMES = frozenset({"internal-tool", "api", "demo", "system"})
|
||||||
|
|
||||||
|
|
||||||
def normalize_known_username(users: Dict[str, Any], username: str | None) -> Optional[str]:
|
|
||||||
"""Return a normalized username only when it exists in the auth user map."""
|
|
||||||
key = str(username or "").strip().lower()
|
|
||||||
if not key or key not in users:
|
|
||||||
return None
|
|
||||||
return key
|
|
||||||
|
|
||||||
|
|
||||||
def _hash_password(password: str) -> str:
|
def _hash_password(password: str) -> str:
|
||||||
@@ -85,15 +65,6 @@ def _verify_password(password: str, hashed: str) -> bool:
|
|||||||
return bcrypt.checkpw(password.encode("utf-8"), hashed.encode("utf-8"))
|
return bcrypt.checkpw(password.encode("utf-8"), hashed.encode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
class SetAdminResult(enum.Enum):
|
|
||||||
"""Outcome of AuthManager.set_admin, so callers can map each case to a
|
|
||||||
precise response instead of guessing from a bare bool."""
|
|
||||||
OK = "ok"
|
|
||||||
USER_NOT_FOUND = "user_not_found"
|
|
||||||
NOT_AUTHORIZED = "not_authorized" # requester is not an admin
|
|
||||||
LAST_ADMIN = "last_admin" # would remove the last remaining admin
|
|
||||||
|
|
||||||
|
|
||||||
class AuthManager:
|
class AuthManager:
|
||||||
"""Manages multi-user password + session-token auth system."""
|
"""Manages multi-user password + session-token auth system."""
|
||||||
|
|
||||||
@@ -105,17 +76,12 @@ class AuthManager:
|
|||||||
# Guards mutations of self._sessions and the on-disk sessions.json.
|
# Guards mutations of self._sessions and the on-disk sessions.json.
|
||||||
# Validate/create/revoke run concurrently from the FastAPI threadpool.
|
# Validate/create/revoke run concurrently from the FastAPI threadpool.
|
||||||
self._sessions_lock = threading.RLock()
|
self._sessions_lock = threading.RLock()
|
||||||
# Guards all mutations of self._config and the on-disk auth.json so
|
|
||||||
# concurrent create/delete/rename/privilege operations don't interleave
|
|
||||||
# and corrupt the user database.
|
|
||||||
self._config_lock = threading.Lock()
|
|
||||||
# Guards the first-run setup check-and-write so concurrent requests
|
# Guards the first-run setup check-and-write so concurrent requests
|
||||||
# cannot both observe is_configured==False and both create admin accounts.
|
# cannot both observe is_configured==False and both create admin accounts.
|
||||||
self._setup_lock = threading.Lock()
|
self._setup_lock = threading.Lock()
|
||||||
self._load()
|
self._load()
|
||||||
self._load_sessions()
|
self._load_sessions()
|
||||||
self._migrate_single_user()
|
self._migrate_single_user()
|
||||||
self._drop_reserved_loaded_users()
|
|
||||||
self._migrate_legacy_admin_role()
|
self._migrate_legacy_admin_role()
|
||||||
|
|
||||||
def _load(self):
|
def _load(self):
|
||||||
@@ -168,52 +134,20 @@ class AuthManager:
|
|||||||
def _migrate_single_user(self):
|
def _migrate_single_user(self):
|
||||||
"""Migrate old single-user format to multi-user format."""
|
"""Migrate old single-user format to multi-user format."""
|
||||||
if "password_hash" in self._config and "users" not in self._config:
|
if "password_hash" in self._config and "users" not in self._config:
|
||||||
old_user = str(self._config.get("username", "admin") or "admin").strip().lower()
|
old_user = self._config.get("username", "admin")
|
||||||
if old_user in RESERVED_USERNAMES:
|
|
||||||
logger.warning(
|
|
||||||
"Migrating legacy single-user reserved username '%s' to 'admin'",
|
|
||||||
old_user,
|
|
||||||
)
|
|
||||||
old_user = "admin"
|
|
||||||
old_hash = self._config["password_hash"]
|
old_hash = self._config["password_hash"]
|
||||||
with self._config_lock:
|
self._config = {
|
||||||
self._config = {
|
"users": {
|
||||||
"users": {
|
old_user: {
|
||||||
old_user: {
|
"password_hash": old_hash,
|
||||||
"password_hash": old_hash,
|
"created": time.time(),
|
||||||
"created": time.time(),
|
"is_admin": True,
|
||||||
"is_admin": True,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
self._save()
|
}
|
||||||
|
self._save()
|
||||||
logger.info(f"Migrated single-user auth to multi-user (admin: {old_user})")
|
logger.info(f"Migrated single-user auth to multi-user (admin: {old_user})")
|
||||||
|
|
||||||
def _drop_reserved_loaded_users(self):
|
|
||||||
"""Fail closed for legacy/manual auth rows that collide with sentinels."""
|
|
||||||
users = self._config.get("users")
|
|
||||||
if not isinstance(users, dict):
|
|
||||||
return
|
|
||||||
normalized = {}
|
|
||||||
removed = []
|
|
||||||
for username, data in users.items():
|
|
||||||
key = str(username or "").strip().lower()
|
|
||||||
if not key:
|
|
||||||
continue
|
|
||||||
if key in RESERVED_USERNAMES:
|
|
||||||
removed.append(key)
|
|
||||||
continue
|
|
||||||
normalized[key] = data
|
|
||||||
if removed or normalized != users:
|
|
||||||
with self._config_lock:
|
|
||||||
self._config["users"] = normalized
|
|
||||||
self._save()
|
|
||||||
if removed:
|
|
||||||
logger.warning(
|
|
||||||
"Removed reserved username(s) from auth config: %s",
|
|
||||||
", ".join(sorted(set(removed))),
|
|
||||||
)
|
|
||||||
|
|
||||||
def _migrate_legacy_admin_role(self):
|
def _migrate_legacy_admin_role(self):
|
||||||
"""Normalize setup.py's old role='admin' marker to is_admin=True."""
|
"""Normalize setup.py's old role='admin' marker to is_admin=True."""
|
||||||
changed = False
|
changed = False
|
||||||
@@ -238,23 +172,13 @@ class AuthManager:
|
|||||||
|
|
||||||
@signup_enabled.setter
|
@signup_enabled.setter
|
||||||
def signup_enabled(self, value: bool):
|
def signup_enabled(self, value: bool):
|
||||||
with self._config_lock:
|
self._config["signup_enabled"] = value
|
||||||
self._config["signup_enabled"] = value
|
self._save()
|
||||||
self._save()
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_configured(self) -> bool:
|
def is_configured(self) -> bool:
|
||||||
return len(self.users) > 0
|
return len(self.users) > 0
|
||||||
|
|
||||||
def policy(self) -> dict:
|
|
||||||
"""Return public auth policy constants for the frontend."""
|
|
||||||
return {
|
|
||||||
"password_min_length": PASSWORD_MIN_LENGTH,
|
|
||||||
"reserved_usernames": sorted(RESERVED_USERNAMES),
|
|
||||||
"signup_enabled": self.signup_enabled,
|
|
||||||
"session_days": TOKEN_TTL // 86400,
|
|
||||||
}
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Account management
|
# Account management
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -274,18 +198,17 @@ class AuthManager:
|
|||||||
if username in RESERVED_USERNAMES:
|
if username in RESERVED_USERNAMES:
|
||||||
logger.warning("Refused to create reserved username '%s'", username)
|
logger.warning("Refused to create reserved username '%s'", username)
|
||||||
return False
|
return False
|
||||||
with self._config_lock:
|
if username in self.users:
|
||||||
if username in self.users:
|
return False
|
||||||
return False
|
if "users" not in self._config:
|
||||||
if "users" not in self._config:
|
self._config["users"] = {}
|
||||||
self._config["users"] = {}
|
self._config["users"][username] = {
|
||||||
self._config["users"][username] = {
|
"password_hash": _hash_password(password),
|
||||||
"password_hash": _hash_password(password),
|
"created": time.time(),
|
||||||
"created": time.time(),
|
"is_admin": is_admin,
|
||||||
"is_admin": is_admin,
|
"privileges": dict(ADMIN_PRIVILEGES if is_admin else DEFAULT_PRIVILEGES),
|
||||||
"privileges": dict(ADMIN_PRIVILEGES if is_admin else DEFAULT_PRIVILEGES),
|
}
|
||||||
}
|
self._save()
|
||||||
self._save()
|
|
||||||
logger.info(f"Created user '{username}' (admin={is_admin})")
|
logger.info(f"Created user '{username}' (admin={is_admin})")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -298,31 +221,14 @@ class AuthManager:
|
|||||||
their cookie expired naturally (default ~30 days).
|
their cookie expired naturally (default ~30 days).
|
||||||
"""
|
"""
|
||||||
username = username.strip().lower()
|
username = username.strip().lower()
|
||||||
with self._config_lock:
|
if username not in self.users:
|
||||||
if username not in self.users:
|
return False
|
||||||
return False
|
if username == requesting_user:
|
||||||
if username == requesting_user:
|
return False
|
||||||
return False
|
if not self.users.get(requesting_user, {}).get("is_admin"):
|
||||||
if not self.users.get(requesting_user, {}).get("is_admin"):
|
return False
|
||||||
return False
|
del self._config["users"][username]
|
||||||
# Revoke API bearer tokens before removing the auth row. The bearer
|
self._save()
|
||||||
# path authenticates from ApiToken rows and does not require the
|
|
||||||
# owner to still exist, so a successful delete must not leave active
|
|
||||||
# rows behind. If the token store is unavailable, fail closed and
|
|
||||||
# keep the user/session state intact so the admin can retry.
|
|
||||||
try:
|
|
||||||
from core.database import get_db_session, ApiToken
|
|
||||||
with get_db_session() as db:
|
|
||||||
removed_tokens = db.query(ApiToken).filter(ApiToken.owner == username).delete()
|
|
||||||
if removed_tokens:
|
|
||||||
logger.info(
|
|
||||||
f"Revoked {removed_tokens} API token(s) owned by deleted user '{username}'"
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
logger.warning(f"Failed to revoke API tokens for deleted user '{username}'")
|
|
||||||
return False
|
|
||||||
del self._config["users"][username]
|
|
||||||
self._save()
|
|
||||||
# Purge all sessions belonging to this user. validate_token doesn't
|
# Purge all sessions belonging to this user. validate_token doesn't
|
||||||
# cross-check `self.users`, so without this step a deleted user's
|
# cross-check `self.users`, so without this step a deleted user's
|
||||||
# cookie keeps authenticating.
|
# cookie keeps authenticating.
|
||||||
@@ -335,6 +241,18 @@ class AuthManager:
|
|||||||
revoked += 1
|
revoked += 1
|
||||||
if revoked:
|
if revoked:
|
||||||
self._save_sessions()
|
self._save_sessions()
|
||||||
|
# Also revoke API bearer tokens owned by this user. The bearer auth
|
||||||
|
# path authenticates straight against ApiToken rows and never
|
||||||
|
# re-checks that the owner still exists, so leaving the rows behind
|
||||||
|
# would let a deleted user keep full API access indefinitely.
|
||||||
|
try:
|
||||||
|
from core.database import get_db_session, ApiToken
|
||||||
|
with get_db_session() as db:
|
||||||
|
removed = db.query(ApiToken).filter(ApiToken.owner == username).delete()
|
||||||
|
if removed:
|
||||||
|
logger.info(f"Revoked {removed} API token(s) owned by deleted user '{username}'")
|
||||||
|
except Exception:
|
||||||
|
logger.warning(f"Failed to revoke API tokens for deleted user '{username}'")
|
||||||
logger.info(f"Deleted user '{username}' (by {requesting_user}); revoked {revoked} active session(s)")
|
logger.info(f"Deleted user '{username}' (by {requesting_user}); revoked {revoked} active session(s)")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -348,15 +266,14 @@ class AuthManager:
|
|||||||
if new_username in RESERVED_USERNAMES:
|
if new_username in RESERVED_USERNAMES:
|
||||||
logger.warning("Refused to rename '%s' into reserved username '%s'", old_username, new_username)
|
logger.warning("Refused to rename '%s' into reserved username '%s'", old_username, new_username)
|
||||||
return False
|
return False
|
||||||
with self._config_lock:
|
if old_username not in self.users:
|
||||||
if old_username not in self.users:
|
return False
|
||||||
return False
|
if new_username in self.users:
|
||||||
if new_username in self.users:
|
return False
|
||||||
return False
|
if not self.users.get(requesting_user, {}).get("is_admin"):
|
||||||
if not self.users.get(requesting_user, {}).get("is_admin"):
|
return False
|
||||||
return False
|
self._config.setdefault("users", {})[new_username] = self._config["users"].pop(old_username)
|
||||||
self._config.setdefault("users", {})[new_username] = self._config["users"].pop(old_username)
|
self._save()
|
||||||
self._save()
|
|
||||||
|
|
||||||
renamed_sessions = 0
|
renamed_sessions = 0
|
||||||
with self._sessions_lock:
|
with self._sessions_lock:
|
||||||
@@ -394,93 +311,28 @@ class AuthManager:
|
|||||||
def set_privileges(self, username: str, privileges: Dict[str, Any]) -> bool:
|
def set_privileges(self, username: str, privileges: Dict[str, Any]) -> bool:
|
||||||
"""Update privileges for a user. Can't modify admin privileges."""
|
"""Update privileges for a user. Can't modify admin privileges."""
|
||||||
username = username.strip().lower()
|
username = username.strip().lower()
|
||||||
with self._config_lock:
|
if username not in self.users:
|
||||||
if username not in self.users:
|
return False
|
||||||
return False
|
if self.users[username].get("is_admin"):
|
||||||
if self.users[username].get("is_admin"):
|
return False # admins always have full access
|
||||||
return False # admins always have full access
|
# Only allow known privilege keys
|
||||||
# Only allow known privilege keys
|
current = self.get_privileges(username)
|
||||||
current = self.get_privileges(username)
|
for k, v in privileges.items():
|
||||||
for k, v in privileges.items():
|
if k in DEFAULT_PRIVILEGES:
|
||||||
if k in DEFAULT_PRIVILEGES:
|
current[k] = v
|
||||||
current[k] = v
|
self._config["users"][username]["privileges"] = current
|
||||||
self._config["users"][username]["privileges"] = current
|
self._save()
|
||||||
self._save()
|
|
||||||
logger.info(f"Updated privileges for '{username}': {current}")
|
logger.info(f"Updated privileges for '{username}': {current}")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def set_admin(self, username: str, is_admin: bool,
|
|
||||||
requesting_user: str) -> SetAdminResult:
|
|
||||||
"""Promote/demote an existing user to/from admin. Admin only.
|
|
||||||
|
|
||||||
Refuses to remove the last remaining admin so the instance can never
|
|
||||||
be locked out of admin access; self-demotion is allowed as long as
|
|
||||||
another admin remains. Admin status is re-checked live on every
|
|
||||||
request, so unlike delete/rename no session or token revocation is
|
|
||||||
needed — a demoted admin simply fails the next is_admin() gate.
|
|
||||||
|
|
||||||
Promotion stashes the user's current privilege map and demotion
|
|
||||||
restores it, so a temporary admin stint can't silently broaden a
|
|
||||||
user's non-admin access; users without a stash (created as admin,
|
|
||||||
or promoted before stashing existed) demote to DEFAULT_PRIVILEGES.
|
|
||||||
|
|
||||||
Counting admins and flipping the flag happen in one critical section
|
|
||||||
so two concurrent demotions can't race the admin count to zero.
|
|
||||||
"""
|
|
||||||
username = (username or "").strip().lower()
|
|
||||||
requesting_user = (requesting_user or "").strip().lower()
|
|
||||||
is_admin = bool(is_admin)
|
|
||||||
with self._config_lock:
|
|
||||||
target = self._config.get("users", {}).get(username)
|
|
||||||
if target is None:
|
|
||||||
return SetAdminResult.USER_NOT_FOUND
|
|
||||||
if not self.users.get(requesting_user, {}).get("is_admin"):
|
|
||||||
return SetAdminResult.NOT_AUTHORIZED
|
|
||||||
currently_admin = bool(target.get("is_admin"))
|
|
||||||
if currently_admin == is_admin:
|
|
||||||
return SetAdminResult.OK # no-op; leave privileges untouched
|
|
||||||
if currently_admin and not is_admin:
|
|
||||||
admin_count = sum(1 for d in self.users.values() if d.get("is_admin"))
|
|
||||||
if admin_count <= 1:
|
|
||||||
return SetAdminResult.LAST_ADMIN
|
|
||||||
# Write order matters for lock-free readers: get_privileges()
|
|
||||||
# reads without _config_lock and trusts is_admin, so the admin
|
|
||||||
# flag must be flipped while the stored map is safe to expose —
|
|
||||||
# before writing admin privileges on promote, after restoring
|
|
||||||
# the pre-admin map on demote.
|
|
||||||
if is_admin:
|
|
||||||
target["is_admin"] = True
|
|
||||||
# Stash the pre-admin map so a later demotion can restore it.
|
|
||||||
# While is_admin is set the stored map is inert: get_privileges
|
|
||||||
# short-circuits to ADMIN_PRIVILEGES and set_privileges refuses
|
|
||||||
# admins, so only set_admin ever touches the stash.
|
|
||||||
target["privileges_before_admin"] = dict(
|
|
||||||
target.get("privileges") or DEFAULT_PRIVILEGES
|
|
||||||
)
|
|
||||||
target["privileges"] = dict(ADMIN_PRIVILEGES)
|
|
||||||
else:
|
|
||||||
# Restore the stashed pre-admin map. Fall back to defaults for
|
|
||||||
# users created as admins (their stored map is ADMIN_PRIVILEGES,
|
|
||||||
# which must not leak past demotion — e.g. can_use_bash) and
|
|
||||||
# for admins promoted before the stash existed.
|
|
||||||
target["privileges"] = dict(
|
|
||||||
target.pop("privileges_before_admin", None)
|
|
||||||
or DEFAULT_PRIVILEGES
|
|
||||||
)
|
|
||||||
target["is_admin"] = False
|
|
||||||
self._save()
|
|
||||||
logger.info("Set is_admin=%s for '%s' (by '%s')", is_admin, username, requesting_user)
|
|
||||||
return SetAdminResult.OK
|
|
||||||
|
|
||||||
def change_password(self, username: str, current_password: str, new_password: str) -> bool:
|
def change_password(self, username: str, current_password: str, new_password: str) -> bool:
|
||||||
username = username.strip().lower()
|
username = username.strip().lower()
|
||||||
if username not in self.users:
|
if username not in self.users:
|
||||||
return False
|
return False
|
||||||
if not _verify_password(current_password, self.users[username]["password_hash"]):
|
if not _verify_password(current_password, self.users[username]["password_hash"]):
|
||||||
return False
|
return False
|
||||||
with self._config_lock:
|
self._config["users"][username]["password_hash"] = _hash_password(new_password)
|
||||||
self._config["users"][username]["password_hash"] = _hash_password(new_password)
|
self._save()
|
||||||
self._save()
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -498,9 +350,8 @@ class AuthManager:
|
|||||||
if username not in self.users:
|
if username not in self.users:
|
||||||
return None
|
return None
|
||||||
secret = pyotp.random_base32()
|
secret = pyotp.random_base32()
|
||||||
with self._config_lock:
|
self._config["users"][username]["totp_secret_pending"] = secret
|
||||||
self._config["users"][username]["totp_secret_pending"] = secret
|
self._save()
|
||||||
self._save()
|
|
||||||
return secret
|
return secret
|
||||||
|
|
||||||
def totp_get_provisioning_uri(self, username: str, secret: str) -> str:
|
def totp_get_provisioning_uri(self, username: str, secret: str) -> str:
|
||||||
@@ -519,14 +370,13 @@ class AuthManager:
|
|||||||
if not totp.verify(code, valid_window=1):
|
if not totp.verify(code, valid_window=1):
|
||||||
return False
|
return False
|
||||||
# Enable 2FA
|
# Enable 2FA
|
||||||
with self._config_lock:
|
self._config["users"][username]["totp_secret"] = secret
|
||||||
self._config["users"][username]["totp_secret"] = secret
|
self._config["users"][username]["totp_enabled"] = True
|
||||||
self._config["users"][username]["totp_enabled"] = True
|
self._config["users"][username].pop("totp_secret_pending", None)
|
||||||
self._config["users"][username].pop("totp_secret_pending", None)
|
# Generate backup codes
|
||||||
# Generate backup codes
|
backup = [secrets.token_hex(4) for _ in range(8)]
|
||||||
backup = [secrets.token_hex(4) for _ in range(8)]
|
self._config["users"][username]["totp_backup_codes"] = backup
|
||||||
self._config["users"][username]["totp_backup_codes"] = backup
|
self._save()
|
||||||
self._save()
|
|
||||||
logger.info(f"2FA enabled for '{username}'")
|
logger.info(f"2FA enabled for '{username}'")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -545,10 +395,9 @@ class AuthManager:
|
|||||||
# Check backup codes first
|
# Check backup codes first
|
||||||
backup = user.get("totp_backup_codes", [])
|
backup = user.get("totp_backup_codes", [])
|
||||||
if code in backup:
|
if code in backup:
|
||||||
with self._config_lock:
|
backup.remove(code)
|
||||||
backup.remove(code)
|
self._config["users"][username]["totp_backup_codes"] = backup
|
||||||
self._config["users"][username]["totp_backup_codes"] = backup
|
self._save()
|
||||||
self._save()
|
|
||||||
logger.info(f"Backup code used for '{username}' ({len(backup)} remaining)")
|
logger.info(f"Backup code used for '{username}' ({len(backup)} remaining)")
|
||||||
return True
|
return True
|
||||||
totp = pyotp.TOTP(secret)
|
totp = pyotp.TOTP(secret)
|
||||||
@@ -559,12 +408,11 @@ class AuthManager:
|
|||||||
username = username.strip().lower()
|
username = username.strip().lower()
|
||||||
if not self.verify_password(username, password):
|
if not self.verify_password(username, password):
|
||||||
return False
|
return False
|
||||||
with self._config_lock:
|
self._config["users"][username].pop("totp_secret", None)
|
||||||
self._config["users"][username].pop("totp_secret", None)
|
self._config["users"][username].pop("totp_secret_pending", None)
|
||||||
self._config["users"][username].pop("totp_secret_pending", None)
|
self._config["users"][username].pop("totp_backup_codes", None)
|
||||||
self._config["users"][username].pop("totp_backup_codes", None)
|
self._config["users"][username]["totp_enabled"] = False
|
||||||
self._config["users"][username]["totp_enabled"] = False
|
self._save()
|
||||||
self._save()
|
|
||||||
logger.info(f"2FA disabled for '{username}'")
|
logger.info(f"2FA disabled for '{username}'")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -583,22 +431,12 @@ class AuthManager:
|
|||||||
username = username.strip().lower()
|
username = username.strip().lower()
|
||||||
if not self.verify_password(username, password):
|
if not self.verify_password(username, password):
|
||||||
return None
|
return None
|
||||||
return self.create_session_trusted(username)
|
|
||||||
|
|
||||||
def create_session_trusted(self, username: str) -> Optional[str]:
|
|
||||||
"""Issue a session token for an already-verified user.
|
|
||||||
Call only after verify_password (and TOTP if enabled) have passed."""
|
|
||||||
username = username.strip().lower()
|
|
||||||
token = secrets.token_hex(32)
|
token = secrets.token_hex(32)
|
||||||
with self._config_lock:
|
with self._sessions_lock:
|
||||||
if username not in self.users:
|
self._sessions[token] = {
|
||||||
logger.warning("Refused to issue session for missing user '%s'", username)
|
"username": username,
|
||||||
return None
|
"expiry": time.time() + TOKEN_TTL,
|
||||||
with self._sessions_lock:
|
}
|
||||||
self._sessions[token] = {
|
|
||||||
"username": username,
|
|
||||||
"expiry": time.time() + TOKEN_TTL,
|
|
||||||
}
|
|
||||||
self._save_sessions()
|
self._save_sessions()
|
||||||
return token
|
return token
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,40 @@
|
|||||||
# core/constants.py
|
# src/constants.py
|
||||||
"""Backward-compatible shim — the single source of truth is src/constants.py.
|
"""Application-wide constants and configuration values."""
|
||||||
|
import os
|
||||||
|
|
||||||
Historically there were two copies of this module (this one lagged behind at
|
APP_VERSION = "0.9.1"
|
||||||
APP_VERSION 0.9.1 and was missing the consolidated tool-output constants). To
|
|
||||||
kill the drift, this now simply re-exports everything from src.constants so
|
# Base paths
|
||||||
there is exactly one place that defines paths and reads ODYSSEUS_DATA_DIR.
|
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + "/"
|
||||||
internal_api_base() also lives in src.constants now and is re-exported here so
|
STATIC_DIR = os.path.join(BASE_DIR, "static")
|
||||||
existing `from core.constants import internal_api_base` callers keep working.
|
DATA_DIR = os.path.join(BASE_DIR, "data")
|
||||||
"""
|
|
||||||
from src.constants import * # noqa: F401,F403
|
# Data file paths
|
||||||
from src.constants import internal_api_base # noqa: F401 (explicit: functions aren't covered by some linters' * checks)
|
SESSIONS_FILE = os.path.join(DATA_DIR, "sessions.json")
|
||||||
|
MEMORY_FILE = os.path.join(DATA_DIR, "memory.json")
|
||||||
|
MEMORY_DOC = os.path.join(DATA_DIR, "memory_doc.md")
|
||||||
|
PERSONAL_DIR = os.path.join(DATA_DIR, "personal_docs")
|
||||||
|
RUNBOOK_DIR = os.path.join(PERSONAL_DIR, "runbook")
|
||||||
|
UPLOAD_DIR = os.path.join(DATA_DIR, "uploads")
|
||||||
|
FEATURES_FILE = os.path.join(DATA_DIR, "features.json")
|
||||||
|
SETTINGS_FILE = os.path.join(DATA_DIR, "settings.json")
|
||||||
|
|
||||||
|
# API Configuration
|
||||||
|
MAX_CONTEXT_MESSAGES = 90
|
||||||
|
REQUEST_TIMEOUT = 20
|
||||||
|
OPENAI_COMPAT_PATH = "/v1/chat/completions"
|
||||||
|
|
||||||
|
# Environment variables with defaults
|
||||||
|
DEFAULT_HOST = os.getenv("LLM_HOST", "localhost")
|
||||||
|
LLM_HOSTS = [h.strip() for h in os.getenv("LLM_HOSTS", "").split(",") if h.strip()]
|
||||||
|
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
|
||||||
|
SEARXNG_INSTANCE = os.getenv('SEARXNG_INSTANCE', 'http://localhost:8080')
|
||||||
|
|
||||||
|
|
||||||
|
# Cleanup configuration
|
||||||
|
CLEANUP_ENABLED = os.getenv("CLEANUP_ENABLED", "True").lower() == "true"
|
||||||
|
CLEANUP_INTERVAL_HOURS = int(os.getenv("CLEANUP_INTERVAL_HOURS", "24"))
|
||||||
|
|
||||||
|
# Default parameters
|
||||||
|
DEFAULT_TEMPERATURE = 1.0
|
||||||
|
DEFAULT_MAX_TOKENS = 0
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# core/exceptions.py
|
# src/exceptions.py
|
||||||
"""Custom exceptions for the application."""
|
"""Custom exceptions for the application."""
|
||||||
|
|
||||||
class SessionNotFoundError(Exception):
|
class SessionNotFoundError(Exception):
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
"""Helpers for keeping sensitive data out of logs.
|
|
||||||
|
|
||||||
Endpoint URLs configured by admins can embed credentials in the userinfo
|
|
||||||
(``https://user:pass@host``) or query string (``?api_key=...``). Logging them
|
|
||||||
raw leaks those secrets, so route/diagnostic logs run URLs through
|
|
||||||
``redact_url`` first. Reconstructing the URL without userinfo/query/fragment
|
|
||||||
also doubles as a sanitizer barrier for CodeQL's clear-text-logging query.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from urllib.parse import urlparse, urlunparse
|
|
||||||
|
|
||||||
|
|
||||||
def redact_url(url: str) -> str:
|
|
||||||
"""Return a URL safe for logs by removing userinfo and query/fragment.
|
|
||||||
|
|
||||||
Keeps scheme, host, port and path so logs stay useful for debugging.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
parsed = urlparse(url or "")
|
|
||||||
host = parsed.hostname or ""
|
|
||||||
if ":" in host: # IPv6 literal — re-bracket so host:port stays unambiguous
|
|
||||||
host = f"[{host}]"
|
|
||||||
if parsed.port:
|
|
||||||
host = f"{host}:{parsed.port}"
|
|
||||||
return urlunparse((parsed.scheme, host, parsed.path, "", "", ""))
|
|
||||||
except Exception:
|
|
||||||
return "<endpoint>"
|
|
||||||
@@ -15,17 +15,6 @@ from starlette.responses import Response
|
|||||||
# same value from this module. Never persisted or exposed externally.
|
# same value from this module. Never persisted or exposed externally.
|
||||||
INTERNAL_TOOL_TOKEN = os.environ.get("ODYSSEUS_INTERNAL_TOKEN") or secrets.token_hex(32)
|
INTERNAL_TOOL_TOKEN = os.environ.get("ODYSSEUS_INTERNAL_TOKEN") or secrets.token_hex(32)
|
||||||
INTERNAL_TOOL_HEADER = "X-Odysseus-Internal-Token"
|
INTERNAL_TOOL_HEADER = "X-Odysseus-Internal-Token"
|
||||||
# Pseudo-username on in-process tool-loopback requests; require_admin trusts it and it is reserved.
|
|
||||||
INTERNAL_TOOL_USER = "internal-tool"
|
|
||||||
|
|
||||||
|
|
||||||
def is_cors_preflight(method: str, headers) -> bool:
|
|
||||||
"""True for a genuine CORS preflight: an OPTIONS request carrying the
|
|
||||||
Access-Control-Request-Method header. Such requests are credential-less by
|
|
||||||
design and must reach CORSMiddleware to be answered -- gating them on auth
|
|
||||||
401s the preflight and breaks every cross-origin browser/WebView client.
|
|
||||||
Pure so it can be unit-tested without standing up the app."""
|
|
||||||
return method == "OPTIONS" and "access-control-request-method" in headers
|
|
||||||
|
|
||||||
|
|
||||||
def require_admin(request: Request):
|
def require_admin(request: Request):
|
||||||
@@ -41,7 +30,7 @@ def require_admin(request: Request):
|
|||||||
hdr = request.headers.get(INTERNAL_TOOL_HEADER)
|
hdr = request.headers.get(INTERNAL_TOOL_HEADER)
|
||||||
if hdr and secrets.compare_digest(hdr, INTERNAL_TOOL_TOKEN):
|
if hdr and secrets.compare_digest(hdr, INTERNAL_TOOL_TOKEN):
|
||||||
return
|
return
|
||||||
if getattr(request.state, "current_user", None) == INTERNAL_TOOL_USER:
|
if getattr(request.state, "current_user", None) == "internal-tool":
|
||||||
return
|
return
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
@@ -67,23 +56,13 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
|||||||
response = await call_next(request)
|
response = await call_next(request)
|
||||||
path = request.url.path
|
path = request.url.path
|
||||||
|
|
||||||
# Tool render endpoints
|
# Tool render endpoints are served inside iframes — allow framing by self
|
||||||
is_tool_render = path.startswith("/api/tools/") and path.endswith("/render")
|
is_tool_render = path.startswith("/api/tools/") and path.endswith("/render")
|
||||||
# Document library PDF preview endpoint
|
|
||||||
is_document_pdf_preview = path.startswith("/api/document/") and path.endswith("/render-pdf")
|
|
||||||
# Visual report pages are self-contained HTML — need inline scripts + external images
|
# Visual report pages are self-contained HTML — need inline scripts + external images
|
||||||
is_report = path.startswith("/api/research/report/")
|
is_report = path.startswith("/api/research/report/")
|
||||||
|
|
||||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||||
response.headers["Referrer-Policy"] = "no-referrer"
|
response.headers["Referrer-Policy"] = "no-referrer"
|
||||||
response.headers["Permissions-Policy"] = "camera=(), microphone=(self), geolocation=()"
|
|
||||||
|
|
||||||
is_https = (
|
|
||||||
request.url.scheme == "https"
|
|
||||||
or request.headers.get("X-Forwarded-Proto") == "https"
|
|
||||||
)
|
|
||||||
if is_https:
|
|
||||||
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
|
|
||||||
|
|
||||||
if is_report:
|
if is_report:
|
||||||
response.headers["Content-Security-Policy"] = (
|
response.headers["Content-Security-Policy"] = (
|
||||||
@@ -96,14 +75,10 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
|||||||
"frame-ancestors 'none'"
|
"frame-ancestors 'none'"
|
||||||
)
|
)
|
||||||
elif is_tool_render:
|
elif is_tool_render:
|
||||||
# Skip framing headers for tools.
|
# Tool iframe content: skip all framing headers — the iframe's
|
||||||
|
# sandbox="allow-scripts" attribute provides isolation.
|
||||||
|
# Don't overwrite the route's own restrictive CSP either.
|
||||||
pass
|
pass
|
||||||
elif is_document_pdf_preview:
|
|
||||||
response.headers["X-Frame-Options"] = "SAMEORIGIN"
|
|
||||||
response.headers["Content-Security-Policy"] = (
|
|
||||||
"default-src 'none'; "
|
|
||||||
"frame-ancestors 'self'"
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
response.headers["X-Frame-Options"] = "DENY"
|
response.headers["X-Frame-Options"] = "DENY"
|
||||||
# NOTE: `style-src 'unsafe-inline'` is intentionally retained.
|
# NOTE: `style-src 'unsafe-inline'` is intentionally retained.
|
||||||
@@ -117,7 +92,7 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
|||||||
f"script-src 'self' 'nonce-{nonce}' https://cdn.jsdelivr.net; "
|
f"script-src 'self' 'nonce-{nonce}' https://cdn.jsdelivr.net; "
|
||||||
"style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "
|
"style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "
|
||||||
"font-src 'self' https://cdn.jsdelivr.net; "
|
"font-src 'self' https://cdn.jsdelivr.net; "
|
||||||
"img-src 'self' data: blob: https:; "
|
"img-src 'self' data: blob:; "
|
||||||
"media-src 'self' blob:; "
|
"media-src 'self' blob:; "
|
||||||
"connect-src 'self'; "
|
"connect-src 'self'; "
|
||||||
"frame-src 'self'; "
|
"frame-src 'self'; "
|
||||||
|
|||||||
@@ -11,24 +11,14 @@ from typing import Dict, List, Any, Optional, TYPE_CHECKING
|
|||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .session_manager import SessionManager
|
from .session_manager import SessionManager
|
||||||
|
|
||||||
# Module-level session manager singleton (single source of truth)
|
# Module-level session manager reference (set at app startup)
|
||||||
_SESSION_MANAGER_INSTANCE: Optional["SessionManager"] = None
|
_session_manager: Optional["SessionManager"] = None
|
||||||
|
|
||||||
|
|
||||||
def set_session_manager_instance(manager: "SessionManager"):
|
def set_session_manager(manager: "SessionManager"):
|
||||||
"""Set the global SessionManager singleton."""
|
"""Set the global session manager reference."""
|
||||||
global _SESSION_MANAGER_INSTANCE
|
global _session_manager
|
||||||
_SESSION_MANAGER_INSTANCE = manager
|
_session_manager = manager
|
||||||
|
|
||||||
|
|
||||||
def get_session_manager_instance() -> Optional["SessionManager"]:
|
|
||||||
"""Get the global SessionManager singleton."""
|
|
||||||
return _SESSION_MANAGER_INSTANCE
|
|
||||||
|
|
||||||
|
|
||||||
# Keep legacy name for backward compatibility
|
|
||||||
set_session_manager = set_session_manager_instance
|
|
||||||
get_session_manager = get_session_manager_instance
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -52,17 +42,7 @@ class ChatMessage:
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Session:
|
class Session:
|
||||||
"""A chat session — pure data container.
|
"""A chat session — pure data container."""
|
||||||
|
|
||||||
``.history`` is the authoritative mutable message list. Callers may
|
|
||||||
read, append, pop, or reassign it directly — these changes take
|
|
||||||
effect immediately. ``_history`` remains a compatibility alias that
|
|
||||||
always resolves to the authoritative ``history`` list.
|
|
||||||
|
|
||||||
Each session gets its own unique history list at construction time
|
|
||||||
(the dataclass default is never shared between instances).
|
|
||||||
"""
|
|
||||||
|
|
||||||
id: str
|
id: str
|
||||||
name: str
|
name: str
|
||||||
endpoint_url: str
|
endpoint_url: str
|
||||||
@@ -76,56 +56,29 @@ class Session:
|
|||||||
message_count: int = 0
|
message_count: int = 0
|
||||||
|
|
||||||
def __post_init__(self):
|
def __post_init__(self):
|
||||||
if self.headers is None:
|
|
||||||
self.headers = {}
|
|
||||||
# Ensure each session gets its OWN list (not the shared dataclass default)
|
|
||||||
if self.history is None:
|
if self.history is None:
|
||||||
self.history = []
|
self.history = []
|
||||||
|
if self.headers is None:
|
||||||
@property
|
self.headers = {}
|
||||||
def _history(self) -> List[ChatMessage]:
|
|
||||||
"""Compatibility alias for callers that still reference ``_history``."""
|
|
||||||
return self.history
|
|
||||||
|
|
||||||
@_history.setter
|
|
||||||
def _history(self, messages: List[ChatMessage]):
|
|
||||||
self.history = messages
|
|
||||||
|
|
||||||
def add_message(self, message: ChatMessage):
|
def add_message(self, message: ChatMessage):
|
||||||
"""
|
"""
|
||||||
Add a message to this session.
|
Add a message to this session.
|
||||||
|
|
||||||
Appends to the authoritative history list and increments
|
Delegates to SessionManager for persistence if available,
|
||||||
message_count. Delegates to SessionManager for persistence
|
otherwise just appends to history.
|
||||||
if available.
|
|
||||||
"""
|
"""
|
||||||
self.history.append(message)
|
self.history.append(message)
|
||||||
self.message_count = len(self.history)
|
self.message_count = len(self.history)
|
||||||
|
|
||||||
# Delegate to session manager for persistence
|
# Delegate to session manager for persistence
|
||||||
if _SESSION_MANAGER_INSTANCE:
|
if _session_manager:
|
||||||
_SESSION_MANAGER_INSTANCE._persist_message(self.id, message)
|
_session_manager._persist_message(self.id, message)
|
||||||
|
|
||||||
def get_context_messages(self) -> List[Dict[str, Any]]:
|
def get_context_messages(self) -> List[Dict[str, Any]]:
|
||||||
"""Get messages in format for LLM API.
|
"""Get messages in format for LLM API."""
|
||||||
|
return [msg.to_dict() for msg in self.history]
|
||||||
Slash-command / setup replies are persisted to history so they render
|
|
||||||
in the transcript, but they are UI chatter (e.g. ``/setup ...`` and its
|
|
||||||
status lines) the user never meant as conversation. They carry
|
|
||||||
``metadata.source == "slash"``; exclude them here so they never reach
|
|
||||||
the model. Display/history-load paths use the raw ``history`` and are
|
|
||||||
unaffected.
|
|
||||||
"""
|
|
||||||
return [
|
|
||||||
msg.to_dict()
|
|
||||||
for msg in self.history
|
|
||||||
if (msg.metadata or {}).get("source") != "slash"
|
|
||||||
]
|
|
||||||
|
|
||||||
def get(self, key: str, default=None):
|
def get(self, key: str, default=None):
|
||||||
"""Dict-like access for compatibility."""
|
"""Dict-like access for compatibility."""
|
||||||
return getattr(self, key, default)
|
return getattr(self, key, default)
|
||||||
|
|
||||||
def __getitem__(self, key: str):
|
|
||||||
"""Allow session['field'] syntax."""
|
|
||||||
return getattr(self, key)
|
|
||||||
|
|||||||
@@ -18,22 +18,10 @@ import ntpath
|
|||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import sys
|
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
import platform
|
|
||||||
|
|
||||||
IS_WINDOWS = os.name == "nt"
|
IS_WINDOWS = os.name == "nt"
|
||||||
IS_POSIX = not IS_WINDOWS
|
IS_POSIX = not IS_WINDOWS
|
||||||
# Allows APFEL support and ARM-native binary recommendations on Apple Silicon Macs.
|
|
||||||
IS_APPLE_SILICON = (
|
|
||||||
IS_POSIX
|
|
||||||
and platform.system() == "Darwin"
|
|
||||||
and platform.machine().lower()
|
|
||||||
in {
|
|
||||||
"arm64",
|
|
||||||
"aarch64",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ── File permissions ────────────────────────────────────────────────────────
|
# ── File permissions ────────────────────────────────────────────────────────
|
||||||
@@ -65,8 +53,9 @@ def detached_popen_kwargs() -> dict:
|
|||||||
and is detached from any console.
|
and is detached from any console.
|
||||||
"""
|
"""
|
||||||
if IS_WINDOWS:
|
if IS_WINDOWS:
|
||||||
flags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0x00000200) | getattr(
|
flags = (
|
||||||
subprocess, "DETACHED_PROCESS", 0x00000008
|
getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0x00000200)
|
||||||
|
| getattr(subprocess, "DETACHED_PROCESS", 0x00000008)
|
||||||
)
|
)
|
||||||
return {"creationflags": flags}
|
return {"creationflags": flags}
|
||||||
return {"start_new_session": True}
|
return {"start_new_session": True}
|
||||||
@@ -161,29 +150,6 @@ _WINDOWS_BASH_RELATIVE_PATHS = (
|
|||||||
("usr", "bin", "bash.exe"),
|
("usr", "bin", "bash.exe"),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Paths to add to the remote SSH probe command to find tools like nvidia-smi that may not be on PATH.
|
|
||||||
_SSH_PATH_MEMBERS = (
|
|
||||||
"/usr/bin",
|
|
||||||
"/usr/local/bin",
|
|
||||||
"/usr/local/cuda/bin",
|
|
||||||
"/usr/lib/wsl/lib"
|
|
||||||
)
|
|
||||||
# Fallback locations for nvidia-smi on WSL and other Linux distros where it may not be on PATH.
|
|
||||||
NVIDIA_PATH_CANDIDATES = (
|
|
||||||
"/usr/bin/nvidia-smi",
|
|
||||||
"/usr/local/bin/nvidia-smi",
|
|
||||||
"/usr/local/cuda/bin/nvidia-smi",
|
|
||||||
"/usr/lib/wsl/lib/nvidia-smi",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _ssh_path_override() -> str:
|
|
||||||
"""Build the PATH export snippet used for remote SSH shell probes."""
|
|
||||||
return f"export PATH=\"$PATH:{':'.join(_SSH_PATH_MEMBERS)}\"; "
|
|
||||||
|
|
||||||
|
|
||||||
SSH_PATH_OVERRIDE = _ssh_path_override()
|
|
||||||
|
|
||||||
|
|
||||||
def _windows_bash_fallbacks() -> List[str]:
|
def _windows_bash_fallbacks() -> List[str]:
|
||||||
roots: List[str] = []
|
roots: List[str] = []
|
||||||
@@ -191,8 +157,6 @@ def _windows_bash_fallbacks() -> List[str]:
|
|||||||
base = os.environ.get(env_name)
|
base = os.environ.get(env_name)
|
||||||
if base:
|
if base:
|
||||||
roots.append(ntpath.join(base, "Git"))
|
roots.append(ntpath.join(base, "Git"))
|
||||||
if env_name == "LocalAppData":
|
|
||||||
roots.append(ntpath.join(base, "Programs", "Git"))
|
|
||||||
roots.extend(_WINDOWS_BASH_DEFAULT_ROOTS)
|
roots.extend(_WINDOWS_BASH_DEFAULT_ROOTS)
|
||||||
|
|
||||||
paths: List[str] = []
|
paths: List[str] = []
|
||||||
@@ -216,21 +180,6 @@ def _is_windows_bash_stub(path: str) -> bool:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def git_bash_path(path: str | Path) -> str:
|
|
||||||
"""Convert a path to POSIX style suitable for Git Bash on Windows.
|
|
||||||
|
|
||||||
Transforms drive letters (e.g., 'C:\\path') to POSIX '/c/path',
|
|
||||||
and uses forward slashes.
|
|
||||||
"""
|
|
||||||
p = Path(path)
|
|
||||||
p_str = p.as_posix()
|
|
||||||
if IS_WINDOWS and len(p_str) >= 2 and p_str[1] == ":":
|
|
||||||
drive = p_str[0].lower()
|
|
||||||
return f"/{drive}{p_str[2:]}"
|
|
||||||
return p_str
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def find_bash() -> Optional[str]:
|
def find_bash() -> Optional[str]:
|
||||||
"""Locate a real ``bash`` interpreter, or None.
|
"""Locate a real ``bash`` interpreter, or None.
|
||||||
|
|
||||||
@@ -293,160 +242,3 @@ def run_script_argv(script_path) -> List[str]:
|
|||||||
comspec = os.environ.get("ComSpec", "cmd.exe")
|
comspec = os.environ.get("ComSpec", "cmd.exe")
|
||||||
return [comspec, "/c", str(script_path)]
|
return [comspec, "/c", str(script_path)]
|
||||||
return ["sh", str(script_path)]
|
return ["sh", str(script_path)]
|
||||||
|
|
||||||
|
|
||||||
def is_wsl() -> bool:
|
|
||||||
"""True if running inside Windows Subsystem for Linux (WSL)."""
|
|
||||||
import sys
|
|
||||||
if sys.platform.startswith("linux") or os.name == "posix":
|
|
||||||
try:
|
|
||||||
with open("/proc/version", "r", encoding="utf-8", errors="ignore") as f:
|
|
||||||
if "microsoft" in f.read().lower():
|
|
||||||
return True
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def translate_path(path_str: str) -> str:
|
|
||||||
"""Translate a path (possibly a Windows path) to the current OS format.
|
|
||||||
|
|
||||||
Particularly handles Windows paths (e.g. C:\\foo or C:/foo) when running
|
|
||||||
under WSL, translating them to /mnt/c/foo.
|
|
||||||
Also handles standard path normalization to avoid string breakages.
|
|
||||||
"""
|
|
||||||
if not path_str:
|
|
||||||
return path_str
|
|
||||||
|
|
||||||
if is_wsl():
|
|
||||||
path_str = path_str.replace("\\", "/")
|
|
||||||
import re
|
|
||||||
m = re.match(r"^([a-zA-Z]):(.*)", path_str)
|
|
||||||
if m:
|
|
||||||
drive = m.group(1).lower()
|
|
||||||
rest = m.group(2)
|
|
||||||
if not rest.startswith("/"):
|
|
||||||
rest = "/" + rest
|
|
||||||
return f"/mnt/{drive}{rest}"
|
|
||||||
|
|
||||||
try:
|
|
||||||
return str(Path(path_str).resolve())
|
|
||||||
except Exception:
|
|
||||||
return path_str
|
|
||||||
|
|
||||||
|
|
||||||
def get_wsl_windows_user_profile() -> Optional[str]:
|
|
||||||
"""Retrieve the Windows host User Profile path from inside WSL."""
|
|
||||||
if not is_wsl():
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
r = run_wsl_windows_powershell("Write-Output $env:USERPROFILE", timeout=5)
|
|
||||||
if r.returncode == 0 and r.stdout.strip():
|
|
||||||
return translate_path(r.stdout.strip())
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
try:
|
|
||||||
users_dir = "/mnt/c/Users"
|
|
||||||
if os.path.isdir(users_dir):
|
|
||||||
for entry in os.listdir(users_dir):
|
|
||||||
if entry not in ("All Users", "Default", "Default User", "desktop.ini", "Public"):
|
|
||||||
path = os.path.join(users_dir, entry)
|
|
||||||
if os.path.isdir(path):
|
|
||||||
return path
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _ssh_exec_argv(
|
|
||||||
remote: str,
|
|
||||||
ssh_port: str | None,
|
|
||||||
*,
|
|
||||||
remote_cmd: str | None = None,
|
|
||||||
connect_timeout: int | None = None,
|
|
||||||
strict_host_key_checking: bool | None = None,
|
|
||||||
) -> list[str]:
|
|
||||||
"""Build a consistent ssh argv for remote command execution."""
|
|
||||||
remote_value = str(remote or "").strip()
|
|
||||||
remote_host = remote_value.rsplit("@", 1)[-1]
|
|
||||||
if not remote_value or remote_value.startswith("-") or not remote_host or remote_host.startswith("-"):
|
|
||||||
raise ValueError("Invalid SSH remote host")
|
|
||||||
argv = ["ssh"]
|
|
||||||
if connect_timeout is not None:
|
|
||||||
argv.extend(["-o", f"ConnectTimeout={int(connect_timeout)}"])
|
|
||||||
if strict_host_key_checking is not None:
|
|
||||||
argv.extend(
|
|
||||||
[
|
|
||||||
"-o",
|
|
||||||
"StrictHostKeyChecking=yes"
|
|
||||||
if strict_host_key_checking
|
|
||||||
else "StrictHostKeyChecking=no",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
if ssh_port and ssh_port != "22":
|
|
||||||
argv.extend(["-p", str(ssh_port)])
|
|
||||||
argv.append(remote)
|
|
||||||
if remote_cmd is not None:
|
|
||||||
argv.append(remote_cmd)
|
|
||||||
return argv
|
|
||||||
|
|
||||||
|
|
||||||
def run_ssh_command(
|
|
||||||
remote: str,
|
|
||||||
ssh_port: str | None,
|
|
||||||
remote_cmd: str,
|
|
||||||
*,
|
|
||||||
timeout: float,
|
|
||||||
connect_timeout: int | None = None,
|
|
||||||
strict_host_key_checking: bool | None = None,
|
|
||||||
text: bool = True,
|
|
||||||
) -> subprocess.CompletedProcess:
|
|
||||||
"""Run an ssh command with centralized timeout and stderr/stdout capture."""
|
|
||||||
return subprocess.run(
|
|
||||||
_ssh_exec_argv(
|
|
||||||
remote,
|
|
||||||
ssh_port,
|
|
||||||
remote_cmd=remote_cmd,
|
|
||||||
connect_timeout=connect_timeout,
|
|
||||||
strict_host_key_checking=strict_host_key_checking,
|
|
||||||
),
|
|
||||||
timeout=timeout,
|
|
||||||
capture_output=True,
|
|
||||||
text=text,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _windows_powershell_argv(
|
|
||||||
command: str,
|
|
||||||
*,
|
|
||||||
no_profile: bool = True,
|
|
||||||
non_interactive: bool = True,
|
|
||||||
) -> List[str]:
|
|
||||||
argv: List[str] = ["powershell.exe"]
|
|
||||||
if no_profile:
|
|
||||||
argv.append("-NoProfile")
|
|
||||||
if non_interactive:
|
|
||||||
argv.append("-NonInteractive")
|
|
||||||
argv.extend(["-Command", command])
|
|
||||||
return argv
|
|
||||||
|
|
||||||
|
|
||||||
def run_wsl_windows_powershell(
|
|
||||||
command: str,
|
|
||||||
*,
|
|
||||||
timeout: float = 5,
|
|
||||||
) -> subprocess.CompletedProcess[str]:
|
|
||||||
"""Run a PowerShell command on the Windows host from WSL.
|
|
||||||
|
|
||||||
Raises ``RuntimeError`` when called outside WSL.
|
|
||||||
"""
|
|
||||||
|
|
||||||
if not is_wsl():
|
|
||||||
raise RuntimeError("run_wsl_windows_powershell is only supported in WSL")
|
|
||||||
return subprocess.run(
|
|
||||||
_windows_powershell_argv(command),
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=timeout,
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -14,13 +14,8 @@ import logging
|
|||||||
from datetime import datetime, timezone, timedelta
|
from datetime import datetime, timezone, timedelta
|
||||||
from typing import Dict, Optional
|
from typing import Dict, Optional
|
||||||
|
|
||||||
from .database import Session as DbSession, ChatMessage as DbChatMessage, Document as DbDocument, SessionLocal, utcnow_naive
|
from .database import Session as DbSession, ChatMessage as DbChatMessage, Document as DbDocument, SessionLocal
|
||||||
from .models import Session, ChatMessage
|
from .models import Session, ChatMessage
|
||||||
from src.attachment_refs import persistable_message_content
|
|
||||||
from src.upload_handler import reserve_message_upload_references
|
|
||||||
|
|
||||||
# Re-export singleton accessors from models for convenience
|
|
||||||
from .models import set_session_manager_instance, get_session_manager_instance
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -42,18 +37,7 @@ def _parse_msg_content(raw):
|
|||||||
if isinstance(raw, str) and raw.startswith('[{') and '"type"' in raw:
|
if isinstance(raw, str) and raw.startswith('[{') and '"type"' in raw:
|
||||||
try:
|
try:
|
||||||
parsed = json.loads(raw)
|
parsed = json.loads(raw)
|
||||||
# Only treat as serialized multimodal content when EVERY element is
|
if isinstance(parsed, list) and all(isinstance(p, dict) for p in parsed):
|
||||||
# a dict whose "type" is a recognized content-block kind. Otherwise a
|
|
||||||
# plain text message that merely *looks* like a JSON array of objects
|
|
||||||
# (e.g. a user pasting an API schema/sample with a "type" field) was
|
|
||||||
# silently parsed back into a list, destroying the original string.
|
|
||||||
_BLOCK_TYPES = {
|
|
||||||
"text", "image", "image_url", "audio", "input_audio",
|
|
||||||
"input_image", "document", "file",
|
|
||||||
}
|
|
||||||
if (isinstance(parsed, list) and parsed
|
|
||||||
and all(isinstance(p, dict) and p.get("type") in _BLOCK_TYPES
|
|
||||||
for p in parsed)):
|
|
||||||
return parsed
|
return parsed
|
||||||
except (json.JSONDecodeError, ValueError):
|
except (json.JSONDecodeError, ValueError):
|
||||||
pass
|
pass
|
||||||
@@ -74,7 +58,6 @@ class SessionManager:
|
|||||||
def __init__(self, sessions_file: str = None):
|
def __init__(self, sessions_file: str = None):
|
||||||
# sessions_file kept for backward compat, not used
|
# sessions_file kept for backward compat, not used
|
||||||
self.sessions: Dict[str, Session] = {}
|
self.sessions: Dict[str, Session] = {}
|
||||||
self.upload_handler = None
|
|
||||||
self.load_sessions()
|
self.load_sessions()
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -205,17 +188,12 @@ class SessionManager:
|
|||||||
"""
|
"""
|
||||||
Add a message to a session and persist to database.
|
Add a message to a session and persist to database.
|
||||||
|
|
||||||
Updates the authoritative history list and persists through this
|
|
||||||
manager directly so tests and temporary managers do not depend on the
|
|
||||||
process-wide session-manager singleton.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
session_id: Session ID
|
session_id: Session ID
|
||||||
message: ChatMessage to add
|
message: ChatMessage to add
|
||||||
"""
|
"""
|
||||||
session = self.get_session(session_id)
|
session = self.get_session(session_id)
|
||||||
session.history.append(message)
|
session.history.append(message)
|
||||||
session._history = session.history
|
|
||||||
session.message_count = len(session.history)
|
session.message_count = len(session.history)
|
||||||
|
|
||||||
self._persist_message(session_id, message)
|
self._persist_message(session_id, message)
|
||||||
@@ -233,26 +211,17 @@ class SessionManager:
|
|||||||
logger.warning("Dropping message for deleted session %s", session_id)
|
logger.warning("Dropping message for deleted session %s", session_id)
|
||||||
return
|
return
|
||||||
|
|
||||||
missing_upload_id = reserve_message_upload_references(
|
|
||||||
getattr(self, "upload_handler", None),
|
|
||||||
getattr(db_session, "owner", None),
|
|
||||||
message.content,
|
|
||||||
message.metadata,
|
|
||||||
)
|
|
||||||
if missing_upload_id:
|
|
||||||
raise ValueError(
|
|
||||||
f"Referenced upload is no longer available: {missing_upload_id}"
|
|
||||||
)
|
|
||||||
|
|
||||||
msg_id = str(uuid.uuid4())
|
msg_id = str(uuid.uuid4())
|
||||||
msg_time = datetime.utcnow()
|
msg_time = datetime.utcnow()
|
||||||
if message.metadata is None:
|
if message.metadata is None:
|
||||||
message.metadata = {}
|
message.metadata = {}
|
||||||
message.metadata.setdefault('timestamp', _message_timestamp_iso(msg_time))
|
message.metadata.setdefault('timestamp', _message_timestamp_iso(msg_time))
|
||||||
# Multimodal content may contain provider data URLs for the live
|
# Multimodal content (image/audio attachments) is a list — serialize
|
||||||
# model call. Persist only readable text plus attachment references
|
# to JSON so the Text column can store it. On reload, _db_to_session
|
||||||
# so chat_messages/FTS do not duplicate upload bytes.
|
# detects the JSON-array prefix and parses it back.
|
||||||
_content = persistable_message_content(message.content, message.metadata)
|
_content = message.content
|
||||||
|
if isinstance(_content, list):
|
||||||
|
_content = json.dumps(_content)
|
||||||
db_message = DbChatMessage(
|
db_message = DbChatMessage(
|
||||||
id=msg_id,
|
id=msg_id,
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
@@ -263,10 +232,7 @@ class SessionManager:
|
|||||||
)
|
)
|
||||||
db.add(db_message)
|
db.add(db_message)
|
||||||
|
|
||||||
if session_id in self.sessions:
|
db_session.message_count = len(self.sessions.get(session_id, {}).history) if session_id in self.sessions else 0
|
||||||
db_session.message_count = len(self.sessions[session_id].history)
|
|
||||||
else:
|
|
||||||
db_session.message_count = 0
|
|
||||||
_now = datetime.now(timezone.utc)
|
_now = datetime.now(timezone.utc)
|
||||||
db_session.last_accessed = _now
|
db_session.last_accessed = _now
|
||||||
# Clean "last conversation" timestamp — only bumped here on a
|
# Clean "last conversation" timestamp — only bumped here on a
|
||||||
@@ -307,17 +273,13 @@ class SessionManager:
|
|||||||
|
|
||||||
db_session = db.query(DbSession).filter(DbSession.id == session_id).first()
|
db_session = db.query(DbSession).filter(DbSession.id == session_id).first()
|
||||||
if db_session:
|
if db_session:
|
||||||
# keep_count can exceed the real message total (e.g. the AI tool
|
db_session.message_count = keep_count
|
||||||
# defaults to keep_count=10 on a short session); message_count must
|
|
||||||
# track the rows that actually remain, not the requested cap.
|
|
||||||
db_session.message_count = min(keep_count, len(db_messages))
|
|
||||||
db_session.updated_at = datetime.now(timezone.utc)
|
db_session.updated_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
# Update in-memory
|
# Update in-memory
|
||||||
session.history = session.history[:keep_count]
|
session.history = session.history[:keep_count]
|
||||||
session._history = session.history
|
|
||||||
|
|
||||||
logger.info(f"Truncated session {session_id} to {keep_count} messages")
|
logger.info(f"Truncated session {session_id} to {keep_count} messages")
|
||||||
return True
|
return True
|
||||||
@@ -334,28 +296,6 @@ class SessionManager:
|
|||||||
session = self.get_session(session_id)
|
session = self.get_session(session_id)
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
db_session = db.query(DbSession).filter(DbSession.id == session_id).first()
|
|
||||||
if db_session is None:
|
|
||||||
logger.warning("Cannot replace history for missing session %s", session_id)
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Reserve every incoming attachment before removing any durable
|
|
||||||
# message row. reserve_upload() shares the upload lifecycle lock
|
|
||||||
# with cleanup, so an upload cannot be deleted between this
|
|
||||||
# ownership check/access touch and the replacement transaction.
|
|
||||||
# A failed reservation must leave the existing transcript intact.
|
|
||||||
for message in messages:
|
|
||||||
missing_upload_id = reserve_message_upload_references(
|
|
||||||
getattr(self, "upload_handler", None),
|
|
||||||
getattr(db_session, "owner", None),
|
|
||||||
message.content,
|
|
||||||
message.metadata,
|
|
||||||
)
|
|
||||||
if missing_upload_id:
|
|
||||||
raise ValueError(
|
|
||||||
f"Referenced upload is no longer available: {missing_upload_id}"
|
|
||||||
)
|
|
||||||
|
|
||||||
db.query(DbChatMessage).filter(DbChatMessage.session_id == session_id).delete()
|
db.query(DbChatMessage).filter(DbChatMessage.session_id == session_id).delete()
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
for i, message in enumerate(messages):
|
for i, message in enumerate(messages):
|
||||||
@@ -364,9 +304,15 @@ class SessionManager:
|
|||||||
id=msg_id,
|
id=msg_id,
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
role=message.role,
|
role=message.role,
|
||||||
# Mirrors _persist_message: keep raw media bytes out of the
|
# Multimodal content (image/audio attachments) is a list;
|
||||||
# persisted transcript and search index.
|
# serialize to JSON so the Text column round-trips via
|
||||||
content=persistable_message_content(message.content, message.metadata),
|
# _parse_msg_content. Storing the raw list let SQLAlchemy
|
||||||
|
# bind its single-quoted repr, which _parse_msg_content
|
||||||
|
# cannot parse (it looks for double-quoted "type"), so the
|
||||||
|
# attachment was destroyed on reload. Mirrors _persist_message.
|
||||||
|
content=(json.dumps(message.content)
|
||||||
|
if isinstance(message.content, list)
|
||||||
|
else message.content),
|
||||||
meta_data=json.dumps(message.metadata) if message.metadata else None,
|
meta_data=json.dumps(message.metadata) if message.metadata else None,
|
||||||
timestamp=now + timedelta(microseconds=i),
|
timestamp=now + timedelta(microseconds=i),
|
||||||
)
|
)
|
||||||
@@ -375,14 +321,15 @@ class SessionManager:
|
|||||||
message.metadata = {}
|
message.metadata = {}
|
||||||
message.metadata["_db_id"] = msg_id
|
message.metadata["_db_id"] = msg_id
|
||||||
|
|
||||||
db_session.message_count = len(messages)
|
db_session = db.query(DbSession).filter(DbSession.id == session_id).first()
|
||||||
db_session.updated_at = now
|
if db_session:
|
||||||
db_session.last_accessed = now
|
db_session.message_count = len(messages)
|
||||||
db_session.last_message_at = now
|
db_session.updated_at = now
|
||||||
|
db_session.last_accessed = now
|
||||||
|
db_session.last_message_at = now
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
session.history = list(messages)
|
session.history = list(messages)
|
||||||
session._history = session.history
|
|
||||||
session.message_count = len(messages)
|
session.message_count = len(messages)
|
||||||
logger.info("Replaced session %s history with %d messages", session_id, len(messages))
|
logger.info("Replaced session %s history with %d messages", session_id, len(messages))
|
||||||
return True
|
return True
|
||||||
@@ -543,12 +490,6 @@ class SessionManager:
|
|||||||
"""Permanently delete a session and all its messages."""
|
"""Permanently delete a session and all its messages."""
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
try:
|
|
||||||
from src.session_image_cleanup import cleanup_session_images
|
|
||||||
cleanup_session_images(session_id, db=db)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Image cleanup failed while deleting session {session_id}: {e}")
|
|
||||||
|
|
||||||
# Detach documents so they survive as orphans in the library
|
# Detach documents so they survive as orphans in the library
|
||||||
db.query(DbDocument).filter(DbDocument.session_id == session_id).update(
|
db.query(DbDocument).filter(DbDocument.session_id == session_id).update(
|
||||||
{DbDocument.session_id: None}, synchronize_session=False
|
{DbDocument.session_id: None}, synchronize_session=False
|
||||||
@@ -664,52 +605,24 @@ class SessionManager:
|
|||||||
def save_sessions(self):
|
def save_sessions(self):
|
||||||
"""No-op for DB compatibility."""
|
"""No-op for DB compatibility."""
|
||||||
|
|
||||||
def ensure_task_session(self, session_id: str, name: str, endpoint_url: str, model: str, owner: str = None, task: object = None) -> Session:
|
|
||||||
"""Create a task session if it doesn't exist, or return the existing one.
|
|
||||||
|
|
||||||
Unlike create_session, this checks the cache first and does NOT
|
|
||||||
overwrite an existing in-memory session. The task scheduler must
|
|
||||||
use this instead of direct dict assignment.
|
|
||||||
"""
|
|
||||||
if session_id in self.sessions:
|
|
||||||
return self.sessions[session_id]
|
|
||||||
|
|
||||||
session = self.create_session(session_id, name, endpoint_url, model, owner=owner)
|
|
||||||
if task is not None:
|
|
||||||
task.session_id = session_id
|
|
||||||
return session
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Cleanup
|
# Cleanup
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def cleanup_empty_sessions(self, auto_archive_days: int = 30, min_age_hours: int = 1) -> dict:
|
def cleanup_empty_sessions(self, auto_archive_days: int = 30) -> dict:
|
||||||
"""Clean up empty and old sessions.
|
"""Clean up empty and old sessions."""
|
||||||
|
|
||||||
Args:
|
|
||||||
auto_archive_days: Age in days before non-important sessions are archived.
|
|
||||||
min_age_hours: Minimum age in hours before an empty session can be deleted.
|
|
||||||
Prevents deleting sessions that were just created.
|
|
||||||
"""
|
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
stats = {'deleted_empty': 0, 'archived_old': 0, 'total_checked': 0}
|
stats = {'deleted_empty': 0, 'archived_old': 0, 'total_checked': 0}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
all_sessions = db.query(DbSession).all()
|
all_sessions = db.query(DbSession).all()
|
||||||
cutoff_date = utcnow_naive() - timedelta(days=auto_archive_days)
|
cutoff_date = datetime.now(timezone.utc) - timedelta(days=auto_archive_days)
|
||||||
min_age = utcnow_naive() - timedelta(hours=min_age_hours)
|
|
||||||
|
|
||||||
for db_session in all_sessions:
|
for db_session in all_sessions:
|
||||||
stats['total_checked'] += 1
|
stats['total_checked'] += 1
|
||||||
|
|
||||||
# Delete empty sessions only if older than min_age_hours
|
# Delete empty sessions
|
||||||
if db_session.message_count == 0:
|
if db_session.message_count == 0:
|
||||||
if db_session.created_at is not None:
|
|
||||||
created = db_session.created_at
|
|
||||||
if created.tzinfo is None:
|
|
||||||
created = created.replace(tzinfo=timezone.utc)
|
|
||||||
if created > min_age:
|
|
||||||
continue # Too young to delete
|
|
||||||
if db_session.id in self.sessions:
|
if db_session.id in self.sessions:
|
||||||
del self.sessions[db_session.id]
|
del self.sessions[db_session.id]
|
||||||
db.delete(db_session)
|
db.delete(db_session)
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
# Odysseus discovery maps
|
|
||||||
|
|
||||||
Compact, code-grounded discovery maps of cross-cutting systems in the checked-in Odysseus codebase. They preserve investigation context and open factual questions; they are not canonical subsystem specifications, a feature certification, or a substitute for normal testing.
|
|
||||||
|
|
||||||
> [!IMPORTANT]
|
|
||||||
> Checked-in code is the source of truth for current behaviour. Mature subsystem specifications, where they exist, are the canonical documentation of accepted subsystem behaviour. Check code, tests, and configuration before reconciling a discovery finding. Discovery remains non-canonical.
|
|
||||||
|
|
||||||
## Explore the maps
|
|
||||||
|
|
||||||
| Document | Purpose |
|
|
||||||
|---|---|
|
|
||||||
| [Current system map](system-map.md) | Records evidence locations, confirmed local observations, and factual open questions about subsystem boundaries. |
|
|
||||||
| [Safety boundaries](safety-boundaries.md) | Records evidence about broad authority, safeguards, confirmed risks or gaps, and unverified behaviour. |
|
|
||||||
|
|
||||||
## Working rules
|
|
||||||
|
|
||||||
- **Trace the code first.** Confirm the current path in source before recording a claim.
|
|
||||||
- **Promote selectively.** When an owning mature specification exists, add a fact only when it is verified, useful, and not already represented there.
|
|
||||||
- **Record missing ownership.** When no owning specification exists, retain the verified finding in discovery and record missing documentation ownership as a follow-up.
|
|
||||||
- **Retain uncertainty here.** Keep unresolved questions and useful investigation context in discovery rather than treating them as canonical truth.
|
|
||||||
- **Keep specifications current-state only.** Do not record intentions, design direction, refactor plans, decision history, priority, ownership, or sequencing here.
|
|
||||||
- **Investigate with cause.** Do not exhaustively revalidate existing functionality without a report, visible failure, relevant change, or high-authority review need.
|
|
||||||
- **Review authority carefully.** Give execution, data access, external tools, credentials, destructive operations, and unattended work focused review.
|
|
||||||
- **Use stable locations.** Cite modules, routes, classes, and functions instead of fragile line ranges or generated evidence tables.
|
|
||||||
|
|
||||||
## Reconciliation flow
|
|
||||||
|
|
||||||
1. Start with the relevant map and trace the cited code.
|
|
||||||
2. Classify the finding against current source evidence and an owning mature specification where one exists.
|
|
||||||
3. Promote only verified, useful facts that are missing from an existing owning specification.
|
|
||||||
4. When no owning specification exists, retain the verified finding here and record missing documentation ownership as a follow-up; otherwise retain unresolved context here and correct stale wording.
|
|
||||||
|
|
||||||
> [!NOTE]
|
|
||||||
> This package intentionally contains no generator, validator, maturity scale, feature database, or parallel work tracker. The [architecture runtime inventory](./architecture-runtime-inventory.md) preserves dated structural metrics, investigation context, and historical planning as an explicitly non-canonical snapshot.
|
|
||||||
@@ -1,331 +0,0 @@
|
|||||||
# Architecture runtime inventory
|
|
||||||
|
|
||||||
> [!WARNING]
|
|
||||||
> This document is a dated structural snapshot, not a canonical runtime specification.
|
|
||||||
> Counts, paths, and implementation details may drift as the repository changes.
|
|
||||||
> Verify implementation-sensitive claims against the current code and tests.
|
|
||||||
|
|
||||||
- **Branch:** `discovery`
|
|
||||||
- **Commit:** `c762efe1c97a`
|
|
||||||
- **Generated:** `2026-07-28T05:38:14+01:00`
|
|
||||||
- **Historical context:** readability and refactor planning in [#4071](https://github.com/odysseus-dev/odysseus/issues/4071) and [#4082](https://github.com/odysseus-dev/odysseus/issues/4082)
|
|
||||||
|
|
||||||
## Disposition
|
|
||||||
|
|
||||||
> [!NOTE]
|
|
||||||
> Reviewed for documentation classification. Stable runtime structure and
|
|
||||||
> subsystem ownership have been transferred to the proposed canonical
|
|
||||||
> destination, [`docs/ARCHITECTURE.md`](../docs/ARCHITECTURE.md), for
|
|
||||||
> maintainer review.
|
|
||||||
>
|
|
||||||
> This document retains dated metrics, rankings, investigation context,
|
|
||||||
> refactor-sensitive observations, and historical planning. Those contents are
|
|
||||||
> non-canonical and belong under `discovery/`.
|
|
||||||
|
|
||||||
| Content | Authority and destination |
|
|
||||||
|---|---|
|
|
||||||
| Stable runtime structure | Proposed canonical destination: `docs/ARCHITECTURE.md` |
|
|
||||||
| Stable subsystem boundaries | Proposed canonical destination: `docs/ARCHITECTURE.md` |
|
|
||||||
| Frontend module organization | `static/js/MODULE_SUMMARY.md` |
|
|
||||||
| Counts, line totals, and rankings | This non-canonical inventory |
|
|
||||||
| Investigation context and open questions | `discovery/` |
|
|
||||||
| Refactor options and prioritization | Issues, Plane, or non-canonical discovery material |
|
|
||||||
|
|
||||||
The transfer preserves the stable facts without promoting generated metrics or
|
|
||||||
historical prioritization into canonical documentation.
|
|
||||||
|
|
||||||
## Purpose
|
|
||||||
|
|
||||||
This inventory provides a reviewable map of the current repository structure,
|
|
||||||
large runtime modules, major subsystem boundaries, and refactor-sensitive areas.
|
|
||||||
|
|
||||||
It does not:
|
|
||||||
|
|
||||||
- define accepted subsystem behaviour;
|
|
||||||
- certify runtime correctness;
|
|
||||||
- prescribe a committed refactor sequence;
|
|
||||||
- replace focused specifications, tests, or source review.
|
|
||||||
|
|
||||||
For cross-cutting implementation evidence, see the [discovery maps](./README.md).
|
|
||||||
|
|
||||||
## Top-level runtime structure
|
|
||||||
|
|
||||||
| Area | Role |
|
|
||||||
|---|---|
|
|
||||||
| `app.py` | FastAPI application composition and entry point |
|
|
||||||
| `launcher.py` | Application launch support |
|
|
||||||
| `setup.py` | Native setup workflow |
|
|
||||||
| `core/` | Authentication, middleware, persistence, sessions, and platform primitives |
|
|
||||||
| `routes/` | HTTP and API route handlers |
|
|
||||||
| `src/` | Application services, orchestration, tools, providers, and runtime helpers |
|
|
||||||
| `services/` | Domain-oriented service packages |
|
|
||||||
| `mcp_servers/` | Built-in MCP server implementations |
|
|
||||||
| `scripts/` | CLI tools, diagnostics, maintenance, and migration helpers |
|
|
||||||
| `static/` | No-build browser frontend and bundled assets |
|
|
||||||
| `tests/` | Automated test suite and supporting test infrastructure |
|
|
||||||
|
|
||||||
## Directory snapshot
|
|
||||||
|
|
||||||
| Directory | Tracked files | Tracked Python files | Direct subdirectories |
|
|
||||||
|---|---:|---:|---|
|
|
||||||
| `src/` | 143 | 143 | `agent_tools/`, `model_capability_readers/`, `search/`, `tools/` |
|
|
||||||
| `routes/` | 73 | 73 | `admin_wipe/`, `cleanup/`, `compare/`, `contacts/`, `gallery/`, `history/`, `memory/`, `note/`, `research/` |
|
|
||||||
| `core/` | 11 | 11 | None |
|
|
||||||
| `services/` | 42 | 40 | `docs/`, `faces/`, `hwfit/`, `memory/`, `research/`, `search/`, `shell/`, `stt/`, `tts/`, `youtube/` |
|
|
||||||
| `mcp_servers/` | 5 | 5 | None |
|
|
||||||
| `scripts/` | 44 | 17 | `_completion/`, `_lib/`, `demo_email/` |
|
|
||||||
| `static/js/` | 154 | 0 | `calendar/`, `color/`, `compare/`, `editor/`, `emailLibrary/`, `markdown/`, `model/`, `research/`, `util/` |
|
|
||||||
| `tests/` | 768 | 758 | `cli/`, `helpers/`, `streaming/`, `tools/` |
|
|
||||||
|
|
||||||
> [!NOTE]
|
|
||||||
> Counts in this table use `git ls-files`, so generated caches, virtual
|
|
||||||
> environments, and other untracked local files are excluded.
|
|
||||||
|
|
||||||
## Largest backend modules
|
|
||||||
|
|
||||||
Large files are review signals, not proof that a module should be split.
|
|
||||||
Coupling, ownership, import compatibility, tests, and runtime authority matter more
|
|
||||||
than line count alone.
|
|
||||||
|
|
||||||
| Rank | File | Lines | Classes | Top-level functions | Review signal |
|
|
||||||
|---:|---|---:|---:|---:|---|
|
|
||||||
| 1 | `routes/email_routes.py` | 6032 | 1 | 58 | High |
|
|
||||||
| 2 | `src/agent_loop.py` | 5248 | 0 | 63 | High |
|
|
||||||
| 3 | `routes/cookbook_routes.py` | 4545 | 0 | 16 | High |
|
|
||||||
| 4 | `mcp_servers/email_server.py` | 2920 | 0 | 77 | High |
|
|
||||||
| 5 | `src/llm_core.py` | 2895 | 3 | 85 | High |
|
|
||||||
| 6 | `src/builtin_actions.py` | 2845 | 2 | 27 | High |
|
|
||||||
| 7 | `routes/model_routes.py` | 2743 | 0 | 65 | High |
|
|
||||||
| 8 | `src/task_scheduler.py` | 2627 | 1 | 8 | Medium |
|
|
||||||
| 9 | `core/database.py` | 2562 | 28 | 67 | High |
|
|
||||||
| 10 | `routes/gallery/gallery_routes.py` | 2325 | 0 | 16 | Medium |
|
|
||||||
| 11 | `routes/chat_routes.py` | 2063 | 0 | 18 | Medium |
|
|
||||||
| 12 | `routes/shell_routes.py` | 1971 | 1 | 21 | Medium |
|
|
||||||
| 13 | `src/visual_report.py` | 1933 | 0 | 11 | Medium |
|
|
||||||
| 14 | `routes/email_helpers.py` | 1888 | 3 | 48 | Medium |
|
|
||||||
| 15 | `routes/document_routes.py` | 1810 | 0 | 5 | Medium |
|
|
||||||
| 16 | `src/tools/cookbook.py` | 1705 | 0 | 34 | Medium |
|
|
||||||
| 17 | `routes/calendar_routes.py` | 1667 | 2 | 19 | Medium |
|
|
||||||
| 18 | `routes/skills_routes.py` | 1662 | 3 | 19 | Medium |
|
|
||||||
| 19 | `src/tool_schemas.py` | 1595 | 0 | 3 | Medium |
|
|
||||||
| 20 | `routes/email_pollers.py` | 1551 | 0 | 23 | Medium |
|
|
||||||
|
|
||||||
The largest current backend concentrations include:
|
|
||||||
|
|
||||||
- email routing and helper logic;
|
|
||||||
- agent-loop orchestration;
|
|
||||||
- Cookbook lifecycle and serving logic;
|
|
||||||
- provider and model routing;
|
|
||||||
- task scheduling;
|
|
||||||
- shared database models and persistence helpers.
|
|
||||||
|
|
||||||
These areas require focused ownership and compatibility analysis before structural
|
|
||||||
changes are attempted.
|
|
||||||
|
|
||||||
## Largest frontend modules
|
|
||||||
|
|
||||||
| Rank | File | Lines |
|
|
||||||
|---:|---|---:|
|
|
||||||
| 1 | `static/style.css` | 41132 |
|
|
||||||
| 2 | `static/js/document.js` | 11200 |
|
|
||||||
| 3 | `static/js/emailLibrary.js` | 8505 |
|
|
||||||
| 4 | `static/js/slashCommands.js` | 6520 |
|
|
||||||
| 5 | `static/js/chat.js` | 6001 |
|
|
||||||
| 6 | `static/js/settings.js` | 5819 |
|
|
||||||
| 7 | `static/js/notes.js` | 5365 |
|
|
||||||
| 8 | `static/app.js` | 4681 |
|
|
||||||
| 9 | `static/js/cookbookRunning.js` | 4433 |
|
|
||||||
| 10 | `static/js/galleryEditor.js` | 4386 |
|
|
||||||
| 11 | `static/js/cookbookServe.js` | 4305 |
|
|
||||||
| 12 | `static/js/calendar.js` | 3722 |
|
|
||||||
| 13 | `static/js/cookbook.js` | 3677 |
|
|
||||||
| 14 | `static/js/sessions.js` | 3665 |
|
|
||||||
| 15 | `static/js/documentLibrary.js` | 3422 |
|
|
||||||
| 16 | `static/js/tasks.js` | 3187 |
|
|
||||||
| 17 | `static/js/admin.js` | 3144 |
|
|
||||||
| 18 | `static/js/gallery.js` | 2958 |
|
|
||||||
| 19 | `static/js/cookbook-hwfit.js` | 2826 |
|
|
||||||
| 20 | `static/js/chatRenderer.js` | 2808 |
|
|
||||||
|
|
||||||
The browser frontend remains a no-build ES-module application. Its current source
|
|
||||||
tree is authoritative; the maintained structural summary is available in
|
|
||||||
[`static/js/MODULE_SUMMARY.md`](../static/js/MODULE_SUMMARY.md).
|
|
||||||
|
|
||||||
CSS modularization remains tracked separately in
|
|
||||||
[#2617](https://github.com/odysseus-dev/odysseus/issues/2617).
|
|
||||||
|
|
||||||
## Major subsystem boundaries
|
|
||||||
|
|
||||||
| Subsystem | Primary implementation locations |
|
|
||||||
|---|---|
|
|
||||||
| Application startup | `app.py`, `src/app_initializer.py`, `core/` |
|
|
||||||
| Authentication and sessions | `core/auth.py`, `core/middleware.py`, `core/session_manager.py`, `routes/auth_routes.py` |
|
|
||||||
| Chat and streaming | `routes/chat_routes.py`, `routes/chat_helpers.py`, `src/chat_handler.py`, `src/chat_processor.py`, `src/llm_core.py` |
|
|
||||||
| Agents and tools | `src/agent_loop.py`, `src/tool_execution.py`, `src/agent_tools/`, `src/tools/`, `src/tool_policy.py`, `src/tool_security.py` |
|
|
||||||
| Models and providers | `routes/model_routes.py`, `src/model_discovery.py`, `src/model_capabilities.py`, `src/endpoint_resolver.py`, `src/llm_core.py` |
|
|
||||||
| Cookbook and hardware fit | `routes/cookbook_routes.py`, `routes/cookbook_helpers.py`, `src/cookbook_serve_lifecycle.py`, `services/hwfit/` |
|
|
||||||
| Search and research | `routes/search_routes.py`, `services/search/`, `routes/research/`, `services/research/`, `src/deep_research.py` |
|
|
||||||
| Documents and retrieval | `routes/document_routes.py`, `src/document_processor.py`, `src/personal_docs.py`, `src/rag_manager.py`, `src/pdf_runtime.py` |
|
|
||||||
| Memory and skills | `routes/memory/`, `services/memory/`, `routes/skills_routes.py` |
|
|
||||||
| Email | `routes/email_routes.py`, `routes/email_helpers.py`, `routes/email_pollers.py`, `mcp_servers/email_server.py` |
|
|
||||||
| Calendar, contacts, notes, and tasks | `routes/calendar_routes.py`, `routes/contacts/`, `routes/note/`, `routes/task_routes.py`, `src/task_scheduler.py` |
|
|
||||||
| Media and speech | `routes/gallery/`, `routes/stt_routes.py`, `routes/tts_routes.py`, `services/stt/`, `services/tts/` |
|
|
||||||
| Persistence and operations | `core/database.py`, `src/runtime_paths.py`, `src/bg_jobs.py`, `routes/backup_routes.py`, `routes/cleanup/` |
|
|
||||||
|
|
||||||
For a broader evidence map, see
|
|
||||||
[`system-map.md`](./system-map.md).
|
|
||||||
|
|
||||||
## Refactor-sensitive areas
|
|
||||||
|
|
||||||
### Shared persistence
|
|
||||||
|
|
||||||
`core/database.py` is a central dependency containing models and shared persistence
|
|
||||||
helpers. Changes can affect routes, services, background work, tests, migrations,
|
|
||||||
and import compatibility.
|
|
||||||
|
|
||||||
A split should not begin from file size alone. It requires:
|
|
||||||
|
|
||||||
- an importer inventory;
|
|
||||||
- model and helper ownership decisions;
|
|
||||||
- migration compatibility checks;
|
|
||||||
- stable re-export or migration strategy;
|
|
||||||
- focused and full-suite validation.
|
|
||||||
|
|
||||||
### Agent orchestration
|
|
||||||
|
|
||||||
`src/agent_loop.py` coordinates model interaction, tool selection, policy decisions,
|
|
||||||
multi-round execution, and background behaviour. Extraction work must preserve tool
|
|
||||||
event semantics, policy enforcement, cancellation, and test patch points.
|
|
||||||
|
|
||||||
Historical agent-loop modularization discussion is tracked in
|
|
||||||
[#3266](https://github.com/odysseus-dev/odysseus/issues/3266).
|
|
||||||
|
|
||||||
### Tool implementation boundaries
|
|
||||||
|
|
||||||
Tool implementation is no longer represented by one proposed future package alone.
|
|
||||||
Current responsibilities are distributed across:
|
|
||||||
|
|
||||||
- `src/tool_execution.py`;
|
|
||||||
- `src/tool_schemas.py`;
|
|
||||||
- `src/tool_index.py`;
|
|
||||||
- `src/tool_policy.py`;
|
|
||||||
- `src/tool_security.py`;
|
|
||||||
- `src/agent_tools/`;
|
|
||||||
- `src/tools/`;
|
|
||||||
- remaining compatibility surfaces such as `src/tool_implementations.py`.
|
|
||||||
|
|
||||||
Historical tool modularization work is tracked in
|
|
||||||
[#3629](https://github.com/odysseus-dev/odysseus/issues/3629).
|
|
||||||
|
|
||||||
### Route ownership
|
|
||||||
|
|
||||||
`routes/` now contains both flat modules and domain packages. Existing package
|
|
||||||
boundaries should be extended only through focused changes. Broad mechanical route
|
|
||||||
movement would affect registration, imports, tests, monkeypatch targets, and
|
|
||||||
compatibility paths.
|
|
||||||
|
|
||||||
### Frontend concentration
|
|
||||||
|
|
||||||
The no-build frontend contains several large JavaScript modules and one central CSS
|
|
||||||
file. Refactors should preserve module load order, global compatibility exports,
|
|
||||||
DOM contracts, deep-link handling, and browser behaviour.
|
|
||||||
|
|
||||||
## Non-implemented architecture options
|
|
||||||
|
|
||||||
> [!NOTE]
|
|
||||||
> The paths below are historical or possible design directions. They do not describe
|
|
||||||
> the current repository and are not approved implementation plans.
|
|
||||||
|
|
||||||
Earlier planning discussed:
|
|
||||||
|
|
||||||
- renaming `app.py` to `main.py`;
|
|
||||||
- moving agent orchestration into a new `src/agent/` package;
|
|
||||||
- introducing broad `src/domain/`, `src/infra/`, `src/api/`, or `src/pkg/` layers;
|
|
||||||
- moving all routes into domain subpackages;
|
|
||||||
- splitting database models into a new infrastructure hierarchy.
|
|
||||||
|
|
||||||
These options should be reconsidered against the current tree rather than copied
|
|
||||||
forward as assumed targets.
|
|
||||||
|
|
||||||
## Refactor guardrails
|
|
||||||
|
|
||||||
- Keep structural changes behaviour-preserving.
|
|
||||||
- Change one ownership boundary at a time.
|
|
||||||
- Do not mix file movement with unrelated feature work.
|
|
||||||
- Preserve existing import and monkeypatch paths where compatibility is required.
|
|
||||||
- Identify focused tests before modifying high-authority modules.
|
|
||||||
- Validate startup, imports, and affected runtime paths.
|
|
||||||
- Avoid repository-wide package reorganizations without maintainer agreement.
|
|
||||||
- Treat generated metrics as snapshots, not architectural decisions.
|
|
||||||
|
|
||||||
## Reproduce the snapshot
|
|
||||||
|
|
||||||
Run these commands from the repository root.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Tracked directory totals
|
|
||||||
for dir in src routes core services mcp_servers scripts static/js tests; do
|
|
||||||
files="$(git ls-files "$dir" | wc -l)"
|
|
||||||
python_files="$(git ls-files "$dir" '*.py' | wc -l)"
|
|
||||||
|
|
||||||
printf '%-14s tracked=%-5s python=%-5s\n' \
|
|
||||||
"$dir" \
|
|
||||||
"$files" \
|
|
||||||
"$python_files"
|
|
||||||
done
|
|
||||||
|
|
||||||
# Largest tracked backend files
|
|
||||||
git ls-files \
|
|
||||||
'app.py' \
|
|
||||||
'launcher.py' \
|
|
||||||
'setup.py' \
|
|
||||||
'core/*.py' \
|
|
||||||
'core/**/*.py' \
|
|
||||||
'routes/*.py' \
|
|
||||||
'routes/**/*.py' \
|
|
||||||
'services/*.py' \
|
|
||||||
'services/**/*.py' \
|
|
||||||
'src/*.py' \
|
|
||||||
'src/**/*.py' \
|
|
||||||
'mcp_servers/*.py' \
|
|
||||||
'scripts/*.py' \
|
|
||||||
'scripts/**/*.py' |
|
|
||||||
xargs wc -l |
|
|
||||||
sort -nr |
|
|
||||||
head -31
|
|
||||||
|
|
||||||
# Largest tracked frontend source files
|
|
||||||
git ls-files \
|
|
||||||
'static/*.js' \
|
|
||||||
'static/*.css' \
|
|
||||||
'static/*.html' \
|
|
||||||
'static/**/*.js' \
|
|
||||||
'static/**/*.css' \
|
|
||||||
'static/**/*.html' |
|
|
||||||
grep -vE '\.min\.js$' |
|
|
||||||
xargs wc -l |
|
|
||||||
sort -nr |
|
|
||||||
head -31
|
|
||||||
```
|
|
||||||
|
|
||||||
## Validation for architecture changes
|
|
||||||
|
|
||||||
Use the smallest relevant checks first, then expand according to risk:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 -m compileall -q app.py core routes services src
|
|
||||||
venv/bin/python -m pytest tests/<focused-test-file>.py -q
|
|
||||||
venv/bin/python -m pytest -q
|
|
||||||
```
|
|
||||||
|
|
||||||
Startup, browser, Docker, and integration checks may also be required depending on
|
|
||||||
the affected boundary.
|
|
||||||
|
|
||||||
## Related documentation
|
|
||||||
|
|
||||||
- [Documentation style](../docs/STYLE.md)
|
|
||||||
- [Discovery maps](../discovery/README.md)
|
|
||||||
- [Current system map](../discovery/system-map.md)
|
|
||||||
- [Safety boundaries](../discovery/safety-boundaries.md)
|
|
||||||
- [Frontend module summary](../static/js/MODULE_SUMMARY.md)
|
|
||||||
- [Testing standard](../tests/TESTING_STANDARD.md)
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
# Safety boundaries
|
|
||||||
|
|
||||||
> [!IMPORTANT]
|
|
||||||
> This non-canonical discovery map records code-grounded safeguards, confirmed risks or gaps, and unverified behaviour. Broad authority does not by itself establish a vulnerability. Verify the cited source before relying on a finding. No destructive test, external connection, or real credential was used for this map.
|
|
||||||
|
|
||||||
## Navigate the boundaries
|
|
||||||
|
|
||||||
- [Shell and subprocess execution](#shell-and-subprocess-execution)
|
|
||||||
- [Filesystem access and workspace confinement](#filesystem-access-and-workspace-confinement)
|
|
||||||
- [Agent-controlled tool dispatch](#agent-controlled-tool-dispatch)
|
|
||||||
- [MCP and external tool servers](#mcp-and-external-tool-servers)
|
|
||||||
- [Outbound network requests and URL validation](#outbound-network-requests-and-url-validation)
|
|
||||||
- [Secrets, credentials, and vault sessions](#secrets-credentials-and-vault-sessions)
|
|
||||||
- [Authentication and privileged administration](#authentication-and-privileged-administration)
|
|
||||||
- [Deletion, wipe, backup, and restore](#deletion-wipe-backup-and-restore)
|
|
||||||
- [Background jobs and unattended task execution](#background-jobs-and-unattended-task-execution)
|
|
||||||
|
|
||||||
## Shell and subprocess execution
|
|
||||||
|
|
||||||
- **Boundary:** Shell routes, agent `bash` and `python` tools, local model serving, and detached background jobs.
|
|
||||||
|
|
||||||
- **Available authority:** Commands run as the application process user and can create child processes.
|
|
||||||
|
|
||||||
- **User-controlled inputs:** Direct shell requests, model-produced tool arguments, scheduled-task prompts, and model-serving configuration.
|
|
||||||
|
|
||||||
- **Current safeguards:** Agent dispatch applies owner/admin checks and tool policy; process helpers use timeouts or bounded background-job lifecycle where implemented.
|
|
||||||
|
|
||||||
- **Confirmed risks or gaps:** Intentional authority with a confirmed gap: the agent shell starts in its workspace but is not sandboxed to it, and has no egress sandbox. This is documented in source and the threat model; it is not a newly demonstrated bypass.
|
|
||||||
|
|
||||||
- **Unverified behaviour:** Role-gate and disabled-tool outcomes, direct shell-route behaviour, and timeout, cancellation, and output handling for foreground and detached processes remain unverified.
|
|
||||||
|
|
||||||
## Filesystem access and workspace confinement
|
|
||||||
|
|
||||||
- **Boundary:** Agent read, write, patch, listing, glob, and grep tools.
|
|
||||||
|
|
||||||
- **Available authority:** Read and modify files within active workspace confinement or fallback allowlisted roots.
|
|
||||||
|
|
||||||
- **User-controlled inputs:** Tool paths, patches, file contents, search patterns, and workspace selection passed into the tool dispatcher.
|
|
||||||
|
|
||||||
- **Current safeguards:** [`src/tool_execution.py`](../src/tool_execution.py) resolves paths, blocks sensitive subpaths, applies allowlist containment, and tightens paths to the active workspace when one is bound. File tools use those resolvers.
|
|
||||||
|
|
||||||
- **Confirmed risks or gaps:** Intentional authority with safeguards. The file-tool policy does not sandbox the shell; treating a workspace as a whole-process containment boundary would be incorrect.
|
|
||||||
|
|
||||||
- **Unverified behaviour:** Traversal, symlink, sensitive-name, absolute-path, and workspace-switch behaviour remains unverified.
|
|
||||||
|
|
||||||
## Agent-controlled tool dispatch
|
|
||||||
|
|
||||||
- **Boundary:** Model output becomes native or parsed tool calls and is dispatched by the agent loop.
|
|
||||||
|
|
||||||
- **Available authority:** The authority of every enabled tool, including privileged built-ins and external tools.
|
|
||||||
|
|
||||||
- **User-controlled inputs:** Chat content, attached/retrieved content that may influence the model, tool arguments, per-request tool selection, and policy toggles.
|
|
||||||
|
|
||||||
- **Current safeguards:** [`src/tool_security.py`](../src/tool_security.py) blocks protected tools for non-admin users and fails closed for malformed tool names; [`src/tool_policy.py`](../src/tool_policy.py) supports disabled and guide-only policy; prompt-security helpers label untrusted context.
|
|
||||||
|
|
||||||
- **Confirmed risks or gaps:** Credible risk requiring verification: aliases, legacy text tools, native function calls, and MCP-qualified names must all reach the same policy outcome. The code has specific alias handling for email/MCP names, which makes this a sensitive compatibility seam.
|
|
||||||
|
|
||||||
- **Unverified behaviour:** The current policy outcomes for owner role, request mode, disabled state, native versus parsed invocation, qualified aliases, and external-content entry points remain unverified.
|
|
||||||
|
|
||||||
## MCP and external tool servers
|
|
||||||
|
|
||||||
- **Boundary:** Configured MCP servers and their tools are exposed to the agent through the MCP manager and routes.
|
|
||||||
|
|
||||||
- **Available authority:** Depends on the server: external network access, local process access, messaging, or data mutation may be delegated outside the application.
|
|
||||||
|
|
||||||
- **User-controlled inputs:** Server configuration, remote OAuth completion, tool arguments, and model-selected MCP calls.
|
|
||||||
|
|
||||||
- **Current safeguards:** MCP routes are registered through [`routes/mcp_routes.py`](../routes/mcp_routes.py); MCP-qualified tools are denied to non-admin users by [`src/tool_security.py`](../src/tool_security.py). OAuth state and token persistence are handled in [`src/mcp_oauth.py`](../src/mcp_oauth.py).
|
|
||||||
|
|
||||||
- **Confirmed risks or gaps:** Credible risk requiring verification: an MCP server authority is broader than the application can infer from its tool name. This map does not establish a trust or approval model for server installation and individual tool invocation.
|
|
||||||
|
|
||||||
- **Unverified behaviour:** Server onboarding, credential storage, server-origin trust, OAuth callback deployment, tool disablement, and invocation audit behaviour remain unverified.
|
|
||||||
|
|
||||||
## Outbound network requests and URL validation
|
|
||||||
|
|
||||||
- **Boundary:** Search/content fetch, research, webhooks, skill import, provider endpoints, and other HTTP clients.
|
|
||||||
|
|
||||||
- **Available authority:** The application can make outbound requests from its network position.
|
|
||||||
|
|
||||||
- **User-controlled inputs:** Search/fetch URLs, imported skill URLs, webhook configuration, and some endpoint settings.
|
|
||||||
|
|
||||||
- **Current safeguards:** [`src/url_security.py`](../src/url_security.py) validates untrusted public HTTP URLs and fails closed on unsuitable schemes or private addresses. [`services/search/content.py`](../services/search/content.py) resolves and rejects non-public hosts, pins resolved addresses for fetches, caps bodies, and limits redirects.
|
|
||||||
|
|
||||||
- **Confirmed risks or gaps:** Intentional split: administrator-created model endpoints may target private providers, while untrusted URLs use public-address checks. That distinction is required for self-hosted deployments but needs explicit call-site review.
|
|
||||||
|
|
||||||
- **Unverified behaviour:** The URL-source classification for outbound clients and the current handling of redirects and DNS changes remain unverified.
|
|
||||||
|
|
||||||
## Secrets, credentials, and vault sessions
|
|
||||||
|
|
||||||
- **Boundary:** Application-managed encrypted secrets, API keys, provider credentials, and Bitwarden/Vaultwarden CLI sessions.
|
|
||||||
|
|
||||||
- **Available authority:** Credentials unlock remote providers and connected personal services.
|
|
||||||
|
|
||||||
- **User-controlled inputs:** Administrative configuration, login/unlock requests, imported settings, and agent vault tool arguments.
|
|
||||||
|
|
||||||
- **Current safeguards:** [`src/secret_storage.py`](../src/secret_storage.py) uses a locally stored Fernet key with restrictive permissions for supported database secrets. Vault routes require an administrator, avoid passing master passwords in command arguments, and set restrictive permissions on the vault-session file.
|
|
||||||
|
|
||||||
- **Confirmed risks or gaps:** Confirmed current boundary: vault session data is persisted through the vault path, not through [`src/secret_storage.py`](../src/secret_storage.py). This is an unresolved question about current security semantics, not a confirmed exposure.
|
|
||||||
|
|
||||||
- **Unverified behaviour:** Current encryption-at-rest, owner scope, rotation, lock/logout, backup/restore, and log/tool-result exposure behaviour remains unverified.
|
|
||||||
|
|
||||||
## Authentication and privileged administration
|
|
||||||
|
|
||||||
- **Boundary:** Session authentication, API tokens, privileged routes, and internal tool loopback.
|
|
||||||
|
|
||||||
- **Available authority:** Administrative identity can access execution, settings, integrations, data deletion, and secrets.
|
|
||||||
|
|
||||||
- **User-controlled inputs:** Login/signup data, session cookies, API tokens, authentication configuration, and requests to privileged routes.
|
|
||||||
|
|
||||||
- **Current safeguards:** [`core/auth.py`](../core/auth.py), [`core/middleware.py`](../core/middleware.py), and route-level checks establish identity and administrator gates. [`app.py`](../app.py) warns when localhost bypass is configured; [`SECURITY.md`](../SECURITY.md) documents deployment requirements.
|
|
||||||
|
|
||||||
- **Confirmed risks or gaps:** Intentional authority with safeguards. Security depends on deployments keeping authentication enabled and internal services private; this map does not audit reverse-proxy or environment configuration.
|
|
||||||
|
|
||||||
- **Unverified behaviour:** Setup, anonymous, non-admin, admin, token, and internal-loopback behaviour, including privileged-route gate consistency, remains unverified.
|
|
||||||
|
|
||||||
## Deletion, wipe, backup, and restore
|
|
||||||
|
|
||||||
- **Boundary:** Administrative wipe, cleanup, backup import/export, and the backup restore command.
|
|
||||||
|
|
||||||
- **Available authority:** Delete or replace user data and credentials.
|
|
||||||
|
|
||||||
- **User-controlled inputs:** Administrative HTTP requests, cleanup choices, backup payloads, archive paths, and restore command options.
|
|
||||||
|
|
||||||
- **Current safeguards:** Administrative wipe routes use the administrative boundary. Cleanup exposes a preview route before mutation. The documented backup tool requires explicit restore confirmation, stages the old data directory, and validates archive members before extraction.
|
|
||||||
|
|
||||||
- **Confirmed risks or gaps:** Intentional destructive authority. Backup archives contain secrets by design, as documented in [`docs/backup-restore.md`](../docs/backup-restore.md); this is an operator confidentiality responsibility, not a code defect established here.
|
|
||||||
|
|
||||||
- **Unverified behaviour:** Role-gate, confirmation, archive-rejection, staged-recovery, and owner-isolation behaviour remains unverified. No destructive runtime test was performed.
|
|
||||||
|
|
||||||
## Background jobs and unattended task execution
|
|
||||||
|
|
||||||
- **Boundary:** Scheduled tasks, background-job monitor, startup tasks, and notification/delivery work that continue without an active browser request.
|
|
||||||
|
|
||||||
- **Available authority:** Scheduled agent work can obtain model access and, for eligible owners, shell and file tools; task output can interact with connected services.
|
|
||||||
|
|
||||||
- **User-controlled inputs:** Stored task prompt, schedule, model/crew selection, enabled-tool configuration, output target, and prior persisted state.
|
|
||||||
|
|
||||||
- **Current safeguards:** [`src/task_scheduler.py`](../src/task_scheduler.py) serializes execution, records task runs, associates work with an owner, and applies the agent owner-based tool gate. [`src/bg_jobs.py`](../src/bg_jobs.py) keeps bounded state and can terminate overlong subprocess jobs.
|
|
||||||
|
|
||||||
- **Confirmed risks or gaps:** Credible risk requiring verification: authority is inherited and exercised later, so changes to roles, task configuration, and disabled tools must be checked at execution time rather than assumed from task creation.
|
|
||||||
|
|
||||||
- **Unverified behaviour:** Creation, editing, role-change, scheduling, cancellation, restart-recovery, and execution behaviour remains unverified, including whether current policy is re-evaluated before privileged action.
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
# Current system map
|
|
||||||
|
|
||||||
> [!NOTE]
|
|
||||||
> This non-canonical discovery map is an evidence guide, not an exhaustive feature catalog or runtime certification. Verify the cited source before relying on a finding. Each section records local implementation observations, evidence locations, confirmed current problems, and unresolved factual questions.
|
|
||||||
|
|
||||||
## Navigate the system
|
|
||||||
|
|
||||||
- [Startup and application composition](#startup-and-application-composition)
|
|
||||||
- [Frontend shell and browser interaction](#frontend-shell-and-browser-interaction)
|
|
||||||
- [Chat, sessions, and streaming](#chat-sessions-and-streaming)
|
|
||||||
- [Agents, tools, and execution](#agents-tools-and-execution)
|
|
||||||
- [Models, providers, and local serving](#models-providers-and-local-serving)
|
|
||||||
- [Search and research](#search-and-research)
|
|
||||||
- [Documents, retrieval, and personal knowledge](#documents-retrieval-and-personal-knowledge)
|
|
||||||
- [Memory and skills](#memory-and-skills)
|
|
||||||
- [Email, calendar, contacts, notes, and tasks](#email-calendar-contacts-notes-and-tasks)
|
|
||||||
- [Media, speech, and image work](#media-speech-and-image-work)
|
|
||||||
- [Authentication, secrets, and privileged administration](#authentication-secrets-and-privileged-administration)
|
|
||||||
- [Persistence, background work, and operations](#persistence-background-work-and-operations)
|
|
||||||
|
|
||||||
## Startup and application composition
|
|
||||||
|
|
||||||
- **How it works:** [`app.py`](../app.py) creates the application, mounts static assets, constructs shared services, registers route factories, and owns lifespan startup and shutdown. [`src/app_initializer.py`](../src/app_initializer.py) prepares application state; [`core/`](../core/) provides persistence, authentication, middleware, sessions, and platform helpers.
|
|
||||||
|
|
||||||
- **Evidence locations:** [`app.py`](../app.py); [`src/app_initializer.py`](../src/app_initializer.py); [`core/database.py`](../core/database.py); [`core/auth.py`](../core/auth.py); [`core/middleware.py`](../core/middleware.py); [`routes/`](../routes/).
|
|
||||||
|
|
||||||
- **Known problems:** None recorded by this mapping.
|
|
||||||
|
|
||||||
- **Open question:** Which component currently owns startup and shutdown for each long-lived service?
|
|
||||||
|
|
||||||
## Frontend shell and browser interaction
|
|
||||||
|
|
||||||
- **How it works:** [`static/index.html`](../static/index.html) is served by the root and SPA deep-link routes in [`app.py`](../app.py); [`static/app.js`](../static/app.js), [`static/style.css`](../static/style.css), and [`static/js/`](../static/js/) implement the client surface.
|
|
||||||
|
|
||||||
- **Evidence locations:** [`static/index.html`](../static/index.html); [`static/app.js`](../static/app.js); [`static/js/`](../static/js/); [`static/style.css`](../static/style.css); [`app.py`](../app.py) deep-link handlers.
|
|
||||||
|
|
||||||
- **Known problems:** The `/backgrounds` route in [`app.py`](../app.py) calls `serve_html_with_nonce` for `static/backgrounds.html`, but that file is absent from [`static/`](../static/). This is a confirmed broken prototype route, not evidence about the rest of the frontend.
|
|
||||||
|
|
||||||
- **Open question:** Is `/backgrounds` currently an intentionally supported route or an obsolete prototype?
|
|
||||||
|
|
||||||
## Chat, sessions, and streaming
|
|
||||||
|
|
||||||
- **How it works:** [`routes/chat_routes.py`](../routes/chat_routes.py) and [`routes/chat_helpers.py`](../routes/chat_helpers.py) coordinate requests, session state, and SSE delivery. [`src/chat_handler.py`](../src/chat_handler.py), [`src/chat_processor.py`](../src/chat_processor.py), [`src/llm_core.py`](../src/llm_core.py), and [`src/session_actions.py`](../src/session_actions.py) provide message preparation, provider interaction, and session operations.
|
|
||||||
|
|
||||||
- **Evidence locations:** [`routes/chat_routes.py`](../routes/chat_routes.py); [`routes/chat_helpers.py`](../routes/chat_helpers.py); [`routes/session_routes.py`](../routes/session_routes.py); [`src/chat_handler.py`](../src/chat_handler.py); [`src/chat_processor.py`](../src/chat_processor.py); [`src/llm_core.py`](../src/llm_core.py); [`core/session_manager.py`](../core/session_manager.py).
|
|
||||||
|
|
||||||
- **Known problems:** [`src/agent_loop.py`](../src/agent_loop.py) annotates `_resolved_tool_event_name` with `Any` but imports no `Any` and does not enable postponed annotation evaluation. Python evaluates that annotation while importing the module, so this is an import-time defect at the checked baseline.
|
|
||||||
|
|
||||||
- **Open question:** No end-to-end provider or browser streaming run was performed for this map.
|
|
||||||
|
|
||||||
## Agents, tools, and execution
|
|
||||||
|
|
||||||
- **How it works:** [`src/agent_loop.py`](../src/agent_loop.py) drives multi-round tool use. [`src/tool_execution.py`](../src/tool_execution.py) dispatches calls and binds workspace context. [`src/agent_tools/`](../src/agent_tools/) contains individual implementations; [`src/tool_security.py`](../src/tool_security.py) and [`src/tool_policy.py`](../src/tool_policy.py) apply role and request policies. Long-running command work is represented by [`src/bg_jobs.py`](../src/bg_jobs.py).
|
|
||||||
|
|
||||||
- **Evidence locations:** [`src/agent_loop.py`](../src/agent_loop.py); [`src/tool_execution.py`](../src/tool_execution.py); [`src/agent_tools/`](../src/agent_tools/); [`src/tool_security.py`](../src/tool_security.py); [`src/tool_policy.py`](../src/tool_policy.py); [`src/tool_schemas.py`](../src/tool_schemas.py); [`src/bg_jobs.py`](../src/bg_jobs.py).
|
|
||||||
|
|
||||||
- **Known problems:** The import-time annotation defect above blocks the main agent/tool path. The shell is intentionally not a filesystem or network sandbox; that is an authority boundary, not by itself a vulnerability claim.
|
|
||||||
|
|
||||||
- **Open question:** Which native, legacy, and MCP-qualified invocation paths reach each policy gate?
|
|
||||||
|
|
||||||
## Models, providers, and local serving
|
|
||||||
|
|
||||||
- **How it works:** Model routes delegate to discovery, capabilities, endpoint resolution, and LLM core modules. Cookbook routes and hardware-fit services handle model lifecycle and local-serving support.
|
|
||||||
|
|
||||||
- **Evidence locations:** [`routes/model_routes.py`](../routes/model_routes.py); [`src/model_discovery.py`](../src/model_discovery.py); [`src/model_capabilities.py`](../src/model_capabilities.py); [`src/endpoint_resolver.py`](../src/endpoint_resolver.py); [`src/llm_core.py`](../src/llm_core.py); [`routes/cookbook_routes.py`](../routes/cookbook_routes.py); [`src/cookbook_serve_lifecycle.py`](../src/cookbook_serve_lifecycle.py); [`services/hwfit/`](../services/hwfit/).
|
|
||||||
|
|
||||||
- **Known problems:** None recorded by this mapping.
|
|
||||||
|
|
||||||
- **Open question:** Which endpoint inputs are administrator-created and permitted to use private provider addresses?
|
|
||||||
|
|
||||||
## Search and research
|
|
||||||
|
|
||||||
- **How it works:** HTTP search routes use [`services/search/`](../services/search/); research is exposed through [`routes/research/`](../routes/research/) and implemented in [`services/research/`](../services/research/), [`src/deep_research.py`](../src/deep_research.py), and related helpers. [`src/search/`](../src/search/) remains an import-compatibility layer for callers not yet moved to `services.search`.
|
|
||||||
|
|
||||||
- **Evidence locations:** [`routes/search_routes.py`](../routes/search_routes.py); [`services/search/`](../services/search/); [`routes/research/research_routes.py`](../routes/research/research_routes.py); [`services/research/`](../services/research/); [`src/deep_research.py`](../src/deep_research.py); [`src/search/`](../src/search/).
|
|
||||||
|
|
||||||
- **Known problems:** None recorded by this mapping.
|
|
||||||
|
|
||||||
- **Open question:** No live provider request was made; provider configuration and network access remain unverified.
|
|
||||||
|
|
||||||
## Documents, retrieval, and personal knowledge
|
|
||||||
|
|
||||||
- **How it works:** Document routes coordinate upload handling, document processing, and editor actions. Personal-document and RAG modules use Chroma and embedding clients. PDF viewing uses the optional-dependency loader in [`src/pdf_runtime.py`](../src/pdf_runtime.py); form extraction and filling live separately in [`src/pdf_forms.py`](../src/pdf_forms.py) and [`src/pdf_form_doc.py`](../src/pdf_form_doc.py).
|
|
||||||
|
|
||||||
- **Evidence locations:** [`routes/document_routes.py`](../routes/document_routes.py); [`src/upload_handler.py`](../src/upload_handler.py); [`src/document_processor.py`](../src/document_processor.py); [`src/document_actions.py`](../src/document_actions.py); [`src/personal_docs.py`](../src/personal_docs.py); [`src/rag_manager.py`](../src/rag_manager.py); [`src/embeddings.py`](../src/embeddings.py); [`src/pdf_runtime.py`](../src/pdf_runtime.py); [`src/pdf_forms.py`](../src/pdf_forms.py); [`src/pdf_form_doc.py`](../src/pdf_form_doc.py).
|
|
||||||
|
|
||||||
- **Known problems:** PDF viewing/runtime loading and PDF form processing are separate implementations. That separation is confirmed and intentional in the source; it is not a defect without a reported behavioural failure.
|
|
||||||
|
|
||||||
- **Open question:** Optional PDF dependencies and representative uploaded documents were not exercised.
|
|
||||||
|
|
||||||
## Memory and skills
|
|
||||||
|
|
||||||
- **How it works:** Memory routes use [`services/memory/`](../services/memory/) and vector helpers. Skills are exposed through [`routes/skills_routes.py`](../routes/skills_routes.py), stored and managed in [`services/memory/skills.py`](../services/memory/skills.py), and may be imported through [`services/memory/skill_importer.py`](../services/memory/skill_importer.py).
|
|
||||||
|
|
||||||
- **Evidence locations:** [`routes/memory/memory_routes.py`](../routes/memory/memory_routes.py); [`services/memory/`](../services/memory/); [`src/memory.py`](../src/memory.py); [`src/memory_vector.py`](../src/memory_vector.py); [`routes/skills_routes.py`](../routes/skills_routes.py); [`services/memory/skills.py`](../services/memory/skills.py); [`services/memory/skill_importer.py`](../services/memory/skill_importer.py).
|
|
||||||
|
|
||||||
- **Known problems:** None recorded by this mapping.
|
|
||||||
|
|
||||||
- **Open question:** Which imported skill content can reach execution-capable paths, and which validation occurs before that point?
|
|
||||||
|
|
||||||
## Email, calendar, contacts, notes, and tasks
|
|
||||||
|
|
||||||
- **How it works:** Dedicated route modules own email, CalDAV calendar, CardDAV contacts, notes, and tasks. Supporting modules include email helpers and pollers, CalDAV sync and writeback, and the task scheduler.
|
|
||||||
|
|
||||||
- **Evidence locations:** [`routes/email_routes.py`](../routes/email_routes.py); [`routes/calendar_routes.py`](../routes/calendar_routes.py); [`routes/contacts/contacts_routes.py`](../routes/contacts/contacts_routes.py); [`routes/note/note_routes.py`](../routes/note/note_routes.py); [`routes/task_routes.py`](../routes/task_routes.py); [`routes/assistant_routes.py`](../routes/assistant_routes.py); [`src/caldav_sync.py`](../src/caldav_sync.py); [`src/caldav_writeback.py`](../src/caldav_writeback.py); [`src/task_scheduler.py`](../src/task_scheduler.py).
|
|
||||||
|
|
||||||
- **Known problems:** None recorded by this mapping.
|
|
||||||
|
|
||||||
- **Open question:** External account behaviour, writeback, and delivery require controlled credentials and are not runtime-validated here.
|
|
||||||
|
|
||||||
## Media, speech, and image work
|
|
||||||
|
|
||||||
- **How it works:** Gallery and image routes coordinate media features. Service modules own speech and media integrations; [`src/generated_images.py`](../src/generated_images.py) and [`src/visual_report.py`](../src/visual_report.py) support artifact handling and presentation.
|
|
||||||
|
|
||||||
- **Evidence locations:** [`routes/gallery/gallery_routes.py`](../routes/gallery/gallery_routes.py); [`routes/stt_routes.py`](../routes/stt_routes.py); [`routes/tts_routes.py`](../routes/tts_routes.py); [`src/generated_images.py`](../src/generated_images.py); [`services/stt/`](../services/stt/); [`services/tts/`](../services/tts/); [`services/faces/`](../services/faces/); [`src/visual_report.py`](../src/visual_report.py).
|
|
||||||
|
|
||||||
- **Known problems:** None recorded by this mapping.
|
|
||||||
|
|
||||||
- **Open question:** Hardware- and provider-dependent media workflows were not exercised.
|
|
||||||
|
|
||||||
## Authentication, secrets, and privileged administration
|
|
||||||
|
|
||||||
- **How it works:** [`core/auth.py`](../core/auth.py) and [`core/middleware.py`](../core/middleware.py) provide identity and request gates. [`src/secret_storage.py`](../src/secret_storage.py) encrypts application-managed database secrets with a local Fernet key. Vault handling is separate: [`routes/vault_routes.py`](../routes/vault_routes.py) and [`src/tools/vault.py`](../src/tools/vault.py) invoke the Bitwarden CLI and persist its session data in the application data area.
|
|
||||||
|
|
||||||
- **Evidence locations:** [`core/auth.py`](../core/auth.py); [`core/middleware.py`](../core/middleware.py); [`routes/auth_routes.py`](../routes/auth_routes.py); [`routes/api_token_routes.py`](../routes/api_token_routes.py); [`src/secret_storage.py`](../src/secret_storage.py); [`routes/vault_routes.py`](../routes/vault_routes.py); [`src/tools/vault.py`](../src/tools/vault.py); [`routes/admin_wipe/admin_wipe_routes.py`](../routes/admin_wipe/admin_wipe_routes.py).
|
|
||||||
|
|
||||||
- **Known problems:** Vault-command handling and local application secret storage are distinct paths with different storage mechanisms. This is a source-confirmed boundary, not evidence that either path is compromised.
|
|
||||||
|
|
||||||
- **Open question:** What are the current confidentiality, ownership, rotation, and backup semantics for vault session data?
|
|
||||||
|
|
||||||
## Persistence, background work, and operations
|
|
||||||
|
|
||||||
- **How it works:** SQLite models and persistence are centred in [`core/database.py`](../core/database.py); managers use application data paths. The scheduler and background-job monitor can continue work outside a live browser request. Operational routes cover cleanup, backup, and administrative wipe; the repository also provides a backup script and user documentation.
|
|
||||||
|
|
||||||
- **Evidence locations:** [`core/database.py`](../core/database.py); [`src/runtime_paths.py`](../src/runtime_paths.py); [`src/task_scheduler.py`](../src/task_scheduler.py); [`src/bg_jobs.py`](../src/bg_jobs.py); [`src/bg_monitor.py`](../src/bg_monitor.py); [`routes/backup_routes.py`](../routes/backup_routes.py); [`routes/cleanup/cleanup_routes.py`](../routes/cleanup/cleanup_routes.py); [`routes/admin_wipe/admin_wipe_routes.py`](../routes/admin_wipe/admin_wipe_routes.py); [`scripts/odysseus-backup`](../scripts/odysseus-backup); [`docs/backup-restore.md`](../docs/backup-restore.md).
|
|
||||||
|
|
||||||
- **Known problems:** None recorded by this mapping.
|
|
||||||
|
|
||||||
- **Open question:** What current behaviour applies to background execution, cancellation, retries, and authority inheritance?
|
|
||||||
@@ -16,18 +16,18 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "${APP_BIND:-127.0.0.1}:${APP_PORT:-7000}:7000"
|
- "${APP_BIND:-127.0.0.1}:${APP_PORT:-7000}:7000"
|
||||||
volumes:
|
volumes:
|
||||||
- ${APP_DATA_DIR:-./data}:/app/data:z
|
- ./data:/app/data:z
|
||||||
- ${APP_LOGS_DIR:-./logs}:/app/logs:z
|
- ./logs:/app/logs:z
|
||||||
# Cookbook remote-server SSH identity. Odysseus can generate a key here;
|
# Cookbook remote-server SSH identity. Odysseus can generate a key here;
|
||||||
# add the shown public key to each remote server's authorized_keys.
|
# add the shown public key to each remote server's authorized_keys.
|
||||||
- ${APP_DATA_DIR:-./data}/ssh:/app/.ssh:z
|
- ./data/ssh:/app/.ssh:z
|
||||||
# Cookbook local model cache. Inside Docker, "Local" means the Odysseus
|
# Cookbook local model cache. Inside Docker, "Local" means the Odysseus
|
||||||
# container, so persist its HuggingFace cache under ./data/huggingface.
|
# container, so persist its HuggingFace cache under ./data/huggingface.
|
||||||
- ${APP_DATA_DIR:-./data}/huggingface:/app/.cache/huggingface:z
|
- ./data/huggingface:/app/.cache/huggingface:z
|
||||||
# Cookbook-installed Python CLIs/packages (vLLM, llama-cpp-python, etc.)
|
# Cookbook-installed Python CLIs/packages (vLLM, llama-cpp-python, etc.)
|
||||||
# land under /app/.local for the odysseus user. Persist them so a
|
# land under /app/.local for the odysseus user. Persist them so a
|
||||||
# container recreate does not silently remove installed serve engines.
|
# container recreate does not silently remove installed serve engines.
|
||||||
- ${APP_DATA_DIR:-./data}/local:/app/.local:z
|
- ./data/local:/app/.local:z
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
# Lets the container reach local services on the Docker host, including
|
# Lets the container reach local services on the Docker host, including
|
||||||
# Ollama at http://host.docker.internal:11434.
|
# Ollama at http://host.docker.internal:11434.
|
||||||
@@ -52,27 +52,15 @@ services:
|
|||||||
- SECURE_COOKIES=${SECURE_COOKIES:-false}
|
- SECURE_COOKIES=${SECURE_COOKIES:-false}
|
||||||
- EMBEDDING_URL=${EMBEDDING_URL:-}
|
- EMBEDDING_URL=${EMBEDDING_URL:-}
|
||||||
- EMBEDDING_MODEL=${EMBEDDING_MODEL:-}
|
- EMBEDDING_MODEL=${EMBEDDING_MODEL:-}
|
||||||
- EMBEDDING_API_KEY=${EMBEDDING_API_KEY:-}
|
|
||||||
- FASTEMBED_MODEL=${FASTEMBED_MODEL:-sentence-transformers/all-MiniLM-L6-v2}
|
- FASTEMBED_MODEL=${FASTEMBED_MODEL:-sentence-transformers/all-MiniLM-L6-v2}
|
||||||
- FASTEMBED_CACHE_PATH=${FASTEMBED_CACHE_PATH:-}
|
- FASTEMBED_CACHE_PATH=${FASTEMBED_CACHE_PATH:-}
|
||||||
- CLEANUP_INTERVAL_HOURS=${CLEANUP_INTERVAL_HOURS:-24}
|
- CLEANUP_INTERVAL_HOURS=${CLEANUP_INTERVAL_HOURS:-24}
|
||||||
- ODYSSEUS_INPROCESS_POLLERS=${ODYSSEUS_INPROCESS_POLLERS:-1}
|
- ODYSSEUS_INPROCESS_POLLERS=${ODYSSEUS_INPROCESS_POLLERS:-1}
|
||||||
- ODYSSEUS_INPROCESS_TASKS=${ODYSSEUS_INPROCESS_TASKS:-1}
|
- ODYSSEUS_INPROCESS_TASKS=${ODYSSEUS_INPROCESS_TASKS:-1}
|
||||||
- ODYSSEUS_SCRIPT_HOST=${ODYSSEUS_SCRIPT_HOST:-localhost}
|
- ODYSSEUS_SCRIPT_HOST=${ODYSSEUS_SCRIPT_HOST:-localhost}
|
||||||
- ODYSSEUS_CHAT_UPLOAD_MAX_BYTES=${ODYSSEUS_CHAT_UPLOAD_MAX_BYTES:-10485760}
|
|
||||||
- ODYSSEUS_GALLERY_UPLOAD_MAX_BYTES=${ODYSSEUS_GALLERY_UPLOAD_MAX_BYTES:-104857600}
|
|
||||||
- ODYSSEUS_GALLERY_TRANSFORM_UPLOAD_MAX_BYTES=${ODYSSEUS_GALLERY_TRANSFORM_UPLOAD_MAX_BYTES:-26214400}
|
|
||||||
- ODYSSEUS_MEMORY_IMPORT_MAX_BYTES=${ODYSSEUS_MEMORY_IMPORT_MAX_BYTES:-10485760}
|
|
||||||
- ODYSSEUS_PERSONAL_UPLOAD_MAX_BYTES=${ODYSSEUS_PERSONAL_UPLOAD_MAX_BYTES:-26214400}
|
|
||||||
- ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES=${ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES:-26214400}
|
|
||||||
- ODYSSEUS_STT_MAX_AUDIO_BYTES=${ODYSSEUS_STT_MAX_AUDIO_BYTES:-26214400}
|
|
||||||
- ODYSSEUS_ICS_MAX_BYTES=${ODYSSEUS_ICS_MAX_BYTES:-10485760}
|
|
||||||
- DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-}
|
- DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-}
|
||||||
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
|
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
|
||||||
- GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-}
|
- GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-}
|
||||||
- GOOGLE_OAUTH_CLIENT_ID=${GOOGLE_OAUTH_CLIENT_ID:-}
|
|
||||||
- GOOGLE_OAUTH_CLIENT_SECRET=${GOOGLE_OAUTH_CLIENT_SECRET:-}
|
|
||||||
- GOOGLE_OAUTH_REDIRECT_URI=${GOOGLE_OAUTH_REDIRECT_URI:-}
|
|
||||||
- TAVILY_API_KEY=${TAVILY_API_KEY:-}
|
- TAVILY_API_KEY=${TAVILY_API_KEY:-}
|
||||||
- SERPER_API_KEY=${SERPER_API_KEY:-}
|
- SERPER_API_KEY=${SERPER_API_KEY:-}
|
||||||
# PUID / PGID — the user/group the container drops to before
|
# PUID / PGID — the user/group the container drops to before
|
||||||
|
|||||||
@@ -15,18 +15,18 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "${APP_BIND:-127.0.0.1}:${APP_PORT:-7000}:7000"
|
- "${APP_BIND:-127.0.0.1}:${APP_PORT:-7000}:7000"
|
||||||
volumes:
|
volumes:
|
||||||
- ${APP_DATA_DIR:-./data}:/app/data:z
|
- ./data:/app/data:z
|
||||||
- ${APP_LOGS_DIR:-./logs}:/app/logs:z
|
- ./logs:/app/logs:z
|
||||||
# Cookbook remote-server SSH identity. Odysseus can generate a key here;
|
# Cookbook remote-server SSH identity. Odysseus can generate a key here;
|
||||||
# add the shown public key to each remote server's authorized_keys.
|
# add the shown public key to each remote server's authorized_keys.
|
||||||
- ${APP_DATA_DIR:-./data}/ssh:/app/.ssh:z
|
- ./data/ssh:/app/.ssh:z
|
||||||
# Cookbook local model cache. Inside Docker, "Local" means the Odysseus
|
# Cookbook local model cache. Inside Docker, "Local" means the Odysseus
|
||||||
# container, so persist its HuggingFace cache under ./data/huggingface.
|
# container, so persist its HuggingFace cache under ./data/huggingface.
|
||||||
- ${APP_DATA_DIR:-./data}/huggingface:/app/.cache/huggingface:z
|
- ./data/huggingface:/app/.cache/huggingface:z
|
||||||
# Cookbook-installed Python CLIs/packages (vLLM, llama-cpp-python, etc.)
|
# Cookbook-installed Python CLIs/packages (vLLM, llama-cpp-python, etc.)
|
||||||
# land under /app/.local for the odysseus user. Persist them so a
|
# land under /app/.local for the odysseus user. Persist them so a
|
||||||
# container recreate does not silently remove installed serve engines.
|
# container recreate does not silently remove installed serve engines.
|
||||||
- ${APP_DATA_DIR:-./data}/local:/app/.local:z
|
- ./data/local:/app/.local:z
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
# Lets the container reach local services on the Docker host, including
|
# Lets the container reach local services on the Docker host, including
|
||||||
# Ollama at http://host.docker.internal:11434.
|
# Ollama at http://host.docker.internal:11434.
|
||||||
@@ -51,27 +51,15 @@ services:
|
|||||||
- SECURE_COOKIES=${SECURE_COOKIES:-false}
|
- SECURE_COOKIES=${SECURE_COOKIES:-false}
|
||||||
- EMBEDDING_URL=${EMBEDDING_URL:-}
|
- EMBEDDING_URL=${EMBEDDING_URL:-}
|
||||||
- EMBEDDING_MODEL=${EMBEDDING_MODEL:-}
|
- EMBEDDING_MODEL=${EMBEDDING_MODEL:-}
|
||||||
- EMBEDDING_API_KEY=${EMBEDDING_API_KEY:-}
|
|
||||||
- FASTEMBED_MODEL=${FASTEMBED_MODEL:-sentence-transformers/all-MiniLM-L6-v2}
|
- FASTEMBED_MODEL=${FASTEMBED_MODEL:-sentence-transformers/all-MiniLM-L6-v2}
|
||||||
- FASTEMBED_CACHE_PATH=${FASTEMBED_CACHE_PATH:-}
|
- FASTEMBED_CACHE_PATH=${FASTEMBED_CACHE_PATH:-}
|
||||||
- CLEANUP_INTERVAL_HOURS=${CLEANUP_INTERVAL_HOURS:-24}
|
- CLEANUP_INTERVAL_HOURS=${CLEANUP_INTERVAL_HOURS:-24}
|
||||||
- ODYSSEUS_INPROCESS_POLLERS=${ODYSSEUS_INPROCESS_POLLERS:-1}
|
- ODYSSEUS_INPROCESS_POLLERS=${ODYSSEUS_INPROCESS_POLLERS:-1}
|
||||||
- ODYSSEUS_INPROCESS_TASKS=${ODYSSEUS_INPROCESS_TASKS:-1}
|
- ODYSSEUS_INPROCESS_TASKS=${ODYSSEUS_INPROCESS_TASKS:-1}
|
||||||
- ODYSSEUS_SCRIPT_HOST=${ODYSSEUS_SCRIPT_HOST:-localhost}
|
- ODYSSEUS_SCRIPT_HOST=${ODYSSEUS_SCRIPT_HOST:-localhost}
|
||||||
- ODYSSEUS_CHAT_UPLOAD_MAX_BYTES=${ODYSSEUS_CHAT_UPLOAD_MAX_BYTES:-10485760}
|
|
||||||
- ODYSSEUS_GALLERY_UPLOAD_MAX_BYTES=${ODYSSEUS_GALLERY_UPLOAD_MAX_BYTES:-104857600}
|
|
||||||
- ODYSSEUS_GALLERY_TRANSFORM_UPLOAD_MAX_BYTES=${ODYSSEUS_GALLERY_TRANSFORM_UPLOAD_MAX_BYTES:-26214400}
|
|
||||||
- ODYSSEUS_MEMORY_IMPORT_MAX_BYTES=${ODYSSEUS_MEMORY_IMPORT_MAX_BYTES:-10485760}
|
|
||||||
- ODYSSEUS_PERSONAL_UPLOAD_MAX_BYTES=${ODYSSEUS_PERSONAL_UPLOAD_MAX_BYTES:-26214400}
|
|
||||||
- ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES=${ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES:-26214400}
|
|
||||||
- ODYSSEUS_STT_MAX_AUDIO_BYTES=${ODYSSEUS_STT_MAX_AUDIO_BYTES:-26214400}
|
|
||||||
- ODYSSEUS_ICS_MAX_BYTES=${ODYSSEUS_ICS_MAX_BYTES:-10485760}
|
|
||||||
- DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-}
|
- DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-}
|
||||||
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
|
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
|
||||||
- GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-}
|
- GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-}
|
||||||
- GOOGLE_OAUTH_CLIENT_ID=${GOOGLE_OAUTH_CLIENT_ID:-}
|
|
||||||
- GOOGLE_OAUTH_CLIENT_SECRET=${GOOGLE_OAUTH_CLIENT_SECRET:-}
|
|
||||||
- GOOGLE_OAUTH_REDIRECT_URI=${GOOGLE_OAUTH_REDIRECT_URI:-}
|
|
||||||
- TAVILY_API_KEY=${TAVILY_API_KEY:-}
|
- TAVILY_API_KEY=${TAVILY_API_KEY:-}
|
||||||
- SERPER_API_KEY=${SERPER_API_KEY:-}
|
- SERPER_API_KEY=${SERPER_API_KEY:-}
|
||||||
# PUID / PGID — the user/group the container drops to before
|
# PUID / PGID — the user/group the container drops to before
|
||||||
|
|||||||
@@ -4,18 +4,18 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "${APP_BIND:-127.0.0.1}:${APP_PORT:-7000}:7000"
|
- "${APP_BIND:-127.0.0.1}:${APP_PORT:-7000}:7000"
|
||||||
volumes:
|
volumes:
|
||||||
- ${APP_DATA_DIR:-./data}:/app/data:z
|
- ./data:/app/data:z
|
||||||
- ${APP_LOGS_DIR:-./logs}:/app/logs:z
|
- ./logs:/app/logs:z
|
||||||
# Cookbook remote-server SSH identity. Odysseus can generate a key here;
|
# Cookbook remote-server SSH identity. Odysseus can generate a key here;
|
||||||
# add the shown public key to each remote server's authorized_keys.
|
# add the shown public key to each remote server's authorized_keys.
|
||||||
- ${APP_DATA_DIR:-./data}/ssh:/app/.ssh:z
|
- ./data/ssh:/app/.ssh:z
|
||||||
# Cookbook local model cache. Inside Docker, "Local" means the Odysseus
|
# Cookbook local model cache. Inside Docker, "Local" means the Odysseus
|
||||||
# container, so persist its HuggingFace cache under ./data/huggingface.
|
# container, so persist its HuggingFace cache under ./data/huggingface.
|
||||||
- ${APP_DATA_DIR:-./data}/huggingface:/app/.cache/huggingface:z
|
- ./data/huggingface:/app/.cache/huggingface:z
|
||||||
# Cookbook-installed Python CLIs/packages (vLLM, llama-cpp-python, etc.)
|
# Cookbook-installed Python CLIs/packages (vLLM, llama-cpp-python, etc.)
|
||||||
# land under /app/.local for the odysseus user. Persist them so a
|
# land under /app/.local for the odysseus user. Persist them so a
|
||||||
# container recreate does not silently remove installed serve engines.
|
# container recreate does not silently remove installed serve engines.
|
||||||
- ${APP_DATA_DIR:-./data}/local:/app/.local:z
|
- ./data/local:/app/.local:z
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
# Lets the container reach local services on the Docker host, including
|
# Lets the container reach local services on the Docker host, including
|
||||||
# Ollama at http://host.docker.internal:11434.
|
# Ollama at http://host.docker.internal:11434.
|
||||||
@@ -40,27 +40,15 @@ services:
|
|||||||
- SECURE_COOKIES=${SECURE_COOKIES:-false}
|
- SECURE_COOKIES=${SECURE_COOKIES:-false}
|
||||||
- EMBEDDING_URL=${EMBEDDING_URL:-}
|
- EMBEDDING_URL=${EMBEDDING_URL:-}
|
||||||
- EMBEDDING_MODEL=${EMBEDDING_MODEL:-}
|
- EMBEDDING_MODEL=${EMBEDDING_MODEL:-}
|
||||||
- EMBEDDING_API_KEY=${EMBEDDING_API_KEY:-}
|
|
||||||
- FASTEMBED_MODEL=${FASTEMBED_MODEL:-sentence-transformers/all-MiniLM-L6-v2}
|
- FASTEMBED_MODEL=${FASTEMBED_MODEL:-sentence-transformers/all-MiniLM-L6-v2}
|
||||||
- FASTEMBED_CACHE_PATH=${FASTEMBED_CACHE_PATH:-}
|
- FASTEMBED_CACHE_PATH=${FASTEMBED_CACHE_PATH:-}
|
||||||
- CLEANUP_INTERVAL_HOURS=${CLEANUP_INTERVAL_HOURS:-24}
|
- CLEANUP_INTERVAL_HOURS=${CLEANUP_INTERVAL_HOURS:-24}
|
||||||
- ODYSSEUS_INPROCESS_POLLERS=${ODYSSEUS_INPROCESS_POLLERS:-1}
|
- ODYSSEUS_INPROCESS_POLLERS=${ODYSSEUS_INPROCESS_POLLERS:-1}
|
||||||
- ODYSSEUS_INPROCESS_TASKS=${ODYSSEUS_INPROCESS_TASKS:-1}
|
- ODYSSEUS_INPROCESS_TASKS=${ODYSSEUS_INPROCESS_TASKS:-1}
|
||||||
- ODYSSEUS_SCRIPT_HOST=${ODYSSEUS_SCRIPT_HOST:-localhost}
|
- ODYSSEUS_SCRIPT_HOST=${ODYSSEUS_SCRIPT_HOST:-localhost}
|
||||||
- ODYSSEUS_CHAT_UPLOAD_MAX_BYTES=${ODYSSEUS_CHAT_UPLOAD_MAX_BYTES:-10485760}
|
|
||||||
- ODYSSEUS_GALLERY_UPLOAD_MAX_BYTES=${ODYSSEUS_GALLERY_UPLOAD_MAX_BYTES:-104857600}
|
|
||||||
- ODYSSEUS_GALLERY_TRANSFORM_UPLOAD_MAX_BYTES=${ODYSSEUS_GALLERY_TRANSFORM_UPLOAD_MAX_BYTES:-26214400}
|
|
||||||
- ODYSSEUS_MEMORY_IMPORT_MAX_BYTES=${ODYSSEUS_MEMORY_IMPORT_MAX_BYTES:-10485760}
|
|
||||||
- ODYSSEUS_PERSONAL_UPLOAD_MAX_BYTES=${ODYSSEUS_PERSONAL_UPLOAD_MAX_BYTES:-26214400}
|
|
||||||
- ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES=${ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES:-26214400}
|
|
||||||
- ODYSSEUS_STT_MAX_AUDIO_BYTES=${ODYSSEUS_STT_MAX_AUDIO_BYTES:-26214400}
|
|
||||||
- ODYSSEUS_ICS_MAX_BYTES=${ODYSSEUS_ICS_MAX_BYTES:-10485760}
|
|
||||||
- DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-}
|
- DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-}
|
||||||
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
|
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
|
||||||
- GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-}
|
- GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-}
|
||||||
- GOOGLE_OAUTH_CLIENT_ID=${GOOGLE_OAUTH_CLIENT_ID:-}
|
|
||||||
- GOOGLE_OAUTH_CLIENT_SECRET=${GOOGLE_OAUTH_CLIENT_SECRET:-}
|
|
||||||
- GOOGLE_OAUTH_REDIRECT_URI=${GOOGLE_OAUTH_REDIRECT_URI:-}
|
|
||||||
- TAVILY_API_KEY=${TAVILY_API_KEY:-}
|
- TAVILY_API_KEY=${TAVILY_API_KEY:-}
|
||||||
- SERPER_API_KEY=${SERPER_API_KEY:-}
|
- SERPER_API_KEY=${SERPER_API_KEY:-}
|
||||||
# PUID / PGID — the user/group the container drops to before
|
# PUID / PGID — the user/group the container drops to before
|
||||||
|
|||||||
@@ -1,70 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Build patched wheels for Real-ESRGAN's unmaintained dependencies.
|
|
||||||
#
|
|
||||||
# basicsr / gfpgan / facexlib (xinntao, last released 2022) read their version
|
|
||||||
# in setup.py with:
|
|
||||||
#
|
|
||||||
# exec(compile(f.read(), version_file, 'exec'))
|
|
||||||
# return locals()['__version__']
|
|
||||||
#
|
|
||||||
# Python 3.13+ implements PEP 667: locals() inside a function returns an
|
|
||||||
# independent snapshot that exec() can no longer mutate, so the read raises
|
|
||||||
# `KeyError: '__version__'` and the sdist build fails. That is why the Cookbook
|
|
||||||
# "install realesrgan" button dies on the python:3.14 image. The packages have
|
|
||||||
# no fixed release, so we patch get_version() to exec into an explicit namespace
|
|
||||||
# dict (works on every Python) and build wheels from the patched source.
|
|
||||||
#
|
|
||||||
# Usage: build-realesrgan-wheels.sh [OUTPUT_DIR] (default: /wheels)
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
OUT="${1:-/wheels}"
|
|
||||||
mkdir -p "$OUT"
|
|
||||||
|
|
||||||
work="$(mktemp -d)"
|
|
||||||
trap 'rm -rf "$work"' EXIT
|
|
||||||
cd "$work"
|
|
||||||
|
|
||||||
# Pinned to the versions Real-ESRGAN 0.3.0 resolves to.
|
|
||||||
SPECS="basicsr==1.4.2 gfpgan==1.3.8 facexlib==0.3.0"
|
|
||||||
|
|
||||||
for spec in $SPECS; do
|
|
||||||
name="${spec%%==*}"
|
|
||||||
ver="${spec##*==}"
|
|
||||||
# pip download builds metadata (and trips the same bug), so fetch the raw
|
|
||||||
# sdist URL from the PyPI JSON API instead.
|
|
||||||
url="$(python - "$name" "$ver" <<'PY'
|
|
||||||
import json, sys, urllib.request
|
|
||||||
name, ver = sys.argv[1], sys.argv[2]
|
|
||||||
data = json.load(urllib.request.urlopen(f"https://pypi.org/pypi/{name}/{ver}/json"))
|
|
||||||
for f in data["urls"]:
|
|
||||||
if f["packagetype"] == "sdist":
|
|
||||||
print(f["url"]); break
|
|
||||||
else:
|
|
||||||
sys.exit(f"no sdist found for {name}=={ver}")
|
|
||||||
PY
|
|
||||||
)"
|
|
||||||
echo ">> fetching ${name} ${ver}: ${url}"
|
|
||||||
curl -fsSL "$url" -o "${name}.tar.gz"
|
|
||||||
tar xzf "${name}.tar.gz"
|
|
||||||
done
|
|
||||||
|
|
||||||
echo ">> patching get_version()"
|
|
||||||
python - <<'PY'
|
|
||||||
import pathlib
|
|
||||||
old_exec = "exec(compile(f.read(), version_file, 'exec'))"
|
|
||||||
new_exec = "_ver_ns = {}\n exec(compile(f.read(), version_file, 'exec'), _ver_ns)"
|
|
||||||
old_ret = "return locals()['__version__']"
|
|
||||||
new_ret = "return _ver_ns['__version__']"
|
|
||||||
patched = 0
|
|
||||||
for setup in pathlib.Path(".").glob("*/setup.py"):
|
|
||||||
s = setup.read_text()
|
|
||||||
if old_exec in s and old_ret in s:
|
|
||||||
setup.write_text(s.replace(old_exec, new_exec).replace(old_ret, new_ret))
|
|
||||||
print(" patched", setup)
|
|
||||||
patched += 1
|
|
||||||
assert patched == 3, f"expected to patch 3 setup.py files, patched {patched}"
|
|
||||||
PY
|
|
||||||
|
|
||||||
echo ">> building wheels into ${OUT}"
|
|
||||||
pip wheel --no-deps -w "$OUT" ./basicsr-* ./gfpgan-* ./facexlib-*
|
|
||||||
ls -l "$OUT"
|
|
||||||
@@ -13,8 +13,6 @@ set -e
|
|||||||
|
|
||||||
PUID="${PUID:-1000}"
|
PUID="${PUID:-1000}"
|
||||||
PGID="${PGID:-1000}"
|
PGID="${PGID:-1000}"
|
||||||
GOSU_BIN="$(command -v gosu)"
|
|
||||||
PYTHON_BIN="$(command -v python)"
|
|
||||||
|
|
||||||
# Reuse an existing matching group/user if the host's UID/GID already
|
# Reuse an existing matching group/user if the host's UID/GID already
|
||||||
# corresponds to one in /etc/passwd (e.g. when the image is rebuilt
|
# corresponds to one in /etc/passwd (e.g. when the image is rebuilt
|
||||||
@@ -26,78 +24,26 @@ if ! getent passwd "$PUID" >/dev/null 2>&1; then
|
|||||||
useradd -u "$PUID" -g "$PGID" -M -s /bin/sh -d /app odysseus
|
useradd -u "$PUID" -g "$PGID" -M -s /bin/sh -d /app odysseus
|
||||||
fi
|
fi
|
||||||
|
|
||||||
ODY_USER="$(getent passwd "$PUID" | cut -d: -f1)"
|
# Repair ownership on every writable path the app touches at runtime.
|
||||||
[ -z "$ODY_USER" ] && ODY_USER=odysseus
|
#
|
||||||
|
# Bind-mounted dirs (/app/data, /app/logs) are the obvious ones, but
|
||||||
# Docker-socket group plumbing for the explicit host-Docker overlay. When
|
# the app ALSO writes inside the image's own source tree at runtime:
|
||||||
# opted in, the socket is owned by root:<host docker gid>. Add the app user
|
# - services/cache/{search,content}/* (search cache LRU)
|
||||||
# to that group and later call gosu by username so supplementary groups are
|
# - services/search_analytics.json
|
||||||
# retained.
|
# - services/search_engine_error.log
|
||||||
DOCKER_SOCK="${DOCKER_SOCK:-/var/run/docker.sock}"
|
# - services/tts cache, etc.
|
||||||
if [ "${ODYSSEUS_ENABLE_HOST_DOCKER:-}" = "true" ] && [ -S "$DOCKER_SOCK" ]; then
|
# These dirs were created as root during `docker build`, so dropping
|
||||||
SOCK_GID="$(stat -c '%g' "$DOCKER_SOCK" 2>/dev/null || echo '')"
|
# to PUID:PGID would otherwise crash on the first import that tries
|
||||||
if [ -n "$SOCK_GID" ] && [ "$SOCK_GID" != "0" ]; then
|
# to mkdir them. Chown the whole /app tree — fast (<1s on this size)
|
||||||
if ! getent group "$SOCK_GID" >/dev/null 2>&1; then
|
# and idempotent via the `-not -uid` filter so we only touch files
|
||||||
groupadd -g "$SOCK_GID" docker_host || true
|
# that need fixing.
|
||||||
fi
|
for dir in /app /app/data /app/logs; do
|
||||||
SOCK_GROUP="$(getent group "$SOCK_GID" | cut -d: -f1)"
|
|
||||||
if [ -n "$SOCK_GROUP" ]; then
|
|
||||||
usermod -aG "$SOCK_GROUP" "$ODY_USER" 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
mount_root_for() {
|
|
||||||
awk -v target="$1" '$5 == target { print $4; exit }' /proc/self/mountinfo 2>/dev/null || true
|
|
||||||
}
|
|
||||||
|
|
||||||
is_broad_mount_root() {
|
|
||||||
case "$1" in
|
|
||||||
/|/home|/srv|/var|/usr|/opt|/tmp|/mnt|/media)
|
|
||||||
return 0
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
repair_tree_ownership() {
|
|
||||||
dir="$1"
|
|
||||||
if [ -d "$dir" ]; then
|
if [ -d "$dir" ]; then
|
||||||
find "$dir" -xdev -not -uid "$PUID" -print0 2>/dev/null \
|
# `find ... -not -uid` keeps this O(touched-files), not
|
||||||
|
# O(everything), so terabyte-sized maildirs don't slow startup.
|
||||||
|
find "$dir" -not -uid "$PUID" -print0 2>/dev/null \
|
||||||
| xargs -0 -r chown "$PUID:$PGID" 2>/dev/null || true
|
| xargs -0 -r chown "$PUID:$PGID" 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
}
|
|
||||||
|
|
||||||
repair_app_tree_ownership() {
|
|
||||||
if [ -d /app ]; then
|
|
||||||
find /app -xdev \
|
|
||||||
\( -path /app/data -o -path /app/logs -o -path /app/.ssh -o -path /app/.cache -o -path /app/.local \) -prune \
|
|
||||||
-o -not -uid "$PUID" -print0 2>/dev/null \
|
|
||||||
| xargs -0 -r chown "$PUID:$PGID" 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
repair_bind_mount_ownership() {
|
|
||||||
dir="$1"
|
|
||||||
if [ ! -d "$dir" ]; then
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
|
|
||||||
mount_root="$(mount_root_for "$dir")"
|
|
||||||
if is_broad_mount_root "$mount_root"; then
|
|
||||||
echo "Skipping recursive ownership repair for $dir because it maps to broad host path $mount_root" >&2
|
|
||||||
chown "$PUID:$PGID" "$dir" 2>/dev/null || true
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
|
|
||||||
repair_tree_ownership "$dir"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Repair image-owned writable paths without walking into bind-mounted host
|
|
||||||
# trees, then repair the app-owned mount roots separately.
|
|
||||||
repair_app_tree_ownership
|
|
||||||
for dir in /app/data /app/logs /app/.ssh /app/.cache/huggingface /app/.local; do
|
|
||||||
repair_bind_mount_ownership "$dir"
|
|
||||||
done
|
done
|
||||||
|
|
||||||
# Cookbook installs vllm/etc. via `pip install --user`, which pulls
|
# Cookbook installs vllm/etc. via `pip install --user`, which pulls
|
||||||
@@ -124,7 +70,6 @@ for cu in \
|
|||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
# Disable the FlashInfer JIT sampler unconditionally — it is sampler-only
|
# Disable the FlashInfer JIT sampler unconditionally — it is sampler-only
|
||||||
# and has no impact on the attention path, but requires nvcc + matching
|
# and has no impact on the attention path, but requires nvcc + matching
|
||||||
# CUDA headers at startup. Without this, vLLM crashes with "Could not find
|
# CUDA headers at startup. Without this, vLLM crashes with "Could not find
|
||||||
@@ -138,9 +83,9 @@ export PATH="/app/.local/bin:$PATH"
|
|||||||
# Run first-time setup as the app user so data/ files get the right ownership.
|
# Run first-time setup as the app user so data/ files get the right ownership.
|
||||||
# setup.py is idempotent — skips auth.json / .env if they already exist.
|
# setup.py is idempotent — skips auth.json / .env if they already exist.
|
||||||
# || true so a setup failure never prevents the container from starting.
|
# || true so a setup failure never prevents the container from starting.
|
||||||
"$GOSU_BIN" "$ODY_USER" "$PYTHON_BIN" /app/setup.py || true
|
gosu "$PUID:$PGID" python /app/setup.py || true
|
||||||
|
|
||||||
# Drop root and run the actual app. `gosu` is preferred over `su` /
|
# Drop root and run the actual app. `gosu` is preferred over `su` /
|
||||||
# `sudo` because it cleans up the process tree (no extra shell layer)
|
# `sudo` because it cleans up the process tree (no extra shell layer)
|
||||||
# so signals (SIGTERM from `docker stop`) reach uvicorn directly.
|
# so signals (SIGTERM from `docker stop`) reach uvicorn directly.
|
||||||
exec "$GOSU_BIN" "$ODY_USER" "$@"
|
exec gosu "$PUID:$PGID" "$@"
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
# High-trust host Docker access. Enable only when local Docker-daemon
|
|
||||||
# management from Cookbook is required and you accept that raw socket access
|
|
||||||
# grants broad control over the host Docker daemon.
|
|
||||||
# COMPOSE_FILE=docker-compose.yml:docker/host-docker.yml
|
|
||||||
# DOCKER_GID=<numeric host Docker group id>
|
|
||||||
services:
|
|
||||||
odysseus:
|
|
||||||
volumes:
|
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
|
||||||
group_add: ["${DOCKER_GID:-963}"]
|
|
||||||
environment:
|
|
||||||
- ODYSSEUS_ENABLE_HOST_DOCKER=true
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
# Architecture
|
|
||||||
|
|
||||||
> [!NOTE]
|
|
||||||
> This document is the proposed canonical destination for stable high-level
|
|
||||||
> architecture facts. It remains subject to maintainer review. Source code,
|
|
||||||
> tests, and configuration remain authoritative for implementation-sensitive
|
|
||||||
> behaviour.
|
|
||||||
|
|
||||||
## Purpose
|
|
||||||
|
|
||||||
This document identifies the stable runtime boundaries and primary ownership
|
|
||||||
locations used to navigate and extend Odysseus.
|
|
||||||
|
|
||||||
It intentionally excludes generated metrics, file-size rankings, refactor
|
|
||||||
priorities, unresolved investigation findings, and proposed package layouts.
|
|
||||||
|
|
||||||
## Runtime structure
|
|
||||||
|
|
||||||
| Area | Responsibility |
|
|
||||||
|---|---|
|
|
||||||
| `app.py` | FastAPI application composition and primary application entry point |
|
|
||||||
| `launcher.py` | Application launch support |
|
|
||||||
| `setup.py` | Native setup workflow |
|
|
||||||
| `core/` | Authentication, middleware, persistence, sessions, and platform primitives |
|
|
||||||
| `routes/` | HTTP and API route handlers |
|
|
||||||
| `src/` | Application orchestration, tools, providers, and runtime helpers |
|
|
||||||
| `services/` | Domain-oriented service implementations |
|
|
||||||
| `mcp_servers/` | Built-in MCP server implementations |
|
|
||||||
| `scripts/` | CLI tools, diagnostics, maintenance, and migration helpers |
|
|
||||||
| `static/` | No-build browser frontend and bundled assets |
|
|
||||||
| `tests/` | Automated tests and supporting test infrastructure |
|
|
||||||
|
|
||||||
## Subsystem boundaries
|
|
||||||
|
|
||||||
| Subsystem | Primary implementation locations |
|
|
||||||
|---|---|
|
|
||||||
| Application startup | `app.py`, `src/app_initializer.py`, `core/` |
|
|
||||||
| Authentication and sessions | `core/auth.py`, `core/middleware.py`, `core/session_manager.py`, `routes/auth_routes.py` |
|
|
||||||
| Chat and streaming | `routes/chat_routes.py`, `routes/chat_helpers.py`, `src/chat_handler.py`, `src/chat_processor.py`, `src/llm_core.py` |
|
|
||||||
| Agents and tools | `src/agent_loop.py`, `src/tool_execution.py`, `src/agent_tools/`, `src/tools/`, `src/tool_policy.py`, `src/tool_security.py` |
|
|
||||||
| Models and providers | `routes/model_routes.py`, `src/model_discovery.py`, `src/model_capabilities.py`, `src/endpoint_resolver.py`, `src/llm_core.py` |
|
|
||||||
| Cookbook and hardware fit | `routes/cookbook_routes.py`, `routes/cookbook_helpers.py`, `src/cookbook_serve_lifecycle.py`, `services/hwfit/` |
|
|
||||||
| Search and research | `routes/search_routes.py`, `services/search/`, `routes/research/`, `services/research/`, `src/deep_research.py` |
|
|
||||||
| Documents and retrieval | `routes/document_routes.py`, `src/document_processor.py`, `src/personal_docs.py`, `src/rag_manager.py`, `src/pdf_runtime.py` |
|
|
||||||
| Memory and skills | `routes/memory/`, `services/memory/`, `routes/skills_routes.py` |
|
|
||||||
| Email | `routes/email_routes.py`, `routes/email_helpers.py`, `routes/email_pollers.py`, `mcp_servers/email_server.py` |
|
|
||||||
| Calendar, contacts, notes, and tasks | `routes/calendar_routes.py`, `routes/contacts/`, `routes/note/`, `routes/task_routes.py`, `src/task_scheduler.py` |
|
|
||||||
| Media and speech | `routes/gallery/`, `routes/stt_routes.py`, `routes/tts_routes.py`, `services/stt/`, `services/tts/` |
|
|
||||||
| Persistence and operations | `core/database.py`, `src/runtime_paths.py`, `src/bg_jobs.py`, `routes/backup_routes.py`, `routes/cleanup/` |
|
|
||||||
|
|
||||||
## Architectural constraints
|
|
||||||
|
|
||||||
- Preserve established import and compatibility paths unless a focused change
|
|
||||||
explicitly migrates them.
|
|
||||||
- Keep HTTP concerns in route modules and reusable domain behaviour in runtime
|
|
||||||
or service modules.
|
|
||||||
- Treat shared persistence, agent orchestration, tool execution, and application
|
|
||||||
startup as high-authority boundaries.
|
|
||||||
- Change one ownership boundary at a time.
|
|
||||||
- Do not mix structural movement with unrelated feature behaviour.
|
|
||||||
- Validate affected imports, startup paths, compatibility surfaces, and tests.
|
|
||||||
|
|
||||||
## Frontend
|
|
||||||
|
|
||||||
The browser frontend is a no-build ES-module application under `static/`.
|
|
||||||
|
|
||||||
Its maintained module-level structure is documented in
|
|
||||||
[`static/js/MODULE_SUMMARY.md`](../static/js/MODULE_SUMMARY.md).
|
|
||||||
|
|
||||||
## Investigation and snapshots
|
|
||||||
|
|
||||||
Non-canonical investigation material is maintained under [`discovery/`](../discovery/).
|
|
||||||
|
|
||||||
The following documents may contain dated observations, metrics, unresolved
|
|
||||||
questions, or historical planning and must not be treated as specifications:
|
|
||||||
|
|
||||||
- [`discovery/system-map.md`](../discovery/system-map.md)
|
|
||||||
- [`discovery/architecture-runtime-inventory.md`](../discovery/architecture-runtime-inventory.md)
|
|
||||||
|
|
||||||
## Documentation authority
|
|
||||||
|
|
||||||
- Code, tests, and configuration define implemented behaviour.
|
|
||||||
- Mature specifications define accepted subsystem behaviour where they exist.
|
|
||||||
- Following maintainer acceptance, this document will define the high-level
|
|
||||||
architecture map.
|
|
||||||
- Discovery documents preserve evidence and uncertainty but remain
|
|
||||||
non-canonical.
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
# Documentation style
|
|
||||||
|
|
||||||
This guide defines the shared structure and writing conventions for Odysseus documentation.
|
|
||||||
|
|
||||||
## Principles
|
|
||||||
|
|
||||||
- Write for a clear audience and purpose.
|
|
||||||
- State whether a document is canonical, informational, a snapshot, or planning material.
|
|
||||||
- Prefer current behaviour over historical explanation.
|
|
||||||
- Link to source files, tests, issues, or other documentation when useful.
|
|
||||||
- Separate verified behaviour from assumptions, open questions, and future work.
|
|
||||||
- Keep headings descriptive and consistent.
|
|
||||||
- Use Markdown callouts where status or risk must be visible.
|
|
||||||
- Do not use emojis.
|
|
||||||
|
|
||||||
## Document status
|
|
||||||
|
|
||||||
Use a status callout near the top when the document is not normal canonical guidance.
|
|
||||||
|
|
||||||
### Canonical documentation
|
|
||||||
|
|
||||||
> [!IMPORTANT]
|
|
||||||
> This document describes accepted current behaviour. Verify implementation-sensitive details against the current code and tests.
|
|
||||||
|
|
||||||
### Discovery material
|
|
||||||
|
|
||||||
> [!NOTE]
|
|
||||||
> This is non-canonical discovery material. It records code-grounded observations and open questions.
|
|
||||||
|
|
||||||
### Snapshot or inventory
|
|
||||||
|
|
||||||
> [!WARNING]
|
|
||||||
> This document is a dated snapshot. Counts, paths, and implementation details may drift as the codebase changes.
|
|
||||||
|
|
||||||
### Planning material
|
|
||||||
|
|
||||||
> [!NOTE]
|
|
||||||
> This document records planning context. It does not define current runtime behaviour or guarantee future implementation.
|
|
||||||
|
|
||||||
## Recommended structure
|
|
||||||
|
|
||||||
Use the following sections where relevant:
|
|
||||||
|
|
||||||
1. Title
|
|
||||||
2. Purpose or status callout
|
|
||||||
3. Scope
|
|
||||||
4. Current behaviour or guidance
|
|
||||||
5. Safety, limitations, or known gaps
|
|
||||||
6. Validation or evidence
|
|
||||||
7. Related documentation
|
|
||||||
|
|
||||||
Not every document needs every section.
|
|
||||||
|
|
||||||
## Writing style
|
|
||||||
|
|
||||||
- Use concise sentences.
|
|
||||||
- Prefer direct language.
|
|
||||||
- Avoid jokes, filler, and informal warnings.
|
|
||||||
- Avoid repeating the same guidance across several files.
|
|
||||||
- Link to the owning document instead of duplicating large sections.
|
|
||||||
- Use lists for procedures, requirements, and comparisons.
|
|
||||||
- Use tables only when they improve scanning.
|
|
||||||
- Use fenced code blocks with an appropriate language identifier.
|
|
||||||
- Use relative repository links for internal files.
|
|
||||||
|
|
||||||
## Authority
|
|
||||||
|
|
||||||
The current code, tests, and configuration are the source of truth for implemented behaviour.
|
|
||||||
|
|
||||||
Canonical documentation describes accepted behaviour and supported workflows.
|
|
||||||
|
|
||||||
Discovery, inventory, and planning documents must identify themselves explicitly and must not silently become behavioural specifications.
|
|
||||||
@@ -1,194 +0,0 @@
|
|||||||
# Agent migration manifests
|
|
||||||
|
|
||||||
Odysseus should be able to learn from another agent without blindly trusting
|
|
||||||
that agent's whole state. The safe migration path is:
|
|
||||||
|
|
||||||
```text
|
|
||||||
source agent export -> source adapter -> agent-migration.v1 manifest -> preview -> apply
|
|
||||||
```
|
|
||||||
|
|
||||||
The manifest is intentionally source-neutral. OpenClaw, Hermes, a folder of
|
|
||||||
Markdown notes, or any other agent can have its own adapter, but Odysseus only
|
|
||||||
needs to understand the normalized manifest.
|
|
||||||
|
|
||||||
## Why not import everything as memory?
|
|
||||||
|
|
||||||
Durable memory should stay compact and useful. Long notes, logs, session
|
|
||||||
transcripts, and project archives are useful context, but they are not all
|
|
||||||
memories. A good migration keeps two layers separate:
|
|
||||||
|
|
||||||
- **Archive documents** preserve source material for search, reading, and later
|
|
||||||
extraction.
|
|
||||||
- **Memory candidates** are short facts or preferences that can be reviewed
|
|
||||||
before being saved into Odysseus memory.
|
|
||||||
|
|
||||||
This keeps Odysseus' existing memory-review flow intact while giving it better
|
|
||||||
source material to review.
|
|
||||||
|
|
||||||
## Manifest shape
|
|
||||||
|
|
||||||
`agent-migration.v1` is a JSON object:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"schema_version": "agent-migration.v1",
|
|
||||||
"generated_at": "2026-06-06T00:00:00Z",
|
|
||||||
"source": {
|
|
||||||
"name": "example-agent",
|
|
||||||
"kind": "generic"
|
|
||||||
},
|
|
||||||
"summary": {
|
|
||||||
"item_count": 3,
|
|
||||||
"counts_by_kind": {
|
|
||||||
"memory": 1,
|
|
||||||
"skill": 1,
|
|
||||||
"conversation_thread": 1,
|
|
||||||
"archive_document": 1
|
|
||||||
},
|
|
||||||
"warning_count": 0
|
|
||||||
},
|
|
||||||
"items": [],
|
|
||||||
"warnings": []
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Each item has a stable `id`, a `kind`, source metadata, and enough content for a
|
|
||||||
future importer to preview it before applying.
|
|
||||||
|
|
||||||
Supported item kinds in the first pass:
|
|
||||||
|
|
||||||
- `memory` — a candidate memory with `text`, `category`, `source`, and
|
|
||||||
provenance metadata.
|
|
||||||
- `skill` — a `SKILL.md` file with content and parsed frontmatter metadata.
|
|
||||||
- `conversation_thread` — a normalized transcript thread from an exported chat
|
|
||||||
history. Message content is optional; adapters can preserve only thread
|
|
||||||
metadata, message counts, timestamps, and hashes when a manifest should stay
|
|
||||||
small or avoid embedding private transcript text.
|
|
||||||
- `archive_document` — long-form source material. Content is optional; adapters
|
|
||||||
can preserve only path/hash/size metadata when a manifest should stay small.
|
|
||||||
|
|
||||||
## Build a manifest
|
|
||||||
|
|
||||||
Use the read-only helper:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 scripts/agent_migration_manifest.py \
|
|
||||||
--source-name old-agent \
|
|
||||||
--source-kind generic \
|
|
||||||
--memory-json /path/to/memories.json \
|
|
||||||
--skills-dir /path/to/skills \
|
|
||||||
--conversation-json /path/to/conversations.json \
|
|
||||||
--archive /path/to/notes \
|
|
||||||
--output /tmp/agent-migration.json
|
|
||||||
```
|
|
||||||
|
|
||||||
The helper does not write to `data/`, call an LLM, import Odysseus modules, or
|
|
||||||
modify the source. It only writes JSON.
|
|
||||||
|
|
||||||
Memory JSON may be:
|
|
||||||
|
|
||||||
```json
|
|
||||||
[
|
|
||||||
"A plain memory string",
|
|
||||||
{
|
|
||||||
"text": "A categorized memory",
|
|
||||||
"category": "preference",
|
|
||||||
"source": "old-agent"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
or an object containing a list under `memories`, `memory`, `items`, or `data`.
|
|
||||||
|
|
||||||
Skills are scanned recursively for `SKILL.md`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 scripts/agent_migration_manifest.py \
|
|
||||||
--source-name hermes \
|
|
||||||
--source-kind hermes \
|
|
||||||
--skills-dir ~/.hermes/skills \
|
|
||||||
--output /tmp/hermes-skills-manifest.json
|
|
||||||
```
|
|
||||||
|
|
||||||
Archive documents are metadata-only by default. To embed text content:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 scripts/agent_migration_manifest.py \
|
|
||||||
--source-name notes-export \
|
|
||||||
--archive /path/to/markdown-notes \
|
|
||||||
--include-archive-content \
|
|
||||||
--output /tmp/notes-manifest.json
|
|
||||||
```
|
|
||||||
|
|
||||||
Conversation exports are also metadata-only by default:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 scripts/agent_migration_manifest.py \
|
|
||||||
--source-name chatgpt-export \
|
|
||||||
--source-kind chatgpt \
|
|
||||||
--conversation-json /path/to/conversations.json \
|
|
||||||
--output /tmp/chatgpt-conversations-manifest.json
|
|
||||||
```
|
|
||||||
|
|
||||||
The first pass supports generic conversation JSON such as:
|
|
||||||
|
|
||||||
```json
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"id": "thread-1",
|
|
||||||
"title": "Project plan",
|
|
||||||
"messages": [
|
|
||||||
{"role": "user", "content": "Can we design this?"},
|
|
||||||
{"role": "assistant", "content": "Yes, start with a narrow slice."}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
It also recognizes ChatGPT-style `mapping` exports from `conversations.json`.
|
|
||||||
To embed normalized messages:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 scripts/agent_migration_manifest.py \
|
|
||||||
--source-name chatgpt-export \
|
|
||||||
--source-kind chatgpt \
|
|
||||||
--conversation-json /path/to/conversations.json \
|
|
||||||
--include-conversation-content \
|
|
||||||
--max-conversation-messages 2000 \
|
|
||||||
--output /tmp/chatgpt-conversations-with-content.json
|
|
||||||
```
|
|
||||||
|
|
||||||
Content embedding is explicit because exported chat histories can be huge and
|
|
||||||
private. A future source-specific adapter can add ZIP traversal, attachment
|
|
||||||
metadata, and provider-specific project/workspace fields while still emitting
|
|
||||||
the same `conversation_thread` manifest item.
|
|
||||||
|
|
||||||
## Recommended apply behavior
|
|
||||||
|
|
||||||
A future Odysseus importer should treat the manifest as untrusted user-provided
|
|
||||||
data and apply it in stages:
|
|
||||||
|
|
||||||
1. Show a dry-run summary with counts, warnings, duplicates, and sample items.
|
|
||||||
2. Back up current `data/` state before writing anything.
|
|
||||||
3. Import archive documents as documents or another searchable source, not as
|
|
||||||
memory.
|
|
||||||
4. Import conversation threads as searchable archived context first, with
|
|
||||||
citations back to the source thread. Do not turn whole transcripts into
|
|
||||||
memory.
|
|
||||||
5. Show memory candidates for review before saving through the normal memory
|
|
||||||
path.
|
|
||||||
6. Import skills only after name/category conflict checks.
|
|
||||||
7. Skip secrets by default. Credentials need explicit, provider-specific flows.
|
|
||||||
|
|
||||||
## What belongs in source adapters?
|
|
||||||
|
|
||||||
Adapters can be source-specific. The core manifest should not be.
|
|
||||||
|
|
||||||
For example, an OpenClaw adapter may know about OpenClaw's workspace files. A
|
|
||||||
Hermes adapter may know about `~/.hermes/config.yaml` and `~/.hermes/skills`.
|
|
||||||
A ChatGPT adapter may know about `conversations.json`, uploaded-file metadata,
|
|
||||||
and image attachment directories. A Claude adapter may know about Claude's
|
|
||||||
export shape and project boundaries. A generic adapter may only know about
|
|
||||||
memory JSON, conversation JSON, `SKILL.md`, and Markdown folders.
|
|
||||||
|
|
||||||
Nonstandard folders should be adapter details, not required Odysseus concepts.
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
# Attachment References and Upload Storage
|
|
||||||
|
|
||||||
> [!NOTE]
|
|
||||||
> This document records the current attachment-reference and upload-lifecycle
|
|
||||||
> contract proposed for maintainer acceptance. Source code, tests, and
|
|
||||||
> configuration remain authoritative for implementation-sensitive behaviour.
|
|
||||||
|
|
||||||
Odysseus stores chat and document attachment bytes under the configured upload
|
|
||||||
directory and passes stable references through chat history, document flows, and
|
|
||||||
tool context.
|
|
||||||
The goal is to avoid duplicating large inline media payloads in
|
|
||||||
`chat_messages.content` or the SQLite FTS index.
|
|
||||||
|
|
||||||
## Reference Shape
|
|
||||||
|
|
||||||
Attachment references use this minimum shape:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"type": "attachment_ref",
|
|
||||||
"attachment_id": "32hex-or-32hex.ext",
|
|
||||||
"name": "original-filename.png",
|
|
||||||
"mime": "image/png",
|
|
||||||
"size": 12345,
|
|
||||||
"checksum_sha256": "hex-digest",
|
|
||||||
"created_at": "2026-07-09T12:00:00"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Optional fields such as `width`, `height`, `vision`, `vision_model`, and
|
|
||||||
`gallery_id` may be present when the uploader or preprocessing path knows them.
|
|
||||||
|
|
||||||
## Persistence
|
|
||||||
|
|
||||||
The live model call may still receive provider-specific multimodal blocks for
|
|
||||||
the current turn. Persistence is different:
|
|
||||||
|
|
||||||
- `chat_messages.content` stores readable text plus compact attachment reference
|
|
||||||
lines, never raw `data:*;base64,...` upload bytes.
|
|
||||||
- `chat_messages.metadata.attachments` stores structured attachment reference
|
|
||||||
metadata for UI reloads and future processing.
|
|
||||||
- The SQLite FTS migration recreates chat-message FTS triggers so new rows do
|
|
||||||
not index inline media payloads, and it scrubs legacy rows that were already
|
|
||||||
indexed with data URLs.
|
|
||||||
|
|
||||||
## Tool Access
|
|
||||||
|
|
||||||
Agent/tool context receives upload entries as `attachment_ref` manifests with an
|
|
||||||
`odysseus://attachment/<id>` URI and `read_policy: "owner_checked_upload"`.
|
|
||||||
|
|
||||||
For compatibility with existing built-in tools, a local `path` may be included
|
|
||||||
only after all of these checks pass:
|
|
||||||
|
|
||||||
- the upload ID resolves through `UploadHandler.resolve_upload`;
|
|
||||||
- the requested owner is allowed to read the upload;
|
|
||||||
- the file remains inside the configured upload directory;
|
|
||||||
- the file path is inside the tool-readable roots.
|
|
||||||
|
|
||||||
External MCP/custom tools should treat the URI and attachment ID as the stable
|
|
||||||
contract and request bytes through an owner-checked server path, not by assuming
|
|
||||||
host filesystem layout.
|
|
||||||
|
|
||||||
## Implementation evidence
|
|
||||||
|
|
||||||
The current contract is implemented primarily through:
|
|
||||||
|
|
||||||
- `src/upload_handler.py` for upload metadata, owner-aware resolution,
|
|
||||||
reservations, cleanup, and deletion;
|
|
||||||
- `src/attachment_refs.py` for compact persisted references and search-index
|
|
||||||
sanitization;
|
|
||||||
- `src/document_processor.py` for resolving attachments into chat/model context;
|
|
||||||
- `src/tool_execution.py` for attachment manifests exposed to tools;
|
|
||||||
- `routes/upload_routes.py` and `routes/document_helpers.py` for upload and
|
|
||||||
retrieval paths.
|
|
||||||
|
|
||||||
Focused regression coverage includes:
|
|
||||||
|
|
||||||
- `tests/test_attachment_refs.py`;
|
|
||||||
- `tests/test_upload_handler_cleanup.py`;
|
|
||||||
- `tests/test_replace_messages_upload_reservations.py`;
|
|
||||||
- `tests/test_resolve_upload_path_nondict.py`;
|
|
||||||
- `tests/test_chat_preprocess_tool_policy.py`;
|
|
||||||
- the upload, attachment, and PDF-marker cases in
|
|
||||||
`tests/test_security_regressions.py`.
|
|
||||||
|
|
||||||
These tests cover compact persistence, owner isolation, path containment,
|
|
||||||
cleanup safety, reservation-before-write behaviour, and traversal resistance.
|
|
||||||
|
|
||||||
## Retention and Deletion
|
|
||||||
|
|
||||||
Current retention behavior is conservative:
|
|
||||||
|
|
||||||
- uploads are indexed in `uploads.json` with owner, checksum, MIME type, size,
|
|
||||||
and creation time;
|
|
||||||
- admin cleanup first scans persisted chat metadata/content, document versions,
|
|
||||||
PDF source markers, gallery hashes, notes, and calendar records for live
|
|
||||||
references;
|
|
||||||
- cleanup fails closed if that reference scan cannot complete, and the lower-level
|
|
||||||
cleanup API removes nothing unless it receives a complete reference snapshot;
|
|
||||||
- expired, unreferenced uploads are removed during the completed scan, while
|
|
||||||
attachment-bearing writers must first take an owner-checked reservation that
|
|
||||||
serializes with deletion and refreshes the upload's access timestamp;
|
|
||||||
- deliberate removal atomically drops matching `uploads.json` rows before deleting
|
|
||||||
the bytes and restores those rows if filesystem removal fails;
|
|
||||||
- deleting a chat removes the chat rows but does not immediately delete shared
|
|
||||||
upload bytes, because the same upload may also be referenced by gallery items,
|
|
||||||
documents, duplicate-upload rows, or future artifact records.
|
|
||||||
|
|
||||||
There is no distinct artifact table in the current schema. Artifact-like upload
|
|
||||||
references persisted in chat or document text are covered by the canonical
|
|
||||||
attachment-ID scan; any future artifact store must be added to reference discovery
|
|
||||||
before cleanup is allowed to consider its uploads unreferenced.
|
|
||||||
|
|
||||||
Cleanup and write reservations share the upload-index lock. This closes the
|
|
||||||
scan/write/delete race in the documented single-worker deployment; a future
|
|
||||||
multi-process deployment must add an inter-process lock or move lifecycle state
|
|
||||||
into the database before enabling destructive cleanup in more than one worker.
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
# Backup & Restore
|
|
||||||
|
|
||||||
Odysseus keeps all of your state in the `data/` directory — the SQLite database
|
|
||||||
(`app.db`), the Fernet encryption key (`data/.app_key`), the vault, memory, RAG
|
|
||||||
indexes, personal documents, and uploads. The `scripts/odysseus-backup` tool
|
|
||||||
snapshots that directory into a single gzip tarball and restores it later.
|
|
||||||
|
|
||||||
Snapshots are safe to take while the app is running: SQLite databases are copied
|
|
||||||
through SQLite's own `.backup` API rather than a raw file copy, so an in-flight
|
|
||||||
write can't corrupt the snapshot.
|
|
||||||
|
|
||||||
> **A snapshot contains your secrets.** The tarball includes the Fernet
|
|
||||||
> encryption key (`data/.app_key`), the vault, sessions, and any stored
|
|
||||||
> provider/API tokens — so treat it like a password. Store backups somewhere
|
|
||||||
> private, never commit them to Git, and prefer an encrypted destination when
|
|
||||||
> copying them offsite.
|
|
||||||
|
|
||||||
## Quick start
|
|
||||||
|
|
||||||
Run the tool from the repository root:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Create a snapshot → backups/odysseus-backup-<YYYYMMDD-HHMMSS>.tar.gz
|
|
||||||
./scripts/odysseus-backup snapshot
|
|
||||||
|
|
||||||
# List existing snapshots (most recent first)
|
|
||||||
./scripts/odysseus-backup list
|
|
||||||
|
|
||||||
# Check a tarball's integrity without extracting it
|
|
||||||
./scripts/odysseus-backup verify backups/odysseus-backup-20260101-120000.tar.gz
|
|
||||||
|
|
||||||
# Restore (destructive — see the warning below)
|
|
||||||
./scripts/odysseus-backup restore backups/odysseus-backup-20260101-120000.tar.gz --yes
|
|
||||||
```
|
|
||||||
|
|
||||||
The script depends only on the Python standard library, so any `python3` on your
|
|
||||||
`PATH` will run it — you don't need the app's virtualenv active.
|
|
||||||
|
|
||||||
Every command prints a JSON result. Add `--pretty` for indented output.
|
|
||||||
|
|
||||||
## Commands
|
|
||||||
|
|
||||||
### `snapshot`
|
|
||||||
|
|
||||||
Writes a `tar.gz` of `data/` to `backups/<timestamp>.tar.gz`.
|
|
||||||
|
|
||||||
| Flag | Effect |
|
|
||||||
| --- | --- |
|
|
||||||
| `--out PATH` | Write to a specific path instead of the default `backups/` location. Must be **outside** `data/`. |
|
|
||||||
| `--include-research` | Include `data/deep_research/` (skipped by default — research runs are large). |
|
|
||||||
| `--include-attachments` | Include `data/mail-attachments/` (skipped by default — cached IMAP extractions, re-derivable). |
|
|
||||||
|
|
||||||
By default the snapshot includes everything under `data/` **except**
|
|
||||||
`deep_research/` and `mail-attachments/`. Personal uploads and documents are
|
|
||||||
included.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Snapshot straight to a mounted NAS path
|
|
||||||
./scripts/odysseus-backup snapshot --out /mnt/nas/odysseus-$(date +%F).tar.gz
|
|
||||||
|
|
||||||
# Full snapshot including research runs and mail attachments
|
|
||||||
./scripts/odysseus-backup snapshot --include-research --include-attachments
|
|
||||||
```
|
|
||||||
|
|
||||||
### `list`
|
|
||||||
|
|
||||||
Lists the tarballs in `backups/`, most recent first, with size and modification
|
|
||||||
time.
|
|
||||||
|
|
||||||
### `verify PATH`
|
|
||||||
|
|
||||||
Opens the tarball read-only and walks every member to confirm it is intact and
|
|
||||||
safe to restore. Nothing is extracted. Use this before relying on an old backup
|
|
||||||
or after copying one across machines.
|
|
||||||
|
|
||||||
### `restore PATH --yes`
|
|
||||||
|
|
||||||
Overwrites `data/` from a tarball.
|
|
||||||
|
|
||||||
> **Restore is destructive.** It replaces the current `data/` directory. `--yes`
|
|
||||||
> is required so a mistyped command can't wipe your live state.
|
|
||||||
|
|
||||||
Restore is not a blind delete: before extracting, the tool **renames your current
|
|
||||||
`data/` to `data.before-restore-<timestamp>`** in the repository root. If a
|
|
||||||
restore turns out to be wrong, your previous state is still there — delete the
|
|
||||||
restored `data/` and rename the stashed directory back. The restore path is also
|
|
||||||
validated entry-by-entry: archives containing absolute paths, `..` segments,
|
|
||||||
symlinks, or anything outside `data/` are rejected.
|
|
||||||
|
|
||||||
## Scheduling offsite backups
|
|
||||||
|
|
||||||
The tarball output composes cleanly with cron and any copy tool. For example, a
|
|
||||||
nightly snapshot copied offsite:
|
|
||||||
|
|
||||||
```cron
|
|
||||||
0 3 * * * cd /path/to/odysseus && ./scripts/odysseus-backup snapshot --out "/mnt/nas/odysseus-$(date +\%F).tar.gz"
|
|
||||||
```
|
|
||||||
|
|
||||||
Swap the `--out` target for `scp`, `rclone`, `s3cmd`, or similar to push the
|
|
||||||
snapshot to remote storage.
|
|
||||||
|
|
||||||
## Docker vs native installs
|
|
||||||
|
|
||||||
The tool reads `data/` and writes `backups/` relative to the repository root, so
|
|
||||||
where you run it matters:
|
|
||||||
|
|
||||||
- **Native installs** — run it from the repo root as shown above. `data/` and
|
|
||||||
`backups/` are both in the repo directory.
|
|
||||||
- **Docker** — `docker-compose.yml` bind-mounts the host's `./data` to
|
|
||||||
`/app/data`, so the live data is also present on the host. **Run the tool on
|
|
||||||
the host** from the repo root; the snapshot reads the bind-mounted `./data` and
|
|
||||||
writes to `./backups` on the host. Running it *inside* the container is not
|
|
||||||
recommended, because `backups/` is not a mounted volume and the tarball would
|
|
||||||
be lost when the container is recreated.
|
|
||||||
|
|
||||||
> **ChromaDB caveat (Docker only).** In the Docker setup, ChromaDB stores its
|
|
||||||
> vectors in a separate Compose-managed volume (declared as `chromadb-data`),
|
|
||||||
> **not** under `./data`. `odysseus-backup` therefore does not capture the Docker
|
|
||||||
> ChromaDB store. Back it up separately if you need it. Compose prefixes the
|
|
||||||
> volume with the project name, so find the real name first
|
|
||||||
> (`docker volume ls | grep chromadb`), then archive it — for example:
|
|
||||||
>
|
|
||||||
> ```bash
|
|
||||||
> docker run --rm -v <project>_chromadb-data:/data -v "$PWD":/backup \
|
|
||||||
> alpine tar czf /backup/chromadb.tar.gz -C /data .
|
|
||||||
> ```
|
|
||||||
>
|
|
||||||
> On native installs ChromaDB lives at `data/chroma/` and is included in the
|
|
||||||
> snapshot normally.
|
|
||||||
|
After Width: | Height: | Size: 3.0 MiB |
|
After Width: | Height: | Size: 3.4 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
@@ -1,17 +0,0 @@
|
|||||||
# Outlook / Office 365 email accounts
|
|
||||||
|
|
||||||
Odysseus email accounts currently use IMAP and SMTP with username/password
|
|
||||||
authentication. That works for providers that still allow app passwords or
|
|
||||||
mailbox passwords for IMAP/SMTP.
|
|
||||||
|
|
||||||
Microsoft disables basic authentication for Outlook and Microsoft 365 in most
|
|
||||||
modern accounts and tenants. If you try to add an Outlook account with a normal
|
|
||||||
password, Microsoft may return errors such as:
|
|
||||||
|
|
||||||
- `IMAP: AUTHENTICATE failed`
|
|
||||||
- `SMTP: 535 5.7.139 Authentication unsuccessful, basic authentication is disabled`
|
|
||||||
|
|
||||||
This is expected. Odysseus does not support Microsoft OAuth or Graph Mail yet,
|
|
||||||
so Outlook / Office 365 accounts cannot currently be added through the password
|
|
||||||
form. Use another email provider with app-password support, or track the future
|
|
||||||
Microsoft Graph OAuth integration.
|
|
||||||
@@ -25,16 +25,9 @@
|
|||||||
--radius: 8px;
|
--radius: 8px;
|
||||||
}
|
}
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
html { scroll-behavior: smooth; scroll-padding-top: 60px; }
|
html { scroll-behavior: smooth; scroll-snap-type: y proximity; scroll-padding-top: 60px; }
|
||||||
/* REMOVED: "scroll-snap-type: y proximity"
|
/* Each section is a full-viewport "page" with its content centered, so only
|
||||||
The idea was: >>Each section is a full-viewport "page" with its content centered,
|
one shows at a time and the snap is obvious. */
|
||||||
so only one shows at a time and the snap is obvious.<<
|
|
||||||
|
|
||||||
PROBLEM: sections easily grow taller than 100vh IRL
|
|
||||||
This cause forced jumps mid-read. It's intrusive UX.
|
|
||||||
The landing-page is not a PowerPoint presentation!
|
|
||||||
|
|
||||||
Preserved: CSS snap-points to avoid destroying code meta-data*/
|
|
||||||
.hero, section {
|
.hero, section {
|
||||||
scroll-snap-align: start; min-height: 100vh;
|
scroll-snap-align: start; min-height: 100vh;
|
||||||
display: flex; flex-direction: column; justify-content: center;
|
display: flex; flex-direction: column; justify-content: center;
|
||||||
@@ -409,7 +402,7 @@
|
|||||||
<a href="#testimonials">Testimonials</a>
|
<a href="#testimonials">Testimonials</a>
|
||||||
<a href="#how">How it started</a>
|
<a href="#how">How it started</a>
|
||||||
<a href="#start">Get started</a>
|
<a href="#start">Get started</a>
|
||||||
<a class="btn" href="https://github.com/odysseus-dev/odysseus" target="_blank">
|
<a class="btn" href="https://github.com/pewdiepie-archdaemon/odysseus" target="_blank">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M12 .5C5.7.5.5 5.7.5 12c0 5.1 3.3 9.4 7.9 10.9.6.1.8-.2.8-.6v-2c-3.2.7-3.9-1.5-3.9-1.5-.5-1.3-1.3-1.7-1.3-1.7-1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.7 1.3 3.4 1 .1-.8.4-1.3.7-1.6-2.6-.3-5.3-1.3-5.3-5.7 0-1.3.5-2.3 1.2-3.1-.1-.3-.5-1.5.1-3.1 0 0 1-.3 3.3 1.2a11.5 11.5 0 0 1 6 0C17.3 4.7 18.3 5 18.3 5c.6 1.6.2 2.8.1 3.1.8.8 1.2 1.8 1.2 3.1 0 4.4-2.7 5.4-5.3 5.7.4.4.8 1.1.8 2.2v3.3c0 .4.2.7.8.6 4.6-1.5 7.9-5.8 7.9-10.9C23.5 5.7 18.3.5 12 .5z"/></svg>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M12 .5C5.7.5.5 5.7.5 12c0 5.1 3.3 9.4 7.9 10.9.6.1.8-.2.8-.6v-2c-3.2.7-3.9-1.5-3.9-1.5-.5-1.3-1.3-1.7-1.3-1.7-1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.7 1.3 3.4 1 .1-.8.4-1.3.7-1.6-2.6-.3-5.3-1.3-5.3-5.7 0-1.3.5-2.3 1.2-3.1-.1-.3-.5-1.5.1-3.1 0 0 1-.3 3.3 1.2a11.5 11.5 0 0 1 6 0C17.3 4.7 18.3 5 18.3 5c.6 1.6.2 2.8.1 3.1.8.8 1.2 1.8 1.2 3.1 0 4.4-2.7 5.4-5.3 5.7.4.4.8 1.1.8 2.2v3.3c0 .4.2.7.8.6 4.6-1.5 7.9-5.8 7.9-10.9C23.5 5.7 18.3.5 12 .5z"/></svg>
|
||||||
GitHub
|
GitHub
|
||||||
</a>
|
</a>
|
||||||
@@ -437,7 +430,7 @@
|
|||||||
</p>
|
</p>
|
||||||
<div class="hero-cta">
|
<div class="hero-cta">
|
||||||
<a class="btn primary" href="#start">Get started</a>
|
<a class="btn primary" href="#start">Get started</a>
|
||||||
<a class="btn" href="https://github.com/odysseus-dev/odysseus" target="_blank">View on GitHub</a>
|
<a class="btn" href="https://github.com/pewdiepie-archdaemon/odysseus" target="_blank">View on GitHub</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -678,9 +671,9 @@
|
|||||||
<div class="eyebrow"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 17l6-5-6-5"/><path d="M12 19h8"/></svg>Get started</div>
|
<div class="eyebrow"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 17l6-5-6-5"/><path d="M12 19h8"/></svg>Get started</div>
|
||||||
<h2 class="h" style="margin-bottom:6px;">Odysseus is yours.</h2>
|
<h2 class="h" style="margin-bottom:6px;">Odysseus is yours.</h2>
|
||||||
<p class="sub center" style="margin:0 auto;">It's open source and free. No sales team, no demo request, no Trojan horse.</p>
|
<p class="sub center" style="margin:0 auto;">It's open source and free. No sales team, no demo request, no Trojan horse.</p>
|
||||||
<div class="codeblock"><span><span class="prompt">$</span> git clone https://github.com/odysseus-dev/odysseus.git && cd odysseus</span><button class="copy-btn" data-copy="git clone https://github.com/odysseus-dev/odysseus.git && cd odysseus">Copy</button></div>
|
<div class="codeblock"><span><span class="prompt">$</span> git clone https://github.com/pewdiepie-archdaemon/odysseus.git && cd odysseus</span><button class="copy-btn" data-copy="git clone https://github.com/pewdiepie-archdaemon/odysseus.git && cd odysseus">Copy</button></div>
|
||||||
<div>
|
<div>
|
||||||
<a class="btn primary" href="https://github.com/odysseus-dev/odysseus" target="_blank" style="margin-top:14px;">View on GitHub</a>
|
<a class="btn primary" href="https://github.com/pewdiepie-archdaemon/odysseus" target="_blank" style="margin-top:14px;">View on GitHub</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="pill-row">
|
<div class="pill-row">
|
||||||
<span class="pill">Self-hosted</span>
|
<span class="pill">Self-hosted</span>
|
||||||
|
|||||||
|
After Width: | Height: | Size: 1003 KiB |
|
Before Width: | Height: | Size: 185 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 79 KiB After Width: | Height: | Size: 45 KiB |
@@ -1,188 +0,0 @@
|
|||||||
# PR Blocker Audit
|
|
||||||
|
|
||||||
`scripts/pr_blocker_audit.py` is a small, read-only triage helper for maintainers who need to inspect open pull request overlap before reviewing or starting related work.
|
|
||||||
|
|
||||||
It is a triage helper, not a replacement for maintainer judgment.
|
|
||||||
|
|
||||||
## What it does
|
|
||||||
|
|
||||||
- Reads open PR metadata from a local JSON file or from `gh`.
|
|
||||||
- Reports files touched by more than one open PR.
|
|
||||||
- Groups active work into broad code areas.
|
|
||||||
- Ranks PRs with a deterministic heuristic score.
|
|
||||||
- Flags possible duplicate candidates based on title keyword overlap and changed-file similarity.
|
|
||||||
- Suggests quieter areas for conservative new work.
|
|
||||||
- Prints Markdown by default, compact terminal output when requested, or machine-readable JSON.
|
|
||||||
|
|
||||||
## What it does not do
|
|
||||||
|
|
||||||
- It does not post comments.
|
|
||||||
- It does not review, approve, label, close, merge, or otherwise mutate PRs.
|
|
||||||
- It does not add or run GitHub Actions.
|
|
||||||
- It does not import the Odysseus application package.
|
|
||||||
- It does not claim that a PR is definitely blocked or duplicated.
|
|
||||||
|
|
||||||
## Read-only safety guarantee
|
|
||||||
|
|
||||||
Offline mode only reads a local JSON file. Live mode runs read-only GitHub CLI commands:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
gh pr list --repo OWNER/REPO --state open --limit 1000 --json number,title,author,files,mergeStateStatus,reviewDecision,updatedAt,url
|
|
||||||
```
|
|
||||||
|
|
||||||
If a PR from that list has missing or empty changed-file metadata, live mode fills it with read-only per-PR REST calls:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
gh api --paginate "repos/OWNER/REPO/pulls/NUMBER/files?per_page=100"
|
|
||||||
```
|
|
||||||
|
|
||||||
If that GraphQL-backed command fails, it falls back to:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
gh api --paginate "repos/OWNER/REPO/pulls?state=open&per_page=100"
|
|
||||||
```
|
|
||||||
|
|
||||||
Per-PR file fetching makes live overlap results useful, but it can be slower on repositories with hundreds of open PRs.
|
|
||||||
|
|
||||||
## Generate input JSON
|
|
||||||
|
|
||||||
For repeatable offline audits, capture PR metadata first:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
gh pr list --repo OWNER/REPO --state open --limit 1000 --json number,title,author,files,mergeStateStatus,reviewDecision,updatedAt,url > open-prs.json
|
|
||||||
```
|
|
||||||
|
|
||||||
## Run offline mode
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 scripts/pr_blocker_audit.py --input open-prs.json
|
|
||||||
```
|
|
||||||
|
|
||||||
## Run live mode
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 scripts/pr_blocker_audit.py --repo OWNER/REPO
|
|
||||||
```
|
|
||||||
|
|
||||||
Live mode fetches up to 1000 open PRs by default. Use `--limit` to cap how many open PRs are fetched and analyzed, and `--top` to cap how many rows are displayed in ranked sections:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 scripts/pr_blocker_audit.py --repo OWNER/REPO --limit 50 --top 10
|
|
||||||
```
|
|
||||||
|
|
||||||
Live mode may take time on large PR queues because it fetches changed-file metadata for each PR that did not include it in the initial list response. Progress is shown on `stderr` by default only when `stderr` is a TTY:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 scripts/pr_blocker_audit.py --repo OWNER/REPO --progress auto
|
|
||||||
python3 scripts/pr_blocker_audit.py --repo OWNER/REPO --progress always
|
|
||||||
python3 scripts/pr_blocker_audit.py --repo OWNER/REPO --progress never
|
|
||||||
```
|
|
||||||
|
|
||||||
Use `--quiet` to suppress progress and non-fatal warning output. Progress and warnings never go to `stdout`, so redirected reports and `--output` files remain clean.
|
|
||||||
|
|
||||||
For a faster metadata-only scan, skip changed-file metadata entirely:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 scripts/pr_blocker_audit.py --repo OWNER/REPO --no-fetch-files
|
|
||||||
```
|
|
||||||
|
|
||||||
## JSON output
|
|
||||||
|
|
||||||
Use `--format json` for machine-readable output suitable for scripting or downstream tooling:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 scripts/pr_blocker_audit.py --input open-prs.json --format json
|
|
||||||
python3 scripts/pr_blocker_audit.py --input open-prs.json --format json --output report.json
|
|
||||||
```
|
|
||||||
|
|
||||||
JSON output is stable and deterministic for the same input. It uses `sort_keys=True` so field order does not vary between runs. It never includes ANSI escape codes, even with `--color always`. Progress text is always `stderr`-only and never appears in JSON output.
|
|
||||||
|
|
||||||
The top-level object contains these keys:
|
|
||||||
|
|
||||||
- `summary` — scalar overview: `total_prs_analyzed`, `unique_files_touched`, `prs_missing_changed_file_metadata`, `main_overlap_drivers`, `highest_risk_areas`, `recommended_first_review_target`
|
|
||||||
- `locked_areas` — list of objects with `area`, `files` (top paths as a string), `prs` (list of PR numbers), `why`, `priority`
|
|
||||||
- `hot_files` — list of objects with `file`, `pr_count`, `pr_numbers` (list of PR numbers); capped at `--top`
|
|
||||||
- `review_priorities` — ranked list with `rank`, `number`, `score`, `title`, `url`, `merge_state`, `review_decision`, `reasons` (list); capped at `--top`
|
|
||||||
- `duplicate_candidates` — list of objects with `pr_numbers` (list) and `titles` (list, one entry per PR in the group)
|
|
||||||
- `safer_areas` — list of strings
|
|
||||||
|
|
||||||
## Write output to a file
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 scripts/pr_blocker_audit.py --input open-prs.json --output pr-blocker-report.md
|
|
||||||
python3 scripts/pr_blocker_audit.py --input open-prs.json --format json --output report.json
|
|
||||||
```
|
|
||||||
|
|
||||||
Markdown and JSON output never include ANSI color codes. ANSI codes are stripped defensively when writing any output file.
|
|
||||||
|
|
||||||
## Terminal output and color
|
|
||||||
|
|
||||||
Use terminal output for quick interactive scans:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 scripts/pr_blocker_audit.py --input open-prs.json --format terminal
|
|
||||||
```
|
|
||||||
|
|
||||||
Terminal output includes locked areas, hot files, review / blocker priorities, possible duplicate candidates, and safer areas.
|
|
||||||
|
|
||||||
Color is readability-only. It is never included in Markdown reports and is stripped defensively when writing output files. Color modes are:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 scripts/pr_blocker_audit.py --input open-prs.json --format terminal --color auto
|
|
||||||
python3 scripts/pr_blocker_audit.py --input open-prs.json --format terminal --color always
|
|
||||||
python3 scripts/pr_blocker_audit.py --input open-prs.json --format terminal --color never
|
|
||||||
```
|
|
||||||
|
|
||||||
`--no-color` is kept as an alias for `--color never`. With `--color auto`, color is used only for terminal output on a TTY when `NO_COLOR` is not set and output is not being written to a file.
|
|
||||||
|
|
||||||
## Interpret locked areas
|
|
||||||
|
|
||||||
Locked areas are broad categories with one or more open PRs. An area is higher priority when several PRs touch it, when PRs share files, or when the highest scoring PR in that area has risk signals. Treat this as a prompt to inspect the PRs together.
|
|
||||||
|
|
||||||
`PRs missing changed-file metadata` counts PRs that still had no changed-file paths after live file fetching, or PRs from offline input that did not include files. Those PRs can still appear in area summaries from title matching, but file overlap analysis is weaker for them.
|
|
||||||
|
|
||||||
`Docs / tooling / tests` is conservative: runtime PRs are not classified there just because they include tests or README changes. Docs-only, README-only, scripts-only, tests-only, or strongly titled docs/tooling/test work still maps there.
|
|
||||||
|
|
||||||
`Other / unclassified` is kept visible for PRs that do not match the area rules. When most of it comes from missing file metadata, the report summarizes that instead of letting long PR lists dominate the locked-area section.
|
|
||||||
|
|
||||||
## Interpret duplicate candidates
|
|
||||||
|
|
||||||
Duplicate candidates are labeled as possible duplicate / needs human review. The script groups PRs only when their file sets are highly similar and their titles share meaningful keywords. Similar PRs can still be complementary.
|
|
||||||
|
|
||||||
## Interpret heuristic scores
|
|
||||||
|
|
||||||
The review priority score is deterministic for the same input. Recency is measured against the newest parseable PR update timestamp in the input, and the score uses simple weights for:
|
|
||||||
|
|
||||||
- direct auth, bearer-token, API-token, privilege, or permission lifecycle signals
|
|
||||||
- security, secret, or data exposure keywords
|
|
||||||
- persistence, migration, database, SQLite, or Postgres keywords
|
|
||||||
- memory, vector, RAG, embedding, or retrieval keywords
|
|
||||||
- overlapping changed files
|
|
||||||
- clean merge state as a small actionability signal
|
|
||||||
- review state
|
|
||||||
- recently updated PRs when timestamp data exists
|
|
||||||
|
|
||||||
Higher scores mean "inspect earlier", not "correct" or "merge-ready". Broad PRs can score high because they overlap many files and may block other work, but they still need normal review and validation.
|
|
||||||
|
|
||||||
Dirty, blocked, conflicting, and unknown merge states are shown as risk/caution reasons. They do not add importance points by themselves.
|
|
||||||
|
|
||||||
## Design note: intentional single-script layout
|
|
||||||
|
|
||||||
`pr_blocker_audit.py` is intentionally kept as one standalone script. The goal is to keep this maintainer/contributor workflow helper low-friction while broader repo tooling and test-suite conventions are still evolving. Splitting it into packages or modules is not ruled out, but is deferred until there is a clearer settled pattern to follow.
|
|
||||||
|
|
||||||
## Limitations
|
|
||||||
|
|
||||||
- Some PRs may still lack changed files if GitHub file metadata calls fail or metadata-only mode is used.
|
|
||||||
- Area classification is intentionally small and editable.
|
|
||||||
- Title keyword matching misses semantic duplicates.
|
|
||||||
- Heuristic scoring cannot know project strategy, reviewer availability, or hidden dependency chains.
|
|
||||||
- Empty or missing file metadata produces a valid report but weak overlap analysis.
|
|
||||||
|
|
||||||
## Validation
|
|
||||||
|
|
||||||
```bash
|
|
||||||
venv/bin/python -m py_compile scripts/pr_blocker_audit.py tests/test_pr_blocker_audit.py
|
|
||||||
venv/bin/python -m pytest tests/test_pr_blocker_audit.py -q
|
|
||||||
python3 scripts/pr_blocker_audit.py --help
|
|
||||||
git diff --check
|
|
||||||
```
|
|
||||||
|
After Width: | Height: | Size: 2.5 MiB |
@@ -1,101 +0,0 @@
|
|||||||
# Security CI guide
|
|
||||||
|
|
||||||
This project runs a set of automated security checks on pull requests and
|
|
||||||
selected branch pushes. This page explains what each one does, whether it can
|
|
||||||
block a merge, and the few one-time settings you should turn on to get the full
|
|
||||||
benefit.
|
|
||||||
|
|
||||||
## What runs, and why
|
|
||||||
|
|
||||||
Most checks live in files under `.github/workflows/`. CodeQL uses the
|
|
||||||
checked-in advanced configuration in `.github/workflows/codeql.yml`. They run
|
|
||||||
automatically; you do not start them.
|
|
||||||
|
|
||||||
| Check | What it protects against | Blocks a merge? |
|
|
||||||
|---|---|---|
|
|
||||||
| **Secret scan** (gitleaks) | An API key, token, or password being committed by mistake or on purpose | Yes |
|
|
||||||
| **Workflow security** (actionlint + zizmor) | A broken or insecure automation file that could leak the repo's access token | Yes |
|
|
||||||
| **Dependency review** | A pull request that adds a software library with a known security hole | Yes |
|
|
||||||
| **pip-audit** | Known security holes in the Python libraries already used | No (advisory) |
|
|
||||||
| **Container scan: hadolint** | Mistakes and insecure patterns in the `Dockerfile` | Yes |
|
|
||||||
| **Container scan: Trivy** | Known security holes in the Docker image | No (advisory) |
|
|
||||||
| **CodeQL** | Real bugs in the app's own code: injection, auth mistakes, path traversal | No (advisory) |
|
|
||||||
|
|
||||||
"Blocks a merge" means a red X appears on the pull request and, once you enable
|
|
||||||
the setting below, the **Merge** button is disabled until it is fixed.
|
|
||||||
|
|
||||||
"Advisory" means it reports problems into the repository's **Security** tab so
|
|
||||||
you can review them on your own schedule, but it never stops a merge. These are
|
|
||||||
advisory on purpose: they often flag long-standing issues in other people's
|
|
||||||
libraries, not something a given pull request introduced.
|
|
||||||
|
|
||||||
## Where results appear
|
|
||||||
|
|
||||||
- **Checks tab of a pull request**: the pass/fail of each check. A green tick is
|
|
||||||
good; a red X needs attention.
|
|
||||||
- **Security tab of the repository**: detailed findings from the advisory
|
|
||||||
scanners (Trivy and CodeQL). This is your dashboard.
|
|
||||||
|
|
||||||
## If a check fails
|
|
||||||
|
|
||||||
- **Secret scan failed**: a real credential may have been committed. Treat it as
|
|
||||||
leaked: rotate (regenerate) that key or token immediately, then remove it from
|
|
||||||
the file. Do not just delete the commit; assume it was seen.
|
|
||||||
- **Dependency review failed**: the pull request adds a library with a known
|
|
||||||
vulnerability. Ask the contributor to use a patched version, or decline the
|
|
||||||
change.
|
|
||||||
- **hadolint / workflow security failed**: the contributor changed the
|
|
||||||
`Dockerfile` or an automation file in a way the linter rejects. Ask them to
|
|
||||||
address the message shown in the failed check.
|
|
||||||
|
|
||||||
## One-time settings to turn on
|
|
||||||
|
|
||||||
These two settings unlock the full value. You only do them once.
|
|
||||||
|
|
||||||
### 1. Require the blocking checks before merging
|
|
||||||
|
|
||||||
This makes the **Merge** button refuse to work until the gating checks pass.
|
|
||||||
|
|
||||||
1. Go to the repository on GitHub.
|
|
||||||
2. Click **Settings** (top right of the repo).
|
|
||||||
3. In the left sidebar, click **Branches**.
|
|
||||||
4. Under **Branch protection rules**, click **Add branch ruleset** (or **Add
|
|
||||||
rule**), and set the branch name pattern to `dev` (this is the branch all
|
|
||||||
pull requests target; `main` is fast-forwarded at releases).
|
|
||||||
5. Enable **Require status checks to pass before merging**.
|
|
||||||
6. In the search box that appears, add these checks by name:
|
|
||||||
- `Python syntax (compileall)`
|
|
||||||
- `JS syntax (node --check)`
|
|
||||||
- `gitleaks`
|
|
||||||
- `actionlint`
|
|
||||||
- `zizmor (Actions SAST)`
|
|
||||||
- `hadolint (Dockerfile lint)`
|
|
||||||
- `dependency-review (PR gate)`
|
|
||||||
|
|
||||||
The first two come from the correctness CI (`ci.yml`); the rest are this
|
|
||||||
security suite. Leave pytest, pip-audit, Trivy, and CodeQL unchecked so they
|
|
||||||
stay advisory.
|
|
||||||
7. Also enable **Require a pull request before merging** and **Require review
|
|
||||||
from Code Owners** (this uses the `.github/CODEOWNERS` file so every change
|
|
||||||
needs your sign-off).
|
|
||||||
8. Click **Create** / **Save changes**.
|
|
||||||
|
|
||||||
Note: a check name only appears in the list after it has run at least once, so
|
|
||||||
let the workflows run on one pull request first, then add them here.
|
|
||||||
|
|
||||||
### 2. Turn on the Security tab features
|
|
||||||
|
|
||||||
1. **Settings -> Code security** (or **Code security and analysis**).
|
|
||||||
2. Turn on **Dependency graph** (usually on by default for public repos) -- this
|
|
||||||
powers Dependency review and Dependabot.
|
|
||||||
3. Turn on **Dependabot alerts** and **Dependabot security updates**.
|
|
||||||
4. Under **Code scanning**, keep **Default setup** disabled. CodeQL is
|
|
||||||
configured by `.github/workflows/codeql.yml`; enabling default setup at the
|
|
||||||
same time causes GitHub to reject uploads from the checked-in workflow.
|
|
||||||
|
|
||||||
## Keeping it current
|
|
||||||
|
|
||||||
`.github/dependabot.yml` opens small weekly pull requests to update Python and
|
|
||||||
npm packages, the Docker base image, and the pinned automation actions
|
|
||||||
themselves. Review and merge those like any other pull request; they keep the
|
|
||||||
project patched without manual tracking.
|
|
||||||
@@ -1,567 +0,0 @@
|
|||||||
# Odysseus Setup Guide
|
|
||||||
|
|
||||||
This guide covers installation, deployment, troubleshooting, and configuration.
|
|
||||||
For a minimal Docker installation, start with the
|
|
||||||
[repository README](../README.md#quick-start).
|
|
||||||
|
|
||||||
## On this page
|
|
||||||
|
|
||||||
- [Quick Start](#quick-start)
|
|
||||||
- [Docker](#docker-recommended)
|
|
||||||
- [Native Linux and macOS](#native-linux--macos)
|
|
||||||
- [Apple Silicon](#apple-silicon)
|
|
||||||
- [Native Windows](#native-windows)
|
|
||||||
- [Troubleshooting and advanced setup](#troubleshooting--advanced-setup)
|
|
||||||
- [Security notes](#security-notes)
|
|
||||||
- [Configuration](#configuration)
|
|
||||||
- [Architecture](#architecture)
|
|
||||||
- [Data and backups](#data)
|
|
||||||
|
|
||||||
Related guidance:
|
|
||||||
|
|
||||||
- [Security policy](../SECURITY.md)
|
|
||||||
- [Architecture overview](ARCHITECTURE.md)
|
|
||||||
- [Backup and restore guide](backup-restore.md)
|
|
||||||
- [Contributing guide](../CONTRIBUTING.md)
|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
> **Branch note:** `dev` is the default branch and contains the latest development changes, but it may be unstable. For the more stable curated branch, use [`main`](https://github.com/odysseus-dev/odysseus/tree/main).
|
|
||||||
|
|
||||||
Defaults work out of the box: clone, run, then configure models/search/email
|
|
||||||
inside **Settings**. Only edit `.env` for deployment-level overrides like
|
|
||||||
`APP_BIND`, `APP_PORT`, `AUTH_ENABLED`, `DATABASE_URL`, or a pre-seeded admin password.
|
|
||||||
|
|
||||||
On first setup, Odysseus creates an admin account (`admin` unless
|
|
||||||
`ODYSSEUS_ADMIN_USER` is set) and prints a temporary password in the terminal.
|
|
||||||
For Docker installs, the same line is in `docker compose logs odysseus`.
|
|
||||||
Use that for the first login, then change it in **Settings**.
|
|
||||||
|
|
||||||
Contributing? See the [contributing guide](../CONTRIBUTING.md) for development
|
|
||||||
setup, testing, and pull request guidelines.
|
|
||||||
|
|
||||||
### Docker (recommended)
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/odysseus-dev/odysseus.git
|
|
||||||
cd odysseus
|
|
||||||
cp .env.example .env # optional, but recommended for explicit defaults
|
|
||||||
docker compose up -d --build
|
|
||||||
```
|
|
||||||
To include optional extras in the image (PDF viewer, Office extraction; includes AGPL PyMuPDF), build with `docker compose build --build-arg INSTALL_OPTIONAL=true` before `up`.
|
|
||||||
|
|
||||||
Open `http://localhost:7000` when the containers are healthy. Docker Compose
|
|
||||||
binds the web UI to `127.0.0.1` by default. If the port is taken, set
|
|
||||||
`APP_PORT=7001` in `.env` and recreate the container. Set `APP_BIND=0.0.0.0`
|
|
||||||
only when you intentionally want LAN/reverse-proxy access.
|
|
||||||
|
|
||||||
> **On Apple Silicon (M-series) Macs:** Docker can't reach the Metal GPU, so
|
|
||||||
> Cookbook serves local models on CPU only. For GPU-accelerated model serving,
|
|
||||||
> run natively instead — see [Apple Silicon](#apple-silicon) below.
|
|
||||||
|
|
||||||
### Native Linux / macOS
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/odysseus-dev/odysseus.git
|
|
||||||
cd odysseus
|
|
||||||
python3 -m venv venv
|
|
||||||
source venv/bin/activate
|
|
||||||
pip install -r requirements.txt
|
|
||||||
python setup.py
|
|
||||||
python -m uvicorn app:app --host 127.0.0.1 --port 7000
|
|
||||||
```
|
|
||||||
Requirements: Python 3.11+. Cookbook also needs `tmux` for background model
|
|
||||||
downloads and serves. The app itself is lightweight; local model serving is the
|
|
||||||
heavy part and depends on the model, runtime, GPU, and VRAM, so small hosts can
|
|
||||||
connect to API or remote model servers instead. Use `--host 0.0.0.0` only when you intentionally want LAN/reverse-proxy access.
|
|
||||||
|
|
||||||
### Apple Silicon
|
|
||||||
Docker on macOS cannot use the Metal GPU. For GPU-accelerated Cookbook on an
|
|
||||||
M-series Mac, run Odysseus natively:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/odysseus-dev/odysseus.git
|
|
||||||
cd odysseus
|
|
||||||
./start-macos.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
It launches at `http://127.0.0.1:7860`. To expose it to your phone over a trusted LAN/VPN such as Tailscale, bind all interfaces:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ODYSSEUS_HOST=0.0.0.0 ./start-macos.sh
|
|
||||||
# then open http://<tailscale-ip>:7860
|
|
||||||
```
|
|
||||||
|
|
||||||
The script also reads `.env` at startup, so `APP_BIND=0.0.0.0` and `APP_PORT`
|
|
||||||
set there are picked up automatically without a command-line override each run.
|
|
||||||
|
|
||||||
Keep `AUTH_ENABLED=true` (the default) before binding outside loopback. Do not
|
|
||||||
expose this port directly to the public internet. To build a clickable app wrapper:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./build-macos-app.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>Cookbook, GPU, Ollama, and troubleshooting notes</summary>
|
|
||||||
|
|
||||||
**Docker bundled services.** Compose starts Odysseus, ChromaDB, SearXNG, and
|
|
||||||
ntfy. Odysseus and the bundled service ports bind to `127.0.0.1` by default, so
|
|
||||||
they are reachable from the host but not exposed to your LAN/public internet
|
|
||||||
unless you opt in.
|
|
||||||
|
|
||||||
**Cookbook storage in Docker.** Downloads live in `./data/huggingface`
|
|
||||||
(`~/.cache/huggingface` in the container). Cookbook-installed Python CLIs and
|
|
||||||
serve engines live in `./data/local` (`~/.local` in the container), so they
|
|
||||||
survive container recreation.
|
|
||||||
|
|
||||||
**Remote servers.** In **Cookbook -> Settings -> Servers**, generate the
|
|
||||||
Odysseus SSH key and add the public key to the remote server's
|
|
||||||
`~/.ssh/authorized_keys`. From the host you can also run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ssh-copy-id -i data/ssh/id_ed25519.pub user@server
|
|
||||||
```
|
|
||||||
|
|
||||||
**Host Docker access (explicit opt-in).** Default Docker Compose intentionally
|
|
||||||
does not mount `/var/run/docker.sock`. You can still connect Odysseus to
|
|
||||||
existing Ollama, vLLM, and other OpenAI-compatible endpoints without Docker
|
|
||||||
socket access.
|
|
||||||
|
|
||||||
Cookbook/local Docker-daemon management requires the opt-in overlay below. Raw
|
|
||||||
Docker socket access is high-trust because it can effectively grant broad
|
|
||||||
control over the host Docker daemon. Remote server Docker workflows over SSH
|
|
||||||
remain preferred.
|
|
||||||
|
|
||||||
Place these values in `.env`, or export them in the shell before running
|
|
||||||
`docker compose`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
COMPOSE_FILE=docker-compose.yml:docker/host-docker.yml
|
|
||||||
DOCKER_GID=<host docker group gid>
|
|
||||||
```
|
|
||||||
|
|
||||||
Combine host Docker access with a GPU overlay when both are intentionally
|
|
||||||
required:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
COMPOSE_FILE=docker-compose.yml:docker/gpu.nvidia.yml:docker/host-docker.yml
|
|
||||||
# or
|
|
||||||
COMPOSE_FILE=docker-compose.yml:docker/gpu.amd.yml:docker/host-docker.yml
|
|
||||||
```
|
|
||||||
|
|
||||||
**Docker GPU overlays.** CPU-only users can skip this section. Cookbook can
|
|
||||||
only detect GPUs that Docker exposes to the container — if the host runtime or
|
|
||||||
device passthrough is not configured, Cookbook sees the iGPU, another card, or
|
|
||||||
CPU instead of your intended GPU.
|
|
||||||
|
|
||||||
For NVIDIA, `scripts/check-docker-gpu.sh` diagnoses GPU passthrough and can
|
|
||||||
optionally install the host runtime or update `.env`.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Read-only diagnostic (default — installs nothing, never edits .env):
|
|
||||||
scripts/check-docker-gpu.sh
|
|
||||||
|
|
||||||
# Print OS-specific install commands without running them:
|
|
||||||
scripts/check-docker-gpu.sh --print-install-commands
|
|
||||||
|
|
||||||
# Install NVIDIA Container Toolkit on Ubuntu/Debian (requires sudo):
|
|
||||||
scripts/check-docker-gpu.sh --install-nvidia-toolkit
|
|
||||||
|
|
||||||
# Write COMPOSE_FILE to .env (only when GPU passthrough is confirmed working):
|
|
||||||
scripts/check-docker-gpu.sh --enable-nvidia-overlay
|
|
||||||
|
|
||||||
# Full assisted setup — install toolkit, then enable overlay if passthrough works:
|
|
||||||
scripts/check-docker-gpu.sh --install-nvidia-toolkit --enable-nvidia-overlay
|
|
||||||
```
|
|
||||||
#### Arch Linux NVIDIA Docker notes
|
|
||||||
|
|
||||||
On Arch Linux, verify the host NVIDIA driver and Docker GPU passthrough before enabling the Odysseus NVIDIA overlay.
|
|
||||||
|
|
||||||
Install the required packages:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo pacman -Syu
|
|
||||||
sudo pacman -S docker docker-compose nvidia-container-toolkit nvidia-utils
|
|
||||||
sudo systemctl enable --now docker
|
|
||||||
```
|
|
||||||
|
|
||||||
Configure Docker to use the NVIDIA container runtime:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo nvidia-ctk runtime configure --runtime=docker
|
|
||||||
sudo systemctl restart docker
|
|
||||||
```
|
|
||||||
|
|
||||||
Verify the host GPU:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
nvidia-smi
|
|
||||||
```
|
|
||||||
|
|
||||||
Verify Docker GPU passthrough:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker run --rm --gpus all nvidia/cuda:12.9.0-base-ubuntu22.04 nvidia-smi
|
|
||||||
```
|
|
||||||
|
|
||||||
Then enable the Odysseus NVIDIA compose overlay:
|
|
||||||
|
|
||||||
```env
|
|
||||||
COMPOSE_FILE=docker-compose.yml:docker/gpu.nvidia.yml
|
|
||||||
```
|
|
||||||
|
|
||||||
Rebuild and verify the GPU inside the Odysseus container:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose up -d --build
|
|
||||||
docker compose exec odysseus nvidia-smi -L
|
|
||||||
```
|
|
||||||
|
|
||||||
For first-time local model testing on 8 GB laptop GPUs, start with GGUF/Q4 models on llama.cpp before trying GPTQ/AWQ models on vLLM or SGLang. This keeps the first run simpler while confirming GPU passthrough works.
|
|
||||||
|
|
||||||
**WSL2 + snap Docker.** If the NVIDIA check fails with this error, Docker may be
|
|
||||||
installed via snap:
|
|
||||||
|
|
||||||
```text
|
|
||||||
failed to fulfil mount request: open /usr/lib/wsl/lib/libdxcore.so: no such file or directory
|
|
||||||
```
|
|
||||||
|
|
||||||
Check with `snap list docker` or:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker info --format '{{.DockerRootDir}}'
|
|
||||||
```
|
|
||||||
|
|
||||||
A Docker root under `/var/snap/docker/` means snap confinement can prevent
|
|
||||||
Docker from seeing WSL2's `/usr/lib/wsl/lib` GPU libraries even when the files
|
|
||||||
exist on the host. Reinstalling or reconfiguring `nvidia-container-toolkit` will
|
|
||||||
not fix that. Remove snap Docker, install the official apt-based Docker Engine
|
|
||||||
([Docker docs](https://docs.docker.com/engine/install/ubuntu/)), then configure
|
|
||||||
the NVIDIA runtime again:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo snap remove docker
|
|
||||||
sudo nvidia-ctk runtime configure --runtime=docker
|
|
||||||
sudo systemctl restart docker
|
|
||||||
```
|
|
||||||
|
|
||||||
Then re-run `scripts/check-docker-gpu.sh`.
|
|
||||||
|
|
||||||
Safety notes:
|
|
||||||
- The app never installs host GPU runtime automatically.
|
|
||||||
- The app never edits `.env` automatically.
|
|
||||||
- `.env` is only modified when `--enable-nvidia-overlay` is explicitly passed,
|
|
||||||
and only after GPU passthrough succeeds. `--yes` skips prompts but does not
|
|
||||||
bypass the passthrough gate.
|
|
||||||
- `.env.bak.*` backups created by `--enable-nvidia-overlay` are ignored by
|
|
||||||
Git and the Docker build context.
|
|
||||||
|
|
||||||
To enable manually without the script, add this to `.env`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
COMPOSE_FILE=docker-compose.yml:docker/gpu.nvidia.yml
|
|
||||||
```
|
|
||||||
|
|
||||||
**AMD / ROCm.** AMD setup is read-only diagnostic plus manual `.env` edit. Run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
scripts/check-docker-amd-gpu.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
Then add the reported values to `.env`, replacing `RENDER_GID` with your host's
|
|
||||||
numeric render group id:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
COMPOSE_FILE=docker-compose.yml:docker/gpu.amd.yml
|
|
||||||
RENDER_GID=989
|
|
||||||
```
|
|
||||||
|
|
||||||
For NVIDIA/AMD GPU support, also read the comments in the selected overlay file: docker/gpu.nvidia.yml or docker/gpu.amd.yml.
|
|
||||||
|
|
||||||
**Stack-management UIs (Portainer, Coolify, Dockhand, etc.).** These tools
|
|
||||||
often accept only a single Compose file and do not reliably honor `COMPOSE_FILE`
|
|
||||||
or multiple `-f` overlays. CLI users should keep using the `COMPOSE_FILE`
|
|
||||||
overlay workflow above. For stack UIs, point the stack at one of the standalone
|
|
||||||
files instead, which bundle the base stack plus the GPU settings:
|
|
||||||
|
|
||||||
- `docker-compose.gpu-nvidia.yml` — still requires the NVIDIA Container Toolkit
|
|
||||||
on the host.
|
|
||||||
- `docker-compose.gpu-amd.yml` — still requires host ROCm/kfd/DRI setup, the
|
|
||||||
`video`/`render` group membership, and `RENDER_GID` when needed.
|
|
||||||
|
|
||||||
The base `docker-compose.yml` plus the `docker/gpu.*.yml` overlays remain the
|
|
||||||
source of truth; the standalone files mirror them for single-file deployments.
|
|
||||||
|
|
||||||
Verify after enabling either overlay:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose exec odysseus nvidia-smi -L # NVIDIA
|
|
||||||
docker compose exec odysseus sh -lc 'test -e /dev/kfd && test -d /dev/dri && ls -l /dev/kfd /dev/dri/renderD*' # AMD
|
|
||||||
```
|
|
||||||
|
|
||||||
> **GPU passthrough ≠ llama.cpp CUDA.** `nvidia-smi` passing inside the
|
|
||||||
> container confirms Docker GPU access, but llama.cpp also needs `cudart` and
|
|
||||||
> the CUDA Toolkit at runtime. If Cookbook logs show `Unable to find cudart
|
|
||||||
> library`, `Could NOT find CUDAToolkit`, `CUDA Toolkit not found`, or
|
|
||||||
> tensors/layers assigned to CPU, that is a Cookbook/llama.cpp build issue —
|
|
||||||
> not a Docker passthrough failure. Reinstall the serve engine via
|
|
||||||
> **Cookbook → Dependencies** to get a CUDA-enabled build.
|
|
||||||
>
|
|
||||||
> The same split applies to AMD/ROCm: seeing `/dev/kfd` and `/dev/dri` inside
|
|
||||||
> the container confirms device passthrough, not ROCm userspace or a
|
|
||||||
> ROCm-enabled vLLM/llama.cpp build. `rocm-smi` and `rocminfo` are not expected
|
|
||||||
> inside the slim Odysseus image.
|
|
||||||
|
|
||||||
**Ollama with Docker.** If Ollama runs on the host, add this endpoint in
|
|
||||||
Settings:
|
|
||||||
|
|
||||||
```text
|
|
||||||
http://host.docker.internal:11434/v1
|
|
||||||
```
|
|
||||||
|
|
||||||
Ollama must listen outside its own loopback interface:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
OLLAMA_HOST=0.0.0.0:11434 ollama serve
|
|
||||||
```
|
|
||||||
|
|
||||||
This connects Odysseus in Docker to an Ollama server that is already running on
|
|
||||||
your host machine; it does not start Ollama inside the container.
|
|
||||||
`host.docker.internal` is Docker's hostname for the host machine from inside the
|
|
||||||
container. Cookbook **Serve** is a separate workflow for serving downloaded
|
|
||||||
models through Odysseus/llama.cpp, so Windows users with an existing Ollama
|
|
||||||
install usually only need to add the endpoint in Settings.
|
|
||||||
|
|
||||||
**Useful checks.**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose ps
|
|
||||||
docker compose logs --tail=120 odysseus
|
|
||||||
docker compose logs odysseus | grep -E 'ChromaDB|MemoryVectorStore|DEGRADED'
|
|
||||||
```
|
|
||||||
|
|
||||||
**macOS details.** `start-macos.sh` installs Homebrew deps, creates the venv,
|
|
||||||
runs setup, and starts uvicorn on port `7860` because AirPlay often holds
|
|
||||||
`7000`. It uses llama.cpp/Ollama for Metal. vLLM/SGLang are CUDA/ROCm-only and
|
|
||||||
do not run on macOS. MLX-only models are not served by Odysseus.
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
### Native Windows
|
|
||||||
|
|
||||||
**One-command launcher** (creates the venv, installs deps, runs setup, starts the
|
|
||||||
server; safe to re-run):
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
git clone https://github.com/odysseus-dev/odysseus.git
|
|
||||||
cd odysseus
|
|
||||||
powershell -ExecutionPolicy Bypass -File .\launch-windows.ps1
|
|
||||||
```
|
|
||||||
|
|
||||||
Or do it by hand:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
git clone https://github.com/odysseus-dev/odysseus.git
|
|
||||||
cd odysseus
|
|
||||||
py -3.11 -m venv venv
|
|
||||||
venv\Scripts\Activate.ps1
|
|
||||||
pip install -r requirements.txt
|
|
||||||
python setup.py
|
|
||||||
python -m uvicorn app:app --host 127.0.0.1 --port 7000
|
|
||||||
```
|
|
||||||
|
|
||||||
If `python` points at an older interpreter, use `py -3.12` (or another installed
|
|
||||||
3.11+ version) for the venv step.
|
|
||||||
|
|
||||||
**Exposing on a LAN/Tailscale (Windows):** the launcher binds to `127.0.0.1` and
|
|
||||||
does **not** read `APP_BIND` / `ODYSSEUS_HOST` from `.env`, so editing `.env`
|
|
||||||
alone leaves the native Windows server on loopback. Pass the launcher's
|
|
||||||
`-BindHost` flag instead:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
powershell -ExecutionPolicy Bypass -File .\launch-windows.ps1 -BindHost 0.0.0.0
|
|
||||||
```
|
|
||||||
|
|
||||||
The manual `uvicorn` command takes the same address as `--host 0.0.0.0`. Bind
|
|
||||||
outside loopback only for a trusted LAN/VPN such as Tailscale: keep
|
|
||||||
`AUTH_ENABLED=true` and do not expose the port directly to the public internet.
|
|
||||||
|
|
||||||
**Requirements:** Python 3.11+. The core app (chat, agent, memory, documents,
|
|
||||||
email, calendar, deep research) runs fully native. For full **Cookbook** background
|
|
||||||
model downloads and the agent shell tool, also install
|
|
||||||
[Git for Windows](https://git-scm.com/download/win) (provides `bash.exe`).
|
|
||||||
Local GPU *serving* of vLLM/SGLang needs Linux/WSL2; for a local model on Windows,
|
|
||||||
[Ollama](https://ollama.com/download) is the easiest path — point Odysseus at
|
|
||||||
`http://localhost:11434/v1` in Settings.
|
|
||||||
|
|
||||||
Open `http://localhost:7000`, log in with the generated admin password,
|
|
||||||
and configure everything else inside **Settings**.
|
|
||||||
|
|
||||||
## Troubleshooting & Advanced Setup
|
|
||||||
|
|
||||||
### `chromadb-client` conflicts with embedded ChromaDB
|
|
||||||
If `chromadb-client` (the lightweight HTTP-only package) is installed alongside the full `chromadb` package, Odysseus starts but ChromaDB silently falls back to HTTP-only mode and fails.
|
|
||||||
|
|
||||||
**Fix:** uninstall `chromadb-client` and force-reinstall the full package:
|
|
||||||
```bash
|
|
||||||
./venv/bin/pip uninstall chromadb-client -y
|
|
||||||
./venv/bin/pip install --force-reinstall chromadb
|
|
||||||
```
|
|
||||||
|
|
||||||
### HTTPS + LAN/Tailscale exposure
|
|
||||||
To expose Odysseus on a local network or Tailscale with HTTPS:
|
|
||||||
1. Change the bind address to `0.0.0.0` in `.env` (`APP_BIND=0.0.0.0` or `ODYSSEUS_HOST=0.0.0.0`).
|
|
||||||
2. Generate a locally-trusted cert for your LAN/Tailscale IPs using [mkcert](https://github.com/FiloSottile/mkcert):
|
|
||||||
```bash
|
|
||||||
mkcert -install
|
|
||||||
mkcert -cert-file cert.pem -key-file key.pem 192.168.1.100 tailscale-ip
|
|
||||||
```
|
|
||||||
3. Run `uvicorn` with the generated certs:
|
|
||||||
```bash
|
|
||||||
python -m uvicorn app:app --host 0.0.0.0 --port 7000 --ssl-certfile=cert.pem --ssl-keyfile=key.pem
|
|
||||||
```
|
|
||||||
4. Install the `mkcert` CA on any other device you want to access Odysseus from (e.g., for iOS, email the `rootCA.pem` to yourself, install the profile, and trust it in Certificate Trust Settings).
|
|
||||||
|
|
||||||
### Common self-host traps (30-second fixes)
|
|
||||||
A grab-bag of small gotchas that otherwise turn into long debugging sessions.
|
|
||||||
|
|
||||||
- **`AUTH_ENABLED=false` is ignored / you're still forced to log in (Windows).** If you edited `.env` in Notepad it may have saved a UTF-8 **BOM**, turning the first key into `AUTH_ENABLED` so it is never matched. Odysseus loads `.env` with `encoding="utf-8-sig"` to tolerate a leading BOM, but the safe fix is to re-save `.env` as **UTF-8 without BOM** (VS Code: *Save with Encoding → UTF-8*).
|
|
||||||
- **macOS: the app isn't at `http://localhost:7000`.** macOS AirPlay Receiver usually holds port `7000`, so the macOS start script serves on **`7860`** instead — open `http://localhost:7860`. To use `7000`, free it (System Settings → General → AirDrop & Handoff → turn off *AirPlay Receiver*) and set `APP_PORT=7000`.
|
|
||||||
- **Copy buttons do nothing over a plain-HTTP Tailscale/LAN URL.** Browsers only expose the clipboard API (`navigator.clipboard`) on **secure origins** — HTTPS, or `localhost`. Over `http://100.x.y.z:7860` it is blocked. Serve over HTTPS (see *HTTPS + LAN/Tailscale exposure* above); `localhost` is exempt, so copy still works on the host itself.
|
|
||||||
- **Self-hosted ntfy reminders don't reach your phone.** Two things: (1) the bundled ntfy binds to loopback by default — to reach it from your phone set `NTFY_BIND` to your host/Tailscale IP and `NTFY_BASE_URL` to the same server URL in `.env`, then recreate the ntfy container (see the `NTFY_*` block in `.env.example`); (2) in the ntfy **Android** app, subscribe to the topic with **Instant delivery** enabled — non-`ntfy.sh` servers don't get instant push otherwise.
|
|
||||||
- **Local mail (Dovecot) login fails: "Plaintext authentication disallowed on non-encrypted connections."** Your IMAP/SMTP server is refusing cleartext auth over an unencrypted link. Prefer enabling TLS on the mail server; on a trusted LAN only, you can allow cleartext (Dovecot: `disable_plaintext_auth = no`).
|
|
||||||
- **Calendar/contacts (Radicale) won't sync.** Point Odysseus at the **full collection URL** with its trailing slash — e.g. `http://host:5232/<user>/<collection-id>/` — not just the server root. Radicale shows this address for each calendar/address book in its web UI.
|
|
||||||
|
|
||||||
### Optional Dependencies
|
|
||||||
`requirements-optional.txt` contains packages that unlock extra features. It is not installed by default.
|
|
||||||
|
|
||||||
| Package | Feature unlocked |
|
|
||||||
|---------|-----------------|
|
|
||||||
| `faster-whisper` | Local speech-to-text (microphone -> text) via the "local" STT provider. |
|
|
||||||
| `ddgs` | DuckDuckGo as a search provider option. |
|
|
||||||
| `PyMuPDF` | PDF page rendering in the side viewer panel and form-filling. (Note: AGPL-3.0) |
|
|
||||||
| `markitdown` | Office/EPUB document text extraction (converts .docx/.xlsx/.pptx/.xls/.epub to Markdown). |
|
|
||||||
|
|
||||||
### Faster, reproducible installs with uv (optional)
|
|
||||||
[uv](https://docs.astral.sh/uv/) works as a drop-in replacement for the
|
|
||||||
venv + pip steps in the native install guides, no project changes are needed but this change results in faster installs along with a lockfile for reproducible environments. After [installing `uv`](https://docs.astral.sh/uv/getting-started/installation/), use:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
uv venv venv --python 3.13
|
|
||||||
uv pip install -r requirements.txt
|
|
||||||
# then continue as usual: python setup.py, uvicorn, ...
|
|
||||||
```
|
|
||||||
|
|
||||||
`requirements.txt` is intentionally unpinned, so two installs at different times can produce different package versions. If you want a reproducible environment (e.g. across your own machines, or to roll back after a bad upgrade), snapshot and restore exact versions with:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
uv pip compile requirements.txt -o requirements.lock # snapshot current resolution
|
|
||||||
uv pip sync requirements.lock # reproduce it exactly later
|
|
||||||
```
|
|
||||||
|
|
||||||
`requirements.lock` is gitignored and platform-specific (compile it on the OS you deploy to). Regenerate it deliberately when you want to take upgrades. The plain `uv pip install -r requirements.txt` keeps following the unpinned requirements like pip does.
|
|
||||||
|
|
||||||
### Outlook / Office 365 email
|
|
||||||
Odysseus email accounts currently use IMAP/SMTP username-password auth. Outlook
|
|
||||||
and Microsoft 365 generally require OAuth instead, so normal Microsoft mailbox
|
|
||||||
passwords will fail. See the [Outlook and Microsoft 365 email guide](email-outlook.md)
|
|
||||||
for the current limitation and planned integration direction.
|
|
||||||
|
|
||||||
## Security Notes
|
|
||||||
Odysseus is a self-hosted workspace with powerful local tools: shell access, file uploads, model downloads, web research, email/calendar integrations, and API tokens. Treat it like an admin console.
|
|
||||||
|
|
||||||
- Keep `AUTH_ENABLED=true` for any network-accessible deployment.
|
|
||||||
- Keep `LOCALHOST_BYPASS=false` outside local development.
|
|
||||||
- Use `SECURE_COOKIES=true` when Odysseus is served through HTTPS by a trusted reverse proxy or private access gateway.
|
|
||||||
- Do not expose it directly to the public internet without HTTPS and a trusted reverse proxy or private access layer.
|
|
||||||
- Keep `.env`, `data/`, `logs/`, databases, uploads, generated media, backups, auth/session files, API keys, and model/provider tokens out of Git and private shares. They are ignored by default.
|
|
||||||
- Review `data/auth.json` after first boot: disable open signup unless you intentionally want it, make only your own account admin, and keep demo/test accounts non-admin.
|
|
||||||
- Non-admin users do not get shell/Python/file read/write by default, and admin-only routes/tools such as MCP management, API tokens, webhooks, model/cookbook serving, backup/vault, and app settings are admin-gated. Other features are controlled by per-user privileges, so review each user's privileges before exposing a deployment.
|
|
||||||
- Rotate any API keys or tokens that were ever pasted into a shared chat, demo, screenshot, or log.
|
|
||||||
- If you enable API tokens or webhooks, create separate tokens per integration and delete unused ones.
|
|
||||||
- Prefer binding manual development runs to `127.0.0.1`; bind to `0.0.0.0` only when you intentionally want LAN/reverse-proxy access.
|
|
||||||
- Keep ChromaDB, SearXNG, ntfy, Ollama, vLLM, llama.cpp, databases, and raw model/provider APIs internal-only. Expose only the authenticated Odysseus web/API entrypoint through your trusted proxy or private access layer.
|
|
||||||
- Before publishing a fork, run `git status --short` and confirm no private files from `.env`, `data/`, `logs/`, uploads, backups, or local databases are staged.
|
|
||||||
|
|
||||||
### Private or proxied deployments
|
|
||||||
Odysseus serves plain HTTP on its app port. Docker Compose binds Odysseus and the bundled services to `127.0.0.1` by default, so a typical production/private setup is:
|
|
||||||
|
|
||||||
1. Keep Odysseus on localhost, for example `127.0.0.1:7000`.
|
|
||||||
2. Terminate HTTPS at a trusted reverse proxy or private access gateway.
|
|
||||||
3. Put the authenticated Odysseus web/API entrypoint behind that layer.
|
|
||||||
4. Keep raw service and model ports internal-only.
|
|
||||||
|
|
||||||
Cloudflare Access, Tailscale, Caddy, nginx, and Traefik can all fit this pattern; none are required by Odysseus. If your access layer reaches Odysseus on the same host, proxy to `http://127.0.0.1:7000` and keep `AUTH_ENABLED=true`, `LOCALHOST_BYPASS=false`, and `SECURE_COOKIES=true`.
|
|
||||||
`ALLOWED_ORIGINS` lists exact permitted origins for cross-origin browser/API clients; ordinary same-origin reverse-proxy access usually does not need a special CORS entry.
|
|
||||||
|
|
||||||
Common internal-only ports from the default docs/compose setup:
|
|
||||||
|
|
||||||
| Port | Service |
|
|
||||||
|---|---|
|
|
||||||
| `7000` | Odysseus raw app port |
|
|
||||||
| `8080` | SearXNG |
|
|
||||||
| `8091` | ntfy |
|
|
||||||
| `8100` | ChromaDB host port for manual/compose access |
|
|
||||||
| `11434` | Ollama |
|
|
||||||
| `8000-8020` | Common local model/provider APIs |
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
Most setup is done inside the app with `/setup` or **Settings**. Use `.env`
|
|
||||||
for deployment-level defaults and secrets you want present before first boot.
|
|
||||||
Key settings:
|
|
||||||
|
|
||||||
| Variable | Default | Description |
|
|
||||||
|---|---|---|
|
|
||||||
| `LLM_HOST` | `localhost` | Your LLM server (e.g. `llm-host.local:8000`) |
|
|
||||||
| `LLM_HOSTS` | -- | Comma-separated list for model discovery |
|
|
||||||
| `OPENAI_API_KEY` | -- | Optional OpenAI key. Prefer adding providers in the app unless pre-seeding. |
|
|
||||||
| `SEARXNG_INSTANCE` | `http://localhost:8080` | SearXNG URL. Docker overrides this to `http://searxng:8080`. |
|
|
||||||
| `SEARXNG_SECRET` | generated on first Docker boot | Optional SearXNG cookie/CSRF secret. Leave blank unless you need to pin it. |
|
|
||||||
| `APP_BIND` | `127.0.0.1` | Docker Compose host bind address for the web UI. Use `0.0.0.0` only for intentional LAN/reverse-proxy access. |
|
|
||||||
| `APP_PORT` | `7000` | Docker Compose host port for the web UI. |
|
|
||||||
| `APP_DATA_DIR` | `./data` | Docker Compose host directory for application data volumes. |
|
|
||||||
| `APP_LOGS_DIR` | `./logs` | Docker Compose host directory for application logs. |
|
|
||||||
| `AUTH_ENABLED` | `true` | Enable/disable login |
|
|
||||||
| `LOCALHOST_BYPASS` | `false` | Development-only auth bypass for loopback requests. Keep false for shared/network deployments. |
|
|
||||||
| `ALLOWED_ORIGINS` | `http://localhost,http://127.0.0.1` | Comma-separated exact permitted origins for cross-origin browser/API clients. |
|
|
||||||
| `SECURE_COOKIES` | `false` | Set true when serving Odysseus through HTTPS at a trusted proxy or private access gateway. |
|
|
||||||
| `DATABASE_URL` | `sqlite:///./data/app.db` | Database connection string |
|
|
||||||
| `CHROMADB_HOST` | `localhost` | ChromaDB host for vector memory. Docker overrides this to `chromadb`. |
|
|
||||||
| `CHROMADB_PORT` | `8100` | ChromaDB port for manual host runs. Docker overrides this to `8000`. |
|
|
||||||
| `EMBEDDING_URL` | -- | OpenAI-compatible embeddings endpoint |
|
|
||||||
| `ODYSSEUS_CHAT_UPLOAD_MAX_BYTES` | `10485760` | Chat/agent attachment cap in bytes. Raise for larger local PDFs or text documents. |
|
|
||||||
| `ODYSSEUS_GALLERY_UPLOAD_MAX_BYTES` | `104857600` | Gallery image upload cap in bytes (100 MB). |
|
|
||||||
| `ODYSSEUS_GALLERY_TRANSFORM_UPLOAD_MAX_BYTES` | `26214400` | Gallery transform input cap in bytes (25 MB). |
|
|
||||||
| `ODYSSEUS_MEMORY_IMPORT_MAX_BYTES` | `10485760` | Memory import file cap in bytes (10 MB). |
|
|
||||||
| `ODYSSEUS_PERSONAL_UPLOAD_MAX_BYTES` | `26214400` | Personal document upload cap in bytes (25 MB). |
|
|
||||||
| `ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES` | `26214400` | Email compose attachment cap in bytes (25 MB). |
|
|
||||||
| `ODYSSEUS_STT_MAX_AUDIO_BYTES` | `26214400` | Speech-to-text audio cap in bytes (25 MB). |
|
|
||||||
| `ODYSSEUS_ICS_MAX_BYTES` | `10485760` | Calendar `.ics` import cap in bytes (10 MB). |
|
|
||||||
|
|
||||||
All upload-limit vars are validated (must be a positive integer) and optional; an invalid value fails fast at startup.
|
|
||||||
|
|
||||||
### Built-in MCP servers (optional setup)
|
|
||||||
|
|
||||||
Odysseus auto-registers a few built-in MCP servers at startup. The npx-based ones (currently the browser server, `@playwright/mcp`) only start when their npm package is already in the local npx cache. If a package isn't cached, that server is skipped with a startup log message explaining what to do, so a fresh install does not block on a multi-minute npm download or hang if Playwright system deps are missing.
|
|
||||||
|
|
||||||
To enable the browser MCP (page navigation, screenshots, vision), run once:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx -y @playwright/mcp@latest --version
|
|
||||||
```
|
|
||||||
|
|
||||||
That installs `@playwright/mcp` plus Playwright (~300MB total). Restart Odysseus and the server will register at startup.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
For stable high-level runtime structure, subsystem boundaries, and documentation
|
|
||||||
authority, see the [architecture overview](ARCHITECTURE.md).
|
|
||||||
|
|
||||||
Source code, tests, and configuration remain authoritative for
|
|
||||||
implementation-sensitive behaviour.
|
|
||||||
|
|
||||||
## Data
|
|
||||||
All user data lives in `data/` (gitignored): `app.db` (sessions, messages, documents),
|
|
||||||
`memory.json`, `presets.json`, `uploads/`, `personal_docs/`, `chroma/`, `settings.json`.
|
|
||||||
|
|
||||||
To protect or recover this data, follow the
|
|
||||||
[backup and restore guide](backup-restore.md).
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: odysseus
|
name: odysseus
|
||||||
description: Use when the user asks Claude Code to read or write Odysseus data (todos, email, calendar, memory, documents) or to launch/monitor/stop a Cookbook model-serve task through the scoped Claude Agent API. Requires ODYSSEUS_URL and ODYSSEUS_API_TOKEN.
|
description: Use when the user asks Claude Code to read or write Odysseus data (todos, email, calendar, memory, documents) through the scoped Claude Agent API. Requires ODYSSEUS_URL and ODYSSEUS_API_TOKEN.
|
||||||
---
|
---
|
||||||
|
|
||||||
# Odysseus
|
# Odysseus
|
||||||
@@ -102,53 +102,9 @@ python3 ~/.claude/skills/odysseus/scripts/odysseus_api.py POST /api/codex/memory
|
|||||||
|
|
||||||
## Email draft + send
|
## Email draft + send
|
||||||
|
|
||||||
- Prefer `POST /api/codex/emails/draft-document` for agent-written email replies. It creates an editable Odysseus Document with `language: "email"` and does not touch IMAP/send.
|
|
||||||
- `POST /api/codex/emails/draft` — body matches `SendEmailRequest` (`to`, `cc`, `bcc`, `subject`, `body`, `body_html`, `attachments`, `account_id`, `in_reply_to`, `references`). Requires `email:draft` (or `email:send`).
|
- `POST /api/codex/emails/draft` — body matches `SendEmailRequest` (`to`, `cc`, `bcc`, `subject`, `body`, `body_html`, `attachments`, `account_id`, `in_reply_to`, `references`). Requires `email:draft` (or `email:send`).
|
||||||
- `POST /api/codex/emails/send` — same body. Requires `email:send`. Never send without explicit user instruction.
|
- `POST /api/codex/emails/send` — same body. Requires `email:send`. Never send without explicit user instruction.
|
||||||
|
|
||||||
## Cookbook serve (debug a failing model launch)
|
|
||||||
|
|
||||||
The Cookbook surface lets you reproduce what a human would do in Odysseus → Cookbook: read which serves are running, tail their tmux output to see why they crashed, edit the launch command, relaunch, kill a stuck one. Use this when the user is debugging a model server that won't come up (compute-capability errors, OOM, missing kernels, wrong attention backend, etc.).
|
|
||||||
|
|
||||||
- `GET /api/codex/cookbook/tasks` — list active serve/download/install tasks (sessionId, type, status, repo_id, remoteHost, payload._cmd). Requires `cookbook:read`.
|
|
||||||
- `GET /api/codex/cookbook/servers` — list configured servers (name, host, port, env type + path, model dirs). Requires `cookbook:read`.
|
|
||||||
- `GET /api/codex/cookbook/cached?host=<NAME>` — list models already cached on the named server (HF cache + Ollama + extra modelDirs). Call BEFORE `serve` to see what's already on disk. Requires `cookbook:read`.
|
|
||||||
- `GET /api/codex/cookbook/presets` — list saved serve presets (model + host + port + cmd). The user's saved preset usually has a working cmd — try `preset NAME` before composing your own. Requires `cookbook:read`.
|
|
||||||
- `GET /api/codex/cookbook/output/{session_id}?tail=400` — read the last N lines of the task's persistent log file (preferred) or tmux pane (fallback). The log file persists across vllm crashes, so this returns the actual Python traceback even after the bash prompt + neofetch banner overwrites the pane. Default tail=400. Requires `cookbook:read`.
|
|
||||||
- `POST /api/codex/cookbook/serve` — launch a serve task. Body matches `ServeRequest`: `{ repo_id, cmd, remote_host?, ssh_port?, env_prefix?, gpus?, platform? }`. The `cmd` is validated: leading binary must be `vllm`/`python3`/`sglang`/`llama-server`/`ollama`/`node`/`npx`. NEVER prefix with `cd …`, `source …`, or chain with `&&`/`||`/`;`/`$(...)` — the validator rejects shell metacharacters. The venv activation (`env_prefix`) is added automatically from the host's saved settings, so pass the bare binary + args. Requires `cookbook:launch`.
|
|
||||||
- `POST /api/codex/cookbook/preset/{name}` — launch a saved preset by name. Reuses the working cmd + host the user already saved. Requires `cookbook:launch`.
|
|
||||||
- `POST /api/codex/cookbook/adopt` — register an externally-launched tmux session into cookbook tracking. Body: `{ tmux_session, model, host?, port? }`. Use this when serve_model rejected a cmd and you fell back to direct ssh+tmux — without adoption, the session is invisible to the UI. Requires `cookbook:launch`.
|
|
||||||
- `POST /api/codex/cookbook/stop/{session_id}` — kill the tmux session for that task. Requires `cookbook:launch`.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Survey what's running
|
|
||||||
python3 ~/.claude/skills/odysseus/scripts/odysseus_api.py cookbook tasks
|
|
||||||
|
|
||||||
# Tail the failing one (sessionId from `cookbook tasks`)
|
|
||||||
python3 ~/.claude/skills/odysseus/scripts/odysseus_api.py cookbook output serve-abc12345 400
|
|
||||||
|
|
||||||
# Stop the previous attempt before you try a new flag set
|
|
||||||
python3 ~/.claude/skills/odysseus/scripts/odysseus_api.py cookbook stop serve-abc12345
|
|
||||||
|
|
||||||
# Relaunch with new flags. cmd MUST begin with one of the allowlisted binaries.
|
|
||||||
python3 ~/.claude/skills/odysseus/scripts/odysseus_api.py cookbook serve \
|
|
||||||
/mnt/HADES/models/Qwen3.5-397B-A17B-AWQ \
|
|
||||||
"vllm serve /mnt/HADES/models/Qwen3.5-397B-A17B-AWQ --host 0.0.0.0 --port 8001 --tensor-parallel-size 8 --max-model-len 262144 --gpu-memory-utilization 0.90 --dtype auto --max-num-seqs 8 --trust-remote-code --enable-expert-parallel --enable-auto-tool-choice --tool-call-parser qwen3_coder --reasoning-parser qwen3" \
|
|
||||||
pewds@192.168.1.12
|
|
||||||
```
|
|
||||||
|
|
||||||
**Debug loop pattern:** when a serve is failing, the productive sequence is
|
|
||||||
|
|
||||||
1. `cookbook tasks` → find the failing sessionId.
|
|
||||||
2. `cookbook output SID 600` → read the last 600 lines, find the actual root-cause line (often above the visible tail because tmux scrollback rolled — request a larger `tail` if the error references "above").
|
|
||||||
3. `cookbook stop SID` — kill the previous attempt before relaunching; two serves on the same `--port` collide.
|
|
||||||
4. `cookbook serve repo "new cmd"` — try the next variation. Wait ~20s, then `cookbook output` on the new sessionId.
|
|
||||||
|
|
||||||
**Hard limits this surface enforces:**
|
|
||||||
- `cookbook serve` cmd allowlist + shell-metacharacter rejection — you cannot run arbitrary shell, only model-server binaries.
|
|
||||||
- `cookbook stop` only targets task sessionIds matching `[a-zA-Z0-9_-]+`.
|
|
||||||
- The agent CAN spawn GPU-pinning long-lived processes — always `cookbook stop` your previous attempt before relaunching, and check `cookbook tasks` for collisions on the same `--port` before launching.
|
|
||||||
|
|
||||||
## Forbidden Bypass Pattern
|
## Forbidden Bypass Pattern
|
||||||
|
|
||||||
If you are about to reach the Odysseus host/container, import app internals, query the database, or call MCP helper modules directly, stop. Those paths bypass Odysseus Settings and token scopes. Ask the user to enable the relevant Claude Agent tool toggle instead.
|
If you are about to reach the Odysseus host/container, import app internals, query the database, or call MCP helper modules directly, stop. Those paths bypass Odysseus Settings and token scopes. Ask the user to enable the relevant Claude Agent tool toggle instead.
|
||||||
|
|||||||
@@ -17,20 +17,6 @@ def _usage() -> int:
|
|||||||
print(" odysseus_api.py todos add TITLE", file=sys.stderr)
|
print(" odysseus_api.py todos add TITLE", file=sys.stderr)
|
||||||
print(" odysseus_api.py emails list [limit]", file=sys.stderr)
|
print(" odysseus_api.py emails list [limit]", file=sys.stderr)
|
||||||
print(" odysseus_api.py emails read UID", file=sys.stderr)
|
print(" odysseus_api.py emails read UID", file=sys.stderr)
|
||||||
print(" odysseus_api.py emails draft-doc JSON_PAYLOAD", file=sys.stderr)
|
|
||||||
print(" odysseus_api.py documents list [limit]", file=sys.stderr)
|
|
||||||
print(" odysseus_api.py documents read DOC_ID", file=sys.stderr)
|
|
||||||
print(" odysseus_api.py documents create JSON_PAYLOAD", file=sys.stderr)
|
|
||||||
print(" odysseus_api.py documents delete DOC_ID", file=sys.stderr)
|
|
||||||
print(" odysseus_api.py cookbook tasks", file=sys.stderr)
|
|
||||||
print(" odysseus_api.py cookbook servers", file=sys.stderr)
|
|
||||||
print(" odysseus_api.py cookbook cached [HOST]", file=sys.stderr)
|
|
||||||
print(" odysseus_api.py cookbook presets", file=sys.stderr)
|
|
||||||
print(" odysseus_api.py cookbook output SESSION_ID [tail]", file=sys.stderr)
|
|
||||||
print(" odysseus_api.py cookbook serve REPO_ID 'CMD' [REMOTE_HOST]", file=sys.stderr)
|
|
||||||
print(" odysseus_api.py cookbook preset NAME", file=sys.stderr)
|
|
||||||
print(" odysseus_api.py cookbook adopt SESSION_ID MODEL [HOST] [PORT]", file=sys.stderr)
|
|
||||||
print(" odysseus_api.py cookbook stop SESSION_ID", file=sys.stderr)
|
|
||||||
print(" odysseus_api.py METHOD /api/codex/path [json-body]", file=sys.stderr)
|
print(" odysseus_api.py METHOD /api/codex/path [json-body]", file=sys.stderr)
|
||||||
return 2
|
return 2
|
||||||
|
|
||||||
@@ -84,88 +70,6 @@ def main() -> int:
|
|||||||
method = "GET"
|
method = "GET"
|
||||||
path = f"/api/codex/emails/{sys.argv[3]}"
|
path = f"/api/codex/emails/{sys.argv[3]}"
|
||||||
body = None
|
body = None
|
||||||
elif action in ("draft-doc", "draft_document") and len(sys.argv) >= 4:
|
|
||||||
method = "POST"
|
|
||||||
path = "/api/codex/emails/draft-document"
|
|
||||||
body = " ".join(sys.argv[3:])
|
|
||||||
else:
|
|
||||||
return _usage()
|
|
||||||
elif command in ("documents", "docs"):
|
|
||||||
if len(sys.argv) < 3:
|
|
||||||
return _usage()
|
|
||||||
action = sys.argv[2].lower()
|
|
||||||
if action == "list":
|
|
||||||
method = "GET"
|
|
||||||
limit = sys.argv[3] if len(sys.argv) >= 4 else "50"
|
|
||||||
path = f"/api/codex/documents?limit={limit}"
|
|
||||||
body = None
|
|
||||||
elif action == "read" and len(sys.argv) >= 4:
|
|
||||||
method = "GET"
|
|
||||||
path = f"/api/codex/documents/{sys.argv[3]}"
|
|
||||||
body = None
|
|
||||||
elif action == "create" and len(sys.argv) >= 4:
|
|
||||||
method = "POST"
|
|
||||||
path = "/api/codex/documents"
|
|
||||||
body = " ".join(sys.argv[3:])
|
|
||||||
elif action == "delete" and len(sys.argv) >= 4:
|
|
||||||
method = "DELETE"
|
|
||||||
path = f"/api/codex/documents/{sys.argv[3]}"
|
|
||||||
body = None
|
|
||||||
else:
|
|
||||||
return _usage()
|
|
||||||
elif command == "cookbook":
|
|
||||||
if len(sys.argv) < 3:
|
|
||||||
return _usage()
|
|
||||||
action = sys.argv[2].lower()
|
|
||||||
if action == "tasks":
|
|
||||||
method = "GET"
|
|
||||||
path = "/api/codex/cookbook/tasks"
|
|
||||||
body = None
|
|
||||||
elif action == "servers":
|
|
||||||
method = "GET"
|
|
||||||
path = "/api/codex/cookbook/servers"
|
|
||||||
body = None
|
|
||||||
elif action == "output" and len(sys.argv) >= 4:
|
|
||||||
method = "GET"
|
|
||||||
sid = sys.argv[3]
|
|
||||||
tail = sys.argv[4] if len(sys.argv) >= 5 else "400"
|
|
||||||
path = f"/api/codex/cookbook/output/{sid}?tail={tail}"
|
|
||||||
body = None
|
|
||||||
elif action == "cached":
|
|
||||||
method = "GET"
|
|
||||||
if len(sys.argv) >= 4:
|
|
||||||
from urllib.parse import quote
|
|
||||||
path = f"/api/codex/cookbook/cached?host={quote(sys.argv[3])}"
|
|
||||||
else:
|
|
||||||
path = "/api/codex/cookbook/cached"
|
|
||||||
body = None
|
|
||||||
elif action == "presets":
|
|
||||||
method = "GET"
|
|
||||||
path = "/api/codex/cookbook/presets"
|
|
||||||
body = None
|
|
||||||
elif action == "preset" and len(sys.argv) >= 4:
|
|
||||||
from urllib.parse import quote
|
|
||||||
method = "POST"
|
|
||||||
path = f"/api/codex/cookbook/preset/{quote(sys.argv[3])}"
|
|
||||||
body = None
|
|
||||||
elif action == "adopt" and len(sys.argv) >= 5:
|
|
||||||
method = "POST"
|
|
||||||
path = "/api/codex/cookbook/adopt"
|
|
||||||
payload = {"tmux_session": sys.argv[3], "model": sys.argv[4]}
|
|
||||||
if len(sys.argv) >= 6: payload["host"] = sys.argv[5]
|
|
||||||
if len(sys.argv) >= 7: payload["port"] = int(sys.argv[6])
|
|
||||||
body = json.dumps(payload)
|
|
||||||
elif action == "serve" and len(sys.argv) >= 5:
|
|
||||||
method = "POST"
|
|
||||||
path = "/api/codex/cookbook/serve"
|
|
||||||
payload = {"repo_id": sys.argv[3], "cmd": sys.argv[4]}
|
|
||||||
if len(sys.argv) >= 6:
|
|
||||||
payload["remote_host"] = sys.argv[5]
|
|
||||||
body = json.dumps(payload)
|
|
||||||
elif action == "stop" and len(sys.argv) >= 4:
|
|
||||||
method = "POST"
|
|
||||||
path = f"/api/codex/cookbook/stop/{sys.argv[3]}"
|
|
||||||
body = None
|
|
||||||
else:
|
else:
|
||||||
return _usage()
|
return _usage()
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -17,20 +17,6 @@ def _usage() -> int:
|
|||||||
print(" odysseus_api.py todos add TITLE", file=sys.stderr)
|
print(" odysseus_api.py todos add TITLE", file=sys.stderr)
|
||||||
print(" odysseus_api.py emails list [limit]", file=sys.stderr)
|
print(" odysseus_api.py emails list [limit]", file=sys.stderr)
|
||||||
print(" odysseus_api.py emails read UID", file=sys.stderr)
|
print(" odysseus_api.py emails read UID", file=sys.stderr)
|
||||||
print(" odysseus_api.py emails draft-doc JSON_PAYLOAD", file=sys.stderr)
|
|
||||||
print(" odysseus_api.py documents list [limit]", file=sys.stderr)
|
|
||||||
print(" odysseus_api.py documents read DOC_ID", file=sys.stderr)
|
|
||||||
print(" odysseus_api.py documents create JSON_PAYLOAD", file=sys.stderr)
|
|
||||||
print(" odysseus_api.py documents delete DOC_ID", file=sys.stderr)
|
|
||||||
print(" odysseus_api.py cookbook tasks", file=sys.stderr)
|
|
||||||
print(" odysseus_api.py cookbook servers", file=sys.stderr)
|
|
||||||
print(" odysseus_api.py cookbook cached [HOST]", file=sys.stderr)
|
|
||||||
print(" odysseus_api.py cookbook presets", file=sys.stderr)
|
|
||||||
print(" odysseus_api.py cookbook output SESSION_ID [tail]", file=sys.stderr)
|
|
||||||
print(" odysseus_api.py cookbook serve REPO_ID 'CMD' [REMOTE_HOST]", file=sys.stderr)
|
|
||||||
print(" odysseus_api.py cookbook preset NAME", file=sys.stderr)
|
|
||||||
print(" odysseus_api.py cookbook adopt SESSION_ID MODEL [HOST] [PORT]", file=sys.stderr)
|
|
||||||
print(" odysseus_api.py cookbook stop SESSION_ID", file=sys.stderr)
|
|
||||||
print(" odysseus_api.py METHOD /api/codex/path [json-body]", file=sys.stderr)
|
print(" odysseus_api.py METHOD /api/codex/path [json-body]", file=sys.stderr)
|
||||||
return 2
|
return 2
|
||||||
|
|
||||||
@@ -84,88 +70,6 @@ def main() -> int:
|
|||||||
method = "GET"
|
method = "GET"
|
||||||
path = f"/api/codex/emails/{sys.argv[3]}"
|
path = f"/api/codex/emails/{sys.argv[3]}"
|
||||||
body = None
|
body = None
|
||||||
elif action in ("draft-doc", "draft_document") and len(sys.argv) >= 4:
|
|
||||||
method = "POST"
|
|
||||||
path = "/api/codex/emails/draft-document"
|
|
||||||
body = " ".join(sys.argv[3:])
|
|
||||||
else:
|
|
||||||
return _usage()
|
|
||||||
elif command in ("documents", "docs"):
|
|
||||||
if len(sys.argv) < 3:
|
|
||||||
return _usage()
|
|
||||||
action = sys.argv[2].lower()
|
|
||||||
if action == "list":
|
|
||||||
method = "GET"
|
|
||||||
limit = sys.argv[3] if len(sys.argv) >= 4 else "50"
|
|
||||||
path = f"/api/codex/documents?limit={limit}"
|
|
||||||
body = None
|
|
||||||
elif action == "read" and len(sys.argv) >= 4:
|
|
||||||
method = "GET"
|
|
||||||
path = f"/api/codex/documents/{sys.argv[3]}"
|
|
||||||
body = None
|
|
||||||
elif action == "create" and len(sys.argv) >= 4:
|
|
||||||
method = "POST"
|
|
||||||
path = "/api/codex/documents"
|
|
||||||
body = " ".join(sys.argv[3:])
|
|
||||||
elif action == "delete" and len(sys.argv) >= 4:
|
|
||||||
method = "DELETE"
|
|
||||||
path = f"/api/codex/documents/{sys.argv[3]}"
|
|
||||||
body = None
|
|
||||||
else:
|
|
||||||
return _usage()
|
|
||||||
elif command == "cookbook":
|
|
||||||
if len(sys.argv) < 3:
|
|
||||||
return _usage()
|
|
||||||
action = sys.argv[2].lower()
|
|
||||||
if action == "tasks":
|
|
||||||
method = "GET"
|
|
||||||
path = "/api/codex/cookbook/tasks"
|
|
||||||
body = None
|
|
||||||
elif action == "servers":
|
|
||||||
method = "GET"
|
|
||||||
path = "/api/codex/cookbook/servers"
|
|
||||||
body = None
|
|
||||||
elif action == "output" and len(sys.argv) >= 4:
|
|
||||||
method = "GET"
|
|
||||||
sid = sys.argv[3]
|
|
||||||
tail = sys.argv[4] if len(sys.argv) >= 5 else "400"
|
|
||||||
path = f"/api/codex/cookbook/output/{sid}?tail={tail}"
|
|
||||||
body = None
|
|
||||||
elif action == "cached":
|
|
||||||
method = "GET"
|
|
||||||
if len(sys.argv) >= 4:
|
|
||||||
from urllib.parse import quote
|
|
||||||
path = f"/api/codex/cookbook/cached?host={quote(sys.argv[3])}"
|
|
||||||
else:
|
|
||||||
path = "/api/codex/cookbook/cached"
|
|
||||||
body = None
|
|
||||||
elif action == "presets":
|
|
||||||
method = "GET"
|
|
||||||
path = "/api/codex/cookbook/presets"
|
|
||||||
body = None
|
|
||||||
elif action == "preset" and len(sys.argv) >= 4:
|
|
||||||
from urllib.parse import quote
|
|
||||||
method = "POST"
|
|
||||||
path = f"/api/codex/cookbook/preset/{quote(sys.argv[3])}"
|
|
||||||
body = None
|
|
||||||
elif action == "adopt" and len(sys.argv) >= 5:
|
|
||||||
method = "POST"
|
|
||||||
path = "/api/codex/cookbook/adopt"
|
|
||||||
payload = {"tmux_session": sys.argv[3], "model": sys.argv[4]}
|
|
||||||
if len(sys.argv) >= 6: payload["host"] = sys.argv[5]
|
|
||||||
if len(sys.argv) >= 7: payload["port"] = int(sys.argv[6])
|
|
||||||
body = json.dumps(payload)
|
|
||||||
elif action == "serve" and len(sys.argv) >= 5:
|
|
||||||
method = "POST"
|
|
||||||
path = "/api/codex/cookbook/serve"
|
|
||||||
payload = {"repo_id": sys.argv[3], "cmd": sys.argv[4]}
|
|
||||||
if len(sys.argv) >= 6:
|
|
||||||
payload["remote_host"] = sys.argv[5]
|
|
||||||
body = json.dumps(payload)
|
|
||||||
elif action == "stop" and len(sys.argv) >= 4:
|
|
||||||
method = "POST"
|
|
||||||
path = f"/api/codex/cookbook/stop/{sys.argv[3]}"
|
|
||||||
body = None
|
|
||||||
else:
|
else:
|
||||||
return _usage()
|
return _usage()
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: odysseus
|
name: odysseus
|
||||||
description: Use when the user asks Codex to read or write Odysseus data (todos, email, calendar, memory, documents) or to launch/monitor/stop a Cookbook model-serve task through the scoped Codex Agent API. Requires ODYSSEUS_URL and ODYSSEUS_API_TOKEN.
|
description: Use when the user asks Codex to read or write Odysseus data from a terminal Codex session through the scoped Codex Agent API. Requires ODYSSEUS_URL and ODYSSEUS_API_TOKEN.
|
||||||
---
|
---
|
||||||
|
|
||||||
# Odysseus
|
# Odysseus
|
||||||
@@ -102,41 +102,9 @@ python3 integrations/codex/scripts/odysseus_api.py POST /api/codex/memory '{"tex
|
|||||||
|
|
||||||
## Email draft + send
|
## Email draft + send
|
||||||
|
|
||||||
- Prefer `POST /api/codex/emails/draft-document` for Codex-written email replies. It creates an editable Odysseus Document with `language: "email"` and does not touch IMAP/send.
|
|
||||||
- `POST /api/codex/emails/draft` — body matches `SendEmailRequest` (`to`, `cc`, `bcc`, `subject`, `body`, `body_html`, `attachments`, `account_id`, `in_reply_to`, `references`). Requires `email:draft` (or `email:send`).
|
- `POST /api/codex/emails/draft` — body matches `SendEmailRequest` (`to`, `cc`, `bcc`, `subject`, `body`, `body_html`, `attachments`, `account_id`, `in_reply_to`, `references`). Requires `email:draft` (or `email:send`).
|
||||||
- `POST /api/codex/emails/send` — same body. Requires `email:send`. Never send without explicit user instruction.
|
- `POST /api/codex/emails/send` — same body. Requires `email:send`. Never send without explicit user instruction.
|
||||||
|
|
||||||
## Cookbook serve (debug a failing model launch)
|
|
||||||
|
|
||||||
The Cookbook surface lets you reproduce what a human would do in Odysseus → Cookbook: read which serves are running, tail their tmux output to see why they crashed, edit the launch command, relaunch, kill a stuck one. Use this when the user is debugging a model server that won't come up (compute-capability errors, OOM, missing kernels, wrong attention backend, etc.).
|
|
||||||
|
|
||||||
- `GET /api/codex/cookbook/tasks` — list active serve/download/install tasks (sessionId, type, status, repo_id, remoteHost, payload._cmd). Requires `cookbook:read`.
|
|
||||||
- `GET /api/codex/cookbook/servers` — list configured servers (name, host, port, env type + path, model dirs). Requires `cookbook:read`.
|
|
||||||
- `GET /api/codex/cookbook/cached?host=<NAME>` — list models already cached on the named server (HF cache + Ollama + extra modelDirs). Call BEFORE `serve` to see what's already on disk. Requires `cookbook:read`.
|
|
||||||
- `GET /api/codex/cookbook/presets` — list saved serve presets (model + host + port + cmd). The user's saved preset usually has a working cmd — try `preset NAME` before composing your own. Requires `cookbook:read`.
|
|
||||||
- `GET /api/codex/cookbook/output/{session_id}?tail=400` — read the last N lines of the task's persistent log file (preferred) or tmux pane (fallback). The log file persists across vllm crashes, so this returns the actual Python traceback even after the bash prompt + neofetch banner overwrites the pane. Default tail=400. Requires `cookbook:read`.
|
|
||||||
- `POST /api/codex/cookbook/serve` — launch a serve task. Body matches `ServeRequest`: `{ repo_id, cmd, remote_host?, ssh_port?, env_prefix?, gpus?, platform? }`. The `cmd` is validated: leading binary must be `vllm`/`python3`/`sglang`/`llama-server`/`ollama`/`node`/`npx`. NEVER prefix with `cd …`, `source …`, or chain with `&&`/`||`/`;`/`$(...)` — the validator rejects shell metacharacters. The venv activation (`env_prefix`) is added automatically from the host's saved settings, so pass the bare binary + args. Requires `cookbook:launch`.
|
|
||||||
- `POST /api/codex/cookbook/preset/{name}` — launch a saved preset by name. Reuses the working cmd + host the user already saved. Requires `cookbook:launch`.
|
|
||||||
- `POST /api/codex/cookbook/adopt` — register an externally-launched tmux session into cookbook tracking. Body: `{ tmux_session, model, host?, port? }`. Use this when serve_model rejected a cmd and you fell back to direct ssh+tmux — without adoption, the session is invisible to the UI. Requires `cookbook:launch`.
|
|
||||||
- `POST /api/codex/cookbook/stop/{session_id}` — kill the tmux session. Requires `cookbook:launch`.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 ~/plugins/odysseus/scripts/odysseus_api.py cookbook tasks
|
|
||||||
python3 ~/plugins/odysseus/scripts/odysseus_api.py cookbook output serve-abc12345 400
|
|
||||||
python3 ~/plugins/odysseus/scripts/odysseus_api.py cookbook stop serve-abc12345
|
|
||||||
python3 ~/plugins/odysseus/scripts/odysseus_api.py cookbook serve \
|
|
||||||
/mnt/HADES/models/Qwen3.5-397B-A17B-AWQ \
|
|
||||||
"vllm serve /mnt/HADES/models/Qwen3.5-397B-A17B-AWQ --host 0.0.0.0 --port 8001 --tensor-parallel-size 8 --max-model-len 262144 --gpu-memory-utilization 0.90 --dtype auto --max-num-seqs 8 --trust-remote-code --enable-expert-parallel --enable-auto-tool-choice --tool-call-parser qwen3_coder --reasoning-parser qwen3" \
|
|
||||||
pewds@192.168.1.12
|
|
||||||
```
|
|
||||||
|
|
||||||
**Debug loop pattern:** `tasks` → `output SID 600` (find root cause; request larger `tail` if it references "above") → `stop SID` → `serve repo "new cmd"` → wait ~20s → `output` on the new sessionId.
|
|
||||||
|
|
||||||
**Hard limits this surface enforces:**
|
|
||||||
- `cookbook serve` cmd allowlist + shell-metacharacter rejection.
|
|
||||||
- `cookbook stop` requires sessionIds matching `[a-zA-Z0-9_-]+`.
|
|
||||||
- Agent CAN spawn GPU-pinning long-lived processes — always `cookbook stop` your previous attempt before relaunching.
|
|
||||||
|
|
||||||
## Forbidden Bypass Pattern
|
## Forbidden Bypass Pattern
|
||||||
|
|
||||||
If you are about to reach the Odysseus host/container, import app internals, query the database, or call MCP helper modules directly, stop. Those paths bypass Odysseus Settings and token scopes. Ask the user to enable the relevant Codex Agent tool toggle instead.
|
If you are about to reach the Odysseus host/container, import app internals, query the database, or call MCP helper modules directly, stop. Those paths bypass Odysseus Settings and token scopes. Ask the user to enable the relevant Codex Agent tool toggle instead.
|
||||||
|
|||||||
@@ -30,26 +30,14 @@ function Fail($msg) {
|
|||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
function Test-WindowsBashStub($path) {
|
|
||||||
if (-not $path) { return $false }
|
|
||||||
$lowered = $path.ToLowerInvariant()
|
|
||||||
foreach ($stub in @("system32\bash.exe", "sysnative\bash.exe", "windowsapps\bash.exe")) {
|
|
||||||
if ($lowered.Contains($stub)) { return $true }
|
|
||||||
}
|
|
||||||
return $false
|
|
||||||
}
|
|
||||||
|
|
||||||
function Find-GitBash {
|
function Find-GitBash {
|
||||||
$cmd = Get-Command bash -ErrorAction SilentlyContinue
|
$cmd = Get-Command bash -ErrorAction SilentlyContinue
|
||||||
if ($cmd -and -not (Test-WindowsBashStub $cmd.Source)) { return $cmd.Source }
|
if ($cmd) { return $cmd.Source }
|
||||||
|
|
||||||
$roots = @()
|
$roots = @()
|
||||||
foreach ($name in @("ProgramFiles", "ProgramW6432", "ProgramFiles(x86)", "LocalAppData")) {
|
foreach ($name in @("ProgramFiles", "ProgramW6432", "ProgramFiles(x86)", "LocalAppData")) {
|
||||||
$base = [Environment]::GetEnvironmentVariable($name)
|
$base = [Environment]::GetEnvironmentVariable($name)
|
||||||
if ($base) {
|
if ($base) { $roots += (Join-Path $base "Git") }
|
||||||
$roots += (Join-Path $base "Git")
|
|
||||||
if ($name -eq "LocalAppData") { $roots += (Join-Path $base "Programs\Git") }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
$roots += @("C:\Program Files\Git", "C:\Program Files (x86)\Git")
|
$roots += @("C:\Program Files\Git", "C:\Program Files (x86)\Git")
|
||||||
|
|
||||||
@@ -105,14 +93,6 @@ if (-not $pyExe) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($pyExe -like "*WindowsApps*python.exe") {
|
|
||||||
$pyCmd = Get-Command py -ErrorAction SilentlyContinue
|
|
||||||
if ($pyCmd) {
|
|
||||||
$pyExe = $pyCmd.Source
|
|
||||||
$pyArgs = @("-3.11")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (-not $pyExe) {
|
if (-not $pyExe) {
|
||||||
Fail "Couldn't find Python 3.11+ for Windows setup. Install Python 3.11+ (or open the Python launcher with 'py -3.11') from https://www.python.org/downloads/, then re-run this script."
|
Fail "Couldn't find Python 3.11+ for Windows setup. Install Python 3.11+ (or open the Python launcher with 'py -3.11') from https://www.python.org/downloads/, then re-run this script."
|
||||||
}
|
}
|
||||||
@@ -149,20 +129,7 @@ if (-not (Find-GitBash)) {
|
|||||||
Write-Host " https://git-scm.com/download/win" -ForegroundColor Yellow
|
Write-Host " https://git-scm.com/download/win" -ForegroundColor Yellow
|
||||||
}
|
}
|
||||||
|
|
||||||
# 6. Point CUDA_PATH at a real CUDA toolkit so GPU llama-cpp-python can import.
|
# 6. Start the server (use `python -m uvicorn` - bare `uvicorn` may not be on PATH)
|
||||||
$cudaBase = "C:\Program Files\NVIDIA GPU Computing Toolkit\CUDA"
|
|
||||||
if (Test-Path $cudaBase) {
|
|
||||||
$cudaBest = Get-ChildItem $cudaBase -Directory -ErrorAction SilentlyContinue |
|
|
||||||
Where-Object { Test-Path (Join-Path $_.FullName "bin") } |
|
|
||||||
Sort-Object { try { [version]($_.Name -replace "^v", "") } catch { [version]"0.0" } } -Descending |
|
|
||||||
Select-Object -First 1
|
|
||||||
if ($cudaBest) {
|
|
||||||
$env:CUDA_PATH = $cudaBest.FullName
|
|
||||||
Write-Host ("Using CUDA_PATH = " + $cudaBest.FullName) -ForegroundColor Cyan
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# 7. Start the server (use `python -m uvicorn` - bare `uvicorn` may not be on PATH)
|
|
||||||
Write-Step ("Starting Odysseus at http://{0}:{1}" -f $BindHost, $Port)
|
Write-Step ("Starting Odysseus at http://{0}:{1}" -f $BindHost, $Port)
|
||||||
Write-Host "Press Ctrl+C to stop."
|
Write-Host "Press Ctrl+C to stop."
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
|
|||||||
@@ -1,142 +0,0 @@
|
|||||||
# launcher.py
|
|
||||||
"""Dedicated entrypoint for the standalone Windows portable launcher.
|
|
||||||
|
|
||||||
Handles:
|
|
||||||
- Immediate GUI splash screen creation using tkinter.
|
|
||||||
- Suppressing console stream crashes in windowed GUI mode via NullWriter.
|
|
||||||
- Spawning system tray icon via pystray and Pillow (lazy-loaded).
|
|
||||||
- Auto-opening default browser pointing to the running backend.
|
|
||||||
- Launching the FastAPI server (importing and running app.py).
|
|
||||||
"""
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
import webbrowser
|
|
||||||
|
|
||||||
# Define a dummy NullWriter to suppress standard stream crashes (isatty etc.) in GUI mode
|
|
||||||
class NullWriter:
|
|
||||||
def write(self, text):
|
|
||||||
pass
|
|
||||||
def flush(self):
|
|
||||||
pass
|
|
||||||
def isatty(self):
|
|
||||||
return False
|
|
||||||
|
|
||||||
if sys.stdout is None:
|
|
||||||
sys.stdout = NullWriter()
|
|
||||||
if sys.stderr is None:
|
|
||||||
sys.stderr = NullWriter()
|
|
||||||
|
|
||||||
|
|
||||||
splash_root = None
|
|
||||||
|
|
||||||
# If running from a frozen PyInstaller bundle, launch the splash screen IMMEDIATELY
|
|
||||||
if getattr(sys, 'frozen', False):
|
|
||||||
import tkinter as tk
|
|
||||||
|
|
||||||
def show_splash_instantly():
|
|
||||||
global splash_root
|
|
||||||
try:
|
|
||||||
splash_root = tk.Tk()
|
|
||||||
splash_root.title("Odysseus")
|
|
||||||
splash_root.overrideredirect(True)
|
|
||||||
splash_root.configure(bg="#1a1c23")
|
|
||||||
|
|
||||||
# Accented borders
|
|
||||||
splash_root.config(highlightbackground="#e06c75", highlightcolor="#e06c75", highlightthickness=1)
|
|
||||||
|
|
||||||
w, h = 360, 160
|
|
||||||
ws = splash_root.winfo_screenwidth()
|
|
||||||
hs = splash_root.winfo_screenheight()
|
|
||||||
x = (ws - w) // 2
|
|
||||||
y = (hs - h) // 2
|
|
||||||
splash_root.geometry(f"{w}x{h}+{x}+{y}")
|
|
||||||
|
|
||||||
tk.Label(splash_root, text="⛵ Odysseus", font=("Segoe UI", 22, "bold"), bg="#1a1c23", fg="#e06c75").pack(pady=(22, 2))
|
|
||||||
tk.Label(splash_root, text="Launching background services...", font=("Segoe UI", 10), bg="#1a1c23", fg="#d1d4e0").pack(pady=2)
|
|
||||||
tk.Label(splash_root, text="Please wait, this will take a few seconds.", font=("Segoe UI", 8, "italic"), bg="#1a1c23", fg="#5c6370").pack(pady=(12, 0))
|
|
||||||
|
|
||||||
splash_root.attributes("-topmost", True)
|
|
||||||
splash_root.mainloop()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Launch the GUI splash screen immediately on a background thread
|
|
||||||
threading.Thread(target=show_splash_instantly, daemon=True).start()
|
|
||||||
|
|
||||||
|
|
||||||
def create_tray_image():
|
|
||||||
# Generate a beautiful 64x64 icon matching Odysseus brand red accent (#e06c75)
|
|
||||||
from PIL import Image, ImageDraw
|
|
||||||
image = Image.new('RGBA', (64, 64), (0, 0, 0, 0))
|
|
||||||
dc = ImageDraw.Draw(image)
|
|
||||||
accent_red = (224, 108, 117, 255)
|
|
||||||
light_red = (224, 108, 117, 150)
|
|
||||||
|
|
||||||
# Draw premium sailing boat
|
|
||||||
dc.polygon([(32, 10), (32, 45), (12, 45)], fill=accent_red)
|
|
||||||
dc.polygon([(32, 18), (32, 45), (48, 45)], fill=light_red)
|
|
||||||
dc.polygon([(8, 48), (56, 48), (44, 56), (20, 56)], fill=accent_red)
|
|
||||||
return image
|
|
||||||
|
|
||||||
|
|
||||||
def on_open_browser(icon, item, url):
|
|
||||||
webbrowser.open(url)
|
|
||||||
|
|
||||||
|
|
||||||
def on_exit(icon, item):
|
|
||||||
icon.stop()
|
|
||||||
os._exit(0)
|
|
||||||
|
|
||||||
|
|
||||||
def setup_system_tray(url):
|
|
||||||
try:
|
|
||||||
import pystray
|
|
||||||
icon_img = create_tray_image()
|
|
||||||
menu = (
|
|
||||||
pystray.MenuItem('Open Odysseus', lambda icon, item: on_open_browser(icon, item, url), default=True),
|
|
||||||
pystray.MenuItem('Exit', on_exit)
|
|
||||||
)
|
|
||||||
tray_icon = pystray.Icon(
|
|
||||||
"Odysseus",
|
|
||||||
icon_img,
|
|
||||||
"Odysseus",
|
|
||||||
menu
|
|
||||||
)
|
|
||||||
tray_icon.run()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def open_browser(url):
|
|
||||||
# Allow uvicorn and app lifecycles to complete warmups
|
|
||||||
time.sleep(3.5)
|
|
||||||
|
|
||||||
# Safely close the splash screen
|
|
||||||
try:
|
|
||||||
global splash_root
|
|
||||||
if splash_root:
|
|
||||||
splash_root.after(0, splash_root.destroy)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
webbrowser.open(url)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
import uvicorn
|
|
||||||
# Import the FastAPI app from app.py
|
|
||||||
from app import app
|
|
||||||
|
|
||||||
bind_host = os.getenv("APP_BIND", "127.0.0.1")
|
|
||||||
bind_port = int(os.getenv("APP_PORT", "7000"))
|
|
||||||
url = f"http://{bind_host}:{bind_port}"
|
|
||||||
|
|
||||||
if getattr(sys, 'frozen', False):
|
|
||||||
# Start browser manager thread
|
|
||||||
threading.Thread(target=open_browser, args=(url,), daemon=True).start()
|
|
||||||
# Start system tray manager thread
|
|
||||||
threading.Thread(target=setup_system_tray, args=(url,), daemon=True).start()
|
|
||||||
|
|
||||||
uvicorn.run(app, host=bind_host, port=bind_port, log_level="info")
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
Copyright (c) 2019-07-29, Abbie Gonzalez (https://abbiecod.es|support@abbiecod.es),
|
|
||||||
with Reserved Font Name OpenDyslexic.
|
|
||||||
Copyright (c) 12/2012 - 2019
|
|
||||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
|
||||||
This license is copied below, and is also available with a FAQ at:
|
|
||||||
http://scripts.sil.org/OFL
|
|
||||||
|
|
||||||
|
|
||||||
-----------------------------------------------------------
|
|
||||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
|
||||||
-----------------------------------------------------------
|
|
||||||
|
|
||||||
PREAMBLE
|
|
||||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
|
||||||
development of collaborative font projects, to support the font creation
|
|
||||||
efforts of academic and linguistic communities, and to provide a free and
|
|
||||||
open framework in which fonts may be shared and improved in partnership
|
|
||||||
with others.
|
|
||||||
|
|
||||||
The OFL allows the licensed fonts to be used, studied, modified and
|
|
||||||
redistributed freely as long as they are not sold by themselves. The
|
|
||||||
fonts, including any derivative works, can be bundled, embedded,
|
|
||||||
redistributed and/or sold with any software provided that any reserved
|
|
||||||
names are not used by derivative works. The fonts and derivatives,
|
|
||||||
however, cannot be released under any other type of license. The
|
|
||||||
requirement for fonts to remain under this license does not apply
|
|
||||||
to any document created using the fonts or their derivatives.
|
|
||||||
|
|
||||||
DEFINITIONS
|
|
||||||
"Font Software" refers to the set of files released by the Copyright
|
|
||||||
Holder(s) under this license and clearly marked as such. This may
|
|
||||||
include source files, build scripts and documentation.
|
|
||||||
|
|
||||||
"Reserved Font Name" refers to any names specified as such after the
|
|
||||||
copyright statement(s).
|
|
||||||
|
|
||||||
"Original Version" refers to the collection of Font Software components as
|
|
||||||
distributed by the Copyright Holder(s).
|
|
||||||
|
|
||||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
|
||||||
or substituting -- in part or in whole -- any of the components of the
|
|
||||||
Original Version, by changing formats or by porting the Font Software to a
|
|
||||||
new environment.
|
|
||||||
|
|
||||||
"Author" refers to any designer, engineer, programmer, technical
|
|
||||||
writer or other person who contributed to the Font Software.
|
|
||||||
|
|
||||||
PERMISSION & CONDITIONS
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining
|
|
||||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
|
||||||
redistribute, and sell modified and unmodified copies of the Font
|
|
||||||
Software, subject to the following conditions:
|
|
||||||
|
|
||||||
1) Neither the Font Software nor any of its individual components,
|
|
||||||
in Original or Modified Versions, may be sold by itself.
|
|
||||||
|
|
||||||
2) Original or Modified Versions of the Font Software may be bundled,
|
|
||||||
redistributed and/or sold with any software, provided that each copy
|
|
||||||
contains the above copyright notice and this license. These can be
|
|
||||||
included either as stand-alone text files, human-readable headers or
|
|
||||||
in the appropriate machine-readable metadata fields within text or
|
|
||||||
binary files as long as those fields can be easily viewed by the user.
|
|
||||||
|
|
||||||
3) No Modified Version of the Font Software may use the Reserved Font
|
|
||||||
Name(s) unless explicit written permission is granted by the corresponding
|
|
||||||
Copyright Holder. This restriction only applies to the primary font name as
|
|
||||||
presented to the users.
|
|
||||||
|
|
||||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
|
||||||
Software shall not be used to promote, endorse or advertise any
|
|
||||||
Modified Version, except to acknowledge the contribution(s) of the
|
|
||||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
|
||||||
permission.
|
|
||||||
|
|
||||||
5) The Font Software, modified or unmodified, in part or in whole,
|
|
||||||
must be distributed entirely under this license, and must not be
|
|
||||||
distributed under any other license. The requirement for fonts to
|
|
||||||
remain under this license does not apply to any document created
|
|
||||||
using the Font Software.
|
|
||||||
|
|
||||||
TERMINATION
|
|
||||||
This license becomes null and void if any of the above conditions are
|
|
||||||
not met.
|
|
||||||
|
|
||||||
DISCLAIMER
|
|
||||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
||||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
|
||||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
|
||||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
|
||||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
|
||||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
|
||||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
||||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
|
||||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
"""
|
||||||
|
_common.py
|
||||||
|
|
||||||
|
Shared constants and helpers for built-in MCP servers.
|
||||||
|
"""
|
||||||
|
|
||||||
|
MAX_OUTPUT_CHARS = 10_000
|
||||||
|
MAX_READ_CHARS = 20_000
|
||||||
|
SHELL_TIMEOUT = 60
|
||||||
|
PYTHON_TIMEOUT = 30
|
||||||
|
SEARCH_TIMEOUT = 30
|
||||||
|
|
||||||
|
|
||||||
|
def truncate(text: str, limit: int = MAX_OUTPUT_CHARS) -> str:
|
||||||
|
"""Truncate text to *limit* characters with a suffix note."""
|
||||||
|
if not isinstance(text, str):
|
||||||
|
# Tool output is occasionally None or a non-string; len(None) would
|
||||||
|
# raise. Coerce so this shared helper never crashes a tool response.
|
||||||
|
text = "" if text is None else str(text)
|
||||||
|
if len(text) > limit:
|
||||||
|
return text[:limit] + f"\n... (truncated, {len(text)} chars total)"
|
||||||
|
return text
|
||||||
@@ -16,8 +16,6 @@ from mcp.types import Tool, TextContent
|
|||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
from src.constants import GENERATED_IMAGES_DIR
|
|
||||||
|
|
||||||
server = Server("image_gen")
|
server = Server("image_gen")
|
||||||
|
|
||||||
|
|
||||||
@@ -73,7 +71,7 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
|||||||
if not model_spec:
|
if not model_spec:
|
||||||
for candidate in ("gpt-image-1.5", "gpt-image-1", "dall-e-3"):
|
for candidate in ("gpt-image-1.5", "gpt-image-1", "dall-e-3"):
|
||||||
try:
|
try:
|
||||||
await asyncio.to_thread(_resolve_model, candidate)
|
_resolve_model(candidate)
|
||||||
model_spec = candidate
|
model_spec = candidate
|
||||||
break
|
break
|
||||||
except ValueError:
|
except ValueError:
|
||||||
@@ -81,13 +79,7 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
|||||||
if not model_spec:
|
if not model_spec:
|
||||||
return [TextContent(type="text", text="Error: No image model found. Configure one in Admin.")]
|
return [TextContent(type="text", text="Error: No image model found. Configure one in Admin.")]
|
||||||
|
|
||||||
try:
|
url, model_id, headers = _resolve_model(model_spec)
|
||||||
url, model_id, headers = await asyncio.to_thread(_resolve_model, model_spec, model_type="image")
|
|
||||||
except ValueError:
|
|
||||||
_lower_model_spec = model_spec.lower()
|
|
||||||
if not any(_name in _lower_model_spec for _name in ("gpt-image", "dall-e")):
|
|
||||||
raise
|
|
||||||
url, model_id, headers = await asyncio.to_thread(_resolve_model, model_spec)
|
|
||||||
|
|
||||||
is_gpt_image = "gpt-image" in model_id.lower()
|
is_gpt_image = "gpt-image" in model_id.lower()
|
||||||
base_url = url.replace("/chat/completions", "").replace("/v1/messages", "").rstrip("/")
|
base_url = url.replace("/chat/completions", "").replace("/v1/messages", "").rstrip("/")
|
||||||
@@ -123,18 +115,14 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
|||||||
|
|
||||||
img = images[0]
|
img = images[0]
|
||||||
image_url = None
|
image_url = None
|
||||||
# Prefix the instance's public base URL (existing app_public_url setting) so the
|
|
||||||
# link is fully-qualified and clickable when the model echoes it. Empty = relative
|
|
||||||
# same-origin path (unchanged default).
|
|
||||||
_pub_base = (get_setting("app_public_url", "") or "").rstrip("/")
|
|
||||||
|
|
||||||
if img.get("b64_json"):
|
if img.get("b64_json"):
|
||||||
img_dir = Path(GENERATED_IMAGES_DIR)
|
img_dir = Path("data/generated_images")
|
||||||
img_dir.mkdir(parents=True, exist_ok=True)
|
img_dir.mkdir(parents=True, exist_ok=True)
|
||||||
filename = f"{uuid.uuid4().hex[:12]}.png"
|
filename = f"{uuid.uuid4().hex[:12]}.png"
|
||||||
img_path = img_dir / filename
|
img_path = img_dir / filename
|
||||||
img_path.write_bytes(base64.b64decode(img["b64_json"]))
|
img_path.write_bytes(base64.b64decode(img["b64_json"]))
|
||||||
image_url = f"{_pub_base}/api/generated-image/{filename}"
|
image_url = f"/api/generated-image/{filename}"
|
||||||
|
|
||||||
# Save to gallery
|
# Save to gallery
|
||||||
try:
|
try:
|
||||||
@@ -158,13 +146,7 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
|||||||
else:
|
else:
|
||||||
return [TextContent(type="text", text="Error: Unexpected image API response format")]
|
return [TextContent(type="text", text="Error: Unexpected image API response format")]
|
||||||
|
|
||||||
# "Direct link:" rather than an "image_url:" label — small models copied the
|
result = f"Generated image for: {prompt[:100]}\nimage_url: {image_url}\nmodel: {model_id}\nsize: {size}"
|
||||||
# label token ("image_url") into the link href, producing a broken link.
|
|
||||||
result = (
|
|
||||||
f"Generated image for: {prompt[:100]}\n"
|
|
||||||
f"Direct link: {image_url}\n"
|
|
||||||
f"model: {model_id}\nsize: {size}"
|
|
||||||
)
|
|
||||||
return [TextContent(type="text", text=result)]
|
return [TextContent(type="text", text=result)]
|
||||||
|
|
||||||
except httpx.TimeoutException:
|
except httpx.TimeoutException:
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ Imports MemoryManager and MemoryVectorStore from the Odysseus codebase.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import os
|
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -24,55 +23,6 @@ _memory_manager = None
|
|||||||
_memory_vector = None
|
_memory_vector = None
|
||||||
_initialized = False
|
_initialized = False
|
||||||
|
|
||||||
_OWNER_ENV_KEYS = ("ODYSSEUS_MCP_MEMORY_OWNER", "ODYSSEUS_MEMORY_OWNER")
|
|
||||||
_OWNER_SCOPE_ERROR = (
|
|
||||||
"Error: Memory MCP owner is not configured for an owner-scoped memory store. "
|
|
||||||
"Set ODYSSEUS_MCP_MEMORY_OWNER for this server or use the owner-aware native memory tool."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _configured_owner() -> str | None:
|
|
||||||
for key in _OWNER_ENV_KEYS:
|
|
||||||
owner = os.environ.get(key, "").strip()
|
|
||||||
if owner:
|
|
||||||
return owner
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _entry_owner(entry: dict) -> str | None:
|
|
||||||
owner = entry.get("owner")
|
|
||||||
if owner is None:
|
|
||||||
return None
|
|
||||||
owner_text = str(owner).strip()
|
|
||||||
return owner_text or None
|
|
||||||
|
|
||||||
|
|
||||||
def _owner_scoped_store(entries: list[dict]) -> bool:
|
|
||||||
return any(_entry_owner(entry) for entry in entries if isinstance(entry, dict))
|
|
||||||
|
|
||||||
|
|
||||||
def _scope_entries() -> tuple[str | None, list[dict], list[dict], str | None]:
|
|
||||||
"""Return configured owner, all entries, visible entries, and optional error."""
|
|
||||||
entries = _memory_manager.load_all()
|
|
||||||
owner = _configured_owner()
|
|
||||||
if owner is None and _owner_scoped_store(entries):
|
|
||||||
return None, entries, [], _OWNER_SCOPE_ERROR
|
|
||||||
if owner is None:
|
|
||||||
visible = [
|
|
||||||
entry for entry in entries
|
|
||||||
if isinstance(entry, dict) and _entry_owner(entry) is None
|
|
||||||
]
|
|
||||||
else:
|
|
||||||
visible = [
|
|
||||||
entry for entry in entries
|
|
||||||
if isinstance(entry, dict) and _entry_owner(entry) == owner
|
|
||||||
]
|
|
||||||
return owner, entries, visible, None
|
|
||||||
|
|
||||||
|
|
||||||
def _text_result(text: str) -> list[TextContent]:
|
|
||||||
return [TextContent(type="text", text=text)]
|
|
||||||
|
|
||||||
|
|
||||||
def _ensure_init():
|
def _ensure_init():
|
||||||
"""Lazy-init memory managers on first use."""
|
"""Lazy-init memory managers on first use."""
|
||||||
@@ -125,46 +75,43 @@ async def list_tools() -> list[Tool]:
|
|||||||
@server.call_tool()
|
@server.call_tool()
|
||||||
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
||||||
if name != "manage_memory":
|
if name != "manage_memory":
|
||||||
return _text_result(f"Unknown tool: {name}")
|
return [TextContent(type="text", text=f"Unknown tool: {name}")]
|
||||||
|
|
||||||
_ensure_init()
|
_ensure_init()
|
||||||
if not _memory_manager:
|
if not _memory_manager:
|
||||||
return _text_result("Error: Memory manager not available")
|
return [TextContent(type="text", text="Error: Memory manager not available")]
|
||||||
|
|
||||||
action = arguments.get("action", "")
|
action = arguments.get("action", "")
|
||||||
|
|
||||||
if action == "list":
|
if action == "list":
|
||||||
category_filter = arguments.get("category", "")
|
category_filter = arguments.get("category", "")
|
||||||
_owner, _all_memories, memories, scope_error = _scope_entries()
|
memories = _memory_manager.load()
|
||||||
if scope_error:
|
|
||||||
return _text_result(scope_error)
|
|
||||||
if category_filter:
|
if category_filter:
|
||||||
memories = [m for m in memories if m.get("category", "").lower() == category_filter.lower()]
|
memories = [m for m in memories if m.get("category", "").lower() == category_filter.lower()]
|
||||||
if not memories:
|
if not memories:
|
||||||
msg = "No memories found"
|
msg = "No memories found"
|
||||||
if category_filter:
|
if category_filter:
|
||||||
msg += f" in category '{category_filter}'"
|
msg += f" in category '{category_filter}'"
|
||||||
return _text_result(msg + ".")
|
return [TextContent(type="text", text=msg + ".")]
|
||||||
|
|
||||||
lines = [f"Found {len(memories)} memory entries:\n"]
|
lines = [f"Found {len(memories)} memory entries:\n"]
|
||||||
for m in memories:
|
for m in memories[:100]:
|
||||||
cat = m.get("category", "fact")
|
cat = m.get("category", "fact")
|
||||||
mid = m.get("id", "?")[:8]
|
mid = m.get("id", "?")[:8]
|
||||||
text = m.get("text", "")
|
text = m.get("text", "")
|
||||||
if len(text) > 150:
|
if len(text) > 150:
|
||||||
text = text[:150] + "..."
|
text = text[:150] + "..."
|
||||||
lines.append(f"- [{cat}] `{mid}` — {text}")
|
lines.append(f"- [{cat}] `{mid}` — {text}")
|
||||||
return _text_result("\n".join(lines))
|
if len(memories) > 100:
|
||||||
|
lines.append(f"... and {len(memories) - 100} more")
|
||||||
|
return [TextContent(type="text", text="\n".join(lines))]
|
||||||
|
|
||||||
elif action == "add":
|
elif action == "add":
|
||||||
text = arguments.get("text", "")
|
text = arguments.get("text", "")
|
||||||
category = arguments.get("category", "fact")
|
category = arguments.get("category", "fact")
|
||||||
if not text:
|
if not text:
|
||||||
return _text_result("Error: Memory text cannot be empty")
|
return [TextContent(type="text", text="Error: Memory text cannot be empty")]
|
||||||
owner, memories, _visible, scope_error = _scope_entries()
|
entry = _memory_manager.add_entry(text, source="ai_agent", category=category)
|
||||||
if scope_error:
|
memories = _memory_manager.load_all()
|
||||||
return _text_result(scope_error)
|
|
||||||
entry = _memory_manager.add_entry(text, source="ai_agent", category=category, owner=owner)
|
|
||||||
memories.append(entry)
|
memories.append(entry)
|
||||||
_memory_manager.save(memories)
|
_memory_manager.save(memories)
|
||||||
if _memory_vector and _memory_vector.healthy:
|
if _memory_vector and _memory_vector.healthy:
|
||||||
@@ -172,28 +119,25 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
|||||||
_memory_vector.add(entry["id"], text)
|
_memory_vector.add(entry["id"], text)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return _text_result(f"Memory added: [{category}] {text} (id: {entry['id'][:8]})")
|
return [TextContent(type="text", text=f"Memory added: [{category}] {text} (id: {entry['id'][:8]})")]
|
||||||
|
|
||||||
elif action == "edit":
|
elif action == "edit":
|
||||||
memory_id = arguments.get("memory_id", "")
|
memory_id = arguments.get("memory_id", "")
|
||||||
new_text = arguments.get("text", "")
|
new_text = arguments.get("text", "")
|
||||||
if not memory_id or not new_text:
|
if not memory_id or not new_text:
|
||||||
return _text_result("Error: edit needs memory_id and text")
|
return [TextContent(type="text", text="Error: edit needs memory_id and text")]
|
||||||
_owner, memories, visible, scope_error = _scope_entries()
|
memories = _memory_manager.load_all()
|
||||||
if scope_error:
|
found = False
|
||||||
return _text_result(scope_error)
|
|
||||||
full_id = None
|
full_id = None
|
||||||
for m in visible:
|
|
||||||
if m.get("id", "").startswith(memory_id):
|
|
||||||
full_id = m["id"]
|
|
||||||
break
|
|
||||||
if not full_id:
|
|
||||||
return _text_result(f"Error: Memory '{memory_id}' not found")
|
|
||||||
for m in memories:
|
for m in memories:
|
||||||
if m.get("id") == full_id:
|
if m.get("id", "").startswith(memory_id):
|
||||||
m["text"] = new_text
|
m["text"] = new_text
|
||||||
m["timestamp"] = int(time.time())
|
m["timestamp"] = int(time.time())
|
||||||
|
found = True
|
||||||
|
full_id = m["id"]
|
||||||
break
|
break
|
||||||
|
if not found:
|
||||||
|
return [TextContent(type="text", text=f"Error: Memory '{memory_id}' not found")]
|
||||||
_memory_manager.save(memories)
|
_memory_manager.save(memories)
|
||||||
if _memory_vector and _memory_vector.healthy and full_id:
|
if _memory_vector and _memory_vector.healthy and full_id:
|
||||||
try:
|
try:
|
||||||
@@ -201,26 +145,24 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
|||||||
_memory_vector.add(full_id, new_text)
|
_memory_vector.add(full_id, new_text)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return _text_result(f"Memory updated: {new_text}")
|
return [TextContent(type="text", text=f"Memory updated: {new_text}")]
|
||||||
|
|
||||||
elif action == "delete":
|
elif action == "delete":
|
||||||
memory_id = arguments.get("memory_id", "")
|
memory_id = arguments.get("memory_id", "")
|
||||||
if not memory_id:
|
if not memory_id:
|
||||||
return _text_result("Error: delete needs memory_id")
|
return [TextContent(type="text", text="Error: delete needs memory_id")]
|
||||||
_owner, memories, visible, scope_error = _scope_entries()
|
memories = _memory_manager.load_all()
|
||||||
if scope_error:
|
|
||||||
return _text_result(scope_error)
|
|
||||||
full_id = None
|
full_id = None
|
||||||
deleted_text = ""
|
deleted_text = ""
|
||||||
deleted_category = ""
|
deleted_category = ""
|
||||||
for m in visible:
|
for m in memories:
|
||||||
if m.get("id", "").startswith(memory_id):
|
if m.get("id", "").startswith(memory_id):
|
||||||
full_id = m["id"]
|
full_id = m["id"]
|
||||||
deleted_text = m.get("text", "")
|
deleted_text = m.get("text", "")
|
||||||
deleted_category = m.get("category", "")
|
deleted_category = m.get("category", "")
|
||||||
break
|
break
|
||||||
if not full_id:
|
if not full_id:
|
||||||
return _text_result(f"Error: Memory '{memory_id}' not found")
|
return [TextContent(type="text", text=f"Error: Memory '{memory_id}' not found")]
|
||||||
memories = [m for m in memories if m.get("id") != full_id]
|
memories = [m for m in memories if m.get("id") != full_id]
|
||||||
_memory_manager.save(memories)
|
_memory_manager.save(memories)
|
||||||
if _memory_vector and _memory_vector.healthy and full_id:
|
if _memory_vector and _memory_vector.healthy and full_id:
|
||||||
@@ -230,32 +172,30 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
|||||||
pass
|
pass
|
||||||
cat = f"[{deleted_category}] " if deleted_category else ""
|
cat = f"[{deleted_category}] " if deleted_category else ""
|
||||||
snippet = deleted_text if len(deleted_text) <= 120 else deleted_text[:117] + "..."
|
snippet = deleted_text if len(deleted_text) <= 120 else deleted_text[:117] + "..."
|
||||||
return _text_result(f"Memory deleted: {cat}{snippet} (id: {memory_id})")
|
return [TextContent(type="text", text=f"Memory deleted: {cat}{snippet} (id: {memory_id})")]
|
||||||
|
|
||||||
elif action == "search":
|
elif action == "search":
|
||||||
query = arguments.get("text", "")
|
query = arguments.get("text", "")
|
||||||
if not query:
|
if not query:
|
||||||
return _text_result("Error: search needs text (query)")
|
return [TextContent(type="text", text="Error: search needs text (query)")]
|
||||||
_owner, _all_memories, memories, scope_error = _scope_entries()
|
memories = _memory_manager.load()
|
||||||
if scope_error:
|
|
||||||
return _text_result(scope_error)
|
|
||||||
if hasattr(_memory_manager, 'get_relevant_memories'):
|
if hasattr(_memory_manager, 'get_relevant_memories'):
|
||||||
results = _memory_manager.get_relevant_memories(query, memories, threshold=0.05, max_items=20)
|
results = _memory_manager.get_relevant_memories(query, memories, threshold=0.05, max_items=20)
|
||||||
else:
|
else:
|
||||||
query_lower = query.lower()
|
query_lower = query.lower()
|
||||||
results = [m for m in memories if query_lower in m.get("text", "").lower()][:20]
|
results = [m for m in memories if query_lower in m.get("text", "").lower()][:20]
|
||||||
if not results:
|
if not results:
|
||||||
return _text_result(f"No memories found matching '{query}'.")
|
return [TextContent(type="text", text=f"No memories found matching '{query}'.")]
|
||||||
lines = [f"Found {len(results)} matching memories:\n"]
|
lines = [f"Found {len(results)} matching memories:\n"]
|
||||||
for m in results:
|
for m in results:
|
||||||
cat = m.get("category", "fact")
|
cat = m.get("category", "fact")
|
||||||
mid = m.get("id", "?")[:8]
|
mid = m.get("id", "?")[:8]
|
||||||
text = m.get("text", "")
|
text = m.get("text", "")
|
||||||
lines.append(f"- [{cat}] `{mid}` — {text}")
|
lines.append(f"- [{cat}] `{mid}` — {text}")
|
||||||
return _text_result("\n".join(lines))
|
return [TextContent(type="text", text="\n".join(lines))]
|
||||||
|
|
||||||
else:
|
else:
|
||||||
return _text_result(f"Error: Unknown action '{action}'. Use: list, add, edit, delete, search")
|
return [TextContent(type="text", text=f"Error: Unknown action '{action}'. Use: list, add, edit, delete, search")]
|
||||||
|
|
||||||
|
|
||||||
async def run():
|
async def run():
|
||||||
|
|||||||
@@ -1,22 +1,93 @@
|
|||||||
{
|
{
|
||||||
"name": "odysseus",
|
"name": "odysseus-ui",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
|
"dependencies": {
|
||||||
|
"@anthropic-ai/sdk": "^0.98.0"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@antithesishq/bombadil": "^0.6.1"
|
"@antithesishq/bombadil": "^0.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@anthropic-ai/sdk": {
|
||||||
|
"version": "0.98.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.98.0.tgz",
|
||||||
|
"integrity": "sha512-N7aXtCvC5g6T1Y4V29lJjceu/zTkVkIZF0jdBvagr0TRFHuKeImffalGWEfqZKrvjH+IQbzJWw6TmSmUzrlMgg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"json-schema-to-ts": "^3.1.1",
|
||||||
|
"standardwebhooks": "^1.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"anthropic-ai-sdk": "bin/cli"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"zod": "^3.25.0 || ^4.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"zod": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@antithesishq/bombadil": {
|
"node_modules/@antithesishq/bombadil": {
|
||||||
"version": "0.6.1",
|
"version": "0.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/@antithesishq/bombadil/-/bombadil-0.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/@antithesishq/bombadil/-/bombadil-0.3.2.tgz",
|
||||||
"integrity": "sha512-d1iufG3MI7gSMSiSmMeNdcMW+qR0yQXL2zdkVynC3n3DYgFJYlYXKUQzygmqU12m4RWlR5iOdQU1hsx5UT6+IA==",
|
"integrity": "sha512-ATy1w9ZY5gbny1H8DFc7rxZitT7DLLLFDiGcRZe+8TQiUrV5tLO+IJGOVNNLp3RpCqjZqSsxGiKoQsx31ipV1g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@babel/runtime": {
|
||||||
|
"version": "7.29.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
|
||||||
|
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bin": {
|
"engines": {
|
||||||
"bombadil": "bin/bombadil.js"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@stablelib/base64": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/fast-sha256": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
|
||||||
|
"license": "Unlicense"
|
||||||
|
},
|
||||||
|
"node_modules/json-schema-to-ts": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.18.3",
|
||||||
|
"ts-algebra": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/standardwebhooks": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@stablelib/base64": "^1.0.0",
|
||||||
|
"fast-sha256": "^1.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ts-algebra": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
|
||||||
|
"license": "MIT"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
{
|
{
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/odysseus-dev/odysseus.git"
|
"url": "https://github.com/pewdiepie-archdaemon/odysseus.git"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@antithesishq/bombadil": "^0.6.1"
|
"@antithesishq/bombadil": "^0.3.2"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@anthropic-ai/sdk": "^0.98.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,3 @@
|
|||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
asyncio_mode = "auto"
|
asyncio_mode = "auto"
|
||||||
# Test-taxonomy markers added at collection time by tests/conftest.py. The
|
|
||||||
# stable area_* markers are declared here; the dynamic sub_<filename-token>
|
|
||||||
# markers are registered before collection by pytest_configure in
|
|
||||||
# tests/conftest.py, so unknown-mark warnings still flag genuine typos outside
|
|
||||||
# the taxonomy. See tests/_taxonomy.py and tests/README.md.
|
|
||||||
markers = [
|
|
||||||
"area_security: tests covering auth, owner-scope, SSRF, XSS, confinement, redaction",
|
|
||||||
"area_routes: tests covering HTTP route / API behavior",
|
|
||||||
"area_services: tests covering service-layer behavior (llm, cookbook, email, calendar, ...)",
|
|
||||||
"area_cli: tests covering CLI / script behavior",
|
|
||||||
"area_js: JavaScript / Node-backed tests",
|
|
||||||
"area_helpers: self-tests for the shared test helpers in tests/helpers/",
|
|
||||||
"area_unit: pure parser / utility tests that do not clearly belong elsewhere",
|
|
||||||
"area_uncategorized: tests not yet matched by the taxonomy (fallback)",
|
|
||||||
# Fast-lane marker (issue #3443). Opt-in and orthogonal to the area_*/sub_*
|
|
||||||
# taxonomy. The fast lane runs `not slow`; mark a test slow only with
|
|
||||||
# duration evidence (see tests/run_focus.py --durations and tests/README.md).
|
|
||||||
"slow: opt-in marker for known-slow tests; excluded by the fast lane (not slow)",
|
|
||||||
]
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ faster-whisper
|
|||||||
# DuckDuckGo as a search provider option.
|
# DuckDuckGo as a search provider option.
|
||||||
# Install if you want DDG in the search-provider dropdown.
|
# Install if you want DDG in the search-provider dropdown.
|
||||||
# Alternatives: SearXNG, Brave, Tavily, Serper, Google PSE.
|
# Alternatives: SearXNG, Brave, Tavily, Serper, Google PSE.
|
||||||
ddgs
|
duckduckgo-search
|
||||||
|
|
||||||
# PDF form-filling feature (fillable AcroForm detection, field extraction,
|
# PDF form-filling feature (fillable AcroForm detection, field extraction,
|
||||||
# value/annotation/signature stamping, page rendering for the form overlay).
|
# value/annotation/signature stamping, page rendering for the form overlay).
|
||||||
@@ -33,4 +33,4 @@ PyMuPDF
|
|||||||
# magika (onnxruntime), already a core dep via fastembed. We avoid the
|
# magika (onnxruntime), already a core dep via fastembed. We avoid the
|
||||||
# [all]/Azure/audio extras (cloud + heavy). Pinned to a release >30 days old per
|
# [all]/Azure/audio extras (cloud + heavy). Pinned to a release >30 days old per
|
||||||
# the dependency-age discussion in issue #485.
|
# the dependency-age discussion in issue #485.
|
||||||
markitdown[docx,pptx,xlsx,xls]==0.1.6
|
markitdown[docx,pptx,xlsx,xls]==0.1.5
|
||||||
|
|||||||
@@ -3,9 +3,8 @@ uvicorn
|
|||||||
python-multipart
|
python-multipart
|
||||||
python-dotenv
|
python-dotenv
|
||||||
httpx
|
httpx
|
||||||
httpcore>=1.0,<2.0
|
pydantic>=2.0
|
||||||
pydantic>=2.13.4
|
pydantic-settings>=2.0
|
||||||
pydantic-settings>=2.14.1
|
|
||||||
SQLAlchemy
|
SQLAlchemy
|
||||||
pypdf
|
pypdf
|
||||||
beautifulsoup4
|
beautifulsoup4
|
||||||
@@ -22,10 +21,6 @@ youtube-transcript-api
|
|||||||
# Markdown rendering for research reports (src/visual_report.py).
|
# Markdown rendering for research reports (src/visual_report.py).
|
||||||
# Imported at module-top so it's a hard core dep, not optional.
|
# Imported at module-top so it's a hard core dep, not optional.
|
||||||
markdown
|
markdown
|
||||||
# HTML sanitizer for rendered research reports (src/visual_report.py). Report
|
|
||||||
# content is untrusted (LLM output over crawled pages) and report pages run
|
|
||||||
# under a relaxed CSP, so the rendered HTML is allowlist-sanitized.
|
|
||||||
nh3
|
|
||||||
# Calendar .ics import/export (routes/calendar_routes.py).
|
# Calendar .ics import/export (routes/calendar_routes.py).
|
||||||
icalendar
|
icalendar
|
||||||
# Recurrence rule expansion for calendar events (routes/calendar_routes.py).
|
# Recurrence rule expansion for calendar events (routes/calendar_routes.py).
|
||||||
@@ -44,7 +39,3 @@ qrcode[pil]
|
|||||||
croniter
|
croniter
|
||||||
pytest
|
pytest
|
||||||
pytest-asyncio
|
pytest-asyncio
|
||||||
# starlette.testclient prefers httpx2 since Starlette 1.2.0 and warns on every
|
|
||||||
# TestClient import when only classic httpx is present. Runtime code keeps
|
|
||||||
# using `httpx` above; this is test-client only.
|
|
||||||
httpx2
|
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
import re
|
|
||||||
|
|
||||||
from fastapi import HTTPException
|
|
||||||
|
|
||||||
|
|
||||||
_REMOTE_HOST_RE = re.compile(
|
|
||||||
r"^(?:[A-Za-z0-9][A-Za-z0-9._-]*@)?[A-Za-z0-9][A-Za-z0-9._-]*$"
|
|
||||||
)
|
|
||||||
_SSH_PORT_RE = re.compile(r"^\d{1,5}$")
|
|
||||||
|
|
||||||
|
|
||||||
def validate_remote_host(v: str | None) -> str | None:
|
|
||||||
if v is None or v == "":
|
|
||||||
return None
|
|
||||||
if not _REMOTE_HOST_RE.match(v):
|
|
||||||
raise HTTPException(
|
|
||||||
400,
|
|
||||||
"Invalid remote_host — must be host or user@host, no SSH option syntax",
|
|
||||||
)
|
|
||||||
return v
|
|
||||||
|
|
||||||
|
|
||||||
def validate_ssh_port(v: str | None) -> str | None:
|
|
||||||
if v is None or v == "":
|
|
||||||
return None
|
|
||||||
if not _SSH_PORT_RE.fullmatch(str(v)):
|
|
||||||
raise HTTPException(400, "Invalid ssh_port")
|
|
||||||
port = int(v)
|
|
||||||
if port < 1 or port > 65535:
|
|
||||||
raise HTTPException(400, "Invalid ssh_port")
|
|
||||||
return str(port)
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
"""Admin wipe route domain package (slice 2h, #4082/#4071).
|
|
||||||
|
|
||||||
Contains admin_wipe_routes.py, migrated from the flat routes/ directory.
|
|
||||||
Backward-compat shim at routes/admin_wipe_routes.py re-exports from here.
|
|
||||||
"""
|
|
||||||
@@ -1,176 +0,0 @@
|
|||||||
"""Admin Danger Zone — per-category wipes.
|
|
||||||
|
|
||||||
Each endpoint is admin-only and truncates exactly one domain so the
|
|
||||||
user can selectively reset memory / skills / notes / etc. without
|
|
||||||
nuking everything. The catch-all `chats` endpoint mirrors the
|
|
||||||
existing /api/sessions/all so the Danger Zone speaks one URL pattern.
|
|
||||||
|
|
||||||
URL shape: DELETE /api/admin/wipe/{kind}
|
|
||||||
Kinds: chats, memory, skills, notes, tasks, documents, gallery, calendar.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import shutil
|
|
||||||
from fastapi import APIRouter, HTTPException, Request
|
|
||||||
|
|
||||||
from core.middleware import require_admin
|
|
||||||
from core.database import (
|
|
||||||
SessionLocal,
|
|
||||||
Session as DbSession,
|
|
||||||
ChatMessage as DbChatMessage,
|
|
||||||
Memory,
|
|
||||||
Note,
|
|
||||||
ScheduledTask,
|
|
||||||
TaskRun,
|
|
||||||
Document,
|
|
||||||
DocumentVersion,
|
|
||||||
GalleryImage,
|
|
||||||
GalleryAlbum,
|
|
||||||
CalendarEvent,
|
|
||||||
CalendarCal,
|
|
||||||
)
|
|
||||||
from src.constants import DATA_DIR, SKILLS_DIR, SKILLS_FILE, GALLERY_DIR, GALLERY_UPLOADS_DIR
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def _wipe_memory_files():
|
|
||||||
"""Blank memory.json + drop the per-owner tidy-state sidecar so the
|
|
||||||
next audit doesn't try to diff against gone memories."""
|
|
||||||
for name in ("memory.json", "memory_tidy_state.json"):
|
|
||||||
p = os.path.join(DATA_DIR, name)
|
|
||||||
if not os.path.exists(p):
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
if name == "memory.json":
|
|
||||||
with open(p, "w", encoding="utf-8") as f:
|
|
||||||
json.dump([], f)
|
|
||||||
else:
|
|
||||||
os.remove(p)
|
|
||||||
except OSError as e:
|
|
||||||
logger.warning(f"Could not reset {name}: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
def _rmtree_quiet(path: str):
|
|
||||||
"""rmtree that doesn't crash if the path doesn't exist."""
|
|
||||||
if os.path.isdir(path):
|
|
||||||
try:
|
|
||||||
shutil.rmtree(path)
|
|
||||||
except OSError as e:
|
|
||||||
logger.warning(f"Could not remove {path}: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
def setup_admin_wipe_routes(session_manager):
|
|
||||||
"""The session_manager is passed in so we can also clear its
|
|
||||||
in-memory cache when wiping chats — without it the DB is empty
|
|
||||||
but the next /api/sessions returns stale entries."""
|
|
||||||
router = APIRouter(prefix="/api/admin")
|
|
||||||
|
|
||||||
@router.delete("/wipe/{kind}")
|
|
||||||
def wipe(kind: str, request: Request):
|
|
||||||
require_admin(request)
|
|
||||||
kind = (kind or "").strip().lower()
|
|
||||||
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
if kind == "chats":
|
|
||||||
count = db.query(DbSession).count()
|
|
||||||
db.query(DbChatMessage).delete()
|
|
||||||
db.query(DbSession).delete()
|
|
||||||
db.commit()
|
|
||||||
try:
|
|
||||||
session_manager.sessions.clear()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return {"status": "deleted", "kind": kind, "count": count}
|
|
||||||
|
|
||||||
if kind == "memory":
|
|
||||||
count = db.query(Memory).count()
|
|
||||||
db.query(Memory).delete()
|
|
||||||
db.commit()
|
|
||||||
_wipe_memory_files()
|
|
||||||
# Drop the vector store too so semantic search doesn't
|
|
||||||
# return ghosts. Lazy import — chromadb may not be
|
|
||||||
# initialised in every deployment.
|
|
||||||
try:
|
|
||||||
from src.memory_vector import get_memory_vector_store
|
|
||||||
mv = get_memory_vector_store()
|
|
||||||
if mv and hasattr(mv, "clear"):
|
|
||||||
mv.clear()
|
|
||||||
except Exception as e:
|
|
||||||
logger.info(f"Memory vector clear skipped: {e}")
|
|
||||||
return {"status": "deleted", "kind": kind, "count": count}
|
|
||||||
|
|
||||||
if kind == "skills":
|
|
||||||
# Skills live as SKILL.md files under data/skills/. Drop
|
|
||||||
# the entire directory; the SkillsManager re-creates the
|
|
||||||
# tree on next write.
|
|
||||||
skills_dir = SKILLS_DIR
|
|
||||||
count = 0
|
|
||||||
if os.path.isdir(skills_dir):
|
|
||||||
# Count SKILL.md files for the response — quick walk.
|
|
||||||
for _, _, files in os.walk(skills_dir):
|
|
||||||
count += sum(1 for f in files if f == "SKILL.md")
|
|
||||||
_rmtree_quiet(skills_dir)
|
|
||||||
# Legacy fallback file
|
|
||||||
legacy = SKILLS_FILE
|
|
||||||
if os.path.exists(legacy):
|
|
||||||
try:
|
|
||||||
os.remove(legacy)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
return {"status": "deleted", "kind": kind, "count": count}
|
|
||||||
|
|
||||||
if kind == "notes":
|
|
||||||
count = db.query(Note).count()
|
|
||||||
db.query(Note).delete()
|
|
||||||
db.commit()
|
|
||||||
return {"status": "deleted", "kind": kind, "count": count}
|
|
||||||
|
|
||||||
if kind == "tasks":
|
|
||||||
# TaskRun rows reference tasks via FK — clear them first.
|
|
||||||
db.query(TaskRun).delete()
|
|
||||||
count = db.query(ScheduledTask).count()
|
|
||||||
db.query(ScheduledTask).delete()
|
|
||||||
db.commit()
|
|
||||||
return {"status": "deleted", "kind": kind, "count": count}
|
|
||||||
|
|
||||||
if kind == "documents":
|
|
||||||
# DocumentVersion FKs Document — clear children first.
|
|
||||||
db.query(DocumentVersion).delete()
|
|
||||||
count = db.query(Document).count()
|
|
||||||
db.query(Document).delete()
|
|
||||||
db.commit()
|
|
||||||
return {"status": "deleted", "kind": kind, "count": count}
|
|
||||||
|
|
||||||
if kind == "gallery":
|
|
||||||
count = db.query(GalleryImage).count() + db.query(GalleryAlbum).count()
|
|
||||||
db.query(GalleryImage).delete()
|
|
||||||
db.query(GalleryAlbum).delete()
|
|
||||||
db.commit()
|
|
||||||
# Also drop the upload dir so disk doesn't keep orphans.
|
|
||||||
_rmtree_quiet(GALLERY_DIR)
|
|
||||||
_rmtree_quiet(GALLERY_UPLOADS_DIR)
|
|
||||||
return {"status": "deleted", "kind": kind, "count": count}
|
|
||||||
|
|
||||||
if kind == "calendar":
|
|
||||||
# Events FK calendars — clear children first, then both.
|
|
||||||
db.query(CalendarEvent).delete()
|
|
||||||
count = db.query(CalendarCal).count()
|
|
||||||
db.query(CalendarCal).delete()
|
|
||||||
db.commit()
|
|
||||||
return {"status": "deleted", "kind": kind, "count": count}
|
|
||||||
|
|
||||||
raise HTTPException(400, f"Unknown wipe kind: {kind!r}")
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
db.rollback()
|
|
||||||
logger.exception(f"Wipe {kind} failed")
|
|
||||||
raise HTTPException(500, f"Wipe {kind} failed: {e}")
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
return router
|
|
||||||
@@ -1,17 +1,176 @@
|
|||||||
"""Backward-compat shim — canonical location is routes/admin_wipe/admin_wipe_routes.py.
|
"""Admin Danger Zone — per-category wipes.
|
||||||
|
|
||||||
This module is replaced in ``sys.modules`` by the canonical module object so
|
Each endpoint is admin-only and truncates exactly one domain so the
|
||||||
that ``import routes.admin_wipe_routes``, ``from routes.admin_wipe_routes
|
user can selectively reset memory / skills / notes / etc. without
|
||||||
import X``, ``importlib.import_module("routes.admin_wipe_routes")``, and the
|
nuking everything. The catch-all `chats` endpoint mirrors the
|
||||||
``import ... as admin_wipe_routes`` + ``monkeypatch.setattr(admin_wipe_routes,
|
existing /api/sessions/all so the Danger Zone speaks one URL pattern.
|
||||||
"SessionLocal", ...)`` / ``"require_admin"`` pattern used by
|
|
||||||
test_admin_wipe_gallery.py all operate on the *same* object the application
|
URL shape: DELETE /api/admin/wipe/{kind}
|
||||||
actually uses. Keeps existing import paths working after slice 2h
|
Kinds: chats, memory, skills, notes, tasks, documents, gallery, calendar.
|
||||||
(#4082/#4071).
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sys as _sys
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
|
|
||||||
from routes.admin_wipe import admin_wipe_routes as _canonical # noqa: F401
|
from core.middleware import require_admin
|
||||||
|
from core.database import (
|
||||||
|
SessionLocal,
|
||||||
|
Session as DbSession,
|
||||||
|
ChatMessage as DbChatMessage,
|
||||||
|
Memory,
|
||||||
|
Note,
|
||||||
|
ScheduledTask,
|
||||||
|
TaskRun,
|
||||||
|
Document,
|
||||||
|
DocumentVersion,
|
||||||
|
GalleryImage,
|
||||||
|
GalleryAlbum,
|
||||||
|
CalendarEvent,
|
||||||
|
CalendarCal,
|
||||||
|
)
|
||||||
|
from src.constants import DATA_DIR
|
||||||
|
|
||||||
_sys.modules[__name__] = _canonical
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _wipe_memory_files():
|
||||||
|
"""Blank memory.json + drop the per-owner tidy-state sidecar so the
|
||||||
|
next audit doesn't try to diff against gone memories."""
|
||||||
|
for name in ("memory.json", "memory_tidy_state.json"):
|
||||||
|
p = os.path.join(DATA_DIR, name)
|
||||||
|
if not os.path.exists(p):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
if name == "memory.json":
|
||||||
|
with open(p, "w", encoding="utf-8") as f:
|
||||||
|
json.dump([], f)
|
||||||
|
else:
|
||||||
|
os.remove(p)
|
||||||
|
except OSError as e:
|
||||||
|
logger.warning(f"Could not reset {name}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def _rmtree_quiet(path: str):
|
||||||
|
"""rmtree that doesn't crash if the path doesn't exist."""
|
||||||
|
if os.path.isdir(path):
|
||||||
|
try:
|
||||||
|
shutil.rmtree(path)
|
||||||
|
except OSError as e:
|
||||||
|
logger.warning(f"Could not remove {path}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def setup_admin_wipe_routes(session_manager):
|
||||||
|
"""The session_manager is passed in so we can also clear its
|
||||||
|
in-memory cache when wiping chats — without it the DB is empty
|
||||||
|
but the next /api/sessions returns stale entries."""
|
||||||
|
router = APIRouter(prefix="/api/admin")
|
||||||
|
|
||||||
|
@router.delete("/wipe/{kind}")
|
||||||
|
def wipe(kind: str, request: Request):
|
||||||
|
require_admin(request)
|
||||||
|
kind = (kind or "").strip().lower()
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
if kind == "chats":
|
||||||
|
count = db.query(DbSession).count()
|
||||||
|
db.query(DbChatMessage).delete()
|
||||||
|
db.query(DbSession).delete()
|
||||||
|
db.commit()
|
||||||
|
try:
|
||||||
|
session_manager.sessions.clear()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return {"status": "deleted", "kind": kind, "count": count}
|
||||||
|
|
||||||
|
if kind == "memory":
|
||||||
|
count = db.query(Memory).count()
|
||||||
|
db.query(Memory).delete()
|
||||||
|
db.commit()
|
||||||
|
_wipe_memory_files()
|
||||||
|
# Drop the vector store too so semantic search doesn't
|
||||||
|
# return ghosts. Lazy import — chromadb may not be
|
||||||
|
# initialised in every deployment.
|
||||||
|
try:
|
||||||
|
from src.memory_vector import get_memory_vector_store
|
||||||
|
mv = get_memory_vector_store()
|
||||||
|
if mv and hasattr(mv, "clear"):
|
||||||
|
mv.clear()
|
||||||
|
except Exception as e:
|
||||||
|
logger.info(f"Memory vector clear skipped: {e}")
|
||||||
|
return {"status": "deleted", "kind": kind, "count": count}
|
||||||
|
|
||||||
|
if kind == "skills":
|
||||||
|
# Skills live as SKILL.md files under data/skills/. Drop
|
||||||
|
# the entire directory; the SkillsManager re-creates the
|
||||||
|
# tree on next write.
|
||||||
|
skills_dir = os.path.join(DATA_DIR, "skills")
|
||||||
|
count = 0
|
||||||
|
if os.path.isdir(skills_dir):
|
||||||
|
# Count SKILL.md files for the response — quick walk.
|
||||||
|
for _, _, files in os.walk(skills_dir):
|
||||||
|
count += sum(1 for f in files if f == "SKILL.md")
|
||||||
|
_rmtree_quiet(skills_dir)
|
||||||
|
# Legacy fallback file
|
||||||
|
legacy = os.path.join(DATA_DIR, "skills.json")
|
||||||
|
if os.path.exists(legacy):
|
||||||
|
try:
|
||||||
|
os.remove(legacy)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return {"status": "deleted", "kind": kind, "count": count}
|
||||||
|
|
||||||
|
if kind == "notes":
|
||||||
|
count = db.query(Note).count()
|
||||||
|
db.query(Note).delete()
|
||||||
|
db.commit()
|
||||||
|
return {"status": "deleted", "kind": kind, "count": count}
|
||||||
|
|
||||||
|
if kind == "tasks":
|
||||||
|
# TaskRun rows reference tasks via FK — clear them first.
|
||||||
|
db.query(TaskRun).delete()
|
||||||
|
count = db.query(ScheduledTask).count()
|
||||||
|
db.query(ScheduledTask).delete()
|
||||||
|
db.commit()
|
||||||
|
return {"status": "deleted", "kind": kind, "count": count}
|
||||||
|
|
||||||
|
if kind == "documents":
|
||||||
|
# DocumentVersion FKs Document — clear children first.
|
||||||
|
db.query(DocumentVersion).delete()
|
||||||
|
count = db.query(Document).count()
|
||||||
|
db.query(Document).delete()
|
||||||
|
db.commit()
|
||||||
|
return {"status": "deleted", "kind": kind, "count": count}
|
||||||
|
|
||||||
|
if kind == "gallery":
|
||||||
|
count = db.query(GalleryImage).count() + db.query(GalleryAlbum).count()
|
||||||
|
db.query(GalleryImage).delete()
|
||||||
|
db.query(GalleryAlbum).delete()
|
||||||
|
db.commit()
|
||||||
|
# Also drop the upload dir so disk doesn't keep orphans.
|
||||||
|
_rmtree_quiet(os.path.join(DATA_DIR, "gallery"))
|
||||||
|
_rmtree_quiet(os.path.join(DATA_DIR, "gallery_uploads"))
|
||||||
|
return {"status": "deleted", "kind": kind, "count": count}
|
||||||
|
|
||||||
|
if kind == "calendar":
|
||||||
|
# Events FK calendars — clear children first, then both.
|
||||||
|
db.query(CalendarEvent).delete()
|
||||||
|
count = db.query(CalendarCal).count()
|
||||||
|
db.query(CalendarCal).delete()
|
||||||
|
db.commit()
|
||||||
|
return {"status": "deleted", "kind": kind, "count": count}
|
||||||
|
|
||||||
|
raise HTTPException(400, f"Unknown wipe kind: {kind!r}")
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.exception(f"Wipe {kind} failed")
|
||||||
|
raise HTTPException(500, f"Wipe {kind} failed: {e}")
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
return router
|
||||||
|
|||||||
@@ -25,13 +25,10 @@ ALLOWED_SCOPES = {
|
|||||||
"calendar:write",
|
"calendar:write",
|
||||||
"memory:read",
|
"memory:read",
|
||||||
"memory:write",
|
"memory:write",
|
||||||
"cookbook:read",
|
|
||||||
"cookbook:launch",
|
|
||||||
}
|
}
|
||||||
TOKEN_PROFILES = {
|
TOKEN_PROFILES = {
|
||||||
"chat": ["chat"],
|
"chat": ["chat"],
|
||||||
"codex_todos": ["todos:read", "todos:write"],
|
"codex_todos": ["todos:read", "todos:write"],
|
||||||
"codex_documents": ["documents:read", "documents:write"],
|
|
||||||
"codex_email_drafts": ["email:read", "email:draft", "documents:read", "documents:write"],
|
"codex_email_drafts": ["email:read", "email:draft", "documents:read", "documents:write"],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,7 +65,6 @@ def _normalize_scopes(scopes: str | list[str] | None = None, profile: str | None
|
|||||||
ensure_before("calendar:write", "calendar:read")
|
ensure_before("calendar:write", "calendar:read")
|
||||||
ensure_before("memory:write", "memory:read")
|
ensure_before("memory:write", "memory:read")
|
||||||
ensure_before("email:draft", "email:read")
|
ensure_before("email:draft", "email:read")
|
||||||
ensure_before("cookbook:launch", "cookbook:read")
|
|
||||||
|
|
||||||
return normalized or [DEFAULT_SCOPES]
|
return normalized or [DEFAULT_SCOPES]
|
||||||
|
|
||||||
@@ -155,39 +151,26 @@ def setup_api_token_routes() -> APIRouter:
|
|||||||
@router.patch("/tokens/{token_id}")
|
@router.patch("/tokens/{token_id}")
|
||||||
async def update_token(request: Request, token_id: str):
|
async def update_token(request: Request, token_id: str):
|
||||||
require_admin(request)
|
require_admin(request)
|
||||||
current_user = get_current_user(request)
|
|
||||||
try:
|
try:
|
||||||
payload = await request.json()
|
payload = await request.json()
|
||||||
except Exception:
|
except Exception:
|
||||||
payload = {}
|
payload = {}
|
||||||
if not isinstance(payload, dict):
|
scope_list = _normalize_scopes(payload.get("scopes"))
|
||||||
payload = {}
|
scopes_value = ",".join(scope_list)
|
||||||
with get_db_session() as db:
|
with get_db_session() as db:
|
||||||
token = db.query(ApiToken).filter(ApiToken.id == token_id).first()
|
token = db.query(ApiToken).filter(ApiToken.id == token_id).first()
|
||||||
if not token:
|
if not token:
|
||||||
raise HTTPException(404, "Token not found")
|
raise HTTPException(404, "Token not found")
|
||||||
if current_user and token.owner != current_user:
|
|
||||||
raise HTTPException(403, "Not your token")
|
|
||||||
if isinstance(payload.get("name"), str) and payload["name"].strip():
|
if isinstance(payload.get("name"), str) and payload["name"].strip():
|
||||||
token.name = payload["name"].strip()[:MAX_NAME_LEN]
|
token.name = payload["name"].strip()[:MAX_NAME_LEN]
|
||||||
# Only touch scopes when the caller actually sent them. A partial
|
token.scopes = scopes_value
|
||||||
# update such as a rename ({"name": ...} with no "scopes" key) must
|
|
||||||
# not silently reset the token to the default scope — that dropped
|
|
||||||
# every previously granted scope.
|
|
||||||
if "scopes" in payload:
|
|
||||||
token.scopes = ",".join(_normalize_scopes(payload.get("scopes")))
|
|
||||||
db.add(token)
|
db.add(token)
|
||||||
current_scopes = [
|
|
||||||
s.strip()
|
|
||||||
for s in (getattr(token, "scopes", "") or DEFAULT_SCOPES).split(",")
|
|
||||||
if s.strip()
|
|
||||||
]
|
|
||||||
response = {
|
response = {
|
||||||
"id": token_id,
|
"id": token_id,
|
||||||
"name": getattr(token, "name", ""),
|
"name": getattr(token, "name", ""),
|
||||||
"owner": getattr(token, "owner", None),
|
"owner": getattr(token, "owner", None),
|
||||||
"token_prefix": getattr(token, "token_prefix", ""),
|
"token_prefix": getattr(token, "token_prefix", ""),
|
||||||
"scopes": current_scopes,
|
"scopes": scope_list,
|
||||||
}
|
}
|
||||||
_invalidate_cache(request)
|
_invalidate_cache(request)
|
||||||
return response
|
return response
|
||||||
@@ -195,14 +178,10 @@ def setup_api_token_routes() -> APIRouter:
|
|||||||
@router.delete("/tokens/{token_id}")
|
@router.delete("/tokens/{token_id}")
|
||||||
def delete_token(request: Request, token_id: str):
|
def delete_token(request: Request, token_id: str):
|
||||||
require_admin(request)
|
require_admin(request)
|
||||||
current_user = get_current_user(request)
|
|
||||||
with get_db_session() as db:
|
with get_db_session() as db:
|
||||||
token = db.query(ApiToken).filter(ApiToken.id == token_id).first()
|
deleted = db.query(ApiToken).filter(ApiToken.id == token_id).delete()
|
||||||
if not token:
|
if not deleted:
|
||||||
raise HTTPException(404, "Token not found")
|
raise HTTPException(404, "Token not found")
|
||||||
if current_user and token.owner != current_user:
|
|
||||||
raise HTTPException(403, "Not your token")
|
|
||||||
db.delete(token)
|
|
||||||
_invalidate_cache(request)
|
_invalidate_cache(request)
|
||||||
return {"status": "deleted"}
|
return {"status": "deleted"}
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ from pydantic import BaseModel
|
|||||||
|
|
||||||
from core.database import SessionLocal, CrewMember, ScheduledTask
|
from core.database import SessionLocal, CrewMember, ScheduledTask
|
||||||
from src.auth_helpers import get_current_user
|
from src.auth_helpers import get_current_user
|
||||||
from core.auth import RESERVED_USERNAMES
|
|
||||||
from src.task_scheduler import compute_next_run
|
from src.task_scheduler import compute_next_run
|
||||||
|
|
||||||
|
|
||||||
@@ -90,11 +89,11 @@ def setup_assistant_routes(task_scheduler) -> APIRouter:
|
|||||||
# check-in tasks seeded. Hitting any /assistant route under one of these
|
# check-in tasks seeded. Hitting any /assistant route under one of these
|
||||||
# used to seed a full CrewMember + Morning/Midday/Evening tasks under that
|
# used to seed a full CrewMember + Morning/Midday/Evening tasks under that
|
||||||
# owner, which then double-fired alongside the real user's check-ins.
|
# owner, which then double-fired alongside the real user's check-ins.
|
||||||
# RESERVED_USERNAMES covers the same set; the `not owner` guard handles "".
|
_SYNTHETIC_OWNERS = frozenset({"internal-tool", "api", "demo", "system", ""})
|
||||||
|
|
||||||
async def _get_or_create(owner: str) -> CrewMember:
|
async def _get_or_create(owner: str) -> CrewMember:
|
||||||
"""Return the per-owner assistant CrewMember, creating it on demand."""
|
"""Return the per-owner assistant CrewMember, creating it on demand."""
|
||||||
if not owner or owner in RESERVED_USERNAMES:
|
if not owner or owner in _SYNTHETIC_OWNERS:
|
||||||
raise HTTPException(status_code=400, detail=f"Cannot seed assistant for {owner!r}")
|
raise HTTPException(status_code=400, detail=f"Cannot seed assistant for {owner!r}")
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -7,13 +7,7 @@ import asyncio
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
|
||||||
import json
|
from core.auth import AuthManager
|
||||||
import re
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from core.atomic_io import atomic_write_json, atomic_write_text
|
|
||||||
from core.auth import AuthManager, RESERVED_USERNAMES, SetAdminResult, TOKEN_TTL
|
|
||||||
from src.constants import DEEP_RESEARCH_DIR, MEMORY_FILE, PASSWORD_MIN_LENGTH, SKILLS_DIR
|
|
||||||
from src.rate_limiter import RateLimiter
|
from src.rate_limiter import RateLimiter
|
||||||
from src.settings_scrub import scrub_settings
|
from src.settings_scrub import scrub_settings
|
||||||
from src.settings import (
|
from src.settings import (
|
||||||
@@ -73,11 +67,6 @@ class DeleteUserRequest(BaseModel):
|
|||||||
class RenameUserRequest(BaseModel):
|
class RenameUserRequest(BaseModel):
|
||||||
username: str
|
username: str
|
||||||
|
|
||||||
|
|
||||||
class SetAdminRequest(BaseModel):
|
|
||||||
is_admin: bool
|
|
||||||
|
|
||||||
|
|
||||||
class SetOpenRegistrationRequest(BaseModel):
|
class SetOpenRegistrationRequest(BaseModel):
|
||||||
enabled: bool
|
enabled: bool
|
||||||
|
|
||||||
@@ -102,12 +91,8 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
|||||||
raise HTTPException(429, "Too many requests — try again later")
|
raise HTTPException(429, "Too many requests — try again later")
|
||||||
if auth_manager.is_configured:
|
if auth_manager.is_configured:
|
||||||
raise HTTPException(400, "Already configured")
|
raise HTTPException(400, "Already configured")
|
||||||
if len(body.password) < PASSWORD_MIN_LENGTH:
|
if len(body.password) < 8:
|
||||||
raise HTTPException(400, f"Password must be at least {PASSWORD_MIN_LENGTH} characters")
|
raise HTTPException(400, "Password must be at least 8 characters")
|
||||||
if len(body.username.strip()) < 1:
|
|
||||||
raise HTTPException(400, "Username is required")
|
|
||||||
if body.username.lower() in RESERVED_USERNAMES:
|
|
||||||
raise HTTPException(403, "Username is reserved")
|
|
||||||
ok = await asyncio.to_thread(auth_manager.setup, body.username, body.password)
|
ok = await asyncio.to_thread(auth_manager.setup, body.username, body.password)
|
||||||
if not ok:
|
if not ok:
|
||||||
raise HTTPException(500, "Setup failed")
|
raise HTTPException(500, "Setup failed")
|
||||||
@@ -122,12 +107,10 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
|||||||
raise HTTPException(400, "Run setup first")
|
raise HTTPException(400, "Run setup first")
|
||||||
if not auth_manager.signup_enabled:
|
if not auth_manager.signup_enabled:
|
||||||
raise HTTPException(403, "Registration is disabled. Ask an admin for an account.")
|
raise HTTPException(403, "Registration is disabled. Ask an admin for an account.")
|
||||||
if len(body.password) < PASSWORD_MIN_LENGTH:
|
if len(body.password) < 8:
|
||||||
raise HTTPException(400, f"Password must be at least {PASSWORD_MIN_LENGTH} characters")
|
raise HTTPException(400, "Password must be at least 8 characters")
|
||||||
if len(body.username.strip()) < 1:
|
if len(body.username.strip()) < 1:
|
||||||
raise HTTPException(400, "Username is required")
|
raise HTTPException(400, "Username is required")
|
||||||
if body.username.lower() in RESERVED_USERNAMES:
|
|
||||||
raise HTTPException(403, "Username is reserved")
|
|
||||||
ok = await asyncio.to_thread(auth_manager.create_user, body.username, body.password, is_admin=False)
|
ok = await asyncio.to_thread(auth_manager.create_user, body.username, body.password, is_admin=False)
|
||||||
if not ok:
|
if not ok:
|
||||||
raise HTTPException(409, "Username already taken")
|
raise HTTPException(409, "Username already taken")
|
||||||
@@ -148,8 +131,8 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
|||||||
return {"ok": False, "requires_totp": True, "username": username}
|
return {"ok": False, "requires_totp": True, "username": username}
|
||||||
if not auth_manager.totp_verify(username, body.totp_code):
|
if not auth_manager.totp_verify(username, body.totp_code):
|
||||||
raise HTTPException(401, "Invalid 2FA code")
|
raise HTTPException(401, "Invalid 2FA code")
|
||||||
# All checks passed — create session (password already verified above)
|
# All checks passed — create session
|
||||||
token = await asyncio.to_thread(auth_manager.create_session_trusted, username)
|
token = await asyncio.to_thread(auth_manager.create_session, username, body.password)
|
||||||
if not token:
|
if not token:
|
||||||
raise HTTPException(401, "Invalid credentials")
|
raise HTTPException(401, "Invalid credentials")
|
||||||
cookie_kwargs = dict(
|
cookie_kwargs = dict(
|
||||||
@@ -161,7 +144,7 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
|||||||
path="/",
|
path="/",
|
||||||
)
|
)
|
||||||
if body.remember:
|
if body.remember:
|
||||||
cookie_kwargs["max_age"] = TOKEN_TTL
|
cookie_kwargs["max_age"] = 60 * 60 * 24 * 7 # 7 days
|
||||||
response.set_cookie(**cookie_kwargs)
|
response.set_cookie(**cookie_kwargs)
|
||||||
return {"ok": True, "username": username}
|
return {"ok": True, "username": username}
|
||||||
|
|
||||||
@@ -190,18 +173,13 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
|||||||
pass
|
pass
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@router.get("/policy")
|
|
||||||
async def auth_policy():
|
|
||||||
"""Return public auth policy constants for the frontend."""
|
|
||||||
return auth_manager.policy()
|
|
||||||
|
|
||||||
@router.post("/change-password")
|
@router.post("/change-password")
|
||||||
async def change_password(body: ChangePasswordRequest, request: Request):
|
async def change_password(body: ChangePasswordRequest, request: Request):
|
||||||
user = _get_current_user(request)
|
user = _get_current_user(request)
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(401, "Not authenticated")
|
raise HTTPException(401, "Not authenticated")
|
||||||
if len(body.new_password) < PASSWORD_MIN_LENGTH:
|
if len(body.new_password) < 8:
|
||||||
raise HTTPException(400, f"Password must be at least {PASSWORD_MIN_LENGTH} characters")
|
raise HTTPException(400, "Password must be at least 8 characters")
|
||||||
current_token = request.cookies.get(SESSION_COOKIE)
|
current_token = request.cookies.get(SESSION_COOKIE)
|
||||||
ok = await asyncio.to_thread(auth_manager.change_password, user, body.current_password, body.new_password)
|
ok = await asyncio.to_thread(auth_manager.change_password, user, body.current_password, body.new_password)
|
||||||
if not ok:
|
if not ok:
|
||||||
@@ -281,12 +259,8 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
|||||||
user = _get_current_user(request)
|
user = _get_current_user(request)
|
||||||
if not user or not auth_manager.is_admin(user):
|
if not user or not auth_manager.is_admin(user):
|
||||||
raise HTTPException(403, "Admin only")
|
raise HTTPException(403, "Admin only")
|
||||||
if len(body.password) < PASSWORD_MIN_LENGTH:
|
if len(body.password) < 8:
|
||||||
raise HTTPException(400, f"Password must be at least {PASSWORD_MIN_LENGTH} characters")
|
raise HTTPException(400, "Password must be at least 8 characters")
|
||||||
if len(body.username.strip()) < 1:
|
|
||||||
raise HTTPException(400, "Username is required")
|
|
||||||
if body.username.lower() in RESERVED_USERNAMES:
|
|
||||||
raise HTTPException(403, "Username is reserved")
|
|
||||||
ok = auth_manager.create_user(body.username, body.password, body.is_admin)
|
ok = auth_manager.create_user(body.username, body.password, body.is_admin)
|
||||||
if not ok:
|
if not ok:
|
||||||
raise HTTPException(409, "Username already taken")
|
raise HTTPException(409, "Username already taken")
|
||||||
@@ -319,30 +293,9 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
|||||||
if new_username in auth_manager.users:
|
if new_username in auth_manager.users:
|
||||||
raise HTTPException(409, "Username already taken")
|
raise HTTPException(409, "Username already taken")
|
||||||
|
|
||||||
# Gate on auth first. Every mutation below is contingent on this
|
|
||||||
# succeeding — doing it last meant a rejected rename (e.g. reserved
|
|
||||||
# username) left file-backed owner fields already rewritten with no
|
|
||||||
# way to roll them back.
|
|
||||||
ok = auth_manager.rename_user(old_username, new_username, user)
|
|
||||||
if not ok:
|
|
||||||
raise HTTPException(400, "Cannot rename user")
|
|
||||||
|
|
||||||
def _rollback_auth_rename() -> bool:
|
|
||||||
# On self-rename the admin session has already moved to the new
|
|
||||||
# username, so the rollback must authenticate as the new user.
|
|
||||||
rollback_user = new_username if user == old_username else user
|
|
||||||
try:
|
|
||||||
return bool(auth_manager.rename_user(new_username, old_username, rollback_user))
|
|
||||||
except Exception as rollback_err:
|
|
||||||
logger.error(
|
|
||||||
"Failed to roll back auth rename %s -> %s after owner migration failure: %s",
|
|
||||||
new_username, old_username, rollback_err,
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Usernames are ownership keys for user data. Rename the common
|
# Usernames are ownership keys for user data. Rename the common
|
||||||
# owner-scoped DB rows so the account keeps access to its sessions,
|
# owner-scoped DB rows before changing auth so the account keeps
|
||||||
# docs, email accounts, tasks, etc.
|
# access to its sessions, docs, email accounts, tasks, etc.
|
||||||
try:
|
try:
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
from core.database import Base, SessionLocal
|
from core.database import Base, SessionLocal
|
||||||
@@ -365,11 +318,6 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
|||||||
db.close()
|
db.close()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Failed to rename owner references %s -> %s: %s", old_username, new_username, e)
|
logger.error("Failed to rename owner references %s -> %s: %s", old_username, new_username, e)
|
||||||
if not _rollback_auth_rename():
|
|
||||||
logger.error(
|
|
||||||
"Auth rename %s -> %s could not be rolled back after owner migration failure",
|
|
||||||
old_username, new_username,
|
|
||||||
)
|
|
||||||
raise HTTPException(500, "Failed to rename user data")
|
raise HTTPException(500, "Failed to rename user data")
|
||||||
|
|
||||||
# Per-user prefs are JSON-backed, not SQL-backed.
|
# Per-user prefs are JSON-backed, not SQL-backed.
|
||||||
@@ -389,168 +337,11 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Failed to rename user prefs %s -> %s: %s", old_username, new_username, e)
|
logger.warning("Failed to rename user prefs %s -> %s: %s", old_username, new_username, e)
|
||||||
|
|
||||||
# In-flight deep-research tasks live in the process-local
|
ok = auth_manager.rename_user(old_username, new_username, user)
|
||||||
# ResearchHandler registry. They are not covered by the persisted JSON
|
if not ok:
|
||||||
# migration above, but the research routes filter and cancel by this
|
raise HTTPException(400, "Cannot rename user")
|
||||||
# owner field while the job is running. Do this before sweeping
|
|
||||||
# completed JSON files so a job that finishes during the rename saves
|
|
||||||
# with the new owner or is caught by the disk sweep below.
|
|
||||||
try:
|
|
||||||
rh = getattr(request.app.state, "research_handler", None)
|
|
||||||
rename_owner = getattr(rh, "rename_owner", None)
|
|
||||||
if callable(rename_owner):
|
|
||||||
rename_owner(old_username, new_username)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("Failed to rename active research tasks %s -> %s: %s", old_username, new_username, e)
|
|
||||||
|
|
||||||
# deep_research: each completed report is a standalone JSON file with
|
|
||||||
# an `owner` field. research_routes filters by d.get("owner") == user,
|
|
||||||
# so a stale owner makes every report invisible to the renamed user.
|
|
||||||
try:
|
|
||||||
dr_dir = Path(DEEP_RESEARCH_DIR)
|
|
||||||
if dr_dir.is_dir():
|
|
||||||
for p in dr_dir.glob("*.json"):
|
|
||||||
try:
|
|
||||||
d = json.loads(p.read_text(encoding="utf-8"))
|
|
||||||
if str(d.get("owner", "")).strip().lower() == old_username:
|
|
||||||
d["owner"] = new_username
|
|
||||||
atomic_write_json(str(p), d)
|
|
||||||
except Exception as err:
|
|
||||||
logger.warning("Failed to update research owner in %s: %s", p.name, err)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("Failed to rename research owner references %s -> %s: %s", old_username, new_username, e)
|
|
||||||
|
|
||||||
# memory.json: a flat JSON array where each entry carries an `owner`
|
|
||||||
# field. memory_manager.load(owner=user) filters on it, so stale
|
|
||||||
# entries disappear from the memory panel.
|
|
||||||
try:
|
|
||||||
if os.path.isfile(MEMORY_FILE):
|
|
||||||
with open(MEMORY_FILE, encoding="utf-8") as fh:
|
|
||||||
entries = json.loads(fh.read())
|
|
||||||
if isinstance(entries, list):
|
|
||||||
changed = False
|
|
||||||
for entry in entries:
|
|
||||||
if isinstance(entry, dict) and str(entry.get("owner", "")).strip().lower() == old_username:
|
|
||||||
entry["owner"] = new_username
|
|
||||||
changed = True
|
|
||||||
if changed:
|
|
||||||
atomic_write_json(MEMORY_FILE, entries)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("Failed to rename memory.json owner references %s -> %s: %s", old_username, new_username, e)
|
|
||||||
|
|
||||||
# uploads.json: upload rows use owner metadata for access checks and
|
|
||||||
# owner-prefixed index keys for dedupe. Rename both so attachments keep
|
|
||||||
# resolving after the account username changes.
|
|
||||||
try:
|
|
||||||
upload_handler = getattr(request.app.state, "upload_handler", None)
|
|
||||||
rename_owner = getattr(upload_handler, "rename_owner", None)
|
|
||||||
if callable(rename_owner):
|
|
||||||
rename_owner(old_username, new_username)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("Failed to rename upload owner references %s -> %s: %s", old_username, new_username, e)
|
|
||||||
|
|
||||||
# direct personal RAG uploads live in per-owner directories and the
|
|
||||||
# vector metadata also carries the username used for owner-filtered
|
|
||||||
# search. Keep both in sync with the auth rename.
|
|
||||||
try:
|
|
||||||
from routes.personal_routes import rename_personal_upload_owner
|
|
||||||
personal_docs_manager = getattr(request.app.state, "personal_docs_manager", None)
|
|
||||||
if personal_docs_manager is not None:
|
|
||||||
rag_manager = getattr(personal_docs_manager, "rag_manager", None)
|
|
||||||
rename_personal_upload_owner(
|
|
||||||
old_username,
|
|
||||||
new_username,
|
|
||||||
personal_docs_manager=personal_docs_manager,
|
|
||||||
rag_manager=rag_manager,
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("Failed to rename personal RAG upload owner references %s -> %s: %s", old_username, new_username, e)
|
|
||||||
|
|
||||||
# skills: SKILL.md frontmatter carries owner: <username>; the usage
|
|
||||||
# sidecar (_usage.json) keys entries as owner::skill-name. Both must
|
|
||||||
# be updated or the renamed user's Skills panel goes empty.
|
|
||||||
try:
|
|
||||||
skills_root = Path(SKILLS_DIR)
|
|
||||||
if skills_root.is_dir():
|
|
||||||
_owner_re = re.compile(
|
|
||||||
r'(?m)^(owner:\s*)' + re.escape(old_username) + r'\s*$',
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
for p in skills_root.rglob("SKILL.md"):
|
|
||||||
try:
|
|
||||||
text = p.read_text(encoding="utf-8")
|
|
||||||
new_text = _owner_re.sub(r'\g<1>' + new_username, text)
|
|
||||||
if new_text != text:
|
|
||||||
atomic_write_text(str(p), new_text)
|
|
||||||
except Exception as err:
|
|
||||||
logger.warning("Failed to update skill owner in %s: %s", p, err)
|
|
||||||
usage_path = skills_root / "_usage.json"
|
|
||||||
if usage_path.is_file():
|
|
||||||
try:
|
|
||||||
usage = json.loads(usage_path.read_text(encoding="utf-8"))
|
|
||||||
if isinstance(usage, dict):
|
|
||||||
new_usage = {}
|
|
||||||
changed = False
|
|
||||||
for k, v in usage.items():
|
|
||||||
owner_part, sep, skill_part = k.partition("::")
|
|
||||||
if sep and owner_part.lower() == old_username:
|
|
||||||
new_usage[new_username + "::" + skill_part] = v
|
|
||||||
changed = True
|
|
||||||
else:
|
|
||||||
new_usage[k] = v
|
|
||||||
if changed:
|
|
||||||
atomic_write_json(str(usage_path), new_usage)
|
|
||||||
except Exception as err:
|
|
||||||
logger.warning("Failed to update skills usage keys %s -> %s: %s", old_username, new_username, err)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("Failed to rename skills owner references %s -> %s: %s", old_username, new_username, e)
|
|
||||||
|
|
||||||
# The in-memory session cache (session_manager.sessions) stores each
|
|
||||||
# session's owner at load time. Without this patch the renamed user's
|
|
||||||
# sessions are invisible on the next /api/sessions call because
|
|
||||||
# get_sessions_for_user does an exact `s.owner == username` comparison
|
|
||||||
# against stale in-memory values.
|
|
||||||
sm = getattr(request.app.state, "session_manager", None)
|
|
||||||
if sm is not None:
|
|
||||||
for sess in list(getattr(sm, "sessions", {}).values()):
|
|
||||||
if str(getattr(sess, "owner", None) or "").strip().lower() == old_username:
|
|
||||||
sess.owner = new_username
|
|
||||||
|
|
||||||
# The owner-rename loop above updated ApiToken.owner in the DB, but the
|
|
||||||
# bearer-token cache still maps each token to the OLD owner. Without
|
|
||||||
# refreshing it, the renamed user's API tokens resolve to the old (now
|
|
||||||
# non-existent) owner and stop reaching their data until the cache next
|
|
||||||
# goes dirty. Invalidate it now, like the token CRUD routes do.
|
|
||||||
invalidator = getattr(request.app.state, "invalidate_token_cache", None)
|
|
||||||
if callable(invalidator):
|
|
||||||
invalidator()
|
|
||||||
return {"ok": True, "username": new_username, "renamed_self": old_username == user}
|
return {"ok": True, "username": new_username, "renamed_self": old_username == user}
|
||||||
|
|
||||||
@router.put("/users/{username}/admin")
|
|
||||||
async def set_user_admin(username: str, body: SetAdminRequest, request: Request):
|
|
||||||
"""Promote/demote a user to/from admin. Admin only.
|
|
||||||
|
|
||||||
The last remaining admin can't be demoted (no lockout). Self-demotion
|
|
||||||
is allowed while another admin exists; the `self` flag tells the UI to
|
|
||||||
reload the acting user into the normal-user view.
|
|
||||||
"""
|
|
||||||
user = _get_current_user(request)
|
|
||||||
if not user or not auth_manager.is_admin(user):
|
|
||||||
raise HTTPException(403, "Admin only")
|
|
||||||
result = auth_manager.set_admin(username, body.is_admin, user)
|
|
||||||
if result is SetAdminResult.USER_NOT_FOUND:
|
|
||||||
raise HTTPException(404, "User not found")
|
|
||||||
if result is SetAdminResult.NOT_AUTHORIZED:
|
|
||||||
raise HTTPException(403, "Admin only")
|
|
||||||
if result is SetAdminResult.LAST_ADMIN:
|
|
||||||
raise HTTPException(400, "Cannot demote the last admin")
|
|
||||||
target = (username or "").strip().lower()
|
|
||||||
return {
|
|
||||||
"ok": True,
|
|
||||||
"is_admin": body.is_admin,
|
|
||||||
"self": target == (user or "").strip().lower(),
|
|
||||||
}
|
|
||||||
|
|
||||||
@router.post("/signup-toggle", deprecated=True)
|
@router.post("/signup-toggle", deprecated=True)
|
||||||
async def toggle_signup(request: Request):
|
async def toggle_signup(request: Request):
|
||||||
"""
|
"""
|
||||||
@@ -581,23 +372,7 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
|||||||
user = _get_current_user(request)
|
user = _get_current_user(request)
|
||||||
if not user or not auth_manager.is_admin(user):
|
if not user or not auth_manager.is_admin(user):
|
||||||
raise HTTPException(403, "Admin only")
|
raise HTTPException(403, "Admin only")
|
||||||
|
ok = auth_manager.delete_user(body.username, user)
|
||||||
def _invalidate_api_token_cache():
|
|
||||||
try:
|
|
||||||
invalidator = getattr(request.app.state, "invalidate_token_cache", None)
|
|
||||||
if invalidator:
|
|
||||||
invalidator()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
try:
|
|
||||||
ok = auth_manager.delete_user(body.username, user)
|
|
||||||
except Exception:
|
|
||||||
# delete_user can touch ApiToken rows before a later auth-store write
|
|
||||||
# fails. Dirty the bearer cache anyway so a partial token purge does
|
|
||||||
# not leave already-cached tokens authenticating until restart.
|
|
||||||
_invalidate_api_token_cache()
|
|
||||||
raise
|
|
||||||
if not ok:
|
if not ok:
|
||||||
raise HTTPException(400, "Cannot delete user")
|
raise HTTPException(400, "Cannot delete user")
|
||||||
# delete_user removes the user's ApiToken rows, but the bearer-auth
|
# delete_user removes the user's ApiToken rows, but the bearer-auth
|
||||||
@@ -605,7 +380,12 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
|||||||
# rebuilds when flagged dirty. Without this, a deleted user's already
|
# rebuilds when flagged dirty. Without this, a deleted user's already
|
||||||
# cached token keeps authenticating until some other token op or a
|
# cached token keeps authenticating until some other token op or a
|
||||||
# restart clears the cache. Mirror what the token routes do.
|
# restart clears the cache. Mirror what the token routes do.
|
||||||
_invalidate_api_token_cache()
|
try:
|
||||||
|
invalidator = getattr(request.app.state, "invalidate_token_cache", None)
|
||||||
|
if invalidator:
|
||||||
|
invalidator()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
# ---- Feature visibility (admin-managed) ----
|
# ---- Feature visibility (admin-managed) ----
|
||||||
@@ -650,24 +430,9 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
|||||||
raise HTTPException(403, "Admin only")
|
raise HTTPException(403, "Admin only")
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
current = _load_settings()
|
current = _load_settings()
|
||||||
# Per-key validation for numeric settings: coerce to int and clamp to a
|
|
||||||
# sane range so a bad value can't disable the agent or let it run away.
|
|
||||||
_INT_RANGES = {
|
|
||||||
"agent_max_rounds": (1, 200),
|
|
||||||
"agent_max_tool_calls": (0, 1000), # 0 = unlimited
|
|
||||||
}
|
|
||||||
for key in DEFAULT_SETTINGS:
|
for key in DEFAULT_SETTINGS:
|
||||||
if key not in body:
|
if key in body:
|
||||||
continue
|
current[key] = body[key]
|
||||||
val = body[key]
|
|
||||||
if key in _INT_RANGES:
|
|
||||||
lo, hi = _INT_RANGES[key]
|
|
||||||
try:
|
|
||||||
val = int(val)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
raise HTTPException(400, f"{key} must be an integer")
|
|
||||||
val = max(lo, min(val, hi))
|
|
||||||
current[key] = val
|
|
||||||
_save_settings(current)
|
_save_settings(current)
|
||||||
return current
|
return current
|
||||||
|
|
||||||
@@ -797,27 +562,6 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
|||||||
hint = " If this is Docker Compose ntfy, set NTFY_BIND to that host/Tailscale IP and NTFY_BASE_URL to the same server URL in .env, then recreate ntfy."
|
hint = " If this is Docker Compose ntfy, set NTFY_BIND to that host/Tailscale IP and NTFY_BASE_URL to the same server URL in .env, then recreate ntfy."
|
||||||
return {"ok": False, "message": f"ntfy publish to {full_url} failed: {e}.{hint}"[:500]}
|
return {"ok": False, "message": f"ntfy publish to {full_url} failed: {e}.{hint}"[:500]}
|
||||||
|
|
||||||
if preset == "discord_webhook":
|
|
||||||
import httpx
|
|
||||||
webhook_url = (integ.get("base_url") or "").strip()
|
|
||||||
if not webhook_url:
|
|
||||||
return {"ok": False, "message": "No webhook URL set — paste the full Discord webhook URL into the Base URL field."}
|
|
||||||
payload = {
|
|
||||||
"embeds": [{
|
|
||||||
"title": "Odysseus connectivity test",
|
|
||||||
"description": "If you see this, your Discord Webhook integration is wired up correctly.",
|
|
||||||
"color": 5793266,
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient(timeout=8.0) as client:
|
|
||||||
r = await client.post(webhook_url, json=payload)
|
|
||||||
if r.is_success:
|
|
||||||
return {"ok": True, "message": "Test embed sent — check your Discord channel to confirm it arrived."}
|
|
||||||
return {"ok": False, "message": f"Discord returned HTTP {r.status_code}: {r.text[:200]}"}
|
|
||||||
except Exception as e:
|
|
||||||
return {"ok": False, "message": f"Request failed: {e}"[:400]}
|
|
||||||
|
|
||||||
# All other presets: GET against a known health endpoint.
|
# All other presets: GET against a known health endpoint.
|
||||||
# Fall back to detecting from name if preset is missing.
|
# Fall back to detecting from name if preset is missing.
|
||||||
health_paths = {
|
health_paths = {
|
||||||
|
|||||||
@@ -101,74 +101,24 @@ def setup_backup_routes(memory_manager, preset_manager, skills_manager) -> APIRo
|
|||||||
# ── Skills ──
|
# ── Skills ──
|
||||||
if "skills" in body and isinstance(body["skills"], list):
|
if "skills" in body and isinstance(body["skills"], list):
|
||||||
existing = skills_manager.load_all()
|
existing = skills_manager.load_all()
|
||||||
# Dedup against THIS user's own skills only. Using every tenant's
|
existing_ids = {s.get("id") for s in existing}
|
||||||
# rows (load_all) meant a skill whose id/name/title matched any
|
existing_titles = {s.get("title", "").strip().lower() for s in existing}
|
||||||
# other user's was silently skipped, so the importing user lost
|
|
||||||
# their own data — same cross-tenant bug fixed for memories above.
|
|
||||||
# The full store is still saved back below.
|
|
||||||
own = [s for s in existing if s.get("owner") == user]
|
|
||||||
existing_names = {s.get("name") for s in own if s.get("name")}
|
|
||||||
existing_ids = {s.get("id") for s in own if s.get("id")}
|
|
||||||
existing_titles = {
|
|
||||||
(s.get("title") or s.get("description") or "").strip().lower()
|
|
||||||
for s in own
|
|
||||||
}
|
|
||||||
added = 0
|
added = 0
|
||||||
for skill in body["skills"]:
|
for skill in body["skills"]:
|
||||||
if not isinstance(skill, dict):
|
if not isinstance(skill, dict) or not skill.get("title"):
|
||||||
continue
|
continue
|
||||||
title = (
|
# Skip if same id or same title already exists
|
||||||
skill.get("title") or skill.get("description")
|
if skill.get("id") in existing_ids:
|
||||||
or skill.get("name") or ""
|
|
||||||
).strip()
|
|
||||||
if not title:
|
|
||||||
continue
|
continue
|
||||||
sid = skill.get("id") or skill.get("name")
|
if skill["title"].strip().lower() in existing_titles:
|
||||||
if sid and sid in existing_ids:
|
|
||||||
continue
|
continue
|
||||||
nm = skill.get("name")
|
if user and not skill.get("owner"):
|
||||||
if nm and nm in existing_names:
|
skill["owner"] = user
|
||||||
continue
|
existing.append(skill)
|
||||||
if title.lower() in existing_titles:
|
existing_ids.add(skill.get("id"))
|
||||||
continue
|
existing_titles.add(skill["title"].strip().lower())
|
||||||
owner = skill.get("owner")
|
|
||||||
if user and not owner:
|
|
||||||
owner = user
|
|
||||||
# Skills live on disk as SKILL.md files; the old JSON-era
|
|
||||||
# skills_manager.save() no longer exists. Write each new skill
|
|
||||||
# via add_skill (source="user" skips auto-dedup — this is an
|
|
||||||
# explicit backup restore).
|
|
||||||
result = skills_manager.add_skill(
|
|
||||||
title=title,
|
|
||||||
name=skill.get("name"),
|
|
||||||
description=skill.get("description"),
|
|
||||||
problem=skill.get("problem", ""),
|
|
||||||
solution=skill.get("solution", ""),
|
|
||||||
steps=skill.get("steps"),
|
|
||||||
tags=skill.get("tags"),
|
|
||||||
source="user",
|
|
||||||
teacher_model=skill.get("teacher_model"),
|
|
||||||
confidence=skill.get("confidence", 0.8),
|
|
||||||
owner=owner,
|
|
||||||
category=skill.get("category", "general"),
|
|
||||||
when_to_use=skill.get("when_to_use"),
|
|
||||||
procedure=skill.get("procedure"),
|
|
||||||
pitfalls=skill.get("pitfalls"),
|
|
||||||
verification=skill.get("verification"),
|
|
||||||
platforms=skill.get("platforms"),
|
|
||||||
requires_toolsets=skill.get("requires_toolsets"),
|
|
||||||
fallback_for_toolsets=skill.get("fallback_for_toolsets"),
|
|
||||||
status=skill.get("status", "draft"),
|
|
||||||
version=skill.get("version", "1.0.0"),
|
|
||||||
)
|
|
||||||
if result.get("_deduped"):
|
|
||||||
continue
|
|
||||||
if result.get("name"):
|
|
||||||
existing_names.add(result["name"])
|
|
||||||
if result.get("id"):
|
|
||||||
existing_ids.add(result["id"])
|
|
||||||
existing_titles.add(title.lower())
|
|
||||||
added += 1
|
added += 1
|
||||||
|
skills_manager.save(existing)
|
||||||
imported.append(f"{added} skills")
|
imported.append(f"{added} skills")
|
||||||
|
|
||||||
# ── Presets ──
|
# ── Presets ──
|
||||||
|
|||||||
@@ -1,21 +1,19 @@
|
|||||||
"""Calendar routes — local SQLite-backed calendar CRUD."""
|
"""Calendar routes — local SQLite-backed calendar CRUD."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import json
|
|
||||||
import re
|
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, date, timedelta
|
from datetime import datetime, date, timedelta
|
||||||
from typing import Optional, List
|
from typing import Optional, List, Tuple
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Request, UploadFile, File
|
from fastapi import APIRouter, HTTPException, Request, UploadFile, File
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import or_, and_
|
from sqlalchemy import or_, and_
|
||||||
from dateutil.rrule import rrulestr
|
from dateutil.rrule import rrulestr, rruleset
|
||||||
|
from dateutil.rrule import DAILY, WEEKLY, MONTHLY, YEARLY
|
||||||
|
|
||||||
from core.database import SessionLocal, CalendarCal, CalendarDeletedEvent, CalendarEvent
|
from core.database import SessionLocal, CalendarCal, CalendarEvent
|
||||||
from src.auth_helpers import effective_user, require_user
|
from src.auth_helpers import get_current_user, require_user
|
||||||
from src.upload_limits import read_upload_limited, ICS_MAX_BYTES
|
from src.upload_limits import read_upload_limited
|
||||||
from src.upload_handler import reserve_upload_references
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -36,24 +34,6 @@ def _ics_naive_dtstart(dt):
|
|||||||
return datetime(dt.year, dt.month, dt.day)
|
return datetime(dt.year, dt.month, dt.day)
|
||||||
return dt
|
return dt
|
||||||
|
|
||||||
|
|
||||||
def _ensure_positive_duration(start_dt, end_dt, all_day):
|
|
||||||
"""Clamp an imported event's end so it has a positive duration.
|
|
||||||
|
|
||||||
Some .ics exporters write a single-day all-day event with DTEND equal to
|
|
||||||
DTSTART (treating DTEND as inclusive rather than the RFC 5545 exclusive
|
|
||||||
bound). Stored verbatim that produces a zero-duration row, which the
|
|
||||||
list_events overlap filter (dtstart < end AND dtend > start) silently
|
|
||||||
drops — the event never appears on the calendar even though the web UI
|
|
||||||
would otherwise show it. Normalize a non-positive end to the same default
|
|
||||||
span used when DTEND is absent: one day for all-day events, one hour
|
|
||||||
otherwise.
|
|
||||||
"""
|
|
||||||
if end_dt <= start_dt:
|
|
||||||
return start_dt + (timedelta(days=1) if all_day else timedelta(hours=1))
|
|
||||||
return end_dt
|
|
||||||
|
|
||||||
|
|
||||||
# Single-user fallback identity. Used only when:
|
# Single-user fallback identity. Used only when:
|
||||||
# 1. The app is configured for single-user (no auth middleware), AND
|
# 1. The app is configured for single-user (no auth middleware), AND
|
||||||
# 2. The request didn't resolve to an authenticated user.
|
# 2. The request didn't resolve to an authenticated user.
|
||||||
@@ -121,15 +101,6 @@ def _ics_escape(text: str) -> str:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _safe_ics_filename(name: str) -> str:
|
|
||||||
"""Return a conservative .ics filename safe for Content-Disposition."""
|
|
||||||
stem = name if isinstance(name, str) else ""
|
|
||||||
stem = re.sub(r"[^A-Za-z0-9._-]", "_", stem).strip("._-")
|
|
||||||
if not stem:
|
|
||||||
stem = "calendar"
|
|
||||||
return f"{stem[:128]}.ics"
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_base_uid(uid: str) -> str:
|
def _resolve_base_uid(uid: str) -> str:
|
||||||
"""Extract the base series UID from a compound occurrence UID.
|
"""Extract the base series UID from a compound occurrence UID.
|
||||||
|
|
||||||
@@ -146,54 +117,6 @@ def _resolve_base_uid(uid: str) -> str:
|
|||||||
raise ValueError("malformed compound UID: missing base before ::")
|
raise ValueError("malformed compound UID: missing base before ::")
|
||||||
return base
|
return base
|
||||||
|
|
||||||
|
|
||||||
async def _push_caldav_event_after_commit(owner: str, uid: str, action: str):
|
|
||||||
"""Best-effort CalDAV write-through. Local writes stay authoritative if
|
|
||||||
the remote server is unreachable; pending flags let /sync retry later."""
|
|
||||||
try:
|
|
||||||
result = {"ok": True}
|
|
||||||
if action == "create":
|
|
||||||
from src.caldav_sync import push_event_create
|
|
||||||
result = await push_event_create(owner, uid)
|
|
||||||
elif action == "update":
|
|
||||||
from src.caldav_sync import push_event_update
|
|
||||||
result = await push_event_update(owner, uid)
|
|
||||||
elif action == "delete":
|
|
||||||
from src.caldav_sync import push_event_delete
|
|
||||||
result = await push_event_delete(owner, uid)
|
|
||||||
if result and not result.get("ok") and not result.get("skipped"):
|
|
||||||
raise RuntimeError(result.get("error") or result)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("CalDAV %s push failed for uid=%s: %s", action, uid, e)
|
|
||||||
if action in {"create", "update"}:
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
ev = _get_or_404_event(db, uid, owner)
|
|
||||||
ev.caldav_sync_pending = action
|
|
||||||
db.commit()
|
|
||||||
except Exception:
|
|
||||||
db.rollback()
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
|
|
||||||
def _record_caldav_delete_tombstone(db, ev: CalendarEvent, owner: str) -> None:
|
|
||||||
if not (ev.calendar and ev.calendar.source == "caldav"):
|
|
||||||
return
|
|
||||||
tombstone = db.query(CalendarDeletedEvent).filter(
|
|
||||||
CalendarDeletedEvent.uid == ev.uid,
|
|
||||||
CalendarDeletedEvent.owner == owner,
|
|
||||||
).first()
|
|
||||||
if not tombstone:
|
|
||||||
tombstone = CalendarDeletedEvent(uid=ev.uid, owner=owner)
|
|
||||||
db.add(tombstone)
|
|
||||||
tombstone.calendar_id = ev.calendar_id
|
|
||||||
tombstone.remote_href = ev.remote_href
|
|
||||||
tombstone.remote_etag = ev.remote_etag
|
|
||||||
tombstone.caldav_base_url = getattr(ev.calendar, "caldav_base_url", None)
|
|
||||||
tombstone.summary = ev.summary or ""
|
|
||||||
tombstone.last_error = None
|
|
||||||
|
|
||||||
# ── Pydantic models ──
|
# ── Pydantic models ──
|
||||||
|
|
||||||
class EventCreate(BaseModel):
|
class EventCreate(BaseModel):
|
||||||
@@ -239,18 +162,26 @@ def _ensure_default_calendar(db, owner: str = None) -> CalendarCal:
|
|||||||
return cal
|
return cal
|
||||||
|
|
||||||
|
|
||||||
# Per-request user time context. chat_routes sets this from browser timezone
|
# Per-request user UTC offset (in minutes east of UTC). chat_routes sets this
|
||||||
# headers so natural-language times the LLM emits ("today at 9pm") are parsed
|
# from the `X-Tz-Offset` header so naive natural-language times the LLM
|
||||||
# in the user's timezone, not the server's clock. None = unknown, fall back to
|
# emits ("today at 9pm") are parsed in the USER's timezone, not the server's
|
||||||
# legacy server-local behavior.
|
# clock. None = unknown, fall back to legacy server-local behavior.
|
||||||
from src.user_time import (
|
from contextvars import ContextVar
|
||||||
get_user_tz_name,
|
_USER_TZ_OFFSET_MIN: ContextVar = ContextVar("user_tz_offset_min", default=None)
|
||||||
get_user_tz_offset,
|
|
||||||
now_user_local,
|
|
||||||
set_user_tz_name,
|
def set_user_tz_offset(offset_min):
|
||||||
set_user_tz_offset,
|
"""Set the current user's UTC offset for this async context."""
|
||||||
user_timezone,
|
try:
|
||||||
)
|
v = int(offset_min)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return
|
||||||
|
_USER_TZ_OFFSET_MIN.set(v)
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_tz_offset():
|
||||||
|
"""Read the current user's UTC offset (minutes east of UTC), or None."""
|
||||||
|
return _USER_TZ_OFFSET_MIN.get()
|
||||||
|
|
||||||
|
|
||||||
def parse_due_for_user(s: str) -> str:
|
def parse_due_for_user(s: str) -> str:
|
||||||
@@ -269,7 +200,6 @@ def parse_due_for_user(s: str) -> str:
|
|||||||
"""
|
"""
|
||||||
from datetime import timezone as _tz, timedelta as _td
|
from datetime import timezone as _tz, timedelta as _td
|
||||||
offset = get_user_tz_offset()
|
offset = get_user_tz_offset()
|
||||||
tz_name = get_user_tz_name()
|
|
||||||
s = (s or "").strip()
|
s = (s or "").strip()
|
||||||
if not s:
|
if not s:
|
||||||
return s
|
return s
|
||||||
@@ -283,11 +213,11 @@ def parse_due_for_user(s: str) -> str:
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
parsed = None
|
parsed = None
|
||||||
|
|
||||||
if offset is None and not tz_name:
|
if offset is None:
|
||||||
# No user tz known — preserve legacy behavior (naive server-local).
|
# No user tz known — preserve legacy behavior (naive server-local).
|
||||||
return _parse_dt(s).isoformat()
|
return _parse_dt(s).isoformat()
|
||||||
|
|
||||||
user_tz = user_timezone()
|
user_tz = _tz(_td(minutes=offset))
|
||||||
|
|
||||||
# Naive ISO → tag with user tz.
|
# Naive ISO → tag with user tz.
|
||||||
if parsed is not None and parsed.tzinfo is None:
|
if parsed is not None and parsed.tzinfo is None:
|
||||||
@@ -295,7 +225,7 @@ def parse_due_for_user(s: str) -> str:
|
|||||||
|
|
||||||
# Natural language — evaluate against user's "now".
|
# Natural language — evaluate against user's "now".
|
||||||
server_now_utc = datetime.now(_tz.utc)
|
server_now_utc = datetime.now(_tz.utc)
|
||||||
user_now = now_user_local(server_now_utc)
|
user_now = server_now_utc.astimezone(user_tz)
|
||||||
# Patch datetime.now() inside _parse_dt by leveraging the user's clock:
|
# Patch datetime.now() inside _parse_dt by leveraging the user's clock:
|
||||||
# we re-implement the small natural-language phrases here against user_now
|
# we re-implement the small natural-language phrases here against user_now
|
||||||
# so the result is naturally in the user's tz.
|
# so the result is naturally in the user's tz.
|
||||||
@@ -303,7 +233,6 @@ def parse_due_for_user(s: str) -> str:
|
|||||||
lower = s.lower().strip()
|
lower = s.lower().strip()
|
||||||
|
|
||||||
def _parse_time(t):
|
def _parse_time(t):
|
||||||
t = _re.sub(r'\b([ap])\s*\.?\s*m\.?\b', r'\1m', t.strip(), flags=_re.IGNORECASE)
|
|
||||||
m = _re.match(r'^\s*(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\s*$', t, _re.IGNORECASE)
|
m = _re.match(r'^\s*(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\s*$', t, _re.IGNORECASE)
|
||||||
if not m: return None
|
if not m: return None
|
||||||
h = int(m.group(1)); mn = int(m.group(2) or 0); ampm = (m.group(3) or "").lower()
|
h = int(m.group(1)); mn = int(m.group(2) or 0); ampm = (m.group(3) or "").lower()
|
||||||
@@ -326,17 +255,6 @@ def parse_due_for_user(s: str) -> str:
|
|||||||
if t is not None:
|
if t is not None:
|
||||||
return base.replace(hour=t[0], minute=t[1]).isoformat()
|
return base.replace(hour=t[0], minute=t[1]).isoformat()
|
||||||
|
|
||||||
# Time-first: "3pm today", "11pm today", "9am tomorrow"
|
|
||||||
m = _re.match(r'^(.+?)\s+(today|tonight|tomorrow|tmrw|yesterday)$', lower)
|
|
||||||
if m:
|
|
||||||
time_part, word = m.group(1).strip(), m.group(2)
|
|
||||||
base = today
|
|
||||||
if word in ("tomorrow", "tmrw"): base = today + _td(days=1)
|
|
||||||
elif word == "yesterday": base = today - _td(days=1)
|
|
||||||
t = _parse_time(time_part)
|
|
||||||
if t is not None:
|
|
||||||
return base.replace(hour=t[0], minute=t[1]).isoformat()
|
|
||||||
|
|
||||||
m = _re.match(r'^in\s+(\d+)\s*(hour|hr|minute|min|day)s?\s*$', lower)
|
m = _re.match(r'^in\s+(\d+)\s*(hour|hr|minute|min|day)s?\s*$', lower)
|
||||||
if m:
|
if m:
|
||||||
n = int(m.group(1)); unit = m.group(2)
|
n = int(m.group(1)); unit = m.group(2)
|
||||||
@@ -424,7 +342,6 @@ def _parse_dt(s: str) -> datetime:
|
|||||||
|
|
||||||
def _parse_time(t: str):
|
def _parse_time(t: str):
|
||||||
"""Return (hour, minute) from '1pm', '1:30 PM', '13:00', etc., or None."""
|
"""Return (hour, minute) from '1pm', '1:30 PM', '13:00', etc., or None."""
|
||||||
t = _re.sub(r'\b([ap])\s*\.?\s*m\.?\b', r'\1m', t.strip(), flags=_re.IGNORECASE)
|
|
||||||
m = _re.match(r'^\s*(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\s*$', t, _re.IGNORECASE)
|
m = _re.match(r'^\s*(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\s*$', t, _re.IGNORECASE)
|
||||||
if not m:
|
if not m:
|
||||||
return None
|
return None
|
||||||
@@ -454,20 +371,6 @@ def _parse_dt(s: str) -> datetime:
|
|||||||
if t is not None:
|
if t is not None:
|
||||||
return base.replace(hour=t[0], minute=t[1])
|
return base.replace(hour=t[0], minute=t[1])
|
||||||
|
|
||||||
# time-first: "3pm today", "9am tomorrow", "11pm tonight"
|
|
||||||
# (parity with parse_due_for_user, which handles these via the same form)
|
|
||||||
m = _re.match(r'^(.+?)\s+(today|tonight|tomorrow|tmrw|yesterday)$', lower)
|
|
||||||
if m:
|
|
||||||
time_part, word = m.group(1).strip(), m.group(2)
|
|
||||||
base = today
|
|
||||||
if word in ("tomorrow", "tmrw"):
|
|
||||||
base = today + timedelta(days=1)
|
|
||||||
elif word == "yesterday":
|
|
||||||
base = today - timedelta(days=1)
|
|
||||||
t = _parse_time(time_part)
|
|
||||||
if t is not None:
|
|
||||||
return base.replace(hour=t[0], minute=t[1])
|
|
||||||
|
|
||||||
# next <weekday> [at] TIME
|
# next <weekday> [at] TIME
|
||||||
weekdays = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"]
|
weekdays = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"]
|
||||||
m = _re.match(r'^next\s+(\w+)(?:\s+at)?\s*(.*)$', lower)
|
m = _re.match(r'^next\s+(\w+)(?:\s+at)?\s*(.*)$', lower)
|
||||||
@@ -502,17 +405,7 @@ def _parse_dt(s: str) -> datetime:
|
|||||||
# Last resort: dateutil's fuzzy parser
|
# Last resort: dateutil's fuzzy parser
|
||||||
try:
|
try:
|
||||||
from dateutil import parser as _du
|
from dateutil import parser as _du
|
||||||
parsed = _du.parse(s)
|
return _du.parse(s)
|
||||||
# Strip tz like every other return path above — this function's
|
|
||||||
# contract is naive datetimes (CalendarEvent.dtstart is naive). An
|
|
||||||
# offset-bearing non-ISO input (e.g. RFC-2822 "Mon, 05 Jan 2026
|
|
||||||
# 14:00:00 +0900") otherwise leaked tz-aware into the naive column and
|
|
||||||
# crashed read-back comparisons in _expand_rrule with "can't compare
|
|
||||||
# offset-naive and offset-aware datetimes".
|
|
||||||
if parsed.tzinfo is not None:
|
|
||||||
from datetime import timezone as _tz
|
|
||||||
return parsed.astimezone(_tz.utc).replace(tzinfo=None)
|
|
||||||
return parsed
|
|
||||||
except Exception:
|
except Exception:
|
||||||
raise ValueError(f"could not parse datetime: {s!r}")
|
raise ValueError(f"could not parse datetime: {s!r}")
|
||||||
|
|
||||||
@@ -543,7 +436,6 @@ def _event_to_dict(ev: CalendarEvent) -> dict:
|
|||||||
"description": ev.description or "",
|
"description": ev.description or "",
|
||||||
"location": ev.location or "",
|
"location": ev.location or "",
|
||||||
"rrule": ev.rrule or "",
|
"rrule": ev.rrule or "",
|
||||||
"recurrence_exdates": _recurrence_exdates(ev),
|
|
||||||
"calendar": ev.calendar.name if ev.calendar else "",
|
"calendar": ev.calendar.name if ev.calendar else "",
|
||||||
"calendar_href": ev.calendar_id,
|
"calendar_href": ev.calendar_id,
|
||||||
"color": ev.color or (ev.calendar.color if ev.calendar else ""),
|
"color": ev.color or (ev.calendar.color if ev.calendar else ""),
|
||||||
@@ -554,31 +446,6 @@ def _event_to_dict(ev: CalendarEvent) -> dict:
|
|||||||
|
|
||||||
# ── Recurrence expansion ──
|
# ── Recurrence expansion ──
|
||||||
|
|
||||||
_RRULE_EXPANSION_LIMIT = 1000
|
|
||||||
|
|
||||||
|
|
||||||
def _recurrence_exdates(ev: CalendarEvent) -> list[str]:
|
|
||||||
raw = getattr(ev, "recurrence_exdates", "") or ""
|
|
||||||
if not raw:
|
|
||||||
return []
|
|
||||||
try:
|
|
||||||
values = json.loads(raw)
|
|
||||||
except Exception:
|
|
||||||
return []
|
|
||||||
if not isinstance(values, list):
|
|
||||||
return []
|
|
||||||
return [str(v) for v in values if isinstance(v, str) and v.strip()]
|
|
||||||
|
|
||||||
|
|
||||||
def _occurrence_exdate_key(uid: str, ev: CalendarEvent) -> str:
|
|
||||||
if "::" not in uid:
|
|
||||||
return ""
|
|
||||||
suffix = uid.split("::", 1)[1]
|
|
||||||
if ev.all_day:
|
|
||||||
return suffix[:10]
|
|
||||||
return suffix[:16]
|
|
||||||
|
|
||||||
|
|
||||||
def _expand_rrule(
|
def _expand_rrule(
|
||||||
ev: CalendarEvent, start: datetime, end: datetime
|
ev: CalendarEvent, start: datetime, end: datetime
|
||||||
) -> List[dict]:
|
) -> List[dict]:
|
||||||
@@ -601,7 +468,6 @@ def _expand_rrule(
|
|||||||
d = _event_to_dict(ev)
|
d = _event_to_dict(ev)
|
||||||
d["is_recurrence"] = False
|
d["is_recurrence"] = False
|
||||||
d["series_uid"] = ev.uid
|
d["series_uid"] = ev.uid
|
||||||
d["truncated"] = False
|
|
||||||
return [d]
|
return [d]
|
||||||
|
|
||||||
# Parse the rrule, applying it to the base dtstart.
|
# Parse the rrule, applying it to the base dtstart.
|
||||||
@@ -627,7 +493,6 @@ def _expand_rrule(
|
|||||||
d = _event_to_dict(ev)
|
d = _event_to_dict(ev)
|
||||||
d["is_recurrence"] = False
|
d["is_recurrence"] = False
|
||||||
d["series_uid"] = ev.uid
|
d["series_uid"] = ev.uid
|
||||||
d["truncated"] = False
|
|
||||||
# Malformed RRULE rows are fetched by the recurring SQL branch
|
# Malformed RRULE rows are fetched by the recurring SQL branch
|
||||||
# with only dtstart < end_dt — the base event may not actually
|
# with only dtstart < end_dt — the base event may not actually
|
||||||
# overlap the window. Only return if it does.
|
# overlap the window. Only return if it does.
|
||||||
@@ -640,43 +505,32 @@ def _expand_rrule(
|
|||||||
# (matching non-recurring overlap semantics: dtstart < end AND
|
# (matching non-recurring overlap semantics: dtstart < end AND
|
||||||
# dtend > start).
|
# dtend > start).
|
||||||
expand_start = start - duration
|
expand_start = start - duration
|
||||||
|
occurrences = rule.between(expand_start, end, inc=True)
|
||||||
|
if not occurrences:
|
||||||
|
return []
|
||||||
|
|
||||||
results = []
|
results = []
|
||||||
truncated = False
|
|
||||||
base = _event_to_dict(ev)
|
base = _event_to_dict(ev)
|
||||||
exdates = set(_recurrence_exdates(ev))
|
|
||||||
|
|
||||||
for occ_start in rule.xafter(expand_start, inc=True):
|
|
||||||
if occ_start >= end:
|
|
||||||
break
|
|
||||||
|
|
||||||
|
for occ_start in occurrences:
|
||||||
occ_end = occ_start + duration
|
occ_end = occ_start + duration
|
||||||
|
|
||||||
# Overlap filter: occurrence must intersect [start, end).
|
# Overlap filter: occurrence must intersect [start, end).
|
||||||
# This enforces exclusive-end semantics (occ_start >= end is
|
# This enforces exclusive-end semantics (occ_start >= end is
|
||||||
# excluded) and includes multi-day crossings (occ_end > start).
|
# excluded) and includes multi-day crossings (occ_end > start).
|
||||||
if occ_end <= start:
|
if occ_start >= end or occ_end <= start:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if len(results) >= _RRULE_EXPANSION_LIMIT:
|
|
||||||
truncated = True
|
|
||||||
break
|
|
||||||
|
|
||||||
# Build the compound uid: {base_uid}::{date} or ::{datetime}
|
# Build the compound uid: {base_uid}::{date} or ::{datetime}
|
||||||
if ev.all_day:
|
if ev.all_day:
|
||||||
occ_uid = f"{ev.uid}::{occ_start.strftime('%Y-%m-%d')}"
|
occ_uid = f"{ev.uid}::{occ_start.strftime('%Y-%m-%d')}"
|
||||||
exdate_key = occ_start.strftime("%Y-%m-%d")
|
|
||||||
else:
|
else:
|
||||||
occ_uid = f"{ev.uid}::{occ_start.strftime('%Y-%m-%dT%H:%M')}"
|
occ_uid = f"{ev.uid}::{occ_start.strftime('%Y-%m-%dT%H:%M')}"
|
||||||
exdate_key = occ_start.strftime("%Y-%m-%dT%H:%M")
|
|
||||||
|
|
||||||
if exdate_key in exdates:
|
|
||||||
continue
|
|
||||||
|
|
||||||
d = dict(base)
|
d = dict(base)
|
||||||
d["uid"] = occ_uid
|
d["uid"] = occ_uid
|
||||||
d["series_uid"] = ev.uid
|
d["series_uid"] = ev.uid
|
||||||
d["is_recurrence"] = True
|
d["is_recurrence"] = True
|
||||||
d["truncated"] = False
|
|
||||||
|
|
||||||
if ev.all_day:
|
if ev.all_day:
|
||||||
d["dtstart"] = occ_start.strftime("%Y-%m-%d")
|
d["dtstart"] = occ_start.strftime("%Y-%m-%d")
|
||||||
@@ -689,199 +543,80 @@ def _expand_rrule(
|
|||||||
|
|
||||||
results.append(d)
|
results.append(d)
|
||||||
|
|
||||||
if truncated:
|
|
||||||
for d in results:
|
|
||||||
d["truncated"] = True
|
|
||||||
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
# ── Routes ──
|
# ── Routes ──
|
||||||
|
|
||||||
def setup_calendar_routes(upload_handler=None) -> APIRouter:
|
def setup_calendar_routes() -> APIRouter:
|
||||||
router = APIRouter(prefix="/api/calendar", tags=["calendar"])
|
router = APIRouter(prefix="/api/calendar", tags=["calendar"])
|
||||||
|
|
||||||
def _reserve_calendar_uploads(request: Request, *values) -> None:
|
# CalDAV connect form (Integrations → Calendar). Storage is local
|
||||||
missing_id = reserve_upload_references(
|
# SQLite; sync (src/caldav_sync.py) pulls remote events into it on
|
||||||
upload_handler,
|
# calendar open and periodically via the scheduler.
|
||||||
effective_user(request),
|
|
||||||
*values,
|
|
||||||
)
|
|
||||||
if missing_id:
|
|
||||||
raise HTTPException(409, f"Referenced upload is no longer available: {missing_id}")
|
|
||||||
|
|
||||||
# ── CalDAV multi-account helpers ─────────────────────────────────────────
|
|
||||||
|
|
||||||
def _get_caldav_accounts(owner: str) -> list:
|
|
||||||
from src.caldav_sync import _load_caldav_accounts
|
|
||||||
return _load_caldav_accounts(owner)
|
|
||||||
|
|
||||||
def _save_caldav_accounts(owner: str, accounts: list) -> None:
|
|
||||||
from routes.prefs_routes import _load_for_user, _save_for_user
|
|
||||||
prefs = _load_for_user(owner) or {}
|
|
||||||
prefs["caldav_accounts"] = accounts
|
|
||||||
prefs.pop("caldav", None)
|
|
||||||
_save_for_user(owner, prefs)
|
|
||||||
|
|
||||||
# ── CalDAV config routes (backward-compat single-account API) ────────────
|
|
||||||
|
|
||||||
@router.get("/config")
|
@router.get("/config")
|
||||||
async def get_config(request: Request):
|
async def get_config(request: Request):
|
||||||
"""Legacy single-account endpoint — returns the first configured account."""
|
|
||||||
owner = _require_user(request)
|
owner = _require_user(request)
|
||||||
accounts = _get_caldav_accounts(owner)
|
from routes.prefs_routes import _load_for_user
|
||||||
if not accounts:
|
cfg = (_load_for_user(owner) or {}).get("caldav", {}) or {}
|
||||||
return {"url": "", "username": "", "password": "", "has_password": False, "local": True}
|
caldav_password = cfg.get("password") or ""
|
||||||
first = accounts[0]
|
if caldav_password:
|
||||||
pw = first.get("password") or ""
|
|
||||||
has_pw = False
|
|
||||||
if pw:
|
|
||||||
try:
|
try:
|
||||||
from src.secret_storage import decrypt
|
from src.secret_storage import decrypt
|
||||||
has_pw = bool(decrypt(pw))
|
caldav_password = decrypt(caldav_password)
|
||||||
except Exception:
|
except Exception:
|
||||||
has_pw = bool(pw)
|
pass
|
||||||
|
# Surface url+username but never hand the password back to the
|
||||||
|
# client — saved-state UI shouldn't leak the credential.
|
||||||
return {
|
return {
|
||||||
"url": first.get("url", "") or "",
|
"url": cfg.get("url", "") or "",
|
||||||
"username": first.get("username", "") or "",
|
"username": cfg.get("username", "") or "",
|
||||||
"password": "",
|
"password": "",
|
||||||
"has_password": has_pw,
|
"has_password": bool(caldav_password),
|
||||||
"local": not bool(first.get("url")),
|
"local": not bool(cfg.get("url")),
|
||||||
}
|
}
|
||||||
|
|
||||||
@router.post("/config")
|
@router.post("/config")
|
||||||
async def save_config(request: Request):
|
async def save_config(request: Request):
|
||||||
"""Legacy single-account endpoint — upserts the first account."""
|
|
||||||
owner = _require_user(request)
|
owner = _require_user(request)
|
||||||
|
from routes.prefs_routes import _load_for_user, _save_for_user
|
||||||
try:
|
try:
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
except Exception:
|
except Exception:
|
||||||
body = {}
|
body = {}
|
||||||
accounts = _get_caldav_accounts(owner)
|
prefs = _load_for_user(owner) or {}
|
||||||
|
cfg = dict(prefs.get("caldav") or {})
|
||||||
|
# Empty url => clear the whole entry (treat as "remove integration").
|
||||||
if not (body.get("url") or "").strip():
|
if not (body.get("url") or "").strip():
|
||||||
_save_caldav_accounts(owner, [])
|
prefs.pop("caldav", None)
|
||||||
|
_save_for_user(owner, prefs)
|
||||||
return {"ok": True, "cleared": True}
|
return {"ok": True, "cleared": True}
|
||||||
from src.caldav_sync import validate_caldav_url
|
from src.caldav_sync import validate_caldav_url
|
||||||
try:
|
try:
|
||||||
validated_url = validate_caldav_url(body.get("url", ""))
|
cfg["url"] = validate_caldav_url(body.get("url", ""))
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(400, str(e))
|
raise HTTPException(400, str(e))
|
||||||
if accounts:
|
cfg["username"] = (body.get("username") or "").strip()
|
||||||
acc = dict(accounts[0])
|
# Preserve the stored password when the client sends an empty
|
||||||
else:
|
# one (edit form re-submitted without re-typing the password).
|
||||||
import uuid as _uuid
|
# cfg already holds the existing (already-encrypted) password from
|
||||||
acc = {"id": str(_uuid.uuid4()), "label": "CalDAV"}
|
# prefs, so we only touch it when a new password is supplied —
|
||||||
acc["url"] = validated_url
|
# re-encrypting the stored value would double-encrypt it.
|
||||||
acc["username"] = (body.get("username") or "").strip()
|
|
||||||
if body.get("password"):
|
if body.get("password"):
|
||||||
from src.secret_storage import encrypt
|
from src.secret_storage import encrypt
|
||||||
acc["password"] = encrypt(body["password"])
|
cfg["password"] = encrypt(body["password"])
|
||||||
new_accounts = [acc] + (accounts[1:] if len(accounts) > 1 else [])
|
prefs["caldav"] = cfg
|
||||||
_save_caldav_accounts(owner, new_accounts)
|
_save_for_user(owner, prefs)
|
||||||
return {"ok": True}
|
|
||||||
|
|
||||||
# ── CalDAV multi-account CRUD ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
@router.get("/config/accounts")
|
|
||||||
async def list_caldav_accounts(request: Request):
|
|
||||||
"""Return all configured CalDAV accounts (passwords never returned)."""
|
|
||||||
owner = _require_user(request)
|
|
||||||
accounts = _get_caldav_accounts(owner)
|
|
||||||
safe = []
|
|
||||||
for acc in accounts:
|
|
||||||
pw = acc.get("password") or ""
|
|
||||||
has_pw = False
|
|
||||||
if pw:
|
|
||||||
try:
|
|
||||||
from src.secret_storage import decrypt
|
|
||||||
has_pw = bool(decrypt(pw))
|
|
||||||
except Exception:
|
|
||||||
has_pw = bool(pw)
|
|
||||||
safe.append({
|
|
||||||
"id": acc.get("id", ""),
|
|
||||||
"label": acc.get("label", "") or acc.get("url", ""),
|
|
||||||
"url": acc.get("url", "") or "",
|
|
||||||
"username": acc.get("username", "") or "",
|
|
||||||
"has_password": has_pw,
|
|
||||||
})
|
|
||||||
return {"accounts": safe}
|
|
||||||
|
|
||||||
@router.post("/config/accounts")
|
|
||||||
async def add_caldav_account(request: Request):
|
|
||||||
"""Add a new CalDAV account."""
|
|
||||||
import uuid as _uuid
|
|
||||||
owner = _require_user(request)
|
|
||||||
try:
|
|
||||||
body = await request.json()
|
|
||||||
except Exception:
|
|
||||||
body = {}
|
|
||||||
from src.caldav_sync import validate_caldav_url
|
|
||||||
try:
|
|
||||||
url = validate_caldav_url(body.get("url", ""))
|
|
||||||
except ValueError as e:
|
|
||||||
raise HTTPException(400, str(e))
|
|
||||||
if not body.get("password"):
|
|
||||||
raise HTTPException(400, "Password is required")
|
|
||||||
from src.secret_storage import encrypt
|
|
||||||
new_acc = {
|
|
||||||
"id": str(_uuid.uuid4()),
|
|
||||||
"label": (body.get("label") or "").strip() or "CalDAV",
|
|
||||||
"url": url,
|
|
||||||
"username": (body.get("username") or "").strip(),
|
|
||||||
"password": encrypt(body["password"]),
|
|
||||||
}
|
|
||||||
accounts = _get_caldav_accounts(owner)
|
|
||||||
accounts.append(new_acc)
|
|
||||||
_save_caldav_accounts(owner, accounts)
|
|
||||||
return {"ok": True, "id": new_acc["id"]}
|
|
||||||
|
|
||||||
@router.put("/config/accounts/{account_id}")
|
|
||||||
async def update_caldav_account(account_id: str, request: Request):
|
|
||||||
"""Update an existing CalDAV account by id."""
|
|
||||||
owner = _require_user(request)
|
|
||||||
try:
|
|
||||||
body = await request.json()
|
|
||||||
except Exception:
|
|
||||||
body = {}
|
|
||||||
accounts = _get_caldav_accounts(owner)
|
|
||||||
idx = next((i for i, a in enumerate(accounts) if a.get("id") == account_id), None)
|
|
||||||
if idx is None:
|
|
||||||
raise HTTPException(404, "Account not found")
|
|
||||||
acc = dict(accounts[idx])
|
|
||||||
if body.get("url"):
|
|
||||||
from src.caldav_sync import validate_caldav_url
|
|
||||||
try:
|
|
||||||
acc["url"] = validate_caldav_url(body["url"])
|
|
||||||
except ValueError as e:
|
|
||||||
raise HTTPException(400, str(e))
|
|
||||||
if body.get("label") is not None:
|
|
||||||
acc["label"] = (body.get("label") or "").strip() or "CalDAV"
|
|
||||||
if body.get("username") is not None:
|
|
||||||
acc["username"] = (body.get("username") or "").strip()
|
|
||||||
if body.get("password"):
|
|
||||||
from src.secret_storage import encrypt
|
|
||||||
acc["password"] = encrypt(body["password"])
|
|
||||||
accounts[idx] = acc
|
|
||||||
_save_caldav_accounts(owner, accounts)
|
|
||||||
return {"ok": True}
|
|
||||||
|
|
||||||
@router.delete("/config/accounts/{account_id}")
|
|
||||||
async def delete_caldav_account(account_id: str, request: Request):
|
|
||||||
"""Remove a CalDAV account by id."""
|
|
||||||
owner = _require_user(request)
|
|
||||||
accounts = _get_caldav_accounts(owner)
|
|
||||||
new_accounts = [a for a in accounts if a.get("id") != account_id]
|
|
||||||
if len(new_accounts) == len(accounts):
|
|
||||||
raise HTTPException(404, "Account not found")
|
|
||||||
_save_caldav_accounts(owner, new_accounts)
|
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
@router.post("/test")
|
@router.post("/test")
|
||||||
async def test_connection(request: Request):
|
async def test_connection(request: Request):
|
||||||
"""Probe a CalDAV server with a PROPFIND. Accepts an optional body:
|
"""Actually probe the configured CalDAV server with a PROPFIND
|
||||||
{url, username, password} to test before saving, or {account_id} to
|
request (the same handshake every CalDAV client uses). Accepts
|
||||||
test an already-saved account. Falls back to the first saved account
|
an optional {url, username, password} body so the user can test
|
||||||
when nothing is provided."""
|
a configuration BEFORE saving it; falls back to the stored
|
||||||
|
creds otherwise. Returns {ok, error?} with a useful message on
|
||||||
|
failure (status code, auth issue, network error)."""
|
||||||
owner = _require_user(request)
|
owner = _require_user(request)
|
||||||
try:
|
try:
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
@@ -891,24 +626,19 @@ def setup_calendar_routes(upload_handler=None) -> APIRouter:
|
|||||||
user = (body.get("username") or "").strip()
|
user = (body.get("username") or "").strip()
|
||||||
pw = body.get("password") or ""
|
pw = body.get("password") or ""
|
||||||
if not (url and user and pw):
|
if not (url and user and pw):
|
||||||
# Look up a saved account: by id if supplied, else first account.
|
# Fall back to saved settings for this user.
|
||||||
accounts = _get_caldav_accounts(owner)
|
from routes.prefs_routes import _load_for_user
|
||||||
acc = None
|
cfg = (_load_for_user(owner) or {}).get("caldav", {}) or {}
|
||||||
if body.get("account_id"):
|
url = url or (cfg.get("url") or "")
|
||||||
acc = next((a for a in accounts if a.get("id") == body["account_id"]), None)
|
user = user or (cfg.get("username") or "")
|
||||||
if acc is None and accounts:
|
if not pw:
|
||||||
acc = accounts[0]
|
pw = cfg.get("password") or ""
|
||||||
if acc:
|
if pw:
|
||||||
url = url or (acc.get("url") or "")
|
try:
|
||||||
user = user or (acc.get("username") or "")
|
from src.secret_storage import decrypt
|
||||||
if not pw:
|
pw = decrypt(pw)
|
||||||
pw = acc.get("password") or ""
|
except Exception:
|
||||||
if pw:
|
pass
|
||||||
try:
|
|
||||||
from src.secret_storage import decrypt
|
|
||||||
pw = decrypt(pw)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
if not (url and user and pw):
|
if not (url and user and pw):
|
||||||
return {"ok": False, "error": "Missing URL, username, or password"}
|
return {"ok": False, "error": "Missing URL, username, or password"}
|
||||||
from src.caldav_sync import validate_caldav_url
|
from src.caldav_sync import validate_caldav_url
|
||||||
@@ -923,24 +653,7 @@ def setup_calendar_routes(upload_handler=None) -> APIRouter:
|
|||||||
'</d:prop></d:propfind>'
|
'</d:prop></d:propfind>'
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
# Build an SSL context that trusts the operator's custom CA bundle
|
async with httpx.AsyncClient(timeout=8.0, follow_redirects=False, trust_env=False) as cx:
|
||||||
# (SSL_CERT_FILE / REQUESTS_CA_BUNDLE) so self-signed CalDAV servers
|
|
||||||
# pass the pre-flight the same way they pass the real sync.
|
|
||||||
# trust_env=False is kept to block proxy/auth env leakage; the CA
|
|
||||||
# bundle is loaded explicitly instead.
|
|
||||||
import ssl as _ssl
|
|
||||||
_ssl_ctx = _ssl.create_default_context()
|
|
||||||
# Disable VERIFY_X509_STRICT so certs without a keyUsage extension
|
|
||||||
# (common in self-signed setups) are accepted, matching the
|
|
||||||
# requests/urllib3 behavior used by the CalDAV sync path.
|
|
||||||
_ssl_ctx.verify_flags &= ~_ssl.VERIFY_X509_STRICT
|
|
||||||
_ca_bundle = _os.environ.get("SSL_CERT_FILE") or _os.environ.get("REQUESTS_CA_BUNDLE")
|
|
||||||
if _ca_bundle:
|
|
||||||
if _os.path.isfile(_ca_bundle):
|
|
||||||
_ssl_ctx.load_verify_locations(_ca_bundle)
|
|
||||||
else:
|
|
||||||
logger.warning("CalDAV test: CA bundle %s not found, using system CAs", _ca_bundle)
|
|
||||||
async with httpx.AsyncClient(timeout=8.0, follow_redirects=False, trust_env=False, verify=_ssl_ctx) as cx:
|
|
||||||
r = await cx.request(
|
r = await cx.request(
|
||||||
"PROPFIND", url,
|
"PROPFIND", url,
|
||||||
auth=(user, pw),
|
auth=(user, pw),
|
||||||
@@ -980,34 +693,13 @@ def setup_calendar_routes(upload_handler=None) -> APIRouter:
|
|||||||
return {"ok": False, "error": str(e)[:200]}
|
return {"ok": False, "error": str(e)[:200]}
|
||||||
|
|
||||||
@router.post("/sync")
|
@router.post("/sync")
|
||||||
async def sync_caldav_endpoint(request: Request, direction: str = "pull"):
|
async def sync_caldav_endpoint(request: Request):
|
||||||
"""Sync events with the configured CalDAV server.
|
"""Pull events from the configured CalDAV server into local DB.
|
||||||
Returns counts + any per-calendar errors. Called by the frontend
|
Returns counts + any per-calendar errors. Called by the frontend
|
||||||
on calendar open and by the periodic scheduler loop."""
|
on calendar open and by the periodic scheduler loop."""
|
||||||
owner = _require_user(request)
|
owner = _require_user(request)
|
||||||
from src.caldav_sync import sync_caldav_direction
|
from src.caldav_sync import sync_caldav
|
||||||
return await sync_caldav_direction(owner, direction)
|
return await sync_caldav(owner)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/calendars/{cal_id}")
|
|
||||||
async def delete_calendar(request: Request, cal_id: str):
|
|
||||||
owner = _require_user(request)
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
cal = _get_or_404_calendar(db, cal_id, owner)
|
|
||||||
db.query(CalendarEvent).filter(CalendarEvent.calendar_id == cal_id).delete()
|
|
||||||
db.delete(cal)
|
|
||||||
db.commit()
|
|
||||||
return {"ok": True}
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
db.rollback()
|
|
||||||
logger.error("Failed to delete calendar %s: %s", cal_id, e)
|
|
||||||
raise HTTPException(500, "Failed to delete calendar")
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/calendars")
|
@router.get("/calendars")
|
||||||
async def list_calendars(request: Request):
|
async def list_calendars(request: Request):
|
||||||
@@ -1017,7 +709,7 @@ def setup_calendar_routes(upload_handler=None) -> APIRouter:
|
|||||||
_ensure_default_calendar(db, owner)
|
_ensure_default_calendar(db, owner)
|
||||||
cals = db.query(CalendarCal).filter(CalendarCal.owner == owner).all()
|
cals = db.query(CalendarCal).filter(CalendarCal.owner == owner).all()
|
||||||
return {"calendars": [
|
return {"calendars": [
|
||||||
{"name": c.name, "href": c.id, "color": c.color, "source": c.source}
|
{"name": c.name, "href": c.id, "color": c.color}
|
||||||
for c in cals
|
for c in cals
|
||||||
]}
|
]}
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
@@ -1080,12 +772,8 @@ def setup_calendar_routes(upload_handler=None) -> APIRouter:
|
|||||||
expanded.extend(_expand_rrule(e, start_dt, end_dt))
|
expanded.extend(_expand_rrule(e, start_dt, end_dt))
|
||||||
|
|
||||||
# Sort by occurrence start time for consistent frontend ordering.
|
# Sort by occurrence start time for consistent frontend ordering.
|
||||||
truncated = any(e.get("truncated") for e in expanded)
|
|
||||||
expanded.sort(key=lambda d: d["dtstart"])
|
expanded.sort(key=lambda d: d["dtstart"])
|
||||||
response: dict = {"events": expanded}
|
return {"events": expanded}
|
||||||
if truncated:
|
|
||||||
response["truncated"] = True
|
|
||||||
return response
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -1097,7 +785,6 @@ def setup_calendar_routes(upload_handler=None) -> APIRouter:
|
|||||||
@router.post("/events")
|
@router.post("/events")
|
||||||
async def create_event(request: Request, data: EventCreate):
|
async def create_event(request: Request, data: EventCreate):
|
||||||
owner = _require_user(request)
|
owner = _require_user(request)
|
||||||
_reserve_calendar_uploads(request, data.color, data.description, data.location)
|
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
cal = None
|
cal = None
|
||||||
@@ -1140,12 +827,19 @@ def setup_calendar_routes(upload_handler=None) -> APIRouter:
|
|||||||
is_utc=_is_utc and not data.all_day,
|
is_utc=_is_utc and not data.all_day,
|
||||||
rrule=data.rrule or "",
|
rrule=data.rrule or "",
|
||||||
color=data.color or None,
|
color=data.color or None,
|
||||||
caldav_sync_pending="create" if cal.source == "caldav" else None,
|
|
||||||
)
|
)
|
||||||
db.add(ev)
|
db.add(ev)
|
||||||
db.commit()
|
db.commit()
|
||||||
if cal.source == "caldav":
|
if cal.source == "caldav":
|
||||||
await _push_caldav_event_after_commit(owner, uid, "create")
|
# Push the new event to the remote so it appears on the user's
|
||||||
|
# other devices — the sync is otherwise pull-only (#800).
|
||||||
|
from src.caldav_writeback import writeback_event
|
||||||
|
await writeback_event(owner, cal.source, cal.id, {
|
||||||
|
"uid": uid, "summary": data.summary, "description": data.description,
|
||||||
|
"location": data.location, "dtstart": dtstart, "dtend": dtend,
|
||||||
|
"all_day": data.all_day, "is_utc": _is_utc and not data.all_day,
|
||||||
|
"rrule": data.rrule or "",
|
||||||
|
})
|
||||||
return {"ok": True, "uid": uid}
|
return {"ok": True, "uid": uid}
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
@@ -1159,7 +853,6 @@ def setup_calendar_routes(upload_handler=None) -> APIRouter:
|
|||||||
@router.put("/events/{uid}")
|
@router.put("/events/{uid}")
|
||||||
async def update_event(request: Request, uid: str, data: EventUpdate):
|
async def update_event(request: Request, uid: str, data: EventUpdate):
|
||||||
owner = _require_user(request)
|
owner = _require_user(request)
|
||||||
_reserve_calendar_uploads(request, data.color, data.description, data.location)
|
|
||||||
try:
|
try:
|
||||||
base_uid = _resolve_base_uid(uid)
|
base_uid = _resolve_base_uid(uid)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
@@ -1192,12 +885,15 @@ def setup_calendar_routes(upload_handler=None) -> APIRouter:
|
|||||||
ev.rrule = data.rrule
|
ev.rrule = data.rrule
|
||||||
if data.color is not None:
|
if data.color is not None:
|
||||||
ev.color = data.color if data.color else None
|
ev.color = data.color if data.color else None
|
||||||
is_caldav = ev.calendar and ev.calendar.source == "caldav"
|
|
||||||
if is_caldav:
|
|
||||||
ev.caldav_sync_pending = "update"
|
|
||||||
db.commit()
|
db.commit()
|
||||||
if is_caldav:
|
cal = db.query(CalendarCal).filter(CalendarCal.id == ev.calendar_id).first()
|
||||||
await _push_caldav_event_after_commit(owner, base_uid, "update")
|
if cal and cal.source == "caldav":
|
||||||
|
from src.caldav_writeback import writeback_event
|
||||||
|
await writeback_event(owner, cal.source, cal.id, {
|
||||||
|
"uid": ev.uid, "summary": ev.summary, "description": ev.description,
|
||||||
|
"location": ev.location, "dtstart": ev.dtstart, "dtend": ev.dtend,
|
||||||
|
"all_day": ev.all_day, "is_utc": ev.is_utc, "rrule": ev.rrule or "",
|
||||||
|
})
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
@@ -1209,7 +905,7 @@ def setup_calendar_routes(upload_handler=None) -> APIRouter:
|
|||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
@router.delete("/events/{uid}")
|
@router.delete("/events/{uid}")
|
||||||
async def delete_event(request: Request, uid: str, scope: str = "series"):
|
async def delete_event(request: Request, uid: str):
|
||||||
owner = _require_user(request)
|
owner = _require_user(request)
|
||||||
try:
|
try:
|
||||||
base_uid = _resolve_base_uid(uid)
|
base_uid = _resolve_base_uid(uid)
|
||||||
@@ -1218,28 +914,15 @@ def setup_calendar_routes(upload_handler=None) -> APIRouter:
|
|||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
ev = _get_or_404_event(db, base_uid, owner)
|
ev = _get_or_404_event(db, base_uid, owner)
|
||||||
is_occurrence_delete = scope in {"occurrence", "instance"} and "::" in uid and bool(ev.rrule)
|
# Capture what the remote push needs BEFORE the row is gone.
|
||||||
is_caldav = ev.calendar and ev.calendar.source == "caldav"
|
_cal = db.query(CalendarCal).filter(CalendarCal.id == ev.calendar_id).first()
|
||||||
if is_occurrence_delete:
|
_is_caldav = bool(_cal and _cal.source == "caldav")
|
||||||
key = _occurrence_exdate_key(uid, ev)
|
_cal_id, _ev_uid = ev.calendar_id, ev.uid
|
||||||
if not key:
|
|
||||||
raise HTTPException(400, "Invalid recurring occurrence uid")
|
|
||||||
exdates = _recurrence_exdates(ev)
|
|
||||||
if key not in exdates:
|
|
||||||
exdates.append(key)
|
|
||||||
ev.recurrence_exdates = json.dumps(sorted(exdates))
|
|
||||||
if is_caldav:
|
|
||||||
ev.caldav_sync_pending = "update"
|
|
||||||
db.commit()
|
|
||||||
if is_caldav:
|
|
||||||
await _push_caldav_event_after_commit(owner, base_uid, "update")
|
|
||||||
return {"ok": True, "scope": "occurrence", "exdate": key}
|
|
||||||
if is_caldav:
|
|
||||||
_record_caldav_delete_tombstone(db, ev, owner)
|
|
||||||
db.delete(ev)
|
db.delete(ev)
|
||||||
db.commit()
|
db.commit()
|
||||||
if is_caldav:
|
if _is_caldav:
|
||||||
await _push_caldav_event_after_commit(owner, base_uid, "delete")
|
from src.caldav_writeback import writeback_event
|
||||||
|
await writeback_event(owner, "caldav", _cal_id, {"uid": _ev_uid}, delete=True)
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
@@ -1253,7 +936,6 @@ def setup_calendar_routes(upload_handler=None) -> APIRouter:
|
|||||||
@router.post("/calendars")
|
@router.post("/calendars")
|
||||||
async def create_calendar(request: Request, name: str = "Imported", color: str = "#5b8abf"):
|
async def create_calendar(request: Request, name: str = "Imported", color: str = "#5b8abf"):
|
||||||
owner = _require_user(request)
|
owner = _require_user(request)
|
||||||
_reserve_calendar_uploads(request, color)
|
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
cal = CalendarCal(
|
cal = CalendarCal(
|
||||||
@@ -1276,7 +958,6 @@ def setup_calendar_routes(upload_handler=None) -> APIRouter:
|
|||||||
@router.put("/calendars/{cal_id}")
|
@router.put("/calendars/{cal_id}")
|
||||||
async def update_calendar(request: Request, cal_id: str, name: str = None, color: str = None):
|
async def update_calendar(request: Request, cal_id: str, name: str = None, color: str = None):
|
||||||
owner = _require_user(request)
|
owner = _require_user(request)
|
||||||
_reserve_calendar_uploads(request, color)
|
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
cal = _get_or_404_calendar(db, cal_id, owner)
|
cal = _get_or_404_calendar(db, cal_id, owner)
|
||||||
@@ -1295,10 +976,27 @@ def setup_calendar_routes(upload_handler=None) -> APIRouter:
|
|||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
@router.delete("/calendars/{cal_id}")
|
||||||
|
async def delete_calendar(request: Request, cal_id: str):
|
||||||
|
owner = _require_user(request)
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
cal = _get_or_404_calendar(db, cal_id, owner)
|
||||||
|
db.query(CalendarEvent).filter(CalendarEvent.calendar_id == cal_id).delete()
|
||||||
|
db.delete(cal)
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
return {"error": str(e)}
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
# Hard cap on ICS upload (ICS_MAX_BYTES, default 10 MB). Loading the whole
|
# 10 MB hard cap on ICS upload. Loading the whole file into memory is
|
||||||
# file into memory is unavoidable with python-icalendar, so an unbounded
|
# unavoidable with python-icalendar, so an unbounded upload would OOM.
|
||||||
# upload would OOM.
|
_ICS_MAX_BYTES = 10 * 1024 * 1024
|
||||||
|
|
||||||
@router.post("/import")
|
@router.post("/import")
|
||||||
async def import_ics(request: Request, file: UploadFile = File(...), calendar_name: str = ""):
|
async def import_ics(request: Request, file: UploadFile = File(...), calendar_name: str = ""):
|
||||||
@@ -1308,7 +1006,7 @@ def setup_calendar_routes(upload_handler=None) -> APIRouter:
|
|||||||
owner = _require_user(request)
|
owner = _require_user(request)
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
content = await read_upload_limited(file, ICS_MAX_BYTES, "ICS file")
|
content = await read_upload_limited(file, _ICS_MAX_BYTES, "ICS file")
|
||||||
try:
|
try:
|
||||||
cal_data = iCal.from_ical(content)
|
cal_data = iCal.from_ical(content)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -1334,7 +1032,7 @@ def setup_calendar_routes(upload_handler=None) -> APIRouter:
|
|||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(target_cal)
|
db.refresh(target_cal)
|
||||||
|
|
||||||
imported = skipped = repaired = 0
|
imported = skipped = 0
|
||||||
for comp in cal_data.walk():
|
for comp in cal_data.walk():
|
||||||
if comp.name != "VEVENT":
|
if comp.name != "VEVENT":
|
||||||
continue
|
continue
|
||||||
@@ -1370,18 +1068,6 @@ def setup_calendar_routes(upload_handler=None) -> APIRouter:
|
|||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
if existing:
|
if existing:
|
||||||
# An import predating the clamp below may have stored
|
|
||||||
# this same event with a non-positive duration, which
|
|
||||||
# the list_events overlap filter hides. Re-importing
|
|
||||||
# lands here and would skip without touching that row,
|
|
||||||
# so the event would stay invisible. Backfill the clamp
|
|
||||||
# onto the stored row before skipping it.
|
|
||||||
fixed_end = _ensure_positive_duration(
|
|
||||||
existing.dtstart, existing.dtend, bool(existing.all_day)
|
|
||||||
)
|
|
||||||
if fixed_end != existing.dtend:
|
|
||||||
existing.dtend = fixed_end
|
|
||||||
repaired += 1
|
|
||||||
skipped += 1
|
skipped += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -1415,8 +1101,6 @@ def setup_calendar_routes(upload_handler=None) -> APIRouter:
|
|||||||
else:
|
else:
|
||||||
end_dt = start_dt + timedelta(hours=1)
|
end_dt = start_dt + timedelta(hours=1)
|
||||||
|
|
||||||
end_dt = _ensure_positive_duration(start_dt, end_dt, all_day)
|
|
||||||
|
|
||||||
ev = CalendarEvent(
|
ev = CalendarEvent(
|
||||||
uid=uid_val,
|
uid=uid_val,
|
||||||
calendar_id=target_cal.id,
|
calendar_id=target_cal.id,
|
||||||
@@ -1437,7 +1121,6 @@ def setup_calendar_routes(upload_handler=None) -> APIRouter:
|
|||||||
"ok": True,
|
"ok": True,
|
||||||
"imported": imported,
|
"imported": imported,
|
||||||
"skipped": skipped,
|
"skipped": skipped,
|
||||||
"repaired": repaired,
|
|
||||||
"calendar": cal_display,
|
"calendar": cal_display,
|
||||||
"calendar_id": target_cal.id,
|
"calendar_id": target_cal.id,
|
||||||
}
|
}
|
||||||
@@ -1491,14 +1174,11 @@ def setup_calendar_routes(upload_handler=None) -> APIRouter:
|
|||||||
lines.append("END:VCALENDAR")
|
lines.append("END:VCALENDAR")
|
||||||
|
|
||||||
ics_data = "\r\n".join(lines)
|
ics_data = "\r\n".join(lines)
|
||||||
download_name = _safe_ics_filename(cal.name)
|
safe_name = cal.name.replace(" ", "_").replace("/", "_")
|
||||||
return Response(
|
return Response(
|
||||||
content=ics_data,
|
content=ics_data,
|
||||||
media_type="text/calendar",
|
media_type="text/calendar",
|
||||||
headers={
|
headers={"Content-Disposition": f'attachment; filename="{safe_name}.ics"'},
|
||||||
"Content-Disposition": f'attachment; filename="{download_name}"',
|
|
||||||
"X-Content-Type-Options": "nosniff",
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
@@ -1520,7 +1200,7 @@ def setup_calendar_routes(upload_handler=None) -> APIRouter:
|
|||||||
"tomorrow", "next Tuesday", "in 30 minutes" resolve correctly.
|
"tomorrow", "next Tuesday", "in 30 minutes" resolve correctly.
|
||||||
Uses the "utility" endpoint (small / fast model) to keep latency low.
|
Uses the "utility" endpoint (small / fast model) to keep latency low.
|
||||||
"""
|
"""
|
||||||
owner = _require_user(request)
|
_require_user(request)
|
||||||
from src.endpoint_resolver import resolve_endpoint
|
from src.endpoint_resolver import resolve_endpoint
|
||||||
from src.llm_core import llm_call_async
|
from src.llm_core import llm_call_async
|
||||||
from src.text_helpers import strip_think
|
from src.text_helpers import strip_think
|
||||||
@@ -1531,36 +1211,23 @@ def setup_calendar_routes(upload_handler=None) -> APIRouter:
|
|||||||
text = (body.get("text") or "").strip()
|
text = (body.get("text") or "").strip()
|
||||||
if not text:
|
if not text:
|
||||||
raise HTTPException(400, "text is required")
|
raise HTTPException(400, "text is required")
|
||||||
from src.user_time import (
|
|
||||||
clear_user_time_context,
|
|
||||||
current_datetime_prompt,
|
|
||||||
now_user_local,
|
|
||||||
set_user_tz_name,
|
|
||||||
set_user_tz_offset,
|
|
||||||
)
|
|
||||||
|
|
||||||
clear_user_time_context()
|
|
||||||
tz_hint = (body.get("tz") or "").strip()
|
tz_hint = (body.get("tz") or "").strip()
|
||||||
if body.get("tz_offset") is not None:
|
|
||||||
set_user_tz_offset(body.get("tz_offset"))
|
|
||||||
if tz_hint:
|
|
||||||
set_user_tz_name(tz_hint)
|
|
||||||
|
|
||||||
url, model, headers = resolve_endpoint("utility", owner=owner or None)
|
url, model, headers = resolve_endpoint("utility")
|
||||||
if not url:
|
if not url:
|
||||||
url, model, headers = resolve_endpoint("default", owner=owner or None)
|
url, model, headers = resolve_endpoint("default")
|
||||||
if not url or not model:
|
if not url or not model:
|
||||||
return {"ok": False, "error": "No LLM endpoint configured"}
|
return {"ok": False, "error": "No LLM endpoint configured"}
|
||||||
|
|
||||||
now = now_user_local()
|
now = datetime.now()
|
||||||
now_iso = now.strftime("%Y-%m-%dT%H:%M:%S")
|
now_iso = now.strftime("%Y-%m-%dT%H:%M:%S")
|
||||||
# The model gets only the schema it needs to fill out; we re-validate
|
# The model gets only the schema it needs to fill out; we re-validate
|
||||||
# everything client-side too.
|
# everything client-side too.
|
||||||
system_prompt = (
|
system_prompt = (
|
||||||
current_datetime_prompt()
|
"You are a calendar event parser. Read the user's one-line "
|
||||||
+ "You are a calendar event parser. Read the user's one-line "
|
|
||||||
"description and emit STRICT JSON describing the event. "
|
"description and emit STRICT JSON describing the event. "
|
||||||
f"The current user-local timestamp is {now_iso}. "
|
f"Today is {now.strftime('%A, %Y-%m-%d')} ({now_iso}). "
|
||||||
|
+ (f"User timezone: {tz_hint}. " if tz_hint else "")
|
||||||
+ "Resolve relative dates (\"tomorrow\", \"friday\", \"next monday\", "
|
+ "Resolve relative dates (\"tomorrow\", \"friday\", \"next monday\", "
|
||||||
"\"in 30 minutes\") against today. Default duration is 60 minutes "
|
"\"in 30 minutes\") against today. Default duration is 60 minutes "
|
||||||
"when no end time is given. If the text mentions a date with no "
|
"when no end time is given. If the text mentions a date with no "
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import time
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
@@ -15,94 +14,14 @@ from core.database import Session as DBSession, ModelEndpoint
|
|||||||
from src.llm_core import normalize_model_id
|
from src.llm_core import normalize_model_id
|
||||||
from src.endpoint_resolver import normalize_base
|
from src.endpoint_resolver import normalize_base
|
||||||
from src.context_compactor import maybe_compact, trim_for_context
|
from src.context_compactor import maybe_compact, trim_for_context
|
||||||
from src.model_context import estimate_tokens
|
from src.auth_helpers import get_current_user
|
||||||
from src.auth_helpers import effective_user
|
|
||||||
from src.prompt_security import untrusted_context_message
|
from src.prompt_security import untrusted_context_message
|
||||||
from src.attachment_refs import attachment_ref
|
|
||||||
from routes.prefs_routes import _load_for_user as load_prefs_for_user
|
from routes.prefs_routes import _load_for_user as load_prefs_for_user
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_CASUAL_OPENING_RE = re.compile(
|
|
||||||
r"^\s*(?:h+i+|hey+|hello+|yo+|sup+|what'?s up|wass?up|hiya|howdy|"
|
|
||||||
r"lol|lmao|haha+|hehe+|thanks?|thank you|ty|idk|dunno|meh|bruh|bro)\b(?P<tail>.*)$",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
_CASUAL_BLOCKLIST_RE = re.compile(
|
|
||||||
r"\b(?:cookbook|serve|serving|launch|start|vllm|sglang|llama\.?cpp|ollama|"
|
|
||||||
r"download|model|email|document|doc|note|calendar|task|search|web|research|"
|
|
||||||
r"file|folder|repo|git|settings?|endpoint|api|token|mcp)\b",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _is_casual_low_signal(text: str) -> bool:
|
|
||||||
"""Short greetings/slang should not pull memory, skills, RAG, or docs."""
|
|
||||||
s = str(text or "").strip()
|
|
||||||
m = _CASUAL_OPENING_RE.match(s)
|
|
||||||
if not m:
|
|
||||||
return False
|
|
||||||
tail = m.group("tail") or ""
|
|
||||||
if _CASUAL_BLOCKLIST_RE.search(tail):
|
|
||||||
return False
|
|
||||||
tail_words = re.findall(r"[A-Za-z0-9_'-]+", tail)
|
|
||||||
return len(tail_words) <= 2
|
|
||||||
|
|
||||||
|
|
||||||
# Strong references to in-flight fire-and-forget tasks scheduled from this
|
|
||||||
# module. asyncio only keeps weak references to tasks created via
|
|
||||||
# create_task, so without this the GC can collect a task mid-execution and
|
|
||||||
# the background work (extraction, auto-naming) silently never runs.
|
|
||||||
# Mirrors WebhookManager._spawn_tracked from src/webhook_manager.py.
|
|
||||||
_BG_TASKS: set[asyncio.Task] = set()
|
|
||||||
_INCOGNITO_CONTEXTS: dict[str, dict[str, Any]] = {}
|
|
||||||
_INCOGNITO_CONTEXT_TTL_SECONDS = 6 * 60 * 60
|
|
||||||
_INCOGNITO_CONTEXT_MAX_MESSAGES = 80
|
|
||||||
|
|
||||||
|
|
||||||
def _spawn_bg(coro) -> asyncio.Task:
|
|
||||||
"""Schedule a background task and hold a strong reference until it finishes."""
|
|
||||||
task = asyncio.create_task(coro)
|
|
||||||
_BG_TASKS.add(task)
|
|
||||||
task.add_done_callback(_BG_TASKS.discard)
|
|
||||||
return task
|
|
||||||
|
|
||||||
|
|
||||||
def _prune_incognito_contexts(now: float | None = None):
|
|
||||||
now = now or time.time()
|
|
||||||
stale = [
|
|
||||||
sid for sid, bundle in _INCOGNITO_CONTEXTS.items()
|
|
||||||
if now - float(bundle.get("updated_at") or 0) > _INCOGNITO_CONTEXT_TTL_SECONDS
|
|
||||||
]
|
|
||||||
for sid in stale:
|
|
||||||
_INCOGNITO_CONTEXTS.pop(sid, None)
|
|
||||||
|
|
||||||
|
|
||||||
def _incognito_messages(session_id: str) -> list[dict[str, Any]]:
|
|
||||||
_prune_incognito_contexts()
|
|
||||||
bundle = _INCOGNITO_CONTEXTS.get(str(session_id or ""))
|
|
||||||
if not bundle:
|
|
||||||
return []
|
|
||||||
return [dict(m) for m in bundle.get("messages", []) if isinstance(m, dict)]
|
|
||||||
|
|
||||||
|
|
||||||
def _append_incognito_message(session_id: str, role: str, content: Any, metadata: dict | None = None):
|
|
||||||
sid = str(session_id or "").strip()
|
|
||||||
if not sid:
|
|
||||||
return
|
|
||||||
_prune_incognito_contexts()
|
|
||||||
bundle = _INCOGNITO_CONTEXTS.setdefault(sid, {"messages": [], "updated_at": time.time()})
|
|
||||||
msg: dict[str, Any] = {"role": role, "content": content}
|
|
||||||
if metadata:
|
|
||||||
msg["metadata"] = dict(metadata)
|
|
||||||
messages = bundle.setdefault("messages", [])
|
|
||||||
messages.append(msg)
|
|
||||||
if len(messages) > _INCOGNITO_CONTEXT_MAX_MESSAGES:
|
|
||||||
del messages[:-_INCOGNITO_CONTEXT_MAX_MESSAGES]
|
|
||||||
bundle["updated_at"] = time.time()
|
|
||||||
|
|
||||||
|
|
||||||
# ── Data containers ────────────────────────────────────────────────────── #
|
# ── Data containers ────────────────────────────────────────────────────── #
|
||||||
|
|
||||||
@@ -139,19 +58,11 @@ class ChatContext:
|
|||||||
uprefs: dict
|
uprefs: dict
|
||||||
preset: PresetInfo
|
preset: PresetInfo
|
||||||
preprocessed: PreprocessedMessage
|
preprocessed: PreprocessedMessage
|
||||||
context_trimmed: bool = False
|
|
||||||
context_messages_before_trim: int = 0
|
|
||||||
context_messages_after_trim: int = 0
|
|
||||||
context_tokens_before_trim: int = 0
|
|
||||||
context_tokens_after_trim: int = 0
|
|
||||||
# Documents auto-created server-side during preprocess (e.g. when an
|
# Documents auto-created server-side during preprocess (e.g. when an
|
||||||
# attached fillable PDF gets rendered into a markdown editor doc).
|
# attached fillable PDF gets rendered into a markdown editor doc).
|
||||||
# The chat route emits a doc_update SSE event for each before streaming
|
# The chat route emits a doc_update SSE event for each before streaming
|
||||||
# begins, so the editor pane switches to the new doc immediately.
|
# begins, so the editor pane switches to the new doc immediately.
|
||||||
auto_opened_docs: list = field(default_factory=list)
|
auto_opened_docs: list = field(default_factory=list)
|
||||||
# Uploads attached to this user turn, resolved and owner-checked for the
|
|
||||||
# agent's private context. This is not emitted to the browser.
|
|
||||||
uploaded_files: list = field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Helpers ────────────────────────────────────────────────────────────── #
|
# ── Helpers ────────────────────────────────────────────────────────────── #
|
||||||
@@ -164,10 +75,10 @@ def _enforce_chat_privileges(request, sess) -> None:
|
|||||||
allowlist, or HTTPException(429) if the user has hit their daily message
|
allowlist, or HTTPException(429) if the user has hit their daily message
|
||||||
cap. No-op for unauthenticated callers or when auth_manager is absent
|
cap. No-op for unauthenticated callers or when auth_manager is absent
|
||||||
(single-user mode). Admins receive ADMIN_PRIVILEGES from get_privileges,
|
(single-user mode). Admins receive ADMIN_PRIVILEGES from get_privileges,
|
||||||
which means unrestricted allowed_models / zero cap -> no-op for them.
|
which means empty allowed_models / zero cap → no-op for them.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
user = effective_user(request)
|
user = get_current_user(request)
|
||||||
except Exception:
|
except Exception:
|
||||||
user = None
|
user = None
|
||||||
if not user:
|
if not user:
|
||||||
@@ -177,18 +88,8 @@ def _enforce_chat_privileges(request, sess) -> None:
|
|||||||
return
|
return
|
||||||
|
|
||||||
privs = auth_manager.get_privileges(user) or {}
|
privs = auth_manager.get_privileges(user) or {}
|
||||||
|
allowed = privs.get("allowed_models") or []
|
||||||
# Explicit "block everything" sentinel takes precedence over the
|
if allowed and sess.model and sess.model not in allowed:
|
||||||
# allowlist — it's the only way to distinguish "user clicked [None]"
|
|
||||||
# (block all) from "user clicked [All]" (no restriction), since both
|
|
||||||
# otherwise produce an empty `allowed_models` list.
|
|
||||||
if privs.get("block_all_models"):
|
|
||||||
raise HTTPException(403, f"Your account is not allowed to use model '{sess.model}'.")
|
|
||||||
|
|
||||||
allowed_raw = privs.get("allowed_models")
|
|
||||||
allowed = allowed_raw if isinstance(allowed_raw, list) else []
|
|
||||||
restricted = bool(privs.get("allowed_models_restricted")) or bool(allowed)
|
|
||||||
if restricted and sess.model and sess.model not in allowed:
|
|
||||||
raise HTTPException(403, f"Your account is not allowed to use model '{sess.model}'.")
|
raise HTTPException(403, f"Your account is not allowed to use model '{sess.model}'.")
|
||||||
|
|
||||||
cap = int(privs.get("max_messages_per_day") or 0)
|
cap = int(privs.get("max_messages_per_day") or 0)
|
||||||
@@ -249,7 +150,7 @@ async def auto_name_session(session_manager, sess):
|
|||||||
|
|
||||||
owner = getattr(sess, "owner", None)
|
owner = getattr(sess, "owner", None)
|
||||||
t_url, t_model, t_headers = resolve_task_endpoint(
|
t_url, t_model, t_headers = resolve_task_endpoint(
|
||||||
sess.endpoint_url, sess.model, sess.headers, owner=owner
|
sess.endpoint_url, sess.model, sess.headers, owner=owner,
|
||||||
)
|
)
|
||||||
if not t_model:
|
if not t_model:
|
||||||
logger.debug("[auto-name] No model provided, skipping")
|
logger.debug("[auto-name] No model provided, skipping")
|
||||||
@@ -293,26 +194,14 @@ def try_fallback_endpoint(sess, session_id: str) -> dict | None:
|
|||||||
Returns {"model": ..., "endpoint_url": ..., "endpoint_name": ...} or None.
|
Returns {"model": ..., "endpoint_url": ..., "endpoint_name": ...} or None.
|
||||||
"""
|
"""
|
||||||
import requests as _req
|
import requests as _req
|
||||||
from src.endpoint_resolver import (
|
from src.endpoint_resolver import build_chat_url, build_headers, build_models_url, normalize_base
|
||||||
build_chat_url,
|
|
||||||
build_headers,
|
|
||||||
build_models_url,
|
|
||||||
normalize_base,
|
|
||||||
resolve_endpoint_runtime,
|
|
||||||
)
|
|
||||||
from src.chatgpt_subscription import is_chatgpt_subscription_base
|
|
||||||
|
|
||||||
current_url = sess.endpoint_url or ""
|
current_url = sess.endpoint_url or ""
|
||||||
owner = getattr(sess, "owner", None)
|
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
q = db.query(ModelEndpoint).filter(
|
endpoints = db.query(ModelEndpoint).filter(
|
||||||
ModelEndpoint.is_enabled == True
|
ModelEndpoint.is_enabled == True
|
||||||
)
|
).all()
|
||||||
if owner:
|
|
||||||
from src.auth_helpers import owner_filter
|
|
||||||
q = owner_filter(q, ModelEndpoint, owner)
|
|
||||||
endpoints = q.all()
|
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
@@ -321,33 +210,26 @@ def try_fallback_endpoint(sess, session_id: str) -> dict | None:
|
|||||||
# Skip current endpoint
|
# Skip current endpoint
|
||||||
if current_url and base in current_url:
|
if current_url and base in current_url:
|
||||||
continue
|
continue
|
||||||
try:
|
# Quick ping
|
||||||
base, api_key = resolve_endpoint_runtime(ep, owner=owner)
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
ping_url = build_models_url(base)
|
ping_url = build_models_url(base)
|
||||||
headers = build_headers(api_key, base)
|
headers = build_headers(ep.api_key, base)
|
||||||
try:
|
try:
|
||||||
if ping_url:
|
r = _req.get(ping_url, headers=headers, timeout=5)
|
||||||
r = _req.get(ping_url, headers=headers, timeout=5)
|
r.raise_for_status()
|
||||||
r.raise_for_status()
|
data = r.json()
|
||||||
data = r.json()
|
models = [m.get("id") for m in (data.get("data") or []) if m.get("id")]
|
||||||
models = [m.get("id") for m in (data.get("data") or []) if m.get("id")]
|
if not models:
|
||||||
if not models:
|
models = [
|
||||||
models = [
|
m.get("name") or m.get("model")
|
||||||
m.get("name") or m.get("model")
|
for m in (data.get("models") or [])
|
||||||
for m in (data.get("models") or [])
|
if m.get("name") or m.get("model")
|
||||||
if m.get("name") or m.get("model")
|
]
|
||||||
]
|
|
||||||
else:
|
|
||||||
models = json.loads(ep.cached_models or "[]")
|
|
||||||
if not models:
|
if not models:
|
||||||
continue
|
continue
|
||||||
# Found a working endpoint — update session
|
# Found a working endpoint — update session
|
||||||
new_model = models[0]
|
new_model = models[0]
|
||||||
chat_url = build_chat_url(base)
|
chat_url = build_chat_url(base)
|
||||||
new_headers = build_headers(api_key, base)
|
new_headers = build_headers(ep.api_key, base)
|
||||||
persisted_headers = {} if is_chatgpt_subscription_base(base) else new_headers
|
|
||||||
|
|
||||||
sess.model = new_model
|
sess.model = new_model
|
||||||
sess.endpoint_url = chat_url
|
sess.endpoint_url = chat_url
|
||||||
@@ -359,7 +241,7 @@ def try_fallback_endpoint(sess, session_id: str) -> dict | None:
|
|||||||
_db.query(DBSession).filter(DBSession.id == session_id).update({
|
_db.query(DBSession).filter(DBSession.id == session_id).update({
|
||||||
"model": new_model,
|
"model": new_model,
|
||||||
"endpoint_url": chat_url,
|
"endpoint_url": chat_url,
|
||||||
"headers": persisted_headers,
|
"headers": json.dumps(new_headers),
|
||||||
})
|
})
|
||||||
_db.commit()
|
_db.commit()
|
||||||
finally:
|
finally:
|
||||||
@@ -393,16 +275,11 @@ def extract_preset(chat_handler, preset_id) -> PresetInfo:
|
|||||||
async def preprocess(
|
async def preprocess(
|
||||||
chat_handler, message, att_ids, sess,
|
chat_handler, message, att_ids, sess,
|
||||||
auto_opened_docs: Optional[list] = None,
|
auto_opened_docs: Optional[list] = None,
|
||||||
allow_tool_preprocessing: bool = True,
|
|
||||||
) -> PreprocessedMessage:
|
) -> PreprocessedMessage:
|
||||||
"""Run chat_handler.preprocess_message and wrap the result."""
|
"""Run chat_handler.preprocess_message and wrap the result."""
|
||||||
enhanced, user_content, text_ctx, yt_transcripts, att_meta = (
|
enhanced, user_content, text_ctx, yt_transcripts, att_meta = (
|
||||||
await chat_handler.preprocess_message(
|
await chat_handler.preprocess_message(
|
||||||
message,
|
message, att_ids, sess, auto_opened_docs=auto_opened_docs
|
||||||
att_ids,
|
|
||||||
sess,
|
|
||||||
auto_opened_docs=auto_opened_docs,
|
|
||||||
allow_tool_preprocessing=allow_tool_preprocessing,
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return PreprocessedMessage(
|
return PreprocessedMessage(
|
||||||
@@ -414,81 +291,24 @@ async def preprocess(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def build_uploaded_file_manifest(att_ids: list, upload_handler, owner: Optional[str]) -> list[dict]:
|
|
||||||
"""Resolve current-turn upload IDs into a small tool-facing manifest.
|
|
||||||
|
|
||||||
The chat UI already sends attachment ids, and preprocessing inlines as much
|
|
||||||
text as fits. Agent mode still needs a discoverable bridge for files whose
|
|
||||||
content was truncated/omitted or when the model chooses file tools. Only
|
|
||||||
owner-authorized uploads are included, and paths must remain inside the
|
|
||||||
configured upload directory.
|
|
||||||
"""
|
|
||||||
if not att_ids or not upload_handler or not hasattr(upload_handler, "resolve_upload"):
|
|
||||||
return []
|
|
||||||
|
|
||||||
def _read_file_can_open(path: str) -> bool:
|
|
||||||
try:
|
|
||||||
from src.tool_execution import _resolve_tool_path
|
|
||||||
|
|
||||||
return _resolve_tool_path(path) == os.path.realpath(path)
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
manifest: list[dict] = []
|
|
||||||
for att_id in att_ids:
|
|
||||||
try:
|
|
||||||
info = upload_handler.resolve_upload(str(att_id), owner=owner)
|
|
||||||
except Exception:
|
|
||||||
logger.debug("Failed to resolve upload %r for agent manifest", att_id, exc_info=True)
|
|
||||||
continue
|
|
||||||
if not isinstance(info, dict):
|
|
||||||
continue
|
|
||||||
|
|
||||||
path = info.get("path")
|
|
||||||
if path:
|
|
||||||
try:
|
|
||||||
inside = True
|
|
||||||
if hasattr(upload_handler, "_inside_upload_dir"):
|
|
||||||
inside = bool(upload_handler._inside_upload_dir(path))
|
|
||||||
elif hasattr(upload_handler, "inside_base_dir"):
|
|
||||||
inside = bool(upload_handler.inside_base_dir(path))
|
|
||||||
if not inside or not os.path.exists(path) or not _read_file_can_open(path):
|
|
||||||
path = None
|
|
||||||
except Exception:
|
|
||||||
path = None
|
|
||||||
|
|
||||||
ref = attachment_ref({**info, "id": info.get("id") or str(att_id)})
|
|
||||||
ref.update({
|
|
||||||
"id": ref["attachment_id"],
|
|
||||||
"uri": f"odysseus://attachment/{ref['attachment_id']}",
|
|
||||||
"read_policy": "owner_checked_upload",
|
|
||||||
# Transitional compatibility: existing built-in tools can still use
|
|
||||||
# this path, but only after owner, upload-root, and tool-root checks.
|
|
||||||
"path": path,
|
|
||||||
})
|
|
||||||
manifest.append(ref)
|
|
||||||
return manifest
|
|
||||||
|
|
||||||
|
|
||||||
def add_user_message(sess, chat_handler, preprocessed: PreprocessedMessage, incognito: bool = False):
|
def add_user_message(sess, chat_handler, preprocessed: PreprocessedMessage, incognito: bool = False):
|
||||||
"""Add user message to session history and update session name.
|
"""Add user message to session history and update session name.
|
||||||
Incognito messages must not mutate persistent session history, even in
|
In incognito mode, still add to in-memory history (for conversation context)
|
||||||
memory, because a later normal turn can persist the same session object."""
|
but skip session name update (which would persist)."""
|
||||||
if incognito:
|
|
||||||
return
|
|
||||||
user_meta = {"attachments": preprocessed.attachment_meta} if preprocessed.attachment_meta else None
|
user_meta = {"attachments": preprocessed.attachment_meta} if preprocessed.attachment_meta else None
|
||||||
sess.add_message(ChatMessage("user", preprocessed.user_content, metadata=user_meta))
|
sess.add_message(ChatMessage("user", preprocessed.user_content, metadata=user_meta))
|
||||||
chat_handler.update_session_name_if_needed(sess, preprocessed.text_for_context)
|
if not incognito:
|
||||||
|
chat_handler.update_session_name_if_needed(sess, preprocessed.text_for_context)
|
||||||
|
|
||||||
|
|
||||||
def fire_message_event(request, webhook_manager, session_id: str, sess, message: str, compare_mode: bool = False):
|
def fire_message_event(request, webhook_manager, session_id: str, sess, message: str, compare_mode: bool = False):
|
||||||
"""Fire webhook and event_bus events for a new user message."""
|
"""Fire webhook and event_bus events for a new user message."""
|
||||||
if webhook_manager and not compare_mode:
|
if webhook_manager and not compare_mode:
|
||||||
webhook_manager.fire_and_forget("chat.message", {
|
asyncio.create_task(webhook_manager.fire("chat.message", {
|
||||||
"session_id": session_id, "model": sess.model, "message": message[:2000],
|
"session_id": session_id, "model": sess.model, "message": message[:2000],
|
||||||
})
|
}))
|
||||||
from src.event_bus import fire_event
|
from src.event_bus import fire_event
|
||||||
user = effective_user(request)
|
user = get_current_user(request)
|
||||||
fire_event("message_sent", user)
|
fire_event("message_sent", user)
|
||||||
|
|
||||||
|
|
||||||
@@ -509,26 +329,16 @@ def _session_url_matches_endpoint(session_url: str, endpoint_base: str) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _has_auth_keys(headers) -> bool:
|
|
||||||
"""True if a headers dict carries an Authorization/x-api-key entry."""
|
|
||||||
return isinstance(headers, dict) and any(
|
|
||||||
k.lower() in ('authorization', 'x-api-key') for k in headers
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_session_auth(sess, session_id: str, owner: Optional[str] = None):
|
def resolve_session_auth(sess, session_id: str, owner: Optional[str] = None):
|
||||||
"""Ensure session has auth headers — resolve from endpoint DB if missing."""
|
"""Ensure session has auth headers — resolve from endpoint DB if missing."""
|
||||||
try:
|
has_auth = sess.headers and isinstance(sess.headers, dict) and any(
|
||||||
from src.chatgpt_subscription import is_chatgpt_subscription_base
|
k.lower() in ('authorization', 'x-api-key') for k in sess.headers
|
||||||
is_chatgpt_subscription = is_chatgpt_subscription_base(getattr(sess, "endpoint_url", "") or "")
|
)
|
||||||
except Exception:
|
if has_auth:
|
||||||
is_chatgpt_subscription = False
|
|
||||||
has_auth = _has_auth_keys(sess.headers)
|
|
||||||
if has_auth and not is_chatgpt_subscription:
|
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from src.endpoint_resolver import build_headers, resolve_endpoint_runtime
|
from src.endpoint_resolver import build_headers, normalize_base
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
target_url = getattr(sess, "endpoint_url", "") or ""
|
target_url = getattr(sess, "endpoint_url", "") or ""
|
||||||
@@ -544,30 +354,10 @@ def resolve_session_auth(sess, session_id: str, owner: Optional[str] = None):
|
|||||||
for ep in q.all():
|
for ep in q.all():
|
||||||
if not _session_url_matches_endpoint(target_url, ep.base_url or ""):
|
if not _session_url_matches_endpoint(target_url, ep.base_url or ""):
|
||||||
continue
|
continue
|
||||||
try:
|
if not ep.api_key:
|
||||||
base, api_key = resolve_endpoint_runtime(ep, owner=owner)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("Failed to resolve provider auth for session %s: %s", session_id, e)
|
|
||||||
return
|
|
||||||
if not api_key:
|
|
||||||
# No usable key (e.g. ChatGPT Subscription needs re-auth).
|
|
||||||
return
|
|
||||||
sess.headers = build_headers(api_key, base)
|
|
||||||
if is_chatgpt_subscription:
|
|
||||||
# The bearer is short-lived and re-resolved per request, so it
|
|
||||||
# stays request-local and is never written to the plaintext
|
|
||||||
# sessions.headers column. Proactively strip any bearer an
|
|
||||||
# older code path may have persisted so it does not linger.
|
|
||||||
stale_q = db.query(DBSession).filter(DBSession.id == session_id)
|
|
||||||
if owner:
|
|
||||||
stale_q = stale_q.filter(DBSession.owner == owner)
|
|
||||||
stored = stale_q.first()
|
|
||||||
if stored is not None and _has_auth_keys(stored.headers):
|
|
||||||
stale_q.update({"headers": {}})
|
|
||||||
db.commit()
|
|
||||||
logger.info(f"Cleared persisted ChatGPT Subscription bearer from session {session_id}")
|
|
||||||
logger.debug(f"Resolved request-local ChatGPT Subscription auth for session {session_id}")
|
|
||||||
return
|
return
|
||||||
|
base = normalize_base(ep.base_url or "")
|
||||||
|
sess.headers = build_headers(ep.api_key, base)
|
||||||
update_q = db.query(DBSession).filter(DBSession.id == session_id)
|
update_q = db.query(DBSession).filter(DBSession.id == session_id)
|
||||||
if owner:
|
if owner:
|
||||||
update_q = update_q.filter(DBSession.owner == owner)
|
update_q = update_q.filter(DBSession.owner == owner)
|
||||||
@@ -611,12 +401,7 @@ def _normalize_model_id_from_cache(sess) -> Optional[str]:
|
|||||||
|
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
q = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True)
|
endpoints = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True).all()
|
||||||
owner = getattr(sess, "owner", None)
|
|
||||||
if owner:
|
|
||||||
from src.auth_helpers import owner_filter
|
|
||||||
q = owner_filter(q, ModelEndpoint, owner)
|
|
||||||
endpoints = q.all()
|
|
||||||
for ep in endpoints:
|
for ep in endpoints:
|
||||||
try:
|
try:
|
||||||
if normalize_base(getattr(ep, "base_url", "") or "") != session_base:
|
if normalize_base(getattr(ep, "base_url", "") or "") != session_base:
|
||||||
@@ -643,29 +428,6 @@ def _normalize_model_id_from_cache(sess) -> Optional[str]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _session_is_research_spinoff(sess) -> bool:
|
|
||||||
"""True if this session was created via research "Discuss" spin-off.
|
|
||||||
|
|
||||||
Detected by the primer system message the spin-off endpoint seeds into
|
|
||||||
history (metadata ``research_spinoff_from``). Such sessions are grounded
|
|
||||||
on the seeded report, so global memory + personal-doc RAG injection is
|
|
||||||
suppressed for them (the report is the sole knowledge base). Handles both
|
|
||||||
ChatMessage objects and plain dicts.
|
|
||||||
"""
|
|
||||||
for m in getattr(sess, "history", []) or []:
|
|
||||||
role = getattr(m, "role", None)
|
|
||||||
if role is None and isinstance(m, dict):
|
|
||||||
role = m.get("role")
|
|
||||||
if role != "system":
|
|
||||||
continue
|
|
||||||
md = getattr(m, "metadata", None)
|
|
||||||
if md is None and isinstance(m, dict):
|
|
||||||
md = m.get("metadata")
|
|
||||||
if (md or {}).get("research_spinoff_from"):
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
async def build_chat_context(
|
async def build_chat_context(
|
||||||
sess,
|
sess,
|
||||||
request,
|
request,
|
||||||
@@ -686,7 +448,6 @@ async def build_chat_context(
|
|||||||
webhook_manager=None,
|
webhook_manager=None,
|
||||||
use_enhanced_message: bool = False,
|
use_enhanced_message: bool = False,
|
||||||
agent_mode: bool = False,
|
agent_mode: bool = False,
|
||||||
allow_tool_preprocessing: bool = True,
|
|
||||||
) -> ChatContext:
|
) -> ChatContext:
|
||||||
"""Build the full context (preface + messages) for an LLM call.
|
"""Build the full context (preface + messages) for an LLM call.
|
||||||
|
|
||||||
@@ -704,64 +465,36 @@ async def build_chat_context(
|
|||||||
preprocessed = await preprocess(
|
preprocessed = await preprocess(
|
||||||
chat_handler, message, att_ids or [], sess,
|
chat_handler, message, att_ids or [], sess,
|
||||||
auto_opened_docs=auto_opened_docs,
|
auto_opened_docs=auto_opened_docs,
|
||||||
allow_tool_preprocessing=allow_tool_preprocessing,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add user message to history. Nobody/incognito uses a request-local
|
# Add user message to history
|
||||||
# transcript store instead of session history so stale saved chats cannot
|
add_user_message(sess, chat_handler, preprocessed, incognito=incognito)
|
||||||
# bleed into context and the turn is not persisted.
|
|
||||||
if incognito:
|
|
||||||
user_meta = {"attachments": preprocessed.attachment_meta} if preprocessed.attachment_meta else None
|
|
||||||
_append_incognito_message(session_id, "user", preprocessed.user_content, user_meta)
|
|
||||||
else:
|
|
||||||
add_user_message(sess, chat_handler, preprocessed, incognito=False)
|
|
||||||
|
|
||||||
# Fire events
|
# Fire events
|
||||||
if not incognito:
|
if not incognito:
|
||||||
fire_message_event(request, webhook_manager, session_id, sess, message, compare_mode)
|
fire_message_event(request, webhook_manager, session_id, sess, message, compare_mode)
|
||||||
|
|
||||||
# Resolve owner-scoped prefs/context. Browser requests keep the cookie user;
|
# Resolve user prefs
|
||||||
# bearer-token chat requests use the token owner instead of the "api" sentinel.
|
user = get_current_user(request)
|
||||||
user = effective_user(request)
|
|
||||||
uprefs = load_prefs_for_user(user)
|
uprefs = load_prefs_for_user(user)
|
||||||
uploaded_files = build_uploaded_file_manifest(
|
|
||||||
att_ids or [],
|
|
||||||
getattr(chat_handler, "upload_handler", None),
|
|
||||||
getattr(sess, "owner", None),
|
|
||||||
)
|
|
||||||
casual_low_signal = _is_casual_low_signal(message)
|
|
||||||
|
|
||||||
# Memory enabled?
|
# Memory enabled?
|
||||||
mem_enabled = not incognito and not no_memory and uprefs.get("memory_enabled", True)
|
mem_enabled = not incognito and not no_memory and uprefs.get("memory_enabled", True)
|
||||||
# Skills injection respects its own enable toggle (mirrors memory_enabled).
|
# Skills injection respects its own enable toggle (mirrors memory_enabled).
|
||||||
# When off, the "Available skills" index is not added to the prompt.
|
# When off, the "Available skills" index is not added to the prompt.
|
||||||
skills_enabled = not incognito and uprefs.get("skills_enabled", True)
|
skills_enabled = not incognito and uprefs.get("skills_enabled", True)
|
||||||
if not allow_tool_preprocessing:
|
|
||||||
mem_enabled = False
|
|
||||||
skills_enabled = False
|
|
||||||
if casual_low_signal:
|
|
||||||
mem_enabled = False
|
|
||||||
skills_enabled = False
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"Memory enabled=%s for user=%s (incognito=%s, no_memory=%s, pref=%s)",
|
"Memory enabled=%s for user=%s (incognito=%s, no_memory=%s, pref=%s)",
|
||||||
mem_enabled, user, incognito, no_memory, uprefs.get("memory_enabled", "NOT_SET"),
|
mem_enabled, user, incognito, no_memory, uprefs.get("memory_enabled", "NOT_SET"),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Research-spinoff ("Discuss") sessions are grounded on the seeded report:
|
|
||||||
# the primer system message IS the knowledge base. Injecting global memory
|
|
||||||
# or personal-doc RAG on every turn pulls in keyword-matched but off-topic
|
|
||||||
# facts ("wrong data") and competes with the report, so suppress both here.
|
|
||||||
is_research_spinoff = _session_is_research_spinoff(sess)
|
|
||||||
if is_research_spinoff:
|
|
||||||
mem_enabled = False
|
|
||||||
|
|
||||||
# Use RAG?
|
# Use RAG?
|
||||||
use_rag_val = (str(use_rag).lower() != "false") if use_rag is not None else True
|
use_rag_val = (str(use_rag).lower() != "false") if use_rag is not None else True
|
||||||
if incognito or not allow_tool_preprocessing or is_research_spinoff or casual_low_signal:
|
if incognito:
|
||||||
use_rag_val = False
|
use_rag_val = False
|
||||||
|
|
||||||
# If pre-fetched search context was provided (compare mode), skip live web search
|
# If pre-fetched search context was provided (compare mode), skip live web search
|
||||||
skip_web = bool(search_context) or not allow_tool_preprocessing or casual_low_signal
|
skip_web = bool(search_context)
|
||||||
|
|
||||||
# Build context preface
|
# Build context preface
|
||||||
# The stream path uses enhanced_message (with CoT/preprocessing applied),
|
# The stream path uses enhanced_message (with CoT/preprocessing applied),
|
||||||
@@ -780,7 +513,7 @@ async def build_chat_context(
|
|||||||
incognito=incognito,
|
incognito=incognito,
|
||||||
use_skills=skills_enabled,
|
use_skills=skills_enabled,
|
||||||
)
|
)
|
||||||
if use_rag is not None or is_research_spinoff or casual_low_signal:
|
if use_rag is not None:
|
||||||
_preface_kwargs["use_rag"] = use_rag_val
|
_preface_kwargs["use_rag"] = use_rag_val
|
||||||
preface, rag_sources, web_sources = chat_processor.build_context_preface(**_preface_kwargs)
|
preface, rag_sources, web_sources = chat_processor.build_context_preface(**_preface_kwargs)
|
||||||
|
|
||||||
@@ -788,7 +521,7 @@ async def build_chat_context(
|
|||||||
used_memories = getattr(chat_processor, '_last_used_memories', [])
|
used_memories = getattr(chat_processor, '_last_used_memories', [])
|
||||||
|
|
||||||
# Inject pre-fetched search context (compare mode)
|
# Inject pre-fetched search context (compare mode)
|
||||||
if search_context and allow_tool_preprocessing and not casual_low_signal:
|
if search_context:
|
||||||
preface.append(untrusted_context_message("prefetched search context", search_context))
|
preface.append(untrusted_context_message("prefetched search context", search_context))
|
||||||
|
|
||||||
# YouTube transcripts
|
# YouTube transcripts
|
||||||
@@ -797,49 +530,18 @@ async def build_chat_context(
|
|||||||
|
|
||||||
# Normalize model ID. Prefer cached endpoint models so group chat does not
|
# Normalize model ID. Prefer cached endpoint models so group chat does not
|
||||||
# re-hit slow local /models endpoints on every participant turn.
|
# re-hit slow local /models endpoints on every participant turn.
|
||||||
norm = _normalize_model_id_from_cache(sess) or normalize_model_id(
|
norm = _normalize_model_id_from_cache(sess) or normalize_model_id(sess.endpoint_url, sess.model)
|
||||||
sess.endpoint_url,
|
|
||||||
sess.model,
|
|
||||||
owner=getattr(sess, "owner", None),
|
|
||||||
)
|
|
||||||
if norm:
|
if norm:
|
||||||
sess.model = norm
|
sess.model = norm
|
||||||
|
|
||||||
# Build messages. In Nobody/incognito mode, never read saved session
|
# Build messages
|
||||||
# history: the session id may be a temporary wrapper or, in buggy clients, a
|
messages = preface + sess.get_context_messages()
|
||||||
# stale normal session id. Only the ephemeral incognito transcript is safe.
|
|
||||||
messages = preface + (_incognito_messages(session_id) if incognito else sess.get_context_messages())
|
|
||||||
|
|
||||||
# Current date/time — injected as a standalone *user*-role context message
|
|
||||||
# placed immediately before the latest user turn, NOT folded into the
|
|
||||||
# system prompt. Its text changes every minute, and local OpenAI-compatible
|
|
||||||
# backends (llama.cpp / LM Studio) key their KV-cache prefix off the
|
|
||||||
# system message byte-for-byte; mixing ever-changing timestamp text into
|
|
||||||
# it would invalidate the cached prefix on every request (issue #2927).
|
|
||||||
# Placing it at the tail also keeps it out of the stable
|
|
||||||
# preface+history prefix, so that prefix stays byte-identical turn over
|
|
||||||
# turn (modulo the genuinely new history entries) and the cache survives.
|
|
||||||
if not agent_mode:
|
|
||||||
try:
|
|
||||||
from src.user_time import current_datetime_context_message
|
|
||||||
_dt_msg = current_datetime_context_message()
|
|
||||||
if messages and messages[-1].get("role") == "user":
|
|
||||||
messages.insert(len(messages) - 1, _dt_msg)
|
|
||||||
else:
|
|
||||||
messages.append(_dt_msg)
|
|
||||||
except Exception:
|
|
||||||
logger.debug("Failed to add current date/time context", exc_info=True)
|
|
||||||
|
|
||||||
# Auto-compact
|
# Auto-compact
|
||||||
messages, context_length, was_compacted = await maybe_compact(
|
messages, context_length, was_compacted = await maybe_compact(
|
||||||
sess, sess.endpoint_url, sess.model, messages, sess.headers, owner=user,
|
sess, sess.endpoint_url, sess.model, messages, sess.headers,
|
||||||
)
|
)
|
||||||
_before_trim_messages = len(messages)
|
|
||||||
_before_trim_tokens = estimate_tokens(messages)
|
|
||||||
messages = trim_for_context(messages, context_length)
|
messages = trim_for_context(messages, context_length)
|
||||||
_after_trim_messages = len(messages)
|
|
||||||
_after_trim_tokens = estimate_tokens(messages)
|
|
||||||
_context_trimmed = _after_trim_messages < _before_trim_messages or _after_trim_tokens < _before_trim_tokens
|
|
||||||
|
|
||||||
return ChatContext(
|
return ChatContext(
|
||||||
preface=preface,
|
preface=preface,
|
||||||
@@ -853,13 +555,7 @@ async def build_chat_context(
|
|||||||
uprefs=uprefs,
|
uprefs=uprefs,
|
||||||
preset=preset,
|
preset=preset,
|
||||||
preprocessed=preprocessed,
|
preprocessed=preprocessed,
|
||||||
context_trimmed=_context_trimmed,
|
|
||||||
context_messages_before_trim=_before_trim_messages,
|
|
||||||
context_messages_after_trim=_after_trim_messages,
|
|
||||||
context_tokens_before_trim=_before_trim_tokens,
|
|
||||||
context_tokens_after_trim=_after_trim_tokens,
|
|
||||||
auto_opened_docs=auto_opened_docs,
|
auto_opened_docs=auto_opened_docs,
|
||||||
uploaded_files=uploaded_files,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -893,8 +589,6 @@ def _normalize_thinking(text: str) -> str:
|
|||||||
import re
|
import re
|
||||||
if not text:
|
if not text:
|
||||||
return text
|
return text
|
||||||
from src.text_helpers import normalize_thinking_markup
|
|
||||||
text = normalize_thinking_markup(text)
|
|
||||||
reasoning_prefix_re = re.compile(
|
reasoning_prefix_re = re.compile(
|
||||||
r'^\s*(?:thinking(?:\s+process)?\s*:|the user |i need |i should |i will |they are |the question |i can )',
|
r'^\s*(?:thinking(?:\s+process)?\s*:|the user |i need |i should |i will |they are |the question |i can )',
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
@@ -1005,10 +699,6 @@ def _extract_thinking_meta(text: str) -> dict | None:
|
|||||||
import re
|
import re
|
||||||
if not text:
|
if not text:
|
||||||
return None
|
return None
|
||||||
from src.text_helpers import normalize_thinking_markup
|
|
||||||
original_text = text
|
|
||||||
text = normalize_thinking_markup(text)
|
|
||||||
normalized_changed = text != original_text
|
|
||||||
|
|
||||||
# Check for <think> tags (native or injected)
|
# Check for <think> tags (native or injected)
|
||||||
time_match = re.search(r'<think(?:ing)?\s+time="([\d.]+)"', text)
|
time_match = re.search(r'<think(?:ing)?\s+time="([\d.]+)"', text)
|
||||||
@@ -1039,9 +729,6 @@ def _extract_thinking_meta(text: str) -> dict | None:
|
|||||||
if thinking and reply:
|
if thinking and reply:
|
||||||
return {"thinking": thinking, "reply": reply, "time": think_time}
|
return {"thinking": thinking, "reply": reply, "time": think_time}
|
||||||
|
|
||||||
if normalized_changed and text.strip() and text.strip() != original_text.strip():
|
|
||||||
return {"thinking": "", "reply": text.strip(), "time": think_time}
|
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -1050,8 +737,7 @@ def clean_thinking_for_save(content: str, metadata: dict | None = None) -> tuple
|
|||||||
md = dict(metadata) if metadata else {}
|
md = dict(metadata) if metadata else {}
|
||||||
info = _extract_thinking_meta(content)
|
info = _extract_thinking_meta(content)
|
||||||
if info:
|
if info:
|
||||||
if info.get("thinking"):
|
md["thinking"] = info["thinking"]
|
||||||
md["thinking"] = info["thinking"]
|
|
||||||
if info.get("time"):
|
if info.get("time"):
|
||||||
md["thinking_time"] = info["time"]
|
md["thinking_time"] = info["time"]
|
||||||
return info["reply"], md
|
return info["reply"], md
|
||||||
@@ -1074,26 +760,9 @@ def save_assistant_response(
|
|||||||
tool_events: list = None,
|
tool_events: list = None,
|
||||||
incognito: bool = False,
|
incognito: bool = False,
|
||||||
):
|
):
|
||||||
"""Add assistant response to session history.
|
"""Add assistant response to session history. In incognito mode, keeps in-memory context but skips DB persistence."""
|
||||||
|
|
||||||
Incognito responses are intentionally not added to the session object. The
|
|
||||||
session may later be saved by a normal turn, so "in-memory only" is not
|
|
||||||
private enough.
|
|
||||||
"""
|
|
||||||
md = dict(last_metrics) if last_metrics else {}
|
md = dict(last_metrics) if last_metrics else {}
|
||||||
def _model_value(value) -> str:
|
md["model"] = sess.model
|
||||||
if value is None:
|
|
||||||
return ""
|
|
||||||
if not isinstance(value, str):
|
|
||||||
value = str(value)
|
|
||||||
return value.strip()
|
|
||||||
|
|
||||||
requested_model = _model_value(md.get("requested_model") or md.get("selected_model") or getattr(sess, "model", ""))
|
|
||||||
actual_model = _model_value(md.get("model") or md.get("actual_model") or requested_model)
|
|
||||||
if requested_model:
|
|
||||||
md["requested_model"] = requested_model
|
|
||||||
if actual_model:
|
|
||||||
md["model"] = actual_model
|
|
||||||
if character_name:
|
if character_name:
|
||||||
md["character_name"] = character_name
|
md["character_name"] = character_name
|
||||||
if web_sources:
|
if web_sources:
|
||||||
@@ -1112,25 +781,24 @@ def save_assistant_response(
|
|||||||
# Extract thinking into metadata (don't pollute message content with <think> tags)
|
# Extract thinking into metadata (don't pollute message content with <think> tags)
|
||||||
_think_info = _extract_thinking_meta(full_response)
|
_think_info = _extract_thinking_meta(full_response)
|
||||||
if _think_info:
|
if _think_info:
|
||||||
if _think_info.get("thinking"):
|
md["thinking"] = _think_info["thinking"]
|
||||||
md["thinking"] = _think_info["thinking"]
|
md["thinking_time"] = _think_info.get("time")
|
||||||
if _think_info.get("time"):
|
|
||||||
md["thinking_time"] = _think_info.get("time")
|
|
||||||
_content = _think_info["reply"]
|
_content = _think_info["reply"]
|
||||||
else:
|
else:
|
||||||
_content = full_response
|
_content = full_response
|
||||||
if incognito:
|
|
||||||
_append_incognito_message(session_id, "assistant", _content, md)
|
|
||||||
return None
|
|
||||||
sess.add_message(ChatMessage("assistant", _content, metadata=md))
|
sess.add_message(ChatMessage("assistant", _content, metadata=md))
|
||||||
|
|
||||||
from core.database import update_session_last_accessed
|
if not incognito:
|
||||||
update_session_last_accessed(session_id)
|
from core.database import update_session_last_accessed
|
||||||
session_manager.save_sessions()
|
update_session_last_accessed(session_id)
|
||||||
|
session_manager.save_sessions()
|
||||||
|
|
||||||
# Return the persisted message's DB id so the stream can wire it onto the
|
# Return the persisted message's DB id so the stream can wire it onto the
|
||||||
# freshly-rendered bubble — lets the user edit/delete a just-streamed reply
|
# freshly-rendered bubble — lets the user edit/delete a just-streamed reply
|
||||||
# without reloading.
|
# without reloading. Incognito returns None: those messages are ephemeral,
|
||||||
|
# so we don't hand out an edit/delete handle for them.
|
||||||
|
if incognito:
|
||||||
|
return None
|
||||||
try:
|
try:
|
||||||
_last = sess.history[-1]
|
_last = sess.history[-1]
|
||||||
_meta = getattr(_last, "metadata", None)
|
_meta = getattr(_last, "metadata", None)
|
||||||
@@ -1141,54 +809,6 @@ def save_assistant_response(
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _is_session_stream_active(session_id: str) -> bool:
|
|
||||||
"""Best-effort check for "is a chat completion currently streaming for
|
|
||||||
this session?" — used to keep background extraction from overlapping a
|
|
||||||
main completion and competing for the local backend's processing slots
|
|
||||||
(issue #2927). Lazily imports the route module's live registry to avoid
|
|
||||||
a circular import (chat_routes imports this module at load time)."""
|
|
||||||
try:
|
|
||||||
from routes import chat_routes as _cr
|
|
||||||
return session_id in getattr(_cr, "_active_streams", {})
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
async def _run_extraction_jobs_sequentially(session_id: str, jobs: list, max_wait_s: float = 120.0):
|
|
||||||
"""Run queued background-extraction coroutines one at a time, only once
|
|
||||||
no chat completion is actively streaming for this session.
|
|
||||||
|
|
||||||
As diagnosed in issue #2927, firing memory/skill extraction concurrently
|
|
||||||
with the main chat completion (or with each other) makes them compete for
|
|
||||||
the local backend's limited processing slots, evicting the main
|
|
||||||
conversation's cached KV-cache checkpoint and forcing a full prompt
|
|
||||||
re-evaluation on the next turn. Waiting for the stream to go idle and then
|
|
||||||
running the jobs strictly in sequence keeps at most one "side" request in
|
|
||||||
flight against the backend at any time, and never alongside the user's
|
|
||||||
own conversation.
|
|
||||||
"""
|
|
||||||
# Wait for the triggering turn's own stream to finish winding down (it
|
|
||||||
# almost always already has by the time this task gets scheduled — this
|
|
||||||
# is a small safety margin, not the primary mechanism).
|
|
||||||
waited = 0.0
|
|
||||||
poll = 0.25
|
|
||||||
while _is_session_stream_active(session_id) and waited < max_wait_s:
|
|
||||||
await asyncio.sleep(poll)
|
|
||||||
waited += poll
|
|
||||||
|
|
||||||
for name, job in jobs:
|
|
||||||
# Re-check before each job: a fast follow-up message from the user
|
|
||||||
# may have started a new stream for this session while we waited.
|
|
||||||
waited = 0.0
|
|
||||||
while _is_session_stream_active(session_id) and waited < max_wait_s:
|
|
||||||
await asyncio.sleep(poll)
|
|
||||||
waited += poll
|
|
||||||
try:
|
|
||||||
await job
|
|
||||||
except Exception:
|
|
||||||
logger.warning("[bg-extract] %s extraction job failed for session %s", name, session_id, exc_info=True)
|
|
||||||
|
|
||||||
|
|
||||||
def run_post_response_tasks(
|
def run_post_response_tasks(
|
||||||
sess,
|
sess,
|
||||||
session_manager,
|
session_manager,
|
||||||
@@ -1209,37 +829,21 @@ def run_post_response_tasks(
|
|||||||
skills_manager=None,
|
skills_manager=None,
|
||||||
owner: str = None,
|
owner: str = None,
|
||||||
extract_skills: bool = True,
|
extract_skills: bool = True,
|
||||||
allow_background_extraction: bool = True,
|
|
||||||
):
|
):
|
||||||
"""Fire background tasks after a completed response: memory extraction, webhooks, auto-name, skill extraction.
|
"""Fire background tasks after a completed response: memory extraction, webhooks, auto-name, skill extraction."""
|
||||||
|
|
||||||
Memory/skill extraction are queued to run *sequentially*, after the main
|
|
||||||
completion stream for this session has fully wound down — never
|
|
||||||
concurrently with it or with each other. As diagnosed in issue #2927,
|
|
||||||
firing these "side" LLM calls in parallel with the main chat completion
|
|
||||||
makes them compete for the local backend's limited processing slots
|
|
||||||
(llama.cpp defaults to 4), evicting the main conversation's cached
|
|
||||||
checkpoint and forcing a full prompt re-evaluation on the next turn. By
|
|
||||||
the time this function runs the main response is already saved, but the
|
|
||||||
extraction calls themselves are still async — queuing them through
|
|
||||||
``_queue_background_extraction`` keeps them from overlapping the *next*
|
|
||||||
turn's request too.
|
|
||||||
"""
|
|
||||||
_extraction_jobs: list = []
|
|
||||||
|
|
||||||
# Memory extraction — only every 4th message pair to avoid excess LLM calls
|
# Memory extraction — only every 4th message pair to avoid excess LLM calls
|
||||||
_msg_count = len(sess.history) if hasattr(sess, 'history') else 0
|
_msg_count = len(sess.history) if hasattr(sess, 'history') else 0
|
||||||
_should_extract = (_msg_count >= 4) and (_msg_count % 4 == 0)
|
_should_extract = (_msg_count >= 4) and (_msg_count % 4 == 0)
|
||||||
if allow_background_extraction and not incognito and not compare_mode and _should_extract and uprefs.get("auto_memory", True):
|
if not incognito and not compare_mode and _should_extract and uprefs.get("auto_memory", True):
|
||||||
from services.memory.memory_extractor import extract_and_store
|
from services.memory.memory_extractor import extract_and_store
|
||||||
from src.task_endpoint import resolve_task_endpoint
|
from src.task_endpoint import resolve_task_endpoint
|
||||||
t_url, t_model, t_headers = resolve_task_endpoint(
|
t_url, t_model, t_headers = resolve_task_endpoint(
|
||||||
sess.endpoint_url, sess.model, sess.headers, owner=owner,
|
sess.endpoint_url, sess.model, sess.headers, owner=owner,
|
||||||
)
|
)
|
||||||
_extraction_jobs.append(("memory", extract_and_store(
|
asyncio.create_task(extract_and_store(
|
||||||
sess, memory_manager, memory_vector,
|
sess, memory_manager, memory_vector,
|
||||||
t_url, t_model, t_headers,
|
t_url, t_model, t_headers,
|
||||||
)))
|
))
|
||||||
|
|
||||||
# Skill extraction from complex agent runs. Only when the user actually
|
# Skill extraction from complex agent runs. Only when the user actually
|
||||||
# chose agent mode — not a chat we auto-escalated for a notes/calendar
|
# chose agent mode — not a chat we auto-escalated for a notes/calendar
|
||||||
@@ -1257,7 +861,6 @@ def run_post_response_tasks(
|
|||||||
)
|
)
|
||||||
if (
|
if (
|
||||||
extract_skills
|
extract_skills
|
||||||
and allow_background_extraction
|
|
||||||
and auto_skills_enabled
|
and auto_skills_enabled
|
||||||
and not incognito
|
and not incognito
|
||||||
and not compare_mode
|
and not compare_mode
|
||||||
@@ -1275,15 +878,12 @@ def run_post_response_tasks(
|
|||||||
sess.endpoint_url, sess.model, sess.headers, owner=owner,
|
sess.endpoint_url, sess.model, sess.headers, owner=owner,
|
||||||
)
|
)
|
||||||
logger.debug("[skill-extract] dispatching extractor (model=%s)", s_model)
|
logger.debug("[skill-extract] dispatching extractor (model=%s)", s_model)
|
||||||
_extraction_jobs.append(("skill", maybe_extract_skill(
|
asyncio.create_task(maybe_extract_skill(
|
||||||
sess, skills_manager,
|
sess, skills_manager,
|
||||||
s_url, s_model, s_headers,
|
s_url, s_model, s_headers,
|
||||||
agent_rounds, agent_tool_calls,
|
agent_rounds, agent_tool_calls,
|
||||||
owner=owner,
|
owner=owner,
|
||||||
)))
|
))
|
||||||
|
|
||||||
if _extraction_jobs:
|
|
||||||
_spawn_bg(_run_extraction_jobs_sequentially(session_id, _extraction_jobs))
|
|
||||||
|
|
||||||
# Token accumulation
|
# Token accumulation
|
||||||
if last_metrics:
|
if last_metrics:
|
||||||
@@ -1291,11 +891,11 @@ def run_post_response_tasks(
|
|||||||
|
|
||||||
# Webhook
|
# Webhook
|
||||||
if webhook_manager and not compare_mode:
|
if webhook_manager and not compare_mode:
|
||||||
webhook_manager.fire_and_forget("chat.completed", {
|
asyncio.create_task(webhook_manager.fire("chat.completed", {
|
||||||
"session_id": session_id, "model": sess.model,
|
"session_id": session_id, "model": sess.model,
|
||||||
"user_message": message, "response": full_response[:2000],
|
"user_message": message, "response": full_response[:2000],
|
||||||
})
|
}))
|
||||||
|
|
||||||
# Auto-name
|
# Auto-name
|
||||||
if needs_auto_name(sess.name):
|
if needs_auto_name(sess.name):
|
||||||
_spawn_bg(auto_name_session(session_manager, sess))
|
asyncio.create_task(auto_name_session(session_manager, sess))
|
||||||
|
|||||||