headroom-kit 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.
- headroom_kit/__init__.py +1 -0
- headroom_kit/__main__.py +5 -0
- headroom_kit/_owner.py +14 -0
- headroom_kit/_serve.py +8 -0
- headroom_kit/cli.py +190 -0
- headroom_kit/copilot.py +156 -0
- headroom_kit/proxy.py +479 -0
- headroom_kit/resources/opencode-plugin/index.js +48 -0
- headroom_kit/resources/opencode-plugin/package.json +1 -0
- headroom_kit/resources/pi-extension.mjs +31 -0
- headroom_kit/runtime.py +236 -0
- headroom_kit/session.py +410 -0
- headroom_kit-0.1.0.dist-info/METADATA +127 -0
- headroom_kit-0.1.0.dist-info/RECORD +18 -0
- headroom_kit-0.1.0.dist-info/WHEEL +4 -0
- headroom_kit-0.1.0.dist-info/entry_points.txt +2 -0
- headroom_kit-0.1.0.dist-info/licenses/LICENSE +21 -0
- headroom_kit-0.1.0.dist-info/licenses/NOTICE.md +6 -0
headroom_kit/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Launch existing coding agents through a local Headroom proxy."""
|
headroom_kit/__main__.py
ADDED
headroom_kit/_owner.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Private detached owner. Not a console script."""
|
|
2
|
+
|
|
3
|
+
import signal
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from headroom_kit.proxy import owner_main
|
|
7
|
+
from headroom_kit.runtime import stop
|
|
8
|
+
|
|
9
|
+
if __name__ == "__main__":
|
|
10
|
+
# The owner is a new session. Without these, SIGTERM/SIGHUP skip cleanup
|
|
11
|
+
# and the proxy child keeps the port and control socket.
|
|
12
|
+
for sig in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP):
|
|
13
|
+
signal.signal(sig, stop)
|
|
14
|
+
sys.exit(owner_main())
|
headroom_kit/_serve.py
ADDED
headroom_kit/cli.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Public command surface for the installed Headroom Kit package."""
|
|
2
|
+
|
|
3
|
+
import importlib.metadata
|
|
4
|
+
import signal
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
|
|
9
|
+
from headroom_kit.copilot import run_copilot_auth
|
|
10
|
+
from headroom_kit.proxy import management
|
|
11
|
+
from headroom_kit.runtime import (
|
|
12
|
+
KitError,
|
|
13
|
+
executable,
|
|
14
|
+
installed_headroom_version,
|
|
15
|
+
load_config,
|
|
16
|
+
merge_config,
|
|
17
|
+
read_config,
|
|
18
|
+
run_agent,
|
|
19
|
+
say,
|
|
20
|
+
stop,
|
|
21
|
+
validate,
|
|
22
|
+
)
|
|
23
|
+
from headroom_kit.session import preflight, session
|
|
24
|
+
|
|
25
|
+
AGENTS = {
|
|
26
|
+
"codex": "codex-headroom",
|
|
27
|
+
"codex-app": "codex-app-headroom",
|
|
28
|
+
"copilot": "copilot-headroom",
|
|
29
|
+
"copilot-vscode": "copilot-vscode-headroom",
|
|
30
|
+
"pi": "pi-headroom",
|
|
31
|
+
"opencode": "opencode-headroom",
|
|
32
|
+
}
|
|
33
|
+
CLI_AGENTS = ("codex-headroom", "copilot-headroom", "pi-headroom", "opencode-headroom")
|
|
34
|
+
GUI_AGENTS = ("codex-app-headroom", "copilot-vscode-headroom")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
38
|
+
for sig in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP):
|
|
39
|
+
signal.signal(sig, stop)
|
|
40
|
+
try:
|
|
41
|
+
return dispatch(list(sys.argv[1:] if argv is None else argv))
|
|
42
|
+
except KitError as error:
|
|
43
|
+
say(str(error))
|
|
44
|
+
except (OSError, subprocess.SubprocessError):
|
|
45
|
+
say("A local process or file operation failed. Check paths, permissions, and disk space.")
|
|
46
|
+
return 1
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def dispatch(argv: list[str]) -> int:
|
|
50
|
+
if not argv or argv == ["--help"] or argv == ["-h"]:
|
|
51
|
+
print_help()
|
|
52
|
+
return 0 if argv else 2
|
|
53
|
+
if argv == ["--version"]:
|
|
54
|
+
return print_version()
|
|
55
|
+
config, rest = take_config(argv)
|
|
56
|
+
if not rest:
|
|
57
|
+
print_help()
|
|
58
|
+
return 2
|
|
59
|
+
command, *args = rest
|
|
60
|
+
if command == "copilot-auth":
|
|
61
|
+
return run_copilot_auth(args)
|
|
62
|
+
if command == "status":
|
|
63
|
+
return status(args)
|
|
64
|
+
if command == "stop":
|
|
65
|
+
return management(["stop", *args])
|
|
66
|
+
if command == "run":
|
|
67
|
+
return run(config, args)
|
|
68
|
+
if command in ("--help", "-h"):
|
|
69
|
+
print_help()
|
|
70
|
+
return 0
|
|
71
|
+
raise KitError(f"Unknown command {command!r}. Run headroom-kit --help.")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def take_config(argv: list[str]) -> tuple[str | None, list[str]]:
|
|
75
|
+
if not argv or argv[0] != "--config":
|
|
76
|
+
return None, argv
|
|
77
|
+
if len(argv) < 2 or not argv[1] or argv[1].startswith("-"):
|
|
78
|
+
raise KitError("--config requires a path to a JSON object.")
|
|
79
|
+
return argv[1], argv[2:]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def status(args: list[str]) -> int:
|
|
83
|
+
if args:
|
|
84
|
+
raise KitError("status does not take arguments. Run headroom-kit status.")
|
|
85
|
+
return management(["status"])
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def run(config: str | None, args: list[str]) -> int:
|
|
89
|
+
if not args or args[0] in ("--help", "-h"):
|
|
90
|
+
print_run_help()
|
|
91
|
+
return 0
|
|
92
|
+
agent, *rest = args
|
|
93
|
+
command = AGENTS.get(agent)
|
|
94
|
+
if command is None:
|
|
95
|
+
raise KitError(
|
|
96
|
+
f"Unknown agent {agent!r}. Choose {', '.join(AGENTS)}. Put agent arguments after --."
|
|
97
|
+
)
|
|
98
|
+
agent_args = agent_arguments(rest)
|
|
99
|
+
if forwards_help(command, agent_args):
|
|
100
|
+
return forward_agent(config, command, agent_args)
|
|
101
|
+
cfg = load_config(config)
|
|
102
|
+
validate(cfg)
|
|
103
|
+
return session(cfg, command, agent_args, installed_headroom_version())
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def agent_arguments(args: list[str]) -> list[str]:
|
|
107
|
+
if not args or args[0] != "--":
|
|
108
|
+
raise KitError("Put agent arguments after --. Example: headroom-kit run codex --")
|
|
109
|
+
return args[1:]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def forwards_help(command: str, args: list[str]) -> bool:
|
|
113
|
+
if command in GUI_AGENTS and args in (["--help"], ["-h"]):
|
|
114
|
+
return True
|
|
115
|
+
options = args[: args.index("--")] if "--" in args else args
|
|
116
|
+
return command in CLI_AGENTS and any(is_help_option(command, arg) for arg in options)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def is_help_option(command: str, arg: str) -> bool:
|
|
120
|
+
return arg in ("--help", "-h", "--version", "-V") or (
|
|
121
|
+
arg == "-v" and command in ("pi-headroom", "opencode-headroom")
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def forward_agent(config: str | None, command: str, args: list[str]) -> int:
|
|
126
|
+
if command in GUI_AGENTS:
|
|
127
|
+
print_gui_help(command)
|
|
128
|
+
return 0
|
|
129
|
+
cfg = merge_config(read_config(config))
|
|
130
|
+
if command == "opencode-headroom":
|
|
131
|
+
binary = executable(cfg["opencodeExecutable"], "HEADROOM_OPENCODE_EXECUTABLE")
|
|
132
|
+
return run_agent([binary, *args])
|
|
133
|
+
binary = preflight(cfg, command, args)
|
|
134
|
+
if not isinstance(binary, str):
|
|
135
|
+
raise KitError("The agent executable could not be resolved.")
|
|
136
|
+
return run_agent([binary, *args])
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def print_version() -> int:
|
|
140
|
+
print(f"headroom-kit {distribution_version('headroom-kit')}")
|
|
141
|
+
print(f"headroom-ai {distribution_version('headroom-ai')}")
|
|
142
|
+
return 0
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def distribution_version(name: str) -> str:
|
|
146
|
+
try:
|
|
147
|
+
return importlib.metadata.version(name)
|
|
148
|
+
except importlib.metadata.PackageNotFoundError:
|
|
149
|
+
return "not installed"
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def print_help() -> None:
|
|
153
|
+
print(
|
|
154
|
+
"""Usage:
|
|
155
|
+
headroom-kit [--config PATH] run <agent> -- <agent arguments...>
|
|
156
|
+
headroom-kit copilot-auth login
|
|
157
|
+
headroom-kit copilot-auth status
|
|
158
|
+
headroom-kit status
|
|
159
|
+
headroom-kit stop <port>
|
|
160
|
+
headroom-kit --help
|
|
161
|
+
headroom-kit --version
|
|
162
|
+
|
|
163
|
+
Agents: codex, codex-app, copilot, copilot-vscode, pi, opencode.
|
|
164
|
+
|
|
165
|
+
--config is a JSON object of non-secret integration defaults. HEADROOM_*
|
|
166
|
+
environment variables override the file, which overrides built-in defaults.
|
|
167
|
+
HEADROOM_VERSION and runtime-selection settings are rejected.
|
|
168
|
+
|
|
169
|
+
copilot-auth runs Headroom's Copilot login in this install. It does not
|
|
170
|
+
replace the headroom command and does not need a separate Headroom install.
|
|
171
|
+
status and stop do not need provider credentials or model downloads."""
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def print_run_help() -> None:
|
|
176
|
+
print("Usage: headroom-kit [--config PATH] run <agent> -- <agent arguments...>")
|
|
177
|
+
print("Agents: " + ", ".join(AGENTS))
|
|
178
|
+
print("Agent help skips the proxy: headroom-kit run codex -- --help")
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def print_gui_help(command: str) -> None:
|
|
182
|
+
if command == "copilot-vscode-headroom":
|
|
183
|
+
print("Usage: headroom-kit run copilot-vscode -- [path]")
|
|
184
|
+
else:
|
|
185
|
+
print("Usage: headroom-kit run codex-app --")
|
|
186
|
+
print("Launch with a shared local Headroom proxy. Use headroom-kit status/stop to manage it.")
|
|
187
|
+
print(
|
|
188
|
+
"Normal Codex and VS Code launches keep their existing settings. "
|
|
189
|
+
"Configure through HEADROOM_*."
|
|
190
|
+
)
|
headroom_kit/copilot.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Adapt Headroom's TLS and subscription auth without changing its installed files."""
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import os
|
|
5
|
+
import ssl
|
|
6
|
+
import urllib.error
|
|
7
|
+
import urllib.parse
|
|
8
|
+
import urllib.request
|
|
9
|
+
from collections.abc import Iterator
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from http.client import HTTPException, HTTPResponse
|
|
12
|
+
from types import ModuleType
|
|
13
|
+
|
|
14
|
+
from headroom_kit.runtime import KitError
|
|
15
|
+
|
|
16
|
+
LOGIN_COMMAND = "headroom-kit copilot-auth login"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class CopilotAuth:
|
|
21
|
+
api_url: str
|
|
22
|
+
refresh_oauth_token: str | None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def configure_urllib_tls() -> None:
|
|
26
|
+
from headroom.proxy import ssl_context
|
|
27
|
+
|
|
28
|
+
def build_context() -> ssl.SSLContext | None:
|
|
29
|
+
# Headroom 0.37.0 shares httpx's ALPN list with urllib, which cannot speak h2.
|
|
30
|
+
context = ssl_context.build_httpx_verify()
|
|
31
|
+
if isinstance(context, ssl.SSLContext):
|
|
32
|
+
context.set_alpn_protocols(["http/1.1"])
|
|
33
|
+
return context
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
ssl_context.build_urlopen_context = build_context
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def run_copilot_auth(args: list[str]) -> int:
|
|
40
|
+
"""Run Headroom's Copilot auth in this interpreter."""
|
|
41
|
+
configure_urllib_tls()
|
|
42
|
+
try:
|
|
43
|
+
from click import ClickException
|
|
44
|
+
from headroom.cli.copilot_auth import copilot_auth
|
|
45
|
+
except ImportError:
|
|
46
|
+
raise KitError(
|
|
47
|
+
"This Headroom release lacks the required Copilot subscription API. "
|
|
48
|
+
"Install headroom-ai 0.37.0 in this environment."
|
|
49
|
+
) from None
|
|
50
|
+
try:
|
|
51
|
+
code = copilot_auth.main(
|
|
52
|
+
args=args,
|
|
53
|
+
prog_name="headroom-kit copilot-auth",
|
|
54
|
+
standalone_mode=False,
|
|
55
|
+
)
|
|
56
|
+
except ClickException as exc:
|
|
57
|
+
exc.show()
|
|
58
|
+
return exc.exit_code
|
|
59
|
+
return 0 if code is None else int(code)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@contextlib.contextmanager
|
|
63
|
+
def auth_failures(adapter: ModuleType) -> Iterator[set[str]]:
|
|
64
|
+
upstream_urlopen = adapter._urlopen
|
|
65
|
+
failures: set[str] = set()
|
|
66
|
+
|
|
67
|
+
@contextlib.contextmanager
|
|
68
|
+
def urlopen(request: urllib.request.Request, *, timeout: float) -> Iterator[HTTPResponse]:
|
|
69
|
+
try:
|
|
70
|
+
with upstream_urlopen(request, timeout=timeout) as response:
|
|
71
|
+
yield response
|
|
72
|
+
except urllib.error.HTTPError as error:
|
|
73
|
+
if error.code not in (401, 403):
|
|
74
|
+
failures.add("service")
|
|
75
|
+
error.close()
|
|
76
|
+
# Upstream catches and logs exchange failures, including response bodies.
|
|
77
|
+
raise OSError("Copilot authorization HTTP request failed.") from None
|
|
78
|
+
except (OSError, HTTPException):
|
|
79
|
+
failures.add("transport")
|
|
80
|
+
raise OSError("Copilot authorization transport failed.") from None
|
|
81
|
+
|
|
82
|
+
adapter._urlopen = urlopen
|
|
83
|
+
try:
|
|
84
|
+
yield failures
|
|
85
|
+
finally:
|
|
86
|
+
adapter._urlopen = upstream_urlopen
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def copilot_auth() -> CopilotAuth:
|
|
90
|
+
# No upstream wrapper lifecycle: it can restart proxies or edit normal config.
|
|
91
|
+
try:
|
|
92
|
+
from headroom import copilot_auth as adapter
|
|
93
|
+
|
|
94
|
+
configure_urllib_tls()
|
|
95
|
+
except ImportError:
|
|
96
|
+
raise KitError(
|
|
97
|
+
"This Headroom release lacks the required Copilot subscription API. "
|
|
98
|
+
"Install headroom-ai 0.37.0 in this environment."
|
|
99
|
+
) from None
|
|
100
|
+
try:
|
|
101
|
+
with (
|
|
102
|
+
auth_failures(adapter) as failures,
|
|
103
|
+
open(os.devnull, "w") as sink,
|
|
104
|
+
contextlib.redirect_stdout(sink),
|
|
105
|
+
contextlib.redirect_stderr(sink),
|
|
106
|
+
):
|
|
107
|
+
resolution = adapter.resolve_subscription_bearer_token_details()
|
|
108
|
+
if resolution is None:
|
|
109
|
+
if failures:
|
|
110
|
+
failure = "transport" if "transport" in failures else "service"
|
|
111
|
+
raise KitError(
|
|
112
|
+
f"Copilot authorization {failure} failed. "
|
|
113
|
+
"Check network, proxy, CA settings, and GitHub service availability; retry. "
|
|
114
|
+
"This does not establish that your saved credentials were rejected."
|
|
115
|
+
)
|
|
116
|
+
raise ValueError
|
|
117
|
+
parsed = urllib.parse.urlsplit(resolution.api_url)
|
|
118
|
+
if (
|
|
119
|
+
parsed.scheme != "https"
|
|
120
|
+
or not (parsed.hostname or "").endswith(".githubcopilot.com")
|
|
121
|
+
or parsed.username
|
|
122
|
+
or parsed.password
|
|
123
|
+
or parsed.query
|
|
124
|
+
or parsed.fragment
|
|
125
|
+
):
|
|
126
|
+
raise ValueError
|
|
127
|
+
if not resolution.refresh_oauth_token:
|
|
128
|
+
raise ValueError
|
|
129
|
+
# Pin the reusable credential, not a rotating access token.
|
|
130
|
+
return CopilotAuth(
|
|
131
|
+
api_url=resolution.api_url,
|
|
132
|
+
refresh_oauth_token=resolution.refresh_oauth_token,
|
|
133
|
+
)
|
|
134
|
+
except (AttributeError, OSError, RuntimeError, TypeError, ValueError):
|
|
135
|
+
raise KitError(
|
|
136
|
+
"Copilot subscription authorization failed or its endpoint is unsupported. "
|
|
137
|
+
f"Run `{LOGIN_COMMAND}`, check your subscription, and retry. "
|
|
138
|
+
"Shared proxies require reusable OAuth and HTTPS *.githubcopilot.com upstreams."
|
|
139
|
+
) from None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def managed_copilot_auth() -> None:
|
|
143
|
+
from headroom import copilot_auth as adapter
|
|
144
|
+
|
|
145
|
+
configure_urllib_tls()
|
|
146
|
+
upstream_auth = adapter.apply_copilot_api_auth
|
|
147
|
+
|
|
148
|
+
async def authenticate(headers: dict[str, str], *, url: str) -> dict[str, str]:
|
|
149
|
+
# CLI placeholders and editor tokens both use this proxy's pinned account.
|
|
150
|
+
if adapter.is_copilot_upstream_url(url):
|
|
151
|
+
headers = {
|
|
152
|
+
k: v for k, v in headers.items() if k.lower() not in ("authorization", "x-api-key")
|
|
153
|
+
}
|
|
154
|
+
return await upstream_auth(headers, url=url)
|
|
155
|
+
|
|
156
|
+
adapter.apply_copilot_api_auth = authenticate
|