pi-antigravity-rotator 2.1.6 → 2.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +49 -0
- package/README.md +5 -4
- package/package.json +11 -3
- package/src/account-store.ts +30 -0
- package/src/admin-auth.ts +84 -1
- package/src/compat/cache.ts +42 -0
- package/src/compat/model-specs.ts +78 -0
- package/src/compat/schema-sanitizer.ts +277 -0
- package/src/compat/translators.ts +1538 -0
- package/src/compat.ts +1537 -2357
- package/src/dashboard.ts +11 -11
- package/src/index.ts +41 -1
- package/src/logger.ts +6 -1
- package/src/notification-poller.ts +4 -1
- package/src/oauth.ts +31 -0
- package/src/onboarding.ts +19 -0
- package/src/paths.ts +23 -3
- package/src/proxy.ts +375 -171
- package/src/responses-store.ts +203 -0
- package/src/rotator.ts +226 -16
- package/src/telemetry.ts +31 -1
- package/src/types.ts +70 -0
- package/src/validators.ts +36 -0
- package/src/antigravity-prompt.ts +0 -80
package/src/dashboard.ts
CHANGED
|
@@ -10,19 +10,19 @@ export function serveDashboard(res: ServerResponse): void {
|
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
export function serveStatusApi(res: ServerResponse, rotator: AccountRotator): void {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
13
|
+
res.writeHead(200, {
|
|
14
|
+
"Content-Type": "application/json",
|
|
15
|
+
"Cache-Control": "no-store",
|
|
16
|
+
});
|
|
17
|
+
res.end(JSON.stringify(rotator.getStatus()));
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
export function serveConfigApi(res: ServerResponse, rotator: AccountRotator): void {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
21
|
+
res.writeHead(200, {
|
|
22
|
+
"Content-Type": "application/json",
|
|
23
|
+
"Cache-Control": "no-store",
|
|
24
|
+
});
|
|
25
|
+
res.end(JSON.stringify(rotator.getConfig()));
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
export function serveConfigExportApi(res: ServerResponse, rotator: AccountRotator): void {
|
|
@@ -2843,7 +2843,7 @@ function escapeHtml(text) {
|
|
|
2843
2843
|
.replace(/&/g, '&')
|
|
2844
2844
|
.replace(/</g, '<')
|
|
2845
2845
|
.replace(/>/g, '>')
|
|
2846
|
-
.replace(
|
|
2846
|
+
.replace(/"/g, '"')
|
|
2847
2847
|
.replace(/'/g, ''');
|
|
2848
2848
|
}
|
|
2849
2849
|
|
package/src/index.ts
CHANGED
|
@@ -8,7 +8,11 @@ import { startProxy } from "./proxy.js";
|
|
|
8
8
|
import { getConfigDir } from "./paths.js";
|
|
9
9
|
import { TelemetryReporter, setActiveReporter } from "./telemetry.js";
|
|
10
10
|
import { loadConfigFromDisk } from "./account-store.js";
|
|
11
|
-
import { getConfiguredAdminToken } from "./admin-auth.js";
|
|
11
|
+
import { ensureAdminToken, getConfiguredAdminToken, setPersistedAdminToken } from "./admin-auth.js";
|
|
12
|
+
import { warnIfUsingFallbackOAuthCreds } from "./oauth.js";
|
|
13
|
+
import { warnIfInsecureTelemetryEndpoint } from "./telemetry.js";
|
|
14
|
+
import { setModelSpecsOverride, loadResponsesStore, flushResponsesStoreSync } from "./compat.js";
|
|
15
|
+
import { setModelAliasesOverride } from "./types.js";
|
|
12
16
|
import { writeTextFileAtomic } from "./storage.js";
|
|
13
17
|
|
|
14
18
|
function loadConfig(): Config {
|
|
@@ -74,6 +78,33 @@ function maybeShowStarNudge(): void {
|
|
|
74
78
|
try { writeTextFileAtomic(promptedPath, String(Date.now())); } catch { /* best effort */ }
|
|
75
79
|
}
|
|
76
80
|
|
|
81
|
+
/**
|
|
82
|
+
* Resolve the effective admin token at startup. If no PI_ROTATOR_ADMIN_TOKEN
|
|
83
|
+
* env var is set and no .admin-token file exists, a new token is generated,
|
|
84
|
+
* persisted to .admin-token, and printed to the operator once. This ensures
|
|
85
|
+
* admin routes are protected by default on first run.
|
|
86
|
+
*/
|
|
87
|
+
function bootstrapAdminToken(configDir: string): void {
|
|
88
|
+
const resolved = ensureAdminToken(configDir);
|
|
89
|
+
setPersistedAdminToken(resolved.token);
|
|
90
|
+
if (resolved.source === "generated") {
|
|
91
|
+
console.log();
|
|
92
|
+
console.log(" ╭──────────────────────────────────────────────────────────╮");
|
|
93
|
+
console.log(" │ Generated admin token (persisted to .admin-token): │");
|
|
94
|
+
const tokenPreview = resolved.token.length > 12
|
|
95
|
+
? `${resolved.token.slice(0, 8)}…${resolved.token.slice(-4)}`
|
|
96
|
+
: resolved.token;
|
|
97
|
+
console.log(` │ ${tokenPreview} │`);
|
|
98
|
+
console.log(" │ (full token in .admin-token; cat to view) │");
|
|
99
|
+
console.log(" │ │");
|
|
100
|
+
console.log(" │ Header: x-rotator-admin-token: <token> │");
|
|
101
|
+
console.log(" │ Bearer: Authorization: Bearer <token> │");
|
|
102
|
+
console.log(" │ URL: <url>?token=<token> │");
|
|
103
|
+
console.log(" ╰──────────────────────────────────────────────────────────╯");
|
|
104
|
+
console.log();
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
77
108
|
function maybeWarnAboutAdminExposure(config: Config): void {
|
|
78
109
|
if (getConfiguredAdminToken()) return;
|
|
79
110
|
console.warn("WARNING: PI_ROTATOR_ADMIN_TOKEN is not configured.");
|
|
@@ -103,7 +134,13 @@ export function main(): void {
|
|
|
103
134
|
console.log();
|
|
104
135
|
|
|
105
136
|
maybeShowStarNudge();
|
|
137
|
+
bootstrapAdminToken(getConfigDir());
|
|
106
138
|
maybeWarnAboutAdminExposure(config);
|
|
139
|
+
warnIfUsingFallbackOAuthCreds();
|
|
140
|
+
warnIfInsecureTelemetryEndpoint();
|
|
141
|
+
setModelSpecsOverride(config.modelSpecs ?? null);
|
|
142
|
+
setModelAliasesOverride(config.modelAliases ?? null);
|
|
143
|
+
void loadResponsesStore();
|
|
107
144
|
|
|
108
145
|
const rotator = new AccountRotator(config);
|
|
109
146
|
|
|
@@ -134,6 +171,9 @@ export function main(): void {
|
|
|
134
171
|
// ── Graceful shutdown ──
|
|
135
172
|
const shutdown = async (): Promise<void> => {
|
|
136
173
|
console.log("\nShutting down...");
|
|
174
|
+
flushResponsesStoreSync();
|
|
175
|
+
rotator.flushPendingStateSaveSync();
|
|
176
|
+
rotator.flushPendingTokenUsageSaveSync();
|
|
137
177
|
await telemetry.shutdown();
|
|
138
178
|
rotator.stopQuotaPolling();
|
|
139
179
|
process.exit(0);
|
package/src/logger.ts
CHANGED
|
@@ -33,7 +33,12 @@ export function redactSensitive(input: unknown): string {
|
|
|
33
33
|
.replace(/(authorization["'\s:=]+)(Bearer\s+)?[A-Za-z0-9._~+/=-]+/gi, "$1[REDACTED]")
|
|
34
34
|
.replace(/("?(?:refresh_token|refreshToken|access_token|accessToken|client_secret|clientSecret)"?\s*[:=]\s*")[^"]+(")/gi, "$1[REDACTED]$2")
|
|
35
35
|
.replace(/("?(?:refresh_token|refreshToken|access_token|accessToken|client_secret|clientSecret)"?\s*[:=]\s*)[^\s,&}]+/gi, "$1[REDACTED]")
|
|
36
|
-
.replace(/1\/\/[A-Za-z0-9._~+/=-]+/g, "1//[REDACTED]")
|
|
36
|
+
.replace(/1\/\/[A-Za-z0-9._~+/=-]+/g, "1//[REDACTED]")
|
|
37
|
+
// Redact sensitive keys when they appear as querystring parameters (?key=val&...)
|
|
38
|
+
// or as path segments. Matches both with and without surrounding quotes/encoding.
|
|
39
|
+
.replace(/([?&](?:access_token|accessToken|refresh_token|refreshToken|token|api_key|apikey|key)=)[^&\s#]+/gi, "$1[REDACTED]")
|
|
40
|
+
// Redact when the key is followed by = and a value in a query-like context
|
|
41
|
+
.replace(/(\?|&)(token|access_token|accessToken|refresh_token|refreshToken|api_key|apikey|key)=([^&\s#]+)/gi, "$1$2=[REDACTED]");
|
|
37
42
|
}
|
|
38
43
|
|
|
39
44
|
export class Logger {
|
|
@@ -13,7 +13,10 @@ import { isTelemetryEnabled } from "./telemetry.js";
|
|
|
13
13
|
const notifLogger = logger.child("notifications");
|
|
14
14
|
|
|
15
15
|
// Same base URL as telemetry endpoint (just different path)
|
|
16
|
-
const
|
|
16
|
+
const DEFAULT_TELEMETRY_BASE = "http://telemetry.dragont.ec:3800";
|
|
17
|
+
const TELEMETRY_BASE = process.env.PI_ROTATOR_TELEMETRY_URL?.trim()
|
|
18
|
+
? new URL(process.env.PI_ROTATOR_TELEMETRY_URL).origin
|
|
19
|
+
: DEFAULT_TELEMETRY_BASE;
|
|
17
20
|
const NOTIFICATIONS_URL = `${TELEMETRY_BASE}/v1/notifications`;
|
|
18
21
|
|
|
19
22
|
const POLL_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes
|
package/src/oauth.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { createHash, randomBytes } from "node:crypto";
|
|
2
2
|
import { CLIENT_ID, CLIENT_SECRET, TOKEN_URL } from "./types.js";
|
|
3
3
|
import { fetchWithRetry } from "./fetch-with-retry.js";
|
|
4
|
+
import { logger } from "./logger.js";
|
|
5
|
+
|
|
6
|
+
const oauthLogger = logger.child("oauth");
|
|
4
7
|
|
|
5
8
|
export const DEFAULT_REDIRECT_URI = "http://localhost:51121/oauth-callback";
|
|
6
9
|
export const SCOPES = [
|
|
@@ -32,6 +35,34 @@ export function getOAuthClientConfig(): OAuthClientConfig {
|
|
|
32
35
|
};
|
|
33
36
|
}
|
|
34
37
|
|
|
38
|
+
let warnedAboutFallback = false;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Check whether the rotator is running on the hardcoded fallback OAuth
|
|
42
|
+
* credentials (the public Antigravity IDE client shipped in types.ts). When
|
|
43
|
+
* the operator has not provided ANTIGRAVITY_CLIENT_ID / ANTIGRAVITY_CLIENT_SECRET,
|
|
44
|
+
* every Google OAuth call uses the bundled client, which may violate Google's
|
|
45
|
+
* ToS for third-party usage of the official client.
|
|
46
|
+
*
|
|
47
|
+
* The warning is printed at most once per process to avoid log spam.
|
|
48
|
+
*/
|
|
49
|
+
export function warnIfUsingFallbackOAuthCreds(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
50
|
+
const usingFallbackId = !env.ANTIGRAVITY_CLIENT_ID?.trim();
|
|
51
|
+
const usingFallbackSecret = !env.ANTIGRAVITY_CLIENT_SECRET?.trim();
|
|
52
|
+
if (!usingFallbackId && !usingFallbackSecret) return false;
|
|
53
|
+
if (warnedAboutFallback) return true;
|
|
54
|
+
warnedAboutFallback = true;
|
|
55
|
+
const missing: string[] = [];
|
|
56
|
+
if (usingFallbackId) missing.push("ANTIGRAVITY_CLIENT_ID");
|
|
57
|
+
if (usingFallbackSecret) missing.push("ANTIGRAVITY_CLIENT_SECRET");
|
|
58
|
+
oauthLogger.log("warn",
|
|
59
|
+
`Using the bundled fallback OAuth client credentials (missing env: ${missing.join(", ")}). ` +
|
|
60
|
+
`This identifies the rotator as the official Antigravity IDE client. ` +
|
|
61
|
+
`For self-hosted deployments, set ${missing.join(" and ")} to your own registered OAuth client.`,
|
|
62
|
+
);
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
|
|
35
66
|
export function isHostedOAuthConfigured(): boolean {
|
|
36
67
|
try {
|
|
37
68
|
const { redirectUri } = getOAuthClientConfig();
|
package/src/onboarding.ts
CHANGED
|
@@ -19,6 +19,7 @@ interface PendingSession {
|
|
|
19
19
|
|
|
20
20
|
const pendingSessions = new Map<string, PendingSession>();
|
|
21
21
|
const SESSION_TTL_MS = 15 * 60 * 1000;
|
|
22
|
+
const SESSION_PRUNE_INTERVAL_MS = 5 * 60 * 1000;
|
|
22
23
|
|
|
23
24
|
function prunePendingSessions(): void {
|
|
24
25
|
const cutoff = Date.now() - SESSION_TTL_MS;
|
|
@@ -29,6 +30,24 @@ function prunePendingSessions(): void {
|
|
|
29
30
|
}
|
|
30
31
|
}
|
|
31
32
|
|
|
33
|
+
// Background reaper. Without this, a long-lived proxy that never sees
|
|
34
|
+
// /auth/antigravity/start or /callback would accumulate stale sessions
|
|
35
|
+
// (each is a 96-byte PKCE verifier + timestamp). The interval is unref'd
|
|
36
|
+
// so it does not block process exit.
|
|
37
|
+
let pruneTimer: ReturnType<typeof setInterval> | null = null;
|
|
38
|
+
function startPendingSessionReaper(): void {
|
|
39
|
+
if (pruneTimer) return;
|
|
40
|
+
pruneTimer = setInterval(() => prunePendingSessions(), SESSION_PRUNE_INTERVAL_MS);
|
|
41
|
+
if (pruneTimer.unref) pruneTimer.unref();
|
|
42
|
+
}
|
|
43
|
+
function stopPendingSessionReaper(): void {
|
|
44
|
+
if (pruneTimer) {
|
|
45
|
+
clearInterval(pruneTimer);
|
|
46
|
+
pruneTimer = null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
startPendingSessionReaper();
|
|
50
|
+
|
|
32
51
|
function renderPage(title: string, body: string): string {
|
|
33
52
|
return `<!DOCTYPE html>
|
|
34
53
|
<html lang="en">
|
package/src/paths.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Default: ~/.pi-antigravity-rotator/
|
|
3
3
|
// Override: --config-dir <path> or PI_ROTATOR_DIR env var
|
|
4
4
|
|
|
5
|
-
import { join } from "node:path";
|
|
5
|
+
import { join, resolve, sep } from "node:path";
|
|
6
6
|
import { homedir } from "node:os";
|
|
7
7
|
import { mkdirSync } from "node:fs";
|
|
8
8
|
|
|
@@ -10,15 +10,35 @@ const DEFAULT_DIR = join(homedir(), ".pi-antigravity-rotator");
|
|
|
10
10
|
|
|
11
11
|
let configDir: string | null = null;
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Validate that a user-supplied config dir doesn't contain obvious path
|
|
15
|
+
* traversal. Refuses to mkdir and returns the default if the input contains
|
|
16
|
+
* `..` segments that escape the intended root. Throws otherwise.
|
|
17
|
+
*/
|
|
18
|
+
export function resolveSafeConfigDir(input: string, source: "argv" | "env" = "argv"): string {
|
|
19
|
+
// Check the ORIGINAL input (before resolve() collapses ".."), since
|
|
20
|
+
// resolve("/a/../../b") silently becomes "/b" and would mask the attack.
|
|
21
|
+
const segments = input.split(/[\\/]+/);
|
|
22
|
+
for (const seg of segments) {
|
|
23
|
+
if (seg === "..") {
|
|
24
|
+
throw new Error(
|
|
25
|
+
`Refusing --config-dir="${input}" from ${source}: contains '..' segment which could escape the config root. ` +
|
|
26
|
+
`Use an absolute path or PI_ROTATOR_DIR env var set by the container orchestrator.`,
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return resolve(input);
|
|
31
|
+
}
|
|
32
|
+
|
|
13
33
|
export function getConfigDir(): string {
|
|
14
34
|
if (configDir) return configDir;
|
|
15
35
|
|
|
16
36
|
// Check CLI arg
|
|
17
37
|
const idx = process.argv.indexOf("--config-dir");
|
|
18
38
|
if (idx !== -1 && process.argv[idx + 1]) {
|
|
19
|
-
configDir = process.argv[idx + 1];
|
|
39
|
+
configDir = resolveSafeConfigDir(process.argv[idx + 1], "argv");
|
|
20
40
|
} else if (process.env.PI_ROTATOR_DIR) {
|
|
21
|
-
configDir = process.env.PI_ROTATOR_DIR;
|
|
41
|
+
configDir = resolveSafeConfigDir(process.env.PI_ROTATOR_DIR, "env");
|
|
22
42
|
} else {
|
|
23
43
|
configDir = DEFAULT_DIR;
|
|
24
44
|
}
|