feat(cli): deliver the Antigravity credential straight to the remote install (#8834)

Validated in local merge-train (devbox-vm-06-dev002) @ combined-tip (FAST gates — only pre-existing audit.test.ts flake).
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-08-05 22:39:39 -03:00
committed by GitHub
parent c485c5e0a5
commit cfd9210bfc
3 changed files with 353 additions and 13 deletions

View File

@@ -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 <host>`), 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/<provider>/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(
"<!doctype html><meta charset=utf-8><title>OmniRoute</title>" +
"<body style=\"font-family:system-ui;padding:2rem\">" +
'<body style="font-family:system-ui;padding:2rem">' +
"<h2>✅ Authorization received</h2>" +
"<p>Return to your terminal — you can close this tab.</p></body>"
);
@@ -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 <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)
.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 <name>", "Push to this context instead of the active one")
.action(runLoginAntigravity);
}

View File

@@ -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 <host>`), 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 <name>`.
**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 <n>`
(pin the loopback port), `--timeout <ms>`.
(pin the loopback port), `--timeout <ms>`, `--push` / `--no-push` (override the
automatic delivery), `--context <name>` (target a specific context).
### Option B — SSH local-forward tunnel

View File

@@ -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 <host>` 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/<provider>/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<string, unknown> }> = [];
const fetchImpl = async (path: string, opts: Record<string, unknown>) => {
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<string, unknown> = {}) {
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);
});