Repository navigation
feat: enhance Nova local integration and authentication flow: #331
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,65 @@ | ||
| import { afterAll, beforeEach, describe, expect, spyOn, test } from "bun:test" | ||
|
|
||
| const requests: { url: string; init?: RequestInit }[] = [] | ||
| let responseStatus = 200 | ||
| let responseBody: unknown = {} | ||
| let networkFailure = false | ||
| const fetchMock = spyOn(globalThis, "fetch").mockImplementation(async (input, init) => { | ||
| requests.push({ url: String(input), init }) | ||
| if (networkFailure) throw new TypeError("Failed to fetch") | ||
| return Response.json(responseBody, { status: responseStatus }) | ||
| }) | ||
|
|
||
| // Import after installing fetch so Better Auth captures the mocked transport. | ||
| const { authClient } = await import("./auth-client") | ||
|
|
||
| afterAll(() => fetchMock.mockRestore()) | ||
| beforeEach(() => { | ||
| requests.length = 0 | ||
| responseStatus = 200 | ||
| responseBody = {} | ||
| networkFailure = false | ||
| }) | ||
|
|
||
| describe("same-origin authentication proxy", () => { | ||
| test("GitHub sign-in uses the proxy and preserves the Nova callback", async () => { | ||
| const callbackURL = "https://supercode-terminal.vercel.app/sign-in?redirect=https%3A%2F%2Fnova.supercodeai.tech%2Fapp" | ||
| responseBody = { url: "https://github.com/login/oauth/authorize", redirect: false } | ||
|
|
||
| await authClient.signIn.social({ provider: "github", callbackURL }) | ||
|
|
||
| expect(requests).toHaveLength(1) | ||
| expect(requests[0].url).toBe("/api/auth/sign-in/social") | ||
| expect(requests[0].init?.credentials).toBe("include") | ||
| expect(JSON.parse(String(requests[0].init?.body))).toMatchObject({ | ||
| provider: "github", | ||
| callbackURL, | ||
| }) | ||
| }) | ||
|
|
||
| test("session checks use the same-origin proxy", async () => { | ||
| responseBody = null | ||
| await authClient.getSession() | ||
| expect(requests[0].url).toBe("/api/auth/get-session") | ||
| }) | ||
|
|
||
| test("Nova one-time tokens use the same-origin proxy", async () => { | ||
| responseBody = { token: "test-one-time-token" } | ||
| const result = await authClient.oneTimeToken.generate() | ||
| expect(requests[0].url).toBe("/api/auth/one-time-token/generate") | ||
| expect(result.data?.token).toBe("test-one-time-token") | ||
| }) | ||
|
|
||
| test("HTTP sign-in failures are returned for the form to display", async () => { | ||
| responseStatus = 503 | ||
| responseBody = { message: "Authentication unavailable" } | ||
| const result = await authClient.signIn.social({ provider: "github" }) | ||
| expect(result.error).toBeTruthy() | ||
| expect(requests[0].url).toBe("/api/auth/sign-in/social") | ||
| }) | ||
|
|
||
| test("network failures reject for the form to catch", async () => { | ||
| networkFailure = true | ||
| await expect(authClient.signIn.social({ provider: "github" })).rejects.toThrow("Failed to fetch") | ||
| }) | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -50,6 +50,32 @@ Nova uses those same variables on the **terminal harness server**, not in browse | |
|
|
||
| The URL must be the bare HTTPS branch origin from Neon Console → branch → AI Gateway, without `/v1`. The token needs `ai_gateway:invoke`; a Neon management API key will not work. Access requires a funded Neon project and varies by branch. The SDK routes Claude to Messages, OpenAI/Codex to Responses, and other models to Chat Completions. Gemini structured output and multi-step tools are currently unsupported; Responses tool continuations can fail upstream. Nova's normal plan and budget limits still apply. | ||
|
|
||
| ### Local Codex in Nova | ||
|
|
||
| Native Codex subscription login is supported only with **locally hosted Nova**, not the Vercel-hosted UI. Install the OpenAI Codex CLI separately, start Nova locally, then run from the workspace you want Codex to inspect: | ||
|
|
||
| ```bash | ||
| # From this repository (development, no publish required) | ||
| bun apps/supercode-cli/server/src/cli/main.ts nova connect --origin http://localhost:3003 | ||
|
|
||
| # Once the updated CLI is installed | ||
| supercode nova connect --origin http://localhost:3003 | ||
|
Comment on lines
+55
to
+62
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Document Line 55 tells the user to run the command "from the workspace you want Codex to inspect". The development example at Line 59 uses a repository-relative path, so it must run from the repository root. In that case, Proposed fix
|
||
| ``` | ||
|
|
||
| Run `codex login` first if you are not already signed in. The companion defaults to your existing `CODEX_HOME` (or `~/.codex`) and detects the signed-in account through Codex app-server without reading or copying auth files. Keep the same `CODEX_HOME` when running both commands. Confirm the profile/workspace/origin in the terminal. In Nova **Settings → Models → Local Codex**, enter the companion port and single-use code, confirm workspace access, and pair. Your account and models appear automatically; choose **Open local Codex chat**. The browser cannot scan installed binaries directly. | ||
|
|
||
| Existing-profile mode cannot sign in, switch accounts or sign out through Nova. Use `codex login` / `codex logout` in the terminal, then restart the companion and pair again. The existing credential-storage backend (including OS keyring) is preserved. Codex itself may renew credentials and write its normal session state. | ||
|
|
||
| For a separate Nova account, add `--profile isolated`. This uses `~/.nova-codex` instead, enables **Continue with ChatGPT** and **Sign out of Nova Codex**, and does not reuse the CLI account. Complete sign-in and click **Refresh account & models**. Nova does not install Codex automatically. | ||
|
|
||
| This is read-only chat/planning with native follow-up threads, not writable Nova agent tools. Read-only sandboxing does not restrict reads to the selected folder. Only normalized text and terminal events are exposed; unsupported native approval/permission requests are denied. Model catalogs are not entitlement guarantees. Failed/interrupted turns are explicitly incomplete. | ||
|
|
||
| The companion binds only `127.0.0.1` (default port `43127`; override with `--port`). It accepts only the exact configured localhost origin and a paired credential. OpenAI credentials stay under Codex's control. Existing mode preserves your configured home and does not change its permissions or credential backend. Your existing Codex configuration may contain hooks, plugins or integrations; this preview does not isolate those. Use `--profile isolated` for a separate profile and avoid adding integrations there. API-key accounts are not supported by this subscription connection. Only one companion should use an isolated profile at a time. | ||
|
|
||
| Pairing codes expire in five minutes, are single-use, and permit at most five attempts. Browser session credentials remain in memory for up to one hour. Reloading/navigating away requires restarting the companion and re-pairing. **Unpair** disconnects the browser; **Sign out of Nova Codex** logs out the dedicated profile and does not claim to revoke other OpenAI sessions. Ctrl+C stops the companion. Disconnects, deadline/overflow cancellation, or a failed native interruption may terminate Codex; restart/re-pair to recover. | ||
|
|
||
| Browsers may require local-network permission or block HTTPS-to-HTTP loopback connections. Do not work around this by exposing the companion publicly or proxying subscription credentials through Vercel. Current OpenAI documentation directs hosted/commercial integrations to partner-provisioned Sign in with ChatGPT; this native-login path rejects remote origins. | ||
|
|
||
| ### Voice Input | ||
|
|
||
| Voice capture requires `ffmpeg` and a Smallest.ai API key. | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,62 @@ | ||||||||||||||||||||||||||||||||||||||||||||
| import { Command } from "commander" | ||||||||||||||||||||||||||||||||||||||||||||
| import { mkdir, chmod, lstat, realpath } from "node:fs/promises" | ||||||||||||||||||||||||||||||||||||||||||||
| import { homedir } from "node:os" | ||||||||||||||||||||||||||||||||||||||||||||
| import { basename, resolve } from "node:path" | ||||||||||||||||||||||||||||||||||||||||||||
| import { createInterface } from "node:readline/promises" | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| import { spawnCodex } from "../../nova/codex-rpc" | ||||||||||||||||||||||||||||||||||||||||||||
| import { resolveCodexProfile } from "../../nova/profile" | ||||||||||||||||||||||||||||||||||||||||||||
| import { LocalCodexRuntime } from "../../nova/runtime" | ||||||||||||||||||||||||||||||||||||||||||||
| import { localOrigin, startCompanion } from "../../nova/server" | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| export const novaCommand = new Command("nova").description("Local Nova integrations") | ||||||||||||||||||||||||||||||||||||||||||||
| novaCommand.command("connect") | ||||||||||||||||||||||||||||||||||||||||||||
| .description("Pair locally hosted Nova with your existing Codex login (read-only)") | ||||||||||||||||||||||||||||||||||||||||||||
| .requiredOption("--origin <origin>", "Exact local Nova origin, e.g. http://localhost:3003") | ||||||||||||||||||||||||||||||||||||||||||||
| .option("--port <port>", "Loopback companion port", "43127") | ||||||||||||||||||||||||||||||||||||||||||||
| .option("--workspace <directory>", "Read-only workspace", process.cwd()) | ||||||||||||||||||||||||||||||||||||||||||||
| .option("--profile <mode>", "Codex account profile: existing or isolated", "existing") | ||||||||||||||||||||||||||||||||||||||||||||
| .action(async (options: { origin: string; port: string; workspace: string; profile: string }) => { | ||||||||||||||||||||||||||||||||||||||||||||
| const profile = resolveCodexProfile(options.profile) | ||||||||||||||||||||||||||||||||||||||||||||
| const origin = localOrigin(options.origin) | ||||||||||||||||||||||||||||||||||||||||||||
| const port = Number(options.port) | ||||||||||||||||||||||||||||||||||||||||||||
| if (!Number.isInteger(port) || port < 1024 || port > 65535) throw new Error("Invalid port") | ||||||||||||||||||||||||||||||||||||||||||||
| if (!process.stdin.isTTY || !process.stdout.isTTY) throw new Error("Pairing requires an interactive terminal") | ||||||||||||||||||||||||||||||||||||||||||||
| const workspace = await realpath(resolve(options.workspace)) | ||||||||||||||||||||||||||||||||||||||||||||
| if (!(await lstat(workspace)).isDirectory()) throw new Error("Workspace must be a directory") | ||||||||||||||||||||||||||||||||||||||||||||
| const prompt = createInterface({ input: process.stdin, output: process.stdout }) | ||||||||||||||||||||||||||||||||||||||||||||
| let answer: string | ||||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||
| answer = await prompt.question(`Allow ${origin} to control local Codex in ${workspace} (read-only), using the ${profile.mode} profile at ${profile.path}? Existing Codex configuration may include integrations. [y/N] `) | ||||||||||||||||||||||||||||||||||||||||||||
| } finally { prompt.close() } | ||||||||||||||||||||||||||||||||||||||||||||
| if (answer.trim().toLowerCase() !== "y") return | ||||||||||||||||||||||||||||||||||||||||||||
| if (profile.mode === "isolated") { | ||||||||||||||||||||||||||||||||||||||||||||
| await mkdir(profile.path, { recursive: true, mode: 0o700 }) | ||||||||||||||||||||||||||||||||||||||||||||
| const metadata = await lstat(profile.path) | ||||||||||||||||||||||||||||||||||||||||||||
| if (metadata.isSymbolicLink() || !metadata.isDirectory() || (typeof process.getuid === "function" && metadata.uid !== process.getuid())) { | ||||||||||||||||||||||||||||||||||||||||||||
| throw new Error("Unsafe Nova Codex profile directory") | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
| await chmod(profile.path, 0o700) | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
| // Do not create, chmod, inspect or copy the shared profile's credential files. | ||||||||||||||||||||||||||||||||||||||||||||
| // Launch outside the selected workspace to avoid startup project config. | ||||||||||||||||||||||||||||||||||||||||||||
| const rpc = spawnCodex(profile.path, homedir(), profile.mode) | ||||||||||||||||||||||||||||||||||||||||||||
| const runtime = new LocalCodexRuntime(rpc, workspace, basename(workspace), profile.mode) | ||||||||||||||||||||||||||||||||||||||||||||
| let companion: Awaited<ReturnType<typeof startCompanion>> | ||||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||
| await runtime.status() | ||||||||||||||||||||||||||||||||||||||||||||
| companion = await startCompanion({ origin, port, runtime }) | ||||||||||||||||||||||||||||||||||||||||||||
| } catch { | ||||||||||||||||||||||||||||||||||||||||||||
| runtime.close() | ||||||||||||||||||||||||||||||||||||||||||||
| throw new Error("Could not start Codex. Install/update the Codex CLI and retry.") | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+46
to
+52
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do not report every startup failure as a Codex installation problem. The
In both cases the message sends the user to reinstall Codex. Separate the two phases and keep safe messages. Proposed fix
Committable suggestion
|
||||||||||||||||||||||||||||||||||||||||||||
| process.stdout.write(`Codex detected (${profile.mode} profile). Companion: http://127.0.0.1:${companion.port}\nOpen Nova Settings → Models. Pairing code (5 minutes, one use): ${companion.pairingCode}\n${profile.mode === "existing" ? "Reusing your Codex login. If not signed in, run codex login with the same CODEX_HOME, then restart this companion. Nova cannot sign out your shared CLI account." : "Credentials remain in the dedicated Nova profile."}\nPress Ctrl+C to stop.\n`) | ||||||||||||||||||||||||||||||||||||||||||||
| let stopping = false | ||||||||||||||||||||||||||||||||||||||||||||
| const stop = () => { | ||||||||||||||||||||||||||||||||||||||||||||
| if (stopping) return | ||||||||||||||||||||||||||||||||||||||||||||
| stopping = true | ||||||||||||||||||||||||||||||||||||||||||||
| void companion.close().finally(() => { process.exitCode = 0 }) | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
| process.once("SIGINT", stop) | ||||||||||||||||||||||||||||||||||||||||||||
| process.once("SIGTERM", stop) | ||||||||||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
baseURLnested underfetchOptionsis not a documented better-auth client option —createAuthClientexposesbaseURLat the top level. Your tests assert the proxy URLs are hit, so the installed version honors it, but that's relying on internal merge behavior. If the top-level option accepts relative paths, prefer it; otherwise pin/verify the better-auth version so this can't silently regress on upgrade. Also note this drops theNEXT_PUBLIC_AUTH_URLoverride entirely — confirm the Next.js proxy is always shipped alongside the terminal client before merging.