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/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
- res.writeHead(200, {
14
- "Content-Type": "application/json",
15
- "Access-Control-Allow-Origin": "*",
16
- });
17
- res.end(JSON.stringify(rotator.getStatus()));
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
- res.writeHead(200, {
22
- "Content-Type": "application/json",
23
- "Access-Control-Allow-Origin": "*",
24
- });
25
- res.end(JSON.stringify(rotator.getConfig()));
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, '&lt;')
2845
2845
  .replace(/>/g, '&gt;')
2846
- .replace(/\"/g, '&quot;')
2846
+ .replace(/"/g, '&quot;')
2847
2847
  .replace(/'/g, '&#39;');
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 TELEMETRY_BASE = "http://telemetry.dragont.ec:3800";
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
  }