@magpie-community/opencode-cline-auth 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +19 -9
  2. package/index.mjs +50 -11
  3. package/package.json +3 -2
package/README.md CHANGED
@@ -15,15 +15,25 @@ Provider id: `cline`.
15
15
 
16
16
  The sign-in is kept where OpenCode keeps auth (`auth.json` under the data
17
17
  directory); magpie keeps plugin sign-ins in its own store
18
- (`~/.config/magpie/plugin-auth.json`). Access tokens are renewed through
19
- `/api/v1/auth/refresh` when they near expiry — Cline rotates the refresh
20
- token, and the rotated pair is saved back the moment it arrives (and kept in
21
- memory beside the store, so a save that fails can't leave the next request
22
- spending the token that was already spent). Refreshes are serialized, so two
23
- requests renewing at once can't spend one token and lose the other. A refresh
24
- that fails for a while keeps the token that is still good, as Cline's own
25
- client does; only a token that has actually expired is treated as the sign-in
26
- gone.
18
+ (`~/.config/magpie/plugin-auth.json`).
19
+
20
+ Refreshing:
21
+ - Access tokens are renewed through `/api/v1/auth/refresh`. Cline rotates
22
+ the refresh token and refuses one already spent, so the rotated pair is
23
+ kept in memory beside the store the moment it arrives: a save that fails
24
+ can't leave the next request spending the token that was already spent.
25
+ - magpie renews the token ten minutes before it ends, through the plugin's
26
+ `auth.refresh`, once for the account and before its requests, models and
27
+ usage need it; magpie saves the new pair. OpenCode doesn't call that hook:
28
+ there the token is refreshed five minutes before it ends, before a
29
+ request, and the new pair is saved.
30
+ - Refreshes are serialized, magpie's renewal included, so two at once can't
31
+ spend one token and lose the other; one that finds the token already
32
+ renewed uses that pair.
33
+ - A refresh that fails for a while keeps the token that is still good, as
34
+ Cline's own client does (magpie tries its renewal again shortly); only a
35
+ refresh Cline refuses, or a token that has actually expired, is treated as
36
+ the sign-in gone.
27
37
 
28
38
  ## Requests
29
39
 
package/index.mjs CHANGED
@@ -21,7 +21,8 @@ const DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code"
21
21
  // signed-in one rides as "workos:<jwt>" (Cline's own clients send it so)
22
22
  const WORKOS_PREFIX = "workos:"
23
23
 
24
- const REFRESH_LEAD = 5 * 60 * 1000
24
+ const REFRESH_LEAD = 5 * 60 * 1000 // a token this close to its end is refreshed before a request
25
+ const RENEW_LEAD = 10 * 60 * 1000 // and this close, magpie renews it ahead of time (auth.refresh)
25
26
  // a refresh that failed transiently keeps a token with this much left, as
26
27
  // Cline's getValidClineCredentials does (DEFAULT_RETRYABLE_TOKEN_GRACE_MS);
27
28
  // only once it has actually expired is the sign-in the one to blame
@@ -692,6 +693,20 @@ export const ClinePlugin = async ({ client } = {}) => {
692
693
  return run
693
694
  }
694
695
 
696
+ // spend trades r's refresh token for a new pair and holds it, whatever a
697
+ // save then does, against the token it spent and the one getAuth handed
698
+ // back (a's): the next request offering either of them finds this chain's
699
+ // newest. Called under the lock only.
700
+ const spend = async (a, r) => {
701
+ const p = await refresh(r)
702
+ const next = { ...r, access: p.access, refresh: p.refresh, expires: p.expires, uid: p.uid || r.uid, email: p.email || r.email, name: p.name || r.name, accountId: r.accountId || p.accountId }
703
+ const chain = held.get(a.refresh) ?? {}
704
+ Object.assign(chain, { access: next.access, refresh: next.refresh, expires: next.expires })
705
+ held.set(a.refresh, chain)
706
+ held.set(r.refresh, chain)
707
+ return next
708
+ }
709
+
695
710
  // fresh is the account with a token still good: one near its end is
696
711
  // renewed, and the rotated pair saved back. A transient refresh failure
697
712
  // keeps the token that is still good, as Cline's own client does; only a
@@ -711,23 +726,15 @@ export const ClinePlugin = async ({ client } = {}) => {
711
726
  const r = h ? { ...a, access: h.access, refresh: h.refresh, expires: h.expires } : a
712
727
  if (r.access && r.expires - Date.now() > REFRESH_LEAD) return { ...r, bearer: bearerOf(r), renewed: false }
713
728
  if (!r.refresh) throw new Lapsed("Cline's access token has expired and there is no refresh token; sign in to Cline again")
714
- let p
729
+ let next
715
730
  try {
716
- p = await refresh(r)
731
+ next = await spend(a, r)
717
732
  } catch (e) {
718
733
  // a token that is still good rides on through a refresh that
719
734
  // failed for a while; an expired one is the sign-in gone
720
735
  if (!e?.expired && r.access && r.expires - Date.now() > RETRYABLE_GRACE) return { ...r, bearer: bearerOf(r), renewed: false }
721
736
  throw e
722
737
  }
723
- const next = { ...r, access: p.access, refresh: p.refresh, expires: p.expires, uid: p.uid || r.uid, email: p.email || r.email, name: p.name || r.name, accountId: r.accountId || p.accountId }
724
- // the pair just rotated, held whatever the save through magpie does,
725
- // against the token it spent and the one getAuth handed back: the
726
- // next request offering either of them finds this chain's newest
727
- const chain = held.get(a.refresh) ?? {}
728
- Object.assign(chain, { access: next.access, refresh: next.refresh, expires: next.expires })
729
- held.set(a.refresh, chain)
730
- held.set(r.refresh, chain)
731
738
  await remember(next)
732
739
  const cred = { ...next, bearer: bearerOf(next), renewed: true }
733
740
  renewals.add(cred)
@@ -782,6 +789,38 @@ export const ClinePlugin = async ({ client } = {}) => {
782
789
  auth: {
783
790
  provider: PROVIDER,
784
791
 
792
+ // magpie renews the sign-in RENEW_LEAD before its end, once for the
793
+ // account, before its requests, models and usage ask for it; the
794
+ // check in fresh before each request stays for OpenCode, which
795
+ // doesn't call this. It takes the same lock and the same held pairs
796
+ // as fresh, so the two never spend one refresh token twice. magpie
797
+ // saves what this gives (it is merged over the sign-in): only what
798
+ // changed comes back, and remember isn't called here.
799
+ refreshLead: RENEW_LEAD,
800
+ refresh: (auth) =>
801
+ locked(async () => {
802
+ const a = authOf(auth)
803
+ // an API key, or a sign-in with nothing to renew with
804
+ if (auth?.type !== "oauth" || !a.refresh) return undefined
805
+ // renewed here already (a request's refresh), the store not
806
+ // yet saying so: that pair, not another spend
807
+ const h = held.get(a.refresh)
808
+ const r = h ? { ...a, access: h.access, refresh: h.refresh, expires: h.expires } : a
809
+ if (h && r.access && r.expires - Date.now() > RENEW_LEAD) return { access: r.access, refresh: r.refresh, expires: r.expires }
810
+ let next
811
+ try {
812
+ next = await spend(a, r)
813
+ } catch (e) {
814
+ // a refused refresh is the sign-in gone; anything else is
815
+ // retried by magpie, the token in hand riding on meanwhile
816
+ if (e?.expired) throw Object.assign(new Error(String(e.message ?? e).replace(/^Cline: /, "")), { signIn: "expired" })
817
+ throw e
818
+ }
819
+ const out = { access: next.access, refresh: next.refresh, expires: next.expires }
820
+ for (const k of ["uid", "email", "name", "accountId"]) if (next[k] && next[k] !== a[k]) out[k] = next[k]
821
+ return out
822
+ }),
823
+
785
824
  async loader(getAuth) {
786
825
  const a = await getAuth()
787
826
  if (a?.type !== "oauth" && a?.type !== "api") return {}
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "@magpie-community/opencode-cline-auth",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Cline (cline.bot) on your own account — usage billing, ClinePass and the free models — in OpenCode and magpie",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",
7
7
  "exports": "./index.mjs",
8
8
  "files": ["index.mjs", "README.md"],
9
9
  "keywords": ["opencode", "opencode-plugin", "magpie", "cline", "clinepass"],
10
- "license": "MIT"
10
+ "license": "MIT",
11
+ "repository": { "type": "git", "url": "git+https://github.com/magpie-community/plugins.git", "directory": "packages/cline" }
11
12
  }