opencode-swap 0.4.0__py3-none-any.whl

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.
@@ -0,0 +1,60 @@
1
+ """Exception hierarchy for opencode-swap."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class OpenCodeSwapError(Exception):
7
+ """Base class for all opencode-swap errors."""
8
+
9
+
10
+ class AuthFileError(OpenCodeSwapError):
11
+ """auth.json is missing, unreadable, or not valid JSON."""
12
+
13
+
14
+ class BackupError(OpenCodeSwapError):
15
+ """A recovery snapshot is unreadable or has an invalid top-level shape."""
16
+
17
+
18
+ class SchemaError(OpenCodeSwapError):
19
+ """A provider's entry in auth.json doesn't match a known shape.
20
+
21
+ Raised instead of silently dropping or guessing at the data (fail-safe:
22
+ refuse to operate on state we don't understand rather than risk
23
+ corrupting it). OpenCode itself is lenient here — its own Auth.all()
24
+ silently drops entries that fail schema decode (auth/index.ts:66) — but
25
+ opencode-swap is about to overwrite that state, so it holds itself to a
26
+ stricter standard.
27
+ """
28
+
29
+
30
+ class LockError(OpenCodeSwapError):
31
+ """Could not acquire opencode-swap's own cross-process file lock."""
32
+
33
+
34
+ class RegistryError(OpenCodeSwapError):
35
+ """opencode-swap's own account registry is missing, corrupt, or the
36
+ requested operation doesn't make sense against its current state."""
37
+
38
+
39
+ class SecretStoreError(OpenCodeSwapError):
40
+ """A secret-store operation failed on every backend available to it."""
41
+
42
+
43
+ class AccountExistsError(OpenCodeSwapError):
44
+ """`add`/`rename` would create a name or identity collision."""
45
+
46
+
47
+ class TransferError(OpenCodeSwapError):
48
+ """An account transfer archive is unsafe, corrupt, or incompatible."""
49
+
50
+
51
+ class RefreshError(OpenCodeSwapError):
52
+ """A standalone OAuth token refresh was attempted and rejected/failed.
53
+
54
+ Distinct from usage.py's UsageSnapshot(available=False): that case is a
55
+ read that degrades gracefully. A rejected refresh grant means the
56
+ account's refresh token is spent -- OpenAI issues a new one on every use
57
+ (docs/architecture.md#why-sync-back-is-mandatory) -- so the account is
58
+ genuinely unusable until the owner re-authenticates with OpenCode
59
+ (`opencode auth login`) and re-adds it (`opencode-swap add`).
60
+ """
@@ -0,0 +1,70 @@
1
+ """Cross-process file lock serializing opencode-swap's own invocations.
2
+
3
+ Ported from claude-swap's locking.py (fcntl.flock on an exclusive,
4
+ non-blocking lock file, polled with a timeout); the Windows msvcrt branch is
5
+ dropped since opencode-swap v1 targets Linux and macOS only.
6
+
7
+ This lock has no relationship to OpenCode's own process — it only prevents
8
+ two concurrent `opencode-swap` invocations from interleaving writes to the
9
+ same account data. See switcher.py for how a *running OpenCode* is handled
10
+ (detected and warned about separately, since OpenCode has no cooperative
11
+ lock protocol to join).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import fcntl
17
+ import time
18
+ from pathlib import Path
19
+ from types import TracebackType
20
+ from typing import IO, Self
21
+
22
+ from opencode_swap.exceptions import LockError
23
+
24
+
25
+ class FileLock:
26
+ def __init__(self, lock_path: Path, timeout: float = 10.0):
27
+ self.lock_path = lock_path
28
+ self.timeout = timeout
29
+ self._lock_file: IO[str] | None = None
30
+ self._locked = False
31
+
32
+ def acquire(self, timeout: float | None = None) -> bool:
33
+ if timeout is None:
34
+ timeout = self.timeout
35
+ self.lock_path.parent.mkdir(parents=True, exist_ok=True)
36
+ lock_file = open(self.lock_path, "w") # noqa: SIM115
37
+ self._lock_file = lock_file
38
+
39
+ start = time.monotonic()
40
+ while True:
41
+ try:
42
+ fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
43
+ self._locked = True
44
+ return True
45
+ except (BlockingIOError, OSError):
46
+ if time.monotonic() - start > timeout:
47
+ lock_file.close()
48
+ self._lock_file = None
49
+ return False
50
+ time.sleep(0.1)
51
+
52
+ def release(self) -> None:
53
+ if self._lock_file and self._locked:
54
+ fcntl.flock(self._lock_file.fileno(), fcntl.LOCK_UN)
55
+ self._lock_file.close()
56
+ self._lock_file = None
57
+ self._locked = False
58
+
59
+ def __enter__(self) -> Self:
60
+ if not self.acquire():
61
+ raise LockError(f"failed to acquire lock at {self.lock_path} within {self.timeout}s — another opencode-swap instance may be running")
62
+ return self
63
+
64
+ def __exit__(
65
+ self,
66
+ exc_type: type[BaseException] | None,
67
+ exc_value: BaseException | None,
68
+ traceback: TracebackType | None,
69
+ ) -> None:
70
+ self.release()
@@ -0,0 +1,136 @@
1
+ """macOS Keychain access via the ``security`` CLI.
2
+
3
+ Ported from claude-swap's macos_keychain.py. A small wrapper around the
4
+ system ``security`` tool for storing generic passwords, used instead of the
5
+ third-party ``keyring`` library:
6
+
7
+ - Keychain items are created and read by the same stable ``security``
8
+ binary, so reads stay silent across upgrades. ``keyring`` (and any
9
+ in-process Security.framework call) anchors the item's access to the
10
+ *Python interpreter*, which ``uv tool upgrade`` rebuilds — at which point
11
+ macOS can show the "wants to use your keychain" prompt. ``security``
12
+ never changes, so creator == reader and there is no prompt.
13
+
14
+ - ``set_password`` hex-encodes the value (``-X``) and pipes the command
15
+ through ``security -i`` (stdin) so the secret never appears in process
16
+ argv (a process-monitor concern). Measured on-device: ``security -i``
17
+ truncates a stdin line at 4095 characters regardless of how much more
18
+ follows (probed with 5K/10K/20K-byte inputs — all truncate identically;
19
+ no line continuation exists for quoted args or a trailing backslash). A
20
+ command over this limit fails closed rather than silently truncating a
21
+ credential. Because this makes any raw credential over ~2000 bytes
22
+ unroutable through ``security -i`` (hex-encoding doubles it), callers
23
+ must not depend on this backend alone for values that size or larger —
24
+ see sealed.py's envelope format, which keeps every Keychain value to a
25
+ fixed 64 hex chars regardless of credential size.
26
+ - ``get_password`` uses ``find-generic-password ... -w`` and treats exit
27
+ code 44 as "not found" (returns ``None``); any *other* non-zero exit
28
+ raises so callers can tell a genuine miss apart from a locked/denied/
29
+ unavailable Keychain.
30
+
31
+ Caveat: values must be printable text. ``find-generic-password -w`` prints
32
+ stored data raw only when it is printable; opencode-swap only ever stores
33
+ JSON (ASCII), so this holds — don't reuse this wrapper for binary data.
34
+
35
+ This module is import-safe on every platform (it only shells out at call
36
+ time); its functions are only meaningful on macOS.
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ import subprocess
42
+
43
+ # ``security -i`` reads stdin with a 4096-byte fgets() buffer and truncates
44
+ # any longer line, with no continuation across lines. Measured directly
45
+ # (5K/10K/20K-byte probe inputs all truncate at the same 4095-char point;
46
+ # BUFSIZ itself is 1024 on darwin, so this is the tool's own internal
47
+ # buffer, not libc's). 64 bytes of headroom guards against line-terminator
48
+ # accounting differences.
49
+ SECURITY_STDIN_LINE_LIMIT = 4096 - 64
50
+
51
+ _NOT_FOUND_RC = 44 # errSecItemNotFound surfaced by find/delete-generic-password
52
+
53
+ # Bound every ``security`` spawn so a wedged Keychain (a locked login
54
+ # keychain prompting for an unlock that never comes on a headless/SSH host)
55
+ # can't hang the CLI. A healthy Keychain answers in well under 100ms.
56
+ _TIMEOUT = 5.0
57
+
58
+ # Pin the absolute path to Apple's system binary rather than resolving via
59
+ # PATH: this is a credential tool, so an attacker-controlled ``security``
60
+ # earlier on PATH must not be able to intercept secrets.
61
+ _SECURITY = "/usr/bin/security"
62
+
63
+
64
+ class KeychainError(Exception):
65
+ """A ``security`` invocation failed for a reason other than "not found"."""
66
+
67
+
68
+ # The exceptions a Keychain operation may raise that callers should treat as
69
+ # "Keychain unusable" (→ fall back to file storage) rather than a
70
+ # programming bug. Catching this tuple — never bare Exception — keeps a real
71
+ # bug loud instead of silently routing to the file backend mid-invocation.
72
+ KEYCHAIN_ERRORS = (KeychainError, subprocess.TimeoutExpired, OSError)
73
+
74
+
75
+ def _quote(value: str) -> str:
76
+ """Quote a value for a ``security -i`` stdin command line."""
77
+ escaped = value.replace("\\", "\\\\").replace('"', '\\"')
78
+ return f'"{escaped}"'
79
+
80
+
81
+ def get_password(service: str, account: str) -> str | None:
82
+ """Return the stored password, or None if no such item exists (rc 44)."""
83
+ try:
84
+ result = subprocess.run(
85
+ [_SECURITY, "find-generic-password", "-a", account, "-w", "-s", service],
86
+ capture_output=True,
87
+ text=True,
88
+ timeout=_TIMEOUT,
89
+ check=False,
90
+ )
91
+ except subprocess.TimeoutExpired as e:
92
+ raise KeychainError(f"security find-generic-password timed out after {_TIMEOUT}s") from e
93
+ if result.returncode == 0:
94
+ return result.stdout.removesuffix("\n")
95
+ if result.returncode == _NOT_FOUND_RC:
96
+ return None
97
+ raise KeychainError(f"security find-generic-password failed (rc={result.returncode}): {result.stderr.strip()}")
98
+
99
+
100
+ def set_password(service: str, account: str, password: str) -> None:
101
+ """Create or update a generic-password item (``-U``)."""
102
+ hex_value = password.encode("utf-8").hex()
103
+ command = f"add-generic-password -U -a {_quote(account)} -s {_quote(service)} -X {hex_value}\n"
104
+ try:
105
+ if len(command.encode("utf-8")) <= SECURITY_STDIN_LINE_LIMIT:
106
+ result = subprocess.run(
107
+ [_SECURITY, "-i"],
108
+ input=command,
109
+ capture_output=True,
110
+ text=True,
111
+ timeout=_TIMEOUT,
112
+ check=False,
113
+ )
114
+ else:
115
+ raise KeychainError("security add-generic-password input exceeds safe stdin limit")
116
+ except subprocess.TimeoutExpired as e:
117
+ raise KeychainError(f"security add-generic-password timed out after {_TIMEOUT}s") from e
118
+ if result.returncode != 0:
119
+ raise KeychainError(f"security add-generic-password failed (rc={result.returncode}): {result.stderr.strip()}")
120
+
121
+
122
+ def delete_password(service: str, account: str) -> None:
123
+ """Delete a generic-password item. rc 44 (already absent) counts as success."""
124
+ try:
125
+ result = subprocess.run(
126
+ [_SECURITY, "delete-generic-password", "-a", account, "-s", service],
127
+ capture_output=True,
128
+ text=True,
129
+ timeout=_TIMEOUT,
130
+ check=False,
131
+ )
132
+ except subprocess.TimeoutExpired as e:
133
+ raise KeychainError(f"security delete-generic-password timed out after {_TIMEOUT}s") from e
134
+ if result.returncode in (0, _NOT_FOUND_RC):
135
+ return
136
+ raise KeychainError(f"security delete-generic-password failed (rc={result.returncode}): {result.stderr.strip()}")
@@ -0,0 +1,190 @@
1
+ """Data models for opencode-swap."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import sys
7
+ from dataclasses import dataclass, field
8
+ from enum import Enum, auto
9
+
10
+ from opencode_swap.exceptions import RegistryError
11
+
12
+ #: Account name validation: lowercase letters/digits/-/_/./@/+, non-empty,
13
+ #: not leading '-' (argparse would read it as a flag) or '.' (keeps the file-
14
+ #: fallback secret-store filename derived from this name free of anything
15
+ #: that could resemble a relative path component). ``@`` and ``+`` support
16
+ #: common email-address labels.
17
+ _NAME_RE = re.compile(r"^[a-z0-9_.@+-]+$")
18
+
19
+ type JsonObject = dict[str, object]
20
+ type AccountKey = tuple[str, str]
21
+
22
+
23
+ def normalize_account_name(name: str) -> str:
24
+ """Lowercase and validate a proposed account name; raise ValueError if invalid."""
25
+ normalized = name.strip().lower()
26
+ if not normalized:
27
+ raise ValueError("account name cannot be empty")
28
+ if normalized.startswith("-"):
29
+ raise ValueError(f"account name '{name}' cannot start with '-' (would be read as a command flag)")
30
+ if normalized.startswith("."):
31
+ raise ValueError(f"account name '{name}' cannot start with '.'")
32
+ if not _NAME_RE.match(normalized):
33
+ raise ValueError(f"account name '{name}' may only contain letters, digits, '-', '_', '.', '@', and '+'")
34
+ return normalized
35
+
36
+
37
+ def normalize_provider_id(provider_id: str) -> str:
38
+ """Validate an exact OpenCode auth.json provider key."""
39
+ if not provider_id or provider_id != provider_id.strip():
40
+ raise ValueError("provider id cannot be empty or have surrounding whitespace")
41
+ if any(ord(char) < 32 or ord(char) == 127 for char in provider_id):
42
+ raise ValueError("provider id contains control characters")
43
+ return provider_id
44
+
45
+
46
+ class Validity(Enum):
47
+ """Result of validating an auth record."""
48
+
49
+ OK = auto()
50
+ EXPIRED = auto()
51
+ INVALID = auto()
52
+
53
+
54
+ class ImportConflictAction(Enum):
55
+ """Action selected when an imported account name already exists."""
56
+
57
+ SKIP = auto()
58
+ OVERWRITE = auto()
59
+ ABORT = auto()
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class AuthRecord:
64
+ """A single provider's entry from OpenCode's auth.json.
65
+
66
+ ``raw`` is the full original dict, preserved verbatim so a round-trip
67
+ (read -> store -> splice back in) never drops a field opencode-swap
68
+ doesn't know about — important since the schema is undocumented and can
69
+ gain fields across OpenCode versions (see SchemaError).
70
+ """
71
+
72
+ type: str
73
+ raw: JsonObject
74
+
75
+
76
+ @dataclass(frozen=True)
77
+ class AccountDesc:
78
+ """Human-facing description of an account, safe to print (no secrets)."""
79
+
80
+ type: str
81
+ email: str | None
82
+ account_id: str | None
83
+ expires: float | None
84
+
85
+
86
+ @dataclass(frozen=True)
87
+ class AccountMeta:
88
+ """Non-secret registry metadata for one saved account.
89
+
90
+ Deliberately excludes the identity string used for switch-time matching
91
+ (see providers/base.py Provider.identity): when no accountId claim is
92
+ available, identity falls back to the raw refresh token, which must
93
+ never land in the non-secret registry. Identity is recomputed on demand
94
+ from the secret-store-held record instead.
95
+ """
96
+
97
+ name: str
98
+ provider: str
99
+ type: str
100
+ account_id: str | None
101
+ email: str | None
102
+ added: str
103
+
104
+ def to_dict(self) -> JsonObject:
105
+ return {
106
+ "provider": self.provider,
107
+ "type": self.type,
108
+ "accountId": self.account_id,
109
+ "email": self.email,
110
+ "added": self.added,
111
+ }
112
+
113
+ @classmethod
114
+ def from_dict(cls, name: str, data: object) -> AccountMeta:
115
+ try:
116
+ if normalize_account_name(name) != name:
117
+ raise ValueError("invalid account name")
118
+ except ValueError as exc:
119
+ raise RegistryError(f"registry account has invalid name {name!r}") from exc
120
+ if not isinstance(data, dict):
121
+ raise RegistryError(f"registry account {name!r} is not an object")
122
+
123
+ unexpected_fields = set(data) - {"provider", "type", "accountId", "email", "added"}
124
+ if unexpected_fields:
125
+ raise RegistryError(f"registry account {name!r} has unsupported fields")
126
+
127
+ provider = data.get("provider")
128
+ record_type = data.get("type", "oauth")
129
+ account_id = data.get("accountId")
130
+ email = data.get("email")
131
+ added = data.get("added", "")
132
+ if not isinstance(provider, str) or not provider:
133
+ raise RegistryError(f"registry account {name!r} has invalid provider")
134
+ if not isinstance(record_type, str) or not record_type:
135
+ raise RegistryError(f"registry account {name!r} has invalid type")
136
+ if account_id is not None and not isinstance(account_id, str):
137
+ raise RegistryError(f"registry account {name!r} has invalid accountId")
138
+ if email is not None and not isinstance(email, str):
139
+ raise RegistryError(f"registry account {name!r} has invalid email")
140
+ if not isinstance(added, str):
141
+ raise RegistryError(f"registry account {name!r} has invalid added timestamp")
142
+ return cls(
143
+ name=name,
144
+ provider=provider,
145
+ type=record_type,
146
+ account_id=account_id,
147
+ email=email,
148
+ added=added,
149
+ )
150
+
151
+
152
+ @dataclass
153
+ class SwitchTransaction:
154
+ """Tracks completed steps of an `use_account` switch so switcher.py can
155
+ roll back the one step that actually mutates OpenCode's live state
156
+ (auth.json) if a later step fails. See switcher.py's use_account/
157
+ _rollback for why only "auth_written" needs an active rollback action:
158
+ every step before it either hasn't touched live state yet, or (for the
159
+ atomic auth.json write itself) is guaranteed by atomic_write_auth to
160
+ leave the original file untouched on failure.
161
+ """
162
+
163
+ original_auth: JsonObject
164
+ completed_steps: list[str] = field(default_factory=list)
165
+
166
+ def record_step(self, step: str) -> None:
167
+ self.completed_steps.append(step)
168
+
169
+
170
+ class Platform(Enum):
171
+ """Supported platforms."""
172
+
173
+ MACOS = auto()
174
+ LINUX = auto()
175
+ UNKNOWN = auto()
176
+
177
+ @classmethod
178
+ def detect(cls) -> Platform:
179
+ """Detect current platform.
180
+
181
+ Uses sys.platform rather than platform.system(), which can shell out
182
+ to external tools depending on OS (see claude-swap's Platform.detect
183
+ docstring for the Windows WMI-hang precedent this avoids).
184
+ """
185
+ platform_name = sys.platform.lower()
186
+ if platform_name == "darwin":
187
+ return cls.MACOS
188
+ if platform_name.startswith("linux"):
189
+ return cls.LINUX
190
+ return cls.UNKNOWN
@@ -0,0 +1,66 @@
1
+ """Decode claims from an OpenAI OAuth access-token JWT. No network calls.
2
+
3
+ Mirrors OpenCode's own claim extraction (opencode's
4
+ packages/opencode/src/plugin/openai/codex.ts:47-76, parseJwtClaims /
5
+ extractAccountIdFromClaims) so opencode-swap derives the same accountId
6
+ OpenCode would from the same token. Not signature-verified: we never trust
7
+ these claims for authorization, only use them to label/identify an account
8
+ that OpenCode itself already trusts.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import base64
14
+ import json
15
+
16
+ from opencode_swap.models import JsonObject
17
+
18
+
19
+ def decode_claims(token: str) -> JsonObject:
20
+ """Decode a JWT's payload claims. Returns {} if token isn't a valid JWT.
21
+
22
+ Empirically (see M0 spike), a real OpenAI access token does not always
23
+ carry an ``email`` claim — callers must treat email as optional.
24
+ """
25
+ parts = token.split(".")
26
+ if len(parts) != 3:
27
+ return {}
28
+ payload = parts[1]
29
+ padded = payload + "=" * (-len(payload) % 4)
30
+ try:
31
+ decoded = base64.urlsafe_b64decode(padded)
32
+ claims = json.loads(decoded)
33
+ except (ValueError, UnicodeDecodeError, json.JSONDecodeError):
34
+ return {}
35
+ return claims if isinstance(claims, dict) else {}
36
+
37
+
38
+ def extract_account_id(claims: JsonObject) -> str | None:
39
+ """Extract chatgpt_account_id from JWT claims, same fallback order as codex.ts."""
40
+ direct = claims.get("chatgpt_account_id")
41
+ if isinstance(direct, str) and direct:
42
+ return direct
43
+ nested = claims.get("https://api.openai.com/auth")
44
+ if isinstance(nested, dict):
45
+ account_id = nested.get("chatgpt_account_id")
46
+ if isinstance(account_id, str) and account_id:
47
+ return account_id
48
+ orgs = claims.get("organizations")
49
+ if isinstance(orgs, list) and orgs and isinstance(orgs[0], dict):
50
+ account_id = orgs[0].get("id")
51
+ if isinstance(account_id, str):
52
+ return account_id
53
+ return None
54
+
55
+
56
+ def extract_email(claims: JsonObject) -> str | None:
57
+ """Extract an email claim if present (not guaranteed on OpenAI access tokens)."""
58
+ email = claims.get("email")
59
+ if isinstance(email, str) and email:
60
+ return email
61
+ nested = claims.get("https://api.openai.com/auth")
62
+ if isinstance(nested, dict):
63
+ email = nested.get("email")
64
+ if isinstance(email, str) and email:
65
+ return email
66
+ return None
@@ -0,0 +1,156 @@
1
+ """Standalone OpenAI OAuth refresh, for accounts opencode-swap manages but
2
+ OpenCode isn't currently pointed at.
3
+
4
+ Verified against OpenCode's own implementation
5
+ (packages/opencode/src/plugin/openai/codex.ts:125-139,
6
+ refreshAccessToken), not guessed: POST {issuer}/oauth/token,
7
+ grant_type=refresh_token, against the same public PKCE client id OpenCode
8
+ itself uses (docs/opencode-auth.md#loading-and-refresh). There is no client
9
+ secret to protect; this is the same request OpenCode makes on every expired
10
+ request, just triggered by opencode-swap instead.
11
+
12
+ Refresh tokens are single-use: OpenAI issues a new one on every grant and
13
+ invalidates the old one (docs/architecture.md#why-sync-back-is-mandatory).
14
+ A caller that fails to persist a successful result here has permanently
15
+ lost access to the account -- the old token is already dead. See
16
+ switcher.py's use of this module for the locking/persistence discipline
17
+ that keeps that window as short as possible.
18
+
19
+ Unlike usage.py, this module raises on failure rather than returning an
20
+ "unavailable" sentinel: a caller deciding whether an account's refresh
21
+ token is dead (needs re-login) versus merely offline needs to be able to
22
+ tell those apart.
23
+
24
+ `expires` is an *integer* epoch-ms timestamp, not a float. OpenCode's auth
25
+ schema types it as `NonNegativeInt` (auth/index.ts's Oauth class, via
26
+ packages/schema/src/schema.ts's `Schema.Int`), and `Auth.all()` decodes
27
+ with `Record.filterMap` -- an entry that fails to decode is silently
28
+ dropped rather than reported. A fractional `expires` therefore makes
29
+ OpenCode behave as if the provider were never logged in at all, which
30
+ surfaces far from the cause (`auth.type` on an undefined record). OpenCode's
31
+ own refresh builds this from `Date.now()`, which is always integral;
32
+ `time.time() * 1000` is not, so the conversion happens here.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import json
38
+ import math
39
+ import urllib.error
40
+ import urllib.parse
41
+ import urllib.request
42
+ from dataclasses import dataclass
43
+
44
+ from opencode_swap.exceptions import RefreshError
45
+ from opencode_swap.oauth_jwt import decode_claims, extract_account_id
46
+
47
+ CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann" # codex.ts:10, public PKCE client, no secret
48
+ ISSUER = "https://auth.openai.com" # codex.ts:11
49
+ _TOKEN_PATH = "/oauth/token"
50
+ _TIMEOUT = 10.0
51
+ _USER_AGENT = "opencode-swap"
52
+ _DEFAULT_EXPIRES_IN = 3600 # codex.ts:372, `tokens.expires_in ?? 3600`
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class RefreshedTokens:
57
+ access: str
58
+ refresh: str
59
+ expires: int # epoch ms, integral -- see module docstring
60
+ account_id: str | None
61
+
62
+
63
+ def _valid_expires_in(value: object) -> float | None:
64
+ """A JSON `expires_in` is otherwise-untrusted server input: it can carry
65
+ a huge literal (`1e400`) that overflows to `inf`, or `NaN`/`Infinity`
66
+ directly (Python's json module accepts both by default). Either would
67
+ silently produce a non-finite `expires` timestamp, which then fails
68
+ `require_expiry`'s finite check the next time this record is loaded
69
+ (providers/common.py) -- by then the old refresh token this grant
70
+ consumed is already dead, permanently bricking the account. Reject here
71
+ instead, before any of that is persisted.
72
+ """
73
+ if not isinstance(value, (int, float)) or isinstance(value, bool):
74
+ return None
75
+ try:
76
+ if not math.isfinite(value) or value <= 0:
77
+ return None
78
+ except OverflowError:
79
+ return None
80
+ return float(value)
81
+
82
+
83
+ def _account_id_from_tokens(id_token: object, access_token: str) -> str | None:
84
+ """Same fallback order as codex.ts's extractAccountId: id_token claims
85
+ first, then access_token claims."""
86
+ if isinstance(id_token, str) and id_token:
87
+ account_id = extract_account_id(decode_claims(id_token))
88
+ if account_id:
89
+ return account_id
90
+ return extract_account_id(decode_claims(access_token))
91
+
92
+
93
+ def refresh_openai_oauth(refresh_token: str, *, now_ms: float, issuer: str = ISSUER) -> RefreshedTokens:
94
+ """Exchange a refresh token for a rotated access/refresh pair.
95
+
96
+ Raises RefreshError on any failure: rejected grant, network error,
97
+ timeout, or a response that doesn't match the expected shape. Never
98
+ includes the response body or the refresh token in the error message
99
+ (docs/security.md: CLI output must never include a secret).
100
+ """
101
+ body = urllib.parse.urlencode(
102
+ {
103
+ "grant_type": "refresh_token",
104
+ "refresh_token": refresh_token,
105
+ "client_id": CLIENT_ID,
106
+ }
107
+ ).encode()
108
+ request = urllib.request.Request(
109
+ f"{issuer}{_TOKEN_PATH}",
110
+ data=body,
111
+ headers={"Content-Type": "application/x-www-form-urlencoded", "User-Agent": _USER_AGENT},
112
+ method="POST",
113
+ )
114
+
115
+ try:
116
+ with urllib.request.urlopen(request, timeout=_TIMEOUT) as response:
117
+ payload = json.loads(response.read())
118
+ except urllib.error.HTTPError as exc:
119
+ if exc.code in (400, 401):
120
+ raise RefreshError(
121
+ "refresh token was rejected (it may already have been used elsewhere, or expired); "
122
+ "run `opencode auth login` for this account, then `opencode-swap add <provider> <name>` again"
123
+ ) from exc
124
+ raise RefreshError(f"token refresh failed (HTTP {exc.code})") from exc
125
+ except (urllib.error.URLError, TimeoutError, OSError) as exc:
126
+ raise RefreshError(f"token refresh failed: {exc.reason if isinstance(exc, urllib.error.URLError) else exc}") from exc
127
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
128
+ raise RefreshError(f"token refresh returned an unreadable response: {exc}") from exc
129
+
130
+ if not isinstance(payload, dict):
131
+ raise RefreshError("token refresh returned an unexpected response shape")
132
+
133
+ access_token = payload.get("access_token")
134
+ new_refresh_token = payload.get("refresh_token")
135
+ if not isinstance(access_token, str) or not access_token:
136
+ raise RefreshError("token refresh response is missing 'access_token'")
137
+ if not isinstance(new_refresh_token, str) or not new_refresh_token:
138
+ raise RefreshError("token refresh response is missing 'refresh_token'")
139
+
140
+ expires_in = _valid_expires_in(payload.get("expires_in"))
141
+ if expires_in is None:
142
+ expires_in = float(_DEFAULT_EXPIRES_IN)
143
+
144
+ expires = now_ms + expires_in * 1000
145
+ if not math.isfinite(expires):
146
+ raise RefreshError("token refresh returned an unusable expiry")
147
+
148
+ return RefreshedTokens(
149
+ access=access_token,
150
+ refresh=new_refresh_token,
151
+ # Truncate rather than round: an expiry that lands marginally early
152
+ # costs at most one extra refresh, while one that lands late would
153
+ # send a request with an already-dead access token.
154
+ expires=int(expires),
155
+ account_id=_account_id_from_tokens(payload.get("id_token"), access_token),
156
+ )