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,54 @@
|
|
|
1
|
+
"""Whole-file read/validate/atomic-write of OpenCode's auth.json.
|
|
2
|
+
|
|
3
|
+
Generic across providers — a Provider only interprets the one key it owns
|
|
4
|
+
(see providers/base.py). This module never inspects provider-specific
|
|
5
|
+
fields; it just guarantees a safe, atomic round-trip of the file OpenCode
|
|
6
|
+
itself writes non-atomically and without a lock (verified:
|
|
7
|
+
opencode/packages/core/src/fs-util.ts:110-114 truncate+write then a separate
|
|
8
|
+
chmod, no temp file, no rename; opencode/packages/opencode/src/auth/index.ts
|
|
9
|
+
:75-79 does an unlocked read-merge-write). opencode-swap holds itself to a
|
|
10
|
+
higher bar: 0600 temp file in the same directory, then os.replace — the only
|
|
11
|
+
publish point, so a crash mid-write never leaves auth.json truncated.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from opencode_swap.atomic import atomic_write_json
|
|
20
|
+
from opencode_swap.exceptions import AuthFileError
|
|
21
|
+
from opencode_swap.models import JsonObject
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def read_auth(path: Path) -> JsonObject:
|
|
25
|
+
"""Read and parse auth.json. Raises AuthFileError if missing/malformed.
|
|
26
|
+
|
|
27
|
+
An absent file is a distinct, expected state (OpenCode with no accounts
|
|
28
|
+
configured yet) — callers that want to treat "no file" as "empty auth"
|
|
29
|
+
should catch AuthFileError and check path.exists() themselves; this
|
|
30
|
+
function never silently invents an empty dict, since a genuinely
|
|
31
|
+
unreadable file (permissions, corruption) must not look identical to
|
|
32
|
+
"nothing here yet."
|
|
33
|
+
"""
|
|
34
|
+
try:
|
|
35
|
+
text = path.read_text(encoding="utf-8")
|
|
36
|
+
except FileNotFoundError as exc:
|
|
37
|
+
raise AuthFileError(f"auth.json not found at {path}") from exc
|
|
38
|
+
except OSError as exc:
|
|
39
|
+
raise AuthFileError(f"could not read {path}: {exc}") from exc
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
data = json.loads(text)
|
|
43
|
+
except json.JSONDecodeError as exc:
|
|
44
|
+
raise AuthFileError(f"{path} is not valid JSON: {exc}") from exc
|
|
45
|
+
|
|
46
|
+
if not isinstance(data, dict):
|
|
47
|
+
raise AuthFileError(f"{path} does not contain a JSON object at the top level")
|
|
48
|
+
|
|
49
|
+
return data
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def atomic_write_auth(path: Path, data: JsonObject) -> None:
|
|
53
|
+
"""Write auth.json atomically: 0600 temp file in the same dir, then rename."""
|
|
54
|
+
atomic_write_json(path, data)
|
opencode_swap/paths.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Path resolution for OpenCode's auth state and opencode-swap's own data.
|
|
2
|
+
|
|
3
|
+
Mirrors OpenCode's own path resolution so opencode-swap reads and writes
|
|
4
|
+
exactly the file OpenCode does. Verified from OpenCode source
|
|
5
|
+
(``packages/core/src/global.ts``, via xdg-basedir) and cross-checked against
|
|
6
|
+
opencode-balancer's independent reimplementation (``src/core/path.ts``):
|
|
7
|
+
|
|
8
|
+
- Data dir: ``$XDG_DATA_HOME/opencode`` if ``XDG_DATA_HOME`` is set (and
|
|
9
|
+
absolute), else ``~/.local/share/opencode``. Identical on Linux and macOS —
|
|
10
|
+
OpenCode uses xdg-basedir defaults everywhere it runs on POSIX; there is no
|
|
11
|
+
macOS-specific data location.
|
|
12
|
+
- Auth file: ``<data dir>/auth.json``.
|
|
13
|
+
- ``OPENCODE_AUTH_CONTENT``, if set in the *OpenCode process's* environment,
|
|
14
|
+
makes ``Auth.all()`` return that JSON in-memory and ignore the file
|
|
15
|
+
entirely (``auth/index.ts:59-63``). We can only detect whether it is set in
|
|
16
|
+
*our own* process env — useful for ``doctor`` diagnostics, not for writing.
|
|
17
|
+
- ``OPENCODE_CONFIG_DIR`` overrides OpenCode's *config* dir only, not the
|
|
18
|
+
data dir — it does not move auth.json (``global.ts:64``).
|
|
19
|
+
- ``OPENCODE_TEST_HOME`` overrides OpenCode's notion of home (``global.ts:19``)
|
|
20
|
+
and is honored here too, so integration tests can point both OpenCode and
|
|
21
|
+
opencode-swap at the same fake home without touching the real one.
|
|
22
|
+
|
|
23
|
+
opencode-swap's own data (registry, backups, fallback secrets) lives under
|
|
24
|
+
``$XDG_DATA_HOME/opencode-swap`` using the same resolution — uniform across
|
|
25
|
+
Linux and macOS since this is a new tool with no legacy layout to preserve.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import os
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
|
|
33
|
+
OPENCODE_DIRNAME = "opencode"
|
|
34
|
+
OPENCODE_SWAP_DIRNAME = "opencode-swap"
|
|
35
|
+
AUTH_FILENAME = "auth.json"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def effective_home() -> Path:
|
|
39
|
+
"""Return the home directory, honoring OPENCODE_TEST_HOME for test parity."""
|
|
40
|
+
test_home = os.environ.get("OPENCODE_TEST_HOME")
|
|
41
|
+
if test_home:
|
|
42
|
+
return Path(test_home)
|
|
43
|
+
return Path.home()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def xdg_data_home() -> Path:
|
|
47
|
+
"""Return XDG_DATA_HOME, falling back to ``<home>/.local/share``.
|
|
48
|
+
|
|
49
|
+
Per the XDG spec (and OpenCode's own xdg-basedir usage), an unset, empty,
|
|
50
|
+
or non-absolute XDG_DATA_HOME is ignored in favor of the default.
|
|
51
|
+
"""
|
|
52
|
+
env = os.environ.get("XDG_DATA_HOME", "")
|
|
53
|
+
if env:
|
|
54
|
+
candidate = Path(os.path.expanduser(env))
|
|
55
|
+
if candidate.is_absolute():
|
|
56
|
+
return candidate
|
|
57
|
+
return effective_home() / ".local" / "share"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def get_opencode_data_dir() -> Path:
|
|
61
|
+
"""Return OpenCode's data directory (``$XDG_DATA_HOME/opencode``)."""
|
|
62
|
+
return xdg_data_home() / OPENCODE_DIRNAME
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def get_opencode_auth_path() -> Path:
|
|
66
|
+
"""Return the path to OpenCode's auth.json."""
|
|
67
|
+
return get_opencode_data_dir() / AUTH_FILENAME
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def opencode_auth_content_override_active() -> bool:
|
|
71
|
+
"""Return True if OPENCODE_AUTH_CONTENT is set in our own process env.
|
|
72
|
+
|
|
73
|
+
When set in the *OpenCode* process's environment, OpenCode ignores
|
|
74
|
+
auth.json entirely and reads this instead — a swap would have no effect.
|
|
75
|
+
We can only observe our own env, so this is a best-effort diagnostic
|
|
76
|
+
signal for ``doctor``, not a guarantee about OpenCode's runtime.
|
|
77
|
+
"""
|
|
78
|
+
return bool(os.environ.get("OPENCODE_AUTH_CONTENT"))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def get_data_root() -> Path:
|
|
82
|
+
"""Return opencode-swap's own data root (``$XDG_DATA_HOME/opencode-swap``)."""
|
|
83
|
+
return xdg_data_home() / OPENCODE_SWAP_DIRNAME
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Best-effort detection of a running OpenCode process.
|
|
2
|
+
|
|
3
|
+
OpenCode has no cooperative lock protocol opencode-swap can join (verified:
|
|
4
|
+
Auth.set does an unlocked read-merge-write, auth/index.ts:75-79). The
|
|
5
|
+
closest thing to a safety net is warning the caller that a swap is racing a
|
|
6
|
+
live OpenCode process, which might be mid-refresh and write auth.json again
|
|
7
|
+
right after opencode-swap does. Detection failure (no `pgrep`, permission
|
|
8
|
+
denied, etc.) degrades to "not detected" rather than raising — this is
|
|
9
|
+
advisory, not a security boundary.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import subprocess
|
|
15
|
+
|
|
16
|
+
_TIMEOUT = 2.0
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def is_opencode_running() -> bool:
|
|
20
|
+
try:
|
|
21
|
+
result = subprocess.run(
|
|
22
|
+
["pgrep", "-x", "opencode"],
|
|
23
|
+
capture_output=True,
|
|
24
|
+
timeout=_TIMEOUT,
|
|
25
|
+
check=False,
|
|
26
|
+
)
|
|
27
|
+
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
|
|
28
|
+
return False
|
|
29
|
+
return result.returncode == 0
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Provider registry: maps an OpenCode provider id to its Provider implementation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from opencode_swap.models import normalize_provider_id
|
|
6
|
+
from opencode_swap.providers.api import ApiProvider
|
|
7
|
+
from opencode_swap.providers.base import Provider
|
|
8
|
+
from opencode_swap.providers.github_copilot import GitHubCopilotProvider
|
|
9
|
+
from opencode_swap.providers.openai import OpenAiProvider
|
|
10
|
+
from opencode_swap.providers.poe import PoeProvider
|
|
11
|
+
from opencode_swap.providers.xai import XaiProvider
|
|
12
|
+
from opencode_swap.providers.zai import ZaiProvider
|
|
13
|
+
|
|
14
|
+
PROVIDERS: dict[str, Provider] = {
|
|
15
|
+
"openai": OpenAiProvider(),
|
|
16
|
+
"github-copilot": GitHubCopilotProvider(),
|
|
17
|
+
"poe": PoeProvider(),
|
|
18
|
+
"xai": XaiProvider(),
|
|
19
|
+
"zai-coding-plan": ZaiProvider(),
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def get_provider(provider_id: str) -> Provider:
|
|
24
|
+
"""Return specialized handling or canonical API-only fallback."""
|
|
25
|
+
provider_id = normalize_provider_id(provider_id)
|
|
26
|
+
return PROVIDERS.get(provider_id) or ApiProvider(provider_id)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Generic static API-key provider supported by OpenCode's common path."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from opencode_swap import usage
|
|
6
|
+
from opencode_swap.exceptions import SchemaError
|
|
7
|
+
from opencode_swap.models import AccountDesc, AuthRecord, JsonObject, Validity
|
|
8
|
+
from opencode_swap.providers.common import credential_values, extract_raw, key_account_hint, published_raw, validate_api
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ApiProvider:
|
|
12
|
+
usage_record_types: frozenset[str] = frozenset() # no known usage endpoint for an arbitrary API provider
|
|
13
|
+
|
|
14
|
+
def __init__(self, provider_id: str):
|
|
15
|
+
self.id = provider_id
|
|
16
|
+
|
|
17
|
+
def extract(self, auth: JsonObject) -> AuthRecord | None:
|
|
18
|
+
raw = extract_raw(auth, self.id)
|
|
19
|
+
if raw is None:
|
|
20
|
+
return None
|
|
21
|
+
if raw.get("type") != "api":
|
|
22
|
+
raise SchemaError(f"{self.id} auth type is not supported by opencode-swap")
|
|
23
|
+
return validate_api(raw, self.id)
|
|
24
|
+
|
|
25
|
+
def splice(self, auth: JsonObject, record: AuthRecord) -> JsonObject:
|
|
26
|
+
if record.type != "api":
|
|
27
|
+
raise SchemaError(f"{self.id} auth type is not supported by opencode-swap")
|
|
28
|
+
return {**auth, self.id: published_raw(record.raw)}
|
|
29
|
+
|
|
30
|
+
def identity(self, record: AuthRecord) -> str:
|
|
31
|
+
key = record.raw.get("key")
|
|
32
|
+
return f"api-key\0{key if isinstance(key, str) else ''}"
|
|
33
|
+
|
|
34
|
+
def identity_is_stable(self, record: AuthRecord) -> bool:
|
|
35
|
+
return True
|
|
36
|
+
|
|
37
|
+
def credential_values(self, record: AuthRecord) -> set[str]:
|
|
38
|
+
return credential_values(record)
|
|
39
|
+
|
|
40
|
+
def describe(self, record: AuthRecord) -> AccountDesc:
|
|
41
|
+
return AccountDesc(type="api", email=None, account_id=key_account_hint(record), expires=None)
|
|
42
|
+
|
|
43
|
+
def validate(self, record: AuthRecord) -> Validity:
|
|
44
|
+
return Validity.OK if record.type == "api" else Validity.INVALID
|
|
45
|
+
|
|
46
|
+
def refresh(self, record: AuthRecord) -> AuthRecord | None:
|
|
47
|
+
return None # API keys don't expire/rotate; nothing to refresh
|
|
48
|
+
|
|
49
|
+
def fetch_usage(self, record: AuthRecord) -> usage.UsageSnapshot | None:
|
|
50
|
+
return None # no usage endpoint for an arbitrary API provider (usage_record_types is empty)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""The Provider seam: the only part of opencode-swap that varies per OpenCode
|
|
2
|
+
provider/account type.
|
|
3
|
+
|
|
4
|
+
auth.json's whole-file handling (read/validate/atomic-write) is generic and
|
|
5
|
+
lives in opencode_auth.py. A Provider owns only what's provider-specific:
|
|
6
|
+
which key(s) it occupies in auth.json, and how to read identity/validity out
|
|
7
|
+
of its record. Adding a second provider means adding a second Provider
|
|
8
|
+
implementation — no changes to the switch/store/lock machinery.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Protocol
|
|
14
|
+
|
|
15
|
+
from opencode_swap import usage
|
|
16
|
+
from opencode_swap.models import AccountDesc, AuthRecord, JsonObject, Validity
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Provider(Protocol):
|
|
20
|
+
id: str
|
|
21
|
+
|
|
22
|
+
usage_record_types: frozenset[str]
|
|
23
|
+
"""Record types (``"api"``, ``"oauth"``, ...) this provider can look up
|
|
24
|
+
live usage for via ``fetch_usage``. Empty (the default for every provider
|
|
25
|
+
without a known usage endpoint) tells ``Switcher.fetch_usage`` to skip all
|
|
26
|
+
lock/refresh work and report "not applicable" rather than "unavailable"."""
|
|
27
|
+
|
|
28
|
+
def extract(self, auth: JsonObject) -> AuthRecord | None:
|
|
29
|
+
"""Pull this provider's entry out of a parsed auth.json.
|
|
30
|
+
|
|
31
|
+
Returns None if the provider has no entry. Raises SchemaError if an
|
|
32
|
+
entry exists but doesn't match a known shape (fail-safe: never guess).
|
|
33
|
+
"""
|
|
34
|
+
...
|
|
35
|
+
|
|
36
|
+
def splice(self, auth: JsonObject, record: AuthRecord) -> JsonObject:
|
|
37
|
+
"""Return a new auth.json dict with this provider's entry replaced.
|
|
38
|
+
|
|
39
|
+
Does not mutate ``auth``; all other providers' keys are preserved.
|
|
40
|
+
|
|
41
|
+
Implementations must route the record through
|
|
42
|
+
``providers.common.published_raw`` -- this is the only path by which
|
|
43
|
+
opencode-swap content reaches OpenCode, so it is where read-side
|
|
44
|
+
tolerance has to stop. ``test_every_provider_splice_publishes_an
|
|
45
|
+
_integer_expiry`` enforces this for every registered provider.
|
|
46
|
+
"""
|
|
47
|
+
...
|
|
48
|
+
|
|
49
|
+
def identity(self, record: AuthRecord) -> str:
|
|
50
|
+
"""A stable string identifying which real-world account this record
|
|
51
|
+
belongs to, independent of token rotation (e.g. account id, or the
|
|
52
|
+
refresh token itself as a fallback)."""
|
|
53
|
+
...
|
|
54
|
+
|
|
55
|
+
def identity_is_stable(self, record: AuthRecord) -> bool:
|
|
56
|
+
"""Whether identity remains unchanged when OpenCode refreshes it."""
|
|
57
|
+
...
|
|
58
|
+
|
|
59
|
+
def credential_values(self, record: AuthRecord) -> set[str]:
|
|
60
|
+
"""Secret strings which must never enter metadata or output."""
|
|
61
|
+
...
|
|
62
|
+
|
|
63
|
+
def describe(self, record: AuthRecord) -> AccountDesc:
|
|
64
|
+
"""Human-facing, secret-free description of the account."""
|
|
65
|
+
...
|
|
66
|
+
|
|
67
|
+
def validate(self, record: AuthRecord) -> Validity:
|
|
68
|
+
"""Whether the record looks usable, expired, or malformed."""
|
|
69
|
+
...
|
|
70
|
+
|
|
71
|
+
def refresh(self, record: AuthRecord) -> AuthRecord | None:
|
|
72
|
+
"""Rotated-token copy of `record` from a standalone OAuth refresh.
|
|
73
|
+
|
|
74
|
+
Returns None if this provider/record type has no standalone refresh
|
|
75
|
+
(the default for every provider but OpenAI oauth records). Raises
|
|
76
|
+
RefreshError if a refresh was attempted and the grant was rejected
|
|
77
|
+
or the request otherwise failed -- callers must not treat that the
|
|
78
|
+
same as "no refresh available".
|
|
79
|
+
"""
|
|
80
|
+
...
|
|
81
|
+
|
|
82
|
+
def fetch_usage(self, record: AuthRecord) -> usage.UsageSnapshot | None:
|
|
83
|
+
"""Live usage/quota for `record`, fetched over the network.
|
|
84
|
+
|
|
85
|
+
Only called for record types in ``usage_record_types``. Returns None
|
|
86
|
+
when this particular record can't be looked up (missing field, secret
|
|
87
|
+
store out of sync) -- distinct from ``UsageSnapshot(available=False)``
|
|
88
|
+
("looked it up, the request failed"). Never raises: network and shape
|
|
89
|
+
failures come back as ``available=False``.
|
|
90
|
+
"""
|
|
91
|
+
...
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Shared validation for OpenCode's canonical auth record shapes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from typing import TypeGuard
|
|
7
|
+
|
|
8
|
+
from opencode_swap.exceptions import SchemaError
|
|
9
|
+
from opencode_swap.models import AuthRecord, JsonObject
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def require_str(raw: JsonObject, field: str, provider_id: str, entry_type: str) -> str:
|
|
13
|
+
value = raw.get(field)
|
|
14
|
+
if not isinstance(value, str) or not value:
|
|
15
|
+
raise SchemaError(f"{provider_id} {entry_type} entry missing/invalid field {field!r}")
|
|
16
|
+
return value
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def optional_str(raw: JsonObject, field: str, provider_id: str, entry_type: str) -> str | None:
|
|
20
|
+
if field not in raw:
|
|
21
|
+
return None
|
|
22
|
+
value = raw[field]
|
|
23
|
+
if not isinstance(value, str):
|
|
24
|
+
raise SchemaError(f"{provider_id} {entry_type} entry has invalid field {field!r}")
|
|
25
|
+
return value
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def is_json_number(value: object) -> TypeGuard[int | float]:
|
|
29
|
+
"""Whether value survives OpenCode's JavaScript JSON-number boundary."""
|
|
30
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
31
|
+
return False
|
|
32
|
+
try:
|
|
33
|
+
return math.isfinite(float(value))
|
|
34
|
+
except OverflowError:
|
|
35
|
+
return False
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def require_expiry(raw: JsonObject, provider_id: str) -> int | float:
|
|
39
|
+
expires = raw.get("expires")
|
|
40
|
+
if not is_json_number(expires) or expires < 0:
|
|
41
|
+
raise SchemaError(f"{provider_id} oauth entry has invalid 'expires'")
|
|
42
|
+
return expires
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def published_raw(raw: JsonObject) -> JsonObject:
|
|
46
|
+
"""Copy of `raw` safe to publish into OpenCode's auth.json.
|
|
47
|
+
|
|
48
|
+
Every `Provider.splice` routes its record through this -- splice is the
|
|
49
|
+
only path by which opencode-swap content reaches auth.json, and the one
|
|
50
|
+
place where our tolerance for what we *read* must stop.
|
|
51
|
+
|
|
52
|
+
OpenCode types `expires` as `NonNegativeInt` (auth/index.ts's Oauth
|
|
53
|
+
class, via packages/schema/src/schema.ts's `Schema.Int`), and `Auth.all()`
|
|
54
|
+
decodes with `Record.filterMap`, which drops entries that fail to decode
|
|
55
|
+
instead of reporting them. Publishing a fractional `expires` therefore
|
|
56
|
+
makes the provider vanish from OpenCode entirely, with the first symptom
|
|
57
|
+
appearing far away as an `auth.type` access on an undefined record
|
|
58
|
+
(codex.ts:331).
|
|
59
|
+
|
|
60
|
+
Deliberately not done in `extract`: that is a validator on records we
|
|
61
|
+
did not write, and normalizing there would also rewrite the live record
|
|
62
|
+
captured by `backup.write_unclaimed`, whose entire purpose is to
|
|
63
|
+
preserve a foreign login exactly as found. Reads stay verbatim; only
|
|
64
|
+
what we hand back to OpenCode is constrained. Records written by older
|
|
65
|
+
versions (see oauth_refresh.py) are healed on the next switch as a
|
|
66
|
+
consequence.
|
|
67
|
+
|
|
68
|
+
Scoped to `type == "oauth"`: that is the only OpenCode auth variant
|
|
69
|
+
whose schema types `expires` as `NonNegativeInt` at all (Api and
|
|
70
|
+
WellKnown carry no `expires` field in OpenCode's schema). Truncating an
|
|
71
|
+
`expires`-named key on any other type -- or any other unknown field --
|
|
72
|
+
would silently mutate data that isn't ours to touch and OpenCode was
|
|
73
|
+
never going to reject in the first place.
|
|
74
|
+
"""
|
|
75
|
+
if raw.get("type") != "oauth":
|
|
76
|
+
return dict(raw)
|
|
77
|
+
expires = raw.get("expires")
|
|
78
|
+
if not is_json_number(expires) or isinstance(expires, int):
|
|
79
|
+
return dict(raw) # absent, non-numeric (rejected elsewhere), or already integral
|
|
80
|
+
return {**raw, "expires": int(expires)}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def extract_raw(auth: JsonObject, provider_id: str) -> JsonObject | None:
|
|
84
|
+
raw = auth.get(provider_id)
|
|
85
|
+
if raw is None:
|
|
86
|
+
return None
|
|
87
|
+
if not isinstance(raw, dict) or "type" not in raw:
|
|
88
|
+
raise SchemaError(f"{provider_id} entry is not a recognizable auth record")
|
|
89
|
+
return raw
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def validate_api(raw: JsonObject, provider_id: str) -> AuthRecord:
|
|
93
|
+
require_str(raw, "key", provider_id, "api")
|
|
94
|
+
metadata = raw.get("metadata")
|
|
95
|
+
if "metadata" in raw and (
|
|
96
|
+
not isinstance(metadata, dict) or any(not isinstance(key, str) or not isinstance(value, str) for key, value in metadata.items())
|
|
97
|
+
):
|
|
98
|
+
raise SchemaError(f"{provider_id} api entry has invalid field 'metadata'")
|
|
99
|
+
return AuthRecord(type="api", raw=dict(raw))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def validate_oauth(raw: JsonObject, provider_id: str) -> AuthRecord:
|
|
103
|
+
require_str(raw, "refresh", provider_id, "oauth")
|
|
104
|
+
require_str(raw, "access", provider_id, "oauth")
|
|
105
|
+
require_expiry(raw, provider_id)
|
|
106
|
+
optional_str(raw, "accountId", provider_id, "oauth")
|
|
107
|
+
optional_str(raw, "enterpriseUrl", provider_id, "oauth")
|
|
108
|
+
return AuthRecord(type="oauth", raw=dict(raw))
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
_MIN_KEY_LENGTH_FOR_HINT = 20 # leaves >=16 characters hidden; see key_account_hint
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def credential_values(record: AuthRecord) -> set[str]:
|
|
115
|
+
return {value for field in ("refresh", "access", "key", "token") if isinstance((value := record.raw.get(field)), str) and value}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def key_account_hint(record: AuthRecord) -> str | None:
|
|
119
|
+
"""`"...{last 4 chars}"` of a static API key -- a stable identifier for
|
|
120
|
+
*which* key a saved account holds, shown in the account-id column in
|
|
121
|
+
place of an account id for static-key providers (which have none). This
|
|
122
|
+
is the same head/tail form providers show on their own API-key
|
|
123
|
+
dashboards, so a row can be matched against one; the literal `...`
|
|
124
|
+
carries no information about the key itself, it only marks the value as
|
|
125
|
+
a deliberate partial (matching how `cli._redact_account_id` marks a
|
|
126
|
+
truncated account id the same way).
|
|
127
|
+
|
|
128
|
+
Only ``record.type == "api"`` is considered: an oauth record's raw dict
|
|
129
|
+
can carry an unrelated/unverified `key` field (schema validation doesn't
|
|
130
|
+
forbid extra fields), and that is not the credential this hint is about.
|
|
131
|
+
|
|
132
|
+
Returns None below `_MIN_KEY_LENGTH_FOR_HINT`: revealing 4 characters of
|
|
133
|
+
a short, possibly low-entropy value (a hand-picked token, not a real API
|
|
134
|
+
key) can materially narrow it -- e.g. an 8-character key exposes half of
|
|
135
|
+
it. The threshold instead guarantees at least 16 characters stay hidden,
|
|
136
|
+
which no realistic API key format (all comfortably 20+ chars) is close
|
|
137
|
+
to tripping.
|
|
138
|
+
"""
|
|
139
|
+
if record.type != "api":
|
|
140
|
+
return None
|
|
141
|
+
key = record.raw.get("key")
|
|
142
|
+
if not isinstance(key, str) or len(key) < _MIN_KEY_LENGTH_FOR_HINT:
|
|
143
|
+
return None
|
|
144
|
+
return f"...{key[-4:]}"
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""GitHub Copilot auth as implemented by OpenCode's built-in plugin."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from opencode_swap.exceptions import SchemaError
|
|
6
|
+
from opencode_swap.models import AccountDesc, AuthRecord, JsonObject, Validity
|
|
7
|
+
from opencode_swap.providers.common import credential_values, extract_raw, published_raw, validate_oauth
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class GitHubCopilotProvider:
|
|
11
|
+
id = "github-copilot"
|
|
12
|
+
usage_record_types: frozenset[str] = frozenset() # no known usage endpoint
|
|
13
|
+
|
|
14
|
+
def extract(self, auth: JsonObject) -> AuthRecord | None:
|
|
15
|
+
raw = extract_raw(auth, self.id)
|
|
16
|
+
if raw is None:
|
|
17
|
+
return None
|
|
18
|
+
if raw.get("type") != "oauth":
|
|
19
|
+
raise SchemaError("github-copilot auth type is not supported by opencode-swap")
|
|
20
|
+
return validate_oauth(raw, self.id)
|
|
21
|
+
|
|
22
|
+
def splice(self, auth: JsonObject, record: AuthRecord) -> JsonObject:
|
|
23
|
+
return {**auth, self.id: published_raw(record.raw)}
|
|
24
|
+
|
|
25
|
+
def identity(self, record: AuthRecord) -> str:
|
|
26
|
+
refresh = record.raw.get("refresh")
|
|
27
|
+
enterprise = record.raw.get("enterpriseUrl")
|
|
28
|
+
return f"copilot\0{enterprise if isinstance(enterprise, str) else ''}\0{refresh if isinstance(refresh, str) else ''}"
|
|
29
|
+
|
|
30
|
+
def identity_is_stable(self, record: AuthRecord) -> bool:
|
|
31
|
+
return True
|
|
32
|
+
|
|
33
|
+
def credential_values(self, record: AuthRecord) -> set[str]:
|
|
34
|
+
return credential_values(record)
|
|
35
|
+
|
|
36
|
+
def describe(self, record: AuthRecord) -> AccountDesc:
|
|
37
|
+
return AccountDesc(type="oauth", email=None, account_id=None, expires=None)
|
|
38
|
+
|
|
39
|
+
def validate(self, record: AuthRecord) -> Validity:
|
|
40
|
+
return Validity.OK if record.type == "oauth" else Validity.INVALID
|
|
41
|
+
|
|
42
|
+
def refresh(self, record: AuthRecord) -> AuthRecord | None:
|
|
43
|
+
return None # no verified standalone refresh flow for this provider yet
|
|
44
|
+
|
|
45
|
+
def fetch_usage(self, record: AuthRecord) -> None:
|
|
46
|
+
return None # unreachable: usage_record_types is empty
|