Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,42 @@ and this project adheres to [Semantic Versioning](https://semver.org/).

## [Unreleased]

## [0.16.0] - 2026-08-17

### Added

- Added an app target selector for switching between all discovered engines,
Antigravity, and Antigravity IDE. The selected target is preserved across
HTTP requests, conversation mutations, and WebSocket updates. (#128)
- Porta can now start a standalone Antigravity CLI/core when no Language Server
is available, with configurable binary selection and clean shutdown handling.
(#127)
- Added a native production runner for launching the built proxy and web
preview together with project environment settings. (#127)

### Changed

- Conversation histories are restored from session storage and refreshed in
the background, reducing blank loading states on mobile reloads. (#128)

### Fixed

- Chat auto-scroll now follows asynchronous content growth when the user is
already near the bottom. (#128)
- Language Server discovery now invalidates stale routing state after
unavailable or not-found RPC responses and supports headless `agy` discovery
and CLI conversation storage. (#127)
- Command-action responses once again use the payload expected by Antigravity.
(#127)

### Security

- Production previews retain Vite host validation by default while still
supporting explicitly configured allowed hosts. (#127)
- Updated React Router and Hono to versions containing upstream fixes for
remote code execution, cross-site scripting, denial-of-service, and related
request-handling vulnerabilities.

## [0.15.0] - 2026-08-03

### Added
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

[![CI](https://github.com/L1M80/porta/actions/workflows/ci.yml/badge.svg)](https://github.com/L1M80/porta/actions/workflows/ci.yml)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
![Version](https://img.shields.io/badge/version-0.15.0-green)
![Version](https://img.shields.io/badge/version-0.16.0-green)

Remote web interface for [Antigravity](https://antigravity.google/) Agent Manager.
Access your local Antigravity sessions from your phone, tablet, or any remote browser through a lightweight LSP bridge.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "porta",
"version": "0.15.0",
"version": "0.16.0",
"private": true,
"scripts": {
"dev": "node scripts/dev.mjs",
Expand Down
4 changes: 2 additions & 2 deletions packages/proxy/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
},
"dependencies": {
"@hono/node-server": "^2.0.10",
"hono": "^4.12.27",
"hono": "^4.12.34",
"ws": "^8.20.1"
},
"devDependencies": {
Expand All @@ -22,4 +22,4 @@
"typescript": "^5.7.0",
"vitest": "^4.1.0"
}
}
}
19 changes: 19 additions & 0 deletions packages/proxy/src/__tests__/conversations-route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,25 @@ describe("POST /api/conversations", () => {
mockScanDiskConversations.mockResolvedValue([]);
});

it("does not fall back to a different engine when the selected engine is unavailable", async () => {
mockGetInstances.mockResolvedValue([]);

const res = await app().request("/api/conversations", {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-porta-target-app": "antigravity",
},
body: JSON.stringify({}),
});

expect(res.status).toBe(503);
await expect(res.json()).resolves.toEqual({
error: "No running Language Server found for antigravity.",
});
expect(mockRpcCall).not.toHaveBeenCalled();
});

it("sets the Antigravity 2.x required trajectory source and caches unscoped hub ownership", async () => {
const hubLS = makeInstance({ pid: 4, workspaceId: undefined });
mockGetInstances.mockResolvedValue([hubLS]);
Expand Down
1 change: 1 addition & 0 deletions packages/proxy/src/__tests__/workspaces-route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const mockRpcCall = vi.fn<
vi.mock("../routing.js", () => ({
discovery: { getInstances: mockGetInstances },
rpc: { call: mockRpcCall },
extractTargetAppDataDir: () => undefined,
}));

const { registerWorkspaceRoutes } = await import("../routes/workspaces.js");
Expand Down
14 changes: 14 additions & 0 deletions packages/proxy/src/__tests__/ws.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,20 @@ describe("WS upgrade validation", () => {
).toEqual({ ok: true, cascadeId: "abc123" });
});

it("keeps the selected target on the WebSocket upgrade", () => {
expect(
validateWebSocketUpgrade(
"/api/conversations/abc123/ws?targetApp=antigravity-ide",
"http://localhost:5173",
3100,
),
).toEqual({
ok: true,
cascadeId: "abc123",
targetApp: "antigravity-ide",
});
});

it("rejects cross-origin upgrades on the WS endpoint", () => {
const allowedOrigins = getAllowedOrigins({});

Expand Down
103 changes: 103 additions & 0 deletions packages/proxy/src/core-manager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { randomUUID } from "node:crypto";
import { spawn, type ChildProcess } from "node:child_process";
import { homedir } from "node:os";
import { join } from "node:path";

let coreProcess: ChildProcess | null = null;
let isStarting = false;

export async function ensureStandaloneCore(): Promise<void> {
if (process.env.PORTA_STANDALONE_CORE !== "true") {
return;
}

if (coreProcess) {
return;
}

if (isStarting) {
// Wait a bit if it's already starting
await new Promise((resolve) => setTimeout(resolve, 2000));
return;
}

isStarting = true;

try {
const binaryPath =
process.env.PORTA_CORE_BINARY_PATH ||
"agy";

const isWin = process.platform === "win32";
const basename = binaryPath.replace(/^.*[\\/]/, "").toLowerCase();
const isAgyCli = basename === "agy" || basename === "agy.exe";

if (isAgyCli) {
if (isWin) {
console.log(`[Core Manager] Starting standalone Antigravity CLI on Windows...`);
coreProcess = spawn(binaryPath, [], {
stdio: "ignore",
detached: true,
});
} else {
console.log(`[Core Manager] Starting standalone Antigravity CLI via script...`);
coreProcess = spawn("script", ["-q", "-c", binaryPath, "/dev/null"], {
stdio: "ignore",
detached: true,
});
}
} else {
const csrfToken = randomUUID();
console.log(`[Core Manager] Starting standalone Antigravity core from ${binaryPath} with csrf ${csrfToken}...`);

coreProcess = spawn(
binaryPath,
[
"--standalone",
"--override_ide_name", "antigravity",
"--subclient_type", "hub",
"--override_ide_version", "2.2.1",
"--override_user_agent_name", "antigravity",
"--https_server_port", "0",
"--csrf_token", csrfToken,
"--app_data_dir", "antigravity-cli",
"--api_server_url", "https://generativelanguage.googleapis.com",
"--cloud_code_endpoint", "https://daily-cloudcode-pa.googleapis.com",
"--enable_sidecars",
],
{
stdio: ["pipe", "ignore", "ignore"],
detached: true,
}
);
}

coreProcess.on("error", (err) => {
console.error(`[Core Manager] Failed to start standalone core:`, err);
coreProcess = null;
isStarting = false;
});

coreProcess.on("exit", (code) => {
console.log(`[Core Manager] Standalone core exited with code ${code}`);
coreProcess = null;
isStarting = false;
});

// Unref so it doesn't keep the proxy alive unnecessarily, though we want it to run as long as proxy runs.
coreProcess.unref();

// Give it a moment to initialize and write the daemon file
await new Promise((resolve) => setTimeout(resolve, 3000));
} finally {
isStarting = false;
}
}

export function stopStandaloneCore() {
if (coreProcess) {
console.log("[Core Manager] Stopping standalone Antigravity core...");
coreProcess.kill("SIGTERM");
coreProcess = null;
}
}
74 changes: 49 additions & 25 deletions packages/proxy/src/discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
rememberSuccessfulTransport,
type TransportProtocol,
} from "./transport-hints.js";
import { ensureStandaloneCore } from "./core-manager.js";

export interface LSInstance {
pid: number;
Expand All @@ -43,6 +44,10 @@ const DAEMON_DIRS = [
dir: join(homedir(), ".gemini", "antigravity-ide", "daemon"),
appDataDir: "antigravity-ide",
},
{
dir: join(homedir(), ".gemini", "antigravity-cli", "daemon"),
appDataDir: "antigravity-cli",
},
];
const SERVICE_PREFIX = "exa.language_server_pb.LanguageServerService";

Expand Down Expand Up @@ -405,48 +410,67 @@ export class LSDiscovery {
}

protected async discover(): Promise<LSInstance[]> {
return discoverInstances();
let instances = await discoverInstances();
if (instances.length === 0) {
await ensureStandaloneCore();
instances = await discoverInstances();
}
return instances;
}

invalidateCache(): void {
this.lastDiscovery = 0;
}

async getInstances(forceRefresh = false): Promise<LSInstance[]> {
async getInstances(
forceRefresh = false,
targetAppDataDir?: string,
): Promise<LSInstance[]> {
const now = Date.now();
const cacheFresh =
!forceRefresh &&
this.instances.length > 0 &&
now - this.lastDiscovery <= this.ttlMs;

let instances: LSInstance[];
if (cacheFresh) {
return this.instances;
}
instances = this.instances;
} else if (!forceRefresh && this.pendingDiscovery) {
instances = await this.pendingDiscovery;
} else {
const generation = ++this.discoveryGeneration;
const pending = this.discover()
.then((discovered) => {
if (generation === this.discoveryGeneration) {
this.instances = discovered;
this.lastDiscovery = Date.now();
}
return discovered;
})
.finally(() => {
if (this.pendingDiscovery === pending) {
this.pendingDiscovery = null;
}
});

if (!forceRefresh && this.pendingDiscovery) {
return this.pendingDiscovery;
this.pendingDiscovery = pending;
instances = await pending;
}

const generation = ++this.discoveryGeneration;
const pending = this.discover()
.then((instances) => {
if (generation === this.discoveryGeneration) {
this.instances = instances;
this.lastDiscovery = Date.now();
}
return instances;
})
.finally(() => {
if (this.pendingDiscovery === pending) {
this.pendingDiscovery = null;
}
});

this.pendingDiscovery = pending;
return pending;
if (targetAppDataDir && targetAppDataDir !== "all") {
return instances.filter((inst) => inst.appDataDir === targetAppDataDir);
}
return instances;
}

/**
* Get the first available instance (or a specific workspace).
*/
async getInstance(workspaceId?: string): Promise<LSInstance | null> {
const instances = await this.getInstances();
async getInstance(
workspaceId?: string,
targetAppDataDir?: string,
): Promise<LSInstance | null> {
const instances = await this.getInstances(false, targetAppDataDir);

if (workspaceId) {
return instances.find((inst) => inst.workspaceId === workspaceId) ?? null;
Expand Down
19 changes: 17 additions & 2 deletions packages/proxy/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { Hono } from "hono";
import { cors } from "hono/cors";
import { createAdaptorServer } from "@hono/node-server";

import { discovery } from "./routing.js";
import { discovery, extractTargetAppDataDir } from "./routing.js";
import { registerConversationRoutes } from "./routes/conversations.js";
import { registerModelRoutes } from "./routes/models.js";
import { registerWorkspaceRoutes } from "./routes/workspaces.js";
Expand All @@ -23,6 +23,7 @@ import {
} from "./exposure.js";
import { getAllowedOrigins, resolveCorsOrigin } from "./origins.js";
import { setupWebSocket } from "./ws.js";
import { stopStandaloneCore } from "./core-manager.js";

const PORT = parseInt(process.env.PORTA_PORT ?? "3170", 10);
const HOST = resolveProxyHost();
Expand All @@ -45,14 +46,18 @@ app.use(
// ── Health ──

app.get("/api/health", async (c) => {
const instances = await discovery.getInstances();
const instances = await discovery.getInstances(
false,
extractTargetAppDataDir(c),
);
return c.json({
status: "ok",
proxy: { port: PORT, uptime: process.uptime() },
languageServers: instances.map((i) => ({
pid: i.pid,
httpsPort: i.httpsPort,
workspaceId: i.workspaceId,
appDataDir: i.appDataDir,
source: i.source,
})),
});
Expand Down Expand Up @@ -93,3 +98,13 @@ void discovery
server.listen(PORT, HOST, () => {
console.log(`✅ Porta proxy listening on ${listenAddress}`);
});

process.on("SIGTERM", () => {
stopStandaloneCore();
process.exit(0);
});

process.on("SIGINT", () => {
stopStandaloneCore();
process.exit(0);
});
Loading
Loading