1 import Base 2 import ../json.bend as Json 3 import ../act.bend as Act 4 import ../store/io.bend as SIo 5 import ../store/base.bend as SBase 6 import ../usage/cred.bend as UCred 7 import ../usage/gate.bend as UGate 8 import ./sess.bend as Sess 9 import ./identity.bend as Id 10 11 # Making a switch target safe to switch to (autoswitch.py _freshen_target) and 12 # naming credentials the way usage.json and the state file do. 13 14 def ActM(-A: Type) -> Type: 15 Act.of(A) 16 17 def ActM.pure(-A: Type, x: A) -> Act.of(A): 18 Act.pure(A, x) 19 20 def ActM.bind(-A: Type, -B: Type, m: Act.of(A), f: A -> Act.of(B)) -> Act.of(B): 21 Act.bind(A, B, m, f) 22 23 # ---- fingerprints ------------------------------------------------------------------------------ 24 25 def hex_of(r: Result<&1, &1, U32 & String, U32 & (String & String)>) -> String: 26 match r: 27 case Fail{e}: 28 "" 29 case Done{(st, (out, err))}: 30 Maybe.default(&2, String, List.head(&2, String, String.split(String.trim(out), ' ')), "") 31 32 # Hex sha256 of a text, through shasum(1) with the text on stdin (never argv). 33 def sha256(+text: String) -> IO(String): 34 IO.bind(Result<&1, &1, U32 & String, U32 & (String & String)>, String, Process.run("shasum", ["-a", "256"], text, 4096, 5000), r => IO.pure(String, hex_of(r))) 35 36 def tagged(+tag: String, +h: String) -> String: 37 tag ++ h 38 39 def hashed(+tag: String, +text: String) -> IO(String): 40 IO.bind(String, String, sha256(text), h => IO.pure(String, tagged(tag, h))) 41 42 def fp_pick(+rt: Maybe<&2, String>, +creds: String) -> IO(String): 43 match rt: 44 case Some{t}: 45 hashed("sha256:", t) 46 case None{}: 47 hashed("sha256-full:", creds) 48 49 def fp_nonempty(+empty: Bool, +creds: String) -> IO(String): 50 match empty: 51 case True{}: 52 IO.pure(String, "") 53 case False{}: 54 fp_pick(UCred.refresh_token(UCred.oauth.of(creds)), creds) 55 56 # ---- freshen --------------------------------------------------------------------------------------- 57 58 # oauth.credential_fingerprint: the refresh token's hash, else the whole 59 # credential's. "" for an empty credential. 60 def fingerprint(+creds: String) -> IO(String): 61 fp_nonempty(String.is_empty(creds), creds) 62 63 def has_oauth(+o: Json.Json) -> Bool: 64 match o: 65 case Json.JObj{f}: 66 True{} 67 case _: 68 False{} 69 70 def rank2(+a: Bool, +b: Bool, +c: Bool, +d: Bool) -> U32: 71 match a b c d: 72 case True{} _ _ _: 73 1 74 case False{} True{} _ _: 75 2 76 case False{} False{} True{} _: 77 3 78 case False{} False{} False{} True{}: 79 4 80 case _ _ _ _: 81 0 82 83 # The deterministic refusals every candidate hits identically (autoswitch.py 84 # _SYSTEMIC_MESSAGES), most actionable first. 85 def systemic_rank(+s: String) -> U32: 86 rank2(String.eq(s, "store-unmirrored"), String.eq(s, "invalid_client"), String.eq(s, "stash-unreadable"), String.eq(s, "consume-busy")) 87 88 def transient_or_systemic(+plain: Bool, +e: String) -> String: 89 match plain: 90 case True{}: 91 "transient" 92 case False{}: 93 e 94 95 def kind_of(+dead: Bool, +e: String) -> String: 96 match dead: 97 case True{}: 98 "invalid_grant" 99 case False{}: 100 transient_or_systemic(U32.is_eq(systemic_rank(e), 0), e) 101 102 def refresh_status(+error: Maybe<&2, String>) -> String: 103 match error: 104 case None{}: 105 "ok" 106 case Some{e}: 107 kind_of(String.eq(e, "invalid_grant") || String.eq(e, "no_refresh_token"), e) 108 109 def judged_grant(+error: Maybe<&2, String>, +root: String, +slot: String, +acct: Json.Json) -> Act.of(String): 110 match error: 111 case None{}: 112 Id.check(root, slot, acct) 113 case Some{e}: 114 Act.pure(String, refresh_status(Some{e})) 115 116 # "ok", "invalid_grant" (a dead credential: quarantine it) or "transient". 117 def after_refresh(g: UGate.Gated, +root: String, +slot: String) -> Act.of(String): 118 match g: 119 case UGate.Gated{cred, error, consumed, acct}: 120 judged_grant(error, root, slot, acct) 121 122 def refresh_now(+root: String, +slot: String, +email: String, +creds: String) -> Act.of(String): 123 do ActM: 124 g : UGate.Gated <- Act.lift(UGate.Gated, UGate.refresh_gated(slot, email, creds, root)) 125 after_refresh(g, root, slot) 126 127 def check2(+oauth: Bool, +near: Bool, +root: String, +slot: String, +email: String, +creds: String) -> Act.of(String): 128 match oauth near: 129 case False{} _: 130 Act.pure(String, "invalid_grant") 131 case True{} True{}: 132 refresh_now(root, slot, email, creds) 133 case True{} False{}: 134 Act.pure(String, "ok") 135 136 def check(+root: String, +slot: String, +email: String, +creds: String, +now: U32) -> Act.of(String): 137 check2(has_oauth(UCred.oauth.of(creds)), UCred.expired(UCred.oauth.of(creds), (now + 300 : U32)), root, slot, email, creds) 138 139 def judge2(+empty: Bool, +root: String, +slot: String, +email: String, +creds: String, +now: U32) -> Act.of(String): 140 match empty: 141 case True{}: 142 Act.pure(String, "transient") 143 case False{}: 144 check(root, slot, email, creds, now) 145 146 def judge(+root: String, +slot: String, +email: String, +creds: String, +now: U32) -> Act.of(String): 147 judge2(String.is_empty(creds), root, slot, email, creds, now) 148 149 # Ensure a candidate's stored token outlives Claude Code's next minutes: a 150 # credential within 10 minutes of expiry is refreshed under its consume lock. 151 def judge4(+live: Bool, +root: String, +slot: String, +email: String, +creds: String, +now: U32) -> Act.of(String): 152 Act.cond(String, live, Act.pure(String, "skip-live-session"), judge(root, slot, email, creds, now)) 153 154 # A Claude session consuming this account owns its token: the daemon leaves it. 155 def judge5(+root: String, +slot: String, +email: String, +creds: String, +now: U32) -> Act.of(String): 156 do ActM: 157 live : Bool <- Act.lift(Bool, Sess.live(root, slot, email)) 158 judge4(live, root, slot, email, creds, now) 159 160 def judge3(+api: Bool, +root: String, +slot: String, +email: String, +creds: String, +now: U32) -> Act.of(String): 161 Act.cond(String, api, Act.pure(String, "ok"), judge5(root, slot, email, creds, now)) 162 163 # Ensure a candidate's stored token outlives Claude Code's next minutes: a 164 # credential within 10 minutes of expiry is refreshed under its consume lock. 165 # An API key never expires. 166 def status(+root: String, +slot: String, +email: String, +now: U32) -> Act.of(String): 167 do ActM: 168 +creds : String <- SIo.read_cred(root, slot, email) 169 judge3(SBase.looks_like_api_key(creds), root, slot, email, creds, now)