daemon/freshen.bend

cswap-bend-cli@0.1.0.0 · 169 lines · 27 definitions

1import Base
2import cswap-bend-cli@0.1.0.0/json.bend as Json
3import cswap-bend-cli@0.1.0.0/act.bend as Act
4import cswap-bend-cli@0.1.0.0/store/io.bend as SIo
5import cswap-bend-cli@0.1.0.0/store/base.bend as SBase
6import cswap-bend-cli@0.1.0.0/usage/cred.bend as UCred
7import cswap-bend-cli@0.1.0.0/usage/gate.bend as UGate
8import cswap-bend-cli@0.1.0.0/daemon/sess.bend as Sess
9import cswap-bend-cli@0.1.0.0/daemon/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
14def ActM(-A: Type) -> Type:
15  Act.of(A)
16
17def ActM.pure(-A: Type, x: A) -> Act.of(A):
18  Act.pure(A, x)
19
20def 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
25def 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).
33def 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
36def tagged(+tag: String, +h: String) -> String:
37  tag ++ h
38
39def hashed(+tag: String, +text: String) -> IO(String):
40  IO.bind(String, String, sha256(text), h => IO.pure(String, tagged(tag, h)))
41
42def 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
49def 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.
60def fingerprint(+creds: String) -> IO(String):
61  fp_nonempty(String.is_empty(creds), creds)
62
63def has_oauth(+o: Json.Json) -> Bool:
64  match o:
65    case Json.JObj{f}:
66      True{}
67    case _:
68      False{}
69
70def 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.
85def 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
88def transient_or_systemic(+plain: Bool, +e: String) -> String:
89  match plain:
90    case True{}:
91      "transient"
92    case False{}:
93      e
94
95def 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
102def 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
109def 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".
117def 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
122def refresh_now(+root: String, +slot: String, +email: String, +creds: String) -> Act.of(String):
123  do ActM<String>:
124    g : UGate.Gated <- Act.lift(UGate.Gated, UGate.refresh_gated(slot, email, creds, root))
125    after_refresh(g, root, slot)
126
127def 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
136def 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
139def 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
146def 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.
151def 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.
155def judge5(+root: String, +slot: String, +email: String, +creds: String, +now: U32) -> Act.of(String):
156  do ActM<String>:
157    live : Bool <- Act.lift(Bool, Sess.live(root, slot, email))
158    judge4(live, root, slot, email, creds, now)
159
160def 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.
166def status(+root: String, +slot: String, +email: String, +now: U32) -> Act.of(String):
167  do ActM<String>:
168    +creds : String <- SIo.read_cred(root, slot, email)
169    judge3(SBase.looks_like_api_key(creds), root, slot, email, creds, now)