Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c12c2aa233 | ||
|
|
7ce6ec7f50 | ||
|
|
20cc23c9bd | ||
|
|
34c9a8adb1 | ||
|
|
8bc16ef245 | ||
|
|
050283c145 | ||
|
|
935eb05c63 | ||
|
|
3b292403dc | ||
|
|
1f00fff837 | ||
|
|
7443c36bd9 | ||
|
|
147d1fbde6 | ||
|
|
8bfd79fe8e | ||
|
|
66fba78011 | ||
|
|
c916224510 | ||
|
|
a7e60ca7ec | ||
|
|
dfccd8256d | ||
|
|
07e69ddf84 | ||
|
|
dd1fa7e1c4 | ||
|
|
cf5c5118d8 | ||
|
|
e5d3f2211b | ||
|
|
93b3e108a6 | ||
|
|
d3e6935d62 | ||
|
|
e92719263e | ||
|
|
bc9104efe2 | ||
|
|
fa1fe7f866 | ||
|
|
594775dc4b | ||
|
|
7b45a94b6d | ||
|
|
f59edee611 | ||
|
|
f876fc7704 | ||
|
|
28c43121d7 | ||
|
|
041c03bf11 | ||
|
|
f2b11ba94e | ||
|
|
f6a5f6592f | ||
|
|
2c7349503a | ||
|
|
e9dfd1a747 | ||
|
|
379a60e5d6 | ||
|
|
d25052e43f | ||
|
|
68cb715914 | ||
|
+2 |
88754035ce | ||
|
|
dc365a1b27 | ||
|
|
965185c6f9 | ||
|
|
e163384015 | ||
|
|
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/
|
||||||
|
|||||||
@@ -56,13 +56,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
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -76,24 +69,12 @@ SEARXNG_INSTANCE=http://localhost:8080
|
|||||||
# Change this if another local service already uses 7000 (macOS AirPlay often does).
|
# Change this if another local service already uses 7000 (macOS AirPlay often does).
|
||||||
# APP_PORT=7000
|
# APP_PORT=7000
|
||||||
|
|
||||||
# Optional HTTP address advertised in companion/mobile pairing codes. Set this
|
|
||||||
# when Docker would otherwise advertise a container address or loopback. Use a
|
|
||||||
# LAN or Tailscale IPv4 address, a single-label hostname, or an mDNS *.local
|
|
||||||
# name that the phone can reach. HTTPS and public hostnames are not supported
|
|
||||||
# by the current companion client. Do not include credentials, a path, query,
|
|
||||||
# or fragment.
|
|
||||||
# COMPANION_BASE_URL=http://192.168.1.50:7000
|
|
||||||
|
|
||||||
# Development-only auth bypass for loopback requests.
|
# Development-only auth bypass for loopback requests.
|
||||||
# Keep false for Docker, LAN, reverse proxy, and any shared deployment.
|
# Keep false for Docker, LAN, reverse proxy, and any shared deployment.
|
||||||
# LOCALHOST_BYPASS=false
|
# LOCALHOST_BYPASS=false
|
||||||
|
|
||||||
# Mark session cookies Secure. Left unset, this follows the request scheme:
|
# Mark session cookies Secure. Set true when Odysseus is served through HTTPS
|
||||||
# an HTTPS login gets a Secure cookie, a plain-HTTP one does not. Set true to
|
# by a trusted reverse proxy or private access gateway.
|
||||||
# force it on, or false to force it off while you still serve plain HTTP.
|
|
||||||
# Upgrading: this used to default to false. Drop a leftover SECURE_COOKIES=false
|
|
||||||
# from your .env unless you still need that escape hatch — it keeps HTTPS logins
|
|
||||||
# on a non-Secure cookie.
|
|
||||||
# SECURE_COOKIES=true
|
# SECURE_COOKIES=true
|
||||||
|
|
||||||
# Optional: pre-seed the first admin password during setup.
|
# Optional: pre-seed the first admin password during setup.
|
||||||
@@ -131,9 +112,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
|
||||||
|
|
||||||
@@ -142,42 +120,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
|
|
||||||
|
|
||||||
# Origin the MCP OAuth callback is sent back to, for remote (Streamable HTTP)
|
|
||||||
# MCP servers that register it dynamically. Defaults to http://localhost:$APP_PORT,
|
|
||||||
# which is right only when you reach Odysseus directly on that port. Set it for
|
|
||||||
# HTTPS, reverse-proxy, hosted, and Docker installs — inside the container the
|
|
||||||
# app always listens on 7000 and cannot see the host port map, so the default is
|
|
||||||
# wrong there whenever APP_PORT is not 7000.
|
|
||||||
#
|
|
||||||
# Not for Google MCP servers. Those use Desktop App credentials, and Google only
|
|
||||||
# accepts loopback redirect URIs for that client type, so a public origin here is
|
|
||||||
# rejected with redirect_uri_mismatch. Leave it unset for a Google-only install:
|
|
||||||
# the loopback default is what Google wants, and remote users finish through the
|
|
||||||
# paste-back page, which never has to load the redirect.
|
|
||||||
# https://developers.google.com/identity/protocols/oauth2/native-app
|
|
||||||
# OAUTH_REDIRECT_BASE_URL=https://your-domain.com
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Misc
|
# Misc
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -202,42 +144,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)
|
|
||||||
# ODYSSEUS_TTS_CACHE_MAX_BYTES=524288000 # TTS cache (500 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)
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -259,10 +165,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
|
|
||||||
|
|||||||
@@ -15,13 +15,6 @@ docker/entrypoint.sh text eol=lf
|
|||||||
*.cmd text eol=crlf
|
*.cmd text eol=crlf
|
||||||
*.bat text eol=crlf
|
*.bat text eol=crlf
|
||||||
|
|
||||||
# Vendored third-party bundles in static/lib/ are published minified artifacts
|
|
||||||
# and must stay byte-identical to what npm ships — stripping trailing whitespace
|
|
||||||
# to satisfy `git diff --check` would desync them from the upstream release. Turn
|
|
||||||
# the whitespace check off for that tree instead, and keep the bundles out of
|
|
||||||
# GitHub's language statistics.
|
|
||||||
static/lib/** -whitespace linguist-vendored
|
|
||||||
|
|
||||||
# Binary assets — never normalize.
|
# Binary assets — never normalize.
|
||||||
*.png binary
|
*.png binary
|
||||||
*.jpg binary
|
*.jpg binary
|
||||||
|
|||||||
@@ -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,36 +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
|
|
||||||
|
|
||||||
- type: input
|
|
||||||
id: revision
|
|
||||||
attributes:
|
|
||||||
label: Odysseus Revision
|
|
||||||
description: |
|
|
||||||
From the repository root (on the host when using Docker), run
|
|
||||||
`git show -s --abbrev=12 --format='%h (%cs)' HEAD`
|
|
||||||
and paste the output exactly.
|
|
||||||
placeholder: "1fef4929cf1d (2026-08-11)"
|
|
||||||
validations:
|
|
||||||
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.
|
||||||
Feature requests that duplicate [ROADMAP.md](https://github.com/odysseus-dev/odysseus/blob/main/ROADMAP.md)
|
Feature requests that duplicate [ROADMAP.md](https://github.com/pewdiepie-archdaemon/odysseus/blob/main/ROADMAP.md)
|
||||||
or an existing open issue will 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.
|
|
||||||
@@ -24,11 +24,10 @@ 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 `dev`
|
||||||
- [ ] 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.
|
||||||
- [ ] I did not run the app/runtime validation and stated that gap in **How to Test**. Leave this unchecked when the app-run box above is checked.
|
|
||||||
|
|
||||||
## How to Test
|
## How to Test
|
||||||
|
|
||||||
|
|||||||
@@ -41,14 +41,6 @@ module.exports = async ({ github, context, core }) => {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case 'bug': {
|
case 'bug': {
|
||||||
const revisionText = section('Odysseus Revision');
|
|
||||||
if (!/^[0-9a-f]{12} \(\d{4}-\d{2}-\d{2}\)$/i.test(revisionText)) {
|
|
||||||
failures.push(
|
|
||||||
'**Odysseus Revision** — paste the 12-character commit SHA and date, ' +
|
|
||||||
'for example `1fef4929cf1d (2026-08-11)`',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!section('Install Method')) {
|
if (!section('Install Method')) {
|
||||||
failures.push('**Install Method** — select how you installed Odysseus');
|
failures.push('**Install Method** — select how you installed Odysseus');
|
||||||
}
|
}
|
||||||
@@ -161,16 +153,6 @@ module.exports = async ({ github, context, core }) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const LABEL_BAD = 'needs more info';
|
|
||||||
const LABEL_GOOD = 'ready for review';
|
|
||||||
|
|
||||||
// Closed issues are no longer awaiting review.
|
|
||||||
// This also prevents later edits to closed issues from restoring the label.
|
|
||||||
if (issue.state === 'closed') {
|
|
||||||
await dropLabel(LABEL_GOOD);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Find existing bot comment to update in-place ──────────────────────────
|
// ── Find existing bot comment to update in-place ──────────────────────────
|
||||||
const MARKER = '<!-- issue-description-check -->';
|
const MARKER = '<!-- issue-description-check -->';
|
||||||
const { data: comments } = await github.rest.issues.listComments({
|
const { data: comments } = await github.rest.issues.listComments({
|
||||||
@@ -178,6 +160,9 @@ module.exports = async ({ github, context, core }) => {
|
|||||||
});
|
});
|
||||||
const existing = comments.find(c => c.user.type === 'Bot' && c.body.includes(MARKER));
|
const existing = comments.find(c => c.user.type === 'Bot' && c.body.includes(MARKER));
|
||||||
|
|
||||||
|
const LABEL_BAD = 'needs more info';
|
||||||
|
const LABEL_GOOD = 'ready for review';
|
||||||
|
|
||||||
if (failures.length === 0) {
|
if (failures.length === 0) {
|
||||||
if (existing) {
|
if (existing) {
|
||||||
await github.rest.issues.deleteComment({ owner, repo, comment_id: existing.id });
|
await github.rest.issues.deleteComment({ owner, repo, comment_id: existing.id });
|
||||||
|
|||||||
@@ -21,11 +21,11 @@ module.exports = async ({ github, context, core }) => {
|
|||||||
return strip(m?.[0].replace(new RegExp(`#+\\s+${heading}`, 'i'), '') ?? '');
|
return strip(m?.[0].replace(new RegExp(`#+\\s+${heading}`, 'i'), '') ?? '');
|
||||||
}
|
}
|
||||||
|
|
||||||
const descriptionProblems = [];
|
const problems = [];
|
||||||
|
|
||||||
// 1. Summary must be filled in.
|
// 1. Summary must be filled in.
|
||||||
if (section('Summary').length < 20) {
|
if (section('Summary').length < 20) {
|
||||||
descriptionProblems.push('**Summary** is empty or too short — describe what changed and why.');
|
problems.push('**Summary** is empty or too short — describe what changed and why.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Linked Issue must reference a real issue. Accept a bare #NNN, a closing
|
// 2. Linked Issue must reference a real issue. Accept a bare #NNN, a closing
|
||||||
@@ -34,18 +34,18 @@ module.exports = async ({ github, context, core }) => {
|
|||||||
const linkedSection = section('Linked Issue');
|
const linkedSection = section('Linked Issue');
|
||||||
const hasIssueRef = /#\d+\b/.test(linkedSection) || /\/issues\/\d+/.test(linkedSection);
|
const hasIssueRef = /#\d+\b/.test(linkedSection) || /\/issues\/\d+/.test(linkedSection);
|
||||||
if (!linkedSection || !hasIssueRef) {
|
if (!linkedSection || !hasIssueRef) {
|
||||||
descriptionProblems.push('**Linked Issue** — add a reference like `Fixes #NNN`, a bare `#NNN`, or a link to the issue.');
|
problems.push('**Linked Issue** — add a reference like `Fixes #NNN`, a bare `#NNN`, or a link to the issue.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. At least one Type of Change box must be checked.
|
// 3. At least one Type of Change box must be checked.
|
||||||
const typeBlock = body.match(/##\s+Type of Change[\s\S]*?(?=\n##\s|$)/i)?.[0] ?? '';
|
const typeBlock = body.match(/##\s+Type of Change[\s\S]*?(?=\n##\s|$)/i)?.[0] ?? '';
|
||||||
if (!/- \[x\]/i.test(typeBlock)) {
|
if (!/- \[x\]/i.test(typeBlock)) {
|
||||||
descriptionProblems.push('**Type of Change** — check at least one box.');
|
problems.push('**Type of Change** — check at least one box.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Duplicate-search checklist item must be checked.
|
// 4. Duplicate-search checklist item must be checked.
|
||||||
if (!/- \[x\] I searched/i.test(body)) {
|
if (!/- \[x\] I searched/i.test(body)) {
|
||||||
descriptionProblems.push('**Checklist** — check the duplicate-search box to confirm you searched existing issues and PRs.');
|
problems.push('**Checklist** — check the duplicate-search box to confirm you searched existing issues and PRs.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. How to Test must contain enough real detail for a reviewer to act on.
|
// 5. How to Test must contain enough real detail for a reviewer to act on.
|
||||||
@@ -53,83 +53,7 @@ module.exports = async ({ github, context, core }) => {
|
|||||||
// code block — so we only require non-trivial content, not a specific shape.
|
// code block — so we only require non-trivial content, not a specific shape.
|
||||||
const howTo = section('How to Test');
|
const howTo = section('How to Test');
|
||||||
if (howTo.length < 30) {
|
if (howTo.length < 30) {
|
||||||
descriptionProblems.push('**How to Test** — explain how a reviewer can verify this change. Numbered steps, the commands you ran, or a short code block all work — give a sentence or two of real detail (not just "tested locally").');
|
problems.push('**How to Test** — explain how a reviewer can verify this change. Numbered steps, the commands you ran, or a short code block all work — give a sentence or two of real detail (not just "tested locally").');
|
||||||
}
|
|
||||||
|
|
||||||
// Classify paths from GitHub's API. This workflow runs in the privileged base
|
|
||||||
// context, so it must never check out or execute code from the PR branch.
|
|
||||||
const changedFiles = await github.paginate(github.rest.pulls.listFiles, {
|
|
||||||
owner, repo, pull_number: prNum, per_page: 100,
|
|
||||||
});
|
|
||||||
const changedPaths = changedFiles.map(file => file.filename);
|
|
||||||
|
|
||||||
function isUiSensitivePath(filename) {
|
|
||||||
const path = filename.toLowerCase();
|
|
||||||
return path.startsWith('static/')
|
|
||||||
|| path.startsWith('templates/')
|
|
||||||
|| /\.(?:html?|css|svg)$/.test(path);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isDocsOnlyPath(filename) {
|
|
||||||
const path = filename.toLowerCase();
|
|
||||||
return /\.(?:md|mdx|rst|adoc|txt)$/.test(path)
|
|
||||||
|| (path.startsWith('docs/') && !isUiSensitivePath(path));
|
|
||||||
}
|
|
||||||
|
|
||||||
function isRuntimeSensitivePath(filename) {
|
|
||||||
const path = filename.toLowerCase();
|
|
||||||
if (isUiSensitivePath(path)) return false;
|
|
||||||
if (path.startsWith('tests/') || path.startsWith('.github/')) return false;
|
|
||||||
return /^(?:app\.py|routes\/|services\/|src\/|core\/|mcp_servers\/|scripts\/|docker\/)/.test(path)
|
|
||||||
|| /^(?:dockerfile|docker-compose.*\.ya?ml|requirements(?:-optional)?\.txt|pyproject\.toml|setup\.py)$/.test(path)
|
|
||||||
|| /\.(?:py|sh|ps1|bat)$/.test(path);
|
|
||||||
}
|
|
||||||
|
|
||||||
let classification = 'tooling';
|
|
||||||
if (changedPaths.some(isUiSensitivePath)) {
|
|
||||||
classification = 'UI-sensitive';
|
|
||||||
} else if (changedPaths.some(isRuntimeSensitivePath)) {
|
|
||||||
classification = 'backend/runtime';
|
|
||||||
} else if (changedPaths.length > 0 && changedPaths.every(isDocsOnlyPath)) {
|
|
||||||
classification = 'docs-only';
|
|
||||||
}
|
|
||||||
|
|
||||||
const appRan = /- \[x\]\s+I actually ran the app\b/i.test(body);
|
|
||||||
const appNotRun = /- \[x\]\s+I did not run the app\/runtime validation\b/i.test(body);
|
|
||||||
// Anchor on the wording, not the template's emphasis: a ticked box the author
|
|
||||||
// retyped without the surrounding ** renders identically on the PR page, so
|
|
||||||
// treating it as unchecked is invisible from their side. Matches the two
|
|
||||||
// attestations above, which already ignore formatting.
|
|
||||||
const screenshotChecked = /- \[x\]\s+[*_]{0,2}Screenshot or short clip[*_]{0,2}/i.test(body);
|
|
||||||
const screenshotSection = section('Screenshots / clips');
|
|
||||||
const hasVisualEvidence = /!\[[^\]]*\]\([^)]+\)|<(?:img|video|source)\b[^>]*(?:src|href)=|https?:\/\/[^\s)]+/i.test(screenshotSection);
|
|
||||||
const evidenceGaps = [];
|
|
||||||
let needsRuntimeValidation = false;
|
|
||||||
let needsVisualEvidence = false;
|
|
||||||
|
|
||||||
if (classification === 'backend/runtime' || classification === 'UI-sensitive') {
|
|
||||||
if (appRan && appNotRun) {
|
|
||||||
needsRuntimeValidation = true;
|
|
||||||
evidenceGaps.push('The app-run and explicit not-run boxes are both checked. Select the one state that is true.');
|
|
||||||
} else if (!appRan) {
|
|
||||||
needsRuntimeValidation = true;
|
|
||||||
if (appNotRun) {
|
|
||||||
evidenceGaps.push('The author explicitly reports that app/runtime validation was not performed.');
|
|
||||||
} else {
|
|
||||||
evidenceGaps.push('App/runtime validation is not author-attested. Check the run box only after running it, or check the explicit not-run box and describe the gap.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (classification === 'UI-sensitive') {
|
|
||||||
if (!screenshotChecked) {
|
|
||||||
needsVisualEvidence = true;
|
|
||||||
evidenceGaps.push('The screenshot/clip checkbox is not checked for this UI-sensitive change.');
|
|
||||||
}
|
|
||||||
if (!hasVisualEvidence) {
|
|
||||||
needsVisualEvidence = true;
|
|
||||||
evidenceGaps.push('The Screenshots / clips section does not contain an actual attachment or link.');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Comment ──────────────────────────────────────────────────────────────
|
// ── Comment ──────────────────────────────────────────────────────────────
|
||||||
@@ -138,43 +62,22 @@ module.exports = async ({ github, context, core }) => {
|
|||||||
});
|
});
|
||||||
const existing = comments.find(c => (c.body ?? '').includes(MARKER));
|
const existing = comments.find(c => (c.body ?? '').includes(MARKER));
|
||||||
|
|
||||||
if (descriptionProblems.length === 0 && evidenceGaps.length === 0) {
|
if (problems.length === 0) {
|
||||||
if (existing) {
|
if (existing) {
|
||||||
await github.rest.issues.deleteComment({ owner, repo, comment_id: existing.id });
|
await github.rest.issues.deleteComment({ owner, repo, comment_id: existing.id });
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const commentLines = [MARKER];
|
const commentBody = [
|
||||||
if (descriptionProblems.length > 0) {
|
MARKER,
|
||||||
commentLines.push(
|
|
||||||
'⚠️ **PR description — action needed**',
|
'⚠️ **PR description — action needed**',
|
||||||
'',
|
'',
|
||||||
'The following required sections are missing or incomplete. Please update the PR description to address them:',
|
'The following required sections are missing or incomplete. Please update the PR description to address them:',
|
||||||
'',
|
'',
|
||||||
descriptionProblems.map(problem => `- ${problem}`).join('\n'),
|
problems.map(p => `- ${p}`).join('\n'),
|
||||||
);
|
|
||||||
} else {
|
|
||||||
commentLines.push(
|
|
||||||
'⚠️ **PR description is complete; validation evidence is still outstanding**',
|
|
||||||
'',
|
|
||||||
`Changed-file classification: **${classification}**.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (evidenceGaps.length > 0) {
|
|
||||||
commentLines.push(
|
|
||||||
'',
|
|
||||||
'**Author-reported runtime / visual state**',
|
|
||||||
'',
|
|
||||||
evidenceGaps.map(gap => `- ${gap}`).join('\n'),
|
|
||||||
'',
|
|
||||||
'Checkboxes are author attestations. GitHub Actions results remain the execution evidence for CI; this check does not prove that a local command ran.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
commentLines.push(
|
|
||||||
'',
|
'',
|
||||||
'---',
|
'---',
|
||||||
'_This comment updates automatically when the description or changed files change._',
|
'_This comment is deleted automatically once all sections are complete._',
|
||||||
);
|
].join('\n');
|
||||||
const commentBody = commentLines.join('\n');
|
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body: commentBody });
|
await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body: commentBody });
|
||||||
@@ -194,47 +97,27 @@ module.exports = async ({ github, context, core }) => {
|
|||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.status === 404) return false;
|
if (e.status === 404) return false;
|
||||||
if (e.status === 403) {
|
|
||||||
core.warning(`Could not inspect label "${name}" — token lacks label read access; skipping.`);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setLabel(name, wanted) {
|
async function swapLabel(num, add, remove) {
|
||||||
if (wanted && await labelExists(name)) {
|
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: prNum, labels: [name] });
|
|
||||||
} 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 && e.status !== 404) throw e;
|
|
||||||
core.warning(`Could not add "${name}" — label is unavailable or the token lacks label write access; skipping.`);
|
|
||||||
}
|
|
||||||
} else if (wanted) {
|
|
||||||
core.warning(`Label "${name}" does not exist in the repo — skipping. Create it once to enable labelling.`);
|
|
||||||
} else {
|
} else {
|
||||||
|
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: prNum, name });
|
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;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const descriptionComplete = descriptionProblems.length === 0;
|
if (problems.length === 0) {
|
||||||
const evidenceComplete = evidenceGaps.length === 0;
|
await swapLabel(prNum, 'ready for review', 'needs work');
|
||||||
const isDraft = Boolean(context.payload.pull_request.draft);
|
} else {
|
||||||
await setLabel(
|
await swapLabel(prNum, 'needs work', 'ready for review');
|
||||||
'ready for review',
|
core.setFailed(`PR description has ${problems.length} issue(s) — see bot comment for details.`);
|
||||||
descriptionComplete && evidenceComplete && !isDraft,
|
|
||||||
);
|
|
||||||
await setLabel('needs work', !descriptionComplete);
|
|
||||||
await setLabel('needs runtime validation', needsRuntimeValidation);
|
|
||||||
await setLabel('needs visual evidence', needsVisualEvidence);
|
|
||||||
|
|
||||||
if (!descriptionComplete) {
|
|
||||||
core.setFailed(`PR description has ${descriptionProblems.length} issue(s) — see bot comment for details.`);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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())
|
|
||||||
@@ -2,7 +2,7 @@ name: CI
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main, dev]
|
branches: [main]
|
||||||
pull_request:
|
pull_request:
|
||||||
|
|
||||||
# Least privilege: none of the jobs write to the repo.
|
# Least privilege: none of the jobs write to the repo.
|
||||||
@@ -15,68 +15,12 @@ concurrency:
|
|||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
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:
|
python-syntax:
|
||||||
name: Python syntax (compileall)
|
name: Python syntax (compileall)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||||
with:
|
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||||
persist-credentials: false
|
|
||||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
|
||||||
with:
|
with:
|
||||||
python-version: "3.11"
|
python-version: "3.11"
|
||||||
# Byte-compile sources — catches syntax errors without installing deps.
|
# Byte-compile sources — catches syntax errors without installing deps.
|
||||||
@@ -86,10 +30,8 @@ jobs:
|
|||||||
name: JS syntax (node --check)
|
name: JS syntax (node --check)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||||
with:
|
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||||
persist-credentials: false
|
|
||||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
node-version: "20"
|
||||||
# Syntax-check our own JS (skip vendored libs in static/lib).
|
# Syntax-check our own JS (skip vendored libs in static/lib).
|
||||||
@@ -103,43 +45,16 @@ jobs:
|
|||||||
python-tests:
|
python-tests:
|
||||||
name: Python tests (pytest)
|
name: Python tests (pytest)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
# Make Python test validation authoritative for the configured scope.
|
# 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:
|
steps:
|
||||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||||
with:
|
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||||
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:
|
with:
|
||||||
python-version: "3.11"
|
python-version: "3.11"
|
||||||
cache: pip
|
cache: pip
|
||||||
- run: pip install -r requirements.txt
|
- 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
|
- run: mkdir -p data # sqlite DB lives at ./data/app.db
|
||||||
if: steps.docs-check.outputs.docs_only != 'true'
|
|
||||||
- run: python -m pytest -q
|
- 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 }}
|
|
||||||
@@ -2,7 +2,7 @@ name: ci / issue description check
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
issues:
|
issues:
|
||||||
types: [opened, edited, reopened, closed]
|
types: [opened, edited, reopened]
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
issues: write
|
issues: write
|
||||||
@@ -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,115 +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, converted_to_draft]
|
|
||||||
|
|
||||||
concurrency:
|
# pull_request_target runs in the base-repo context (has secrets).
|
||||||
group: pr-description-${{ github.event.pull_request.number }}
|
# The checkout below pins to the base branch so no fork code is executed.
|
||||||
cancel-in-progress: true
|
# The script only reads context.payload and calls the GitHub API.
|
||||||
|
permissions:
|
||||||
# Default-deny at the workflow level; each job opts into only the scopes it needs.
|
issues: write
|
||||||
# Note: modifying a PR's labels/comments needs pull-requests:write even though the
|
pull-requests: write
|
||||||
# REST path is under /issues/{n}/...; issues:write alone returns 403 on PRs.
|
|
||||||
permissions: {}
|
|
||||||
|
|
||||||
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
|
|
||||||
needs: check-description
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
pull-requests: write
|
|
||||||
issues: write
|
|
||||||
# Run after description validation failures, but never from an obsolete
|
|
||||||
# workflow run canceled by a newer PR event.
|
|
||||||
if: ${{ !cancelled() && 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/
|
|
||||||
|
|||||||
@@ -65,16 +65,6 @@ Vendored in `static/lib/` and served directly:
|
|||||||
| [jsPDF](https://github.com/parallax/jsPDF) (bundled in html2pdf) | PDF generation | MIT |
|
| [jsPDF](https://github.com/parallax/jsPDF) (bundled in html2pdf) | PDF generation | MIT |
|
||||||
| [html2canvas](https://github.com/niklasvh/html2canvas) (bundled in html2pdf) | DOM → canvas rasterization | MIT |
|
| [html2canvas](https://github.com/niklasvh/html2canvas) (bundled in html2pdf) | DOM → canvas rasterization | MIT |
|
||||||
| [node-qrcode](https://github.com/soldair/node-qrcode) (`qrcode.min.js`) | QR-code rendering (2FA setup) | MIT |
|
| [node-qrcode](https://github.com/soldair/node-qrcode) (`qrcode.min.js`) | QR-code rendering (2FA setup) | MIT |
|
||||||
| [KaTeX](https://github.com/KaTeX/KaTeX) v0.16.22 (`katex/katex.min.{js,css}` + `katex/fonts/*.woff2`) | Math typesetting | MIT ([`licenses/KaTeX-MIT-LICENSE.txt`](licenses/KaTeX-MIT-LICENSE.txt)) |
|
|
||||||
| [Mermaid](https://github.com/mermaid-js/mermaid) v11.16.1 (`mermaid.min.js`) | Diagrams from text | MIT ([`licenses/Mermaid-MIT-LICENSE.txt`](licenses/Mermaid-MIT-LICENSE.txt)) |
|
|
||||||
|
|
||||||
KaTeX and Mermaid are loaded on first use by `static/js/markdown.js` rather than
|
|
||||||
from `index.html`, so a session that renders no math and no diagram never fetches
|
|
||||||
either. Only the `.woff2` KaTeX fonts are shipped, matching `static/fonts/`; the
|
|
||||||
`.woff` and `.ttf` variants its stylesheet also lists are never requested by a
|
|
||||||
browser that supports `woff2`. The bundles are the published npm artifacts,
|
|
||||||
unmodified — `.gitattributes` turns the whitespace check off for `static/lib/`
|
|
||||||
so they can stay byte-identical to upstream.
|
|
||||||
|
|
||||||
## Front-end libraries loaded at runtime (CDN)
|
## Front-end libraries loaded at runtime (CDN)
|
||||||
|
|
||||||
@@ -82,6 +72,8 @@ Referenced from `cdn.jsdelivr.net` / `cdnjs.cloudflare.com` at runtime — not v
|
|||||||
|
|
||||||
| Library | Purpose | License |
|
| Library | Purpose | License |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
| [KaTeX](https://github.com/KaTeX/KaTeX) 0.16.22 | Math typesetting | MIT |
|
||||||
|
| [Mermaid](https://github.com/mermaid-js/mermaid) 11 | Diagrams from text | MIT |
|
||||||
| [Pyodide](https://github.com/pyodide/pyodide) 0.27.5 | In-browser Python runtime | MPL-2.0 |
|
| [Pyodide](https://github.com/pyodide/pyodide) 0.27.5 | In-browser Python runtime | MPL-2.0 |
|
||||||
| [PDFObject](https://github.com/pipwerks/PDFObject) 2.1.1 | Inline PDF embedding | MIT |
|
| [PDFObject](https://github.com/pipwerks/PDFObject) 2.1.1 | Inline PDF embedding | MIT |
|
||||||
|
|
||||||
@@ -94,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
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,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 +37,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 +94,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,81 +1,449 @@
|
|||||||
<p align="center">
|
# Odysseus
|
||||||
<img src="docs/odysseus-wordmark.png" alt="Odysseus" width="238">
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p align="center">
|
```
|
||||||
A self-hosted AI workspace for chat, agents, research, documents, email, notes, calendar, and local model workflows.
|
───────────────────────────────────────────────
|
||||||
</p>
|
⊹ ࣪ ˖ ૮( ˶ᵔ ᵕ ᵔ˶ )っ Odysseus vers. 1.0
|
||||||
|
───────────────────────────────────────────────
|
||||||
|
```
|
||||||
|
|
||||||
<p align="center">
|

|
||||||
<a href="#quick-start">Quick Start</a> ·
|
|
||||||
<a href="docs/setup.md">Setup Guide</a> ·
|
|
||||||
<a href="CONTRIBUTING.md">Contributing</a> ·
|
|
||||||
<a href="ROADMAP.md">Roadmap</a>
|
|
||||||
</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="https://repology.org/project/odysseus-ai/versions"><img src="https://repology.org/badge/vertical-allrepos/odysseus-ai.svg" alt="Packaging status"></a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p align="center">
|
## Features
|
||||||
<img src="docs/odysseus-browser.jpg" alt="Odysseus interface">
|
- **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>
|
||||||
|
|
||||||
---
|
## Demo
|
||||||
|
A full, hover-to-play tour lives on the landing page (`docs/index.html`).
|
||||||
|
|
||||||
|
<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
|
||||||
|
```
|
||||||
|
|
||||||
A full hover-to-play tour lives on the landing page: [`docs/index.html`](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.
|
||||||
|
|
||||||
## Contributing
|
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:
|
||||||
|
|
||||||
Help is welcome. The best entry points are fresh-install testing, provider setup bugs, mobile/editor polish, docs, and small focused refactors. See [CONTRIBUTING.md](CONTRIBUTING.md) and [ROADMAP.md](ROADMAP.md).
|
```bash
|
||||||
|
./build-macos-app.sh
|
||||||
|
```
|
||||||
|
|
||||||
## Security
|
<details>
|
||||||
|
<summary>Cookbook, GPU, Ollama, and troubleshooting notes</summary>
|
||||||
|
|
||||||
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/service ports publicly.
|
**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 `AUTH_ENABLED=true` for any network-accessible deployment.
|
||||||
- Keep `LOCALHOST_BYPASS=false` outside local development.
|
- 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.
|
||||||
|
|
||||||
Deployment details are in the [setup guide](docs/setup.md#security-notes).
|
### 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
|
||||||
|
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.
|
||||||
|
|
||||||
|
## 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. |
|
||||||
|
| `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 |
|
||||||
|
|
||||||
|
### 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://star-history.dera.page/#odysseus-dev/odysseus&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://star-history.dera.page/svg?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://star-history.dera.page/svg?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://star-history.dera.page/svg?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).
|
||||||
|
|
||||||
AGPL-3.0-or-later -- see [LICENSE](LICENSE) and [ACKNOWLEDGMENTS.md](ACKNOWLEDGMENTS.md).
|
```
|
||||||
|
|
|
||||||
|
|||
|
||||||
|
|||||
|
||||||
|
| | | |||||||
|
||||||
|
)_) )_) )_) ~|~
|
||||||
|
)___))___))___)\ |
|
||||||
|
)____)____)_____)\\|
|
||||||
|
_____|____|____|_____\\\__
|
||||||
|
\ /
|
||||||
|
~^~^~~^~^~~^~^~~^~^~~^~^~~^~^~~^~^~~^~^~
|
||||||
|
~^~ all aboard! ~^~
|
||||||
|
~^~^~~^~^~~^~^~~^~^~~^~^~~^~^~~^~^~~^~^~
|
||||||
|
```
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ the codebase, you are probably right to stay away.
|
|||||||
and WSL all need coverage.
|
and WSL all need coverage.
|
||||||
|
|
||||||
- Integration audit: do integrations even work? Confirm what works, what needs setup docs, and what should be removed or hidden.
|
- 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 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
|
- Cookbook SGLang support across platforms. Make sure SGLang setup/serve works
|
||||||
predictably on Linux, Windows/WSL, macOS where possible, Docker, and common
|
predictably on Linux, Windows/WSL, macOS where possible, Docker, and common
|
||||||
@@ -32,14 +33,6 @@ the codebase, you are probably right to stay away.
|
|||||||
before the user request really starts. We need slimmer prompts, better tool
|
before the user request really starts. We need slimmer prompts, better tool
|
||||||
selection, smaller default tool sets, and clearer guidance for models with
|
selection, smaller default tool sets, and clearer guidance for models with
|
||||||
4k/8k/16k context windows.
|
4k/8k/16k context windows.
|
||||||
- Local model speculative decoding support. For Odysseus-tuned local models,
|
|
||||||
plan to ship or recommend a small same-tokenizer draft model when the serving
|
|
||||||
backend supports it. Early vLLM testing showed a generic `Qwen3-0.6B` draft
|
|
||||||
beside `Qwen3-8B` can materially reduce wall time, while an unsupported
|
|
||||||
DSpark conversion performed poorly. Treat this as a supported draft-model lane
|
|
||||||
first; keep MTP-specific packaging as future work only when the architecture
|
|
||||||
and runtime support are real. Judge this by time-to-success, tool correctness,
|
|
||||||
grammar, and unchanged target output, not tokens/sec alone.
|
|
||||||
- Skill/tool prompt-injection audit. User-editable skills, notes, documents,
|
- Skill/tool prompt-injection audit. User-editable skills, notes, documents,
|
||||||
fetched pages, and memories should be treated as untrusted data. Keep testing
|
fetched pages, and memories should be treated as untrusted data. Keep testing
|
||||||
whether models follow malicious instructions from those surfaces.
|
whether models follow malicious instructions from those surfaces.
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ Security fixes are handled on the default branch until formal releases are cut.
|
|||||||
|
|
||||||
- Keep `AUTH_ENABLED=true` for any network-accessible deployment.
|
- Keep `AUTH_ENABLED=true` for any network-accessible deployment.
|
||||||
- Keep `LOCALHOST_BYPASS=false` outside local development.
|
- Keep `LOCALHOST_BYPASS=false` outside local development.
|
||||||
- Leave `SECURE_COOKIES` unset unless you need to override it: session cookies are marked `Secure` whenever the request arrives over HTTPS. Set `SECURE_COOKIES=true` to force it on (for a proxy Odysseus cannot see the scheme of), or `SECURE_COOKIES=false` to force it off while you still serve plain HTTP alongside HTTPS.
|
- Set `SECURE_COOKIES=true` when Odysseus is served through HTTPS by a trusted reverse proxy or private access gateway.
|
||||||
- Use HTTPS when exposing the app beyond localhost.
|
- Use HTTPS when exposing the app beyond localhost.
|
||||||
- Put the authenticated Odysseus web/API entrypoint behind a trusted reverse proxy or private access layer such as Cloudflare Access, Tailscale, or a VPN.
|
- Put the authenticated Odysseus web/API entrypoint behind a trusted reverse proxy or private access layer such as Cloudflare Access, Tailscale, or a VPN.
|
||||||
- Keep ChromaDB, SearXNG, ntfy, Ollama, vLLM, llama.cpp, databases, and raw model/provider APIs internal-only.
|
- Keep ChromaDB, SearXNG, ntfy, Ollama, vLLM, llama.cpp, databases, and raw model/provider APIs internal-only.
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ Non-admin defaults are in `core/auth.py:DEFAULT_PRIVILEGES`. Tool enforcement is
|
|||||||
|
|
||||||
- **Sessions:** bcrypt passwords, 7-day session tokens stored atomically in `data/sessions.json` via `core/atomic_io.py`.
|
- **Sessions:** bcrypt passwords, 7-day session tokens stored atomically in `data/sessions.json` via `core/atomic_io.py`.
|
||||||
- **2FA:** TOTP with 8 single-use backup codes. Verified after password check, before session issuance.
|
- **2FA:** TOTP with 8 single-use backup codes. Verified after password check, before session issuance.
|
||||||
- **Reserved usernames:** request sentinels and the Default/Local storage owner cannot be registered or renamed into. Defined in `core/auth.py:RESERVED_USERNAMES`.
|
- **Reserved usernames:** `internal-tool`, `api`, `demo`, `system` cannot be registered or renamed into. Defined in `core/auth.py:RESERVED_USERNAMES`.
|
||||||
- `internal-tool` is security-critical: `core/middleware.py:require_admin` treats any request where `request.state.current_user == "internal-tool"` as the in-process tool loopback and grants admin unconditionally. A real account with that name would silently pass every `require_admin` check.
|
- `internal-tool` is security-critical: `core/middleware.py:require_admin` treats any request where `request.state.current_user == "internal-tool"` as the in-process tool loopback and grants admin unconditionally. A real account with that name would silently pass every `require_admin` check.
|
||||||
- **Orphan sessions:** `validate_token` re-checks that the user record still exists on every call. A deleted user's cookie is dropped on next request rather than continuing to authenticate.
|
- **Orphan sessions:** `validate_token` re-checks that the user record still exists on every call. A deleted user's cookie is dropped on next request rather than continuing to authenticate.
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
@@ -50,31 +38,24 @@ load_dotenv(encoding="utf-8-sig")
|
|||||||
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 (
|
from core.middleware import SecurityHeadersMiddleware
|
||||||
SecurityHeadersMiddleware,
|
from core.auth import AuthManager
|
||||||
get_application_route_path,
|
|
||||||
is_cors_preflight,
|
|
||||||
path_is_route_or_child,
|
|
||||||
with_asgi_root_path,
|
|
||||||
)
|
|
||||||
from core.auth import AuthManager, normalize_known_username
|
|
||||||
from core.exceptions import (
|
from core.exceptions import (
|
||||||
SessionNotFoundError, InvalidFileUploadError,
|
SessionNotFoundError, InvalidFileUploadError,
|
||||||
LLMServiceError, WebSearchError,
|
LLMServiceError, WebSearchError,
|
||||||
@@ -82,43 +63,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 src.owner_identity import auth_disabled
|
|
||||||
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 =========
|
||||||
@@ -132,13 +84,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",
|
||||||
@@ -152,16 +103,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)
|
||||||
|
|
||||||
@@ -187,7 +128,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
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -205,57 +145,14 @@ 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
|
||||||
|
|
||||||
auth_manager = AuthManager()
|
auth_manager = AuthManager()
|
||||||
app.state.auth_manager = auth_manager
|
app.state.auth_manager = auth_manager
|
||||||
AUTH_ENABLED = not auth_disabled()
|
AUTH_ENABLED = os.getenv("AUTH_ENABLED", "true").lower() != "false"
|
||||||
LOCALHOST_BYPASS = os.getenv("LOCALHOST_BYPASS", "false").lower() == "true"
|
LOCALHOST_BYPASS = os.getenv("LOCALHOST_BYPASS", "false").lower() == "true"
|
||||||
if LOCALHOST_BYPASS:
|
if LOCALHOST_BYPASS:
|
||||||
logger.warning("LOCALHOST_BYPASS is enabled, loopback requests bypass authentication. Do not expose this instance to a network.")
|
logger.warning("LOCALHOST_BYPASS is enabled, loopback requests bypass authentication. Do not expose this instance to a network.")
|
||||||
@@ -291,7 +188,7 @@ if AUTH_ENABLED:
|
|||||||
def _is_auth_exempt(path: str) -> bool:
|
def _is_auth_exempt(path: str) -> bool:
|
||||||
if path in AUTH_EXEMPT_EXACT:
|
if path in AUTH_EXEMPT_EXACT:
|
||||||
return True
|
return True
|
||||||
if any(path_is_route_or_child(path, p) for p in AUTH_EXEMPT_PREFIXES):
|
if any(path.startswith(p) for p in AUTH_EXEMPT_PREFIXES):
|
||||||
return True
|
return True
|
||||||
return any(p.match(path) for p in AUTH_EXEMPT_PATTERNS)
|
return any(p.match(path) for p in AUTH_EXEMPT_PATTERNS)
|
||||||
|
|
||||||
@@ -319,16 +216,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()
|
||||||
@@ -362,16 +251,7 @@ 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 = get_application_route_path(request.scope)
|
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
|
||||||
@@ -379,7 +259,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
|
||||||
@@ -391,11 +271,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
|
||||||
@@ -406,10 +286,7 @@ if AUTH_ENABLED:
|
|||||||
if not auth_manager.is_configured:
|
if not auth_manager.is_configured:
|
||||||
# No users yet — redirect to login for first-time setup
|
# No users yet — redirect to login for first-time setup
|
||||||
if not path.startswith("/api/"):
|
if not path.startswith("/api/"):
|
||||||
return RedirectResponse(
|
return RedirectResponse(url="/login", status_code=302)
|
||||||
url=with_asgi_root_path(request.scope, "/login"),
|
|
||||||
status_code=302,
|
|
||||||
)
|
|
||||||
return JSONResponse(status_code=401, content={"error": "Setup required"})
|
return JSONResponse(status_code=401, content={"error": "Setup required"})
|
||||||
|
|
||||||
# --- Bearer token auth (API tokens for external integrations) ---
|
# --- Bearer token auth (API tokens for external integrations) ---
|
||||||
@@ -451,10 +328,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
|
||||||
@@ -471,10 +349,7 @@ if AUTH_ENABLED:
|
|||||||
if not auth_manager.validate_token(token):
|
if not auth_manager.validate_token(token):
|
||||||
if path.startswith("/api/"):
|
if path.startswith("/api/"):
|
||||||
return JSONResponse(status_code=401, content={"error": "Not authenticated"})
|
return JSONResponse(status_code=401, content={"error": "Not authenticated"})
|
||||||
return RedirectResponse(
|
return RedirectResponse(url="/login", status_code=302)
|
||||||
url=with_asgi_root_path(request.scope, "/login"),
|
|
||||||
status_code=302,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Attach current username to request state for downstream routes
|
# Attach current username to request state for downstream routes
|
||||||
request.state.current_user = auth_manager.get_username_for_token(token)
|
request.state.current_user = auth_manager.get_username_for_token(token)
|
||||||
@@ -506,13 +381,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).
|
||||||
@@ -532,8 +413,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",
|
||||||
@@ -548,7 +429,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 =========
|
||||||
@@ -587,21 +468,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"]
|
||||||
@@ -640,31 +514,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,
|
|
||||||
maybe_stop_background_tasks_for_heartbeat,
|
|
||||||
)
|
|
||||||
|
|
||||||
await mark_browser_activity()
|
|
||||||
|
|
||||||
async def _stop_background():
|
|
||||||
try:
|
|
||||||
await maybe_stop_background_tasks_for_heartbeat(
|
|
||||||
task_scheduler.stop_background_tasks_for_foreground
|
|
||||||
)
|
|
||||||
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)
|
||||||
@@ -679,19 +528,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
|
||||||
@@ -708,15 +552,15 @@ 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.search_routes import setup_search_routes
|
from routes.search_routes import setup_search_routes
|
||||||
app.include_router(setup_search_routes(config))
|
app.include_router(setup_search_routes(config))
|
||||||
|
|
||||||
# Presets
|
# Presets
|
||||||
@@ -725,10 +569,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
|
||||||
@@ -743,14 +587,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))
|
||||||
@@ -763,7 +599,7 @@ app.include_router(setup_stt_routes(stt_service))
|
|||||||
logger.info("STT service initialized (provider managed via settings)")
|
logger.info("STT service initialized (provider managed via settings)")
|
||||||
|
|
||||||
# Documents (artifacts/canvas)
|
# Documents (artifacts/canvas)
|
||||||
from routes.document.document_routes import setup_document_routes
|
from routes.document_routes import setup_document_routes
|
||||||
document_router = setup_document_routes(session_manager, upload_handler)
|
document_router = setup_document_routes(session_manager, upload_handler)
|
||||||
app.include_router(document_router)
|
app.include_router(document_router)
|
||||||
|
|
||||||
@@ -772,7 +608,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)
|
||||||
@@ -784,7 +620,7 @@ from src.task_scheduler import TaskScheduler
|
|||||||
task_scheduler = TaskScheduler(session_manager)
|
task_scheduler = TaskScheduler(session_manager)
|
||||||
from src.event_bus import set_task_scheduler
|
from src.event_bus import set_task_scheduler
|
||||||
set_task_scheduler(task_scheduler)
|
set_task_scheduler(task_scheduler)
|
||||||
from routes.task.task_routes import setup_task_routes
|
from routes.task_routes import setup_task_routes
|
||||||
app.include_router(setup_task_routes(task_scheduler))
|
app.include_router(setup_task_routes(task_scheduler))
|
||||||
|
|
||||||
from routes.assistant_routes import setup_assistant_routes
|
from routes.assistant_routes import setup_assistant_routes
|
||||||
@@ -792,7 +628,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)
|
||||||
@@ -803,15 +639,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
|
||||||
@@ -829,7 +662,7 @@ app.include_router(setup_font_routes())
|
|||||||
# MCP (Model Context Protocol)
|
# MCP (Model Context Protocol)
|
||||||
from src.mcp_manager import McpManager
|
from src.mcp_manager import McpManager
|
||||||
from src.agent_tools import set_mcp_manager
|
from src.agent_tools import set_mcp_manager
|
||||||
from routes.mcp.mcp_routes import setup_mcp_routes
|
from routes.mcp_routes import setup_mcp_routes
|
||||||
|
|
||||||
mcp_manager = McpManager()
|
mcp_manager = McpManager()
|
||||||
set_mcp_manager(mcp_manager)
|
set_mcp_manager(mcp_manager)
|
||||||
@@ -844,7 +677,7 @@ set_ai_rag_manager(rag_manager, personal_docs_mgr)
|
|||||||
logger.info("AI interaction tools initialized (session, memory, RAG, UI control)")
|
logger.info("AI interaction tools initialized (session, memory, RAG, UI control)")
|
||||||
|
|
||||||
# Webhooks
|
# Webhooks
|
||||||
from routes.webhook.webhook_routes import setup_webhook_routes
|
from routes.webhook_routes import setup_webhook_routes
|
||||||
app.include_router(setup_webhook_routes(webhook_manager, auth_manager, session_manager, api_key_manager))
|
app.include_router(setup_webhook_routes(webhook_manager, auth_manager, session_manager, api_key_manager))
|
||||||
|
|
||||||
# API Tokens
|
# API Tokens
|
||||||
@@ -854,8 +687,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
|
||||||
@@ -876,11 +709,11 @@ app.include_router(setup_codex_routes(
|
|||||||
))
|
))
|
||||||
app.include_router(setup_claude_routes())
|
app.include_router(setup_claude_routes())
|
||||||
|
|
||||||
from routes.vault.vault_routes import setup_vault_routes
|
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
|
||||||
@@ -888,17 +721,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):
|
||||||
@@ -939,13 +778,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():
|
||||||
@@ -954,35 +791,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:
|
||||||
@@ -1072,7 +881,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:
|
||||||
@@ -1080,11 +889,11 @@ 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
|
||||||
@@ -1096,15 +905,14 @@ async def _startup_event():
|
|||||||
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)
|
||||||
@@ -1115,14 +923,8 @@ async def _startup_event():
|
|||||||
logger.debug(f"Warmup ping skipped: {e}")
|
logger.debug(f"Warmup ping skipped: {e}")
|
||||||
|
|
||||||
_startup_tasks.append(asyncio.create_task(_warmup_endpoints()))
|
_startup_tasks.append(asyncio.create_task(_warmup_endpoints()))
|
||||||
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
|
# Keep-alive: ping endpoints every 60 seconds to prevent cold starts
|
||||||
# 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():
|
async def _keepalive_loop():
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
@@ -1139,7 +941,7 @@ async def _startup_event():
|
|||||||
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())
|
||||||
@@ -1186,7 +988,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
|
||||||
@@ -1258,16 +1060,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():
|
||||||
@@ -1294,12 +1086,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")
|
|
||||||
|
|||||||
@@ -73,10 +73,6 @@ cat > "$APP/Contents/MacOS/$APP_NAME.tmpl" <<'LAUNCHER'
|
|||||||
INSTALL_DIR="__INSTALL_DIR__"
|
INSTALL_DIR="__INSTALL_DIR__"
|
||||||
PORT="__PORT__"
|
PORT="__PORT__"
|
||||||
URL="http://127.0.0.1:${PORT}"
|
URL="http://127.0.0.1:${PORT}"
|
||||||
# uvicorn is started with --port below, but APP_PORT is what the app itself
|
|
||||||
# reads when it needs to build a URL for this instance (internal_api_base(),
|
|
||||||
# companion pairing, the MCP OAuth callback), so export it as well.
|
|
||||||
export APP_PORT="$PORT"
|
|
||||||
export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:$PATH"
|
export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:$PATH"
|
||||||
|
|
||||||
UVICORN="$INSTALL_DIR/venv/bin/uvicorn"
|
UVICORN="$INSTALL_DIR/venv/bin/uvicorn"
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -6,119 +6,18 @@ units so the route layer stays thin and the logic is directly testable.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import ipaddress
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
|
||||||
import secrets
|
import secrets
|
||||||
import socket
|
import socket
|
||||||
import uuid
|
import uuid
|
||||||
from urllib.parse import urlsplit
|
|
||||||
|
|
||||||
import bcrypt
|
import bcrypt
|
||||||
|
|
||||||
from src.constants import AUTH_FILE
|
|
||||||
|
|
||||||
PAIRING_VERSION = 1
|
PAIRING_VERSION = 1
|
||||||
COMPANION_SCOPE = "chat"
|
COMPANION_SCOPE = "chat"
|
||||||
|
|
||||||
|
|
||||||
_COMPANION_IPV4_NETWORKS = tuple(
|
|
||||||
ipaddress.ip_network(cidr)
|
|
||||||
for cidr in (
|
|
||||||
"10.0.0.0/8",
|
|
||||||
"100.64.0.0/10",
|
|
||||||
"127.0.0.0/8",
|
|
||||||
"169.254.0.0/16",
|
|
||||||
"172.16.0.0/12",
|
|
||||||
"192.168.0.0/16",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
_DNS_LABEL_RE = re.compile(r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\Z")
|
|
||||||
|
|
||||||
|
|
||||||
def _valid_companion_client_host(host: str) -> bool:
|
|
||||||
"""Match the host forms supported by the current v1 Expo client."""
|
|
||||||
if not host or len(host) > 253 or not host.isascii() or "%" in host:
|
|
||||||
return False
|
|
||||||
|
|
||||||
try:
|
|
||||||
address = ipaddress.ip_address(host)
|
|
||||||
except ValueError:
|
|
||||||
labels = host.split(".")
|
|
||||||
if any(not _DNS_LABEL_RE.fullmatch(label) for label in labels):
|
|
||||||
return False
|
|
||||||
if any(label.startswith("xn--") for label in labels):
|
|
||||||
return False
|
|
||||||
# WHATWG URL parsers treat a decimal or ``0x`` single-label hostname
|
|
||||||
# as an IPv4 number even though Python's strict ``ipaddress`` parser
|
|
||||||
# rejects that spelling. The v1 client interpolates this host back
|
|
||||||
# into a URL, so accepting e.g. ``134744072`` would make the phone send
|
|
||||||
# its bearer token to public 8.8.8.8. Keep DNS labels unambiguous.
|
|
||||||
if len(labels) == 1 and (
|
|
||||||
labels[0].isdigit()
|
|
||||||
or re.fullmatch(r"0x[0-9a-f]*", labels[0]) is not None
|
|
||||||
):
|
|
||||||
return False
|
|
||||||
return len(labels) == 1 or (len(labels) >= 2 and labels[-1] == "local")
|
|
||||||
|
|
||||||
return isinstance(address, ipaddress.IPv4Address) and any(
|
|
||||||
address in network for network in _COMPANION_IPV4_NETWORKS
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def parse_companion_base_url(value: str) -> tuple[str, int]:
|
|
||||||
"""Validate a v1 companion address and return its legacy (host, port).
|
|
||||||
|
|
||||||
The deployed client understands only HTTP plus a LAN-style host and port.
|
|
||||||
Reject anything outside that exact contract instead of advertising a URL
|
|
||||||
the client would reject, downgrade, or interpret differently.
|
|
||||||
"""
|
|
||||||
if not isinstance(value, str) or not value:
|
|
||||||
raise ValueError("COMPANION_BASE_URL must be a canonical HTTP LAN origin")
|
|
||||||
if not value.isascii():
|
|
||||||
raise ValueError("COMPANION_BASE_URL must contain only ASCII characters")
|
|
||||||
if any(
|
|
||||||
ord(char) <= 32 or ord(char) == 127 or char in {"\\", "%"}
|
|
||||||
for char in value
|
|
||||||
):
|
|
||||||
raise ValueError(
|
|
||||||
"COMPANION_BASE_URL contains a forbidden character"
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
parsed = urlsplit(value)
|
|
||||||
port = parsed.port
|
|
||||||
except ValueError as exc:
|
|
||||||
raise ValueError("COMPANION_BASE_URL must be a valid HTTP LAN origin") from exc
|
|
||||||
|
|
||||||
host = parsed.hostname
|
|
||||||
if parsed.scheme.lower() != "http" or not parsed.netloc or not host:
|
|
||||||
raise ValueError("COMPANION_BASE_URL must be a canonical HTTP LAN origin")
|
|
||||||
if parsed.username is not None or parsed.password is not None:
|
|
||||||
raise ValueError("COMPANION_BASE_URL must not contain credentials")
|
|
||||||
if parsed.path or parsed.query or parsed.fragment:
|
|
||||||
raise ValueError("COMPANION_BASE_URL must not contain a path, query, or fragment")
|
|
||||||
if port is not None and not 1 <= port <= 65535:
|
|
||||||
raise ValueError("COMPANION_BASE_URL port must be between 1 and 65535")
|
|
||||||
if not _valid_companion_client_host(host):
|
|
||||||
raise ValueError("COMPANION_BASE_URL host is not supported by companion v1")
|
|
||||||
|
|
||||||
netloc = f"{host}:{port}" if port is not None else host
|
|
||||||
origin = f"http://{netloc}"
|
|
||||||
if value != origin:
|
|
||||||
raise ValueError("COMPANION_BASE_URL must be a canonical HTTP LAN origin")
|
|
||||||
return host, port or 80
|
|
||||||
|
|
||||||
|
|
||||||
def configured_companion_origin() -> tuple[str, int] | None:
|
|
||||||
"""Return the validated operator-configured v1 address, if any."""
|
|
||||||
value = os.environ.get("COMPANION_BASE_URL")
|
|
||||||
if value is None or value == "":
|
|
||||||
return None
|
|
||||||
return parse_companion_base_url(value)
|
|
||||||
|
|
||||||
|
|
||||||
def default_port() -> int:
|
def default_port() -> int:
|
||||||
"""Best guess at the port the server is reachable on. Callers that know the
|
"""Best guess at the port the server is reachable on. Callers that know the
|
||||||
real request port should pass it explicitly."""
|
real request port should pass it explicitly."""
|
||||||
@@ -162,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,11 +18,11 @@ 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
|
||||||
from src.auth_helpers import _auth_disabled, get_current_user
|
from src.auth_helpers import get_current_user
|
||||||
|
|
||||||
from companion import pairing as _pairing
|
from companion import pairing as _pairing
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -113,22 +100,15 @@ def setup_companion_routes() -> APIRouter:
|
|||||||
The stock /api/models route scopes to get_current_user, which for a
|
The stock /api/models route scopes to get_current_user, which for a
|
||||||
bearer token is the sandboxed pseudo-user "api" (owns nothing). Here we
|
bearer token is the sandboxed pseudo-user "api" (owns nothing). Here we
|
||||||
scope to the token's real owner instead, plus legacy null-owner shared
|
scope to the token's real owner instead, plus legacy null-owner shared
|
||||||
rows -- the same rule as owner_filter. Explicit auth-disabled mode keeps
|
rows -- the same rule as owner_filter. Read-only; never returns api_key
|
||||||
the stock route's single-user all-endpoints view. Read-only; never
|
material.
|
||||||
returns api_key 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
|
||||||
from src.endpoint_resolver import build_chat_url
|
from src.endpoint_resolver import build_chat_url
|
||||||
|
|
||||||
owner = token_owner(request)
|
owner = token_owner(request)
|
||||||
single_user_mode = (
|
|
||||||
owner is None
|
|
||||||
and not getattr(request.state, "api_token", False)
|
|
||||||
and _auth_disabled()
|
|
||||||
)
|
|
||||||
out = []
|
out = []
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
@@ -139,7 +119,7 @@ def setup_companion_routes() -> APIRouter:
|
|||||||
if owner:
|
if owner:
|
||||||
q = q.filter((ModelEndpoint.owner == owner) | (ModelEndpoint.owner == None)) # noqa: E711
|
q = q.filter((ModelEndpoint.owner == owner) | (ModelEndpoint.owner == None)) # noqa: E711
|
||||||
for ep in q.all():
|
for ep in q.all():
|
||||||
if not single_user_mode and not owner_can_see(ep.owner, owner):
|
if not owner_can_see(ep.owner, owner):
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
model_ids = _json.loads(ep.cached_models) if ep.cached_models else []
|
model_ids = _json.loads(ep.cached_models) if ep.cached_models else []
|
||||||
@@ -200,18 +180,10 @@ def setup_companion_routes() -> APIRouter:
|
|||||||
the code works immediately, no restart. `?format=json` returns the
|
the code works immediately, no restart. `?format=json` returns the
|
||||||
payload for an in-app pairing screen."""
|
payload for an in-app pairing screen."""
|
||||||
require_admin(request)
|
require_admin(request)
|
||||||
try:
|
|
||||||
configured_origin = _pairing.configured_companion_origin()
|
|
||||||
except ValueError as exc:
|
|
||||||
raise HTTPException(500, str(exc)) from None
|
|
||||||
owner = get_current_user(request)
|
owner = get_current_user(request)
|
||||||
invalidate = getattr(request.app.state, "invalidate_token_cache", None)
|
invalidate = getattr(request.app.state, "invalidate_token_cache", None)
|
||||||
token_id, raw_token = mint_pairing_token(owner, invalidate)
|
token_id, raw_token = mint_pairing_token(owner, invalidate)
|
||||||
|
|
||||||
if configured_origin:
|
|
||||||
host, port = configured_origin
|
|
||||||
hosts = [host]
|
|
||||||
else:
|
|
||||||
hosts = _pairing.lan_ip_candidates()
|
hosts = _pairing.lan_ip_candidates()
|
||||||
host = hosts[0] if hosts else "127.0.0.1"
|
host = hosts[0] if hosts else "127.0.0.1"
|
||||||
port = request.url.port or _pairing.default_port()
|
port = request.url.port or _pairing.default_port()
|
||||||
@@ -220,7 +192,7 @@ def setup_companion_routes() -> APIRouter:
|
|||||||
qr_ok = bool(qr and qr.startswith("data:image/png;base64,"))
|
qr_ok = bool(qr and qr.startswith("data:image/png;base64,"))
|
||||||
|
|
||||||
if (request.query_params.get("format") or "").lower() == "json":
|
if (request.query_params.get("format") or "").lower() == "json":
|
||||||
response = {
|
return {
|
||||||
"host": host,
|
"host": host,
|
||||||
"port": port,
|
"port": port,
|
||||||
"token": raw_token,
|
"token": raw_token,
|
||||||
@@ -229,7 +201,6 @@ def setup_companion_routes() -> APIRouter:
|
|||||||
"payload": payload,
|
"payload": payload,
|
||||||
"qr": qr if qr_ok else None,
|
"qr": qr if qr_ok else None,
|
||||||
}
|
}
|
||||||
return response
|
|
||||||
|
|
||||||
import json as _json
|
import json as _json
|
||||||
payload_json = _json.dumps(payload, separators=(",", ":"))
|
payload_json = _json.dumps(payload, separators=(",", ":"))
|
||||||
|
|||||||
@@ -15,53 +15,29 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import uuid
|
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
|
||||||
def atomic_write_json(path: str, data: Any, *, indent: Optional[int] = None) -> None:
|
def atomic_write_json(path: str, data: Any, *, indent: Optional[int] = None) -> None:
|
||||||
"""Atomically persist `data` as JSON at `path`.
|
"""Atomically persist `data` as JSON at `path`.
|
||||||
|
|
||||||
The temp file uses a random suffix so two concurrent writers saving the
|
The temp file uses the live PID as a suffix so two processes saving the
|
||||||
same file don't collide on the rename target. A PID suffix does not do
|
same file (e.g. unit tests) don't collide on the rename target.
|
||||||
this: the PID is constant for the life of a process, so two writers on
|
|
||||||
the same path within one process (or one single-process container, where
|
|
||||||
the PID never changes at all) still race for the same temp file.
|
|
||||||
"""
|
"""
|
||||||
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
||||||
tmp = f"{path}.tmp.{uuid.uuid4().hex}"
|
tmp = f"{path}.tmp.{os.getpid()}"
|
||||||
|
|
||||||
try:
|
|
||||||
with open(tmp, "w", encoding="utf-8") as f:
|
with open(tmp, "w", encoding="utf-8") as f:
|
||||||
json.dump(data, f, indent=indent)
|
json.dump(data, f, indent=indent)
|
||||||
f.flush()
|
f.flush()
|
||||||
os.fsync(f.fileno())
|
os.fsync(f.fileno())
|
||||||
os.replace(tmp, path)
|
os.replace(tmp, path)
|
||||||
finally:
|
|
||||||
# Directly unlink to avoid a check-then-act race condition.
|
|
||||||
# Swallows FileNotFoundError (on success path) and other cleanup OSErrors.
|
|
||||||
try:
|
|
||||||
os.unlink(tmp)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
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.{uuid.uuid4().hex}"
|
tmp = f"{path}.tmp.{os.getpid()}"
|
||||||
|
|
||||||
try:
|
|
||||||
with open(tmp, "w", encoding="utf-8") as f:
|
with open(tmp, "w", encoding="utf-8") as f:
|
||||||
f.write(text)
|
f.write(text)
|
||||||
f.flush()
|
f.flush()
|
||||||
os.fsync(f.fileno())
|
os.fsync(f.fileno())
|
||||||
os.replace(tmp, path)
|
os.replace(tmp, path)
|
||||||
finally:
|
|
||||||
# Directly unlink to avoid a check-then-act race condition.
|
|
||||||
# Swallows FileNotFoundError (on success path) and other cleanup OSErrors.
|
|
||||||
try:
|
|
||||||
os.unlink(tmp)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
@@ -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
|
||||||
@@ -31,43 +30,31 @@ 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(
|
||||||
from src.owner_identity import RESERVED_AUTH_USERNAMES
|
Path(__file__).parent.parent, "data", "auth.json"
|
||||||
DEFAULT_AUTH_PATH = AUTH_FILE
|
)
|
||||||
TOKEN_TTL = 60 * 60 * 24 * 7 # 7 days
|
TOKEN_TTL = 60 * 60 * 24 * 7 # 7 days
|
||||||
|
|
||||||
# Usernames the auth + middleware layer reserves for request sentinels and
|
# Usernames the auth + middleware layer reserve as internal "synthetic owner"
|
||||||
# internal storage owners; they must never belong to a real login account.
|
# sentinels; they must never belong to a real account. The most dangerous is
|
||||||
# "internal-tool" is the most dangerous because `core.middleware.require_admin`
|
# "internal-tool": `core.middleware.require_admin` treats any request whose
|
||||||
# treats it as the in-process tool loopback. "api" collides with bearer-token
|
# `current_user == "internal-tool"` as the in-process tool loopback and grants
|
||||||
# attribution. "demo"/"system" are synthetic owners already special-cased by
|
# admin, and because the cookie auth path sets `current_user` to the raw
|
||||||
# scheduler/assistant/research paths. The Default/Local owner is a storage
|
# username, an account literally named "internal-tool" would be silently
|
||||||
# bucket for explicit auth-disabled no-login mode, not a login username.
|
# treated as an admin by every `require_admin`-gated route. "api" collides with
|
||||||
RESERVED_USERNAMES = frozenset(RESERVED_AUTH_USERNAMES)
|
# the bearer-token owner-attribution sentinel. "demo"/"system" round out the
|
||||||
|
# synthetic-owner set the rest of the codebase already special-cases (see
|
||||||
|
# `_SYNTHETIC_OWNERS` in routes/assistant_routes.py and the matching guards in
|
||||||
def normalize_known_username(users: Dict[str, Any], username: str | None) -> Optional[str]:
|
# src/task_scheduler.py / routes/research_routes.py) — a real account with one
|
||||||
"""Return a normalized username only when it exists in the auth user map."""
|
# of those names would be denied an assistant and inconsistently owner-scoped.
|
||||||
key = str(username or "").strip().lower()
|
# Refuse to create or rename into any of them so the sentinels can't be
|
||||||
if not key or key not in users:
|
# impersonated. (Keep this in sync with that synthetic-owner set.)
|
||||||
return None
|
RESERVED_USERNAMES = frozenset({"internal-tool", "api", "demo", "system"})
|
||||||
return key
|
|
||||||
|
|
||||||
|
|
||||||
def _hash_password(password: str) -> str:
|
def _hash_password(password: str) -> str:
|
||||||
@@ -78,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."""
|
||||||
|
|
||||||
@@ -98,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):
|
||||||
@@ -161,15 +134,8 @@ 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: {
|
||||||
@@ -182,31 +148,6 @@ class AuthManager:
|
|||||||
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
|
||||||
@@ -231,7 +172,6 @@ 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()
|
||||||
|
|
||||||
@@ -239,15 +179,6 @@ class AuthManager:
|
|||||||
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
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -267,7 +198,6 @@ 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:
|
||||||
@@ -291,29 +221,12 @@ 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
|
||||||
# Revoke API bearer tokens before removing the auth row. The bearer
|
|
||||||
# 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]
|
del self._config["users"][username]
|
||||||
self._save()
|
self._save()
|
||||||
# Purge all sessions belonging to this user. validate_token doesn't
|
# Purge all sessions belonging to this user. validate_token doesn't
|
||||||
@@ -328,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
|
||||||
|
|
||||||
@@ -341,7 +266,6 @@ 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:
|
||||||
@@ -387,7 +311,6 @@ 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"):
|
||||||
@@ -402,76 +325,12 @@ class AuthManager:
|
|||||||
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
|
||||||
@@ -491,7 +350,6 @@ 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
|
||||||
@@ -512,7 +370,6 @@ 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)
|
||||||
@@ -538,7 +395,6 @@ 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()
|
||||||
@@ -552,7 +408,6 @@ 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)
|
||||||
@@ -576,17 +431,7 @@ 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:
|
|
||||||
if username not in self.users:
|
|
||||||
logger.warning("Refused to issue session for missing user '%s'", username)
|
|
||||||
return None
|
|
||||||
with self._sessions_lock:
|
with self._sessions_lock:
|
||||||
self._sessions[token] = {
|
self._sessions[token] = {
|
||||||
"username": username,
|
"username": username,
|
||||||
|
|||||||
@@ -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>"
|
|
||||||
@@ -3,14 +3,10 @@
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
from collections.abc import Mapping
|
|
||||||
|
|
||||||
from fastapi import HTTPException, Request
|
from fastapi import HTTPException, Request
|
||||||
from starlette.middleware.base import BaseHTTPMiddleware
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
from starlette.responses import Response
|
from starlette.responses import Response
|
||||||
from starlette.routing import get_route_path
|
|
||||||
|
|
||||||
from src.owner_identity import INTERNAL_TOOL_USER, auth_disabled
|
|
||||||
|
|
||||||
|
|
||||||
# Per-process token that lets the in-app tool layer hit admin-gated
|
# Per-process token that lets the in-app tool layer hit admin-gated
|
||||||
@@ -21,39 +17,6 @@ INTERNAL_TOOL_TOKEN = os.environ.get("ODYSSEUS_INTERNAL_TOKEN") or secrets.token
|
|||||||
INTERNAL_TOOL_HEADER = "X-Odysseus-Internal-Token"
|
INTERNAL_TOOL_HEADER = "X-Odysseus-Internal-Token"
|
||||||
|
|
||||||
|
|
||||||
def get_application_route_path(scope: Mapping[str, object]) -> str:
|
|
||||||
"""Return the application-relative path used by Starlette routing.
|
|
||||||
|
|
||||||
Uvicorn prefixes ``scope["path"]`` with a configured ASGI ``root_path``;
|
|
||||||
Starlette removes that prefix before matching routes. Middleware policy
|
|
||||||
must use the same path form or a deployment prefix can change which policy
|
|
||||||
applies to an otherwise unchanged application route.
|
|
||||||
"""
|
|
||||||
return get_route_path(scope)
|
|
||||||
|
|
||||||
|
|
||||||
def with_asgi_root_path(scope: Mapping[str, object], path: str) -> str:
|
|
||||||
"""Prefix an application path for a client-facing redirect target."""
|
|
||||||
root_path = scope.get("root_path", "")
|
|
||||||
if not isinstance(root_path, str) or not root_path:
|
|
||||||
return path
|
|
||||||
return f"{root_path.rstrip('/')}{path}"
|
|
||||||
|
|
||||||
|
|
||||||
def path_is_route_or_child(path: str, prefix: str) -> bool:
|
|
||||||
"""Return whether ``path`` is exactly ``prefix`` or below that route."""
|
|
||||||
return path == prefix or path.startswith(prefix + "/")
|
|
||||||
|
|
||||||
|
|
||||||
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):
|
||||||
"""Raise 403 if the current user isn't an admin.
|
"""Raise 403 if the current user isn't an admin.
|
||||||
Allows access when auth is explicitly disabled, or when the request carries
|
Allows access when auth is explicitly disabled, or when the request carries
|
||||||
@@ -67,13 +30,13 @@ 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
|
||||||
|
|
||||||
auth_mgr = getattr(request.app.state, "auth_manager", None)
|
auth_mgr = getattr(request.app.state, "auth_manager", None)
|
||||||
if auth_disabled():
|
if os.getenv("AUTH_ENABLED", "true").lower() == "false":
|
||||||
return
|
return
|
||||||
if not auth_mgr or not auth_mgr.is_configured:
|
if not auth_mgr or not auth_mgr.is_configured:
|
||||||
raise HTTPException(403, "Admin only")
|
raise HTTPException(403, "Admin only")
|
||||||
@@ -93,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"] = (
|
||||||
@@ -122,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.
|
||||||
@@ -143,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'; "
|
||||||
|
|||||||
@@ -8,61 +8,17 @@ These are simple datacontainers. All persistence is handled by SessionManager.
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Dict, List, Any, Optional, TYPE_CHECKING
|
from typing import Dict, List, Any, Optional, TYPE_CHECKING
|
||||||
|
|
||||||
from src.tool_approval_scopes import (
|
|
||||||
CHAT_SESSION_APPROVAL_CONTEXT_MARKER,
|
|
||||||
CHAT_SESSION_APPROVAL_DECISION,
|
|
||||||
)
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def _history_grants_chat_session_approval(
|
|
||||||
history: List["ChatMessage"],
|
|
||||||
session_id: str,
|
|
||||||
) -> bool:
|
|
||||||
"""Return whether this exact chat has a resolved session-scope grant."""
|
|
||||||
|
|
||||||
expected_session = str(session_id or "")
|
|
||||||
if not expected_session:
|
|
||||||
return False
|
|
||||||
for message in reversed(history or []):
|
|
||||||
metadata = getattr(message, "metadata", None)
|
|
||||||
if not isinstance(metadata, dict):
|
|
||||||
continue
|
|
||||||
tool_events = metadata.get("tool_events")
|
|
||||||
if not isinstance(tool_events, list):
|
|
||||||
continue
|
|
||||||
for event in reversed(tool_events):
|
|
||||||
ask_user = event.get("ask_user") if isinstance(event, dict) else None
|
|
||||||
if not isinstance(ask_user, dict):
|
|
||||||
continue
|
|
||||||
if (
|
|
||||||
ask_user.get("kind") == "tool_approval"
|
|
||||||
and ask_user.get("resolved") == CHAT_SESSION_APPROVAL_DECISION
|
|
||||||
and str(ask_user.get("session_id") or "") == expected_session
|
|
||||||
):
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -86,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
|
||||||
@@ -110,72 +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.
|
|
||||||
"""
|
|
||||||
messages = [
|
|
||||||
msg.to_dict()
|
|
||||||
for msg in self.history
|
|
||||||
if (msg.metadata or {}).get("source") != "slash"
|
|
||||||
]
|
|
||||||
if not _history_grants_chat_session_approval(self.history, self.id):
|
|
||||||
return messages
|
|
||||||
|
|
||||||
# Keep the grant close to the latest user request so route-neutral
|
|
||||||
# compaction/trimming preserves it. Copy the metadata instead of
|
|
||||||
# mutating the durable transcript object.
|
|
||||||
for index in range(len(messages) - 1, -1, -1):
|
|
||||||
if messages[index].get("role") != "user":
|
|
||||||
continue
|
|
||||||
message = dict(messages[index])
|
|
||||||
metadata = dict(message.get("metadata") or {})
|
|
||||||
metadata[CHAT_SESSION_APPROVAL_CONTEXT_MARKER] = True
|
|
||||||
message["metadata"] = metadata
|
|
||||||
messages[index] = message
|
|
||||||
break
|
|
||||||
return messages
|
|
||||||
|
|
||||||
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,15 +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 sqlalchemy import func
|
from .database import Session as DbSession, ChatMessage as DbChatMessage, Document as DbDocument, SessionLocal
|
||||||
|
|
||||||
from .database import Session as DbSession, ChatMessage as DbChatMessage, Document as DbDocument, SessionLocal, utcnow_naive
|
|
||||||
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__)
|
||||||
|
|
||||||
@@ -44,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
|
||||||
@@ -76,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()
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -94,28 +75,14 @@ class SessionManager:
|
|||||||
try:
|
try:
|
||||||
db_sessions = db.query(DbSession).filter(
|
db_sessions = db.query(DbSession).filter(
|
||||||
DbSession.archived == False,
|
DbSession.archived == False,
|
||||||
DbSession.messages.any(),
|
DbSession.message_count > 0,
|
||||||
).order_by(DbSession.last_accessed.desc()).limit(100).all()
|
).order_by(DbSession.last_accessed.desc()).limit(100).all()
|
||||||
|
|
||||||
# message_count is derived metadata and can drift after interrupted
|
|
||||||
# or legacy writes. Count only the bounded discovery set so startup
|
|
||||||
# remains metadata-only while lazy hydration sees an authoritative
|
|
||||||
# positive count for every discovered non-empty session.
|
|
||||||
message_counts = {}
|
|
||||||
if db_sessions:
|
|
||||||
message_counts = dict(
|
|
||||||
db.query(DbChatMessage.session_id, func.count(DbChatMessage.id))
|
|
||||||
.filter(DbChatMessage.session_id.in_([row.id for row in db_sessions]))
|
|
||||||
.group_by(DbChatMessage.session_id)
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
|
|
||||||
loaded_count = 0
|
loaded_count = 0
|
||||||
for db_session in db_sessions:
|
for db_session in db_sessions:
|
||||||
try:
|
try:
|
||||||
session = self._db_to_session_meta(db_session)
|
session = self._db_to_session_meta(db_session)
|
||||||
if session is not None:
|
if session is not None:
|
||||||
session.message_count = message_counts[db_session.id]
|
|
||||||
self.sessions[db_session.id] = session
|
self.sessions[db_session.id] = session
|
||||||
loaded_count += 1
|
loaded_count += 1
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -210,12 +177,7 @@ class SessionManager:
|
|||||||
is_important=getattr(db_session, 'is_important', False) or False,
|
is_important=getattr(db_session, 'is_important', False) or False,
|
||||||
)
|
)
|
||||||
|
|
||||||
# The rows just loaded are the whole transcript, so they — not the
|
session.message_count = getattr(db_session, 'message_count', len(history))
|
||||||
# denormalized sessions.message_count column — are the truth for this
|
|
||||||
# cached object. get_session's hydration gate compares against this
|
|
||||||
# number; seeding it from a drifted column would ask for a reload that
|
|
||||||
# can never close the gap.
|
|
||||||
session.message_count = len(history)
|
|
||||||
return session
|
return session
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -226,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)
|
||||||
@@ -254,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,
|
||||||
@@ -284,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
|
||||||
@@ -328,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
|
||||||
@@ -355,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):
|
||||||
@@ -385,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),
|
||||||
)
|
)
|
||||||
@@ -396,6 +321,8 @@ class SessionManager:
|
|||||||
message.metadata = {}
|
message.metadata = {}
|
||||||
message.metadata["_db_id"] = msg_id
|
message.metadata["_db_id"] = msg_id
|
||||||
|
|
||||||
|
db_session = db.query(DbSession).filter(DbSession.id == session_id).first()
|
||||||
|
if db_session:
|
||||||
db_session.message_count = len(messages)
|
db_session.message_count = len(messages)
|
||||||
db_session.updated_at = now
|
db_session.updated_at = now
|
||||||
db_session.last_accessed = now
|
db_session.last_accessed = now
|
||||||
@@ -403,7 +330,6 @@ class SessionManager:
|
|||||||
|
|
||||||
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
|
||||||
@@ -419,50 +345,30 @@ class SessionManager:
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def get_session(self, session_id: str) -> Session:
|
def get_session(self, session_id: str) -> Session:
|
||||||
"""Get a session by ID, loading complete DB history when needed.
|
"""Get a session by ID, loading from DB if needed.
|
||||||
|
|
||||||
Sessions seeded by ``load_sessions`` start with empty history, and a
|
Sessions seeded by `load_sessions` start with empty history. The
|
||||||
cached session can also become partially stale. Refresh metadata first,
|
first read here hydrates them with the message rows.
|
||||||
then hydrate whenever the cached transcript is short of the stored rows.
|
|
||||||
Model-send routes enter through this method before building context,
|
|
||||||
while paginated display history reads SQLite directly.
|
|
||||||
|
|
||||||
The gate compares against ``sync_session_metadata``'s reconciled count
|
|
||||||
(the real ``chat_messages`` total), never the denormalized column, so a
|
|
||||||
hydrate always closes the gap and the next read is a cache hit.
|
|
||||||
"""
|
"""
|
||||||
if session_id not in self.sessions:
|
if session_id not in self.sessions:
|
||||||
self._load_session_from_db(session_id)
|
self._load_session_from_db(session_id)
|
||||||
|
else:
|
||||||
|
cached = self.sessions[session_id]
|
||||||
|
# Lazy hydrate: metadata-only entries get their messages on first read.
|
||||||
|
if not cached.history and getattr(cached, "message_count", 0) > 0:
|
||||||
|
self._load_session_from_db(session_id)
|
||||||
|
|
||||||
# Keep model/endpoint metadata fresh. Endpoint deletion can clear the
|
# Keep model/endpoint metadata fresh. Endpoint deletion can clear the
|
||||||
# DB row while a session object is still cached in RAM. Refreshing first
|
# DB row while a session object is still cached in RAM.
|
||||||
# also exposes the authoritative message count before completeness is
|
|
||||||
# checked.
|
|
||||||
self.sync_session_metadata(session_id)
|
self.sync_session_metadata(session_id)
|
||||||
|
|
||||||
cached = self.sessions[session_id]
|
|
||||||
cached_count = len(cached.history or [])
|
|
||||||
stored_count = int(getattr(cached, "message_count", 0) or 0)
|
|
||||||
if cached_count < stored_count:
|
|
||||||
self._load_session_from_db(session_id)
|
|
||||||
|
|
||||||
# Update last_accessed
|
# Update last_accessed
|
||||||
self._touch_session(session_id)
|
self._touch_session(session_id)
|
||||||
|
|
||||||
return self.sessions[session_id]
|
return self.sessions[session_id]
|
||||||
|
|
||||||
def sync_session_metadata(self, session_id: str) -> bool:
|
def sync_session_metadata(self, session_id: str) -> bool:
|
||||||
"""Refresh non-message session fields from the DB into the cached object.
|
"""Refresh non-message session fields from the DB into the cached object."""
|
||||||
|
|
||||||
``message_count`` is reconciled against the real ``chat_messages`` rows
|
|
||||||
rather than copied from the denormalized ``sessions.message_count``
|
|
||||||
column. That column drifts in normal operation — ``_persist_message``
|
|
||||||
swallows a failed insert but ``add_message`` has already appended in
|
|
||||||
memory, so the next successful persist writes rows+1, and a persist for
|
|
||||||
an uncached session writes 0. Hydration keys off this number: a
|
|
||||||
drifted-high column would reload the whole transcript on every warm
|
|
||||||
read, and a drifted-low one would leave the model a truncated one.
|
|
||||||
"""
|
|
||||||
session = self.sessions.get(session_id)
|
session = self.sessions.get(session_id)
|
||||||
if session is None:
|
if session is None:
|
||||||
return False
|
return False
|
||||||
@@ -485,11 +391,7 @@ class SessionManager:
|
|||||||
session.archived = db_session.archived
|
session.archived = db_session.archived
|
||||||
session.owner = getattr(db_session, "owner", None)
|
session.owner = getattr(db_session, "owner", None)
|
||||||
session.is_important = getattr(db_session, "is_important", False) or False
|
session.is_important = getattr(db_session, "is_important", False) or False
|
||||||
session.message_count = (
|
session.message_count = getattr(db_session, "message_count", session.message_count) or 0
|
||||||
db.query(DbChatMessage)
|
|
||||||
.filter(DbChatMessage.session_id == session_id)
|
|
||||||
.count()
|
|
||||||
)
|
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error syncing session metadata {session_id}: {e}")
|
logger.error(f"Error syncing session metadata {session_id}: {e}")
|
||||||
@@ -588,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
|
||||||
@@ -709,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)
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -46,40 +46,21 @@ services:
|
|||||||
- DATABASE_URL=${DATABASE_URL:-sqlite:///./data/app.db}
|
- DATABASE_URL=${DATABASE_URL:-sqlite:///./data/app.db}
|
||||||
- AUTH_ENABLED=${AUTH_ENABLED:-true}
|
- AUTH_ENABLED=${AUTH_ENABLED:-true}
|
||||||
- LOCALHOST_BYPASS=${LOCALHOST_BYPASS:-false}
|
- LOCALHOST_BYPASS=${LOCALHOST_BYPASS:-false}
|
||||||
- COMPANION_BASE_URL=${COMPANION_BASE_URL:-}
|
|
||||||
- ODYSSEUS_ADMIN_USER=${ODYSSEUS_ADMIN_USER:-admin}
|
- ODYSSEUS_ADMIN_USER=${ODYSSEUS_ADMIN_USER:-admin}
|
||||||
- ODYSSEUS_ADMIN_PASSWORD=${ODYSSEUS_ADMIN_PASSWORD:-}
|
- ODYSSEUS_ADMIN_PASSWORD=${ODYSSEUS_ADMIN_PASSWORD:-}
|
||||||
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-http://localhost,http://127.0.0.1}
|
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-http://localhost,http://127.0.0.1}
|
||||||
- SECURE_COOKIES=${SECURE_COOKIES:-}
|
- 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}
|
|
||||||
- ODYSSEUS_TTS_CACHE_MAX_BYTES=${ODYSSEUS_TTS_CACHE_MAX_BYTES}
|
|
||||||
- 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:-}
|
|
||||||
# Externally reachable origin for MCP OAuth callbacks. The container
|
|
||||||
# always listens on 7000 and cannot see the host port map above, so
|
|
||||||
# remote MCP OAuth needs this set whenever the browser reaches
|
|
||||||
# Odysseus on anything other than http://localhost:7000.
|
|
||||||
- OAUTH_REDIRECT_BASE_URL=${OAUTH_REDIRECT_BASE_URL:-}
|
|
||||||
- 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
|
||||||
@@ -135,17 +116,12 @@ services:
|
|||||||
fi
|
fi
|
||||||
sed "s|__SEARXNG_SECRET__|$$secret|g" /tmp/searxng-settings.yml.template > /etc/searxng/settings.yml
|
sed "s|__SEARXNG_SECRET__|$$secret|g" /tmp/searxng-settings.yml.template > /etc/searxng/settings.yml
|
||||||
fi
|
fi
|
||||||
# Advisory: a settings file the migration cannot parse or rewrite must
|
|
||||||
# not be what stops searxng from booting. It explains itself on stderr
|
|
||||||
# and we carry on, letting searxng report anything genuinely wrong.
|
|
||||||
/usr/local/searxng/.venv/bin/python /tmp/migrate-searxng-settings.py /etc/searxng/settings.yml || true
|
|
||||||
exec /usr/local/searxng/entrypoint.sh
|
exec /usr/local/searxng/entrypoint.sh
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:8080:8080"
|
- "127.0.0.1:8080:8080"
|
||||||
volumes:
|
volumes:
|
||||||
- searxng-data:/etc/searxng
|
- searxng-data:/etc/searxng
|
||||||
- ./config/searxng/settings.yml:/tmp/searxng-settings.yml.template:ro,z
|
- ./config/searxng/settings.yml:/tmp/searxng-settings.yml.template:ro,z
|
||||||
- ./scripts/migrate_searxng_settings.py:/tmp/migrate-searxng-settings.py:ro,z
|
|
||||||
environment:
|
environment:
|
||||||
- SEARXNG_BASE_URL=http://localhost:8080/
|
- SEARXNG_BASE_URL=http://localhost:8080/
|
||||||
- SEARXNG_SECRET=${SEARXNG_SECRET:-}
|
- SEARXNG_SECRET=${SEARXNG_SECRET:-}
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -45,40 +45,21 @@ services:
|
|||||||
- DATABASE_URL=${DATABASE_URL:-sqlite:///./data/app.db}
|
- DATABASE_URL=${DATABASE_URL:-sqlite:///./data/app.db}
|
||||||
- AUTH_ENABLED=${AUTH_ENABLED:-true}
|
- AUTH_ENABLED=${AUTH_ENABLED:-true}
|
||||||
- LOCALHOST_BYPASS=${LOCALHOST_BYPASS:-false}
|
- LOCALHOST_BYPASS=${LOCALHOST_BYPASS:-false}
|
||||||
- COMPANION_BASE_URL=${COMPANION_BASE_URL:-}
|
|
||||||
- ODYSSEUS_ADMIN_USER=${ODYSSEUS_ADMIN_USER:-admin}
|
- ODYSSEUS_ADMIN_USER=${ODYSSEUS_ADMIN_USER:-admin}
|
||||||
- ODYSSEUS_ADMIN_PASSWORD=${ODYSSEUS_ADMIN_PASSWORD:-}
|
- ODYSSEUS_ADMIN_PASSWORD=${ODYSSEUS_ADMIN_PASSWORD:-}
|
||||||
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-http://localhost,http://127.0.0.1}
|
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-http://localhost,http://127.0.0.1}
|
||||||
- SECURE_COOKIES=${SECURE_COOKIES:-}
|
- 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}
|
|
||||||
- ODYSSEUS_TTS_CACHE_MAX_BYTES=${ODYSSEUS_TTS_CACHE_MAX_BYTES}
|
|
||||||
- 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:-}
|
|
||||||
# Externally reachable origin for MCP OAuth callbacks. The container
|
|
||||||
# always listens on 7000 and cannot see the host port map above, so
|
|
||||||
# remote MCP OAuth needs this set whenever the browser reaches
|
|
||||||
# Odysseus on anything other than http://localhost:7000.
|
|
||||||
- OAUTH_REDIRECT_BASE_URL=${OAUTH_REDIRECT_BASE_URL:-}
|
|
||||||
- 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
|
||||||
@@ -138,17 +119,12 @@ services:
|
|||||||
fi
|
fi
|
||||||
sed "s|__SEARXNG_SECRET__|$$secret|g" /tmp/searxng-settings.yml.template > /etc/searxng/settings.yml
|
sed "s|__SEARXNG_SECRET__|$$secret|g" /tmp/searxng-settings.yml.template > /etc/searxng/settings.yml
|
||||||
fi
|
fi
|
||||||
# Advisory: a settings file the migration cannot parse or rewrite must
|
|
||||||
# not be what stops searxng from booting. It explains itself on stderr
|
|
||||||
# and we carry on, letting searxng report anything genuinely wrong.
|
|
||||||
/usr/local/searxng/.venv/bin/python /tmp/migrate-searxng-settings.py /etc/searxng/settings.yml || true
|
|
||||||
exec /usr/local/searxng/entrypoint.sh
|
exec /usr/local/searxng/entrypoint.sh
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:8080:8080"
|
- "127.0.0.1:8080:8080"
|
||||||
volumes:
|
volumes:
|
||||||
- searxng-data:/etc/searxng
|
- searxng-data:/etc/searxng
|
||||||
- ./config/searxng/settings.yml:/tmp/searxng-settings.yml.template:ro,z
|
- ./config/searxng/settings.yml:/tmp/searxng-settings.yml.template:ro,z
|
||||||
- ./scripts/migrate_searxng_settings.py:/tmp/migrate-searxng-settings.py:ro,z
|
|
||||||
environment:
|
environment:
|
||||||
- SEARXNG_BASE_URL=http://localhost:8080/
|
- SEARXNG_BASE_URL=http://localhost:8080/
|
||||||
- SEARXNG_SECRET=${SEARXNG_SECRET:-}
|
- SEARXNG_SECRET=${SEARXNG_SECRET:-}
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -34,40 +34,21 @@ services:
|
|||||||
- DATABASE_URL=${DATABASE_URL:-sqlite:///./data/app.db}
|
- DATABASE_URL=${DATABASE_URL:-sqlite:///./data/app.db}
|
||||||
- AUTH_ENABLED=${AUTH_ENABLED:-true}
|
- AUTH_ENABLED=${AUTH_ENABLED:-true}
|
||||||
- LOCALHOST_BYPASS=${LOCALHOST_BYPASS:-false}
|
- LOCALHOST_BYPASS=${LOCALHOST_BYPASS:-false}
|
||||||
- COMPANION_BASE_URL=${COMPANION_BASE_URL:-}
|
|
||||||
- ODYSSEUS_ADMIN_USER=${ODYSSEUS_ADMIN_USER:-admin}
|
- ODYSSEUS_ADMIN_USER=${ODYSSEUS_ADMIN_USER:-admin}
|
||||||
- ODYSSEUS_ADMIN_PASSWORD=${ODYSSEUS_ADMIN_PASSWORD:-}
|
- ODYSSEUS_ADMIN_PASSWORD=${ODYSSEUS_ADMIN_PASSWORD:-}
|
||||||
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-http://localhost,http://127.0.0.1}
|
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-http://localhost,http://127.0.0.1}
|
||||||
- SECURE_COOKIES=${SECURE_COOKIES:-}
|
- 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}
|
|
||||||
- ODYSSEUS_TTS_CACHE_MAX_BYTES=${ODYSSEUS_TTS_CACHE_MAX_BYTES}
|
|
||||||
- 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:-}
|
|
||||||
# Externally reachable origin for MCP OAuth callbacks. The container
|
|
||||||
# always listens on 7000 and cannot see the host port map above, so
|
|
||||||
# remote MCP OAuth needs this set whenever the browser reaches
|
|
||||||
# Odysseus on anything other than http://localhost:7000.
|
|
||||||
- OAUTH_REDIRECT_BASE_URL=${OAUTH_REDIRECT_BASE_URL:-}
|
|
||||||
- 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
|
||||||
@@ -116,17 +97,12 @@ services:
|
|||||||
fi
|
fi
|
||||||
sed "s|__SEARXNG_SECRET__|$$secret|g" /tmp/searxng-settings.yml.template > /etc/searxng/settings.yml
|
sed "s|__SEARXNG_SECRET__|$$secret|g" /tmp/searxng-settings.yml.template > /etc/searxng/settings.yml
|
||||||
fi
|
fi
|
||||||
# Advisory: a settings file the migration cannot parse or rewrite must
|
|
||||||
# not be what stops searxng from booting. It explains itself on stderr
|
|
||||||
# and we carry on, letting searxng report anything genuinely wrong.
|
|
||||||
/usr/local/searxng/.venv/bin/python /tmp/migrate-searxng-settings.py /etc/searxng/settings.yml || true
|
|
||||||
exec /usr/local/searxng/entrypoint.sh
|
exec /usr/local/searxng/entrypoint.sh
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:8080:8080"
|
- "127.0.0.1:8080:8080"
|
||||||
volumes:
|
volumes:
|
||||||
- searxng-data:/etc/searxng
|
- searxng-data:/etc/searxng
|
||||||
- ./config/searxng/settings.yml:/tmp/searxng-settings.yml.template:ro,z
|
- ./config/searxng/settings.yml:/tmp/searxng-settings.yml.template:ro,z
|
||||||
- ./scripts/migrate_searxng_settings.py:/tmp/migrate-searxng-settings.py:ro,z
|
|
||||||
environment:
|
environment:
|
||||||
- SEARXNG_BASE_URL=http://localhost:8080/
|
- SEARXNG_BASE_URL=http://localhost:8080/
|
||||||
- SEARXNG_SECRET=${SEARXNG_SECRET:-}
|
- SEARXNG_SECRET=${SEARXNG_SECRET:-}
|
||||||
|
|||||||
@@ -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,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,85 +0,0 @@
|
|||||||
# Attachment References and Upload Storage
|
|
||||||
|
|
||||||
Odysseus stores uploaded bytes once under the configured upload directory and
|
|
||||||
passes stable references through chat history, tools, and future artifact work.
|
|
||||||
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.
|
|
||||||
|
|
||||||
## 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 |
|
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,744 +0,0 @@
|
|||||||
# Odysseus Setup Guide
|
|
||||||
|
|
||||||
This page keeps the detailed install, deployment, troubleshooting, and configuration notes out of the front README.
|
|
||||||
|
|
||||||
## 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 [CONTRIBUTING.md](../CONTRIBUTING.md) for 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.
|
|
||||||
|
|
||||||
**Tool calls not firing on a manually-added Ollama `/v1` endpoint.** By
|
|
||||||
design, a local Ollama `/v1` endpoint defaults to the conservative
|
|
||||||
text-based (fenced-block) tool-calling path rather than native structured
|
|
||||||
tool calls, since some locally-served models mishandle native schemas (see
|
|
||||||
#1567). This is correct for most local setups, but if you know your specific
|
|
||||||
model reliably supports native tool calling (check `ollama show <model>` for
|
|
||||||
`tools` under Capabilities), you can opt that endpoint in explicitly. There
|
|
||||||
is currently no UI control for this on manually-added endpoints (see #5192);
|
|
||||||
the flag can still be set directly against the existing API, from a browser
|
|
||||||
console on an authenticated admin session:
|
|
||||||
|
|
||||||
```js
|
|
||||||
fetch('/api/model-endpoints/<endpoint-id>', {
|
|
||||||
method: 'PATCH',
|
|
||||||
credentials: 'same-origin',
|
|
||||||
headers: {'Content-Type': 'application/json'},
|
|
||||||
body: JSON.stringify({supports_tools: true})
|
|
||||||
}).then(r => r.json()).then(console.log)
|
|
||||||
```
|
|
||||||
|
|
||||||
Find `<endpoint-id>` by inspecting the `/api/model-endpoints` response (or
|
|
||||||
your browser's network tab while Settings loads the endpoint list). Send
|
|
||||||
`supports_tools: false` to disable native structured tool calls and force the
|
|
||||||
conservative fenced/text path, or `supports_tools: null` to return the endpoint
|
|
||||||
to the Auto heuristic.
|
|
||||||
|
|
||||||
**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. |
|
|
||||||
| `kokoro`, `soundfile` | Local Kokoro-82M text-to-speech on a CUDA GPU. The pinned Kokoro release supports Odysseus installs on Python 3.11-3.12; these packages are intentionally skipped on Python 3.13+ (including the Python 3.14 container image). |
|
|
||||||
| `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). |
|
|
||||||
|
|
||||||
Install the optional set only when you need these features:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pip install -r requirements-optional.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
The default Docker image currently uses Python 3.14, while Kokoro 0.9.4 declares Python `>=3.10,<3.13`. Odysseus itself continues to support Python 3.11+, but this pinned optional local-TTS feature requires a native Python 3.11 or 3.12 environment. Kokoro declares `torch`, but the local provider only activates when that torch build has CUDA and a GPU is visible; install the CUDA build appropriate for your host. Browser and configured endpoint TTS remain available on Python 3.13+ and in the container image.
|
|
||||||
|
|
||||||
### 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 [docs/email-outlook.md](docs/email-outlook.md) for the
|
|
||||||
current limitation and the 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.
|
|
||||||
- Leave `SECURE_COOKIES` unset unless you need to override it: session cookies are marked `Secure` whenever the request arrives over HTTPS. Use `SECURE_COOKIES=true` to force it on for a proxy whose scheme Odysseus cannot see, or `SECURE_COOKIES=false` to force it off while you still serve plain HTTP alongside HTTPS.
|
|
||||||
- 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.
|
|
||||||
|
|
||||||
> **Upgrading an existing install:** `SECURE_COOKIES` used to default to
|
|
||||||
> `false`, so an install set up before scheme derivation may still carry
|
|
||||||
> `SECURE_COOKIES=false` in its own `.env`. That explicit value stays
|
|
||||||
> authoritative, so HTTPS logins keep getting a non-`Secure` session cookie.
|
|
||||||
> Pulling this change updates the tracked Compose files, but nothing rewrites
|
|
||||||
> your `.env` — drop the line from it unless you deliberately serve plain HTTP
|
|
||||||
> alongside HTTPS and want the escape hatch.
|
|
||||||
|
|
||||||
### 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` and `LOCALHOST_BYPASS=false`. Any proxy that forwards `X-Forwarded-Proto: https` gets `Secure` session cookies without configuration, so `SECURE_COOKIES` only needs setting when you want to override that — force it on for a proxy that forwards no scheme at all, or off while you still serve plain HTTP.
|
|
||||||
`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.
|
|
||||||
|
|
||||||
#### Faster over the network: HTTP/2
|
|
||||||
|
|
||||||
The frontend is raw ES modules with no bundler, so a page load is a few hundred
|
|
||||||
small same-origin requests. Over HTTP/1.1 browsers typically allow only a small
|
|
||||||
number of concurrent connections per host (commonly around six), so many of
|
|
||||||
those requests are serialized across multiple round trips. On localhost that
|
|
||||||
costs almost nothing. Over a LAN, VPN, or remote link it can become a major
|
|
||||||
part of load time, especially as latency increases.
|
|
||||||
|
|
||||||
HTTP/2 multiplexes them onto one connection and the serialisation disappears.
|
|
||||||
Odysseus needs no changes for this — uvicorn keeps speaking HTTP/1.1 on
|
|
||||||
loopback and the proxy speaks HTTP/2 to the browser. Mainstream browsers
|
|
||||||
negotiate HTTP/2 for normal web pages over TLS; they do not use the cleartext
|
|
||||||
h2c mode here, so browser-facing HTTP/2 requires a certificate. The
|
|
||||||
`--ssl-certfile` route in *HTTPS + LAN/Tailscale exposure* above gives you
|
|
||||||
HTTPS but not HTTP/2 — uvicorn does not speak it.
|
|
||||||
|
|
||||||
**1. Install Caddy.** See the [install docs](https://caddyserver.com/docs/install)
|
|
||||||
for your platform; on macOS, `brew install caddy`.
|
|
||||||
|
|
||||||
**2. Write a `Caddyfile`.** Pick the block that matches how you reach the
|
|
||||||
machine. Replace `7000` if Odysseus listens elsewhere — the macOS start script
|
|
||||||
uses `7860`.
|
|
||||||
|
|
||||||
Public domain, Caddy obtains and renews the certificate itself:
|
|
||||||
|
|
||||||
```
|
|
||||||
odysseus.example.com {
|
|
||||||
reverse_proxy 127.0.0.1:7000
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Tailscale, no public DNS needed — `tailscale cert` issues a browser-trusted
|
|
||||||
certificate for a tailnet name and writes `<domain>.crt` and `<domain>.key`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
tailscale cert myhost.tailnet-name.ts.net
|
|
||||||
```
|
|
||||||
|
|
||||||
```
|
|
||||||
myhost.tailnet-name.ts.net {
|
|
||||||
tls /path/to/myhost.tailnet-name.ts.net.crt /path/to/myhost.tailnet-name.ts.net.key
|
|
||||||
reverse_proxy 127.0.0.1:7000
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
LAN with your own certificate — same shape, your own files:
|
|
||||||
|
|
||||||
```
|
|
||||||
odysseus.lan {
|
|
||||||
tls /path/to/cert.pem /path/to/key.pem
|
|
||||||
reverse_proxy 127.0.0.1:7000
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Give `tls` absolute paths: a service starts in a working directory you did not
|
|
||||||
choose. If port 443 is already taken, append a port to the site address
|
|
||||||
(`odysseus.example.com:8443`) and use it in the URL. That alone does not free
|
|
||||||
port 80 — Caddy still binds it for the HTTP-to-HTTPS redirect, and fails to
|
|
||||||
start with `listen tcp :80: bind: address already in use` if something else
|
|
||||||
holds it. Turn the redirect off with a global block at the top of the file:
|
|
||||||
|
|
||||||
```
|
|
||||||
{
|
|
||||||
auto_https disable_redirects
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**3. Run it in the foreground first:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
caddy run --config ./Caddyfile
|
|
||||||
```
|
|
||||||
|
|
||||||
Once that works, run it as a service:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
brew services start caddy # macOS — reads $(brew --prefix)/etc/Caddyfile, not ./Caddyfile
|
|
||||||
sudo systemctl enable --now caddy # Linux, if your package installed the unit
|
|
||||||
```
|
|
||||||
|
|
||||||
Odysseus's own service is unchanged; the proxy runs alongside it. Under Docker,
|
|
||||||
run the proxy as another container, or on the host pointing at the published
|
|
||||||
port.
|
|
||||||
|
|
||||||
**4. Point Odysseus at the new origin** in `.env`, then restart it.
|
|
||||||
|
|
||||||
A proxy that exposes the HTTPS request scheme to Odysseus needs no `SECURE_COOKIES` setting. Only force it on when the proxy cannot expose that scheme:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# only if the proxy cannot expose the external HTTPS scheme to Odysseus:
|
|
||||||
SECURE_COOKIES=true
|
|
||||||
# only if you use remote MCP servers with OAuth:
|
|
||||||
OAUTH_REDIRECT_BASE_URL=https://odysseus.example.com
|
|
||||||
```
|
|
||||||
|
|
||||||
Gmail OAuth needs nothing here when the proxy runs on the same host: the
|
|
||||||
redirect URI is built from the incoming request, and uvicorn rewrites the
|
|
||||||
scheme from `X-Forwarded-Proto` for proxies it trusts — by default only
|
|
||||||
`127.0.0.1`. A proxy in a separate container or on another machine is not
|
|
||||||
trusted, so pin the URI there:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
GOOGLE_OAUTH_REDIRECT_URI=https://odysseus.example.com/api/email/oauth/google/callback
|
|
||||||
```
|
|
||||||
|
|
||||||
(uvicorn's own `FORWARDED_ALLOW_IPS` widens that trust, but it has to be in the
|
|
||||||
environment uvicorn starts with — `.env` is read by the app afterwards, too
|
|
||||||
late for it to take effect.)
|
|
||||||
|
|
||||||
**5. Confirm HTTP/2 is really on:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -s -o /dev/null -w '%{http_version}\n' https://odysseus.example.com/
|
|
||||||
# 2
|
|
||||||
```
|
|
||||||
|
|
||||||
The status code is not the thing to check here — a logged-out request redirects
|
|
||||||
to the login page, so `curl -I` shows `HTTP/2 302`, and the `HTTP/2` prefix is
|
|
||||||
the part that matters. The browser reports the same in the Network panel's
|
|
||||||
Protocol column (`h2`); in Chrome and Firefox that column is hidden until you
|
|
||||||
enable it by right-clicking the column headers.
|
|
||||||
|
|
||||||
Three things bite when moving an existing install behind TLS:
|
|
||||||
|
|
||||||
- Leave `SECURE_COOKIES` unset when Odysseus can see the external HTTPS scheme;
|
|
||||||
the cookie then follows the request automatically. If your proxy cannot expose
|
|
||||||
that scheme, set `SECURE_COOKIES=true` **at the same time** you stop serving
|
|
||||||
plain HTTP, not before. An explicit `true` applies to every login, so while an
|
|
||||||
HTTP entrypoint is still reachable the browser will reject the `Secure` cookie
|
|
||||||
there and login will appear to loop.
|
|
||||||
- `OAUTH_REDIRECT_BASE_URL` defaults to `http://localhost:7000`. Unlike the
|
|
||||||
Gmail redirect URI it cannot be derived from a request — it is registered
|
|
||||||
with each MCP authorization server up front — so set it to the external
|
|
||||||
origin if you use remote MCP servers over OAuth.
|
|
||||||
- Odysseus sends `Strict-Transport-Security` once it sees `X-Forwarded-Proto:
|
|
||||||
https`. HSTS applies to the whole hostname and ignores the port, so any other
|
|
||||||
plain-HTTP service on that same hostname becomes unreachable in browsers that
|
|
||||||
have visited Odysseus. Give Odysseus its own hostname, or strip the header at
|
|
||||||
the proxy (`header_down -Strict-Transport-Security` in Caddy).
|
|
||||||
|
|
||||||
Server-sent events are not buffered by this configuration, so chat streaming
|
|
||||||
arrives token by token; add `flush_interval -1` inside the `reverse_proxy`
|
|
||||||
block if you want that pinned explicitly. nginx needs `proxy_buffering off;`
|
|
||||||
for the same reason.
|
|
||||||
|
|
||||||
Changing the external origin also affects state scoped to it. Service workers
|
|
||||||
and their caches are origin-scoped, so moving to a different origin starts with
|
|
||||||
a cold load. Cookies follow their own domain/path/security rules rather than
|
|
||||||
being port-scoped: changing the hostname normally requires a new login, while
|
|
||||||
changing only the scheme or port does not by itself guarantee that existing
|
|
||||||
cookies disappear.
|
|
||||||
|
|
||||||
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` | derived from the request scheme | Marks session cookies `Secure` on HTTPS requests. Set true to force it on, false to force it off. |
|
|
||||||
| `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
|
|
||||||
```
|
|
||||||
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`.
|
|
||||||
|
|
||||||
To back up or restore everything in `data/`, see the
|
|
||||||
[Backup & 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,24 +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)
|
|
||||||
# -Port only reaches uvicorn as a flag. Everything that builds a URL for this
|
|
||||||
# instance - internal_api_base(), companion pairing, the MCP OAuth callback -
|
|
||||||
# reads APP_PORT, so set it too or they all assume 7000.
|
|
||||||
$env:APP_PORT = $Port
|
|
||||||
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,21 +0,0 @@
|
|||||||
The MIT License (MIT)
|
|
||||||
|
|
||||||
Copyright (c) 2013-2020 Khan Academy and other contributors
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
The MIT License (MIT)
|
|
||||||
|
|
||||||
Copyright (c) 2014 - 2022 Knut Sveidqvist
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
|
||||||
@@ -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
|
||||||
@@ -17,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.memory import MemoryStoreUnreadable
|
|
||||||
|
|
||||||
server = Server("memory")
|
server = Server("memory")
|
||||||
|
|
||||||
# Late-initialized managers (set during first tool call)
|
# Late-initialized managers (set during first tool call)
|
||||||
@@ -26,71 +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."
|
|
||||||
)
|
|
||||||
_UNREADABLE_STORE_ERROR = (
|
|
||||||
"Error: Memory store is temporarily unreadable — nothing was saved. "
|
|
||||||
"Repair or restore memory.json, then retry."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
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(for_update: bool = False) -> tuple[str | None, list[dict], list[dict], str | None]:
|
|
||||||
"""Return configured owner, all entries, visible entries, and optional error.
|
|
||||||
|
|
||||||
``for_update=True`` is for read-modify-write callers. They save the ``all
|
|
||||||
entries`` list back, so an unreadable store must be reported as an error
|
|
||||||
instead of degrading to ``[]`` — otherwise the save writes their one new
|
|
||||||
entry over the whole store (issue #5673).
|
|
||||||
"""
|
|
||||||
if for_update:
|
|
||||||
try:
|
|
||||||
entries = _memory_manager.load_all_for_update()
|
|
||||||
except MemoryStoreUnreadable as e:
|
|
||||||
return None, [], [], f"{_UNREADABLE_STORE_ERROR} ({e})"
|
|
||||||
else:
|
|
||||||
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."""
|
||||||
@@ -143,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(for_update=True)
|
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:
|
||||||
@@ -190,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:
|
||||||
@@ -219,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:
|
||||||
@@ -248,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)",
|
|
||||||
]
|
|
||||||
|
|||||||
@@ -12,20 +12,10 @@
|
|||||||
# GPU-accelerated transcription — it's auto-detected, CPU is used otherwise.
|
# GPU-accelerated transcription — it's auto-detected, CPU is used otherwise.
|
||||||
faster-whisper
|
faster-whisper
|
||||||
|
|
||||||
# Local text-to-speech via Kokoro-82M for the "local" TTS provider.
|
|
||||||
# Kokoro 0.9.4 declares Python >=3.10,<3.13; Odysseus itself requires 3.11+,
|
|
||||||
# so pip installs these extras on 3.11-3.12 and deliberately skips them on
|
|
||||||
# Python 3.13+ (including the Python 3.14 container image). Kokoro declares
|
|
||||||
# torch; the local provider still
|
|
||||||
# requires a CUDA-enabled torch build and GPU at runtime. SoundFile is separate
|
|
||||||
# in Kokoro's official install instructions and is not a transitive dependency.
|
|
||||||
kokoro==0.9.4; python_version >= "3.11" and python_version < "3.13"
|
|
||||||
soundfile; python_version >= "3.11" and python_version < "3.13"
|
|
||||||
|
|
||||||
# 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).
|
||||||
@@ -43,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
|
||||||
@@ -38,16 +37,9 @@ python-dateutil
|
|||||||
caldav
|
caldav
|
||||||
cryptography
|
cryptography
|
||||||
bcrypt
|
bcrypt
|
||||||
# Built-in servers use the v1 low-level Server decorator API. MCP SDK v2 is a
|
mcp
|
||||||
# breaking rewrite, so keep fresh installs on the maintained v1 line until the
|
|
||||||
# servers are migrated together.
|
|
||||||
mcp<2
|
|
||||||
pyotp
|
pyotp
|
||||||
qrcode[pil]
|
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 src.owner_identity import REQUEST_SENTINEL_OWNERS
|
|
||||||
from src.task_scheduler import compute_next_run
|
from src.task_scheduler import compute_next_run
|
||||||
|
|
||||||
|
|
||||||
@@ -90,12 +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.
|
||||||
# REQUEST_SENTINEL_OWNERS covers request-only identities; Default/Local is a
|
_SYNTHETIC_OWNERS = frozenset({"internal-tool", "api", "demo", "system", ""})
|
||||||
# reserved login name but remains a valid storage owner.
|
|
||||||
|
|
||||||
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 REQUEST_SENTINEL_OWNERS:
|
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 (
|
||||||
@@ -22,8 +16,6 @@ from src.settings import (
|
|||||||
load_features as _load_features,
|
load_features as _load_features,
|
||||||
save_features as _save_features,
|
save_features as _save_features,
|
||||||
DEFAULT_SETTINGS,
|
DEFAULT_SETTINGS,
|
||||||
RETIRED_SETTING_KEYS,
|
|
||||||
without_retired_settings,
|
|
||||||
)
|
)
|
||||||
from src.integrations import (
|
from src.integrations import (
|
||||||
load_integrations,
|
load_integrations,
|
||||||
@@ -75,44 +67,12 @@ 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
|
||||||
|
|
||||||
SESSION_COOKIE = "odysseus_session"
|
SESSION_COOKIE = "odysseus_session"
|
||||||
|
|
||||||
|
|
||||||
def _secure_cookie(request: Request) -> bool:
|
|
||||||
"""Decide the ``Secure`` attribute of the session cookie.
|
|
||||||
|
|
||||||
``SECURE_COOKIES`` stays authoritative when it holds an explicit value:
|
|
||||||
``true`` always marks the cookie Secure (the documented knob for a TLS
|
|
||||||
proxy), ``false`` never does, which is the escape hatch for an install
|
|
||||||
that still answers on plain HTTP alongside HTTPS. Anything else —
|
|
||||||
unset, or the present-but-empty value docker-compose injects for a
|
|
||||||
variable the host has not defined — derives it from the request, so an
|
|
||||||
HTTPS login gets a Secure cookie without any configuration.
|
|
||||||
|
|
||||||
Either the connection scheme or ``X-Forwarded-Proto`` saying https is
|
|
||||||
enough, which is the same test ``core/middleware.py`` applies before it
|
|
||||||
sends HSTS. Uvicorn's proxy-headers middleware already folds that header
|
|
||||||
into the scheme for the proxies it trusts, so reading it here only adds
|
|
||||||
the case of a terminator that is not on a trusted address; the cost is
|
|
||||||
that a client talking to the app directly can set the header and lock
|
|
||||||
its own session out over plain HTTP.
|
|
||||||
"""
|
|
||||||
configured = os.getenv("SECURE_COOKIES", "").strip().lower()
|
|
||||||
if configured in ("true", "false"):
|
|
||||||
return configured == "true"
|
|
||||||
# A chained proxy sends a list — the client-facing hop comes first.
|
|
||||||
forwarded_proto = request.headers.get("x-forwarded-proto", "").split(",")[0]
|
|
||||||
return request.url.scheme == "https" or forwarded_proto.strip().lower() == "https"
|
|
||||||
|
|
||||||
|
|
||||||
def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
||||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||||
|
|
||||||
@@ -131,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")
|
||||||
@@ -151,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")
|
||||||
@@ -177,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(
|
||||||
@@ -186,11 +140,11 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
|||||||
value=token,
|
value=token,
|
||||||
httponly=True,
|
httponly=True,
|
||||||
samesite="lax",
|
samesite="lax",
|
||||||
secure=_secure_cookie(request),
|
secure=os.getenv("SECURE_COOKIES", "false").lower() == "true",
|
||||||
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}
|
||||||
|
|
||||||
@@ -219,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:
|
||||||
@@ -310,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")
|
||||||
@@ -348,87 +293,14 @@ 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 (
|
from core.database import Base, SessionLocal
|
||||||
Base,
|
|
||||||
EmailAccount,
|
|
||||||
SessionLocal,
|
|
||||||
lock_email_account_owner_mutations,
|
|
||||||
)
|
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
# Email-account defaults are protected by per-owner mutex rows.
|
|
||||||
# A rename crosses two owner partitions, so lock both in the
|
|
||||||
# shared helper's canonical order before inspecting either.
|
|
||||||
lock_email_account_owner_mutations(
|
|
||||||
db, old_username, new_username
|
|
||||||
)
|
|
||||||
|
|
||||||
source_default_ids = [
|
|
||||||
row[0]
|
|
||||||
for row in (
|
|
||||||
db.query(EmailAccount.id)
|
|
||||||
.filter(
|
|
||||||
func.lower(EmailAccount.owner) == old_username,
|
|
||||||
EmailAccount.is_default == True, # noqa: E712
|
|
||||||
)
|
|
||||||
.order_by(EmailAccount.created_at.asc(), EmailAccount.id.asc())
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
]
|
|
||||||
destination_default_ids = [
|
|
||||||
row[0]
|
|
||||||
for row in (
|
|
||||||
db.query(EmailAccount.id)
|
|
||||||
.filter(
|
|
||||||
func.lower(EmailAccount.owner) == new_username,
|
|
||||||
EmailAccount.is_default == True, # noqa: E712
|
|
||||||
)
|
|
||||||
.order_by(EmailAccount.created_at.asc(), EmailAccount.id.asc())
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
]
|
|
||||||
if destination_default_ids:
|
|
||||||
clear_default_ids = (
|
|
||||||
destination_default_ids[1:] + source_default_ids
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
clear_default_ids = source_default_ids[1:]
|
|
||||||
if clear_default_ids:
|
|
||||||
(
|
|
||||||
db.query(EmailAccount)
|
|
||||||
.filter(EmailAccount.id.in_(clear_default_ids))
|
|
||||||
.update(
|
|
||||||
{EmailAccount.is_default: False},
|
|
||||||
synchronize_session=False,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
for mapper in Base.registry.mappers:
|
for mapper in Base.registry.mappers:
|
||||||
model = mapper.class_
|
model = mapper.class_
|
||||||
if not hasattr(model, "owner"):
|
if not hasattr(model, "owner"):
|
||||||
@@ -446,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.
|
||||||
@@ -470,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):
|
||||||
"""
|
"""
|
||||||
@@ -662,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")
|
||||||
|
|
||||||
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)
|
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
|
||||||
@@ -686,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) ----
|
||||||
@@ -718,7 +417,7 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
|||||||
a scrubbed copy with secret keys blanked. The frontend uses this
|
a scrubbed copy with secret keys blanked. The frontend uses this
|
||||||
for keybinds + TTS prefs, so it stays callable without admin."""
|
for keybinds + TTS prefs, so it stays callable without admin."""
|
||||||
user = _get_current_user(request)
|
user = _get_current_user(request)
|
||||||
settings = without_retired_settings(_load_settings())
|
settings = _load_settings()
|
||||||
if user and auth_manager.is_admin(user):
|
if user and auth_manager.is_admin(user):
|
||||||
return settings
|
return settings
|
||||||
return scrub_settings(settings)
|
return scrub_settings(settings)
|
||||||
@@ -731,28 +430,11 @@ 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 in RETIRED_SETTING_KEYS:
|
if key in body:
|
||||||
continue
|
current[key] = body[key]
|
||||||
if key not in body:
|
|
||||||
continue
|
|
||||||
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 without_retired_settings(current)
|
return current
|
||||||
|
|
||||||
# ---- Integrations CRUD ----
|
# ---- Integrations CRUD ----
|
||||||
|
|
||||||
@@ -880,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 = {
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ from datetime import datetime
|
|||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Request, Response
|
from fastapi import APIRouter, HTTPException, Request, Response
|
||||||
from core.middleware import require_admin
|
from core.middleware import require_admin
|
||||||
from services.memory import MemoryStoreUnreadable
|
|
||||||
from src.auth_helpers import get_current_user
|
from src.auth_helpers import get_current_user
|
||||||
from src.settings import load_settings, save_settings, load_features, save_features
|
from src.settings import load_settings, save_settings, load_features, save_features
|
||||||
|
|
||||||
@@ -77,15 +76,7 @@ def setup_backup_routes(memory_manager, preset_manager, skills_manager) -> APIRo
|
|||||||
|
|
||||||
# ── Memories ──
|
# ── Memories ──
|
||||||
if "memories" in body and isinstance(body["memories"], list):
|
if "memories" in body and isinstance(body["memories"], list):
|
||||||
# Strict load: importing on top of an unreadable store would write
|
existing = memory_manager.load_all()
|
||||||
# only the incoming rows back and drop everything already saved.
|
|
||||||
try:
|
|
||||||
existing = memory_manager.load_all_for_update()
|
|
||||||
except MemoryStoreUnreadable as e:
|
|
||||||
logger.error("Refusing to import memories: %s", e)
|
|
||||||
raise HTTPException(
|
|
||||||
503, "Memory store is temporarily unreadable — nothing was imported."
|
|
||||||
)
|
|
||||||
# Dedup against THIS user's own memories only. Using every tenant's
|
# Dedup against THIS user's own memories only. Using every tenant's
|
||||||
# rows (load_all) meant a memory whose text matched any other
|
# rows (load_all) meant a memory whose text matched any other
|
||||||
# user's was silently skipped, so the importing user lost their own
|
# user's was silently skipped, so the importing user lost their own
|
||||||
@@ -110,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,8 +1,6 @@
|
|||||||
"""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
|
||||||
@@ -10,13 +8,11 @@ from typing import Optional, List
|
|||||||
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 sqlalchemy.exc import IntegrityError
|
|
||||||
from dateutil.rrule import rrulestr
|
from dateutil.rrule import rrulestr
|
||||||
|
|
||||||
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 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__)
|
||||||
|
|
||||||
@@ -37,24 +33,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.
|
||||||
@@ -122,15 +100,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.
|
||||||
|
|
||||||
@@ -147,54 +116,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):
|
||||||
@@ -222,126 +143,23 @@ class EventUpdate(BaseModel):
|
|||||||
|
|
||||||
# ── Helpers ──
|
# ── Helpers ──
|
||||||
|
|
||||||
_DEFAULT_CALENDAR_NAMESPACE = uuid.UUID("4840613a-9847-4a3b-bd75-19e6bc5fc3ce")
|
|
||||||
|
|
||||||
|
|
||||||
def _default_calendar_id(owner: str, collision_index: int = 0) -> str:
|
|
||||||
"""Return one stable primary-key candidate for an owner's lazy default.
|
|
||||||
|
|
||||||
Slot zero preserves the original owner-derived identifier. Later slots
|
|
||||||
let a username be reused after its prior calendar was migrated to another
|
|
||||||
owner during a rename, without making concurrent first use choose random
|
|
||||||
and therefore divergent identifiers.
|
|
||||||
"""
|
|
||||||
if collision_index == 0:
|
|
||||||
candidate_name = owner
|
|
||||||
else:
|
|
||||||
candidate_name = json.dumps(
|
|
||||||
[owner, collision_index],
|
|
||||||
ensure_ascii=False,
|
|
||||||
separators=(",", ":"),
|
|
||||||
)
|
|
||||||
return str(uuid.uuid5(_DEFAULT_CALENDAR_NAMESPACE, candidate_name))
|
|
||||||
|
|
||||||
|
|
||||||
def _begin_sqlite_default_write(db) -> None:
|
|
||||||
"""Serialize an absent-default check with other SQLite writers.
|
|
||||||
|
|
||||||
SQLite's default deferred transactions allow two workers to both read an
|
|
||||||
empty calendar set before either writes. ``BEGIN IMMEDIATE`` acquires the
|
|
||||||
writer reservation before the second, authoritative lookup. We issue it
|
|
||||||
only when the driver has not already opened a write transaction; a caller
|
|
||||||
with a pending write already owns the required reservation.
|
|
||||||
"""
|
|
||||||
connection = db.connection()
|
|
||||||
dbapi_connection = connection.connection
|
|
||||||
driver_connection = getattr(
|
|
||||||
dbapi_connection,
|
|
||||||
"driver_connection",
|
|
||||||
dbapi_connection,
|
|
||||||
)
|
|
||||||
if not getattr(driver_connection, "in_transaction", False):
|
|
||||||
connection.exec_driver_sql("BEGIN IMMEDIATE")
|
|
||||||
|
|
||||||
|
|
||||||
def _ensure_default_calendar(db, owner: str = None) -> CalendarCal:
|
def _ensure_default_calendar(db, owner: str = None) -> CalendarCal:
|
||||||
"""Return the owner's calendar, staging a default in the caller's transaction.
|
"""Create default calendar if none exist for this owner."""
|
||||||
|
|
||||||
A stable owner-derived primary key makes concurrent first-use inserts
|
|
||||||
converge on one row on every SQL backend. SQLite additionally serializes
|
|
||||||
the absent-row check because its deferred transactions otherwise permit
|
|
||||||
both workers to read the gap before either writes. Other backends recover
|
|
||||||
a lost insert race inside a savepoint so the caller's event transaction
|
|
||||||
remains usable and atomic.
|
|
||||||
"""
|
|
||||||
owner = owner or FALLBACK_OWNER
|
owner = owner or FALLBACK_OWNER
|
||||||
cal = db.query(CalendarCal).filter(CalendarCal.owner == owner).first()
|
cal = db.query(CalendarCal).filter(CalendarCal.owner == owner).first()
|
||||||
if cal:
|
if not cal:
|
||||||
return cal
|
|
||||||
|
|
||||||
dialect = db.get_bind().dialect.name
|
|
||||||
if dialect == "sqlite":
|
|
||||||
_begin_sqlite_default_write(db)
|
|
||||||
# Another worker may have committed while BEGIN IMMEDIATE waited.
|
|
||||||
cal = db.query(CalendarCal).filter(CalendarCal.owner == owner).first()
|
|
||||||
if cal:
|
|
||||||
return cal
|
|
||||||
|
|
||||||
collision_index = 0
|
|
||||||
while True:
|
|
||||||
default_id = _default_calendar_id(owner, collision_index)
|
|
||||||
|
|
||||||
if dialect == "sqlite":
|
|
||||||
# BEGIN IMMEDIATE above makes this occupancy check authoritative:
|
|
||||||
# another SQLite writer cannot rename, delete, or claim this slot
|
|
||||||
# until the caller commits or rolls back.
|
|
||||||
occupant = db.query(CalendarCal).filter(
|
|
||||||
CalendarCal.id == default_id,
|
|
||||||
).first()
|
|
||||||
if occupant is not None:
|
|
||||||
if occupant.owner == owner:
|
|
||||||
return occupant
|
|
||||||
collision_index += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
cal = CalendarCal(
|
cal = CalendarCal(
|
||||||
id=default_id,
|
id=str(uuid.uuid4()),
|
||||||
owner=owner,
|
owner=owner,
|
||||||
name="Personal",
|
name="Personal",
|
||||||
color="#5b8abf",
|
color="#5b8abf",
|
||||||
source="local",
|
source="local",
|
||||||
)
|
)
|
||||||
|
|
||||||
if dialect == "sqlite":
|
|
||||||
db.add(cal)
|
db.add(cal)
|
||||||
db.flush()
|
db.commit()
|
||||||
|
db.refresh(cal)
|
||||||
return cal
|
return cal
|
||||||
|
|
||||||
try:
|
|
||||||
# A uniqueness failure rolls back only this savepoint, not an event
|
|
||||||
# or reminder already staged by the caller's outer transaction.
|
|
||||||
with db.begin_nested():
|
|
||||||
db.add(cal)
|
|
||||||
db.flush()
|
|
||||||
return cal
|
|
||||||
except IntegrityError:
|
|
||||||
# Use a locking/current read so repeatable-read backends can observe
|
|
||||||
# the row that won after our transaction's original empty snapshot.
|
|
||||||
occupant = db.query(CalendarCal).filter(
|
|
||||||
CalendarCal.id == default_id,
|
|
||||||
).with_for_update().first()
|
|
||||||
if occupant is None:
|
|
||||||
# Do not misclassify an unrelated integrity failure as an ID
|
|
||||||
# collision and loop forever. A concurrently deleted winner is
|
|
||||||
# safe for the caller to retry as a fresh transaction.
|
|
||||||
raise
|
|
||||||
if occupant.owner == owner:
|
|
||||||
return occupant
|
|
||||||
# A renamed calendar owns this deterministic slot. Advance to the
|
|
||||||
# next stable slot; concurrent callers for this owner will still
|
|
||||||
# converge there.
|
|
||||||
collision_index += 1
|
|
||||||
|
|
||||||
|
|
||||||
# Per-request user time context. chat_routes sets this from browser timezone
|
# Per-request user time context. chat_routes sets this from browser timezone
|
||||||
# headers so natural-language times the LLM emits ("today at 9pm") are parsed
|
# headers so natural-language times the LLM emits ("today at 9pm") are parsed
|
||||||
@@ -430,17 +248,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)
|
||||||
@@ -558,20 +365,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)
|
||||||
@@ -606,17 +399,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}")
|
||||||
|
|
||||||
@@ -647,7 +430,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 ""),
|
||||||
@@ -658,31 +440,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]:
|
||||||
@@ -705,7 +462,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.
|
||||||
@@ -731,7 +487,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.
|
||||||
@@ -744,43 +499,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")
|
||||||
@@ -793,199 +537,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()
|
||||||
@@ -995,18 +620,13 @@ 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:
|
|
||||||
acc = accounts[0]
|
|
||||||
if acc:
|
|
||||||
url = url or (acc.get("url") or "")
|
|
||||||
user = user or (acc.get("username") or "")
|
|
||||||
if not pw:
|
if not pw:
|
||||||
pw = acc.get("password") or ""
|
pw = cfg.get("password") or ""
|
||||||
if pw:
|
if pw:
|
||||||
try:
|
try:
|
||||||
from src.secret_storage import decrypt
|
from src.secret_storage import decrypt
|
||||||
@@ -1027,24 +647,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),
|
||||||
@@ -1084,34 +687,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):
|
||||||
@@ -1119,18 +701,14 @@ def setup_calendar_routes(upload_handler=None) -> APIRouter:
|
|||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
_ensure_default_calendar(db, owner)
|
_ensure_default_calendar(db, owner)
|
||||||
# Listing calendars intentionally lazily creates a durable default.
|
|
||||||
# Other callers commit it with the event they are creating.
|
|
||||||
db.commit()
|
|
||||||
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:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
db.rollback()
|
|
||||||
logger.error("Failed to list calendars: %s", e)
|
logger.error("Failed to list calendars: %s", e)
|
||||||
raise HTTPException(500, "Failed to list calendars")
|
raise HTTPException(500, "Failed to list calendars")
|
||||||
finally:
|
finally:
|
||||||
@@ -1188,12 +766,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:
|
||||||
@@ -1205,7 +779,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
|
||||||
@@ -1248,12 +821,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
|
||||||
@@ -1267,7 +847,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:
|
||||||
@@ -1300,12 +879,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
|
||||||
@@ -1317,7 +899,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)
|
||||||
@@ -1326,28 +908,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
|
||||||
@@ -1361,7 +930,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(
|
||||||
@@ -1384,7 +952,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)
|
||||||
@@ -1403,10 +970,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 = ""):
|
||||||
@@ -1416,7 +1000,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:
|
||||||
@@ -1442,7 +1026,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
|
||||||
@@ -1478,18 +1062,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
|
||||||
|
|
||||||
@@ -1523,8 +1095,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,
|
||||||
@@ -1545,7 +1115,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,
|
||||||
}
|
}
|
||||||
@@ -1599,14 +1168,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
|
||||||
@@ -1628,7 +1194,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
|
||||||
@@ -1654,9 +1220,9 @@ def setup_calendar_routes(upload_handler=None) -> APIRouter:
|
|||||||
if tz_hint:
|
if tz_hint:
|
||||||
set_user_tz_name(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"}
|
||||||
|
|
||||||
|
|||||||
@@ -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, get_context_length
|
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,51 +58,15 @@ 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)
|
|
||||||
# Route-neutral prompt before any model-window compaction/trimming. This is
|
|
||||||
# retained only when explicit foreground fallbacks are enabled so each
|
|
||||||
# concrete candidate can apply its own context budget independently.
|
|
||||||
route_messages: list = field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Helpers ────────────────────────────────────────────────────────────── #
|
# ── Helpers ────────────────────────────────────────────────────────────── #
|
||||||
|
|
||||||
def _allowed_models_from_privileges(privs: dict) -> Optional[frozenset[str]]:
|
|
||||||
if privs.get("block_all_models"):
|
|
||||||
return frozenset()
|
|
||||||
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)
|
|
||||||
return frozenset(model for model in allowed if isinstance(model, str)) if restricted else None
|
|
||||||
|
|
||||||
|
|
||||||
def _allowed_models_for_request(request) -> Optional[frozenset[str]]:
|
|
||||||
"""Return the caller's model allowlist, or ``None`` when unrestricted."""
|
|
||||||
|
|
||||||
try:
|
|
||||||
user = effective_user(request)
|
|
||||||
except Exception:
|
|
||||||
user = None
|
|
||||||
if not user:
|
|
||||||
return None
|
|
||||||
auth_manager = getattr(getattr(request.app, "state", None), "auth_manager", None)
|
|
||||||
if not auth_manager:
|
|
||||||
return None
|
|
||||||
privs = auth_manager.get_privileges(user) or {}
|
|
||||||
return _allowed_models_from_privileges(privs)
|
|
||||||
|
|
||||||
def _enforce_chat_privileges(request, sess) -> None:
|
def _enforce_chat_privileges(request, sess) -> None:
|
||||||
"""Apply the per-user privilege gates (allowed_models + max_messages_per_day)
|
"""Apply the per-user privilege gates (allowed_models + max_messages_per_day)
|
||||||
that both /api/chat and /api/chat_stream must enforce BEFORE any LLM work.
|
that both /api/chat and /api/chat_stream must enforce BEFORE any LLM work.
|
||||||
@@ -192,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:
|
||||||
@@ -205,16 +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_models = _allowed_models_from_privileges(privs)
|
|
||||||
if allowed_models is not None and sess.model and sess.model not in allowed_models:
|
|
||||||
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)
|
||||||
@@ -275,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")
|
||||||
@@ -313,6 +188,77 @@ async def auto_name_session(session_manager, sess):
|
|||||||
logger.error(f"Auto-name failed for {sess.id}: {e}\n{traceback.format_exc()}")
|
logger.error(f"Auto-name failed for {sess.id}: {e}\n{traceback.format_exc()}")
|
||||||
|
|
||||||
|
|
||||||
|
def try_fallback_endpoint(sess, session_id: str) -> dict | None:
|
||||||
|
"""Find an alternative working endpoint when the current one fails.
|
||||||
|
|
||||||
|
Returns {"model": ..., "endpoint_url": ..., "endpoint_name": ...} or None.
|
||||||
|
"""
|
||||||
|
import requests as _req
|
||||||
|
from src.endpoint_resolver import build_chat_url, build_headers, build_models_url, normalize_base
|
||||||
|
|
||||||
|
current_url = sess.endpoint_url or ""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
endpoints = db.query(ModelEndpoint).filter(
|
||||||
|
ModelEndpoint.is_enabled == True
|
||||||
|
).all()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
for ep in endpoints:
|
||||||
|
base = normalize_base(ep.base_url)
|
||||||
|
# Skip current endpoint
|
||||||
|
if current_url and base in current_url:
|
||||||
|
continue
|
||||||
|
# Quick ping
|
||||||
|
ping_url = build_models_url(base)
|
||||||
|
headers = build_headers(ep.api_key, base)
|
||||||
|
try:
|
||||||
|
r = _req.get(ping_url, headers=headers, timeout=5)
|
||||||
|
r.raise_for_status()
|
||||||
|
data = r.json()
|
||||||
|
models = [m.get("id") for m in (data.get("data") or []) if m.get("id")]
|
||||||
|
if not models:
|
||||||
|
models = [
|
||||||
|
m.get("name") or m.get("model")
|
||||||
|
for m in (data.get("models") or [])
|
||||||
|
if m.get("name") or m.get("model")
|
||||||
|
]
|
||||||
|
if not models:
|
||||||
|
continue
|
||||||
|
# Found a working endpoint — update session
|
||||||
|
new_model = models[0]
|
||||||
|
chat_url = build_chat_url(base)
|
||||||
|
new_headers = build_headers(ep.api_key, base)
|
||||||
|
|
||||||
|
sess.model = new_model
|
||||||
|
sess.endpoint_url = chat_url
|
||||||
|
sess.headers = new_headers
|
||||||
|
|
||||||
|
# Persist
|
||||||
|
_db = SessionLocal()
|
||||||
|
try:
|
||||||
|
_db.query(DBSession).filter(DBSession.id == session_id).update({
|
||||||
|
"model": new_model,
|
||||||
|
"endpoint_url": chat_url,
|
||||||
|
"headers": json.dumps(new_headers),
|
||||||
|
})
|
||||||
|
_db.commit()
|
||||||
|
finally:
|
||||||
|
_db.close()
|
||||||
|
|
||||||
|
logger.info(f"Fallback: switched session {session_id} from {current_url} to {ep.name} ({new_model})")
|
||||||
|
return {
|
||||||
|
"model": new_model,
|
||||||
|
"endpoint_url": chat_url,
|
||||||
|
"endpoint_name": ep.name,
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def extract_preset(chat_handler, preset_id) -> PresetInfo:
|
def extract_preset(chat_handler, preset_id) -> PresetInfo:
|
||||||
"""Extract preset parameters via chat_handler."""
|
"""Extract preset parameters via chat_handler."""
|
||||||
temperature, max_tokens, system_prompt, char_name = (
|
temperature, max_tokens, system_prompt, char_name = (
|
||||||
@@ -329,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(
|
||||||
@@ -350,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))
|
||||||
|
if not incognito:
|
||||||
chat_handler.update_session_name_if_needed(sess, preprocessed.text_for_context)
|
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)
|
||||||
|
|
||||||
|
|
||||||
@@ -445,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 ""
|
||||||
@@ -480,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)
|
||||||
@@ -547,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:
|
||||||
@@ -579,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,
|
||||||
@@ -622,10 +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,
|
|
||||||
defer_context_shaping: bool = False,
|
|
||||||
continuation_context_message: str | None = None,
|
|
||||||
persist_user_message: bool = True,
|
|
||||||
) -> ChatContext:
|
) -> ChatContext:
|
||||||
"""Build the full context (preface + messages) for an LLM call.
|
"""Build the full context (preface + messages) for an LLM call.
|
||||||
|
|
||||||
@@ -643,82 +465,41 @@ 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 persist_user_message and incognito:
|
|
||||||
user_meta = {"attachments": preprocessed.attachment_meta} if preprocessed.attachment_meta else None
|
|
||||||
_append_incognito_message(session_id, "user", preprocessed.user_content, user_meta)
|
|
||||||
elif persist_user_message:
|
|
||||||
add_user_message(sess, chat_handler, preprocessed, incognito=False)
|
|
||||||
|
|
||||||
# Fire events
|
# Fire events
|
||||||
if persist_user_message and 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),
|
|
||||||
)
|
|
||||||
context_message = (
|
|
||||||
str(continuation_context_message).strip()
|
|
||||||
if continuation_context_message
|
|
||||||
else message
|
|
||||||
)
|
|
||||||
casual_low_signal = _is_casual_low_signal(context_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),
|
||||||
# the sync path uses text_for_context.
|
# the sync path uses text_for_context.
|
||||||
_ctx_msg = (
|
_ctx_msg = preprocessed.enhanced_message if use_enhanced_message else preprocessed.text_for_context
|
||||||
context_message
|
|
||||||
if continuation_context_message
|
|
||||||
else (
|
|
||||||
preprocessed.enhanced_message
|
|
||||||
if use_enhanced_message
|
|
||||||
else preprocessed.text_for_context
|
|
||||||
)
|
|
||||||
)
|
|
||||||
_preface_kwargs = dict(
|
_preface_kwargs = dict(
|
||||||
message=_ctx_msg,
|
message=_ctx_msg,
|
||||||
session=sess,
|
session=sess,
|
||||||
@@ -732,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)
|
||||||
|
|
||||||
@@ -740,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
|
||||||
@@ -749,58 +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
|
# Auto-compact
|
||||||
# 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)
|
|
||||||
|
|
||||||
route_messages = list(messages)
|
|
||||||
# Explicit fallback routing must shape from the same route-neutral prompt
|
|
||||||
# for every candidate. Running selected-model compaction here would mutate
|
|
||||||
# session history before we know which route can answer and would make a
|
|
||||||
# later larger-context candidate unable to recover discarded history.
|
|
||||||
if defer_context_shaping:
|
|
||||||
context_length = get_context_length(sess.endpoint_url, sess.model)
|
|
||||||
was_compacted = False
|
|
||||||
else:
|
|
||||||
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)
|
|
||||||
if not defer_context_shaping:
|
|
||||||
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,
|
||||||
@@ -814,14 +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,
|
|
||||||
route_messages=route_messages,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -1036,26 +770,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:
|
||||||
@@ -1081,18 +798,19 @@ def save_assistant_response(
|
|||||||
_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))
|
||||||
|
|
||||||
|
if not incognito:
|
||||||
from core.database import update_session_last_accessed
|
from core.database import update_session_last_accessed
|
||||||
update_session_last_accessed(session_id)
|
update_session_last_accessed(session_id)
|
||||||
session_manager.save_sessions()
|
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)
|
||||||
@@ -1103,54 +821,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,
|
||||||
@@ -1171,37 +841,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
|
||||||
@@ -1219,7 +873,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
|
||||||
@@ -1237,15 +890,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:
|
||||||
@@ -1253,11 +903,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))
|
||||||
|
|||||||
@@ -1,170 +0,0 @@
|
|||||||
"""ChatGPT Subscription device-flow setup routes."""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import uuid
|
|
||||||
from typing import Dict, Optional
|
|
||||||
|
|
||||||
from fastapi import HTTPException, Request
|
|
||||||
|
|
||||||
from core.database import ModelEndpoint, ProviderAuthSession, SessionLocal, utcnow_naive
|
|
||||||
from routes.device_flow import (
|
|
||||||
DeviceFlowPoll,
|
|
||||||
DeviceFlowStart,
|
|
||||||
PendingDeviceFlowStore,
|
|
||||||
create_device_flow_router,
|
|
||||||
)
|
|
||||||
from src.auth_helpers import get_current_user
|
|
||||||
from src import chatgpt_subscription
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
_DEVICE_FLOW_STORE = PendingDeviceFlowStore()
|
|
||||||
|
|
||||||
|
|
||||||
def _provision_endpoint(tokens: Dict, owner: Optional[str]) -> Dict:
|
|
||||||
access_token = tokens.get("access_token")
|
|
||||||
refresh_token = tokens.get("refresh_token")
|
|
||||||
if not access_token or not refresh_token:
|
|
||||||
raise ValueError("ChatGPT token response was missing access_token or refresh_token")
|
|
||||||
|
|
||||||
base = chatgpt_subscription.DEFAULT_CHATGPT_SUBSCRIPTION_BASE_URL
|
|
||||||
models = chatgpt_subscription.fetch_available_models(access_token)
|
|
||||||
if not models:
|
|
||||||
raise ValueError("ChatGPT Subscription connected, but no usable Codex models were discovered for this account.")
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
auth = (
|
|
||||||
db.query(ProviderAuthSession)
|
|
||||||
.filter(
|
|
||||||
ProviderAuthSession.provider == chatgpt_subscription.CHATGPT_SUBSCRIPTION_PROVIDER,
|
|
||||||
ProviderAuthSession.owner == owner,
|
|
||||||
)
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
if auth is None:
|
|
||||||
auth = ProviderAuthSession(
|
|
||||||
id=str(uuid.uuid4())[:8],
|
|
||||||
provider=chatgpt_subscription.CHATGPT_SUBSCRIPTION_PROVIDER,
|
|
||||||
owner=owner,
|
|
||||||
label="ChatGPT Subscription",
|
|
||||||
base_url=base,
|
|
||||||
auth_mode="chatgpt",
|
|
||||||
)
|
|
||||||
db.add(auth)
|
|
||||||
auth.base_url = base
|
|
||||||
auth.access_token = access_token
|
|
||||||
auth.refresh_token = refresh_token
|
|
||||||
auth.last_refresh = utcnow_naive()
|
|
||||||
auth.auth_mode = "chatgpt"
|
|
||||||
|
|
||||||
ep = (
|
|
||||||
db.query(ModelEndpoint)
|
|
||||||
.filter(
|
|
||||||
ModelEndpoint.base_url == base,
|
|
||||||
ModelEndpoint.provider_auth_id == auth.id,
|
|
||||||
ModelEndpoint.owner == owner,
|
|
||||||
)
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
if ep is None:
|
|
||||||
ep = ModelEndpoint(
|
|
||||||
id=str(uuid.uuid4())[:8],
|
|
||||||
name="ChatGPT Subscription",
|
|
||||||
base_url=base,
|
|
||||||
model_type="llm",
|
|
||||||
endpoint_kind="api",
|
|
||||||
owner=owner,
|
|
||||||
)
|
|
||||||
db.add(ep)
|
|
||||||
ep.name = "ChatGPT Subscription"
|
|
||||||
ep.base_url = base
|
|
||||||
ep.api_key = None
|
|
||||||
ep.provider_auth_id = auth.id
|
|
||||||
ep.is_enabled = True
|
|
||||||
ep.supports_tools = False
|
|
||||||
ep.model_type = "llm"
|
|
||||||
ep.endpoint_kind = "api"
|
|
||||||
ep.model_refresh_mode = "manual"
|
|
||||||
ep.cached_models = json.dumps(models)
|
|
||||||
db.commit()
|
|
||||||
result = {
|
|
||||||
"id": ep.id,
|
|
||||||
"name": ep.name,
|
|
||||||
"base_url": ep.base_url,
|
|
||||||
"models": models,
|
|
||||||
}
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
try:
|
|
||||||
from routes.model_routes import _invalidate_models_cache
|
|
||||||
|
|
||||||
_invalidate_models_cache()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def _start_device_flow(request: Request, _form) -> DeviceFlowStart:
|
|
||||||
try:
|
|
||||||
data = chatgpt_subscription.request_device_code()
|
|
||||||
except Exception as exc:
|
|
||||||
raise chatgpt_subscription.to_http_exception(exc)
|
|
||||||
|
|
||||||
device_auth_id = data.get("device_auth_id")
|
|
||||||
user_code = data.get("user_code")
|
|
||||||
if not device_auth_id or not user_code:
|
|
||||||
raise HTTPException(502, "ChatGPT did not return a complete device code")
|
|
||||||
verification_uri = data.get("verification_uri") or f"{chatgpt_subscription.CHATGPT_OAUTH_ISSUER}/codex/device"
|
|
||||||
return DeviceFlowStart(
|
|
||||||
pending={
|
|
||||||
"device_auth_id": device_auth_id,
|
|
||||||
"user_code": user_code,
|
|
||||||
"owner": get_current_user(request) or None,
|
|
||||||
},
|
|
||||||
response={
|
|
||||||
"user_code": user_code,
|
|
||||||
"verification_uri": verification_uri,
|
|
||||||
},
|
|
||||||
interval=int(data.get("interval") or 5),
|
|
||||||
expires_in=int(data.get("expires_in") or 900),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _poll_device_flow(_request: Request, pending: Dict) -> DeviceFlowPoll:
|
|
||||||
try:
|
|
||||||
data = chatgpt_subscription.poll_device_auth(pending["device_auth_id"], pending["user_code"])
|
|
||||||
except Exception as exc:
|
|
||||||
logger.debug("ChatGPT device poll failed: %s", exc)
|
|
||||||
return DeviceFlowPoll.pending(str(exc))
|
|
||||||
|
|
||||||
authorization_code = data.get("authorization_code")
|
|
||||||
code_verifier = data.get("code_verifier")
|
|
||||||
if authorization_code and code_verifier:
|
|
||||||
try:
|
|
||||||
tokens = chatgpt_subscription.exchange_authorization_code(authorization_code, code_verifier)
|
|
||||||
result = _provision_endpoint(tokens, pending["owner"])
|
|
||||||
except Exception as exc:
|
|
||||||
logger.exception("ChatGPT Subscription endpoint provisioning failed")
|
|
||||||
raise chatgpt_subscription.to_http_exception(exc)
|
|
||||||
return DeviceFlowPoll.authorized(result)
|
|
||||||
|
|
||||||
err = data.get("error") or data.get("status")
|
|
||||||
if err in ("authorization_pending", "pending", None):
|
|
||||||
return DeviceFlowPoll.pending()
|
|
||||||
if err == "slow_down":
|
|
||||||
return DeviceFlowPoll.slow_down(int(data.get("interval") or 0) or None)
|
|
||||||
if err in ("expired_token", "access_denied", "denied"):
|
|
||||||
return DeviceFlowPoll.failed(err)
|
|
||||||
return DeviceFlowPoll.pending(err or "unknown")
|
|
||||||
|
|
||||||
|
|
||||||
def setup_chatgpt_subscription_routes():
|
|
||||||
return create_device_flow_router(
|
|
||||||
prefix="/api/chatgpt-subscription",
|
|
||||||
tags=["chatgpt-subscription"],
|
|
||||||
store=_DEVICE_FLOW_STORE,
|
|
||||||
start_flow=_start_device_flow,
|
|
||||||
poll_flow=_poll_device_flow,
|
|
||||||
)
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
"""Cleanup route domain package (slice 2g, #4082/#4071).
|
|
||||||
|
|
||||||
Contains cleanup_routes.py, migrated from the flat routes/ directory.
|
|
||||||
Backward-compat shim at routes/cleanup_routes.py re-exports from here.
|
|
||||||
"""
|
|
||||||