Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/changes.md
Original file line number Diff line number Diff line change
Expand Up @@ -851,6 +851,13 @@ sync` report a clear permission-denied error.

### Added

- **Forge proxy for git-forge OAuth tokens.** With `forge_proxy_hosts`
set, klangkd keeps a forge's OAuth tokens on the host and workspaces
reach the forge through `/forge-proxy/<host>/` with their workspace
token, so no forge token enters a container. Operations are enabled
explicitly in `forge_proxy_features` and repositories in
`forge_proxy_allowed_repos`; both refuse everything when empty.

- **Quiet Pi startup with on-demand `/header` (#3537).** The
per-user `~/.pi/agent/settings.json` provisioned at first workspace
login now sets `"quietStartup": true`, so the startup header
Expand Down
123 changes: 121 additions & 2 deletions features/git-credential/tools/git-credential-klangk
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,8 @@ def _absorb_line(cred, wwwauth, line):
return True
if key == "wwwauth[]":
wwwauth.append(value)
elif key == "capability[]":
cred.setdefault("capability", []).append(value)
else:
cred[key] = value
return True
Expand Down Expand Up @@ -1192,6 +1194,108 @@ def _pat_dialog_get(cred, browser_id):
return username, password, None


def _egress_netloc():
"""host:port of the egress endpoint (the bridge URL's netloc)."""
return urllib.parse.urlsplit(BRIDGE_URL).netloc.lower() if BRIDGE_URL else ""


def is_forge_proxy_request(cred):
"""True for git requests to the egress /forge-proxy/ route.

git rewrites https://<forge>/... to the egress URL (url.insteadOf),
so the credential request's host is the egress endpoint itself.
"""
netloc = _egress_netloc()
return (
bool(netloc)
and cred.get("host", "").lower() == netloc
and cred.get("path", "").startswith("forge-proxy/")
)


def forge_proxy_output(cred):
"""Bearer workspace-JWT answer for the forge proxy, or None.

The proxy authenticates the workspace; the forge token stays on the
host. Needs git's ``authtype`` capability (git >= 2.46).
"""
if "authtype" not in cred.get("capability", []):
return None
token = _get_workspace_token()
if not token:
return None
return "\n".join(
[
"capability[]=authtype",
"authtype=Bearer",
f"credential={token}",
"ephemeral=true",
]
)


def _post_egress_json(path, timeout=TIMEOUT):
"""POST/GET to an egress path with the workspace JWT; parsed JSON."""
req = urllib.request.Request(
f"{BRIDGE_URL}{path}",
data=b"{}" if path.endswith("/start") else None,
headers=_bridge_headers(),
method="POST" if path.endswith("/start") else "GET",
)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
return json.loads(resp.read())
except urllib.error.HTTPError as e:
try:
return json.loads(e.read() or b"null") or {"detail": f"HTTP {e.code}"}
except (json.JSONDecodeError, ValueError, OSError):
return {"detail": f"HTTP {e.code}"}
except (urllib.error.URLError, OSError, json.JSONDecodeError, ValueError) as e:
return {"detail": str(e)}


def _configure_forge_rewrite(host):
"""Route https://<host>/ through the egress forge proxy for git."""
proxy = f"{BRIDGE_URL}/forge-proxy/{host}/"
for args in (
[f"url.{proxy}.insteadOf", f"https://{host}/"],
# The path tells this helper a request is for the forge proxy.
[f"credential.{BRIDGE_URL}.useHttpPath", "true"],
):
subprocess.run(["git", "config", "--global", *args], check=False)


def run_forge_auth(host, browser_id):
"""Authorize this workspace for a proxied forge; the token stays on
the host. Returns a process exit code."""
host = _normalize_host(host)
status = _post_egress_json(f"/forge-proxy/{host}/_klangk/status")
if status.get("connected"):
_configure_forge_rewrite(host)
sys.stdout.write(f"already authorized for {host} as {status.get('login')}\n")
return 0
start = _post_egress_json(f"/forge-proxy/{host}/_klangk/oauth/start")
txn_id = start.get("txn_id")
if not txn_id:
sys.stderr.write(f"forge-auth: {start.get('detail', 'cannot start')}\n")
return 1
sys.stderr.write("Approve the authorization in the browser tab...\n")
resp = post_bridge(
"auth_flow_start",
{"protocol": "https", "host": host},
browser_id,
timeout=GET_TIMEOUT,
extra={"txn_id": txn_id},
)
answer = _unwrap_bridge_response(resp or {})
if answer.get("status") != "connected":
sys.stderr.write(f"forge-auth: {answer.get('error') or answer.get('detail') or 'not authorized'}\n")
return 1
_configure_forge_rewrite(host)
sys.stdout.write(f"authorized {host} as {answer.get('login')}; git and the API now go through the forge proxy\n")
return 0


def _run_get(cred, browser_id):
"""Serve a get operation: provider flow or PAT dialog, then the
git-credential protocol output."""
Expand All @@ -1210,12 +1314,27 @@ def main():
browser_id = _get_browser_id()
_log_startup(operation, browser_id)

if not _bridge_ready(browser_id):
sys.exit(1)
if operation == "forge-auth":
host = sys.argv[2] if len(sys.argv) > 2 else ""
if not host or not _bridge_ready(browser_id):
sys.stderr.write("usage: git-credential-klangk forge-auth <forge-host>\n")
sys.exit(2)
sys.exit(run_forge_auth(host, browser_id))

cred = read_credential_input()
_debug(f"cred input: {_redact(cred)}")

if is_forge_proxy_request(cred):
# Never a forge credential: the workspace JWT for the egress proxy.
if operation == "get":
out = forge_proxy_output(cred)
if out:
sys.stdout.write(out + "\n")
return

if not _bridge_ready(browser_id):
sys.exit(1)

if operation == "get":
_run_get(cred, browser_id)
elif operation in ("store", "erase"):
Expand Down
3 changes: 3 additions & 0 deletions src/klangk/klangk/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
admin as _admin_routes,
auth as _auth_routes,
browser_delegate as _browser_routes,
forge_proxy as _forge_proxy_routes,
llm_proxy as _llm_proxy_routes,
resources as _resources_routes,
workspaces as _workspace_routes,
Expand Down Expand Up @@ -454,6 +455,8 @@ async def my_permissions(
# LLM proxy routes live at /llm-proxy/ (outside /api/v1/) so they are
# mounted on root_router, not on the api-prefixed router. #2072
root_router.include_router(_llm_proxy_routes.router)
# Forge proxy routes live at /forge-proxy/ (container egress, workspace JWT).
root_router.include_router(_forge_proxy_routes.router)


__all__ = (
Expand Down
49 changes: 49 additions & 0 deletions src/klangk/klangk/api/browser_delegate.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
)
from pydantic import BaseModel

from .. import forge_proxy as forge_proxy_mod
from . import forge_proxy as forge_proxy_routes
from .common import get_app_dep
from .common import (
require_workspace_token,
Expand Down Expand Up @@ -83,6 +85,40 @@ def _resolve_bridge_target(
return session, target_sock, body.model_dump(exclude={"browser_id"})


async def _forge_relay(app, workspace_id, session, target_sock, payload):
"""Apply the forge proxy's bridge policy; None leaves the plain relay.

Proxied forges (forge_proxy.py): the container may only start a
klangkd-issued authorization; its code is exchanged here and never
relayed back, and nothing that could hand the container a forge
credential (its own authorize URL, a PAT prompt, a cache read) passes.
"""
forge = getattr(app.state, "forge_proxy", None)
policy = forge.bridge_policy(payload) if forge else "pass"
if policy == "refuse":
raise HTTPException(
status_code=403,
detail="This forge is proxied: run `git-credential-klangk forge-auth <host>`",
)
if policy != "txn":
return None

async def dispatch(request, timeout):
return await session.dispatch_browser_request_to(
target_sock, request, timeout=timeout
)

client = forge_proxy_routes.http_client_for(app)
try:
return await forge.relay_authorization(
workspace_id, payload, dispatch, client
)
except forge_proxy_mod.ForgeProxyError as exc:
raise HTTPException(status_code=exc.status, detail=exc.detail)
finally:
await client.aclose()


@router.post("/browser-delegate")
async def browser_delegate(
body: BrowserDelegateRequest,
Expand All @@ -99,6 +135,11 @@ async def browser_delegate(
session, target_sock, payload = _resolve_bridge_target(
body, app.state.container_registry, app.state.sockets, workspace_id
)
relayed = await _forge_relay(
app, workspace_id, session, target_sock, payload
)
if relayed is not None:
return relayed
# Credential get operations may wait for user interaction (PAT
# dialog or OAuth device flow) — allow up to 15 minutes (matching
# GitHub's device code expiry). The browser authorization flow
Expand Down Expand Up @@ -140,6 +181,14 @@ async def browser_delegate_stream(
session, target_sock, payload = _resolve_bridge_target(
body, app.state.container_registry, app.state.sockets, workspace_id
)
# Proxied-forge credential flows are never streamed: an authorization
# code or token must not reach the container through this path either.
forge = getattr(app.state, "forge_proxy", None)
if forge and forge.bridge_policy(payload) != "pass":
raise HTTPException(
status_code=403,
detail="This forge is proxied: run `git-credential-klangk forge-auth <host>`",
)
# Fetch the workspace so its settings.bridge_timeout override can apply.
# One DB lookup per stream request — these are not high-frequency
# (one per browser-delegated long-running action from the container).
Expand Down
Loading
Loading