virtual-key-mapper 0.1.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,3 @@
1
+ """Virtual Key Mapper: short-lived keys from your company gateway, labelled with the repository you work in, for every tool you use."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,115 @@
1
+ """Interfaces between the vendor-neutral core and its adapters.
2
+
3
+ Auth adapters obtain credentials, gateway adapters know URLs and the outputs labels can fill, and
4
+ integration adapters configure and launch tools. Implementations are listed in ``registry``.
5
+ """
6
+
7
+ import sys
8
+ from dataclasses import dataclass, field
9
+ from enum import StrEnum
10
+ from pathlib import Path
11
+ from typing import Callable, Protocol
12
+
13
+ from .labels import Attribution, Choice, LabelConfig, Output
14
+ from .profiles import Profile
15
+
16
+
17
+ class Api(StrEnum):
18
+ """A wire protocol a tool speaks; a gateway maps each one it supports to a base URL."""
19
+
20
+ ANTHROPIC_MESSAGES = "anthropic-messages"
21
+ OPENAI_CHAT = "openai-chat"
22
+ OPENAI_RESPONSES = "openai-responses"
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class Credential:
27
+ """A bearer secret for the gateway; ``notice`` is a nonsecret message for the user, e.g. sign in again soon."""
28
+
29
+ secret: str = field(repr=False)
30
+ expires_at: float | None = None
31
+ user: str | None = None
32
+ notice: str | None = None
33
+
34
+
35
+ class Auth(Protocol):
36
+ """Obtains credentials for one profile; never opens a browser outside ``login``."""
37
+
38
+ def login(self, notify: Callable[[str], None], **options) -> Credential: ...
39
+ def logout(self, notify: Callable[[str], None]) -> None: ...
40
+ def status(self) -> dict: ...
41
+ def credential(self) -> Credential: ...
42
+
43
+
44
+ class Gateway(Protocol):
45
+ """Knows a gateway's endpoints and the outputs (headers) labels can fill there.
46
+
47
+ The core renders the headers (``LabelConfig.headers``) from the adapter's outputs, so header names,
48
+ defaults and reserved results stay in the adapter while formats and checks stay in one place.
49
+ """
50
+
51
+ def endpoint(self, api: Api) -> str:
52
+ """Return the base URL for ``api``; raise CredentialError if the gateway does not offer it."""
53
+
54
+ @staticmethod
55
+ def outputs() -> tuple[Output, ...]:
56
+ """The outputs this gateway kind can fill, in header order; static, so profiles validate without a gateway."""
57
+
58
+ def catalogue(self, key: str, credential: Callable[[], Credential], config: LabelConfig) -> list[Choice] | None:
59
+ """Values the gateway lists for one label key, or None when it cannot list them for this key.
60
+
61
+ ``credential`` is called only when the gateway asks for the list, so no sign-in is needed otherwise.
62
+ """
63
+
64
+
65
+ @dataclass(frozen=True)
66
+ class IntegrationContext:
67
+ """What an integration may use: the profile, its gateway, a credential and how to call this CLI.
68
+
69
+ ``cli`` is the argv prefix that runs this CLI with the same configuration folder; tools that renew
70
+ credentials themselves call ``cli + ("token", "--profile", NAME)`` instead of receiving ``token``.
71
+ ``home`` holds the integration's own nonsecret state (what setup changed, for a clean removal).
72
+ ``labels`` is the effective label configuration; by default the profile's own labels without central rules.
73
+ """
74
+
75
+ profile: Profile
76
+ gateway: Gateway
77
+ token: str = field(repr=False)
78
+ home: Path | None = None
79
+ cli: tuple[str, ...] = (sys.executable, "-I", "-m", "virtual_key_mapper")
80
+ labels: LabelConfig | None = None
81
+
82
+ def __post_init__(self) -> None:
83
+ if self.labels is None:
84
+ object.__setattr__(self, "labels", LabelConfig.build(
85
+ self.gateway.outputs(), profile_labels=self.profile.labels, profile_outputs=self.profile.outputs,
86
+ ))
87
+
88
+ def headers(self, attribution: Attribution | None) -> dict[str, str]:
89
+ """The attribution headers for this context's labels; none without an attribution."""
90
+ return self.labels.headers(attribution) if attribution is not None else {}
91
+
92
+ def cli_command(self, *args: str) -> tuple[str, ...]:
93
+ """This CLI with the integration's configuration folder, e.g. ``cli_command("token", "--profile", "a")``."""
94
+ return (*self.cli, *(("--home", str(self.home)) if self.home is not None else ()), *args)
95
+
96
+
97
+ class Integration(Protocol):
98
+ """Configures and launches one tool; it only uses its context and the attribution.
99
+
100
+ ``attribution`` is the resolved labels (``Attribution``); its headers come from ``context.headers``.
101
+ """
102
+
103
+ def setup(self, context: IntegrationContext, model: str | None = None, replace: bool = False) -> list[str]:
104
+ """Write the tool's user-level configuration; return messages for the user."""
105
+
106
+ def remove(self, context: IntegrationContext) -> list[str]:
107
+ """Undo ``setup``, keeping unrelated configuration."""
108
+
109
+ def command(
110
+ self, context: IntegrationContext, attribution: Attribution | None, args: list[str], model: str | None,
111
+ ) -> tuple[list[str], dict[str, str]]: ...
112
+
113
+ def launch(
114
+ self, context: IntegrationContext, attribution: Attribution | None, args: list[str], model: str | None,
115
+ ) -> int: ...
@@ -0,0 +1 @@
1
+ """Auth adapters: how a profile obtains its gateway credential (see ``registry.AUTH``)."""
@@ -0,0 +1,155 @@
1
+ """The forwarded auth kind: credentials come from the sign-in on the user's local machine.
2
+
3
+ For dev containers and other remote VS Code windows. The Virtual Key Mapper extension running in the
4
+ remote window listens on a local socket and relays each request through VS Code to the companion
5
+ extension on the local machine, which answers with the local CLI (and its OS vault). Only short-lived
6
+ access tokens reach the remote side; the refresh grant never leaves the local vault.
7
+
8
+ Protocol: one JSON line per connection, ``{"version": 1, "operation": "token"|"profile", "profile": NAME}``,
9
+ answered by ``{"ok": true, "result": {...}}`` or ``{"ok": false, "error": "..."}``. A "token" result is
10
+ ``{access_token, expires_at, profile, user}``; this module asks only for tokens. The extension asks for
11
+ "profile" to create the remote profile: ``{name, gateway, endpoints, rules, labels, outputs}``, where
12
+ ``labels`` and ``outputs`` are the local profile's own definitions (``Profile.definitions()``), not the
13
+ merged ones; the remote CLI fetches the central label rules itself. Labels never travel with tokens: they
14
+ are resolved in the remote window by the remote CLI.
15
+ """
16
+
17
+ import json
18
+ import os
19
+ import socket
20
+ import stat
21
+ import tempfile
22
+ from pathlib import Path
23
+ from typing import Callable, Mapping
24
+
25
+ from ..adapters import Credential
26
+ from ..errors import CredentialError
27
+ from ..profiles import Profile, ProfileStore, validate_profile_name
28
+
29
+ AGENT_VARIABLE = "VKM_AGENT"
30
+ TIMEOUT_SECONDS = 120 # The local side may renew the credential first.
31
+ _MAX_ANSWER = 1_048_576
32
+
33
+
34
+ def settings(values: dict) -> dict:
35
+ """Optional ``host_profile``: the local profile to use; the local default when absent."""
36
+ if not set(values) <= {"host_profile"}:
37
+ raise CredentialError("Forwarded sign-in takes only --host-profile.")
38
+ if "host_profile" in values:
39
+ validate_profile_name(values["host_profile"])
40
+ return dict(values)
41
+
42
+
43
+ def agent_path(environ: Mapping[str, str] | None = None) -> str:
44
+ """Where the VS Code extension listens; the same default is computed by the extension."""
45
+ environ = os.environ if environ is None else environ
46
+ if environ.get(AGENT_VARIABLE):
47
+ return environ[AGENT_VARIABLE]
48
+ if environ.get("XDG_RUNTIME_DIR"):
49
+ return str(Path(environ["XDG_RUNTIME_DIR"]) / "vkm" / "agent.sock")
50
+ user = os.getuid() if hasattr(os, "getuid") else os.environ.get("USERNAME", "user")
51
+ return str(Path(tempfile.gettempdir()) / f"vkm-{user}" / "agent.sock")
52
+
53
+
54
+ def _check_owner(path: str) -> None:
55
+ """Refuse a socket another user could have placed: it must be ours, in a real directory only we (or root) can write."""
56
+ directory, sock = os.lstat(os.path.dirname(path) or "."), os.lstat(path)
57
+ if (
58
+ not stat.S_ISDIR(directory.st_mode) or directory.st_uid not in (os.getuid(), 0) or directory.st_mode & 0o022
59
+ or not stat.S_ISSOCK(sock.st_mode) or sock.st_uid != os.getuid() or sock.st_mode & 0o022
60
+ ):
61
+ raise CredentialError(
62
+ f"Refusing the sign-in socket {path}: it must belong to you, in a directory nobody else can write to."
63
+ )
64
+
65
+
66
+ def _connect(path: str) -> socket.socket:
67
+ if not hasattr(socket, "AF_UNIX"):
68
+ raise CredentialError("Forwarded sign-in needs a Unix socket; it is meant for Linux dev containers.")
69
+ if hasattr(os, "getuid"):
70
+ _check_owner(path)
71
+ connection = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
72
+ connection.settimeout(TIMEOUT_SECONDS)
73
+ connection.connect(path)
74
+ return connection
75
+
76
+
77
+ def request(operation: str, host_profile: str | None, path: str,
78
+ connect: Callable[[str], socket.socket] | None = None) -> dict:
79
+ """Ask the local machine through the VS Code extension; raise CredentialError with its message on failure."""
80
+ try:
81
+ connection = (connect or _connect)(path)
82
+ except (OSError, CredentialError) as error:
83
+ if isinstance(error, CredentialError):
84
+ raise
85
+ raise CredentialError(
86
+ "No connection to the sign-in on your local machine. Open this folder in VS Code with the "
87
+ "'Virtual Key Mapper' extension in this container and 'Virtual Key Mapper: local sign-in' in your local VS Code."
88
+ ) from None
89
+ with connection:
90
+ try:
91
+ message = {"version": 1, "operation": operation, "profile": host_profile or ""}
92
+ connection.sendall(json.dumps(message).encode() + b"\n")
93
+ raw = b""
94
+ while not raw.endswith(b"\n") and len(raw) <= _MAX_ANSWER:
95
+ chunk = connection.recv(65536)
96
+ if not chunk:
97
+ break
98
+ raw += chunk
99
+ except OSError:
100
+ raise CredentialError("The connection to your local sign-in failed; retry.") from None
101
+ try:
102
+ answer = json.loads(raw)
103
+ except (ValueError, RecursionError):
104
+ raise CredentialError("Your local sign-in sent an unreadable answer; update both VS Code extensions.") from None
105
+ if not isinstance(answer, dict) or answer.get("ok") not in (True, False):
106
+ raise CredentialError("Your local sign-in sent an unexpected answer; update both VS Code extensions.")
107
+ if not answer["ok"]:
108
+ error = answer.get("error")
109
+ raise CredentialError(f"Local sign-in: {error if isinstance(error, str) else 'request failed'}")
110
+ if not isinstance(answer.get("result"), dict):
111
+ raise CredentialError("Your local sign-in sent an unexpected answer; update both VS Code extensions.")
112
+ return answer["result"]
113
+
114
+
115
+ class Forwarded:
116
+ """Credentials from the local machine's sign-in, relayed by VS Code; nothing is stored here."""
117
+
118
+ def __init__(self, profile: Profile, store: ProfileStore, secrets: Callable[[], object] | None = None,
119
+ connect: Callable[[str], socket.socket] | None = None) -> None:
120
+ # ``secrets`` (the vault factory) is never called: nothing is stored on this side.
121
+ self.profile = profile
122
+ self.connect = connect
123
+
124
+ def _request(self, operation: str) -> dict:
125
+ return request(operation, self.profile.auth.get("host_profile"), agent_path(), self.connect or _connect)
126
+
127
+ def login(self, notify: Callable[[str], None], **options) -> Credential:
128
+ raise CredentialError(
129
+ f"Profile '{self.profile.name}' uses the sign-in of your local machine: run 'vkm login' "
130
+ "there, in a local terminal."
131
+ )
132
+
133
+ def logout(self, notify: Callable[[str], None]) -> None:
134
+ raise CredentialError(
135
+ f"Profile '{self.profile.name}' uses the sign-in of your local machine: run 'vkm logout' there."
136
+ )
137
+
138
+ def status(self) -> dict:
139
+ base = {"profile": self.profile.name, "gateway": self.profile.gateway, "auth": "forwarded", "agent": agent_path()}
140
+ try:
141
+ credential = self.credential()
142
+ except CredentialError as error:
143
+ return {**base, "state": "unavailable", "error": str(error)}
144
+ return {**base, "state": "ready", "user_id": credential.user, "expires_at": credential.expires_at}
145
+
146
+ def credential(self) -> Credential:
147
+ result = self._request("token")
148
+ token, expires_at, user = result.get("access_token"), result.get("expires_at"), result.get("user")
149
+ if (
150
+ not isinstance(token, str) or not token or any(ord(char) < 33 for char in token)
151
+ or not (expires_at is None or type(expires_at) in (int, float))
152
+ or not (user is None or isinstance(user, str))
153
+ ):
154
+ raise CredentialError("Your local sign-in returned an invalid token.")
155
+ return Credential(token, expires_at, user)