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); +});