es: rogue-key guard — чужой audit-ключ пишет legacy, стор не ломается (инциденты 06.10/08.10)

This commit is contained in:
Simbiot Harness 2026-10-08 08:52:56 +00:00
parent 665db4343a
commit 336d47f978
11 changed files with 959 additions and 0 deletions

View file

@ -0,0 +1,168 @@
%PDF-1.4
%“Œ‹ž ReportLab Generated PDF document (opensource)
1 0 obj
<<
/F1 2 0 R /F2 3 0 R /F3 4 0 R /F4 5 0 R /F5 7 0 R
>>
endobj
2 0 obj
<<
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
>>
endobj
3 0 obj
<<
/BaseFont /Times-Roman /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
>>
endobj
4 0 obj
<<
/BaseFont /Times-Italic /Encoding /WinAnsiEncoding /Name /F3 /Subtype /Type1 /Type /Font
>>
endobj
5 0 obj
<<
/BaseFont /Times-Bold /Encoding /WinAnsiEncoding /Name /F4 /Subtype /Type1 /Type /Font
>>
endobj
6 0 obj
<<
/Contents 15 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 14 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
7 0 obj
<<
/BaseFont /Times-BoldItalic /Encoding /WinAnsiEncoding /Name /F5 /Subtype /Type1 /Type /Font
>>
endobj
8 0 obj
<<
/Contents 16 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 14 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
9 0 obj
<<
/Contents 17 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 14 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
10 0 obj
<<
/Contents 18 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 14 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
11 0 obj
<<
/Contents 19 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 14 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
12 0 obj
<<
/PageMode /UseNone /Pages 14 0 R /Type /Catalog
>>
endobj
13 0 obj
<<
/Author (Shkor Labs LLC) /CreationDate (D:20260923161207+00'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260923161207+00'00') /Producer (ReportLab PDF Library - \(opensource\))
/Subject (\(unspecified\)) /Title (Studio Participation Agreement \(42 Labs\)) /Trapped /False
>>
endobj
14 0 obj
<<
/Count 5 /Kids [ 6 0 R 8 0 R 9 0 R 10 0 R 11 0 R ] /Type /Pages
>>
endobj
15 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2786
>>
stream
Gatm<?#SK-&q05P(_h2!mY.e,bHd@\ng7*D^eK5e#=XdIJX0u=)d.8S.Tu5D^I(o)/Usk._4j@+>P)msbaC7Co"`4/T(t<N!rP65M]30h+!rf5677H.&-e=/5<kM_5/^4J4E;IWHA#qt?SU/f]ZQrj7ueD+!<f+VHJKYAC^I3UR8`T=_;l,or)`pPQjk1h7M@/LD!os?7S]3CM$L<?F5gM>bZkN2;t/lF4d^3iK"`r8$I^l.]IY>QG:rY4qHhp6@OHtZ+*ub]nWG<:OR]=1\bO!nrVrAW\6uJR\-^!0[(!cMSSa?6&(e9t@FW&agg8fsqNK[;GL6A;58\bcgeA]I*-K7#pKVuYKg#pe<*EhHn3S_t22lQRZsb6DMQj1A.DG*&()d(7AoLIW<!\XOI6-hq)e(#Jn#0G&-e/0kZe+sMRgDam?Uhs4A8j)d&'ih,'+.Zq%k5_iO:"TO*Ot,:E]PR.OR.[te2\I7'I>ga2#QtSYEp)]mtNHHqRZ2rZbH/4FP+s//=;*q.Po(Q7"f"u>7ni!!WBR%F@cjNCeH?.Ho.G_U=.a8W?,o[Cjg^-"DA[F;1U0+L#P?Cn/4>p,B?"D)]d.71f`mr_DP3_W9"pR=%D%4cq0?Cf'8OKipo4)6$O0l^l,kn*h_J]!$*_aOUm2m_r9.>qe[JFD9\EKQp]/b9HcGW,?'maV3PH!,/kJJq=YhCP>`m>K9bFd*S9si2t5Vdku/O-R9BSh&rAP[dU:i5L!OAP/g(L>p&c/IO;04jMc[3%_ps@ABi358i%6[Pp"Jmhm[qd]--4J=Zo.Vk1%-H%8aJIn+RYr,mf<=Vk$^6&iS\d,#^?5#K-*U'09Co+HXU'6j`^`dW-#M4mkFQ$)Z47nP*0B;.u;kgs8)S@0HT9+O&o[1mO%\c/&jJE4p'7DQ@;<B;68%ZHacl=Dq1o(,&$oD/&a8<WT?,oVEtZM4F;:>GWVYV\01@MIGaRHW7O]!UGHT;JI/W_d[HHZQj>0-1TnU=Z?f\H<_t52g68i5+7,C/%'=is&_QbVF$Z*^o&odc*(KIt<&TimcfHH(WESeMkBq.N81IK6Wml'Ud?I3SU,0HL^2ic!LAl#p`s@o.<oCHV_mY!_;IRqf=&^%0Q0kHD-q:p&NL"94VM0-]f$9/S[0"O"ZQr7&Lem_,8s-i$Br,+$WtcGnGWf)ZXn;.U,nlaHC4Y6c(U+F9kOaoc%P:jBMn_l4%44kB,>+1aA*;G%C-rX&f:eRI9G4^"[SodC)_gFq`B-^:SuLFgV>)PQftmWhOl1^sB>@/W5F!Llg(&Qa"eHJ8]1`BtRXPA!F@!5G(">$@@9"tF>04sGMpQZGQ93Rgq0EH:n!%\nJQ8i!F$i(cY+"i#32GA@V]quab;3?J<VX=s%oj?U*:'?7Oi!X+L1&Z@;c:maGAS)G77G.$8\";FBh_()@7F/NV$r,qr`5#8_+[5MRuG).S?Fb&46TLR_=Z(H'*@cg#_GtUe[%M9as(,)m<tZs8_\ST9OJoKW)VKt)fk%cs/pMX#B$#"eMfp>erp*3c@8#!j0i>8nINW7,[%V>>2e"LTp1!n^=.g]dN$qk<7-SkkIY:6Rqf(D@UCXW=rS21eZ;\jTU"^@b5Y60K?b"a89!(o#t@c:!pYT]DoBW#B(lR6j%rjXh+4oFJu=iF=P`c_hN\fK_J7XZe&oA#%ZD4B7\YZ.,\)?,W26F!\RS=-+JCKc>UjH#.+F+A[c:o<Ri):+<,MopFr`tl]Y[D\GH4JP]L]RuF^+CQ9Fs"=6c+H5%%HAnrQ6$Y]-M/oE3.Y?8Y>ZX6C%guqGRnOR(sHEFp'[gg3YmIKlNhG;nP2%E`FS=gW*b\H\lX4<;-Cp+7JSE1URPM$ScUnC(MmMear&DQ*t2k:,H1C#fFTh!`EJH'^W$'QYTQCE8%Uod9_],Lq!.spjhrcITuiHhXl7E\-8`kBl+31n!GZP#qN5%@._McH!Voscoj8MKY;[K%K.Dphb&S+MRrdGD0JQC+YSs\\D,_\Dd^plA3nuL,i`TQgUe6pc0!B'+`L[.Q.M0rhoK.4`6NG[?h?[rWtA-2;iu$4;/[DcO0DC4]VqlU]2jG"=tIDm[s(O^`,SVU8M.5OjXb6OlG.'36:G;q4epM%!aF.(4Ck',1N3TAWThpD6;P#/We_!-PqZe!e;"f?OcBl$];[4`),[lj2mC5Ah]_!WgBho[L=3E[HGR^[_bT/c&pSDZ]Ost0*V\2OJ8T4Zf1Q2bc>#lR;:f=V;F[:[.s=^5:%(#$R<"hWV*]$o)9.W&;j&aO8a<gGgP^%*1Dri*O=N(1@&^>D[#bu]cRWR@3$F9!AhTE39Mqsm?1aM*]3Si@9O.cKlIgGQ6Ai-,.sW]rX;\V61,07(#X[gA=#,f$c&V%[/RT7O4r0`Z,`sZ'qPS%Tn=LstNZu;tI!o],2gm%\3JO1gVYt)GAi$MEN=[u_D,EO,L9aGCS$fR1Z/VfZ\^GDtXOFUp2S`F81P9'^GX7B5nV3B74\bX[q_qF>7"P*XV:2W7c6ZakWK7LrdV>OS37J^rcVpmkHe8D[e(U]aW9TopDL+;TW/fN%"Z/5dUo7&&9;sU_$>JMq^2"Ir(5]pF/VgcrNF[ns%boLMiD4;VgQcJ3^Bd/\oJna3E1G+:=ga)$;%J/6ZVd9%CWhlmch?W1G9Iq89I,Vbi1E7kW]'RU649^K3Z25>Ai#^Nq7;dVL3G8-^#?C9G9cErF-N`#Cp#h/(g&2p01caNh*?*kbuFA-!cMo,]A#4dI8k]A(cS%)\5ee'I/#qbQO:h~>endstream
endobj
16 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 3015
>>
stream
Gau0Dmr-r?')g\%Jgg=hBAofc@EDE"qiI]"@Y%fm0?F8'S.>ioU2=dZaVCeNrpT:b8_KC\2ar6aKA)/1EotW92_tl0O+2`V5Mn0gnU\Bg%9O1ILF!=?hMaVq`]cW"q>/)n$!HQ4k)o+dmLe%&T4UNM!k2[,-=2e2&W:7m+2i65[UG@ape%d[Jj'%V*e'uqc*e3WC"(:!gn%%5`/0gk@2?)sT6ogMB"%Nbn>0`T0V#I\h69@28b("GG:]rP8r<-&J:GE/DQb>YU*L0#4IKX,,_O:kZX=;rfcQ6pABF`')0osZp&[J"P2PE-=g:Q7GH>++]b[[^h_`N%.B7tYCf=D]D2]Y)\ud;\Cs*bLF1LC9eeMC_PY__Ee<J'ifl3dW2V2dj()Q=7XCS.EQI5\]2"dm-)g8EiK@f^9eC,Q6JoOS]Lg9V#57(p>H[%aF3rC/)6SJ)!F7+S-jHE]'VW$VOq^?Snb6fj:ig]Jf42lgM\n&J-0tos;1b2bG>-)Z#X]4Y5eHiDlG^s/$=sMP-OADUD?q[WGqLWJ`i,ja/:Yp=P5>F#pfEC[6<nA;CJC'NB<Yud\b@2Z?^:&#24!UV]ZaZim4Z'gofAb;6[tk[#MEt'[lm\u9.$&eR1Yh;8UhAl/TEA,7D$%@eA1i[4%_WB+`Y_B5MXd*V7(b[HK(!Vf,aqHeLr)=NeJW=C,ClQbhUm>G^F^WTgP12^9S+^(io\"E]f2S_io3B?j"rocE6)ji@0dBM8&OMtBZ(/eNGTnf!uVC-4/.8(E_7d5\BV3/h\j"J]^#'6&&X&'kBFt5>9TBB:QW0AOeW<OV%5ZNM'&*E#7A'aKVD6X6bk2sAi6/<Neqr.K/%*kEk.D?QE18si]&oXK-&"*B5HL[IM0sNA*\==l9IWZj_\_<RQ]TF8B)(H(s/:#)E5E\AiI2G`Rqpb0JQ;=H"Kd`,<H[)O*>f+M^p,+W;)?q/>VR:YAabfM/_QfX^ng`g:mUYr_o/!'t_;U^PMe\9lIL5'ZQuCSj\^iP7^m.,l5ST?@CF*eS[DXoJ9:2e140m[p!JKP>$,3;fL-!TQgN"8sB_M^oJ-o+cJu4VA>r,hT1S@#J"n"]9jQ`)*3HliUp6nK"rdYM[LKOhP/7q<!o!s-gl+]aP--+BYgM$DX0d$Q./coPu3cT?PKNo0jI`t0ih+Tm*&,9Rkj?=BhL%eV5+gXd\hE%aMm-6E^$t4(O0)Oc0):3F4"*\]+^`9%&2u70NRN-&3Z,<h6/gq/$I,ojI5)&R1`\iqbh'T0A'/\!77$d6(D@lQ`bt)YDtuWJ`Q;u!VPOeB8g!9pkkicd&%;j.H6uI**8;@CK$?#k9p8Q!BO@WmbnE@,iE"UdDrq4/8,<jn[%Vf>&K"AD7Dj=bnDT+TPB'?`,0Ro_8"SQJE4"h/(+^bZ7X7d=YrdX,8Qgt7%[jQg6L2US3)<gs8?aL3rKT&R<?lre0t;X`H"d`,s$$*/hDq&:K`uh%7oMWmW]Oo)2TWdh[l+gq[#.5lf3-F4t?*1-#h'iTNFRh$OVa?mijoDe<ZOUUIOr:)qHY&U$)FU\FJqc!,aWSK>`<[>B#W(">$)OH+eqZ_%YQd"!c*_f6%DFh:P4iE,@lij[K<m.kJf!JOn5+4>ol&'A>tM&I3/2^^'(i`:Q_%KPJCH9CW+oKWi4'ho\6F63iDdSL.15?4:0t+:@8o"pEPGc'-XQ?6u,((YGDQBn`d]Wp.:uqThASOah([PW&9QLk7[i`>J5+pkQGTnjYgsL?K\4Z=ll3lPHFP)/VqT47>hS*sH?ugH=/a2Jho]HVluc5E:Bg+TBV3m!G&>O[1aP+T"^mC^/Raod&Q%$9LnC2T@6:/Jk-H@RC^(VFHWBX6dd#0V.,u:3['YMk*j:8q>pCkc7Di5+%pn`6U[Z3de+c3Ec^2N)!<Tk_h%T<`>kph0FQ?!![kl3Vs*+UZ/1t@6=S,%mjK4Nf+;gmhJR(^ND@"D?J+^qb`L@)>,nCWX.GJce26s:lk^ZG-g,2Ws[=9+r"BD*r+71S#1%:/)@%Iob+%7)[?Cc[lE4tQISon[aSa<<;%HMQdB*,I@^`Yf=$*Gr\\Q]^<N\cer>q^ZA(#j;OE4%1ndiY'XK%jioG6f&:.icq*OA]AK/H9ANJm)`#QTL"8s:,AQ*MLhC;4reCF9S@`f_q(a:@AqZmuL%YG<PN+=GC;skR/"FSAMBrV!No.N>TObS=,Xs6Y-gQ0?o)C4q+iA]VS&dr)Y[H`)#^2fePET;:2D.-4(/N<''?#*g`Ri=:h5mY!;X>"GsZT(Z1-NL)N1o1CP80\a(o'VTc%5f?Ph;?U5Z>jB.)2a,oLG=;\b61C);cb`PlP-]g)r'o)WgCqXkmD'C@Zd3X9(UJ:^i]SF0/uZWQW-3):ck&hbpD5fJ8Q$Y*6@HCkb`<dD2oLdJJ<rm34$jOiN?<dY@,NkTCf^ogXZ.66[Fd$::E'&TjMN0ecKCcH<6IlQdfp-2VVCAUACPCF"*_Y'u!03LDRH&_%+G0$amcW9kY'h<A1X>LpEB0XiO06&:o'H^X4H:QXt@WUX!3R/ga('DVD#_F>uk=%7tV]6f\&PSBW$dmD,el-f@)Oi[DsIl1V$!n$)p_N&U#YgH8.oYH:3JLOEL,;7J:$*7Jr?XnPiZ*>JgjbTONs:2F[o?uQfn.e6eOpU(1""c=SfIC\,RO*2j!4'RPhh+61rPRGPYg?;ci"V6k&=uJo"aCD6!hlU9FCQX)aD69t$D..0R&s"i?#V&^a1_URY]WU56d7*VMDn]`@BGuAUUu"3C'E==;NQ3jmcGED!Klq''`l.ZK#B%1KOLae^,u'V_+MrW.ADMXfB1Wk)JrL!/l$EBoL:Re@5)co"O;5j.I]f.n8%4G<Q6hj3<"Y+CIlLu9.h;!M@9*(%n8<Ki(sHTk]/*7gW6d7WFJ76sh)nZ3X8SSWs$#H+\-%^d[iH`V5Kb7Oa_-Wdp)NNff=MB<'gnOD,i^OR]KFu/co'f[+C2M@2M6mD!H-Z<,0*gZk'thM&1I~>endstream
endobj
17 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2659
>>
stream
Gatm<?'!]1(4FN4.#epp?I9gD@8-6VlqCG3AS8@(]Ig0]@2D`Xj!%LbB%UJ*kE]th_1WS-obTIHEntf^^%Yb:bf<fCIV2m)50_d,T*#j^Cqa@Em)<f?L&F&^oDO[G,\3l5E?G!_fR/?0os\8VU7g0o9G9'MdtgGtk6I2"0&*\Z=2#G$H=6>_D#P6^g2.e6PW#(X8[E&BT1S#gZZ$\ae=V'O1j7XOUA;s;72SeQ8nk0AM\LNH4KqLZV%H/Z#62WpgV&d"Mioc)du!MsZXefPlc=Z&%"Se*b6$HVMK,^\_(_R(;>>t%`U<so2@(r7l89OBdUX<"f>95RiKj!c%V;%Zfr?M/O8[5-%GPl>&$/`2Y,#8MR)1N$e;Eee^j)Gd@1:%K,p9N5XfD'6Xa@KV0%E&4#3jS[7m!P!0>(WPK<^3ffWW?CLF&rtq>#(?D);lEA;qf$Z"IB^I<b[Z<dVGeIoW>qE`lPEb+$FC_!`!fib<g>gjA0ZP40MFE4WhpmM,*UbpB-G]3R_4mfBt<W."qB\^9dR'AHfd%U/tZTIj$DTiq+bQC6G6W3,!D3)bpnFsj@>1[NYe,OhEMS`12K9eKa?TlqV-%FDf&C:2W?lS[idKed,P<*GT'-P)We*@DCALtq7m1(GSp(3"1#6UF%$dc/'Q\_MGb%2.D;SE>-1pA?1-V_`PX>%<LI"!n<f-!e6f$*N/W?/N4Qe1d,a53u`hS/`Mnh^eN>$Nfbua2J/@9D-^t_h`3:j@E]^$<@^WCaK)aF@`3G8sTV*_QE\!&rdhkliH\+io,'d,X,5#6$5X/C"&OSEDU.gLGVAZbn8'/+J0PT"pD4KnJ@muN"2RX1*Lb6<YmhL16E5d%=DY0L-npU/V#A8WPCr0$'TJj;lKpDUcgeaa=b2#,:*OiqsWEls3Fo49[*pL%RJX8+s!8]NQA.e+Cg-CXY1T#O3+tHmZ`i7^'L%JeI&ilAnQ^!$G.+K4Nm8a(ilE4PuIhQH\H8MR^Gu!m+g1<,S%$BVlait.*$f#hcnp2>!bA)!):IkXJ.J8%hQ3;/58e?PiY-05SS\&?>T,7U/62?:*6/"S/&k3)T7*aG^ojA>C,YX6CjAGK>OlbjV5)3XhO9N=@18cY%[]+)6@/'A@^.D=C#LHdULG7I/,j.<r#p$1M-h#'0D\'?d-X9T15@ma\9+;=-0.t&?)#,^%cY=%2W3X":T-n!%:dtaJLJh[+_+.$t]F-aP]ud]B0ieMC9u+k!`^G>LusB1KG+E&Q<7\GYO$PPPA;`V3ZrNjbEioh@^P6FjGQFWUYE;QG'XE,^(kM3H^b$0+%23QPn?>8QrgK>YrC;&.?4uPHmu"PfC$P4Z8:CbJgFJhfr90MuS_RcSF\!N($(C%TI(c7S1KRX)qV\=Y;HdOJ\#"1sg@@P\(j\1XY0X&c+=Z-V5o^V\O#R'8/2Z=S]I.3_2,3i(ZtL2>#eSdYcn%6cE1F/%7Ji6sVC8FH@QWU\)Bi6K1`*ecKE5A55Os>\+Y(Cg(-h^Yo>6NRlJ+$($agM)BLb'O^i?B[r@6)@FF4.l-:8F@g_2C*CP<2qE3hV,;W<C*s*`nP[B;K%c=@N5H)G#,H(cqd[d300a'U(2IUqkfM8\VS)^1p1JA;jrR>9LM$=]`PNQ9$?i7/!hHB,=boSG>"TT'LU-VBMh_i8qGF@qCoL#t`dDGm:fZ)t1oK^ZO4i]plW8)AC8]>r,B@6>p!tSO2<>(C]1-<m8tcCm;1*Qk8j)gPcX\h.&T^#dF#*83g6V%K\UY&1?:`rd6'9Kl$d=-.C^:B]E6u3Ze^sOMqui3dp1hBcE8n%C?TW&2\91TriYHi:cEt]8J2?jn-&!O\13OtqBTL/9/]VOb&g1$b:83]6AArKoX`9WHW'[0U;'0]3_o7S]1OZG,+LhZ,*t4W3O*rU)<`efIM-iD7paV7MZ[4R@U`"6&Rd8%9_s7m5X;$#9VLhA\8SiPo!t2%<k5EY'qF"6pW$H/dh^CH'=k$(#nri>hIS/?B"S5b&h]`8:5-Wjjg0@FP[B>:WL0%6]$C^C]g2+>!6Wdk30$8*R*(F!OVUVG)o\sSJLA*^`,n3G4:n:"Y(%d;MAnG_FSf]I=U6;@l&!90eYf;cjjA$W65h-eeX7O(JT,o$,"FgsP&;DZLSO\>$isFTqa_d5AgFZ.]kiL65<;*cWU?,;WDN@(B8)k\KGCD$iIJUGNi+u%l!"RY6l<i$=<J:c/8m^/fD'">jk*$W8S>AM,]p$NN.'VSC!Mf`)J\DcV#*&#626p=S%CeoYh+cTuH>]4lScAA%`"_?0VK$H4NXh45cacNDafI0Vdk2T=`=ZcMT2MT@NK994\$$]/Ars4e3WZoUIr:K=e&a.(FmIWYG$6urKMINa*(tMM.H/fi+^J`t(D$IYBscbg=S:73?k&8aOJ$!*[\iK#kFm$Y8O^S16>TNiIshZ\1$,CAU*pRTKu9Vt@s557&*tBb*8bSnmcKp8pT,a5>kb$k1/#Ns4DB3)0k=-8/_:qW>+m!M7M7CeLE)NG(_Gk>m,f%%5%[FsJ`2#g/Ek]n2JDYMR&7T>.1/LRq5)RC0:dfr5B1MK_q/?3q#9%!dp1-]bQ"M^O347]31]*cdoVd`$lXXd'aIa%-0%bgk[f#kg1Ci+F;3%f^Yn)ZdBGS0"8m9GqZ~>endstream
endobj
18 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2487
>>
stream
Gatm<_/e:"'#"/mqR6$^KEXUR"QE@cYu@Vo;Vm;>p7f_A(m&5<0Z_I'2#i#3%fd5=CeLU&fd0JuB$f#qp$:4#.)s"Ir%&e]TC1oM*th1D\uR:56lW`7NS`R+q<LbbP#<?<_kf=ba%NpTphp[j;_md.k#mr0q3om(-r.34/8Jql;&giuhU:4-])*kY%bn-8k=pj_)u#liZd#5G8;i/lGSfJR:"\T@.r%lSBtC(n=L.K`aV4'f<1:OD;U6ER+2gA1Q0$CS92jQH\(EMfE+E45OuOnrKHC/"SgUE.R\.hr_!/=<K!lY=[+ul\Hp#ek';Z<>,g+F<Mc_J*ocolnr)(n7^9<cpqc+m>d8WT)_tf%KOOfV^9rE]3mY:6.]JnuZ]75`k2+"]g[eq5-3bpcjrAg<A)=rKE.*]'1-peJh\GlXq??1'YW?.i[6*4j>jd/m>%U\"Q2.rW]q6m_0P#eDIlGsq0h'L_PocO^\ZV"n$-fpDhje*A$X?KhZ8X;P*_@^Q`bN\NV[:k-8=0[VB(LWF0?@E!IZm^f;0qu"7kf<D@#<9q).BV\'PNubYC/kVCc^08XW>pAA<RO&R@1HsO;BqZ3m'UBF'GBf'3jc1Z/sclJm+%Mi3cT$Sr\7!=lMq%U7(&9Il""A;QR$?0j_BE7+*//HDm^2)'Hi5*^@fW!l(/;n]A("AN*Z,tbguT5\&eiu7$L(rVfX0/`&.P5k2aBl;"\cQd@NqQK?tn>#j3qWb#3LAJ1[Mq*Mo#oCu+cW!NdcndVPCYJ@jCi#b#LD/"F(sl$J>/RR[Dgdd@ao@_!oelBPrWoa"==K>:K.C)9b&`_hnp=YY`UGT;9T]P\rFQPpYs6kLe,*E@AiS;m-jW'eXmWU#@h%t>u]OLaYhBM6Ik<@u._!cu+ShEX'HiZX>=rXo*qC"uD:h98O7*Do5+<Y)3n+_GX]lY8$qZ\\(!KgJ_p;'ibn&8!dDLP!AiO+_Ban((u3QbE!f)-"o6$M%>gedETs8M?=J(RGBe><_Qhk;Du28=2q1i:]un@rT=7L@sLRb??OC`'EC`2."gG#%J"]:&C8Id2mlJi`lEB)&g"Zo-en/rflQDVsRGXBMe04+:B0m*,Bp/pBlb9%M;HsKJjhePa)KT6.lBar$%+`-p[i.C*ue^:CJ#KfXITXO]'uE5;+IMmui&d%LVRd`rfKU(Xl4<QPs"m<mXg]@e:+B)MjL.CNToIqdC'plJ%tn`u8WEr@Im[\*L2,Vjg*Rfc=g+]Ek'HMs`d'T7n)4C&^).NH,%L6A,;6#Eu8s&Bsn&AJ2-*360U$/UQ(*pB&J'"?Zp;QEhJ-LqsU&/2e5K%m3I=c.HV>Q?%/1Rp[,NWo!E1j\j_9H#%`P%9.$5hJ1/seZe'C[]F!e>M]Vp\WRJ$U[BV!(WlLi3(@)Amb^21\/1F\GHF<((R$0i<U$$nXF-+#B&ceK7EI14#IsM#CG*M^nK01'3nRU.+NcK_#"^elZ2?ZX6oSto9\./]Y_t;@,$!K)Uj$]YT$Gd\Q0GuAkRU%qoE#rtV0keJWCH\KP,Df8F`q\t4A"Q"_!^T#/4RQ-,@+-5_\=]$e[HYcW!XPUH]D5r[QU'2\#^@Zg<]D,EeTUka5*J4]k56s"mi^ZXTMM<9"oq)+S2-Q!;TMXd^5&@,eqF+kPRIFbZC]i!mY_i;8fq20.CHk\:0AD%N1K9W3^#1JaZ@,Ghm/&"G\O-3:b>B&:*:Z$!IWL"1t&EA^C5U>/2neRRn2EG1c/*FT!G)o`;K=8u3j>6YgH3&f2&?TPANGV&$(V3dp6Cf0T\58DCe0bTDi;:Hs>m^[@c+M2NW"Yfd7A0=#CK4V%k2p#\/[(q#j`?^7W1`-oiYN2$h7@\bDU.#_O9B^n6MrD-SAbC$[8EOm[[_,mO3ErYiF1;_+k)U':n'oe,c(QI*W4lboKIcC;5_Wl*rTp]R.N3GDtF8Uob%khnjk(U/&cR1Nne\f(>$nr/jSkD&Y4ff-rio/g6XfQ#UCMci7n&KgE6qL7gWgc?[MfbE[1TnN#C\T-0!>^e&R:6\MnZu4;]ZtcI;KSX$R4YL$%g\^Aj`2X+nUPc'fU3D@5g2..H3!I\Ph3-oUPdV"Z@EDEX<u?dHlkiC:n*[1](j4Z-XbZ;iOW^45mA2QVj\+]TZTn-.u?QDqgWhu1m')Dd]88s3.6790hASd+nX#7h[O/BA:@:?/&DjiDhl@dSrolILa4J@FId88l:2W6RIT[#3L6iWcQs*LRSiXUO[FiG!!W`OT]$YC9e\a$h"j[7?-m<qe\,N5\9.Q$@[e)-d.`R;*r*E>7,a`!X>0"j+7'uf@JU&Z70T!?1TnH`H1_8fKe9%>oS!IdqjRs`^s#=H?#S_:\7^`qKo*1*Z&!$1m_T@bj4R>(q7D>ri.PF3Iaki#H[UtI3/"#nqWP>r:N+p"%o^]lU]0#.6g]hA#g(QS2:O=kZSYui:?U<s=_/K4AIY2,fNo'JCFL@W$,-.!43h[s~>endstream
endobj
19 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2420
>>
stream
Gb!;d>B;!t&q8_Fd185PS?g1V^YkH/_2/gE4,0l-;oZ3W1BOp)7RRm/r;4skb[=O?9_OuUH.Q9o^dtTqYJ7fn!?6M.o/iKDZj1PriIKt*?jr'6q(T=1mbl!T+3YZ?@Oj.9Cs/qeiL9>PR-6t6-..]>bDB^>4Q!H8+MqtOB\\aWL#<4@T4.5AhJL2W&8Rtr'im(Kj92.k->qg.Ce%MGq*n%JOKjD2lUESj!X*p\F:"]WUjQ$1RVs28)sUbQ$=h6e>o+\<5bVV<-KA=RZ(3.^i6/mip=S;KqJ6TB=s$]D"^5EfEe;rKnTbG#aDrm-39LGn.V>b)MXdDI,Yb8&TK)So,%DTp0c!/,Yt3D3:nU6(JLF>no*g"_*`J6^-R)s<$T5mtYai"@=rV/@,HW55j!ZX[Dk%,k4AlK-4Jo)=1X[leSr=#$Ui_WpjZ2*'rY](\jXgl[I=0H^rSc-AgsIKQ/1C.g"P(D.7J2A-.:l:3gX;cMU?DopBeqB3&QVHD(A&#+Yl_X9;NW/5`<7ejO_:+KS)gA9l$DliXN2ff)qqff2a"CS>e8C`p/a9)$;.Mu8G^K0L4tW>JtMfO'nt64MCd)H_rQ3=W-hO/0X<W:ffIH9b6+m!NT)qId+']$ACc5&[:5."K.3#2RKuMuZqu8p-:V-+'Z3353[[R'ZcHfrCol\VqMH]$?iRk"kcdXdi^JL>0eA`]U<Cn@'-]2+h38`9S>GM(O&*.\D$l(f:?C5j$=1\mG,%ujR-"4ZS]"lj%0g2,-]h8$nI^I0;DQR"BSkgG;i/n;9Lc+Z-4qK"K]2E9=HJm_(El03AR$3jp2WStH',Fb_HcSp;CtN_Db"Q(3-D$?;$`@aoM[JI?s4Nk5oH3H@0Xm.H`\/#/0tJ,?5/$XPWWO>fn*QHfu3AF7m68_9C8r$=eYB?5/K`f+97d"F@9TP'M>!]o%BN_/`Q@g0UXX/HZ(tZ%GGd`ruQ<>"kQGF1e1Xr?b@CpJK'#k!e+*an2d%CDA38>P7emOh*NhiD5<1M9P4&IduVpfAAOMmNF\5&r^IlKopc<,0HdU&+XtfGpfd-#DP^h0Uk5XaR`%s;-`%$cls%N)hHC\Vn"gLaC%Q]Hf#Xoc@)*t#aEYpL,RW(ZGS]q]@T\MpRQBQmc$H9?Vd!n\=MP$j0nIu=B$lYgbH91WCotFN_=udOf&bG75/=Y#O**dN&K[rk@DQADYZuu?;k/tu)AdZ'1roMHGem8[*77dUMhFuo0&1gS&++=Cau(cgM/>8Ums4>E<q-^C.fGuh]t&cr<kAlD=6I29JjuHIC-jGKcWDnDF\]Fn"ds%1>gqB)L8nUMg>%g@:?C_pKRN$*d$IJ:n$E;TLFp14K&c0'ln?0M]6IT9I-c!eGP`*@@<eGs^".?#iK_rcb(#Zkq"1G!r?2$QVO2fnV&ADs>\93>@.7-4d+c-JFcICGqB_<%H+(t+VEb&1'\DlPS*5L+En7/$c<?q(5_^;fi"JCg:%2HT>"8'"_f!UOakpI_oEIOtJ%Dbo2/Q*g?2O#3U>=%=:\L4][O=^kH[#'SpNusqHj?/*-1UkVX"cE*1)4V3c4p"3]e3PqpAU)KiEcT;Xm&&fm)q\AmBu:/nq1tUWYg_,$4B?;7?gJpJ:#1FCrkb+?!jH4V_#'47's08<_ZOR5N.B?YlQbc96SkLeVUY&Yu/V'#-?ue/=E<G6l5JuI5.Z0O5<PV=Gsls:UjHdHElWP='TOqY`HMk(i/"T8TMJ8f5sBJ4df0M)F-,OL_(kHeI/3i?M:N+U,-V0kCdF!9^08Vme-W`."7?,0mZs25gURBj&%csQBF/(7/$].h'De35dtP_E"gQ*^-.70ZM=IsiEmMXT*1L$4fJr2.B]'U5,4<D"giq/Or$9WJ3N;06lPf9pi8=cA`[79CGu85?p%cRoQQ7ia.<c[S515@FpR[/SuM27Ut@Sfe;S<X(+SC3%.hR0#[+@CQd[^Z$l_UD\eA7[!j>)+\deJZRD(k0J`k;Um"Tkn_E3=J:N@X[-V1os6?%tY$8u(P(Hs-*5CH_AF(nJ)7.>.@gi"H;PJHmm8sPU)Q6Y6L1P<,8jfGTU?\.`&Y?+7SeRK]6/:6"PC1IMr5uY$:B?N$+qN@`dE5hRIb@8EuH:U"Vml_Y/ai[k&#TS1KX/[VVO*$ji7'E%EE=D!Q!cTHGRtp`(:?_(T=+/El%g0'93k<ZZ>OMQuAkB,IHH`j#Cf>5OD'nn7o!%Yhh`3p??\;j0)idm[3;@.@2n56eG#AO"r"(Bk\VY/#@6Z,Z65'ul=rlAC>,*WB1OIokYd972eF#BVk^6obZIo*X%kaeaEq+t23PXfa>i&aRY<2OXk3giAYbK^eg:mT%FEJZo(UMS6s8:gURQ>oec=pHhKH/thm$72X\1T$#eI2oQ-.(q!m^iAr4q>JcrrNUjQNI~>endstream
endobj
xref
0 20
0000000000 65535 f
0000000061 00000 n
0000000132 00000 n
0000000239 00000 n
0000000348 00000 n
0000000458 00000 n
0000000566 00000 n
0000000771 00000 n
0000000885 00000 n
0000001090 00000 n
0000001295 00000 n
0000001501 00000 n
0000001707 00000 n
0000001777 00000 n
0000002088 00000 n
0000002174 00000 n
0000005052 00000 n
0000008159 00000 n
0000010910 00000 n
0000013489 00000 n
trailer
<<
/ID
[<e39ac81b70f6b5dd3bd1a9be5a60bf19><e39ac81b70f6b5dd3bd1a9be5a60bf19>]
% ReportLab generated PDF document -- digest (opensource)
/Info 13 0 R
/Root 12 0 R
/Size 20
>>
startxref
16001
%%EOF

View file

@ -0,0 +1,74 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>42 Labs — условия студии и инвестиционной программы</title>
<style>
:root{--paper:#faf8f3;--ink:#15150f;--signal:#FF4F00;--dim:#6b675e;--line:#d8d3c8;--card:#f3f0e8;--ok:#1d7a3e}
*{margin:0;padding:0;box-sizing:border-box}
body{background:var(--paper);color:var(--ink);font:16px/1.62 -apple-system,"Segoe UI",Roboto,Arial,sans-serif}
.wrap{max-width:980px;margin:0 auto;padding:56px 24px 100px}
header{border-bottom:3px solid var(--ink);padding-bottom:26px;margin-bottom:40px}
h1{font-size:34px;letter-spacing:-.02em;margin-bottom:8px}
.sub{color:var(--dim)}
h2{font-size:22px;margin:44px 0 14px;padding-top:18px;border-top:2px solid var(--line)}
p,li{margin-bottom:10px}
ul{padding-left:22px}
.card{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:20px 22px;margin:16px 0}
.formula{font-family:ui-monospace,Menlo,Consolas,monospace;background:var(--card);border-left:4px solid var(--signal);padding:12px 16px;margin:14px 0;border-radius:0 8px 8px 0}
table{width:100%;border-collapse:collapse;margin:16px 0}
th,td{border:1px solid var(--line);padding:10px 12px;text-align:left}
th{background:var(--card)}
footer{margin-top:56px;color:var(--dim);font-size:14px;border-top:2px solid var(--line);padding-top:16px}
a{color:var(--signal)}
</style>
</head>
<body>
<div class="wrap">
<header>
<h1>42 Labs — условия студии</h1>
<p class="sub">Участие · Инвестиционная программа · Управление. Редакция октябрь 2026. Не заменяет договор; при расхождении действует договор.</p>
</header>
<h2>1. Как устроено участие</h2>
<p>Набор в студию — <strong>раз в квартал</strong>. Условия участия подробно: <a href="https://42.cil.by/">42.cil.by</a>, объяснение договора: <a href="https://cil.by/static/dogovor_obiascenie.html">cil.by/static/dogovor_obiascenie.html</a>.</p>
<ul>
<li>Проект на 100% твой: студия не берёт долю в капитале, код, бренд и клиенты твои с первого дня.</li>
<li>Платишь большее из двух и только его: месячный взнос <strong>$999</strong> или <strong>[X]% от выручки проекта</strong>. Месяца, где платишь и то и другое, не существует.</li>
<li>Неоплата не расторгает договор: долг индексируется только вверх вместе с биткоином (CME CF BRR, запасной источник CoinGecko):</li>
</ul>
<div class="formula">Долг = Транш × max(1, BTC сегодня / BTC тогда) — только вверх, без процентов и множителей</div>
<ul>
<li>Возврат — из выручки проекта или прироста зарплаты. Личным долгом не становится. Выход — в любой месяц.</li>
</ul>
<h2>2. Инвестиционная программа (SAFE MFN)</h2>
<p>Инвесторы вкладываются по <strong>SAFE с MFN</strong> (most favored nation). Голос инвестора определяется на момент инвестиции:</p>
<div class="formula">Голоса = сколько BTC можно было купить на сумму инвестиции в момент её совершения</div>
<p>Голоса фиксируются в момент входа и дальше не переоцениваются — растёт или падает биткоин после инвестиции, число голосов не меняется.</p>
<h2>3. Квартальные уровни инвестиций студии</h2>
<p>Раз в квартал студия распределяет инвестиции по уровням:</p>
<table>
<tr><th>Уровень</th><th>Размер инвестиции</th></tr>
<tr><td>1</td><td>$1 000</td></tr>
<tr><td>2</td><td>$10 000</td></tr>
<tr><td>3</td><td>$100 000</td></tr>
<tr><td>4</td><td>$1 000 000</td></tr>
</table>
<ul>
<li>Подаваться нужно <strong>на текущий уровень</strong> — перепрыгивать уровни нельзя.</li>
<li>Количество победителей на уровне регулирует правление. Сейчас — <strong>по 1 победителю на уровень</strong>.</li>
</ul>
<h2>4. Ежедневные звонки: 3 через 3</h2>
<p>В рабочие дни звонки идут по схеме «3 через 3». Время распределяется <strong>пропорционально текущим голосам инвесторов</strong>. Если у инвестора ноль голосов — ему отводится <strong>3 минуты</strong>.</p>
<footer>
42 Labs · Shkor Labs LLC (Вайоминг) · ПК «Друзья Коллективного Разума» (Минск) · <a href="https://42.cil.by/">42.cil.by</a><br>
Эта страница объясняет условия, но не заменяет договор. При расхождении действует договор.
</footer>
</div>
</body>
</html>

View file

@ -0,0 +1,45 @@
# 42 Labs — условия студии
Редакция октябрь 2026. Не заменяет договор; при расхождении действует договор.
Подробно: [42.cil.by](https://42.cil.by/) · Объяснение договора: [cil.by/static/dogovor_obiascenie.html](https://cil.by/static/dogovor_obiascenie.html)
## 1. Как устроено участие
Набор в студию — **раз в квартал**.
- Проект на 100% твой: студия не берёт долю в капитале, код, бренд и клиенты твои с первого дня.
- Платишь **большее из двух** и только его: месячный взнос **$999** или **[X]% от выручки проекта**. Месяца, где платишь и то и другое, не существует.
- Неоплата не расторгает договор: долг индексируется только вверх вместе с биткоином (CME CF BRR, запасной источник CoinGecko):
> Долг = Транш × max(1, BTC сегодня / BTC тогда) — только вверх, без процентов и множителей.
- Возврат — из выручки проекта или прироста зарплаты. Личным долгом не становится. Выход — в любой месяц.
## 2. Инвестиционная программа (SAFE MFN)
Инвесторы вкладываются по **SAFE с MFN** (most favored nation). Голос инвестора определяется на момент инвестиции:
> **Голоса = сколько BTC можно было купить на сумму инвестиции в момент её совершения**
Голоса фиксируются в момент входа и дальше не переоцениваются.
## 3. Квартальные уровни инвестиций студии
Раз в квартал студия распределяет инвестиции по уровням:
| Уровень | Размер инвестиции |
|---|---|
| 1 | $1 000 |
| 2 | $10 000 |
| 3 | $100 000 |
| 4 | $1 000 000 |
- Подаваться нужно **на текущий уровень** — перепрыгивать уровни нельзя.
- Количество победителей на уровне регулирует правление. Сейчас — **по 1 победителю на уровень**.
## 4. Ежедневные звонки: 3 через 3
В рабочие дни звонки идут по схеме «3 через 3». Время распределяется **пропорционально текущим голосам инвесторов**. Если у инвестора ноль голосов — ему отводится **3 минуты**.
---
42 Labs · Shkor Labs LLC (Вайоминг) · ПК «Друзья Коллективного Разума» (Минск) · 42.cil.by

View file

@ -0,0 +1,43 @@
# Дайджест hour 2026-10-06 ~04:00 UTC
## Батч задач (2/2 закрыты, обе [M])
1. **t-261006034257-2cacd1** — MCP-инструменты OpenCode FR#4-#7. В гейт
cil.by/mcp добавлены 5 read-only тулов: `list_members` (31 член:
роль/проекты/паи/агенты), `list_applications` (заявки+голоса),
`projects_list` (22 проекта, фильтры owner/status), `list_agents`
(18 агентов + last_heartbeat — дока ask_agent теперь валидна),
`treasury_status` (wallet-treasury **3733.60 USDA**, кошельки членов,
паевые, последние движения). E2E через гейт: все ok, tools/list=68,
фильтры проверены. Дока MCP_CONNECT.md дополнена. Портал перезапущен.
Бэкап mcp.py.backup-1006-readtools.
2. **t-261006034507-f41aa2** — аудит фича-запросов OpenCode. mcp_audit:
пришло ровно 4 (все ok) — в портале ничего не потерялось, но в Kan
была потеря: только 2 карточки-дубля [FR#7], #4/#5/#6 отсутствовали.
Причина: **гонка в `_kan_psql`** — общий remote-файл
/tmp/kan_agent_fr.sql перезаписывался при 4 параллельных
feature_request (портал threaded=True), ssh выполнял чужой SQL.
Фиксы: уникальный remote-файл (uuid) + настоящий cardNumber вместо
id фичи (раньше писалось kan_card_number=id). Бэкфилл: [FR#4]№27,
[FR#5]№28, [FR#6]№29, дубль #7 скрыт. E2E полного тракта FR→Kan
верифицирован, тестовые записи вычищены. Итог: 4 запроса = 4
карточки №26-29, feature_backlog синхронен.
## Инфра/события
- Мин-тики часа: 6× gate:quiet — событий выше порога нет, LLM не
запускался (экономия burn).
- Фоновые задачи: нет (list пуст).
- Эскалации: открытых 0 (все acked; свежая e-261006025634 по TG-токену
Лунары — вне этого тика, наблюдалась ранее).
- Доска: только 2 старых blocked S-клона (02.10, ждут уточнений),
ready пусто.
- coop-portal.service перезапускался 3× в течение тика (по фиксам),
финально active, health ok.
## AUM-нотация
Тулы состояния (казна/члены/проекты/агенты) — база для агентских
контуров кооператива (twin-lite студия, AUM-симуляции): агенты больше
не ходят вслепую, а фикс гонки гарантирует, что фича-бэклог не теряет
запросы — прямой вклад в долгосрочную базу.

View file

@ -0,0 +1,43 @@
# Дайджест hour 2026-10-06 ~08:30 UTC
## Батч задач (1/1 закрыта)
1. **t-261006072315-18199d** [L] — Доска фича-реквестов на странице
проекта (Alex). На портале cil.by `/project/<id>` добавлен раздел
«Фича-реквесты»: агрегирует все входящие MCP `feature_request` с их
статусами. Сделано:
- миграция: `agent_feature_requests.project_id` + индекс (init_db,
применена на проде);
- API в `domains/boards.py`: `GET /api/projects/<pid>/feature-requests`
(FR проекта + общий бэклог портала `project_id=''`, голоса, can_manage)
и `PATCH .../<id>/status` (владелец/Председатель; статусы
new/accepted/in_progress/done/rejected);
- UI `static/project_detail.html` в стиле портала: карточки
(title, агент, дата, Kan №, голоса ⬡, проблема/предложение/usage_hint),
селектор статуса у владельца, счётчики по статусам;
- MCP `feature_request` теперь привязывает запрос к проекту агента
(`agents.project_id`).
E2E на изолированной копии БД: 7 FR на проекте 1, PATCH ок, невалидный
статус 400, member 403 на PATCH, изоляция проектов ок, 404 на несущ.,
`node --check` ok. Прод: coop-portal рестартован, active, миграция
применена. Бэкапы `*.backup-1006-frboard`.
## Инфра/события
- Мин-тики часа: 6× gate:quiet — событий выше порога нет, LLM не
запускался (экономия burn).
- Фоновые задачи: нет (list пуст).
- Эскалации: открытых 0, все acked. Свежая e-261006065717 (mail_send
500) уже закрыта фикс-коммитом `bc3c3e5` (import sys → NameError).
- coop-portal.service: 1 рестарт в тике (деплой доски), финально
active, health ok, агенты поллят штатно.
- Git /root/coop: изменения в 4 файлах (app.py, boards.py, mcp.py,
project_detail.html) — уйдут очередным auto-commit.
## AUM-нотация
Доска фича-реквестов замыкает контур обратной связи агент→владелец:
запросы больше не лежат мёртвым списком, а имеют видимый статус и
приоритет (голоса) прямо на странице проекта. Это повышает отдачу от
агентского контура кооператива (больше полезных фич → больше ресурса
на длинной дистанции) — прямой вклад в долгосрочную AUM-базу.

View file

@ -0,0 +1,38 @@
# Дайджест hour 2026-10-06 ~18:10 UTC
## Эскалации (обе закрыты в этом тике)
- **e-261006145651 (critical, «стор скомпрометирован»)** — ЛОЖНАЯ ТРЕВОГА, разобрано и закрыто.
Полный аудит `cli.py verify`: OK по всем 1.3M событий (подписи + тампер-цепочка, ключ faf8d19d9aed71fe).
Событие 1303573 перепроверено точечной криптопроверкой — подпись ВАЛИДНА. Единственная дырка: 1303601
(сама эта EscalationRaised) записана без sig из-за транзиентного отказа подписи — аудит корректно
считает её legacy-границей, не ошибкой. Причина паники: упавший процесс верифицировал дельту чужим
ключом (изоляты /tmp/simbiot-iso-* имеют собственные ключи аудита) → false positive.
- **e-261006145703 (warn, дублирующий трейсбек)** — тот же инцидент, закрыт вместе с первым.
Рекомендация на day: прокинуть key_id стора в верификатор/не считать чужой key_id ошибкой.
Состояние после разбора: проекции догнали голову стора (last_event = max id), CLI работает,
min-тик жив, inbox чист (0 без ответа; m-516173853/854 из контекста эскалации давно replied).
## Батч задач (2/2 закрыто)
1. **t-261006175404 (M) deploy-claude-code.sh — ГОТОВО.**
`scripts/deploy-claude-code.sh` (289 строк, +x): воспроизводимая развёртка Claude Code + ttyd web-TUI
по боёвому опыту Лунара (node 22 nodesource/tarball-фолбэк, npm i -g @anthropic-ai/claude-code,
ttyd 1.7.7 под архитектуру, OS-юзер, systemd-юнит с basic-auth). Опции --port/--user/--token/--service/
--ttyd-version/--claude-version/--interface/--env K=V/--readonly/--dry-run/--check. Протестировано:
bash -n, два dry-run (включая лунар-профиль — ExecStart совпал с боевым), валидация аргументов
(exit 2), --help, --check. Идемпотентен (не дёргает restart при неизменном юните).
2. **t-261006175504 (M) API для поиска товаров по Shopify Collective — ГОТОВО.**
Публичного Collective-поиска НЕ СУЩЕСТВУЕТ (searchShopifyCollectiveSuppliers отсутствует в схеме,
404). Официально: приглашения/коннекты/просмотр каталогов — только UI приложений Collective; подтверждено
стаффом Shopify (тред 2025-04) и changelog (свежего нет). Рабочий путь: авто-импорт policies → товары
с тегом 'Shopify Collective' + {supplier_name} → Admin GraphQL products(query:"tag:...") + вебхуки
products/create|update|delete; поставщиком — productCreate + publishablePublish в 'Collective (Supplier)'.
Детали в ответе задачи (доставлено в TG).
## Сводка состояния
- Задачи: доска чиста от ready по этому батчу; tasks_done растёт (обе M закрыты с доставкой в TG).
- Эскалации открытых: 0 (было 2).
- Верхний контекст: стор здоров, DRY-инцидент закрыт без потери данных.

View file

@ -28,6 +28,10 @@ from harness import commands, es # noqa: E402
_HERE = os.path.dirname(os.path.abspath(__file__)) _HERE = os.path.dirname(os.path.abspath(__file__))
_INDEX = os.path.join(_HERE, "dashboard.html") _INDEX = os.path.join(_HERE, "dashboard.html")
_ROY_INDEX = os.path.join(_HERE, "roy.html")
# ttyd (Claude Code) на агенте Roy — t-261008084337-3afbca
ROY_TTYD_HOST = os.environ.get("SIMBIOT_ROY_TTYD_HOST", "178.104.52.234")
ROY_TTYD_PORT = int(os.environ.get("SIMBIOT_ROY_TTYD_PORT", "7681"))
_ADDR = os.environ.get("SIMBIOT_DASH_ADDR", "0.0.0.0") _ADDR = os.environ.get("SIMBIOT_DASH_ADDR", "0.0.0.0")
_PORT = int(os.environ.get("SIMBIOT_DASH_PORT", "8765")) _PORT = int(os.environ.get("SIMBIOT_DASH_PORT", "8765"))
@ -541,6 +545,142 @@ class _Handler(BaseHTTPRequestHandler):
self.end_headers() self.end_headers()
self.wfile.write(body) self.wfile.write(body)
# ── WebSocket-прокси на ttyd агента Roy (t-261008084337-3afbca) ────────
def _roy_ws_proxy(self):
"""Прозрачный WS-прокси клиент ↔ ws://178.104.52.234:7681/ws.
stdlib-only: свой минимальный WS-клиент (рукопожатие + чтение фреймов)
+ переупаковка ответов ttyd (серверные фреймы без маски, клиенту
маскируем). Управляющие фреймы проксируем на обе стороны.
"""
import base64 as _b64
import socket as _socket
import struct as _struct
host, port = ROY_TTYD_HOST, ROY_TTYD_PORT
try:
up = self.headers.get("Upgrade", "").lower()
key = self.headers.get("Sec-WebSocket-Key", "")
if up != "websocket" or not key:
return self._json({"error": "expected websocket upgrade"}, code=400)
version = self.headers.get("Sec-WebSocket-Version", "13")
try:
sk = _socket.create_connection((host, port), timeout=10)
except OSError as e:
return self._json({"error": f"ttyd {host}:{port} unreachable: {e}"},
code=502)
sk.settimeout(None)
# рукопожатие с ttyd (без cookie: ttyd на Roy — credential=false)
req = (f"GET /ws HTTP/1.1\r\nHost: {host}:{port}\r\n"
"Upgrade: websocket\r\nConnection: Upgrade\r\n"
f"Sec-WebSocket-Key: {key}\r\n"
f"Sec-WebSocket-Version: {version}\r\n\r\n")
sk.sendall(req.encode())
buf = b""
while b"\r\n\r\n" not in buf:
chunk = sk.recv(4096)
if not chunk:
sk.close()
return self._json({"error": "ttyd closed during handshake"},
code=502)
buf += chunk
if len(buf) > 16384:
sk.close()
return self._json({"error": "ttyd handshake too large"}, code=502)
head, rest = buf.split(b"\r\n\r\n", 1)
status = head.split(b"\r\n", 1)[0].decode("latin-1", "replace")
if " 101 " not in status + " ":
sk.close()
return self._json({"error": f"ttyd handshake failed: {status}"},
code=502)
# отвечаем клиенту тем же 101 (echo его ключа — валидация на браузере)
resp_head = head.decode("latin-1", "replace")
self.connection.sendall((resp_head + "\r\n\r\n").encode("latin-1"))
def _read_frame(sock, masked):
hdr = b""
while len(hdr) < 2:
c = sock.recv(2 - len(hdr))
if not c:
raise ConnectionError("eof")
hdr += c
fin_op = hdr[0]
op = fin_op & 0x0F
ln = hdr[1] & 0x7F
if ln == 126:
ext = b""
while len(ext) < 2:
ext += sock.recv(2 - len(ext))
ln = _struct.unpack(">H", ext)[0]
elif ln == 127:
ext = b""
while len(ext) < 8:
ext += sock.recv(8 - len(ext))
ln = _struct.unpack(">Q", ext)[0]
mask = b""
if masked:
while len(mask) < 4:
mask += sock.recv(4 - len(mask))
data = b""
while len(data) < ln:
data += sock.recv(min(65536, ln - len(data)))
if masked:
data = bytes(b ^ mask[i % 4] for i, b in enumerate(data))
return op, data
def _send_frame(sock, op, data, mask_it):
fin = 0x80 | op
n = len(data)
if n < 126:
hdr = _struct.pack(">BB", fin, (0x80 if mask_it else 0) | n)
elif n < 65536:
hdr = _struct.pack(">BBH", fin, (0x80 if mask_it else 0) | 126, n)
else:
hdr = _struct.pack(">BBQ", fin, (0x80 if mask_it else 0) | 127, n)
if mask_it:
mk = os.urandom(4)
hdr += mk
data = bytes(b ^ mk[i % 4] for i, b in enumerate(data))
sock.sendall(hdr + data)
# мост: ttyd→клиент в фоне, клиент→ttyd в текущем потоке
self.connection.settimeout(None)
import threading as _th
def _upstream():
try:
while True:
op, data = _read_frame(sk, masked=False)
_send_frame(self.connection, op, data, mask_it=False)
except Exception:
pass
finally:
try:
self.connection.shutdown(_socket.SHUT_RDWR)
except Exception:
pass
t = _th.Thread(target=_upstream, daemon=True)
t.start()
try:
while True:
op, data = _read_frame(self.connection, masked=True)
_send_frame(sk, op, data, mask_it=True)
if op == 0x8: # close от клиента
break
except Exception:
pass
finally:
try:
sk.close()
except Exception:
pass
except Exception as e: # прокси не должен ронять дашборд
try:
self._json({"error": f"ws proxy: {e}"}, code=502)
except Exception:
pass
# ── routes ── # ── routes ──
def do_GET(self): def do_GET(self):
route = self.path.partition("?")[0] route = self.path.partition("?")[0]
@ -572,6 +712,22 @@ class _Handler(BaseHTTPRequestHandler):
return self._json(build_agent(lvl)) return self._json(build_agent(lvl))
except Exception as e: except Exception as e:
return self._json({"error": str(e)[:500]}, code=503) return self._json({"error": str(e)[:500]}, code=503)
if route == "/roy":
# страница-обёртка ttyd/Claude Code на агенте Roy
# (t-261008084337-3afbca): UI в дашборде, терминал — через /roy/ws
if not self._authorized():
return self._forbidden()
try:
with open(_ROY_INDEX, encoding="utf-8") as f:
return self._html(f.read())
except OSError as e:
return self._json({"error": f"roy.html not found: {e}"}, code=500)
if route.startswith("/roy/ws"):
# прокси WebSocket → ttyd Roy (178.104.52.234:7681): пробрасываем
# заголовки апгрейда, буферизуем фреймы как есть (Opcode 0-2/A).
if not self._authorized():
return self._forbidden()
return self._roy_ws_proxy()
if route == "/api/health": if route == "/api/health":
return self._json({"ok": True, "ts": _utcnow()}) return self._json({"ok": True, "ts": _utcnow()})
if route in ("/", "/index.html"): if route in ("/", "/index.html"):

View file

@ -295,6 +295,7 @@ def append_event(aggregate: str, aggregate_id: str, event_type: str, data: dict)
version = (row["v"] or 0) + 1 version = (row["v"] or 0) + 1
ts = datetime.now(timezone.utc).isoformat() ts = datetime.now(timezone.utc).isoformat()
data_str = json.dumps(data, ensure_ascii=False) data_str = json.dumps(data, ensure_ascii=False)
sig_f = {}
try: try:
from . import audit as _audit from . import audit as _audit
prev = db.execute( prev = db.execute(
@ -302,6 +303,20 @@ def append_event(aggregate: str, aggregate_id: str, event_type: str, data: dict)
sig_f = _audit.sign_event_row(aggregate, aggregate_id, event_type, sig_f = _audit.sign_event_row(aggregate, aggregate_id, event_type,
data_str, ts, version, data_str, ts, version,
prev["sig"] if prev else None) prev["sig"] if prev else None)
# t-261008084600 (rogue-key guard): стор с чужими подписями не подпишем
# своим ключом. Инциденты 06.10/08.10: cli из /tmp-копии репо наследовал
# SIMBIOT_HOME прод-стора, не находил рядом свой .audit-ключ, генерил
# НОВЫЙ и вписывал его подпись в прод-стор → run_projections падал
# «стор скомпрометирован» каждую секунду. Правило: если стор уже подписан,
# key_id нового ключа обязан совпадать с key_id существующих событий;
# иначе пишем без подписи (legacy-граница, проекции живы).
if sig_f.get("key_id"):
dom = db.execute(
"SELECT key_id FROM events WHERE key_id IS NOT NULL "
"ORDER BY id DESC LIMIT 1").fetchone()
if dom and dom["key_id"] and dom["key_id"] != sig_f["key_id"]:
sig_f = {}
# чужой ключ → без подписи; верификатор пометит legacy
except Exception: except Exception:
sig_f = {} # без подписи (legacy) — аудит отразит, харнес не падает sig_f = {} # без подписи (legacy) — аудит отразит, харнес не падает
try: try:

83
harness/roy.html Normal file
View file

@ -0,0 +1,83 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Simbiot · Roy — Claude Code (ttyd)</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.min.css">
<style>
html,body{margin:0;height:100%;background:#161b22;color:#e6edf3;font-family:system-ui,sans-serif}
#bar{display:flex;align-items:center;gap:10px;padding:8px 12px;background:#0d1117;border-bottom:1px solid #30363d}
#bar b{font-size:14px}
#st{margin-left:auto;font-size:12px;color:#8b949e}
#st.ok{color:#3fb950}#st.err{color:#f85149}
#term{padding:6px;height:calc(100% - 42px);box-sizing:border-box}
</style>
</head>
<body>
<div id="bar"><b>Roy · 178.104.52.234 · Claude Code (ttyd)</b>
<span id="st">подключение…</span></div>
<div id="term"></div>
<script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.min.js"></script>
<script>
(async () => {
const term = new Terminal({cursorBlink:true, fontSize:14, theme:{background:'#161b22'}});
const fit = new FitAddon.FitAddon();
term.loadAddon(fit);
term.open(document.getElementById('term'));
fit.fit();
addEventListener('resize', () => fit.fit());
// токен — как у дашборда: из cookie или query этой страницы
let token = '';
const q = new URLSearchParams(location.search).get('token');
if (q) { token = q; document.cookie = 'simbiot_token=' + encodeURIComponent(q); }
else { const m = document.cookie.match(/(?:^|;\s*)simbiot_token=([^;]+)/); if (m) token = decodeURIComponent(m[1]); }
const wsUrl = (location.protocol==='https:'?'wss://':'ws://') + location.host
+ '/roy/ws' + (token ? '?token=' + encodeURIComponent(token) : '');
const st = document.getElementById('st');
// ttyd-протокол: первый JSON-фрейм {AuthToken:'', Columns:, Rows:}
const ws = new WebSocket(wsUrl);
ws.binaryType = 'arraybuffer';
let initSent = false;
ws.onopen = () => {
ws.send(JSON.stringify({AuthToken:'', Columns:term.cols, Rows:term.rows}));
initSent = true;
st.textContent = 'онлайн'; st.className = 'ok';
term.focus();
};
ws.onmessage = ev => {
const d = new Uint8Array(ev.data instanceof ArrayBuffer ? ev.data : new TextEncoder().encode(ev.data));
if (!d.length) return;
if (d[0] === 0x00) { // OUTPUT: печатаем как есть
term.write(new Uint8Array(d.buffer, 1));
} else if (d[0] === 0x01) { // PING
const p = new Uint8Array(2); p[0] = 0x02; ws.send(p);
}
};
ws.onclose = () => {
st.textContent = initSent ? 'связь потеряна — обновите страницу' : 'нет доступа (токен?)';
st.className = 'err';
};
term.onData(data => {
// INPUT: 0x00 + utf-8
const p = new Uint8Array(1 + data.length);
p[0] = 0x00;
for (let i = 0; i < data.length; i++) p[1+i] = data.charCodeAt(i) & 0xFF;
ws.send(p);
});
// resize: WINDOW_RESIZE 0x02 + json {Columns,Rows}
term.onResize(({cols, rows}) => {
if (ws.readyState !== 1) return;
const j = JSON.stringify({Columns:cols, Rows:rows});
const p = new Uint8Array(1 + j.length);
p[0] = 0x02;
for (let i = 0; i < j.length; i++) p[1+i] = j.charCodeAt(i);
ws.send(p);
});
})();
</script>
</body>
</html>

5
out/dashboard.log Normal file
View file

@ -0,0 +1,5 @@
[dash] http://0.0.0.0:8765/ (health 127.0.0.1:8766/api/health) refresh=5s token=off
[dash] 127.0.0.1 "GET /api/health HTTP/1.1" 200 -
[dash] 185.203.154.209 "GET /api/state?t=1791449512284 HTTP/1.1" 200 -
[dash] 185.203.154.209 "GET /api/state?t=1791449517287 HTTP/1.1" 200 -
[dash] 185.203.154.209 "GET /api/state?t=1791449522283 HTTP/1.1" 200 -

289
scripts/deploy-claude-code.sh Executable file
View file

@ -0,0 +1,289 @@
#!/usr/bin/env bash
# ============================================================================
# deploy-claude-code.sh — развернуть Claude Code + ttyd web-TUI на любом сервере
# ============================================================================
# Воспроизводит боевую развёртку Лунара (t-261005155643: 2.28.121.212, user
# lunar, Claude Code 2.1.289 + ttyd 1.7.7, systemd-юнит ttyd-claude.service):
# 1) node/npm (если нет: apt → nodesource 22.x, иначе статический tarball)
# 2) npm i -g @anthropic-ai/claude-code
# 3) ttyd (бинарник с github-releases под архитектуру)
# 4) OS-юзер (если нет), systemd-юнит ttyd-claude.service
# 5) daemon-reload + enable --now + финальная проверка версий и здоровья
#
# Использование (root или sudo):
# bash scripts/deploy-claude-code.sh # дефолты: :7681, юзер claude, токен сгенерится
# bash scripts/deploy-claude-code.sh --port 7681 --user lunar --token MySecret
# bash scripts/deploy-claude-code.sh --dry-run --port 9000 --user bob --token x
# bash scripts/deploy-claude-code.sh --check # только здоровье, без установки
#
# Опции:
# --port N порт ttyd (по умолчанию 7681, как на Лунаре)
# --user NAME OS-юзер сервиса И логин basic-auth (по умолчанию claude)
# --token SECRET пароль basic-auth (по умолчанию: openssl rand -hex 12)
# --service NAME имя systemd-юнита (по умолчанию ttyd-claude)
# --ttyd-version V версия ttyd (по умолчанию 1.7.7)
# --node-version V версия node для tarball-фолбэка (по умолчанию 22.14.0)
# --claude-version V пин версии claude-code (по умолчанию latest)
# --interface IF на чём слушать (по умолчанию 0.0.0.0)
# --env K=V доп. Environment= в юнит (повторяемый), например
# --env ANTHROPIC_BASE_URL=https://api.z.ai
# --readonly терминал только для чтения (без -W)
# --dry-run показать план, ничего не делать (root не нужен)
# --check только проверка здоровья (версии + HTTP + сервис)
# -h | --help эта справка
#
# Безопасность: веб-терминнал закрыт basic-auth (-c user:token) — как на Лунаре
# (логин lunar / Zm9yZXZ0b25lClaude01). Сервис работает от непривилегированного
# юзера (User=), никакого root-shell в браузере.
# ============================================================================
set -euo pipefail
PORT="7681"
USER_NAME="claude"
TOKEN=""
SERVICE="ttyd-claude"
TTYD_VERSION="1.7.7"
NODE_VERSION="22.14.0"
CLAUDE_VERSION=""
INTERFACE="0.0.0.0"
READONLY=0
DRY_RUN=0
CHECK_ONLY=0
UNIT_ENV=()
log() { printf '[deploy-claude-code] %s\n' "$*"; }
die() { printf '[deploy-claude-code] ОШИБКА: %s\n' "$*" >&2; exit 2; }
have() { command -v "$1" >/dev/null 2>&1; }
usage() { sed -n '2,37p' "$0" | sed 's/^# \{0,1\}//'; }
# ── парсинг аргументов ──────────────────────────────────────────────────────
while [ $# -gt 0 ]; do
case "$1" in
--port) PORT="${2:-}"; shift 2 ;;
--user) USER_NAME="${2:-}"; shift 2 ;;
--token) TOKEN="${2:-}"; shift 2 ;;
--service) SERVICE="${2:-}"; shift 2 ;;
--ttyd-version) TTYD_VERSION="${2:-}"; shift 2 ;;
--node-version) NODE_VERSION="${2:-}"; shift 2 ;;
--claude-version) CLAUDE_VERSION="${2:-}"; shift 2 ;;
--interface) INTERFACE="${2:-}"; shift 2 ;;
--env) UNIT_ENV+=("${2:-}"); shift 2 ;;
--readonly) READONLY=1; shift ;;
--dry-run) DRY_RUN=1; shift ;;
--check) CHECK_ONLY=1; shift ;;
-h|--help) usage; exit 0 ;;
--port=*|--user=*|--token=*|--service=*|--ttyd-version=*|--node-version=*|\
--claude-version=*|--interface=*|--env=*)
# форма --opt=value
opt="${1%%=*}"; val="${1#*=}"
case "$opt" in
--port) PORT="$val" ;; --user) USER_NAME="$val" ;;
--token) TOKEN="$val" ;; --service) SERVICE="$val" ;;
--ttyd-version) TTYD_VERSION="$val" ;; --node-version) NODE_VERSION="$val" ;;
--claude-version) CLAUDE_VERSION="$val" ;; --interface) INTERFACE="$val" ;;
--env) UNIT_ENV+=("$val") ;;
esac; shift ;;
*) usage >&2; die "неизвестная опция: $1" ;;
esac
done
# ── валидация ────────────────────────────────────────────────────────────────
[[ "$PORT" =~ ^[0-9]+$ ]] && [ "$PORT" -ge 1 ] && [ "$PORT" -le 65535 ] \
|| die "--port должен быть числом 1..65535 (получили: '$PORT')"
[[ "$USER_NAME" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]] \
|| die "--user должен быть валидным unix-логином (a-z, 0-9, _, -; получили: '$USER_NAME')"
[[ "$SERVICE" =~ ^[a-zA-Z0-9_.@-]+$ ]] || die "--service: некорректное имя юнита '$SERVICE'"
case "$TOKEN" in
*:*) die "--token не может содержать ':' (ломает -c user:token)" ;;
*' '*) die "--token не может содержать пробелы" ;;
esac
if [ "${#UNIT_ENV[@]}" -gt 0 ]; then
for e in "${UNIT_ENV[@]}"; do
[[ "$e" =~ ^[A-Za-z_][A-Za-z0-9_]*=.+$ ]] || die "--env ожидает KEY=VALUE (получили: '$e')"
done
fi
# токен: явный или сгенерированный (в dry-run без openssl — плейсхолдер)
if [ -z "$TOKEN" ]; then
if have openssl; then TOKEN="$(openssl rand -hex 12)"; else TOKEN="CHANGE-ME-$(date +%s)"; fi
fi
RUN() { # RUN 'описание' cmd args... — единая точка для dry-run
local desc="$1"; shift
if [ "$DRY_RUN" -eq 1 ]; then
printf '[dry] %s\n -> %s\n' "$desc" "$*"
else
log "$desc"
"$@"
fi
}
# ── --check: только здоровье ─────────────────────────────────────────────────
health_check() {
local fail=0
if have claude; then
printf 'claude : %s\n' "$(claude --version 2>&1 | head -1)"
else
printf 'claude : НЕ УСТАНОВЛЕН\n'; fail=1
fi
if have ttyd; then
printf 'ttyd : %s\n' "$(ttyd --version 2>&1 | head -1)"
else
printf 'ttyd : НЕ УСТАНОВЛЕН\n'; fail=1
fi
if [ "$(id -u)" -eq 0 ] && have systemctl; then
if systemctl is-active --quiet "$SERVICE.service"; then
printf 'service: %s active\n' "$SERVICE"
else
printf 'service: %s НЕ активен\n' "$SERVICE"; fail=1
fi
fi
local code
code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 "http://127.0.0.1:${PORT}/" || true)"
[ -n "$code" ] || code=000
# 200 = отвечает; 401 = basic-auth требует логин — тоже «жив»
if [ "$code" = "200" ] || [ "$code" = "401" ]; then
printf 'http : :%s -> %s (жив)\n' "$PORT" "$code"
else
printf 'http : :%s -> %s (НЕ отвечает)\n' "$PORT" "$code"; fail=1
fi
[ "$fail" -eq 0 ] && log 'здоровье: OK' || log 'здоровье: есть проблемы'
return "$fail"
}
if [ "$CHECK_ONLY" -eq 1 ]; then
health_check || exit 1
exit 0
fi
# ── предварительные проверки окружения ───────────────────────────────────────
[ "$(id -u)" -eq 0 ] || [ "$DRY_RUN" -eq 1 ] || die "запусти от root (sudo bash $0 ...)"
have curl || die "нужен curl (apt-get install -y curl)"
if [ "$DRY_RUN" -eq 0 ]; then
have systemctl || die "нужен systemd (systemctl не найден)"
fi
log "план: порт=$PORT user=$USER_NAME service=$SERVICE ttyd=$TTYD_VERSION readonly=$READONLY"
[ "$DRY_RUN" -eq 1 ] && log 'режим DRY-RUN: ничего не устанавливается'
# ── 1) node/npm ──────────────────────────────────────────────────────────────
node_major() { node -v 2>/dev/null | sed 's/^v//' | cut -d. -f1; }
node_ok() {
have node && have npm && [ "$(node_major)" -ge 18 ] 2>/dev/null
}
if node_ok; then
log "node $(node -v) / npm $(npm -v) уже есть — пропускаю"
else
if have apt-get; then
# путь Лунара: nodesource setup_22.x + apt
RUN 'устанавливаю node 22 (nodesource + apt)' \
bash -c 'set -e; curl -fsSL https://deb.nodesource.com/setup_22.x -o /tmp/n.sh && bash /tmp/n.sh >/dev/null 2>&1; apt-get install -y nodejs >/dev/null 2>&1 || apt-get install -y nodejs'
else
# фолбэк: статический tarball с nodejs.org (любой дистрибутив)
NARCH=''; case "$(uname -m)" in
x86_64) NARCH='x64' ;;
aarch64) NARCH='arm64' ;;
armv7l) NARCH='armv7l' ;;
*) die "неподдерживаемая архитектура для node-tarball: $(uname -m)" ;;
esac
RUN "устанавливаю node $NODE_VERSION (tarball linux-$NARCH в /usr/local)" \
bash -c "set -e; curl -fsSL -o /tmp/node.tar.xz https://nodejs.org/dist/v$NODE_VERSION/node-v$NODE_VERSION-linux-$NARCH.tar.xz && tar -xJf /tmp/node.tar.xz -C /usr/local --strip-components=1"
fi
[ "$DRY_RUN" -eq 1 ] || { node_ok || die 'node не установился (см. вывод выше)'; }
fi
# ── 2) Claude Code ───────────────────────────────────────────────────────────
if have claude && [ -z "$CLAUDE_VERSION" ] && [ "$DRY_RUN" -eq 0 ]; then
log "claude уже установлен: $(claude --version 2>&1 | head -1) — обновляю до latest"
fi
if [ -n "$CLAUDE_VERSION" ]; then
RUN "npm i -g @anthropic-ai/claude-code@$CLAUDE_VERSION" npm install -g "@anthropic-ai/claude-code@$CLAUDE_VERSION"
else
RUN 'npm i -g @anthropic-ai/claude-code (latest)' npm install -g @anthropic-ai/claude-code
fi
if [ "$DRY_RUN" -eq 0 ]; then
have claude || ln -sf "$(npm prefix -g)/bin/claude" /usr/local/bin/claude 2>/dev/null || true
have claude || die 'claude не найден после npm i'
log "claude: $(claude --version 2>&1 | head -1)"
fi
# ── 3) ttyd ──────────────────────────────────────────────────────────────────
TARCH=''; case "$(uname -m)" in
x86_64) TARCH='x86_64' ;;
aarch64) TARCH='aarch64' ;;
armv7l|armv6l) TARCH='arm' ;;
i686) TARCH='i686' ;;
*) die "неподдерживаемая архитектура для ttyd: $(uname -m)" ;;
esac
if have ttyd && [ "$DRY_RUN" -eq 0 ]; then
log "ttyd уже есть: $(ttyd --version 2>&1 | head -1) — пропускаю скачивание"
else
RUN "ставлю ttyd $TTYD_VERSION ($TARCH) в /usr/local/bin" \
bash -c "set -e; curl -fsSL -o /usr/local/bin/ttyd https://github.com/tsl0922/ttyd/releases/download/$TTYD_VERSION/ttyd.$TARCH && chmod +x /usr/local/bin/ttyd"
fi
# ── 4) OS-юзер и systemd-юнит ────────────────────────────────────────────────
if [ "$DRY_RUN" -eq 0 ]; then
if ! id -u "$USER_NAME" >/dev/null 2>&1; then
log "создаю юзера $USER_NAME (без root-shell в веб-терминале)"
useradd -m -s /bin/bash "$USER_NAME"
fi
fi
WRITABLE='-W'; [ "$READONLY" -eq 1 ] && WRITABLE=''
UNIT_PATH="/etc/systemd/system/${SERVICE}.service"
ENV_LINES="Environment=HOME=/home/${USER_NAME}
Environment=PATH=/usr/local/bin:/usr/bin:/bin"
if [ ${#UNIT_ENV[@]} -gt 0 ]; then
for e in "${UNIT_ENV[@]}"; do ENV_LINES+="
Environment=$e"; done
fi
UNIT_CONTENT="[Unit]
Description=ttyd web terminal -> Claude Code (deploy-claude-code.sh)
After=network-online.target
Wants=network-online.target
[Service]
User=${USER_NAME}
${ENV_LINES}
ExecStart=/usr/local/bin/ttyd ${WRITABLE} -c ${USER_NAME}:${TOKEN} -p ${PORT} -i ${INTERFACE} claude
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
"
if [ "$DRY_RUN" -eq 1 ]; then
printf '[dry] пишу %s:\n%s\n' "$UNIT_PATH" "$UNIT_CONTENT"
else
CHANGED=1
if [ -f "$UNIT_PATH" ] && [ "$(cat "$UNIT_PATH")" = "$UNIT_CONTENT" ]; then
CHANGED=0; log 'юнит не изменился — перезапуск не нужен'
fi
printf '%s\n' "$UNIT_CONTENT" > "$UNIT_PATH"
systemctl daemon-reload
systemctl enable --now "$SERVICE.service" >/dev/null 2>&1 || true
if [ "$CHANGED" -eq 1 ] || ! systemctl is-active --quiet "$SERVICE.service"; then
systemctl restart "$SERVICE.service"
fi
sleep 1
fi
# ── 5) финальная проверка ────────────────────────────────────────────────────
if [ "$DRY_RUN" -eq 1 ]; then
log 'dry-run завершён: план выше, запускай без --dry-run'
exit 0
fi
IP="$(hostname -I 2>/dev/null | awk '{print $1}')"
[ -n "$IP" ] || IP='<ip-сервера>'
log '─'
health_check || die 'финальная проверка не прошла (см. выше)'
log '─'
log "ГОТОВО: Claude Code + ttyd web-TUI"
log " URL : http://${IP}:${PORT}"
log " логин : ${USER_NAME}"
log " пароль : ${TOKEN}"
log " сервис : systemctl status ${SERVICE}"
log " первый вход: открой URL, пройди авторизацию claude (или передай --env ANTHROPIC_BASE_URL=... при развёртке)"