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
5 changes: 3 additions & 2 deletions PROTOCOL.md
Original file line number Diff line number Diff line change
Expand Up @@ -254,8 +254,9 @@ Otherwise JSON snapshot:

Cookie session after GitHub OAuth.

- `GET /auth/me` — login, visible logins, teams. GitHub OAuth is sign-in only (`read:user`); the website never calls GitHub for dashboard data.
- `GET /api/state` — materialized rows the caller may see (sessions include `can_control` and `control_connected`). The signed-in website shows only this replica. Replica arrays are newest-first (`updated_at` descending, then `row_id`). `devices` is newest-first (`created_at` descending, then `id`).
- `GET /auth/me` — login, visible logins, teams. GitHub OAuth is sign-in and PR-search (`read:user repo`); the token is not in the cookie.
- `GET /api/prs` — open GitHub pull requests authored or assigned to visible logins. Search uses the signed-in user's OAuth token (scope `read:user repo`), stored in hub sqlite (`oauth_token`) and memory, not in the cookie. `source` is `github` or `none` (signed in, but no token — sign in again). Columns: author, org, repo, number, title, status (`open`|`draft`), url. `truncated` is true when 50 rows were returned.
- `GET /api/state` — materialized rows the caller may see (sessions include `can_control` and `control_connected`). The signed-in website leads with `/api/prs`, then replica tables from `/api/state`. Replica arrays are newest-first (`updated_at` descending, then `row_id`). `devices` is newest-first (`created_at` descending, then `id`).
- `GET /api/sessions/{id}` — one visible session plus control flags.
- `POST /api/sessions/{id}/control` — start / stop / input / resize (owner device only).
- `GET /api/sessions/{id}/terminal` — JSON ring snapshot or SSE terminal stream.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Image builds on `develop` / `main` push `dfxswiss/agent-core:beta` / `:latest` a

## Run locally

Create a GitHub OAuth App whose callback is `http://127.0.0.1:8787/auth/github/callback`. The hub requests scope `read:user` so people can sign in. The website shows only the local hub replica — sessions, tasks, agents, pings, devices, usage snapshots. Then:
Create a GitHub OAuth App whose callback is `http://127.0.0.1:8787/auth/github/callback`. The hub requests scope `read:user repo` so people can sign in and the website can list open pull requests. After sign-in the website leads with open PRs from GitHub (`GET /api/prs`); usage, sessions, tasks, agents, pings, and devices come from the local hub replica. Then:

```bash
python3 -m venv .venv
Expand Down
35 changes: 35 additions & 0 deletions src/agent_core/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ def __init__(self, cfg: Config, github: GitHub, store: Store, teams: dict[str, l
self.control_ready: dict[str, asyncio.Queue[dict[str, Any]]] = {}
self.terminal_rings: dict[str, list[dict[str, Any]]] = {}
self.terminal_queues: list[tuple[str, str, asyncio.Queue[dict[str, Any]]]] = []
self.github_tokens: dict[str, str] = {}
for login, token in self.store.all_oauth_tokens():
self.github_tokens[login] = token

def visible(self, login: str) -> set[str]:
return visible_logins(login, self.teams)
Expand Down Expand Up @@ -185,13 +188,20 @@ def auth_callback(request: Request, code: str | None = None, state: str | None =
raise HTTPException(status_code=401, detail=str(exc)) from exc
request.session.pop("oauth_state", None)
request.session["login"] = user.login
if user.token:
hub.github_tokens[user.login] = user.token
hub.store.put_oauth_token(user.login, user.token)
dest = request.session.pop("after_login", "/")
if not isinstance(dest, str) or not dest.startswith("/"):
dest = "/"
return RedirectResponse(dest, status_code=302)

@app.post("/auth/logout")
def auth_logout(request: Request) -> JSONResponse:
login = request.session.get("login")
if isinstance(login, str) and login:
hub.store.delete_oauth_token(login)
hub.github_tokens.pop(login, None)
request.session.clear()
return JSONResponse({"ok": True})

Expand Down Expand Up @@ -386,6 +396,31 @@ def sync_query(request: Request, body: dict[str, Any]) -> dict[str, Any]:
match = _validate_match(body["match"])
return {"rows": _query_matching_rows(hub, device["github_login"], match)}

@app.get("/api/prs")
def api_prs(request: Request) -> dict[str, Any]:
login = hub.session_login(request)
allowed = sorted(hub.visible(login))
token = hub.github_tokens.get(login, "")
if token == "":
token = hub.store.get_oauth_token(login)
if token:
hub.github_tokens[login] = token
if token == "":
return {"generated_at": utcnow(), "prs": [], "source": "none"}
failed = token
try:
prs = hub.github.search_open_prs(token, allowed)
except GitHubError as exc:
text = str(exc)
if "HTTP 401" in text:
hub.store.delete_oauth_token(login, failed)
if hub.github_tokens.get(login) == failed:
hub.github_tokens.pop(login, None)
return {"generated_at": utcnow(), "prs": [], "source": "none"}
raise HTTPException(status_code=502, detail=text) from exc
truncated = len(prs) >= 50
return {"generated_at": utcnow(), "prs": prs, "source": "github", "truncated": truncated}

@app.get("/api/state")
def api_state(request: Request) -> dict[str, Any]:
login = hub.session_login(request)
Expand Down
41 changes: 40 additions & 1 deletion src/agent_core/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,12 @@
FOREIGN KEY (device_id) REFERENCES device(id)
);

CREATE TABLE IF NOT EXISTS oauth_token (
github_login TEXT PRIMARY KEY,
token TEXT NOT NULL,
updated_at TEXT NOT NULL
);

CREATE INDEX IF NOT EXISTS event_origin_idx ON ledger_event (origin_device_id, origin_seq);
CREATE INDEX IF NOT EXISTS replica_login_idx ON row_replica (github_login, table_name);
CREATE INDEX IF NOT EXISTS device_login_idx ON device (github_login);
Expand All @@ -77,7 +83,6 @@ def __init__(self, path: str) -> None:
self._conn.execute("PRAGMA foreign_keys = ON")
self._conn.execute("PRAGMA journal_mode = WAL")
self._conn.executescript(SCHEMA)
self._conn.execute("DROP TABLE IF EXISTS oauth_token")
self._conn.commit()

def close(self) -> None:
Expand Down Expand Up @@ -140,6 +145,40 @@ def replace_device_subscriptions(self, device_id: str, match_jsons: list[str]) -
self._conn.rollback()
raise

def put_oauth_token(self, github_login: str, token: str) -> None:
self.execute(
"INSERT INTO oauth_token (github_login, token, updated_at) VALUES (?, ?, ?) "
"ON CONFLICT(github_login) DO UPDATE SET token = excluded.token, updated_at = excluded.updated_at",
(github_login, token, utcnow()),
)

def get_oauth_token(self, github_login: str) -> str:
row = self.query_one("SELECT token FROM oauth_token WHERE github_login = ?", (github_login,))
if row is None:
return ""
token = row["token"]
return token if isinstance(token, str) else ""

def delete_oauth_token(self, github_login: str, token: str | None = None) -> None:
if token is None:
self.execute("DELETE FROM oauth_token WHERE github_login = ?", (github_login,))
return
self.execute(
"DELETE FROM oauth_token WHERE github_login = ? AND token = ?",
(github_login, token),
)

def all_oauth_tokens(self) -> list[tuple[str, str]]:
rows = self.query("SELECT github_login, token FROM oauth_token")
out: list[tuple[str, str]] = []
for row in rows:
login = row["github_login"]
token = row["token"]
if isinstance(login, str) and isinstance(token, str) and login and token:
out.append((login, token))
return out


def row_dict(row: sqlite3.Row) -> dict[str, Any]:
return {k: row[k] for k in row.keys()}

Expand Down
111 changes: 105 additions & 6 deletions src/agent_core/github.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
"""GitHub OAuth for hub sign-in. Tests inject FakeGitHub."""
"""GitHub OAuth and open-PR search. Tests inject FakeGitHub."""

from __future__ import annotations

from dataclasses import dataclass
from urllib.parse import urlencode
from typing import Any
from urllib.parse import urlencode, urlparse

import httpx

from .config import Config

GITHUB_SEARCH_URL = "https://api.github.com/search/issues"


class GitHubError(ValueError):
pass
Expand All @@ -17,6 +20,7 @@ class GitHubError(ValueError):
@dataclass(frozen=True)
class GitHubUser:
login: str
token: str = ""


class GitHub:
Expand All @@ -26,6 +30,38 @@ def authorize_url(self, state: str, redirect_uri: str) -> str:
def login_for_code(self, code: str) -> GitHubUser:
raise NotImplementedError

def search_open_prs(self, token: str, logins: list[str]) -> list[dict[str, Any]]:
raise NotImplementedError


def _pr_from_search_item(item: dict[str, Any]) -> dict[str, Any] | None:
html = item.get("html_url")
if not isinstance(html, str) or "/pull/" not in html:
return None
path = urlparse(html).path.strip("/").split("/")
# owner/repo/pull/N
if len(path) < 4 or path[2] != "pull":
return None
org, repo, _, num = path[0], path[1], path[2], path[3]
if not num.isdigit():
return None
user = item.get("user") if isinstance(item.get("user"), dict) else {}
author = user.get("login") if isinstance(user, dict) else ""
if not isinstance(author, str) or author == "":
author = ""
title = item.get("title") if isinstance(item.get("title"), str) else ""
draft = bool(item.get("draft"))
status = "draft" if draft else (item.get("state") if isinstance(item.get("state"), str) else "open")
return {
"author": author.lower(),
"org": org,
"repo": repo,
"number": int(num),
"title": title,
"status": status,
"url": html,
}


class RealGitHub(GitHub):
def __init__(self, cfg: Config) -> None:
Expand All @@ -36,7 +72,7 @@ def authorize_url(self, state: str, redirect_uri: str) -> str:
{
"client_id": self._cfg.github_client_id,
"redirect_uri": redirect_uri,
"scope": "read:user",
"scope": "read:user repo",
"state": state,
}
)
Expand Down Expand Up @@ -71,21 +107,84 @@ def login_for_code(self, code: str) -> GitHubUser:
login = user_resp.json().get("login")
if not isinstance(login, str) or login.strip() == "":
raise GitHubError("user lookup returned no login")
return GitHubUser(login=login.strip().lower())
return GitHubUser(login=login.strip().lower(), token=access)

def search_open_prs(self, token: str, logins: list[str]) -> list[dict[str, Any]]:
if token == "" or not logins:
return []
unique = []
seen: set[str] = set()
for login in logins:
low = login.strip().lower()
if low == "" or low in seen:
continue
seen.add(low)
unique.append(low)
clauses = []
for login in unique:
clauses.append(f"author:{login}")
clauses.append(f"assignee:{login}")
q = "is:pr is:open (" + " OR ".join(clauses) + ")"
resp = httpx.get(
GITHUB_SEARCH_URL,
params={"q": q, "sort": "updated", "order": "desc", "per_page": 50},
headers={
"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
timeout=20.0,
)
if resp.status_code != 200:
raise GitHubError(f"PR search failed: HTTP {resp.status_code}")
body = resp.json()
items = body.get("items") if isinstance(body, dict) else None
if not isinstance(items, list):
raise GitHubError("PR search failed: HTTP 200")
out: list[dict[str, Any]] = []
seen_keys: set[tuple[str, str, int]] = set()
for item in items:
if not isinstance(item, dict):
continue
row = _pr_from_search_item(item)
if row is None:
continue
key = (row["org"], row["repo"], row["number"])
if key in seen_keys:
continue
seen_keys.add(key)
out.append(row)
return out


class FakeGitHub(GitHub):
"""Maps authorization codes to logins. Used only by tests."""

def __init__(self, codes: dict[str, str]) -> None:
def __init__(self, codes: dict[str, str], prs: list[dict[str, Any]] | None = None) -> None:
if not codes:
raise GitHubError("FakeGitHub requires at least one code")
self._codes = {k: v.strip().lower() for k, v in codes.items()}
self._prs = list(prs or [])
self.search_status = 200

def authorize_url(self, state: str, redirect_uri: str) -> str:
return f"https://github.test/login/oauth/authorize?state={state}&redirect_uri={redirect_uri}"

def login_for_code(self, code: str) -> GitHubUser:
if code not in self._codes:
raise GitHubError(f"unknown authorization code: {code}")
return GitHubUser(login=self._codes[code])
login = self._codes[code]
return GitHubUser(login=login, token="tok-" + login)

def search_open_prs(self, token: str, logins: list[str]) -> list[dict[str, Any]]:
if self.search_status in (401, 403):
raise GitHubError(f"PR search failed: HTTP {self.search_status}")
if token == "" or not token.startswith("tok-"):
return []
allowed = {m.strip().lower() for m in logins}
return [
p
for p in self._prs
if str(p.get("author", "")).lower() in allowed
or str(p.get("assignee", "")).lower() in allowed
]
Loading
Loading