mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-06 07:12:12 +03:00
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:
committed by
GitHub
parent
c485c5e0a5
commit
cfd9210bfc
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
221
tests/unit/cli-login-push-remote.test.ts
Normal file
221
tests/unit/cli-login-push-remote.test.ts
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user