@magpie-community/opencode-zcode-auth 0.1.1 → 0.1.2

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 +7 -1
  2. package/index.mjs +144 -3
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -20,6 +20,11 @@ over Anthropic's Messages API. Provider id: `zcode`.
20
20
  with ZCode's session token and ZCode's own headers.
21
21
  - The Start Plan's token can't be refreshed. When it runs out, sign in
22
22
  again.
23
+ - **ZCode app's sign-in**
24
+ - Uses the account the ZCode app on this computer is signed in to. For
25
+ every request it reads ZCode's own credentials (`~/.zcode/v2`), so it
26
+ follows ZCode as it signs in again, renews its session or switches to a
27
+ team's plan.
23
28
  - **GLM Coding Plan API key**
24
29
  - Paste a key (`<id>.<secret>`) and say which site it is from.
25
30
 
@@ -33,7 +38,8 @@ In OpenCode's `auth.json` (magpie: `plugin-auth.json`), under `zcode`:
33
38
  token, the team project, the plan and a device id of this plugin's own.
34
39
  - **A pasted key** is kept as an `api` entry, with the site in `metadata`.
35
40
 
36
- The plugin never reads or writes ZCode's own credential store.
41
+ The plugin never writes ZCode's own credential store. It reads it only for
42
+ the ZCode app's sign-in (`refresh` then holds `"source": "zcode"`).
37
43
 
38
44
  ## How requests are routed
39
45
 
package/index.mjs CHANGED
@@ -16,8 +16,9 @@
16
16
  // `refresh` the rest of it as JSON (site, base, key, jwt, team project,
17
17
  // plan, device id), `accountId` the account's email.
18
18
 
19
+ import { createDecipheriv, createHash } from "node:crypto"
19
20
  import { existsSync, readFileSync, readdirSync } from "node:fs"
20
- import { homedir } from "node:os"
21
+ import { homedir, userInfo } from "node:os"
21
22
  import { join } from "node:path"
22
23
 
23
24
  const PROVIDER = "zcode"
@@ -661,7 +662,7 @@ async function authorize(site, log) {
661
662
  instructions: `Sign in to ${SITES[site].name} in the browser; this finishes on its own.`,
662
663
  method: "auto",
663
664
  async callback() {
664
- const fail = (msg) => (log(msg), { type: "failed" })
665
+ const fail = (msg) => (log(msg), { type: "failed", error: msg })
665
666
  for (;;) {
666
667
  await sleep(interval)
667
668
  if (Date.now() > deadline) return fail("the sign-in expired; start again")
@@ -696,6 +697,136 @@ async function authorize(site, log) {
696
697
  }
697
698
  }
698
699
 
700
+ // ---- ZCode's own sign-in ------------------------------------------------------------
701
+ // ZCode keeps its sign-in in ~/.zcode/v2/credentials.json, each value
702
+ // encrypted (AES-256-GCM, "enc:v1:<iv>.<tag>.<data>") with a key made from
703
+ // the machine's user; ~/.zcode/v2/setting.json says whether it is on a
704
+ // team's plan. An account signed in "as ZCode" reads them for every
705
+ // request, so it follows ZCode's own sign-in, its renewed session and its
706
+ // switches between plans, as magpie's built-in did (zcode.go zcodeOwn).
707
+
708
+ const zcodeDir = () => join(homedir(), ".zcode", "v2")
709
+
710
+ // secret is the key ZCode encrypts its credentials with.
711
+ function secret() {
712
+ let seed = process.env.ZCODE_CREDENTIAL_SECRET
713
+ if (!seed) {
714
+ let name = ""
715
+ try {
716
+ name = userInfo().username
717
+ } catch {}
718
+ name = name.slice(name.lastIndexOf("\\") + 1) // DOMAIN\user
719
+ seed = `zcode-credential-fallback:${process.platform}:${homedir()}:${name}`
720
+ }
721
+ return createHash("sha256").update(seed).digest()
722
+ }
723
+
724
+ function decrypt(key, v) {
725
+ if (typeof v !== "string" || !v.startsWith("enc:v1:")) return null
726
+ const parts = v.slice(7).split(".")
727
+ if (parts.length !== 3) return null
728
+ try {
729
+ const [iv, tag, data] = parts.map((p) => Buffer.from(p.replace(/=+$/, ""), "base64url"))
730
+ const d = createDecipheriv("aes-256-gcm", key, iv)
731
+ d.setAuthTag(tag)
732
+ return Buffer.concat([d.update(data), d.final()]).toString("utf8")
733
+ } catch {
734
+ return null
735
+ }
736
+ }
737
+
738
+ function readJSON(path) {
739
+ try {
740
+ return JSON.parse(readFileSync(path, "utf8"))
741
+ } catch {
742
+ return null
743
+ }
744
+ }
745
+
746
+ // ownTeam is the team plan ZCode switched to, when it did.
747
+ function ownTeam(store, key) {
748
+ const set = readJSON(join(zcodeDir(), "setting.json"))
749
+ if (!set) return null
750
+ const families = set.providerFamilyDomain === "zai" || set.providerFamilyDomain === "bigmodel" ? [set.providerFamilyDomain] : ["zai", "bigmodel"]
751
+ for (const fam of families) {
752
+ let org = ""
753
+ let project = ""
754
+ const sel = set.providerFamilyConnectionSelections?.[fam]
755
+ if (sel) {
756
+ if (sel.kind === "team-coding-plan") [org, project] = [first(sel.organizationId), first(sel.projectId)]
757
+ } else {
758
+ // before providerFamilyConnectionSelections
759
+ const legacy = first(set.modelProviderFamilySelectedKeys?.[fam])
760
+ const pre = `team-plan:builtin:${fam}-coding-plan:`
761
+ if (legacy.startsWith(pre)) {
762
+ const ps = legacy.slice(pre.length).split(":")
763
+ if (ps.length === 3) {
764
+ try {
765
+ ;[org, project] = [decodeURIComponent(ps[1]), decodeURIComponent(ps[2])]
766
+ } catch {}
767
+ }
768
+ }
769
+ }
770
+ if (!org || !project) continue
771
+ const tok = first(decrypt(key, store[`oauth:${fam}:access_token`]))
772
+ if (!tok) continue
773
+ // a stale token ZCode won't use either
774
+ if (fam === "bigmodel" && first(decrypt(key, store.zcodejwttoken)) === tok) continue
775
+ return { site: fam, base: SITES[fam].base, key: "", token: tok, org, project }
776
+ }
777
+ return null
778
+ }
779
+
780
+ // ownSignIn is what ZCode is signed in to now: the account, its coding
781
+ // plan key, ZCode's session token (the Start Plan's key) and the team plan
782
+ // it is switched to; null when it has none of them.
783
+ function ownSignIn() {
784
+ const store = readJSON(join(zcodeDir(), "credentials.json"))
785
+ if (!store || typeof store !== "object") return null
786
+ const key = secret()
787
+ let s = { key: "", base: "" }
788
+ for (const [name, v] of Object.entries(store)) {
789
+ // account-provider:coding-plan:account:zai-individual-coding-plan:account:<uuid>:api-key
790
+ if (!name.includes(":coding-plan:") || !name.endsWith(":api-key")) continue
791
+ const k = decrypt(key, v)
792
+ if (!k || !k.includes(".")) continue
793
+ const base = name.includes(":bigmodel-") ? BIGMODEL_BASE : ZAI_BASE
794
+ // Z.ai's first, as ZCode lists it
795
+ if (!s.key || (base === ZAI_BASE && s.base !== ZAI_BASE)) s = { key: k, base }
796
+ }
797
+ const jwt = first(first(decrypt(key, store.zcodejwttoken)).replace(/^Bearer /, ""))
798
+ const team = ownTeam(store, key)
799
+ if (team) s = { ...team, jwt }
800
+ else s = { ...s, jwt, base: s.base || ZAI_BASE, site: s.base === BIGMODEL_BASE ? "bigmodel" : "zai" }
801
+ if (!s.key && !s.jwt && !isTeam(s)) return null
802
+ let info = {}
803
+ for (const name of ["oauth:zai:user_info", "oauth:bigmodel:user_info"]) {
804
+ const v = decrypt(key, store[name])
805
+ if (v && !first(info.email, info.name, info.user_id)) {
806
+ try {
807
+ info = JSON.parse(v)
808
+ } catch {}
809
+ }
810
+ }
811
+ return { ...s, user: who(info) }
812
+ }
813
+
814
+ // asZCode is the sign-in "as ZCode": what it reads is ZCode's own.
815
+ function asZCode() {
816
+ return {
817
+ url: "",
818
+ instructions: "Uses the account the ZCode app is signed in to, and follows it.",
819
+ method: "auto",
820
+ async callback() {
821
+ const own = ownSignIn()
822
+ if (!own) return { type: "failed", error: "ZCode isn't signed in on this computer; sign in to ZCode, or sign in to Z.ai or BigModel here" }
823
+ const { user, ...s } = own
824
+ const state = { source: "zcode", device: anyDevice, ...s }
825
+ return { type: "success", refresh: JSON.stringify(state), access: s.key || s.jwt || "", expires: 0, accountId: user }
826
+ },
827
+ }
828
+ }
829
+
699
830
  // stateOf is a stored sign-in as the requests need it.
700
831
  function stateOf(auth) {
701
832
  if (auth?.type === "oauth") {
@@ -703,6 +834,15 @@ function stateOf(auth) {
703
834
  try {
704
835
  s = JSON.parse(auth.refresh)
705
836
  } catch {}
837
+ if (s.source === "zcode") {
838
+ // ZCode's own, read now: what it was when this was signed in, if
839
+ // ZCode has since signed out
840
+ const own = ownSignIn()
841
+ if (own) {
842
+ const { user, ...now } = own
843
+ s = { source: "zcode", device: s.device, plan: s.plan, ...now }
844
+ }
845
+ }
706
846
  const site = siteOf(s.site)
707
847
  return { ...s, site, base: s.base || SITES[site].base, key: s.key ?? (s.jwt ? "" : auth.access), device: s.device || anyDevice }
708
848
  }
@@ -983,6 +1123,7 @@ export async function ZCodeAuthPlugin({ client }) {
983
1123
  methods: [
984
1124
  oauth("zai"),
985
1125
  oauth("bigmodel"),
1126
+ { type: "oauth", label: "ZCode app's sign-in", authorize: async () => asZCode() },
986
1127
  {
987
1128
  type: "api",
988
1129
  label: "GLM Coding Plan API key",
@@ -1004,4 +1145,4 @@ export async function ZCodeAuthPlugin({ client }) {
1004
1145
  }
1005
1146
 
1006
1147
  // for tests
1007
- export const _internal = { limitWindows, termOf, startUsage, routes, teamKeys }
1148
+ export const _internal = { limitWindows, termOf, startUsage, routes, teamKeys, ownSignIn, stateOf }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magpie-community/opencode-zcode-auth",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "OpenCode provider plugin: signs in to Z.ai's or BigModel's (智谱) GLM Coding Plan as ZCode does, including team seats and ZCode's Start Plan",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",