Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ _In development — bullets added per PR; finalized at release._

### ✨ New Features

- **feat(oauth): remote Antigravity login via local helper + paste-credentials** — Antigravity (and other Google "native/desktop" OAuth providers) use Google's `firstparty/nativeapp` consent, which only releases the auth code when the loopback redirect (`127.0.0.1:<port>`) is reachable from the approving browser. On a remote VPS install that loopback lives on the server, so the consent hangs forever and never emits a code — the "paste the callback URL" fallback has nothing to paste (a Google-side constraint, identical in upstream 9router). A new `omniroute login antigravity` CLI helper runs the OAuth on the user's **own** machine (where 127.0.0.1 works), exchanges the code, and prints a single-line `omniroute-cred-v1.…` credential blob; the dashboard's Antigravity Connect → Step 2 field now accepts that blob (alongside callback URLs) and persists the connection via a new `paste-credentials` action (server-side onboarding, provider-allowlisted, with the blob's embedded provider required to match the route). The SSH local-forward tunnel is documented as a zero-tooling alternative. See [`docs/guides/REMOTE-MODE.md`](docs/guides/REMOTE-MODE.md). ([#5203](https://github.com/diegosouzapw/OmniRoute/pull/5203))
- **feat(agent-bridge): graceful cert-install fallback for containers / headless** — when the MITM root CA can't be installed into the system trust store automatically (Docker / headless / no sudo / read-only trust store), the Agent Bridge no longer hard-fails on start with a generic "Certificate install failed". It now starts in skip mode and the dashboard surfaces a platform-specific **manual-install guide** (plus a CA download link) so the operator can trust the certificate by hand. The trust-cert endpoints return a structured `{ skippable, manualGuide }` response (HTTP 200) for environment failures instead of a 500; an explicit user cancellation is still reported distinctly. ([#4546](https://github.com/diegosouzapw/OmniRoute/issues/4546) — thanks @phuchptty)

### 🔧 Bug Fixes
Expand Down
192 changes: 192 additions & 0 deletions bin/cli/commands/login.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
import { createServer } from "node:http";
import { randomUUID } from "node:crypto";

/**
* `omniroute login antigravity` — local OAuth helper for remote installs.
*
* Why this exists: Google's `firstparty/nativeapp` consent for the embedded
* Antigravity desktop client only releases the authorization code when the
* loopback redirect (127.0.0.1:<port>) is REACHABLE. On a remote VPS install the
* loopback is unreachable, so the consent hangs forever and never emits a code —
* the dashboard's "paste the callback URL" fallback has nothing to paste. (The
* same flow works locally and over an SSH tunnel, where the loopback IS reachable.)
*
* This command runs the OAuth on the user's OWN machine — where 127.0.0.1 works —
* captures the code on a local loopback server, exchanges it for tokens, and
* prints a single-line credential blob. The user pastes that blob into the remote
* dashboard (Antigravity → "Paste credentials"), which decodes it, finalizes the
* onboarding server-side, and persists the connection.
*
* It talks ONLY to Google (no OmniRoute server needed locally), so it works even
* if the remote VPS is firewalled from the user's machine.
*/

const PROVIDER = "antigravity";

/** Open the system browser; no-op if the optional `open` dependency is missing. */
async function defaultOpenBrowser(url) {
try {
const { default: open } = await import("open");
await open(url);
} catch {
// `open` not available — the caller already printed the URL to paste manually.
}
}

/**
* Start a loopback HTTP server bound to 127.0.0.1 (NOT 0.0.0.0 — we never want to
* expose the callback to the LAN). Resolves to { port, waitForCallback, close }.
*/
function defaultStartServer(preferredPort) {
return new Promise((resolve, reject) => {
let resolveCallback;
const callbackPromise = new Promise((r) => {
resolveCallback = r;
});

const server = createServer((req, res) => {
const url = new URL(req.url, "http://127.0.0.1");
if (url.pathname !== "/callback" && url.pathname !== "/auth/callback") {
res.writeHead(404).end();
return;
}
const params = Object.fromEntries(url.searchParams.entries());
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.end(
"<!doctype html><meta charset=utf-8><title>OmniRoute</title>" +
"<body style=\"font-family:system-ui;padding:2rem\">" +
"<h2>✅ Authorization received</h2>" +
"<p>Return to your terminal — you can close this tab.</p></body>"
);
resolveCallback(params);
});

server.on("error", reject);
server.listen(preferredPort || 0, "127.0.0.1", () => {
const { port } = server.address();
resolve({
port,
waitForCallback: () => callbackPromise,
close: () => new Promise((r) => server.close(() => r())),
});
});
});
}

/** Lazy-load the antigravity provider + blob codec (TS source via tsx). */
async function loadDeps() {
const { antigravity } = await import("../../../src/lib/oauth/providers/antigravity.ts");
const { encodeCredentialBlob } = await import("../../../src/lib/oauth/credentialBlob.ts");
return { antigravity, encodeCredentialBlob };
}

/**
* Build the Google authorization request for a given loopback port. Uses a plain
* authorization_code grant (NO PKCE code_challenge) — matching the working flow:
* a code_challenge here would force the exchange to require a code_verifier.
*/
export async function buildAntigravityAuthRequest(port, makeState = randomUUID) {
const { antigravity } = await loadDeps();
const redirectUri = `http://127.0.0.1:${port}/callback`;
const state = makeState();
const authUrl = antigravity.buildAuthUrl(antigravity.config, redirectUri, state);
return { redirectUri, state, authUrl };
}

/** Exchange the captured code for raw Google tokens (no code_verifier — no PKCE). */
export async function exchangeAntigravityCode(code, redirectUri) {
const { antigravity } = await loadDeps();
return antigravity.exchangeToken(antigravity.config, code, redirectUri);
}

/**
* Orchestrate the local login. Dependencies are injectable for testing; the real
* path uses a 127.0.0.1 loopback server, the system browser, and a live token
* exchange against Google. Returns the credential blob string.
*/
export async function runAntigravityLogin(opts = {}, deps = {}) {
const startServer = deps.startServer ?? defaultStartServer;
const openBrowser = deps.openBrowser ?? defaultOpenBrowser;
const exchange = deps.exchange ?? exchangeAntigravityCode;
const makeState = deps.makeState ?? randomUUID;
const print = deps.print ?? ((s) => process.stdout.write(s));
const log = deps.log ?? ((s) => process.stderr.write(s));
const { encodeCredentialBlob } = await loadDeps();

const server = await startServer(opts.port);
const { redirectUri, state, authUrl } = await buildAntigravityAuthRequest(server.port, makeState);

log(`\nOpen this URL to authorize Antigravity (it will open automatically):\n\n ${authUrl}\n\n`);
if (opts.browser !== false) await openBrowser(authUrl);
log("Waiting for Google to redirect back to the local loopback...\n");

const timeoutMs = opts.timeout ?? 300000;
let timer;
let params;
try {
params = await Promise.race([
server.waitForCallback(),
new Promise((_, reject) => {
timer = setTimeout(
() => reject(new Error("Timed out waiting for the OAuth callback")),
timeoutMs
);
// Don't keep the event loop alive solely for this timer.
if (typeof timer.unref === "function") timer.unref();
}),
]);
} finally {
clearTimeout(timer);
await server.close();
}

if (params.error) {
throw new Error(`Authorization failed: ${params.error_description || params.error}`);
}
if (params.state !== state) {
throw new Error("State mismatch — aborting (possible CSRF). Please retry the login.");
}
if (!params.code) {
throw new Error("No authorization code returned by Google.");
}

const tokens = await exchange(params.code, redirectUri);
const blob = encodeCredentialBlob({ provider: PROVIDER, tokens });

print(
"\n" +
"Antigravity authorized. Copy the line below and paste it into your remote\n" +
"OmniRoute dashboard: Providers → Antigravity → Connect → \"Paste credentials\".\n" +
"(This contains a refresh token — treat it like a password.)\n\n" +
blob +
"\n\n"
);
return blob;
}

async function runLoginAntigravity(opts) {
try {
await runAntigravityLogin({
browser: opts.browser,
timeout: opts.timeout,
port: opts.port,
});
} catch (err) {
process.stderr.write(`\nLogin failed: ${err?.message || err}\n`);
process.exit(1);
}
}

export function registerLogin(program) {
const login = program
.command("login")
.description("Local OAuth helpers for remote OmniRoute installs (run on your own machine)");

login
.command("antigravity")
.description("Authorize Antigravity locally and print a credential blob to paste remotely")
.option("--no-browser", "Do not auto-open the browser; print the URL instead")
.option("--port <n>", "Fixed loopback port (default: OS-assigned)", (v) => parseInt(v, 10))
.option("--timeout <ms>", "How long to wait for the callback", (v) => parseInt(v, 10), 300000)
.action(runLoginAntigravity);
}
2 changes: 2 additions & 0 deletions bin/cli/commands/registry.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { registerMemory } from "./memory.mjs";
import { registerSkills } from "./skills.mjs";
import { registerAudit } from "./audit.mjs";
import { registerOAuth } from "./oauth.mjs";
import { registerLogin } from "./login.mjs";
import { registerCloud } from "./cloud.mjs";
import { registerEval } from "./eval.mjs";
import { registerWebhooks } from "./webhooks.mjs";
Expand Down Expand Up @@ -83,6 +84,7 @@ export function registerCommands(program) {
registerSkills(program);
registerAudit(program);
registerOAuth(program);
registerLogin(program);
registerCloud(program);
registerEval(program);
registerWebhooks(program);
Expand Down
62 changes: 62 additions & 0 deletions docs/guides/REMOTE-MODE.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,68 @@ A token with insufficient scope gets `403` with a clear message.
---

## Connecting Antigravity on a remote install

Antigravity (and other Google "native/desktop" OAuth providers such as
`gemini-cli`) use Google's `firstparty/nativeapp` consent screen. Google only
releases the authorization code when the **loopback redirect**
(`http://127.0.0.1:<port>/callback`) is **reachable from the browser that
approves the sign-in**. On a remote VPS install that loopback lives on the
server, not on your machine, so the consent screen **hangs forever and never
emits a code** — the normal "paste the callback URL" fallback has nothing to
paste. (This is a Google-side constraint: the same hang happens in any proxy
that uses the bundled Antigravity desktop client, not just OmniRoute.)

There are two supported ways to connect Antigravity to a remote OmniRoute.

### Option A — local login helper (recommended)

Run the OAuth on **your own computer**, where `127.0.0.1` is reachable, and paste
the result into the remote dashboard. The helper talks only to Google — it does
**not** need network access to your VPS, so it works even behind firewalls.

```bash
# On your LOCAL machine (needs Node.js + a browser):
npx omniroute login antigravity
# ↳ opens the Google consent in your browser, captures the callback on a local
# loopback port, exchanges it, and prints a one-line credential blob:
#
# omniroute-cred-v1.eyJ2IjoxLCJ...
```

Then, in the **remote** dashboard: **Providers → Antigravity → Connect**, and
paste the `omniroute-cred-v1.…` blob into the **Step 2** field (it accepts either
a callback URL or a credential blob). OmniRoute decodes it, runs the Cloud Code
onboarding server-side, and persists the connection.

> The blob contains a refresh token — treat it like a password. It is sent once
> over your dashboard connection and stored encrypted at rest.
Flags: `--no-browser` (print the URL instead of auto-opening), `--port <n>`
(pin the loopback port), `--timeout <ms>`.

### Option B — SSH local-forward tunnel

If you have SSH access to the VPS, forward the dashboard port so that the
loopback callback resolves back to the server through the tunnel:

```bash
# On your LOCAL machine:
ssh -L 20128:localhost:20128 user@your-vps
# then open http://localhost:20128 in your LOCAL browser and connect Antigravity
# normally — the 127.0.0.1:20128/callback redirect now reaches the VPS via SSH.
```

Because you reach the dashboard as `localhost:20128`, the Google consent
completes and the callback is delivered to the server through the same tunnel —
no blob needed. Keep the tunnel open until the connection shows as active.

> A fully headless alternative (no helper, no tunnel) is to configure your **own**
> Google OAuth web credentials + a public base URL; see the provider's OAuth
> environment variables. The two options above need no extra Google setup.
---

## Managing tokens

```bash
Expand Down
90 changes: 90 additions & 0 deletions src/app/api/oauth/[provider]/paste-credentials/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { NextResponse } from "next/server";
import { finalizeTokens } from "@/lib/oauth/providers";
import { persistOAuthConnection } from "@/lib/oauth/connectionPersistence";
import { parsePastedCredentials } from "@/lib/oauth/pasteCredentials";
import { oauthPasteCredentialsSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";

/**
* POST /api/oauth/[provider]/paste-credentials
*
* Persist credentials produced by the local remote-login helper
* (`omniroute login antigravity`). Google's `firstparty/nativeapp` consent only
* releases the auth code when the loopback redirect is reachable, which never
* happens on a remote VPS — so the helper runs the OAuth on the user's own
* machine and prints a single-line credential blob. The dashboard pastes that
* blob here; we decode + validate it (provider allowlist + match), finalize the
* tokens (the Cloud Code onboarding runs here on the server, which CAN reach
* Google's APIs), and persist the connection. Same finalize path as the
* `device-complete` action. See src/lib/oauth/credentialBlob.ts.
*
* This lives in its own static route segment (not the dynamic `[action]` route)
* so Next.js routes `/paste-credentials` here; static segments win over `[action]`.
*/
export async function POST(
request: Request,
{ params }: { params: Promise<{ provider: string }> }
) {
// Creating a connection is owner-only — gate behind dashboard auth.
if ((await isAuthRequired(request)) && !(await isAuthenticated(request))) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

try {
const { provider } = await params;

let rawBody: unknown;
try {
rawBody = await request.json();
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}

const validation = validateBody(oauthPasteCredentialsSchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { blob, connectionId } = validation.data;

// Decode + gate (allowlist + blob-provider must match the route provider).
let pasted;
try {
pasted = parsePastedCredentials(provider, blob);
} catch (gateErr: any) {
return NextResponse.json(
{ success: false, error: sanitizeErrorMessage(gateErr?.message) || "Invalid credentials" },
{ status: 400 }
);
}

let tokenData: any;
try {
tokenData = await finalizeTokens(provider, pasted.tokens);
} catch (finalizeErr: any) {
return NextResponse.json(
{
success: false,
error: sanitizeErrorMessage(finalizeErr?.message) || "Failed to finalize tokens",
},
{ status: 500 }
);
}

const connection = await persistOAuthConnection(provider, tokenData, connectionId);

return NextResponse.json({
success: true,
connection: {
id: connection.id,
provider: connection.provider,
email: connection.email,
displayName: connection.displayName,
},
});
} catch (error) {
console.error("OAuth paste-credentials error:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}
2 changes: 1 addition & 1 deletion src/i18n/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -7749,7 +7749,7 @@
"deviceCodeVerificationUrl": "Verification URL",
"deviceCodeYourCode": "Your code",
"deviceCodeWaiting": "Waiting for authorization...",
"googleOAuthWarning": "Remote access + Google OAuth: bundled credentials only accept loopback redirects like <code>127.0.0.1</code>. The browser that approves Google must be able to reach OmniRoute on that local port, usually by opening OmniRoute locally or using an SSH/local-forward tunnel. For fully remote use without this local callback, <a>configure your own OAuth credentials</a>.",
"googleOAuthWarning": "Remote access + Google OAuth: bundled credentials only accept loopback redirects like <code>127.0.0.1</code>. The browser that approves Google must be able to reach OmniRoute on that local port, usually by opening OmniRoute locally or using an SSH/local-forward tunnel. Recommended for remote installs: on your own computer run <code>npx omniroute login antigravity</code> and paste the credential blob it prints into the field below. For fully remote use without this local callback, <a>configure your own OAuth credentials</a>.",
"remoteAccessInfo": "Remote access: Since you're accessing OmniRoute remotely, after authorization you'll see an error page (localhost not found). This is normal — just copy the full URL from your browser address bar and paste it below.",
"step1OpenUrl": "Step 1: Open this URL in your browser",
"copy": "Copy",
Expand Down
Loading
Loading