From 384f06aa4181e6eb2b3cb2e3dd438d3c8ef96f42 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Tue, 28 Jul 2026 04:07:13 -0300 Subject: [PATCH] feat(cli): deliver the Antigravity credential straight to the remote install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `omniroute login antigravity` already runs the OAuth where it actually works — the operator's own machine, the only place Google's firstparty/nativeapp loopback resolves — but it stopped at PRINTING a credential blob to copy into the remote dashboard by hand. Every piece needed to close that loop was already in place: - `omniroute connect ` saves an admin-scoped token in the active context; - `apiFetch()` injects that context's baseUrl + Bearer automatically; - `/api/oauth` requires admin scope (src/server/authz/accessScopes.ts) and stays remote-reachable — routeGuard.ts loopback-gates only `/api/oauth/cursor/auto-import`, with an explicit comment that the rest must remain reachable; - `isAuthenticated()` accepts a Bearer management token; - `/api/oauth//paste-credentials` already decodes a blob and persists. So the CLI now POSTs the blob itself whenever the active context points at another machine. No paste, and no SSH tunnel. What it deliberately does NOT do is make the push mandatory. This helper exists because it needs no route to the VPS at all — it talks only to Google, so it works from behind a firewall. A failed push therefore falls back to printing the blob rather than discarding an authorization the operator just completed in a browser. `--no-push` forces the old behaviour, `--push` forces delivery, `--context ` targets a specific install. A successful push does not echo the blob: it wraps a refresh token and it already landed. Printing it would only widen the exposure. Note for the follow-up: `oauth start --provider antigravity` cannot substitute for this. `runBrowserFlow` asks the SERVER to start the flow and then polls the server, so the callback still has to reach the server's loopback — it hangs on a remote install exactly like the dashboard does. --- bin/cli/commands/login.mjs | 101 ++++++++++- docs/guides/REMOTE-MODE.md | 44 +++-- tests/unit/cli-login-push-remote.test.ts | 221 +++++++++++++++++++++++ 3 files changed, 353 insertions(+), 13 deletions(-) create mode 100644 tests/unit/cli-login-push-remote.test.ts diff --git a/bin/cli/commands/login.mjs b/bin/cli/commands/login.mjs index 506f4e28f9..ef98c9d42d 100644 --- a/bin/cli/commands/login.mjs +++ b/bin/cli/commands/login.mjs @@ -19,6 +19,19 @@ import { randomUUID } from "node:crypto"; * * 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. + * + * Push mode: when an active remote context exists (`omniroute connect `), the + * blob is POSTed straight to that install instead of being printed for a manual + * copy-paste — every piece was already in place: + * + * - the context carries an admin-scoped token, and `apiFetch()` injects it; + * - `/api/oauth` requires admin scope (src/server/authz/accessScopes.ts) and stays + * remote-reachable — routeGuard.ts loopback-gates only `/api/oauth/cursor/auto-import`; + * - `/api/oauth//paste-credentials` already decodes the blob and persists. + * + * The push NEVER becomes a hard requirement: this helper exists precisely because it + * needs no route to the VPS, so a failed push falls back to printing the blob rather + * than losing an authorization the operator just completed in their browser. */ const PROVIDER = "antigravity"; @@ -54,7 +67,7 @@ function defaultStartServer(preferredPort) { res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); res.end( "OmniRoute" + - "" + + '' + "

✅ Authorization received

" + "

Return to your terminal — you can close this tab.

" ); @@ -73,6 +86,51 @@ function defaultStartServer(preferredPort) { }); } +/** + * Is this context pointing at another machine? Loopback (and an unresolvable value) + * counts as local, so we never auto-push somewhere we cannot reason about. + */ +export function isRemoteBaseUrl(baseUrl) { + if (!baseUrl) return false; + try { + const { hostname } = new URL(baseUrl); + const host = hostname.replace(/^\[|\]$/g, ""); // strip IPv6 brackets + return host !== "localhost" && host !== "127.0.0.1" && host !== "::1"; + } catch { + return false; + } +} + +/** + * POST a credential blob to the active context's install. Never throws: the caller + * decides whether a failure is fatal (it is not — it falls back to printing). + */ +export async function pushCredentialBlob(provider, blob, deps = {}) { + try { + const fetchImpl = deps.fetchImpl ?? (await import("../api.mjs")).apiFetch; + const res = await fetchImpl(`/api/oauth/${provider}/paste-credentials`, { + method: "POST", + body: { blob }, + }); + const data = await res.json().catch(() => ({})); + if (!res.ok || data?.success === false) { + const message = + (typeof data?.error === "string" ? data.error : data?.error?.message) || + `HTTP ${res.status}`; + return { ok: false, error: message }; + } + return { ok: true, connectionId: data?.connection?.id }; + } catch (err) { + return { ok: false, error: err?.message || String(err) }; + } +} + +/** Read the active CLI context (baseUrl + scoped token) written by `omniroute connect`. */ +async function defaultResolveContext(overrideName) { + const { resolveActiveContext } = await import("../contexts.mjs"); + return resolveActiveContext(overrideName); +} + /** Lazy-load the antigravity provider + blob codec (TS source via tsx). */ async function loadDeps() { const { antigravity } = await import("../../../src/lib/oauth/providers/antigravity.ts"); @@ -153,10 +211,41 @@ export async function runAntigravityLogin(opts = {}, deps = {}) { const tokens = await exchange(params.code, redirectUri); const blob = encodeCredentialBlob({ provider: PROVIDER, tokens }); + // Push when the operator explicitly asked, or when the active context already points + // at another machine — that is exactly the situation this helper was built for. + const resolveContext = deps.resolveContext ?? defaultResolveContext; + const push = deps.push ?? pushCredentialBlob; + let context = null; + try { + context = await resolveContext(opts.context); + } catch { + // No usable context store — fall through to printing. + } + const wantsPush = + opts.push === true || (opts.push !== false && isRemoteBaseUrl(context?.baseUrl)); + + if (wantsPush) { + log(`\nSending the credential to ${context?.baseUrl || "the active context"}...\n`); + const result = await push(PROVIDER, blob, { context }); + if (result?.ok) { + log( + `Antigravity connected on ${context?.baseUrl || "the remote install"}` + + `${result.connectionId ? ` (connection ${result.connectionId})` : ""}.\n` + + "Nothing to paste — you can close this terminal.\n" + ); + // Deliberately NOT printed: the blob wraps a refresh token and it already landed. + return blob; + } + log( + `\nCould not deliver the credential automatically: ${result?.error || "unknown error"}\n` + + "Falling back to manual paste — the authorization itself is still valid.\n" + ); + } + print( "\n" + "Antigravity authorized. Copy the line below and paste it into your remote\n" + - "OmniRoute dashboard: Providers → Antigravity → Connect → \"Paste credentials\".\n" + + 'OmniRoute dashboard: Providers → Antigravity → Connect → "Paste credentials".\n' + "(This contains a refresh token — treat it like a password.)\n\n" + blob + "\n\n" @@ -170,6 +259,8 @@ async function runLoginAntigravity(opts) { browser: opts.browser, timeout: opts.timeout, port: opts.port, + push: opts.push, + context: opts.context, }); } catch (err) { process.stderr.write(`\nLogin failed: ${err?.message || err}\n`); @@ -188,5 +279,11 @@ export function registerLogin(program) { .option("--no-browser", "Do not auto-open the browser; print the URL instead") .option("--port ", "Fixed loopback port (default: OS-assigned)", (v) => parseInt(v, 10)) .option("--timeout ", "How long to wait for the callback", (v) => parseInt(v, 10), 300000) + .option( + "--push", + "Send the credential to the active context instead of printing it (default when that context is remote)" + ) + .option("--no-push", "Always print the blob, never contact the server") + .option("--context ", "Push to this context instead of the active one") .action(runLoginAntigravity); } diff --git a/docs/guides/REMOTE-MODE.md b/docs/guides/REMOTE-MODE.md index 2a43f03977..2e0e202227 100644 --- a/docs/guides/REMOTE-MODE.md +++ b/docs/guides/REMOTE-MODE.md @@ -121,29 +121,51 @@ 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. +Run the OAuth on **your own computer**, where `127.0.0.1` is reachable. The helper +talks to Google directly, so the consent completes where the dashboard's version +cannot. + +**If you are already connected** (`omniroute connect `), there is nothing to +copy — the helper delivers the credential to that install for you: ```bash # On your LOCAL machine (needs Node.js + a browser): +omniroute connect 192.168.0.15 # once — mints an admin-scoped context token 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: +# ↳ opens the Google consent, captures the callback on a local loopback port, +# exchanges it, and POSTs the credential to the active context: # +# Antigravity connected on http://192.168.0.15:20128 (connection abc123). +# Nothing to paste — you can close this terminal. +``` + +The push happens automatically whenever the active context points at another +machine. Force it either way with `--push` / `--no-push`, or aim at a specific +context with `--context `. + +**If your machine cannot reach the VPS** (firewalled, no SSH, air-gapped desk), the +helper still works — it only ever _needs_ Google. Use `--no-push`, or just let the +push fail: it falls back to printing the blob rather than discarding an +authorization you already completed. + +```bash +npx omniroute login antigravity --no-push # 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 +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. +> The blob contains a refresh token — treat it like a password. On the push path it +> is sent once over your context's authenticated connection; on the paste path, over +> your dashboard connection. Either way it is stored encrypted at rest, and a +> successful push never prints it to your terminal. Flags: `--no-browser` (print the URL instead of auto-opening), `--port ` -(pin the loopback port), `--timeout `. +(pin the loopback port), `--timeout `, `--push` / `--no-push` (override the +automatic delivery), `--context ` (target a specific context). ### Option B — SSH local-forward tunnel diff --git a/tests/unit/cli-login-push-remote.test.ts b/tests/unit/cli-login-push-remote.test.ts new file mode 100644 index 0000000000..541f96c2e8 --- /dev/null +++ b/tests/unit/cli-login-push-remote.test.ts @@ -0,0 +1,221 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +// @ts-expect-error — plain .mjs CLI module, no type declarations by design. +import { + isRemoteBaseUrl, + pushCredentialBlob, + runAntigravityLogin, +} from "../../bin/cli/commands/login.mjs"; + +// `omniroute login antigravity` already runs the OAuth on the operator's OWN machine — +// the only place Google's firstparty/nativeapp loopback actually resolves — but it +// stopped at PRINTING a credential blob for the operator to paste into the remote +// dashboard by hand. +// +// Every piece needed to close that loop already exists: +// - `omniroute connect ` saves an admin-scoped token in the active context; +// - `apiFetch()` injects that context's baseUrl + Bearer automatically; +// - `/api/oauth` is admin-scoped (accessScopes.ts) and stays REMOTE-REACHABLE — +// routeGuard.ts loopback-gates only `/api/oauth/cursor/auto-import`; +// - `/api/oauth//paste-credentials` already decodes a blob and persists. +// +// So the CLI can push the blob straight to the remote install. The one thing it must +// NOT do is regress the case the helper was built for: it talks only to Google, so it +// works from behind a firewall that cannot reach the VPS at all. A failed push must +// therefore fall back to printing the blob, never abort the login. + +test("isRemoteBaseUrl: loopback hosts are local, everything else is remote", () => { + assert.equal(isRemoteBaseUrl("http://localhost:20128"), false); + assert.equal(isRemoteBaseUrl("http://127.0.0.1:20128"), false); + assert.equal(isRemoteBaseUrl("http://[::1]:20128"), false); + assert.equal(isRemoteBaseUrl("http://192.168.0.15:20128"), true); + assert.equal(isRemoteBaseUrl("https://omni.example.com"), true); + // Unparseable / absent → treated as local, so we never auto-push into the unknown. + assert.equal(isRemoteBaseUrl(""), false); + assert.equal(isRemoteBaseUrl(undefined), false); +}); + +test("pushCredentialBlob POSTs the blob to the provider's paste-credentials route", async () => { + const calls: Array<{ path: string; opts: Record }> = []; + const fetchImpl = async (path: string, opts: Record) => { + calls.push({ path, opts }); + return { + ok: true, + status: 200, + json: async () => ({ success: true, connection: { id: "c1" } }), + }; + }; + + const result = await pushCredentialBlob("antigravity", "omniroute-cred-v1.abc", { fetchImpl }); + + assert.equal(result.ok, true); + assert.equal(calls.length, 1); + assert.equal(calls[0].path, "/api/oauth/antigravity/paste-credentials"); + assert.equal(calls[0].opts.method, "POST"); + assert.deepEqual(calls[0].opts.body, { blob: "omniroute-cred-v1.abc" }); +}); + +test("pushCredentialBlob surfaces a server rejection instead of pretending success", async () => { + const fetchImpl = async () => ({ + ok: false, + status: 400, + json: async () => ({ error: "Pasted credential provider mismatch" }), + }); + + const result = await pushCredentialBlob("agy", "omniroute-cred-v1.abc", { fetchImpl }); + + assert.equal(result.ok, false); + assert.match(result.error, /provider mismatch/i); +}); + +test("pushCredentialBlob turns a transport failure into a result, never a throw", async () => { + const fetchImpl = async () => { + throw new Error("ECONNREFUSED 192.168.0.15:20128"); + }; + + const result = await pushCredentialBlob("antigravity", "blob", { fetchImpl }); + + assert.equal(result.ok, false); + assert.match(result.error, /ECONNREFUSED/); +}); + +// --- end-to-end through runAntigravityLogin, with the OAuth half stubbed ------------ + +function loginDeps(overrides: Record = {}) { + const state = "st-1"; + return { + startServer: async () => ({ + port: 4321, + waitForCallback: async () => ({ code: "the-code", state }), + close: async () => {}, + }), + openBrowser: async () => {}, + exchange: async () => ({ access_token: "at", refresh_token: "rt" }), + makeState: () => state, + print: () => {}, + log: () => {}, + ...overrides, + }; +} + +test("a remote context auto-pushes and does NOT print the blob", async () => { + let pushed: { provider: string; blob: string } | null = null; + const printed: string[] = []; + + await runAntigravityLogin( + {}, + loginDeps({ + print: (s: string) => printed.push(s), + resolveContext: () => ({ baseUrl: "http://192.168.0.15:20128", accessToken: "oma_live_x" }), + push: async (provider: string, blob: string) => { + pushed = { provider, blob }; + return { ok: true, connectionId: "c1" }; + }, + }) + ); + + assert.ok(pushed, "a remote context must trigger the push"); + assert.equal(pushed!.provider, "antigravity"); + assert.match(pushed!.blob, /^omniroute-cred-v1\./); + // The blob carries a refresh token; don't spray it on a terminal when it already landed. + assert.equal( + printed.join("").includes("omniroute-cred-v1."), + false, + "a successful push must not also print the secret" + ); +}); + +test("a FAILED push falls back to printing the blob — the firewall case still works", async () => { + const printed: string[] = []; + + const blob = await runAntigravityLogin( + {}, + loginDeps({ + print: (s: string) => printed.push(s), + resolveContext: () => ({ baseUrl: "http://192.168.0.15:20128", accessToken: "oma_live_x" }), + push: async () => ({ ok: false, error: "ECONNREFUSED" }), + }) + ); + + assert.match(blob, /^omniroute-cred-v1\./); + assert.ok( + printed.join("").includes(blob), + "the operator must still get the blob to paste when the push cannot land" + ); +}); + +test("a LOCAL context prints as before — no surprise network call", async () => { + let pushCalls = 0; + const printed: string[] = []; + + await runAntigravityLogin( + {}, + loginDeps({ + print: (s: string) => printed.push(s), + resolveContext: () => ({ baseUrl: "http://localhost:20128" }), + push: async () => { + pushCalls++; + return { ok: true }; + }, + }) + ); + + assert.equal(pushCalls, 0, "a loopback context must not auto-push"); + assert.ok(printed.join("").includes("omniroute-cred-v1.")); +}); + +test("--no-push forces print-only even against a remote context", async () => { + let pushCalls = 0; + const printed: string[] = []; + + await runAntigravityLogin( + { push: false }, + loginDeps({ + print: (s: string) => printed.push(s), + resolveContext: () => ({ baseUrl: "http://192.168.0.15:20128", accessToken: "oma_live_x" }), + push: async () => { + pushCalls++; + return { ok: true }; + }, + }) + ); + + assert.equal(pushCalls, 0); + assert.ok(printed.join("").includes("omniroute-cred-v1.")); +}); + +test("--push forces the push even when the context looks local", async () => { + let pushCalls = 0; + + await runAntigravityLogin( + { push: true }, + loginDeps({ + resolveContext: () => ({ baseUrl: "http://localhost:20128" }), + push: async () => { + pushCalls++; + return { ok: true }; + }, + }) + ); + + assert.equal(pushCalls, 1, "an explicit --push must be honoured regardless of the baseUrl"); +}); + +test("a context without a token still pushes — auth may be disabled server-side", async () => { + // `isAuthenticated()` short-circuits to true when requireLogin is off, so refusing to + // push on a missing token would break perfectly valid unauthenticated installs. + let pushCalls = 0; + + await runAntigravityLogin( + {}, + loginDeps({ + resolveContext: () => ({ baseUrl: "http://192.168.0.15:20128" }), + push: async () => { + pushCalls++; + return { ok: true }; + }, + }) + ); + + assert.equal(pushCalls, 1); +});