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.
- opencode_swap/__init__.py +3 -0
- opencode_swap/__main__.py +4 -0
- opencode_swap/atomic.py +95 -0
- opencode_swap/backup.py +128 -0
- opencode_swap/cli.py +799 -0
- opencode_swap/exceptions.py +60 -0
- opencode_swap/locking.py +70 -0
- opencode_swap/macos_keychain.py +136 -0
- opencode_swap/models.py +190 -0
- opencode_swap/oauth_jwt.py +66 -0
- opencode_swap/oauth_refresh.py +156 -0
- opencode_swap/opencode_auth.py +54 -0
- opencode_swap/paths.py +83 -0
- opencode_swap/process_detection.py +29 -0
- opencode_swap/providers/__init__.py +26 -0
- opencode_swap/providers/api.py +50 -0
- opencode_swap/providers/base.py +91 -0
- opencode_swap/providers/common.py +144 -0
- opencode_swap/providers/github_copilot.py +46 -0
- opencode_swap/providers/openai.py +186 -0
- opencode_swap/providers/poe.py +64 -0
- opencode_swap/providers/xai.py +72 -0
- opencode_swap/providers/zai.py +31 -0
- opencode_swap/sealed.py +83 -0
- opencode_swap/store.py +551 -0
- opencode_swap/switcher.py +952 -0
- opencode_swap/transfer.py +149 -0
- opencode_swap/usage.py +304 -0
- opencode_swap-0.4.0.dist-info/METADATA +307 -0
- opencode_swap-0.4.0.dist-info/RECORD +33 -0
- opencode_swap-0.4.0.dist-info/WHEEL +4 -0
- opencode_swap-0.4.0.dist-info/entry_points.txt +3 -0
- opencode_swap-0.4.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|
+
"""
|
opencode_swap/locking.py
ADDED
|
@@ -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()}")
|
opencode_swap/models.py
ADDED
|
@@ -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
|
+
)
|