swlive.bend

cswap-bend-cli@0.1.0.0 · 274 lines · 47 definitions

1import Base
2import cswap-bend-cli@0.1.0.0/json.bend as Json
3import cswap-bend-cli@0.1.0.0/fs.bend as Fs
4import cswap-bend-cli@0.1.0.0/act.bend as Act
5import cswap-bend-cli@0.1.0.0/store.bend as Store
6import cswap-bend-cli@0.1.0.0/lock.bend as Lock
7import cswap-bend-cli@0.1.0.0/dirlock.bend as Dirlock
8import cswap-bend-cli@0.1.0.0/b64.bend as B64
9import cswap-bend-cli@0.1.0.0/swbase.bend as B
10import cswap-bend-cli@0.1.0.0/swcreds.bend as C
11import cswap-bend-cli@0.1.0.0/fs/write.bend as FsW
12import cswap-bend-cli@0.1.0.0/store/base.bend as SBase
13import cswap-bend-cli@0.1.0.0/store/io.bend as SIo
14import cswap-bend-cli@0.1.0.0/store/plan.bend as SPlan
15import cswap-bend-cli@0.1.0.0/store/seq.bend as SSeq
16
17# Switch, part 4: the live login on disk, the locks, the ownership lookup.
18
19# `do ActM<A>:` (see store.bend).
20
21type Oracle is Data:
22  OHttp{}
23  OFile{path: String}
24  ONone{}
25
26type Opts is Data:
27  Opts{delay_ms: U32, oracle: Oracle}
28
29# ~/.claude.json and .credentials.json as they are right now.
30type 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.
34type Pre is Data:
35  Pre{live: String, prof: SPlan.Prof}
36
37def ActM(-A: Type) -> Type:
38  Act.of(A)
39
40def ActM.pure(-A: Type, x: A) -> Act.of(A):
41  Act.pure(A, x)
42
43def 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
46def opts_delay(+o: Opts) -> U32:
47  match o:
48    case Opts{d, r}:
49      d
50
51def opts_oracle(+o: Opts) -> Oracle:
52  match o:
53    case Opts{d, r}:
54      r
55
56def 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.
65def 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
74def 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<Maybe<&2, String>>:
80        t : String <- Fs.read_file(path)
81        return Some{t}
82
83# The file's text, None when it does not exist.
84def read_text_opt(+path: String) -> Act.of(Maybe<&2, String>):
85  do ActM<Maybe<&2, String>>:
86    ex : Bool <- Act.lift(Bool, Fs.exists(path))
87    read_text_opt2(ex, path)
88
89def 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
96def 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
103def read_live(+ctx: SBase.Ctx) -> Act.of(Live):
104  do ActM<Live>:
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
109def 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
114def lv_cfg_text(+l: Live) -> Maybe<&2, String>:
115  match l:
116    case Live{t, c, k}:
117      t
118
119def lv_cfg_ok(+l: Live) -> Bool:
120  match l:
121    case Live{t, c, k}:
122      B.is_some_json(c)
123
124def 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.
130def 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.
134def lv_email(+l: Live) -> String:
135  Store.id_email(Store.ident_of(lv_cfg(l)))
136
137def lv_org(+l: Live) -> String:
138  Store.id_org(Store.ident_of(lv_cfg(l)))
139
140def 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.
148def lv_slot(+l: Live, +seq: Json.Json) -> Maybe<&2, String>:
149  lv_slot2(SBase.nonempty(lv_email(l)), l, seq)
150
151def 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
158def 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<List<&2, C.Other>>:
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
168def archive_live(+root: String, +email: String, +creds: String, +ts: String) -> Act.of(Unit):
169  do ActM<Unit>:
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
174def 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
177def 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<Unit>:
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.
187def write_live_creds(+ctx: SBase.Ctx, +creds: String) -> Act.of(Unit):
188  write_live_creds2(SBase.looks_like_api_key(creds), ctx, creds)
189
190def 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
193def 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.
201def restore(+ctx: SBase.Ctx, +root: String, +creds: Maybe<&2, String>, +cfg: Maybe<&2, String>, +seq_text: String) -> Act.of(Unit):
202  do ActM<Unit>:
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
207def 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
214def 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.
221def 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
227def 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
234def 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
239def no_pre() -> Pre:
240  Pre{"", SPlan.ProfNone{}}
241
242def 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).
247def 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
250def lookup_need(+l: Live, +seq: Json.Json, +backup: String) -> Bool:
251  need_lookup(lv_slot(l, seq), lv_creds(l), backup)
252
253def 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
256def lookup_for(+ctx: SBase.Ctx, +o: Oracle, +l: Live, +seq: Json.Json) -> Act.of(SPlan.Prof):
257  do ActM<SPlan.Prof>:
258    backup : String <- backup_of(ctx, l, seq)
259    prefetch2(lookup_need(l, seq, backup), o, lv_creds(l))
260
261def 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<Pre>:
267        p : SPlan.Prof <- lookup_for(ctx, o, l, seq)
268        return Pre{lv_creds(l), p}
269
270def prefetch(+ctx: SBase.Ctx, +o: Oracle) -> Act.of(Pre):
271  do ActM<Pre>:
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)