1 import Base 2 import ./json.bend as Json 3 import ./fs.bend as Fs 4 import ./act.bend as Act 5 import ./store.bend as Store 6 import ./lock.bend as Lock 7 import ./dirlock.bend as Dirlock 8 import ./b64.bend as B64 9 import ./swbase.bend as B 10 import ./swcreds.bend as C 11 import ./fs/write.bend as FsW 12 import ./store/base.bend as SBase 13 import ./store/io.bend as SIo 14 import ./store/plan.bend as SPlan 15 import ./store/seq.bend as SSeq 16 17 # Switch, part 4: the live login on disk, the locks, the ownership lookup. 18 19 # `do ActM:` (see store.bend). 20 21 type Oracle is Data: 22 OHttp{} 23 OFile{path: String} 24 ONone{} 25 26 type Opts is Data: 27 Opts{delay_ms: U32, oracle: Oracle} 28 29 # ~/.claude.json and .credentials.json as they are right now. 30 type Live is Data: 31 Live{cfg_text: Maybe<&2, String>, cfg: Maybe<&2, Json.Json>, creds_text: Maybe<&2, String>} 32 33 # The live credential as read before the locks, and what the oracle said of it. 34 type Pre is Data: 35 Pre{live: String, prof: SPlan.Prof} 36 37 def ActM(-A: Type) -> Type: 38 Act.of(A) 39 40 def ActM.pure(-A: Type, x: A) -> Act.of(A): 41 Act.pure(A, x) 42 43 def ActM.bind(-A: Type, -B: Type, m: Act.of(A), f: A -> Act.of(B)) -> Act.of(B): 44 Act.bind(A, B, m, f) 45 46 def opts_delay(+o: Opts) -> U32: 47 match o: 48 case Opts{d, r}: 49 d 50 51 def opts_oracle(+o: Opts) -> Oracle: 52 match o: 53 case Opts{d, r}: 54 r 55 56 def file_prof(r: Fs.Res(String)) -> SPlan.Prof: 57 match r: 58 case Fail{e}: 59 SPlan.ProfNone{} 60 case Done{t}: 61 SPlan.profile_of_text(t) 62 63 # Ask the oracle whose token this is. OHttp is GET /api/oauth/profile; OFile 64 # answers from a JSON file (tests); ONone never resolves. 65 def run_oracle(+o: Oracle, +token: String) -> IO(SPlan.Prof): 66 match o: 67 case OHttp{}: 68 Store.profile_http(token) 69 case OFile{path}: 70 IO.bind(Fs.Res(String), SPlan.Prof, Fs.read_file(path), r => IO.pure(SPlan.Prof, file_prof(r))) 71 case ONone{}: 72 IO.pure(SPlan.Prof, SPlan.ProfNone{}) 73 74 def read_text_opt2(+ex: Bool, +path: String) -> Act.of(Maybe<&2, String>): 75 match ex: 76 case False{}: 77 Act.pure(Maybe<&2, String>, None{}) 78 case True{}: 79 do ActM>: 80 t : String <- Fs.read_file(path) 81 return Some{t} 82 83 # The file's text, None when it does not exist. 84 def read_text_opt(+path: String) -> Act.of(Maybe<&2, String>): 85 do ActM>: 86 ex : Bool <- Act.lift(Bool, Fs.exists(path)) 87 read_text_opt2(ex, path) 88 89 def parse_text2(r: Result<&2, &2, String, Json.Json>) -> Maybe<&2, Json.Json>: 90 match r: 91 case Done{j}: 92 Some{j} 93 case Fail{e}: 94 None{} 95 96 def parse_text(+m: Maybe<&2, String>) -> Maybe<&2, Json.Json>: 97 match m: 98 case None{}: 99 None{} 100 case Some{t}: 101 parse_text2(Json.parse(t)) 102 103 def read_live(+ctx: SBase.Ctx) -> Act.of(Live): 104 do ActM: 105 +cfg_text : Maybe<&2, String> <- read_text_opt(SBase.global_config_path(ctx)) 106 creds_text : Maybe<&2, String> <- read_text_opt(SBase.live_creds_path(ctx)) 107 return Live{cfg_text, parse_text(cfg_text), creds_text} 108 109 def lv_cfg(+l: Live) -> Json.Json: 110 match l: 111 case Live{t, +c, k}: 112 Store.sel.j(B.is_some_json(c), B.unwrap_json(c), Json.JNull{}) 113 114 def lv_cfg_text(+l: Live) -> Maybe<&2, String>: 115 match l: 116 case Live{t, c, k}: 117 t 118 119 def lv_cfg_ok(+l: Live) -> Bool: 120 match l: 121 case Live{t, c, k}: 122 B.is_some_json(c) 123 124 def lv_creds_text(+l: Live) -> Maybe<&2, String>: 125 match l: 126 case Live{t, c, k}: 127 k 128 129 # The live credential: the file, else a managed key in the config. 130 def lv_creds(+l: Live) -> String: 131 SBase.sel.s(B.is_some_str(lv_creds_text(l)), B.text_or(lv_creds_text(l)), SSeq.field_str(lv_cfg(l), "primaryApiKey")) 132 133 # (email, organizationUuid) the config names; "" email means logged out. 134 def lv_email(+l: Live) -> String: 135 Store.id_email(Store.ident_of(lv_cfg(l))) 136 137 def lv_org(+l: Live) -> String: 138 Store.id_org(Store.ident_of(lv_cfg(l))) 139 140 def lv_slot2(+has: Bool, +l: Live, +seq: Json.Json) -> Maybe<&2, String>: 141 match has: 142 case True{}: 143 SSeq.find_slot(seq, lv_email(l), lv_org(l)) 144 case False{}: 145 None{} 146 147 # The slot the live login belongs to, if it is managed. 148 def lv_slot(+l: Live, +seq: Json.Json) -> Maybe<&2, String>: 149 lv_slot2(SBase.nonempty(lv_email(l)), l, seq) 150 151 def sel_o(+c: Bool, +x: List<&2, C.Other>, +y: List<&2, C.Other>) -> List<&2, C.Other>: 152 match c: 153 case True{}: 154 x 155 case False{}: 156 y 157 158 def other_creds(+root: String, +seq: Json.Json, +slots: List<&2, String>, +current: String) -> Act.of(List<&2, C.Other>): 159 match slots: 160 case Nil{}: 161 Act.pure(List<&2, C.Other>, []) 162 case Con{s, t}: 163 do ActM>: 164 c : String <- SIo.read_cred(root, s, SIo.slot_email(seq, s)) 165 +rest : List<&2, C.Other> <- other_creds(root, seq, t, current) 166 return sel_o(String.eq(s, current), rest, C.Other{s, c} <> rest) 167 168 def archive_live(+root: String, +email: String, +creds: String, +ts: String) -> Act.of(Unit): 169 do ActM: 170 SIo.secure_dir(SBase.removed_dir(root)) 171 SIo.write_secret(SBase.removed_dir(root) ++ "/" ++ ts ++ "-live-" ++ SBase.sel.s(String.is_empty(email), "unknown", email) ++ ".enc", B64.encode(creds)) 172 SIo.log(root, "WARNING", "Archived the live login (" ++ SBase.sel.s(String.is_empty(email), "unknown", email) ++ ") to credentials/.removed/" ++ ts ++ "-live-...enc before replacing it; credentials are moved aside, never deleted") 173 174 def archive_if(+needed: Bool, +root: String, +email: String, +creds: String, +ts: String) -> Act.of(Unit): 175 Act.cond(Unit, needed, archive_live(root, email, creds, ts), Act.pure(Unit, Unit{})) 176 177 def write_live_creds2(+api_key: Bool, +ctx: SBase.Ctx, +creds: String) -> Act.of(Unit): 178 match api_key: 179 case True{}: 180 FsW.remove(SBase.live_creds_path(ctx)) 181 case False{}: 182 do ActM: 183 FsW.mkdir_p(Fs.dirname(SBase.live_creds_path(ctx))) 184 SIo.write_secret(SBase.live_creds_path(ctx), creds) 185 186 # Put the target's credential live. An API key leaves no .credentials.json. 187 def write_live_creds(+ctx: SBase.Ctx, +creds: String) -> Act.of(Unit): 188 write_live_creds2(SBase.looks_like_api_key(creds), ctx, creds) 189 190 def write_cfg(+ctx: SBase.Ctx, +j: Json.Json) -> Act.of(Unit): 191 SIo.write_secret(SBase.global_config_path(ctx), Json.emit_pretty(j)) 192 193 def restore_text(+path: String, +m: Maybe<&2, String>) -> Act.of(Unit): 194 match m: 195 case Some{t}: 196 SIo.write_secret(path, t) 197 case None{}: 198 FsW.remove(path) 199 200 # Undo a half-done switch: the three files go back to their old text. 201 def restore(+ctx: SBase.Ctx, +root: String, +creds: Maybe<&2, String>, +cfg: Maybe<&2, String>, +seq_text: String) -> Act.of(Unit): 202 do ActM: 203 restore_text(SBase.live_creds_path(ctx), creds) 204 restore_text(SBase.global_config_path(ctx), cfg) 205 SIo.write_secret(SBase.seq_path(root), seq_text) 206 207 def wrap_failure(r: Fs.Res(Unit)) -> Fs.Res(Unit): 208 match r: 209 case Done{u}: 210 Done{u} 211 case Fail{(code, msg)}: 212 Fail{(code, "Switch failed and was rolled back: " ++ msg)} 213 214 def config_home_of(+ctx: SBase.Ctx) -> String: 215 match ctx: 216 case SBase.Ctx{h, c, g}: 217 c 218 219 # Claude Code's lock directories and cswap's own flock, in the reference's 220 # order: .lock, then the credentials pair, then the config lock. 221 def with_locks(-A: Type, +ctx: SBase.Ctx, act: Act.of(A)) -> Act.of(A): 222 Lock.with_lock(A, SBase.lock_path(SBase.root_of(ctx)), 10000, 223 Dirlock.with_dirlock(A, config_home_of(ctx) ++ "/.oauth_refresh.lock", 9000, 60, 224 Dirlock.with_dirlock(A, config_home_of(ctx) ++ ".lock", 9000, 60, 225 Dirlock.with_dirlock(A, SBase.global_config_path(ctx) ++ ".lock", 9000, 10, act)))) 226 227 def sel_prof(+c: Bool, +x: SPlan.Prof, +y: SPlan.Prof) -> SPlan.Prof: 228 match c: 229 case True{}: 230 x 231 case False{}: 232 y 233 234 def pre_prof(+pre: Pre, +live: String) -> SPlan.Prof: 235 match pre: 236 case Pre{l, p}: 237 sel_prof(String.eq(l, live), p, SPlan.ProfNone{}) 238 239 def no_pre() -> Pre: 240 Pre{"", SPlan.ProfNone{}} 241 242 def need_lookup(+slot: Maybe<&2, String>, +live: String, +backup: String) -> Bool: 243 B.is_some_str(slot) && SBase.nonempty(live) && Bool.not(String.eq(backup, live)) && Bool.not(C.same_lineage(backup, live)) 244 245 # `_prefetch_live_identity`: before any lock is taken, look the diverged live 246 # credential up (the only network call of a switch). 247 def prefetch2(+want: Bool, +o: Oracle, +live: String) -> Act.of(SPlan.Prof): 248 Act.cond(SPlan.Prof, want && SBase.nonempty(C.access_of(live)), Act.lift(SPlan.Prof, run_oracle(o, C.access_of(live))), Act.pure(SPlan.Prof, SPlan.ProfNone{})) 249 250 def lookup_need(+l: Live, +seq: Json.Json, +backup: String) -> Bool: 251 need_lookup(lv_slot(l, seq), lv_creds(l), backup) 252 253 def backup_of(+ctx: SBase.Ctx, +l: Live, +seq: Json.Json) -> Act.of(String): 254 SIo.read_cred(SBase.root_of(ctx), SSeq.str_or(lv_slot(l, seq), ""), lv_email(l)) 255 256 def lookup_for(+ctx: SBase.Ctx, +o: Oracle, +l: Live, +seq: Json.Json) -> Act.of(SPlan.Prof): 257 do ActM: 258 backup : String <- backup_of(ctx, l, seq) 259 prefetch2(lookup_need(l, seq, backup), o, lv_creds(l)) 260 261 def prefetch3(+ctx: SBase.Ctx, +o: Oracle, +l: Live, +m: Maybe<&2, Json.Json>) -> Act.of(Pre): 262 match m: 263 case None{}: 264 Act.pure(Pre, no_pre()) 265 case Some{seq}: 266 do ActM
:
267	        p : SPlan.Prof <- lookup_for(ctx, o, l, seq)
268	        return Pre{lv_creds(l), p}
269	
270	def prefetch(+ctx: SBase.Ctx, +o: Oracle) -> Act.of(Pre):
271	  do ActM
:
272	    l : Live <- read_live(ctx)
273	    m : Maybe<&2, Json.Json> <- SIo.read_seq(SBase.root_of(ctx))
274	    prefetch3(ctx, o, l, m)