Compare commits

..
Author SHA1 Message Date
Mohamed Boudra ca0e14e549 fix(relay): order replacement sockets explicitly
Wall-clock timestamps can tie during rapid daemon socket replacement. Persist a per-connection generation so forwarding ownership follows acceptance order, including when replacing pre-generation sockets during rollout.
2026-08-12 10:04:16 +00:00
Mohamed Boudra c4cb6aed30 fix(relay): prevent duplicate client frame delivery
Cloudflare may retain closing daemon data sockets under the same connection tag while their replacements are already active. Forwarding to every tagged socket multiplied state-changing requests; select the newest daemon socket so each client frame is handled once.
2026-08-12 09:58:04 +00:00
18 changed files with 137 additions and 343 deletions
+3 -5
View File
@@ -131,11 +131,9 @@ desktop-only environment inherited by terminals opened inside Paseo from couplin
a new worktree instance to the parent desktop instance's profile or single-instance a new worktree instance to the parent desktop instance's profile or single-instance
lock. lock.
The desktop workspace script `exec`s the dev runner so the terminal owns the runner Electron remains in the desktop dev runner's process group. Closing or stopping the
PID. Terminal shutdown reaches the runner as `SIGHUP`; the runner stops Metro and workspace-script terminal must terminate Electron with Metro; detaching Electron
asks Electron to quit through its normal app lifecycle. Do not add an npm wrapper or leaves an orphan holding the worktree's single-instance lock and broken output pipes.
detach Electron: either change leaves an orphan holding the worktree's single-instance
lock and broken output pipes.
With desktop dev running, verify the real BrowserWindow, titlebar clearance, fullscreen With desktop dev running, verify the real BrowserWindow, titlebar clearance, fullscreen
transition, and 751-pixel settings split with: transition, and 751-pixel settings split with:
@@ -88,43 +88,6 @@ describe("useDraftAgentCreateFlow", () => {
expect(onCreateSuccess).toHaveBeenCalledTimes(1); expect(onCreateSuccess).toHaveBeenCalledTimes(1);
}); });
it("starts one create request when submit is invoked twice before React rerenders", async () => {
let resolveCreate: ((value: { agentId: string; result: { id: string } }) => void) | undefined;
const createRequest = vi.fn(
() =>
new Promise<{ agentId: string; result: { id: string } }>((resolve) => {
resolveCreate = resolve;
}),
);
const onCreateSuccess = vi.fn();
const { result } = renderHook(() =>
useDraftAgentCreateFlow({
draftId: "draft-1",
getPendingServerId: () => "server-1",
buildDraftAgent: (attempt) => ({ attempt }),
createRequest,
onCreateSuccess,
}),
);
const submission = { text: "build this", attachments: [], cwd: "/repo" };
let first: Promise<void>;
let duplicate: Promise<void>;
act(() => {
first = result.current.handleCreateFromInput(submission);
duplicate = result.current.handleCreateFromInput(submission);
});
await expect(duplicate!).rejects.toThrow();
expect(createRequest).toHaveBeenCalledTimes(1);
await act(async () => {
resolveCreate?.({ agentId: "agent-1", result: { id: "agent-1" } });
await first!;
});
expect(onCreateSuccess).toHaveBeenCalledTimes(1);
});
it("allows retrying an empty prompt when the draft still has context attachments", async () => { it("allows retrying an empty prompt when the draft still has context attachments", async () => {
const attachment = { const attachment = {
kind: "chat_history", kind: "chat_history",
+3 -16
View File
@@ -84,17 +84,6 @@ interface CreateRequestContext {
cwd: string; cwd: string;
} }
function assertCreateAvailable(input: {
draftId: string;
isSubmitting: boolean;
alreadyLoadingMessage: string;
}): void {
const pending = useCreateFlowStore.getState().pendingByDraftId[input.draftId];
if (input.isSubmitting || pending?.lifecycle === "active") {
throw new Error(input.alreadyLoadingMessage);
}
}
interface UseDraftAgentCreateFlowOptions<TDraftAgent, TCreateResult> { interface UseDraftAgentCreateFlowOptions<TDraftAgent, TCreateResult> {
draftId: string; draftId: string;
getPendingServerId: () => string | null; getPendingServerId: () => string | null;
@@ -250,11 +239,9 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
const handleCreateFromInput = useCallback( const handleCreateFromInput = useCallback(
async ({ text, attachments, cwd }: SubmitContext) => { async ({ text, attachments, cwd }: SubmitContext) => {
assertCreateAvailable({ if (isSubmitting) {
draftId, throw new Error(t("composer.errors.alreadyLoading"));
isSubmitting, }
alreadyLoadingMessage: t("composer.errors.alreadyLoading"),
});
dispatch({ type: "DRAFT_SET_ERROR", message: "" }); dispatch({ type: "DRAFT_SET_ERROR", message: "" });
const trimmedPrompt = text.trim(); const trimmedPrompt = text.trim();
@@ -15,9 +15,3 @@ export function createElectronSpawnOptions({ env, colorEnv, expoDevUrl }) {
export function resolveChildKillTarget(pid, detached) { export function resolveChildKillTarget(pid, detached) {
return detached ? -pid : pid; return detached ? -pid : pid;
} }
export function registerDevRunnerShutdownSignals({ signalSource, stop }) {
for (const signal of ["SIGHUP", "SIGINT", "SIGTERM"]) {
signalSource.on(signal, () => stop("SIGTERM"));
}
}
@@ -1,23 +1,7 @@
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
import { readFileSync } from "node:fs"; import { createElectronSpawnOptions, resolveChildKillTarget } from "./dev-runner-config.mjs";
import {
createElectronSpawnOptions,
registerDevRunnerShutdownSignals,
resolveChildKillTarget,
} from "./dev-runner-config.mjs";
describe("desktop dev process ownership", () => { describe("desktop dev process ownership", () => {
test("makes the signal-handling runner the workspace terminal process", () => {
const paseoConfig = JSON.parse(
readFileSync(new URL("../../../paseo.json", import.meta.url), "utf8"),
);
const devScript = readFileSync(new URL("./dev.sh", import.meta.url), "utf8");
expect(paseoConfig.scripts.desktop.command).toContain("exec ./packages/desktop/scripts/dev.sh");
expect(devScript).toContain('npm --prefix "$DESKTOP_DIR" run build:main');
expect(devScript).toContain('exec node "$SCRIPT_DIR/dev-runner.mjs"');
});
test("keeps Electron in the runner process group", () => { test("keeps Electron in the runner process group", () => {
const options = createElectronSpawnOptions({ const options = createElectronSpawnOptions({
env: { PATH: "/usr/bin" }, env: { PATH: "/usr/bin" },
@@ -39,24 +23,4 @@ describe("desktop dev process ownership", () => {
expect(resolveChildKillTarget(42, true)).toBe(-42); expect(resolveChildKillTarget(42, true)).toBe(-42);
expect(resolveChildKillTarget(42, false)).toBe(42); expect(resolveChildKillTarget(42, false)).toBe(42);
}); });
test("stops children when the owning terminal hangs up", () => {
const listeners = new Map();
const receivedSignals = [];
registerDevRunnerShutdownSignals({
signalSource: {
on(signal, listener) {
listeners.set(signal, listener);
},
},
stop(signal) {
receivedSignals.push(signal);
},
});
expect(Array.from(listeners.keys())).toEqual(["SIGHUP", "SIGINT", "SIGTERM"]);
listeners.get("SIGHUP")();
expect(receivedSignals).toEqual(["SIGTERM"]);
});
}); });
+3 -6
View File
@@ -4,11 +4,7 @@ import path from "node:path";
import { execFileSync, spawn } from "node:child_process"; import { execFileSync, spawn } from "node:child_process";
import { createRequire } from "node:module"; import { createRequire } from "node:module";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { import { createElectronSpawnOptions, resolveChildKillTarget } from "./dev-runner-config.mjs";
createElectronSpawnOptions,
registerDevRunnerShutdownSignals,
resolveChildKillTarget,
} from "./dev-runner-config.mjs";
import { resolveDevElectronArgs } from "./dev-runner-args.mjs"; import { resolveDevElectronArgs } from "./dev-runner-args.mjs";
@@ -163,7 +159,8 @@ function canConnect(port, host) {
}); });
} }
registerDevRunnerShutdownSignals({ signalSource: process, stop: stopAll }); process.on("SIGINT", () => stopAll("SIGTERM"));
process.on("SIGTERM", () => stopAll("SIGTERM"));
spawnChild("metro", "npx", ["expo", "start", "--port", String(expoPort)], { spawnChild("metro", "npx", ["expo", "start", "--port", String(expoPort)], {
cwd: appDir, cwd: appDir,
+1 -1
View File
@@ -31,7 +31,7 @@ export PASEO_DAEMON_ENDPOINT="$DAEMON_ENDPOINT"
export PASEO_CORS_ORIGINS="${PASEO_CORS_ORIGINS:-*}" export PASEO_CORS_ORIGINS="${PASEO_CORS_ORIGINS:-*}"
npm --prefix "$DESKTOP_DIR" run build:main npm run build:main
echo "══════════════════════════════════════════════════════" echo "══════════════════════════════════════════════════════"
echo " Paseo Desktop Dev" echo " Paseo Desktop Dev"
@@ -3,7 +3,6 @@ import { describe, expect, it } from "vitest";
import { DEFAULT_DESKTOP_SETTINGS } from "../settings/desktop-settings"; import { DEFAULT_DESKTOP_SETTINGS } from "../settings/desktop-settings";
import { import {
createQuitLifecycle, createQuitLifecycle,
registerExternalQuitSignals,
shouldStopDesktopManagedDaemonOnQuit, shouldStopDesktopManagedDaemonOnQuit,
stopDesktopManagedDaemonOnQuitIfNeeded, stopDesktopManagedDaemonOnQuitIfNeeded,
} from "./quit-lifecycle"; } from "./quit-lifecycle";
@@ -30,25 +29,6 @@ function waitForQuitLifecycle(): Promise<void> {
} }
describe("quit-lifecycle", () => { describe("quit-lifecycle", () => {
it("turns external termination signals into one Electron quit", () => {
const listeners = new Map<NodeJS.Signals, () => void>();
const quits: string[] = [];
registerExternalQuitSignals({
signals: {
on: (signal, listener) => {
listeners.set(signal, listener);
},
},
quit: () => quits.push("quit"),
});
expect(Array.from(listeners.keys())).toEqual(["SIGHUP", "SIGINT", "SIGTERM"]);
listeners.get("SIGTERM")?.();
listeners.get("SIGHUP")?.();
expect(quits).toEqual(["quit"]);
});
it("stops by default and only keeps running when keepRunningAfterQuit is enabled", () => { it("stops by default and only keeps running when keepRunningAfterQuit is enabled", () => {
expect(shouldStopDesktopManagedDaemonOnQuit(SETTINGS_STOP_ON_QUIT)).toBe(true); expect(shouldStopDesktopManagedDaemonOnQuit(SETTINGS_STOP_ON_QUIT)).toBe(true);
expect(shouldStopDesktopManagedDaemonOnQuit(SETTINGS_KEEP_RUNNING)).toBe(false); expect(shouldStopDesktopManagedDaemonOnQuit(SETTINGS_KEEP_RUNNING)).toBe(false);
@@ -14,10 +14,6 @@ interface BeforeQuitApp {
exit(code: number): void; exit(code: number): void;
} }
interface ExternalQuitSignalSource {
on(signal: NodeJS.Signals, listener: () => void): unknown;
}
interface QuitLifecycle { interface QuitLifecycle {
handleBeforeQuit(event: BeforeQuitEvent): void; handleBeforeQuit(event: BeforeQuitEvent): void;
handleBeforeQuitForUpdate(): void; handleBeforeQuitForUpdate(): void;
@@ -35,23 +31,6 @@ export interface StopOnQuitDeps {
showShutdownFeedback: () => void; showShutdownFeedback: () => void;
} }
export function registerExternalQuitSignals({
signals,
quit,
}: {
signals: ExternalQuitSignalSource;
quit: () => void;
}): void {
let quitRequested = false;
for (const signal of ["SIGHUP", "SIGINT", "SIGTERM"] satisfies NodeJS.Signals[]) {
signals.on(signal, () => {
if (quitRequested) return;
quitRequested = true;
quit();
});
}
}
export function shouldStopDesktopManagedDaemonOnQuit(settings: QuitLifecycleSettings): boolean { export function shouldStopDesktopManagedDaemonOnQuit(settings: QuitLifecycleSettings): boolean {
return !settings.daemon.keepRunningAfterQuit; return !settings.daemon.keepRunningAfterQuit;
} }
-2
View File
@@ -83,7 +83,6 @@ import {
} from "./daemon/daemon-manager.js"; } from "./daemon/daemon-manager.js";
import { import {
createQuitLifecycle, createQuitLifecycle,
registerExternalQuitSignals,
stopDesktopManagedDaemonOnQuitIfNeeded, stopDesktopManagedDaemonOnQuitIfNeeded,
} from "./daemon/quit-lifecycle.js"; } from "./daemon/quit-lifecycle.js";
import { runDesktopStartup } from "./desktop-startup.js"; import { runDesktopStartup } from "./desktop-startup.js";
@@ -1082,7 +1081,6 @@ const quitLifecycle = createQuitLifecycle({
// electron-updater forwards this event through Electron's built-in autoUpdater. // electron-updater forwards this event through Electron's built-in autoUpdater.
electronAutoUpdater.on("before-quit-for-update", quitLifecycle.handleBeforeQuitForUpdate); electronAutoUpdater.on("before-quit-for-update", quitLifecycle.handleBeforeQuitForUpdate);
app.on("before-quit", quitLifecycle.handleBeforeQuit); app.on("before-quit", quitLifecycle.handleBeforeQuit);
registerExternalQuitSignals({ signals: process, quit: () => app.quit() });
app.on("window-all-closed", () => { app.on("window-all-closed", () => {
if (process.platform !== "darwin") { if (process.platform !== "darwin") {
@@ -230,6 +230,74 @@ describe("RelayDurableObject control nudge/reset behavior", () => {
}); });
}); });
describe("RelayDurableObject data forwarding", () => {
it("forwards a client frame only to the newest daemon socket during replacement", () => {
const connectionId = "clt_replacing_daemon";
const client = createMockSocket({
version: "2",
role: "client",
connectionId,
serverId: "srv_test",
createdAt: 1,
});
const daemonSockets = Array.from({ length: 6 }, (_, index) =>
createMockSocket({
version: "2",
role: "server",
connectionId,
serverId: "srv_test",
generation: index + 1,
createdAt: 1,
}),
);
const { state, setTagSockets } = createMockState();
setTagSockets(`server:${connectionId}`, daemonSockets);
const relay = new RelayDurableObject(state as unknown as DurableObjectStateArg);
relay.webSocketMessage(client as unknown as WebSocket, "create_agent_request");
for (const staleSocket of daemonSockets.slice(0, -1)) {
expect(staleSocket.send).not.toHaveBeenCalled();
}
expect(daemonSockets.at(-1)?.send).toHaveBeenCalledOnce();
expect(daemonSockets.at(-1)?.send).toHaveBeenCalledWith("create_agent_request");
});
it("prefers a generated replacement over rollout sockets without generations", () => {
const connectionId = "clt_rollout";
const client = createMockSocket({
version: "2",
role: "client",
connectionId,
serverId: "srv_test",
createdAt: 1,
});
const staleSocket = createMockSocket({
version: "2",
role: "server",
connectionId,
serverId: "srv_test",
createdAt: 2,
});
const replacementSocket = createMockSocket({
version: "2",
role: "server",
connectionId,
serverId: "srv_test",
generation: 1,
createdAt: 2,
});
const { state, setTagSockets } = createMockState();
setTagSockets(`server:${connectionId}`, [staleSocket, replacementSocket]);
const relay = new RelayDurableObject(state as unknown as DurableObjectStateArg);
relay.webSocketMessage(client as unknown as WebSocket, "create_agent_request");
expect(staleSocket.send).not.toHaveBeenCalled();
expect(replacementSocket.send).toHaveBeenCalledWith("create_agent_request");
});
});
describe("relay worker endpoint routing", () => { describe("relay worker endpoint routing", () => {
it("routes missing v to legacy v1 isolated DO ids", async () => { it("routes missing v to legacy v1 isolated DO ids", async () => {
const fetch = vi.fn( const fetch = vi.fn(
+32 -6
View File
@@ -86,6 +86,29 @@ function getString(record: Record<string, unknown>, key: string): string | undef
return typeof value === "string" ? value : undefined; return typeof value === "string" ? value : undefined;
} }
function getSocketGeneration(ws: WebSocket): number {
const attachment = deserializeAttachment(ws);
if (!isRecord(attachment)) return 0;
return typeof attachment.generation === "number" ? attachment.generation : 0;
}
function selectNewestSocket(sockets: WebSocket[]): WebSocket | null {
let newest: WebSocket | null = null;
let newestGeneration = Number.NEGATIVE_INFINITY;
for (const socket of sockets) {
const generation = getSocketGeneration(socket);
if (generation > newestGeneration) {
newest = socket;
newestGeneration = generation;
}
}
return newest;
}
function nextSocketGeneration(sockets: WebSocket[]): number {
return sockets.reduce((highest, socket) => Math.max(highest, getSocketGeneration(socket)), 0) + 1;
}
function getGlobalWebSocketPair(): (new () => WebSocketPair) | undefined { function getGlobalWebSocketPair(): (new () => WebSocketPair) | undefined {
// Access WebSocketPair from global scope (Cloudflare Workers runtime) // Access WebSocketPair from global scope (Cloudflare Workers runtime)
// Use Reflect to access global property without type assertions // Use Reflect to access global property without type assertions
@@ -352,6 +375,9 @@ export class RelayDurableObject {
const isServerControl = role === "server" && !resolvedConnectionId; const isServerControl = role === "server" && !resolvedConnectionId;
const isServerData = role === "server" && !!resolvedConnectionId; const isServerData = role === "server" && !!resolvedConnectionId;
const serverDataGeneration = isServerData
? nextSocketGeneration(this.state.getWebSockets(`server:${resolvedConnectionId}`))
: undefined;
// Close any existing server-side connection with the same identity. // Close any existing server-side connection with the same identity.
// - server-control: single per serverId // - server-control: single per serverId
@@ -377,6 +403,7 @@ export class RelayDurableObject {
role, role,
version: CURRENT_RELAY_VERSION, version: CURRENT_RELAY_VERSION,
connectionId: resolvedConnectionId || null, connectionId: resolvedConnectionId || null,
...(serverDataGeneration === undefined ? {} : { generation: serverDataGeneration }),
createdAt: Date.now(), createdAt: Date.now(),
}; };
serializeAttachment(server, attachment); serializeAttachment(server, attachment);
@@ -484,12 +511,11 @@ export class RelayDurableObject {
this.bufferFrame(connectionId, message); this.bufferFrame(connectionId, message);
return; return;
} }
for (const target of servers) { const target = selectNewestSocket(servers);
try { try {
target.send(message); target?.send(message);
} catch (error) { } catch (error) {
console.error(`[Relay DO] Failed to forward client->server(${connectionId}):`, error); console.error(`[Relay DO] Failed to forward client->server(${connectionId}):`, error);
}
} }
return; return;
} }
+2
View File
@@ -24,5 +24,7 @@ export interface RelaySessionAttachment {
* independent socket + E2EE channel per connected connection. * independent socket + E2EE channel per connected connection.
*/ */
connectionId?: string | null; connectionId?: string | null;
/** Monotonic acceptance order for v2 daemon data sockets sharing a connectionId. */
generation?: number;
createdAt: number; createdAt: number;
} }
@@ -1,6 +1,8 @@
import { execFile } from "node:child_process";
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
import { homedir } from "node:os"; import { homedir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { promisify } from "node:util";
import type { Logger } from "pino"; import type { Logger } from "pino";
import { z } from "zod"; import { z } from "zod";
import type { ProviderUsage, ProviderUsageBalance } from "../../../server/messages.js"; import type { ProviderUsage, ProviderUsageBalance } from "../../../server/messages.js";
@@ -14,25 +16,8 @@ import {
unavailableUsage, unavailableUsage,
} from "../usage.js"; } from "../usage.js";
// Cursor stores auth in VS Code's ItemTable (state.vscdb). Modern builds keep the const execFileAsync = promisify(execFile);
// access token as a plain JWT string under `cursorAuth/accessToken`; older builds const CURSOR_SQLITE_TIMEOUT_MS = 2_000;
// kept a JSON blob under `cursorAuthStatus`. Read it with node:sqlite so we don't
// depend on a `sqlite3` CLI, which isn't installed by default on Windows (or on many
// Linux hosts) — a missing binary silently rendered Cursor usage unavailable.
const CURSOR_ACCESS_TOKEN_KEY = "cursorAuth/accessToken";
const CURSOR_LEGACY_AUTH_KEY = "cursorAuthStatus";
// @types/node@20 predates the node:sqlite typings; declare the slice we use.
interface CursorStateStatement {
get(...params: unknown[]): Record<string, unknown> | undefined;
}
interface CursorStateDatabase {
prepare(sql: string): CursorStateStatement;
close(): void;
}
interface NodeSqliteModule {
DatabaseSync: new (path: string, options?: { readOnly?: boolean }) => CursorStateDatabase;
}
const CursorBillingCycleTimestampSchema = z.preprocess( const CursorBillingCycleTimestampSchema = z.preprocess(
(value) => (typeof value === "string" || typeof value === "number" ? value : null), (value) => (typeof value === "string" || typeof value === "number" ? value : null),
@@ -62,7 +47,6 @@ type CursorUsageResponse = z.infer<typeof CursorUsageResponseSchema>;
interface CursorQuotaProviderOptions { interface CursorQuotaProviderOptions {
logger: Logger; logger: Logger;
fetch?: ProviderApiFetch; fetch?: ProviderApiFetch;
homeDir?: string;
} }
function parseCursorBillingCycleTimestamp( function parseCursorBillingCycleTimestamp(
@@ -86,38 +70,14 @@ function centsToDollars(value: number | null): number | null {
return value === null ? null : value / 100; return value === null ? null : value / 100;
} }
function readItemTableValue(db: CursorStateDatabase, key: string): string | null { async function readCursorTokenFromSqlite(): Promise<string | null> {
const row = db.prepare("SELECT value FROM ItemTable WHERE key = ?").get(key);
const value = row?.["value"];
if (typeof value === "string") return value;
if (value instanceof Uint8Array) return Buffer.from(value).toString("utf8");
return null;
}
function cursorTokenFromDb(db: CursorStateDatabase): string | null {
const modern = readItemTableValue(db, CURSOR_ACCESS_TOKEN_KEY)?.trim();
if (modern) return modern;
const legacy = readItemTableValue(db, CURSOR_LEGACY_AUTH_KEY);
if (legacy) {
try {
const parsed = CursorAuthStatusSchema.parse(JSON.parse(legacy));
if (parsed.accessToken) return parsed.accessToken;
} catch {
// ignore a malformed legacy blob
}
}
return null;
}
async function readCursorTokenFromSqlite(homeDir: string, logger: Logger): Promise<string | null> {
const dbPaths: string[] = []; const dbPaths: string[] = [];
if (process.env["APPDATA"]) { if (process.env["APPDATA"]) {
dbPaths.push(join(process.env["APPDATA"], "Cursor", "User", "globalStorage", "state.vscdb")); dbPaths.push(join(process.env["APPDATA"], "Cursor", "User", "globalStorage", "state.vscdb"));
} }
dbPaths.push( dbPaths.push(
join( join(
homeDir, homedir(),
"Library", "Library",
"Application Support", "Application Support",
"Cursor", "Cursor",
@@ -126,32 +86,22 @@ async function readCursorTokenFromSqlite(homeDir: string, logger: Logger): Promi
"state.vscdb", "state.vscdb",
), ),
); );
dbPaths.push(join(homeDir, ".config", "Cursor", "User", "globalStorage", "state.vscdb")); dbPaths.push(join(homedir(), ".config", "Cursor", "User", "globalStorage", "state.vscdb"));
// Held in a variable so TypeScript skips module resolution: @types/node@20 has no
// node:sqlite typings yet, while the runtime (Node 22+ / Electron) provides it.
const sqliteSpecifier: string = "node:sqlite";
let sqlite: NodeSqliteModule;
try {
sqlite = (await import(sqliteSpecifier)) as unknown as NodeSqliteModule;
} catch (err) {
logger.debug({ err }, "node:sqlite unavailable; cannot read Cursor state.vscdb");
return null; // runtime without node:sqlite
}
for (const path of dbPaths) { for (const path of dbPaths) {
if (!existsSync(path)) continue; if (!existsSync(path)) continue;
let db: CursorStateDatabase | undefined;
try { try {
db = new sqlite.DatabaseSync(path, { readOnly: true }); const { stdout } = await execFileAsync(
const token = cursorTokenFromDb(db); "sqlite3",
if (token) return token; [path, "SELECT value FROM ItemTable WHERE key = 'cursorAuthStatus'"],
} catch (err) { { timeout: CURSOR_SQLITE_TIMEOUT_MS },
// Locked/permission/corrupt/schema failures all land here; log so an );
// unavailable Cursor card is diagnosable, then try the next candidate. if (stdout) {
logger.debug({ err, path }, "Failed to read Cursor token from state.vscdb"); const parsed = CursorAuthStatusSchema.parse(JSON.parse(stdout.trim()));
} finally { if (parsed.accessToken) return parsed.accessToken;
db?.close(); }
} catch {
continue;
} }
} }
return null; return null;
@@ -163,19 +113,17 @@ export class CursorQuotaProvider implements ProviderUsageFetcher {
private readonly logger: Logger; private readonly logger: Logger;
private readonly fetchApi: ProviderApiFetch; private readonly fetchApi: ProviderApiFetch;
private readonly homeDir: string;
constructor(options: CursorQuotaProviderOptions) { constructor(options: CursorQuotaProviderOptions) {
this.logger = options.logger; this.logger = options.logger;
this.fetchApi = options.fetch ?? fetch; this.fetchApi = options.fetch ?? fetch;
this.homeDir = options.homeDir ?? homedir();
} }
async fetchUsage(): Promise<ProviderUsage> { async fetchUsage(): Promise<ProviderUsage> {
const token = const token =
process.env["CURSOR_ACCESS_TOKEN"] || process.env["CURSOR_ACCESS_TOKEN"] ||
process.env["CURSOR_TOKEN"] || process.env["CURSOR_TOKEN"] ||
(await readCursorTokenFromSqlite(this.homeDir, this.logger)); (await readCursorTokenFromSqlite());
if (!token) return unavailableUsage(this); if (!token) return unavailableUsage(this);
@@ -1,7 +1,6 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { mkdtempSync, rmSync } from "node:fs"; import { mkdtempSync, rmSync } from "node:fs";
import { promises as fsPromises } from "node:fs"; import { promises as fsPromises } from "node:fs";
import { createRequire } from "node:module";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -59,31 +58,6 @@ function writeKimiCredentials(dir: string, accessToken: string, overrides: objec
); );
} }
// node:sqlite has no @types/node@20 typings; require it with a narrow local type.
const testRequire = createRequire(import.meta.url);
interface TestSqliteDb {
exec(sql: string): void;
prepare(sql: string): { run(...params: unknown[]): void };
close(): void;
}
// Cursor builds have stored ItemTable values as both TEXT and BLOB. Keys map to the
// real layouts: a plain modern token or the legacy JSON object.
function writeCursorStateDb(homeDir: string, rows: Record<string, string | Uint8Array>): void {
const dir = join(homeDir, ".config", "Cursor", "User", "globalStorage");
mkdirSync(dir, { recursive: true });
const { DatabaseSync } = testRequire("node:sqlite") as {
DatabaseSync: new (path: string) => TestSqliteDb;
};
const db = new DatabaseSync(join(dir, "state.vscdb"));
db.exec("CREATE TABLE IF NOT EXISTS ItemTable (key TEXT UNIQUE ON CONFLICT REPLACE, value BLOB)");
const insert = db.prepare("INSERT INTO ItemTable (key, value) VALUES (?, ?)");
for (const [key, value] of Object.entries(rows)) {
insert.run(key, value);
}
db.close();
}
function writeGrokAuth(home: string, auth: Record<string, unknown>): void { function writeGrokAuth(home: string, auth: Record<string, unknown>): void {
mkdirSync(join(home, ".grok"), { recursive: true }); mkdirSync(join(home, ".grok"), { recursive: true });
writeFileSync(join(home, ".grok", "auth.json"), JSON.stringify(auth)); writeFileSync(join(home, ".grok", "auth.json"), JSON.stringify(auth));
@@ -398,7 +372,6 @@ describe("real provider usage fetchers", () => {
platform?: typeof process.platform; platform?: typeof process.platform;
keychain?: () => Promise<unknown | null>; keychain?: () => Promise<unknown | null>;
kimiHomeDir?: string; kimiHomeDir?: string;
cursorHomeDir?: string;
miniMaxConfigPath?: string; miniMaxConfigPath?: string;
miniMaxCredentialsPath?: string; miniMaxCredentialsPath?: string;
} = {}, } = {},
@@ -419,11 +392,7 @@ describe("real provider usage fetchers", () => {
}), }),
new CodexQuotaProvider({ logger, codexHome, fetch: fetchThroughTestDouble }), new CodexQuotaProvider({ logger, codexHome, fetch: fetchThroughTestDouble }),
new CopilotQuotaProvider({ logger, fetch: fetchThroughTestDouble }), new CopilotQuotaProvider({ logger, fetch: fetchThroughTestDouble }),
new CursorQuotaProvider({ new CursorQuotaProvider({ logger, fetch: fetchThroughTestDouble }),
logger,
fetch: fetchThroughTestDouble,
homeDir: options.cursorHomeDir,
}),
new ZaiQuotaProvider({ logger, fetch: fetchThroughTestDouble }), new ZaiQuotaProvider({ logger, fetch: fetchThroughTestDouble }),
new GrokQuotaProvider({ new GrokQuotaProvider({
logger, logger,
@@ -733,75 +702,6 @@ describe("real provider usage fetchers", () => {
}); });
}); });
it("reads the Cursor token from the modern cursorAuth/accessToken key in state.vscdb", async () => {
writeCursorStateDb(homeDir, { "cursorAuth/accessToken": "cursor_state_jwt" });
let authorization: string | null = null;
fetchApi = (async (url: RequestInfo | URL, init?: RequestInit) => {
authorization = (init?.headers as Record<string, string> | undefined)?.Authorization ?? null;
return jsonResponse({
planUsage: {
totalSpend: "1500",
includedSpend: "1000",
bonusSpend: "500",
remaining: "2500",
limit: "4000",
},
billingCycleStart: "2026-01-14T12:42:14.000Z",
billingCycleEnd: "2026-02-14T12:42:14.000Z",
});
}) as unknown as typeof fetch;
const cursor = findProvider(await service({ cursorHomeDir: homeDir }).listUsage(), "cursor");
expect(authorization).toBe("Bearer cursor_state_jwt");
expect(cursor).toMatchObject({
status: "available",
balances: [expect.objectContaining({ id: "plan_usage", used: 15, remaining: 25, limit: 40 })],
});
});
it("falls back to the legacy cursorAuthStatus JSON blob when the modern key is absent", async () => {
writeCursorStateDb(homeDir, {
cursorAuthStatus: Buffer.from(JSON.stringify({ accessToken: "cursor_legacy_jwt" }), "utf8"),
});
let authorization: string | null = null;
fetchApi = (async (url: RequestInfo | URL, init?: RequestInit) => {
authorization = (init?.headers as Record<string, string> | undefined)?.Authorization ?? null;
return jsonResponse({
planUsage: { totalSpend: "0", remaining: "100", limit: "100" },
billingCycleStart: null,
billingCycleEnd: null,
});
}) as unknown as typeof fetch;
const cursor = findProvider(await service({ cursorHomeDir: homeDir }).listUsage(), "cursor");
expect(authorization).toBe("Bearer cursor_legacy_jwt");
expect(cursor.status).toBe("available");
});
it("logs a debug diagnostic and stays unavailable when state.vscdb is unreadable", async () => {
const dir = join(homeDir, ".config", "Cursor", "User", "globalStorage");
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, "state.vscdb"), "not a sqlite database");
const logger = createLogger();
const provider = new CursorQuotaProvider({
logger,
fetch: (() => {
throw new Error("usage API should not be called without a token");
}) as unknown as typeof fetch,
homeDir,
});
const usage = await provider.fetchUsage();
expect(usage.status).toBe("unavailable");
expect((logger as unknown as { debug: ReturnType<typeof vi.fn> }).debug).toHaveBeenCalledWith(
expect.objectContaining({ path: expect.stringContaining("state.vscdb") }),
expect.stringContaining("Failed to read Cursor token"),
);
});
it("fetches Z.ai usage from ZAI_API_KEY", async () => { it("fetches Z.ai usage from ZAI_API_KEY", async () => {
process.env["ZAI_API_KEY"] = "zai_test_token"; process.env["ZAI_API_KEY"] = "zai_test_token";
fetchApi = mockFetch( fetchApi = mockFetch(
+1 -1
View File
@@ -19,7 +19,7 @@
}, },
"desktop": { "desktop": {
"type": "service", "type": "service",
"command": "PASEO_DEV_MANAGED_HOME=1 PASEO_DEV_ROOT=\"${PASEO_WORKTREE_PATH:-$PWD}\" PASEO_HOME=\"${PASEO_WORKTREE_PATH:-$PWD}/.dev/paseo-home\" PASEO_LISTEN=0.0.0.0:${PASEO_SERVICE_DAEMON_PORT:-6768} PASEO_DEV_DAEMON_ENDPOINT=localhost:${PASEO_SERVICE_DAEMON_PORT:-6768} EXPO_PORT=${PASEO_PORT:-} exec ./packages/desktop/scripts/dev.sh" "command": "PASEO_DEV_MANAGED_HOME=1 PASEO_DEV_ROOT=\"${PASEO_WORKTREE_PATH:-$PWD}\" PASEO_HOME=\"${PASEO_WORKTREE_PATH:-$PWD}/.dev/paseo-home\" PASEO_LISTEN=0.0.0.0:${PASEO_SERVICE_DAEMON_PORT:-6768} PASEO_DEV_DAEMON_ENDPOINT=localhost:${PASEO_SERVICE_DAEMON_PORT:-6768} EXPO_PORT=${PASEO_PORT:-} npm run dev --workspace=@getpaseo/desktop"
}, },
"ios-simulator": { "ios-simulator": {
"type": "service", "type": "service",
+1 -9
View File
@@ -72,18 +72,10 @@ environments:
cwd: /workspace/project cwd: /workspace/project
worktree: worktree:
mode: branch-off mode: branch-off
newBranch: trigger-${{ paseo.execution.id }} newBranch: paseo/review
base: origin/main base: origin/main
``` ```
`newBranch` is a branch-name string. Embed `${{ paseo.execution.id }}`, which renders the execution's UUID, so every execution branches off `base` on its own branch and keeps it when Hub retries or recovers that execution.
One execution is one step run, so two steps selecting the same environment get separate branches.
`${{ paseo.execution.id }}` is the only expression `newBranch` accepts. `paseo.prompt`, `paseo.context`, `paseo.inputs.*`, `values.*`, `steps.<id>.outputs.*`, and provider event fields are unavailable here, and each one fails bundle activation at the authored field, such as `.paseo/hub.yml.environments.review.worktree.newBranch`.
`${{ paseo.execution.id }}` fails activation the same way anywhere else in a bundle. `branch` and `prNumber` take literal values.
An environment is a complete named object. A step selects its name; objects are not inherited, merged, or partially overridden. An environment is a complete named object. A step selects its name; objects are not inherited, merged, or partially overridden.
### Named agents ### Named agents
+2 -4
View File
@@ -80,13 +80,11 @@ To keep executions off your working tree, add a worktree:
```yaml ```yaml
worktree: worktree:
mode: branch-off mode: branch-off
newBranch: trigger-${{ paseo.execution.id }} newBranch: hub/investigation
base: origin/main base: origin/main
``` ```
`${{ paseo.execution.id }}` renders the execution's UUID, so every execution gets its own branch off `origin/main`. See [Git worktrees](/docs/worktrees) for setup hooks and scripts.
[Environment fields](/docs/hub/configuration/hub-yml#environments) lists what `newBranch` accepts. See [Git worktrees](/docs/worktrees) for setup hooks and scripts.
## What Hub owns ## What Hub owns