diff --git a/apps/supercode-cli/client/components/auth/login-form.tsx b/apps/supercode-cli/client/components/auth/login-form.tsx
index 56f6442..6f6afdb 100644
--- a/apps/supercode-cli/client/components/auth/login-form.tsx
+++ b/apps/supercode-cli/client/components/auth/login-form.tsx
@@ -11,6 +11,7 @@ import { PixelLogo } from '@/components/ui/pixel-logo'
const ALLOWED_REDIRECT_ORIGINS = new Set([
"https://nova.supercodeai.tech",
+ "http://nova.localhost:3000",
"http://nova.localhost:3003",
])
@@ -45,17 +46,23 @@ const LoginForm = () => {
let cancelled = false
const transferSession = async () => {
- const { data: tokenData, error: tokenError } = await authClient.oneTimeToken.generate()
- if (cancelled) return
- if (tokenError || !tokenData?.token) {
- setError("Failed to create the Nova login session. Please try again.")
- return
+ try {
+ const { data: tokenData, error: tokenError } = await authClient.oneTimeToken.generate()
+ if (cancelled) return
+ if (tokenError || !tokenData?.token) {
+ setError("Failed to create the Nova login session. Please reload to try again.")
+ return
+ }
+
+ const callbackUrl = new URL("/api/auth/cli/callback", novaUrl.origin)
+ callbackUrl.searchParams.set("token", tokenData.token)
+ callbackUrl.searchParams.set("redirect", novaUrl.pathname)
+ window.location.replace(callbackUrl.toString())
+ } catch {
+ if (!cancelled) {
+ setError("The authentication service is unavailable. Please reload to try again.")
+ }
}
-
- const callbackUrl = new URL("/api/auth/cli/callback", novaUrl.origin)
- callbackUrl.searchParams.set("token", tokenData.token)
- callbackUrl.searchParams.set("redirect", novaUrl.pathname)
- window.location.replace(callbackUrl.toString())
}
void transferSession()
@@ -78,7 +85,15 @@ const LoginForm = () => {
}
if (data?.session) {
- return null
+ return (
+
+ {error ? (
+
{error}
+ ) : (
+
+ )}
+
+ )
}
const handleGitHubSignIn = async () => {
@@ -89,14 +104,16 @@ const LoginForm = () => {
const callbackURL = novaUrl
? `${window.location.origin}/sign-in?redirect=${encodeURIComponent(novaUrl.toString())}`
: window.location.origin
- await authClient.signIn.social({
+ const { error: signInError } = await authClient.signIn.social({
provider: 'github',
callbackURL,
errorCallbackURL: `${window.location.origin}/sign-in`,
})
- } catch (err) {
- console.error('Sign in error:', err)
- setError('Failed to connect to authentication server. Make sure the terminal server is running.')
+ if (signInError) {
+ setError("GitHub sign-in could not be started. Please try again.")
+ }
+ } catch {
+ setError("The authentication service is unavailable. Please try again shortly.")
} finally {
setIsLoading(false)
}
@@ -170,7 +187,7 @@ const LoginForm = () => {
{error && (
-
+
{error}
)}
diff --git a/apps/supercode-cli/client/lib/auth-client.test.ts b/apps/supercode-cli/client/lib/auth-client.test.ts
new file mode 100644
index 0000000..e9c2325
--- /dev/null
+++ b/apps/supercode-cli/client/lib/auth-client.test.ts
@@ -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")
+ })
+})
diff --git a/apps/supercode-cli/client/lib/auth-client.ts b/apps/supercode-cli/client/lib/auth-client.ts
index 2a61c54..310b0ff 100644
--- a/apps/supercode-cli/client/lib/auth-client.ts
+++ b/apps/supercode-cli/client/lib/auth-client.ts
@@ -5,7 +5,10 @@ import {
} from "better-auth/client/plugins"
export const authClient = createAuthClient({
- baseURL: process.env.NEXT_PUBLIC_AUTH_URL || "http://localhost:3004",
+ // Always use the Next.js/Vercel proxy, not the backend URL inferred from env.
+ fetchOptions: {
+ baseURL: "/api/auth",
+ },
plugins: [
deviceAuthorizationClient(),
oneTimeTokenClient(),
diff --git a/apps/supercode-cli/server/README.md b/apps/supercode-cli/server/README.md
index 99806fd..88e6da4 100644
--- a/apps/supercode-cli/server/README.md
+++ b/apps/supercode-cli/server/README.md
@@ -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
+```
+
+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.
diff --git a/apps/supercode-cli/server/src/cli/commands/nova.ts b/apps/supercode-cli/server/src/cli/commands/nova.ts
new file mode 100644
index 0000000..94ac561
--- /dev/null
+++ b/apps/supercode-cli/server/src/cli/commands/nova.ts
@@ -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 ", "Exact local Nova origin, e.g. http://localhost:3003")
+ .option("--port ", "Loopback companion port", "43127")
+ .option("--workspace ", "Read-only workspace", process.cwd())
+ .option("--profile ", "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>
+ 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.")
+ }
+ 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)
+ })
diff --git a/apps/supercode-cli/server/src/cli/main.ts b/apps/supercode-cli/server/src/cli/main.ts
index 565a915..971a4ee 100755
--- a/apps/supercode-cli/server/src/cli/main.ts
+++ b/apps/supercode-cli/server/src/cli/main.ts
@@ -9,6 +9,7 @@ import { theme } from "./utils/tui"
import { supercodeInit } from "./commands/ai/init"
import { skillCommand } from "./commands/skill"
import { renderWelcome } from "./utils/welcome"
+import { novaCommand } from "./commands/nova"
process.on("unhandledRejection", (reason) => {
try {
@@ -33,13 +34,14 @@ async function main() {
.addCommand(loginCommand)
.addCommand(supercodeInit)
.addCommand(skillCommand)
+ .addCommand(novaCommand)
program.action(async () => {
renderWelcome(version)
program.help()
})
- program.parse()
+ await program.parseAsync()
}
main().catch((err) => {
diff --git a/apps/supercode-cli/server/src/lib/auth.ts b/apps/supercode-cli/server/src/lib/auth.ts
index 2d84633..b48b866 100644
--- a/apps/supercode-cli/server/src/lib/auth.ts
+++ b/apps/supercode-cli/server/src/lib/auth.ts
@@ -9,6 +9,7 @@ const clientUrl = process.env.CLIENT_URL || "http://localhost:3000"
const isProduction = serverUrl.startsWith("https://")
const novaOrigins = [
"https://nova.supercodeai.tech",
+ "http://nova.localhost:3000",
"http://nova.localhost:3003",
]
diff --git a/apps/supercode-cli/server/src/nova/codex-rpc.ts b/apps/supercode-cli/server/src/nova/codex-rpc.ts
new file mode 100644
index 0000000..aab9e33
--- /dev/null
+++ b/apps/supercode-cli/server/src/nova/codex-rpc.ts
@@ -0,0 +1,124 @@
+import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"
+import type { CodexProfileMode } from "./profile"
+
+export type NativeMessage = { id?: number | string; method?: string; params?: Record; result?: unknown; error?: unknown }
+export interface CodexRpc {
+ request(method: string, params: unknown): Promise
+ subscribe(listener: (message: NativeMessage) => void): () => void
+ close(): void
+}
+
+export class StdioCodex implements CodexRpc {
+ private nextId = 0
+ private pending = new Map }>()
+ private listeners = new Set<(message: NativeMessage) => void>()
+ private buffer = ""
+ private closed = false
+ private ready: Promise
+
+ constructor(private readonly process: ChildProcessWithoutNullStreams, private readonly timeout = 30_000) {
+ process.stdout.setEncoding("utf8")
+ process.stdout.on("data", (chunk: string) => this.receive(chunk))
+ process.stderr.resume()
+ process.on("error", () => this.close())
+ process.on("exit", () => this.close())
+ process.stdin.on("error", () => this.close())
+ this.ready = this.sendRequest("initialize", {
+ clientInfo: { name: "nova_local", title: "Nova Local", version: "0.1.0" },
+ }).then(() => { this.write({ method: "initialized", params: {} }) })
+ void this.ready.catch(() => this.close())
+ }
+
+ private write(message: NativeMessage): void {
+ if (this.closed || this.process.stdin.writableLength > 1_048_576) throw new Error("Codex is unavailable")
+ this.process.stdin.write(`${JSON.stringify(message)}\n`)
+ }
+
+ private sendRequest(method: string, params: unknown): Promise {
+ if (this.closed || this.pending.size >= 32) return Promise.reject(new Error("Codex is unavailable"))
+ const id = ++this.nextId
+ return new Promise((resolve, reject) => {
+ const timer = setTimeout(() => { this.close() }, this.timeout)
+ this.pending.set(id, { resolve, reject, timer })
+ try { this.write({ id, method, params: params as Record }) } catch { this.close() }
+ })
+ }
+
+ async request(method: string, params: unknown): Promise {
+ await this.ready
+ return this.sendRequest(method, params)
+ }
+
+ private receive(chunk: string): void {
+ if (this.closed) return
+ this.buffer += chunk
+ try {
+ let end: number
+ while ((end = this.buffer.indexOf("\n")) >= 0) {
+ if (end > 1_048_576) throw new Error("size")
+ const line = this.buffer.slice(0, end)
+ this.buffer = this.buffer.slice(end + 1)
+ if (!line.trim()) continue
+ const message = JSON.parse(line) as NativeMessage
+ if (!message || typeof message !== "object" || Array.isArray(message)) throw new Error("message")
+ if (message.method && message.id !== undefined) {
+ // Unsupported server requests must never gain execution or permissions.
+ this.write({ id: message.id, error: { code: -32601, message: "Unsupported in Nova read-only mode" } })
+ } else if (message.id !== undefined) {
+ const pending = typeof message.id === "number" ? this.pending.get(message.id) : undefined
+ if (!pending) continue
+ this.pending.delete(message.id as number)
+ clearTimeout(pending.timer)
+ if ("error" in message) pending.reject(new Error("Codex request failed"))
+ else if ("result" in message) pending.resolve(message.result)
+ else pending.reject(new Error("Invalid Codex response"))
+ } else if (typeof message.method === "string") {
+ for (const listener of this.listeners) listener(message)
+ }
+ }
+ if (this.buffer.length > 1_048_576) throw new Error("size")
+ } catch { this.close() }
+ }
+
+ subscribe(listener: (message: NativeMessage) => void): () => void {
+ this.listeners.add(listener)
+ return () => this.listeners.delete(listener)
+ }
+
+ close(): void {
+ if (this.closed) return
+ this.closed = true
+ for (const pending of this.pending.values()) {
+ clearTimeout(pending.timer)
+ pending.reject(new Error("Codex stopped or timed out"))
+ }
+ this.pending.clear()
+ for (const listener of this.listeners) listener({ method: "nova/processExited" })
+ this.listeners.clear()
+ this.buffer = ""
+ this.process.kill("SIGTERM")
+ const kill = setTimeout(() => this.process.kill("SIGKILL"), 2000)
+ kill.unref()
+ }
+}
+
+export function codexLaunchArgs(mode: CodexProfileMode): string[] {
+ return [
+ "app-server", "--listen", "stdio://",
+ // Existing mode must preserve the CLI's file/keyring credential backend.
+ ...(mode === "isolated" ? ["-c", 'cli_auth_credentials_store="file"'] : []),
+ "-c", 'forced_login_method="chatgpt"',
+ "-c", 'sandbox_mode="read-only"',
+ "-c", 'approval_policy="on-request"',
+ "-c", "mcp_servers={}",
+ "-c", "analytics.enabled=false",
+ ]
+}
+
+export function spawnCodex(profile: string, cwd: string, mode: CodexProfileMode = "existing"): StdioCodex {
+ const env: NodeJS.ProcessEnv = { CODEX_HOME: profile }
+ for (const key of ["PATH", "HOME", "USER", "LOGNAME", "TMPDIR", "TEMP", "TMP", "SystemRoot", "LANG"]) {
+ if (process.env[key]) env[key] = process.env[key]
+ }
+ return new StdioCodex(spawn("codex", codexLaunchArgs(mode), { cwd, env, stdio: ["pipe", "pipe", "pipe"], shell: false }))
+}
diff --git a/apps/supercode-cli/server/src/nova/local-codex.test.ts b/apps/supercode-cli/server/src/nova/local-codex.test.ts
new file mode 100644
index 0000000..3f54c85
--- /dev/null
+++ b/apps/supercode-cli/server/src/nova/local-codex.test.ts
@@ -0,0 +1,125 @@
+import { describe, expect, test } from "bun:test"
+import type { CodexRpc, NativeMessage } from "./codex-rpc"
+import { codexLaunchArgs } from "./codex-rpc"
+import { resolveCodexProfile } from "./profile"
+import { LocalCodexRuntime, type LocalEvent } from "./runtime"
+import { localOrigin, startCompanion } from "./server"
+
+class FakeCodex implements CodexRpc {
+ calls: Array<{ method: string; params: unknown }> = []
+ listeners = new Set<(message: NativeMessage) => void>()
+ account: unknown = { type: "chatgpt", email: "fixture@example.test", planType: "plus" }
+ closed = false
+ async request(method: string, params: unknown): Promise {
+ this.calls.push({ method, params })
+ if (method === "account/read") return { account: this.account, accessToken: "never-expose" }
+ if (method === "model/list") return { data: [{ model: "fixture", displayName: "Fixture" }, { model: "hidden", displayName: "Hidden", hidden: true }], nextCursor: null }
+ if (method === "thread/start") return { thread: { id: "native-private-thread" } }
+ if (method === "turn/start") {
+ queueMicrotask(() => {
+ this.notify({ method: "item/agentMessage/delta", params: { threadId: "native-private-thread", delta: "Hello" } })
+ this.notify({ method: "turn/completed", params: { threadId: "native-private-thread", turn: { id: "turn-1", status: "completed" } } })
+ })
+ return { turn: { id: "turn-1" } }
+ }
+ if (method === "account/login/start") return { loginId: "login-1", authUrl: "https://auth.openai.com/authorize?state=fixture" }
+ return {}
+ }
+ notify(message: NativeMessage) { for (const listener of this.listeners) listener(message) }
+ subscribe(listener: (message: NativeMessage) => void) { this.listeners.add(listener); return () => { this.listeners.delete(listener) } }
+ close() { this.closed = true; this.notify({ method: "nova/processExited" }) }
+}
+
+ describe("existing Codex login", () => {
+ test("resolves default, custom, tilde and isolated homes without filesystem access", () => {
+ expect(resolveCodexProfile("existing", "", "/fixture")).toEqual({ mode: "existing", path: "/fixture/.codex" })
+ expect(resolveCodexProfile("existing", "/work/codex", "/fixture").path).toBe("/work/codex")
+ expect(resolveCodexProfile("existing", "~/personal", "/fixture").path).toBe("/fixture/personal")
+ expect(resolveCodexProfile("isolated", "/work/codex", "/fixture").path).toBe("/fixture/.nova-codex")
+ expect(() => resolveCodexProfile("invalid")).toThrow()
+ expect(codexLaunchArgs("existing").join(" ")).not.toContain("cli_auth_credentials_store")
+ expect(codexLaunchArgs("isolated").join(" ")).toContain('cli_auth_credentials_store="file"')
+ })
+
+ test("reuses account/model RPC and denies changes to shared CLI authentication", async () => {
+ const rpc = new FakeCodex()
+ const runtime = new LocalCodexRuntime(rpc, "/fixture", "fixture")
+ const status = await runtime.status()
+ expect(status.profile).toBe("existing")
+ expect(status.account?.planType).toBe("plus")
+ expect(JSON.stringify(status)).not.toContain("never-expose")
+ expect(await runtime.models()).toEqual([{ id: "fixture", label: "Fixture" }])
+ await expect(runtime.login()).rejects.toThrow("shared Codex")
+ await expect(runtime.logout()).rejects.toThrow("shared Codex")
+ await runtime.cancelLogin()
+ runtime.unpair("owner")
+ expect(rpc.calls.every(({ method }) => !method.includes("login") && method !== "account/logout")).toBe(true)
+ runtime.close()
+ })
+
+ test("unauthenticated accounts return no models and API-key accounts fail closed", async () => {
+ const rpc = new FakeCodex()
+ const runtime = new LocalCodexRuntime(rpc, "/fixture", "fixture")
+ rpc.account = null
+ expect((await runtime.status()).account).toBeNull()
+ expect(await runtime.models()).toEqual([])
+ rpc.account = { type: "apiKey" }
+ await expect(runtime.status()).rejects.toThrow("ChatGPT account")
+ runtime.close()
+ })
+
+ test("isolated mode retains native login/cancel/logout", async () => {
+ const rpc = new FakeCodex()
+ const runtime = new LocalCodexRuntime(rpc, "/fixture", "fixture", "isolated")
+ await runtime.login()
+ expect((await runtime.status()).login).toBe("pending")
+ await runtime.cancelLogin()
+ await runtime.logout()
+ expect(rpc.calls.map(({ method }) => method)).toContain("account/logout")
+ runtime.close()
+ })
+
+ test("read-only turns expose opaque owned handles and invalidate them on account updates", async () => {
+ const rpc = new FakeCodex()
+ const runtime = new LocalCodexRuntime(rpc, "/fixture", "fixture")
+ const events: LocalEvent[] = []
+ const input = { content: "Hello", model: "fixture" }
+ await runtime.turn(input, "owner", new AbortController().signal, (event) => events.push(event))
+ expect(events.map((event) => event.type)).toEqual(["thread", "text", "finish"])
+ expect(events.at(-1)).toEqual({ type: "finish", reason: "completed" })
+ expect(JSON.stringify(events)).not.toContain("native-private-thread")
+ const thread = events.find((event) => event.type === "thread")
+ if (thread?.type !== "thread") throw new Error("Missing thread")
+ await expect(runtime.turn({ ...input, thread: thread.id }, "other", new AbortController().signal, () => {})).rejects.toThrow("Thread unavailable")
+ rpc.notify({ method: "account/updated" })
+ await expect(runtime.turn({ ...input, thread: thread.id }, "owner", new AbortController().signal, () => {})).rejects.toThrow("Thread unavailable")
+ const start = rpc.calls.find(({ method }) => method === "turn/start")
+ expect(start?.params).toMatchObject({ sandboxPolicy: { type: "readOnly" }, approvalPolicy: "on-request" })
+ runtime.close()
+ })
+
+ test("loopback pairing protects shared login and unpair revokes access without logout", async () => {
+ const rpc = new FakeCodex()
+ const runtime = new LocalCodexRuntime(rpc, "/fixture", "fixture")
+ const origin = "http://localhost:3003"
+ const companion = await startCompanion({ origin, port: 0, runtime })
+ const endpoint = `http://127.0.0.1:${companion.port}`
+ let token = ""
+ const request = (path: string, value = {}, caller = origin) => fetch(`${endpoint}${path}`, {
+ method: "POST", headers: { Origin: caller, "Content-Type": "application/json", Authorization: `Bearer ${token}` }, body: JSON.stringify(value),
+ })
+ try {
+ expect((await request("/status")).status).toBe(401)
+ expect((await request("/pair", { code: companion.pairingCode, confirm: true }, "https://attacker.test")).status).toBe(403)
+ const pairing = await request("/pair", { code: companion.pairingCode, confirm: true })
+ token = (await pairing.json() as { token: string }).token
+ expect((await request("/status")).status).toBe(200)
+ expect((await request("/login")).status).toBe(400)
+ expect((await request("/logout")).status).toBe(400)
+ expect((await request("/unpair")).status).toBe(200)
+ expect((await request("/status")).status).toBe(401)
+ expect(rpc.calls.some(({ method }) => method === "account/logout")).toBe(false)
+ } finally { await companion.close() }
+ expect(() => localOrigin("https://nova.example")).toThrow()
+ })
+})
diff --git a/apps/supercode-cli/server/src/nova/profile.ts b/apps/supercode-cli/server/src/nova/profile.ts
new file mode 100644
index 0000000..617742b
--- /dev/null
+++ b/apps/supercode-cli/server/src/nova/profile.ts
@@ -0,0 +1,13 @@
+import { homedir } from "node:os"
+import { join, resolve } from "node:path"
+
+export type CodexProfileMode = "existing" | "isolated"
+
+export function resolveCodexProfile(mode: string, configuredHome = process.env.CODEX_HOME, home = homedir()) {
+ if (mode !== "existing" && mode !== "isolated") throw new Error("Profile must be existing or isolated")
+ const directory = mode === "isolated"
+ ? join(home, ".nova-codex")
+ : configuredHome?.trim() || join(home, ".codex")
+ const path = directory === "~" ? home : directory.startsWith("~/") ? join(home, directory.slice(2)) : resolve(directory)
+ return { mode: mode as CodexProfileMode, path }
+}
diff --git a/apps/supercode-cli/server/src/nova/runtime.ts b/apps/supercode-cli/server/src/nova/runtime.ts
new file mode 100644
index 0000000..83f6d1d
--- /dev/null
+++ b/apps/supercode-cli/server/src/nova/runtime.ts
@@ -0,0 +1,229 @@
+import { randomUUID } from "node:crypto"
+import { z } from "zod"
+import type { CodexRpc, NativeMessage } from "./codex-rpc"
+import type { CodexProfileMode } from "./profile"
+
+const accountSchema = z.object({ account: z.object({
+ type: z.literal("chatgpt"), email: z.string().max(320).nullable(), planType: z.string().max(100),
+}).nullable() })
+const modelsSchema = z.object({ data: z.array(z.object({
+ model: z.string().min(1).max(200), displayName: z.string().max(200), hidden: z.boolean().optional(),
+})).max(1000), nextCursor: z.string().nullable().optional() })
+export type LocalStatus = { account: { email: string | null; planType: string } | null; login: "idle" | "pending" | "succeeded" | "failed"; workspace: string; readOnly: true; profile: CodexProfileMode }
+export type LocalEvent = { type: "text"; content: string } | { type: "thread"; id: string } | { type: "finish"; reason: "completed" | "interrupted" | "failed" }
+export const turnInput = z.object({
+ content: z.string().trim().min(1).max(32_000), model: z.string().min(1).max(200),
+ thread: z.string().uuid().optional(),
+}).strict()
+
+export function loginUrl(value: string): string {
+ const url = new URL(value)
+ if (!["https://auth.openai.com", "https://chatgpt.com"].includes(url.origin) || url.username || url.password || url.hash || url.searchParams.has("access_token") || url.searchParams.has("id_token")) {
+ throw new Error("Invalid Codex login URL")
+ }
+ return url.href
+}
+
+export class LocalCodexRuntime {
+ private loginId: string | null = null
+ private loginState: LocalStatus["login"] = "idle"
+ private threads = new Map()
+ private identity: string | null = null
+ private busy = false
+ private active: { thread: string; turn?: string; stop(): void } | null = null
+ private generation = 0
+ private unsubscribe: () => void
+
+ constructor(private rpc: CodexRpc, private readonly workspace: string, private readonly workspaceName: string, private readonly profile: CodexProfileMode = "existing") {
+ this.unsubscribe = rpc.subscribe((message) => {
+ if (message.method === "account/updated") {
+ this.generation++
+ this.threads.clear()
+ this.identity = null
+ if (this.active) { this.active.stop(); this.rpc.close() }
+ }
+ if (message.method === "account/login/completed" && message.params?.loginId === this.loginId) {
+ this.loginState = message.params?.success === true ? "succeeded" : "failed"
+ this.loginId = null
+ }
+ })
+ }
+
+ async status(): Promise {
+ const raw = await this.rpc.request("account/read", { refreshToken: false })
+ const parsed = accountSchema.safeParse(raw)
+ if (!parsed.success) throw new Error("Nova requires a ChatGPT account, not API-key authentication")
+ const account = parsed.data.account
+ const identity = account ? JSON.stringify(account) : null
+ if (this.identity !== identity) {
+ this.threads.clear()
+ this.identity = identity
+ if (this.active) { this.active.stop(); this.rpc.close() }
+ }
+ return { account: account ? { email: account.email, planType: account.planType } : null, login: this.loginState, workspace: this.workspaceName, readOnly: true, profile: this.profile }
+ }
+
+ async login(): Promise<{ authUrl: string }> {
+ this.assertIsolated()
+ if (this.busy || this.loginId) throw new Error("Codex is busy")
+ this.busy = true
+ try {
+ const result = z.object({ loginId: z.string(), authUrl: z.string() }).parse(
+ await this.rpc.request("account/login/start", { type: "chatgpt" }),
+ )
+ this.loginId = result.loginId
+ this.loginState = "pending"
+ return { authUrl: loginUrl(result.authUrl) }
+ } finally { this.busy = false }
+ }
+
+ async cancelLogin(): Promise {
+ if (!this.loginId) return
+ this.assertIsolated()
+ if (this.busy) throw new Error("Codex is busy")
+ const id = this.loginId
+ await this.rpc.request("account/login/cancel", { loginId: id })
+ this.loginId = null
+ this.loginState = "idle"
+ }
+
+ async models(): Promise> {
+ if (!(await this.status()).account) return []
+ const generation = this.generation
+ const models: Array<{ id: string; label: string }> = []
+ let cursor: string | undefined
+ for (let page = 0; page < 10; page++) {
+ const result = modelsSchema.parse(await this.rpc.request("model/list", { cursor, limit: 100 }))
+ models.push(...result.data.filter((entry) => !entry.hidden).map((entry) => ({ id: entry.model, label: entry.displayName })))
+ if (generation !== this.generation) throw new Error("Codex account changed")
+ if (!result.nextCursor) return models
+ cursor = result.nextCursor
+ }
+ throw new Error("Codex catalog exceeded limits")
+ }
+
+ private assertIsolated(): void {
+ if (this.profile !== "isolated") throw new Error("Manage your shared Codex login in the terminal with codex login or codex logout")
+ }
+
+ async logout(): Promise {
+ this.assertIsolated()
+ if (this.busy) throw new Error("Stop the active turn before signing out")
+ this.busy = true
+ try {
+ if (this.loginId) await this.rpc.request("account/login/cancel", { loginId: this.loginId })
+ await this.rpc.request("account/logout", {})
+ this.loginId = null
+ this.loginState = "idle"
+ this.threads.clear()
+ this.identity = null
+ } finally { this.busy = false }
+ }
+
+ async turn(value: unknown, owner: string, signal: AbortSignal, emit: (event: LocalEvent) => void): Promise {
+ const input = turnInput.parse(value)
+ if (this.busy || this.loginId) throw new Error("Codex is busy")
+ this.busy = true
+ let unsubscribe = () => {}
+ let deadline: ReturnType | undefined
+ let abort = () => {}
+ try {
+ signal.throwIfAborted()
+ const generation = this.generation
+ const models = await this.models()
+ if (!this.identity || !models.some((model) => model.id === input.model)) throw new Error("Model unavailable")
+ signal.throwIfAborted()
+ if (generation !== this.generation) throw new Error("Codex account changed")
+ let thread = input.thread ? this.threads.get(input.thread) : undefined
+ if (input.thread && (!thread || thread.owner !== owner)) throw new Error("Thread unavailable")
+ if (!thread) {
+ if (this.threads.size >= 100) throw new Error("Restart companion to create more threads")
+ const started = z.object({ thread: z.object({ id: z.string() }) }).parse(await this.rpc.request("thread/start", {
+ cwd: this.workspace, model: input.model, sandbox: "read-only", approvalPolicy: "on-request",
+ config: { mcp_servers: {}, web_search: "disabled" },
+ developerInstructions: "Read-only Nova session. Do not modify files, request elevated permissions, or use external integrations.",
+ }))
+ thread = { native: started.thread.id, owner }
+ input.thread = randomUUID()
+ this.threads.set(input.thread, thread)
+ }
+ signal.throwIfAborted()
+ if (generation !== this.generation) throw new Error("Codex account changed")
+ emit({ type: "thread", id: input.thread! })
+ const native = thread.native
+ let turnId: string | undefined
+ let done = false
+ let stopping = false
+ let bytes = 0
+ let resolveFinished!: () => void
+ const finished = new Promise((resolve) => { resolveFinished = resolve })
+ const finish = (reason: "completed" | "failed" | "interrupted") => {
+ if (done) return
+ done = true
+ try { emit({ type: "finish", reason }) } finally { resolveFinished() }
+ }
+ abort = () => {
+ if (done || stopping) return
+ stopping = true
+ // Terminating this dedicated process also covers cancellation before turn/start replies.
+ this.rpc.close()
+ finish("interrupted")
+ }
+ this.active = { thread: native, stop: abort }
+ unsubscribe = this.rpc.subscribe((message: NativeMessage) => {
+ if (message.method === "nova/processExited") { finish(stopping ? "interrupted" : "failed"); return }
+ const params = message.params
+ if (params?.threadId !== native || done) return
+ if (message.method === "turn/started") {
+ const parsed = z.object({ turn: z.object({ id: z.string() }) }).safeParse(params)
+ if (parsed.success) { turnId = parsed.data.turn.id; if (this.active) this.active.turn = turnId }
+ }
+ if (turnId && params.turnId && params.turnId !== turnId) return
+ if (message.method === "item/agentMessage/delta" && typeof params.delta === "string") {
+ bytes += Buffer.byteLength(params.delta)
+ if (bytes > 2_097_152) { abort(); return }
+ try { emit({ type: "text", content: params.delta }) } catch { abort() }
+ }
+ if (message.method === "turn/completed") {
+ const parsed = z.object({ turn: z.object({ id: z.string(), status: z.string() }) }).safeParse(params)
+ if (parsed.success && (!turnId || parsed.data.turn.id === turnId)) {
+ const status = parsed.data.turn.status
+ finish(status === "completed" ? "completed" : status === "interrupted" ? "interrupted" : "failed")
+ }
+ }
+ })
+ signal.addEventListener("abort", abort, { once: true })
+ deadline = setTimeout(abort, 180_000)
+ signal.throwIfAborted()
+ const result = z.object({ turn: z.object({ id: z.string() }) }).parse(await this.rpc.request("turn/start", {
+ threadId: native, input: [{ type: "text", text: input.content }], model: input.model,
+ cwd: this.workspace, approvalPolicy: "on-request", sandboxPolicy: { type: "readOnly" },
+ }))
+ turnId = result.turn.id
+ if (this.active) this.active.turn = turnId
+ await finished
+ } finally {
+ clearTimeout(deadline)
+ signal.removeEventListener("abort", abort)
+ unsubscribe()
+ this.active = null
+ this.busy = false
+ }
+ }
+
+ async interrupt(): Promise {
+ const active = this.active
+ if (!active) return
+ if (!active.turn) { active.stop(); return }
+ try { await this.rpc.request("turn/interrupt", { threadId: active.thread, turnId: active.turn }) }
+ catch { active.stop() }
+ }
+
+ unpair(owner: string): void {
+ this.generation++
+ if (this.busy) this.active?.stop()
+ for (const [id, thread] of this.threads) if (thread.owner === owner) this.threads.delete(id)
+ }
+
+ close(): void { this.active?.stop(); this.unsubscribe(); this.rpc.close() }
+}
diff --git a/apps/supercode-cli/server/src/nova/server.ts b/apps/supercode-cli/server/src/nova/server.ts
new file mode 100644
index 0000000..18a2da5
--- /dev/null
+++ b/apps/supercode-cli/server/src/nova/server.ts
@@ -0,0 +1,168 @@
+import { createHash, randomBytes, timingSafeEqual } from "node:crypto"
+import { createServer, type IncomingMessage, type ServerResponse } from "node:http"
+import { z } from "zod"
+import type { LocalCodexRuntime } from "./runtime"
+
+export function localOrigin(value: string): string {
+ const url = new URL(value)
+ if (!["localhost", "127.0.0.1", "[::1]"].includes(url.hostname) || !["http:", "https:"].includes(url.protocol) || url.origin !== value || url.username || url.password) {
+ throw new Error("Nova native login requires a locally hosted Nova origin")
+ }
+ return url.origin
+}
+const digest = (value: string) => createHash("sha256").update(value).digest()
+const matches = (a: string, b: string) => timingSafeEqual(digest(a), digest(b))
+
+async function body(req: IncomingMessage): Promise {
+ let size = 0
+ const chunks: Buffer[] = []
+ for await (const chunk of req) {
+ const bytes = Buffer.from(chunk)
+ size += bytes.length
+ if (size > 65_536) throw new Error("Request too large")
+ chunks.push(bytes)
+ }
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"))
+}
+function json(res: ServerResponse, value: unknown, status = 200): void {
+ res.writeHead(status, { "Content-Type": "application/json" })
+ res.end(JSON.stringify(value))
+}
+
+export async function startCompanion(options: { origin: string; port: number; runtime: LocalCodexRuntime; now?: () => number }) {
+ const origin = localOrigin(options.origin)
+ const now = options.now ?? Date.now
+ const pairingCode = randomBytes(24).toString("base64url")
+ const pairExpiry = now() + 300_000
+ let used = false
+ let attempts = 0
+ let credential: string | null = null
+ let expires = 0
+ let owner = ""
+ let activeController: AbortController | null = null
+ let inFlight = 0
+ let port = 0
+ const server = createServer({ maxHeaderSize: 8192 }, (req, res) => {
+ res.setHeader("Cache-Control", "no-store")
+ res.setHeader("X-Content-Type-Options", "nosniff")
+ if (req.headers.host !== `127.0.0.1:${port}` || req.headers.origin !== origin || !["127.0.0.1", "::ffff:127.0.0.1"].includes(req.socket.remoteAddress ?? "")) {
+ json(res, { error: "Companion request denied" }, 403)
+ return
+ }
+ res.setHeader("Access-Control-Allow-Origin", origin)
+ res.setHeader("Vary", "Origin")
+ if (req.method === "OPTIONS") {
+ const headers = String(req.headers["access-control-request-headers"] ?? "").toLowerCase().split(",").map((value) => value.trim()).filter(Boolean)
+ if (req.headers["access-control-request-method"] !== "POST" || headers.some((header) => !["authorization", "content-type"].includes(header))) {
+ json(res, { error: "Preflight denied" }, 403)
+ return
+ }
+ res.setHeader("Access-Control-Allow-Methods", "POST")
+ res.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type")
+ res.setHeader("Access-Control-Allow-Private-Network", "true")
+ res.writeHead(204).end()
+ return
+ }
+ if (req.method !== "POST" || req.headers["content-type"] !== "application/json" || !req.url || !/^\/[a-z-]+$/.test(req.url)) {
+ json(res, { error: "Invalid request" }, 400)
+ return
+ }
+ const path = req.url
+ if (path === "/pair") {
+ attempts++
+ if (used || attempts > 5 || now() >= pairExpiry) { json(res, { error: "Restart companion to pair again" }, 403); return }
+ } else if (!credential || now() >= expires || !matches(req.headers.authorization ?? "", `Bearer ${credential}`)) {
+ if (now() >= expires) activeController?.abort()
+ json(res, { error: "Pairing expired; restart companion" }, 401)
+ return
+ }
+ if (inFlight >= 8) { json(res, { error: "Companion busy" }, 429); return }
+ inFlight++
+ void (async () => {
+ try {
+ const value = await body(req)
+ if (path === "/pair") {
+ const input = z.object({ code: z.string().max(100), confirm: z.literal(true) }).strict().parse(value)
+ if (used || now() >= pairExpiry || !matches(input.code, pairingCode)) { json(res, { error: "Pairing denied" }, 403); return }
+ used = true
+ credential = randomBytes(32).toString("base64url")
+ owner = randomBytes(16).toString("hex")
+ expires = now() + 3_600_000
+ json(res, { token: credential, expiresAt: expires })
+ return
+ }
+ // Reading the body yields: unpair/expiry may have revoked this request meanwhile.
+ if (!credential || now() >= expires || !matches(req.headers.authorization ?? "", `Bearer ${credential}`)) {
+ json(res, { error: "Pairing expired; restart companion" }, 401)
+ return
+ }
+ if (path === "/turn") {
+ if (activeController) throw new Error("busy")
+ const controller = new AbortController()
+ activeController = controller
+ const expiry = setTimeout(() => controller.abort(), Math.max(0, expires - now()))
+ const onClose = () => { if (!res.writableEnded) controller.abort() }
+ res.on("close", onClose)
+ res.writeHead(200, { "Content-Type": "application/x-ndjson" })
+ let terminal = false
+ try {
+ await options.runtime.turn(value, owner, controller.signal, (event) => {
+ if (res.destroyed || res.writableLength > 262_144) { controller.abort(); throw new Error("Stream overflow") }
+ if (terminal) return
+ if (event.type === "finish") terminal = true
+ res.write(`${JSON.stringify(event)}\n`)
+ })
+ } catch {
+ if (!res.destroyed && !terminal) res.write(`${JSON.stringify({ type: "finish", reason: "failed" })}\n`)
+ } finally {
+ clearTimeout(expiry)
+ res.off("close", onClose)
+ activeController = null
+ res.end()
+ }
+ return
+ }
+ z.object({}).strict().parse(value)
+ switch (path) {
+ case "/status": json(res, await options.runtime.status()); break
+ case "/models": json(res, { models: await options.runtime.models() }); break
+ case "/login": json(res, await options.runtime.login()); break
+ case "/cancel-login": await options.runtime.cancelLogin(); json(res, { ok: true }); break
+ case "/logout": await options.runtime.logout(); json(res, { ok: true }); break
+ case "/interrupt": await options.runtime.interrupt(); json(res, { ok: true }); break
+ case "/unpair":
+ activeController?.abort()
+ credential = null
+ options.runtime.unpair(owner)
+ await options.runtime.cancelLogin()
+ json(res, { ok: true })
+ break
+ default: json(res, { error: "Not found" }, 404)
+ }
+ } catch {
+ if (!res.headersSent) json(res, { error: "Local Codex request failed. Check Codex installation, sign-in, or restart the companion." }, 400)
+ else res.end()
+ } finally { inFlight-- }
+ })()
+ })
+ server.requestTimeout = 10_000
+ server.headersTimeout = 10_000
+ server.maxConnections = 16
+ await new Promise((resolve, reject) => {
+ server.once("error", reject)
+ server.listen(options.port, "127.0.0.1", () => resolve())
+ })
+ const address = server.address()
+ if (!address || typeof address === "string") throw new Error("Companion could not listen")
+ port = address.port
+ return {
+ port, pairingCode,
+ close: async () => {
+ credential = null
+ activeController?.abort()
+ options.runtime.close()
+ server.closeAllConnections()
+ await new Promise((resolve) => server.close(() => resolve()))
+ },
+ }
+}
diff --git a/apps/web/app/api/nova/codex/connection/route.ts b/apps/web/app/api/nova/codex/connection/route.ts
new file mode 100644
index 0000000..8dd8a3c
--- /dev/null
+++ b/apps/web/app/api/nova/codex/connection/route.ts
@@ -0,0 +1,37 @@
+import { codexPublicError } from "@/modules/nova/codex/errors"
+import { assertCodexOrigin, parseCodexAction } from "@/modules/nova/codex/request"
+import { codexServiceForRequest } from "@/modules/nova/codex/server"
+
+export const runtime = "nodejs"
+export const dynamic = "force-dynamic"
+export const maxDuration = 40
+
+const responseHeaders = { "Cache-Control": "no-store, private", "Pragma": "no-cache", "Referrer-Policy": "no-referrer" }
+function failure(error: unknown) {
+ const safe = codexPublicError(error)
+ const status = safe.statusCode
+ return Response.json({ error: safe.message }, {
+ status,
+ headers: { ...responseHeaders, ...(status === 429 ? { "Retry-After": "60" } : {}) },
+ })
+}
+
+export async function GET(request: Request) {
+ try {
+ const service = await codexServiceForRequest(request)
+ return Response.json(await service.status(), { headers: responseHeaders })
+ } catch (error) { return failure(error) }
+}
+
+export async function POST(request: Request) {
+ try {
+ assertCodexOrigin(request)
+ const action = await parseCodexAction(request)
+ const service = await codexServiceForRequest(request)
+ const result = action.action === "start" ? await service.start()
+ : action.action === "poll" ? await service.poll(action.attemptId)
+ : action.action === "cancel" ? await service.cancel(action.attemptId)
+ : await service.disconnect()
+ return Response.json(result, { headers: responseHeaders })
+ } catch (error) { return failure(error) }
+}
diff --git a/apps/web/app/api/nova/codex/models/route.ts b/apps/web/app/api/nova/codex/models/route.ts
new file mode 100644
index 0000000..7ba4c48
--- /dev/null
+++ b/apps/web/app/api/nova/codex/models/route.ts
@@ -0,0 +1,21 @@
+import { codexPublicError } from "@/modules/nova/codex/errors"
+import { createCodexRuntime } from "@/modules/nova/codex/runtime"
+import { codexContextForRequest } from "@/modules/nova/codex/server"
+
+export const runtime = "nodejs"
+export const dynamic = "force-dynamic"
+export const maxDuration = 40
+const headers = { "Cache-Control": "no-store, private", Pragma: "no-cache" }
+
+export async function GET(request: Request) {
+ try {
+ const context = await codexContextForRequest(request)
+ const row = await context.store.get()
+ if (row.state !== "connected") return Response.json({ connected: false, models: [] }, { headers })
+ const models = await createCodexRuntime(context).catalog(request.signal)
+ return Response.json({ connected: true, models }, { headers })
+ } catch (error) {
+ const safe = codexPublicError(error)
+ return Response.json({ error: safe.message }, { status: safe.statusCode, headers })
+ }
+}
diff --git a/apps/web/app/api/nova/sandbox/machine-sizes/route.ts b/apps/web/app/api/nova/sandbox/machine-sizes/route.ts
new file mode 100644
index 0000000..3a44542
--- /dev/null
+++ b/apps/web/app/api/nova/sandbox/machine-sizes/route.ts
@@ -0,0 +1,14 @@
+import { auth } from "@super/auth/server"
+import { headers } from "next/headers"
+
+import { machineCapabilities } from "@/modules/nova/sandbox/machine-config"
+
+export const runtime = "nodejs"
+export const dynamic = "force-dynamic"
+
+export async function GET() {
+ const session = await auth.api.getSession({ headers: await headers() })
+ const responseHeaders = { "Cache-Control": "private, no-store" }
+ if (!session?.user?.id) return Response.json({ error: "Unauthorized" }, { status: 401, headers: responseHeaders })
+ return Response.json(machineCapabilities(), { headers: responseHeaders })
+}
diff --git a/apps/web/app/api/nova/sessions/[sessionId]/turn/route.ts b/apps/web/app/api/nova/sessions/[sessionId]/turn/route.ts
index 0da499c..52e7a84 100644
--- a/apps/web/app/api/nova/sessions/[sessionId]/turn/route.ts
+++ b/apps/web/app/api/nova/sessions/[sessionId]/turn/route.ts
@@ -4,16 +4,26 @@ import { NextRequest } from "next/server"
import { z } from "zod"
import { localAttachmentsSchema } from "@/modules/nova/attachments/contracts"
+import { CHATGPT_PROVIDER, CHATGPT_HOSTED_STATUS, CHATGPT_UNAVAILABLE_MESSAGE } from "@/modules/nova/chatgpt/contracts"
+import { CODEX_PROVIDER, codexEffortSchema } from "@/modules/nova/codex/models"
+import { codexPublicError } from "@/modules/nova/codex/errors"
+import { assertCodexOrigin } from "@/modules/nova/codex/request"
+import { createCodexRuntime } from "@/modules/nova/codex/runtime"
+import { codexContextForRequest } from "@/modules/nova/codex/server"
+import type { ChatTransport } from "@/modules/nova/harness/agent"
import { ensureHarnessToken } from "@/modules/nova/harness/auth"
import { referencesInputSchema } from "@/modules/nova/references/contracts"
import { runWebTurn } from "@/modules/nova/sessions/turn"
+import { machineSizeSchema } from "@/modules/nova/sandbox/machine-sizes"
+import { validateMachineSelection } from "@/modules/nova/sandbox/machine-config"
const turnSchema = z.object({
content: z.string().trim().min(1).max(20_000),
clientMessageId: z.string().trim().min(1).max(128).optional(),
model: z.string().trim().min(1).max(128).optional(),
provider: z.string().trim().min(1).max(64).optional(),
- effort: z.enum(["low", "medium", "high", "xhigh"]).optional(),
+ effort: codexEffortSchema.optional(),
+ machineSize: machineSizeSchema.optional().default("default"),
mode: z.enum(["agent", "plan", "chat"]).optional(),
references: referencesInputSchema,
localAttachments: localAttachmentsSchema,
@@ -43,9 +53,37 @@ export async function POST(
})
}
- let harnessAuth: Awaited>
+ if (parsed.data.provider === CHATGPT_PROVIDER) {
+ return Response.json(
+ { error: CHATGPT_UNAVAILABLE_MESSAGE, code: CHATGPT_HOSTED_STATUS },
+ { status: 503, headers: { "Cache-Control": "no-store" } },
+ )
+ }
+
+ try {
+ validateMachineSelection(parsed.data.machineSize)
+ } catch (error) {
+ return Response.json({ error: error instanceof Error ? error.message : "Invalid machine size" }, { status: 400 })
+ }
+
+ const isCodex = parsed.data.provider === CODEX_PROVIDER
+ let chatTransport: ChatTransport | undefined
+ if (isCodex) {
+ try {
+ assertCodexOrigin(request)
+ const runtime = createCodexRuntime(await codexContextForRequest(request))
+ chatTransport = await runtime.prepare(parsed.data.model, parsed.data.effort, request.signal)
+ } catch (error) {
+ const safe = codexPublicError(error)
+ return Response.json({ error: safe.message }, { status: safe.statusCode, headers: { "Cache-Control": "no-store" } })
+ }
+ } else if (parsed.data.effort === "none" || parsed.data.effort === "minimal") {
+ return Response.json({ error: "This provider does not support the selected reasoning effort" }, { status: 400 })
+ }
+
+ let harnessAuth: Awaited> | undefined
try {
- harnessAuth = await ensureHarnessToken({
+ if (!isCodex) harnessAuth = await ensureHarnessToken({
email: session.user.email,
name: session.user.name,
image: session.user.image,
@@ -67,7 +105,7 @@ export async function POST(
Connection: "keep-alive",
"X-Accel-Buffering": "no",
})
- if (harnessAuth.setCookie) {
+ if (harnessAuth?.setCookie) {
responseHeaders.append("Set-Cookie", harnessAuth.setCookie)
}
@@ -92,8 +130,10 @@ export async function POST(
model: parsed.data.model,
provider: parsed.data.provider,
effort: parsed.data.effort,
+ machineSize: parsed.data.machineSize,
mode: parsed.data.mode ?? "agent",
- harnessToken: harnessAuth.token,
+ harnessToken: harnessAuth?.token ?? "",
+ chatTransport,
signal,
references: parsed.data.references,
localAttachments: parsed.data.localAttachments,
diff --git a/apps/web/modules/nova-web/api.ts b/apps/web/modules/nova-web/api.ts
index f521c78..18fc9ae 100644
--- a/apps/web/modules/nova-web/api.ts
+++ b/apps/web/modules/nova-web/api.ts
@@ -9,6 +9,8 @@ import type {
} from "@/modules/nova-web/types"
import type { LocalAttachment } from "@/modules/nova/attachments/contracts"
import type { NovaReference } from "@/modules/nova/references/contracts"
+import type { NovaEffort } from "@/modules/nova-web/models"
+import type { MachineSize } from "@/modules/nova/sandbox/machine-sizes"
export async function requestJson(url: string, init?: RequestInit): Promise {
const response = await fetch(url, init)
@@ -179,7 +181,8 @@ export async function* streamTurn(
clientMessageId?: string
model?: string
provider?: string
- effort?: "low" | "medium" | "high" | "xhigh"
+ effort?: NovaEffort
+ machineSize?: MachineSize
mode?: "agent" | "plan" | "chat"
references?: NovaReference[]
localAttachments?: LocalAttachment[]
@@ -194,6 +197,7 @@ export async function* streamTurn(
model: options?.model,
provider: options?.provider,
effort: options?.effort,
+ machineSize: options?.machineSize,
mode: options?.mode,
// Local workspace ids are attached as localAttachments client-side; never send them as GitHub refs.
references: options?.references
diff --git a/apps/web/modules/nova-web/codex-catalog.tsx b/apps/web/modules/nova-web/codex-catalog.tsx
new file mode 100644
index 0000000..1462cc9
--- /dev/null
+++ b/apps/web/modules/nova-web/codex-catalog.tsx
@@ -0,0 +1,70 @@
+"use client"
+
+import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react"
+import { codexCatalogSchema, type CodexModel } from "@/modules/nova/codex/models"
+
+import { CATALOG_FRESH_MS } from "./refresh-policy"
+
+export async function fetchCodexCatalog(signal?: AbortSignal) {
+ const response = await fetch("/api/nova/codex/models", { cache: "no-store", credentials: "same-origin", signal })
+ const body = await response.json()
+ if (!response.ok) throw new Error(typeof body.error === "string" ? body.error : "Could not load Codex models")
+ return codexCatalogSchema.parse(body)
+}
+
+type Catalog = { models: CodexModel[]; connected: boolean; loading: boolean; error: string | null; retry: () => void }
+export const CodexCatalogContext = createContext({ models: [], connected: false, loading: true, error: null, retry: () => {} })
+export const useCodexCatalog = () => useContext(CodexCatalogContext)
+
+export function useAccountCodexCatalog(userId: string): Catalog {
+ const freshness = useRef({ userId, at: 0, loading: false })
+ const [state, setState] = useState<{ userId: string; models: CodexModel[]; connected: boolean; loading: boolean; error: string | null }>({ userId, models: [], connected: false, loading: true, error: null })
+ const [generation, setGeneration] = useState(0)
+ const retry = useCallback(() => setGeneration((value) => value + 1), [])
+ useEffect(() => {
+ const controller = new AbortController()
+ void Promise.resolve().then(async () => {
+ if (controller.signal.aborted) return
+ freshness.current = { userId, at: freshness.current.userId === userId ? freshness.current.at : 0, loading: true }
+ setState((current) => current.userId === userId
+ ? { ...current, loading: true, error: null }
+ : { userId, models: [], connected: false, loading: true, error: null })
+ try {
+ const catalog = await fetchCodexCatalog(controller.signal)
+ if (!controller.signal.aborted) {
+ freshness.current = { userId, at: Date.now(), loading: false }
+ setState({ ...catalog, userId, loading: false, error: null })
+ }
+ } catch (error) {
+ if (!controller.signal.aborted) {
+ freshness.current = { userId, at: Date.now(), loading: false }
+ setState((current) => ({
+ ...(current.userId === userId ? current : { models: [], connected: false }),
+ userId,
+ loading: false,
+ error: error instanceof Error ? error.message : "Could not load Codex models",
+ }))
+ }
+ }
+ })
+ return () => controller.abort()
+ }, [userId, generation])
+ useEffect(() => {
+ const revalidate = () => {
+ if (document.visibilityState !== "visible" || freshness.current.loading) return
+ if (freshness.current.userId !== userId || Date.now() - freshness.current.at >= CATALOG_FRESH_MS) {
+ freshness.current.loading = true
+ retry()
+ }
+ }
+ window.addEventListener("nova:codex-connection", retry)
+ window.addEventListener("focus", revalidate)
+ document.addEventListener("visibilitychange", revalidate)
+ return () => {
+ window.removeEventListener("nova:codex-connection", retry)
+ window.removeEventListener("focus", revalidate)
+ document.removeEventListener("visibilitychange", revalidate)
+ }
+ }, [retry, userId])
+ return state.userId === userId ? { ...state, retry } : { models: [], connected: false, loading: true, error: null, retry }
+}
diff --git a/apps/web/modules/nova-web/codex-local.test.ts b/apps/web/modules/nova-web/codex-local.test.ts
new file mode 100644
index 0000000..e0cecb6
--- /dev/null
+++ b/apps/web/modules/nova-web/codex-local.test.ts
@@ -0,0 +1,51 @@
+import { describe, expect, test } from "bun:test"
+import { isLocalNova, LocalCodexClient, localStatusSchema, readLocalEvents } from "./codex-local"
+
+async function collect(chunks: Uint8Array[], signal = new AbortController().signal) {
+ const stream = new ReadableStream({ start(controller) { for (const chunk of chunks) controller.enqueue(chunk); controller.close() } })
+ const events = []
+ for await (const event of readLocalEvents(stream, signal)) events.push(event)
+ return events
+}
+const encode = (value: string) => new TextEncoder().encode(value)
+
+describe("local Codex browser connection", () => {
+ test("requires explicit profile mode and only accepts local origins", () => {
+ const status = { account: null, login: "idle", workspace: "fixture", readOnly: true, profile: "existing" }
+ expect(localStatusSchema.parse(status).profile).toBe("existing")
+ expect(() => localStatusSchema.parse({ ...status, profile: "unknown" })).toThrow()
+ expect(isLocalNova("http://localhost:3003")).toBe(true)
+ expect(isLocalNova("https://nova.example")).toBe(false)
+ expect(() => new LocalCodexClient(43127, "https://nova.example")).toThrow()
+ })
+
+ test("requires terminal completion and supports fragmented UTF-8 text", async () => {
+ const data = encode('{"type":"text","content":"héllo"}\n{"type":"finish","reason":"completed"}\n')
+ expect(await collect(Array.from(data, (byte) => new Uint8Array([byte])))).toEqual([{ type: "text", content: "héllo" }, { type: "finish", reason: "completed" }])
+ await expect(collect([encode('{"type":"text","content":"partial"}\n')])).rejects.toThrow("before completion")
+ await expect(collect([new Uint8Array([0xff])])).rejects.toThrow()
+ const controller = new AbortController()
+ controller.abort()
+ await expect(collect([], controller.signal)).rejects.toThrow()
+ })
+
+ test("pairs, parses shared status and forgets authorization after unpair", async () => {
+ const calls: Array<{ path: string; auth: string | null }> = []
+ const fetcher = (async (input: string | URL | Request, options?: RequestInit) => {
+ const path = new URL(String(input)).pathname
+ calls.push({ path, auth: new Headers(options?.headers).get("Authorization") })
+ if (path === "/pair") return Response.json({ token: "x".repeat(32), expiresAt: Date.now() + 60_000 })
+ if (path === "/status") return Response.json({ account: { email: "fixture@example.test", planType: "plus" }, login: "idle", workspace: "fixture", readOnly: true, profile: "existing" })
+ return Response.json({ ok: true })
+ }) as typeof fetch
+ const client = new LocalCodexClient(43127, "http://localhost:3003", fetcher)
+ await expect(client.status()).rejects.toThrow("pair again")
+ await client.pair("fixture-code")
+ expect((await client.status()).profile).toBe("existing")
+ await client.unpair()
+ await expect(client.status()).rejects.toThrow("pair again")
+ expect(calls.map(({ path }) => path)).toEqual(["/pair", "/status", "/unpair"])
+ expect(calls[0]?.auth).toBeNull()
+ expect(calls[1]?.auth).toBe(`Bearer ${"x".repeat(32)}`)
+ })
+})
diff --git a/apps/web/modules/nova-web/codex-local.ts b/apps/web/modules/nova-web/codex-local.ts
new file mode 100644
index 0000000..948b4d9
--- /dev/null
+++ b/apps/web/modules/nova-web/codex-local.ts
@@ -0,0 +1,132 @@
+import { z } from "zod"
+
+export const localStatusSchema = z.object({
+ account: z.object({ email: z.string().nullable(), planType: z.string() }).nullable(),
+ login: z.enum(["idle", "pending", "succeeded", "failed"]), workspace: z.string(), readOnly: z.literal(true),
+ profile: z.enum(["existing", "isolated"]),
+}).strict()
+export type LocalCodexStatus = z.infer
+const eventSchema = z.discriminatedUnion("type", [
+ z.object({ type: z.literal("text"), content: z.string().max(1_048_576) }).strict(),
+ z.object({ type: z.literal("thread"), id: z.string().uuid() }).strict(),
+ z.object({ type: z.literal("finish"), reason: z.enum(["completed", "interrupted", "failed"]) }).strict(),
+])
+export function isLocalNova(origin: string): boolean {
+ try {
+ const url = new URL(origin)
+ return ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname) && ["http:", "https:"].includes(url.protocol) && url.origin === origin
+ } catch { return false }
+}
+export function safeCodexLoginUrl(value: string): string {
+ const url = new URL(value)
+ if (!["https://auth.openai.com", "https://chatgpt.com"].includes(url.origin) || url.username || url.password || url.hash || url.searchParams.has("access_token") || url.searchParams.has("id_token")) throw new Error("Invalid Codex sign-in URL")
+ return url.href
+}
+
+export async function* readLocalEvents(stream: ReadableStream, signal: AbortSignal) {
+ const reader = stream.getReader()
+ const decoder = new TextDecoder("utf-8", { fatal: true })
+ let buffer = ""
+ let bytes = 0
+ const abort = () => { void reader.cancel().catch(() => undefined) }
+ signal.addEventListener("abort", abort, { once: true })
+ try {
+ signal.throwIfAborted()
+ while (true) {
+ const chunk = await reader.read()
+ signal.throwIfAborted()
+ bytes += chunk.value?.length ?? 0
+ if (bytes > 4_194_304) throw new Error("Local stream exceeded limits")
+ buffer += decoder.decode(chunk.value, { stream: !chunk.done })
+ let index: number
+ while ((index = buffer.indexOf("\n")) >= 0) {
+ if (index > 1_048_576) throw new Error("Local event exceeded limits")
+ const line = buffer.slice(0, index)
+ buffer = buffer.slice(index + 1)
+ if (!line.trim()) continue
+ const event = eventSchema.parse(JSON.parse(line))
+ yield event
+ if (event.type === "finish") return
+ }
+ if (buffer.length > 1_048_576) throw new Error("Local event exceeded limits")
+ if (chunk.done) throw new Error("Local Codex disconnected before completion")
+ }
+ } finally {
+ signal.removeEventListener("abort", abort)
+ await reader.cancel().catch(() => undefined)
+ reader.releaseLock()
+ }
+}
+
+export class LocalCodexClient {
+ private token: string | null = null
+ private expires = 0
+ private readonly endpoint: string
+ constructor(port: number, origin: string, private readonly fetcher: typeof fetch = fetch) {
+ if (!isLocalNova(origin) || !Number.isInteger(port) || port < 1024 || port > 65535) throw new Error("Local Codex requires locally hosted Nova and a valid port")
+ this.endpoint = `http://127.0.0.1:${port}`
+ }
+ private async request(path: string, value: unknown, signal?: AbortSignal): Promise {
+ if (path !== "/pair" && (!this.token || this.expires <= Date.now())) throw new Error("Restart the companion and pair again")
+ let response: Response
+ try {
+ response = await this.fetcher(`${this.endpoint}${path}`, {
+ method: "POST", mode: "cors", credentials: "omit", cache: "no-store", redirect: "error",
+ referrerPolicy: "no-referrer",
+ headers: { "Content-Type": "application/json", ...(this.token ? { Authorization: `Bearer ${this.token}` } : {}) },
+ body: JSON.stringify(value), signal: signal ?? AbortSignal.timeout(35_000),
+ })
+ } catch {
+ if (signal?.aborted) throw new DOMException("Local turn cancelled", "AbortError")
+ throw new Error("Cannot reach local Codex. Start the companion and allow local-network access in your browser.")
+ }
+ if (!response.ok) {
+ if (response.status === 401) this.forget()
+ throw new Error("Local Codex request failed. Check sign-in or restart the companion and pair again.")
+ }
+ return response
+ }
+ private async json(path: string, value: unknown = {}): Promise {
+ const response = await this.request(path, value)
+ // Normalized account/model responses are small; do not buffer an unbounded peer response.
+ const reader = response.body?.getReader()
+ if (!reader) throw new Error("Empty companion response")
+ const chunks: Uint8Array[] = []
+ let size = 0
+ try {
+ while (true) {
+ const next = await reader.read()
+ if (next.done) break
+ size += next.value.length
+ if (size > 524_288) throw new Error("Companion response exceeded limits")
+ chunks.push(next.value)
+ }
+ const bytes = new Uint8Array(size)
+ let offset = 0
+ for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.length }
+ return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes))
+ } finally { await reader.cancel().catch(() => undefined); reader.releaseLock() }
+ }
+ async pair(code: string): Promise {
+ const result = z.object({ token: z.string().min(32).max(100), expiresAt: z.number().positive() }).strict().parse(await this.json("/pair", { code, confirm: true }))
+ this.token = result.token
+ this.expires = result.expiresAt
+ }
+ async status(): Promise { return localStatusSchema.parse(await this.json("/status")) }
+ async models(): Promise> {
+ return z.object({ models: z.array(z.object({ id: z.string().max(200), label: z.string().max(200) }).strict()).max(10_000) }).strict().parse(await this.json("/models")).models
+ }
+ async login(): Promise {
+ return safeCodexLoginUrl(z.object({ authUrl: z.string().max(8192) }).strict().parse(await this.json("/login")).authUrl)
+ }
+ async cancelLogin(): Promise { await this.json("/cancel-login") }
+ async logout(): Promise { await this.json("/logout") }
+ async interrupt(): Promise { await this.json("/interrupt") }
+ async unpair(): Promise { try { await this.json("/unpair") } finally { this.forget() } }
+ forget(): void { this.token = null; this.expires = 0 }
+ async *turn(input: { content: string; model: string; thread?: string }, signal: AbortSignal) {
+ const response = await this.request("/turn", input, signal)
+ if (!response.body || !response.headers.get("content-type")?.startsWith("application/x-ndjson")) throw new Error("Invalid local stream")
+ yield* readLocalEvents(response.body, signal)
+ }
+}
diff --git a/apps/web/modules/nova-web/components/chatgpt-connection-card.tsx b/apps/web/modules/nova-web/components/chatgpt-connection-card.tsx
new file mode 100644
index 0000000..5c4e9be
--- /dev/null
+++ b/apps/web/modules/nova-web/components/chatgpt-connection-card.tsx
@@ -0,0 +1,175 @@
+"use client"
+
+import { useEffect, useRef, useState, useSyncExternalStore } from "react"
+
+import { isLocalNova, LocalCodexClient, type LocalCodexStatus } from "@/modules/nova-web/codex-local"
+
+type Message = { role: "user" | "assistant"; content: string }
+const buttonClass = "rounded-lg border border-white/10 px-3 py-2 text-[12px] hover:bg-white/5 disabled:cursor-not-allowed disabled:opacity-40"
+
+const subscribeOrigin = () => () => {}
+const browserOrigin = () => window.location.origin
+const serverOrigin = () => null
+
+export function ChatGPTConnectionCard() {
+ const origin = useSyncExternalStore(subscribeOrigin, browserOrigin, serverOrigin)
+ const [port, setPort] = useState("43127")
+ const [code, setCode] = useState("")
+ const [confirmed, setConfirmed] = useState(false)
+ const [client, setClient] = useState(null)
+ const [status, setStatus] = useState(null)
+ const [models, setModels] = useState>([])
+ const [model, setModel] = useState("")
+ const [authUrl, setAuthUrl] = useState(null)
+ const [error, setError] = useState(null)
+ const [busy, setBusy] = useState(false)
+ const [openChat, setOpenChat] = useState(false)
+ const [draft, setDraft] = useState("")
+ const [messages, setMessages] = useState([])
+ const [streaming, setStreaming] = useState(false)
+ const thread = useRef(undefined)
+ const abort = useRef(null)
+ const clientRef = useRef(null)
+
+ useEffect(() => {
+ return () => {
+ abort.current?.abort()
+ const current = clientRef.current
+ if (current) void current.unpair().catch(() => undefined)
+ }
+ }, [])
+
+ async function refresh(current = client) {
+ if (!current) return
+ const next = await current.status()
+ if (status?.account && JSON.stringify(status.account) !== JSON.stringify(next.account)) {
+ thread.current = undefined
+ setMessages([])
+ }
+ setStatus(next)
+ if (next.account) {
+ const catalog = await current.models()
+ setModels(catalog)
+ setModel((selected) => catalog.some((entry) => entry.id === selected) ? selected : catalog[0]?.id ?? "")
+ if (next.login !== "pending") setAuthUrl(null)
+ } else {
+ setModels([])
+ setModel("")
+ thread.current = undefined
+ setMessages([])
+ }
+ if (next.login === "failed") setError("Codex sign-in failed. Cancel and try again.")
+ }
+
+ async function action(operation: () => Promise) {
+ if (busy || streaming) return
+ setBusy(true)
+ setError(null)
+ try { await operation() }
+ catch (failure) { setError(failure instanceof Error ? failure.message : "Local Codex request failed") }
+ finally { setBusy(false) }
+ }
+
+ async function pair() {
+ if (!origin || !confirmed) return
+ const next = new LocalCodexClient(Number(port), origin)
+ await next.pair(code)
+ setCode("")
+ clientRef.current = next
+ setClient(next)
+ await refresh(next)
+ }
+
+ async function unpair() {
+ if (!client) return
+ try { await client.unpair() }
+ finally {
+ clientRef.current = null
+ setClient(null)
+ setStatus(null)
+ setAuthUrl(null)
+ setModels([])
+ setMessages([])
+ thread.current = undefined
+ setOpenChat(false)
+ }
+ }
+
+ async function send() {
+ if (!client || streaming || busy || !draft.trim() || !model) return
+ const content = draft.trim()
+ const controller = new AbortController()
+ abort.current = controller
+ setStreaming(true)
+ setError(null)
+ setDraft("")
+ setMessages((current) => [...current, { role: "user", content }, { role: "assistant", content: "" }])
+ try {
+ for await (const event of client.turn({ content, model, thread: thread.current }, controller.signal)) {
+ if (event.type === "thread") thread.current = event.id
+ if (event.type === "text") setMessages((current) => current.map((item, index) => index === current.length - 1 ? { ...item, content: item.content + event.content } : item))
+ if (event.type === "finish" && event.reason !== "completed") setError(`Codex turn ${event.reason}. The answer may be incomplete.`)
+ }
+ } catch (failure) {
+ setError(controller.signal.aborted ? "Turn cancelled. Restart and re-pair if the companion stopped." : failure instanceof Error ? failure.message : "Codex turn failed")
+ } finally {
+ abort.current = null
+ setStreaming(false)
+ }
+ }
+
+ return (
+
+
+
ChatGPT plan · Local Codex
+ Read-only preview
+
+
Use your existing local Codex login and ChatGPT plan. Codex manages credentials and renewal on your machine; Nova never reads or copies your auth files. Plan limits and model access depend on your account.
+ {!origin || !isLocalNova(origin) ? (
+
The local companion is available only in locally hosted Nova. For a backend-held login without a companion, use Connect Codex above.
+ ) : !client ? (
+
+
Install the Codex CLI and run codex login if needed, then run this in your workspace:
+ supercode nova connect --origin {origin}
+
Uses CODEX_HOME or ~/.codex by default. For a separate Nova account, add --profile isolated. Restart the companion to get a fresh code after a reload or disconnect. Pairing expires after one hour.
Using your existing Codex profile. Nova cannot change or sign out this account. To sign in or switch accounts, run codex login in a terminal with the same CODEX_HOME, then restart the companion and pair again. Unpair only disconnects this tab.
Catalog entries do not guarantee access; a completed turn verifies model access for that request.
+
+
+ ) : null}
+ {openChat && status?.account ? (
+
+
Local read-only chat/planning. Prompts go directly to the companion, not hosted Nova sessions. Read-only does not limit reads to this folder; do not use it for sensitive workspaces. Writable tools and Nova cloud integrations are unavailable.
Choose OpenAI Codex in the chat model picker to use your subscription. Models and reasoning levels depend on your account. Credentials stay encrypted on Nova’s backend. Disconnect removes Nova’s credentials, not other OpenAI sessions.
{filter === "codex" && !codex.models.length ? codex.loading ? "Loading account models…" : codex.error ? "Could not load Codex models. Retry above." : codex.connected ? "No models available for this account." : "Connect Codex to see your account models." : "No models match your search."}