Skip to content
Closed
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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ uvx codex-lb

Open [localhost:2455](http://localhost:2455) → Add account → Done.

## Upstream Sync (Fork-safe)

If you run custom features in your fork, follow [SYNC_RUNBOOK.md](SYNC_RUNBOOK.md) to merge upstream updates without losing custom behavior.

## Client Setup

Point any OpenAI-compatible client at codex-lb. If [API key auth](#api-key-authentication) is enabled, pass a key from the dashboard as a Bearer token.
Expand Down
150 changes: 150 additions & 0 deletions SYNC_RUNBOOK.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
# Sync Runbook (Fork + Upstream, No Feature Loss)

This runbook keeps custom codex-lb features safe while regularly ingesting upstream updates.

## Branch and Remote Model

- `origin` = your fork (production source of truth)
- `upstream` = official project (`Soju06/codex-lb`)
- `main` = stable branch used for deploy/promote
- `codex/sync-upstream-YYYYMMDD` = temporary integration branch
- `codex/feature-*` = custom feature branches

Verify once per clone:

```bash
git remote -v
# Expect:
# origin git@github.com:<you>/codex-lb.git
# upstream https://github.com/Soju06/codex-lb.git
```

## Rules (Hard Requirements)

1. Never hard-reset `main` to upstream.
2. Never deploy untested merge results to main.
3. Always test in canary first, then promote.
4. Keep custom behavior in small, labeled commits (`[custom] ...`).
5. Keep a must-keep list current (see [Custom Features Checklist](#custom-features-checklist)).

## Standard Sync Procedure

### 1) Prepare

```bash
git checkout main
git pull --ff-only origin main
git fetch upstream
```

### 2) Create sync branch

```bash
SYNC_BRANCH="codex/sync-upstream-$(date +%Y%m%d)"
git checkout -b "$SYNC_BRANCH"
```

### 3) Merge upstream

```bash
git merge upstream/main
```

If conflicts happen:

- Resolve conflict-by-conflict.
- Preserve required custom behavior.
- Do not accept upstream version blindly for custom files.

### 4) Validate locally

```bash
# Python app
uv sync
uv run pytest

# Frontend (if touched)
cd frontend
bun install
bun run build
cd ..
```

If tests are partial, document exactly what was run and what was skipped.

### 5) Canary deploy and smoke test

Deploy sync branch to canary only.

Minimum smoke matrix:

1. `GET /health`
2. `POST /v1/responses` (store true/false)
3. `POST /v1/embeddings`
4. dashboard load + settings save
5. force-model behavior visible in logs
6. actor logging (ip/app/api key) visible in logs
7. Home Assistant plugin (`codex_lb.generate`, `codex_lb.generate_data`)

### 6) Merge to fork main only after canary pass

```bash
git checkout main
git merge --no-ff "$SYNC_BRANCH"
git push origin main
```

### 7) Promote runtime (no downtime flow)

- Keep main container running.
- Promote via tested canary image/container handoff.
- Validate endpoints immediately post-promotion.

## Emergency Rollback

If regression appears after promotion:

1. Repoint traffic to last known-good image/container.
2. Revert merge commit on `main`:

```bash
git checkout main
git log --oneline -n 20
# identify bad merge commit SHA

git revert -m 1 <merge_sha>
git push origin main
```

3. Redeploy previous good revision.
4. Open follow-up fix branch from current `main`.

## Custom Features Checklist

Before accepting an upstream sync, explicitly verify these remain intact:

- Round-robin account routing behavior.
- Force-model override rules (global + per actor) and reasoning effort enforcement.
- Actor logging (ip/app/api key/model) in logs/dashboard.
- Store/chaining compatibility layer (`store=true`, `previous_response_id`).
- Embeddings routing and health.
- Home Assistant integration behavior.

Keep this checklist updated whenever new custom behavior is added.

## PR Policy

When syncing upstream:

1. Push sync branch to fork.
2. Open PR in fork first (`sync branch -> main`).
3. Require at least one canary evidence comment (commands + results).
4. Merge only after checklist is fully green.

## Team Conventions

- Use branch names with `codex/` prefix.
- Keep commits scoped and descriptive.
- Prefer non-interactive git commands.
- Never force-push shared `main`.

1 change: 1 addition & 0 deletions app/modules/oauth/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ class OauthStatusResponse(DashboardModel):
class OauthCompleteRequest(DashboardModel):
device_auth_id: str | None = None
user_code: str | None = None
callback_url: str | None = None


class OauthCompleteResponse(DashboardModel):
Expand Down
43 changes: 43 additions & 0 deletions app/modules/oauth/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from dataclasses import dataclass
from pathlib import Path
from typing import Awaitable, Callable
from urllib.parse import parse_qs, urlparse

from aiohttp import web

Expand Down Expand Up @@ -154,6 +155,8 @@ async def oauth_status(self) -> OauthStatusResponse:

async def complete_oauth(self, request: OauthCompleteRequest | None = None) -> OauthCompleteResponse:
payload = request or OauthCompleteRequest()
if payload.callback_url:
return await self._complete_browser_with_callback_url(payload.callback_url)
async with self._store.lock:
state = self._store.state
if payload.device_auth_id:
Expand Down Expand Up @@ -182,6 +185,38 @@ async def complete_oauth(self, request: OauthCompleteRequest | None = None) -> O
state.poll_task = asyncio.create_task(self._poll_device_tokens(poll_context))
return OauthCompleteResponse(status="pending")

async def _complete_browser_with_callback_url(self, callback_url: str) -> OauthCompleteResponse:
code, state_token = _extract_code_state_from_callback_url(callback_url)
if not code or not state_token:
await self._set_error("Invalid callback URL. Missing code/state.")
return OauthCompleteResponse(status="error")

async with self._store.lock:
expected_state = self._store.state.state_token
verifier = self._store.state.code_verifier
method = self._store.state.method

if method != "browser":
await self._set_error("Browser PKCE flow is not initialized.")
return OauthCompleteResponse(status="error")

if not verifier or not expected_state or state_token != expected_state:
await self._set_error("Invalid OAuth callback state.")
return OauthCompleteResponse(status="error")

try:
tokens = await exchange_authorization_code(code=code, code_verifier=verifier)
await self._persist_tokens(tokens)
await self._set_success()
asyncio.create_task(self._stop_callback_server())
return OauthCompleteResponse(status="success")
except OAuthError as exc:
await self._set_error(exc.message)
return OauthCompleteResponse(status="error")
except AccountIdentityConflictError as exc:
await self._set_error(str(exc))
return OauthCompleteResponse(status="error")

async def _start_browser_flow(self) -> OauthStartResponse:
await self._store.reset()
code_verifier, code_challenge = generate_pkce_pair()
Expand Down Expand Up @@ -364,3 +399,11 @@ def _success_html() -> str:

def _error_html(message: str) -> str:
return f"<html><body><h1>Login failed</h1><p>{message}</p></body></html>"


def _extract_code_state_from_callback_url(callback_url: str) -> tuple[str | None, str | None]:
parsed = urlparse(callback_url.strip())
values = parse_qs(parsed.query)
code = values.get("code", [None])[0]
state = values.get("state", [None])[0]
return code, state
4 changes: 2 additions & 2 deletions frontend/src/features/accounts/components/accounts-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -120,8 +120,8 @@ export function AccountsPage() {
onStart={async (method) => {
await oauth.start(method);
}}
onComplete={async () => {
await oauth.complete();
onComplete={async (callbackUrl?: string) => {
await oauth.complete(callbackUrl);
await accountsQuery.refetch();
}}
onReset={oauth.reset}
Expand Down
58 changes: 58 additions & 0 deletions frontend/src/features/accounts/components/oauth-dialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,64 @@ describe("OauthDialog", () => {
expect(screen.getByRole("button", { name: "Change method" })).toBeInTheDocument();
});

it("allows manual callback completion in browser stage", async () => {
const user = userEvent.setup();
const onComplete = vi.fn().mockResolvedValue(undefined);
const browserPendingState = {
...idleState,
status: "pending" as const,
method: "browser" as const,
authorizationUrl: "https://auth.example.com/start",
};

render(
<OauthDialog
open
state={browserPendingState}
onOpenChange={vi.fn()}
onStart={vi.fn().mockResolvedValue(undefined)}
onComplete={onComplete}
onReset={vi.fn()}
/>,
);

const input = screen.getByPlaceholderText(
"http://127.0.0.1:1455/auth/callback?code=...&state=...",
);
await user.type(input, "http://127.0.0.1:1455/auth/callback?code=abc&state=xyz");

await user.click(screen.getByRole("button", { name: "Complete with callback URL" }));
expect(onComplete).toHaveBeenCalledWith(
"http://127.0.0.1:1455/auth/callback?code=abc&state=xyz",
);
});

it("shows validation error when manual callback submit is empty", async () => {
const user = userEvent.setup();
const onComplete = vi.fn().mockResolvedValue(undefined);
const browserPendingState = {
...idleState,
status: "pending" as const,
method: "browser" as const,
authorizationUrl: "https://auth.example.com/start",
};

render(
<OauthDialog
open
state={browserPendingState}
onOpenChange={vi.fn()}
onStart={vi.fn().mockResolvedValue(undefined)}
onComplete={onComplete}
onReset={vi.fn()}
/>,
);

await user.click(screen.getByRole("button", { name: "Complete with callback URL" }));
expect(screen.getByText("Paste the callback URL first.")).toBeInTheDocument();
expect(onComplete).not.toHaveBeenCalled();
});

it("renders success stage", () => {
render(
<OauthDialog
Expand Down
Loading