mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-20 11:19:56 +08:00
fix: keep cookie picker alive after cli exits
Fixes garrytan/gstack#985
This commit is contained in:
@@ -40,6 +40,23 @@ export function generatePickerCode(): string {
|
|||||||
return code;
|
return code;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Return true while the picker still has a live code or session. */
|
||||||
|
export function hasActivePicker(): boolean {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
for (const [code, expiry] of pendingCodes) {
|
||||||
|
if (expiry > now) return true;
|
||||||
|
pendingCodes.delete(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [session, expiry] of validSessions) {
|
||||||
|
if (expiry > now) return true;
|
||||||
|
validSessions.delete(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/** Extract session ID from the gstack_picker cookie. */
|
/** Extract session ID from the gstack_picker cookie. */
|
||||||
function getSessionFromCookie(req: Request): string | null {
|
function getSessionFromCookie(req: Request): string | null {
|
||||||
const cookie = req.headers.get('cookie');
|
const cookie = req.headers.get('cookie');
|
||||||
|
|||||||
@@ -764,14 +764,18 @@ if (BROWSE_PARENT_PID > 0) {
|
|||||||
try {
|
try {
|
||||||
process.kill(BROWSE_PARENT_PID, 0); // signal 0 = existence check only, no signal sent
|
process.kill(BROWSE_PARENT_PID, 0); // signal 0 = existence check only, no signal sent
|
||||||
} catch {
|
} catch {
|
||||||
// Parent exited. Behavior depends on mode:
|
// Parent exited. Resolution order:
|
||||||
// - Normal (headless) mode: stay alive. Claude Code's Bash tool kills the
|
// 1. Active cookie picker (one-time code or session live)? Stay alive
|
||||||
// parent shell between invocations, so the server must survive. The
|
// regardless of mode — tearing down the server mid-import leaves the
|
||||||
// idle timeout (30 min) handles eventual cleanup.
|
// picker UI with a stale "Failed to fetch" error.
|
||||||
// - Headed / tunnel mode: the idle timeout DOESN'T apply (see idleCheckInterval
|
// 2. Headed / tunnel mode? Shutdown. The idle timeout doesn't apply in
|
||||||
// above — both modes early-return). If we ignored parent death here too,
|
// these modes (see idleCheckInterval above — both early-return), so
|
||||||
// orphan daemons would accumulate forever after /pair-agent or
|
// ignoring parent death here would leak orphan daemons after
|
||||||
// /open-gstack-browser sessions end. Shutdown instead.
|
// /pair-agent or /open-gstack-browser sessions.
|
||||||
|
// 3. Normal (headless) mode? Stay alive. Claude Code's Bash tool kills
|
||||||
|
// the parent shell between invocations. The idle timeout (30 min)
|
||||||
|
// handles eventual cleanup.
|
||||||
|
if (hasActivePicker()) return;
|
||||||
const headed = browserManager.getConnectionMode() === 'headed';
|
const headed = browserManager.getConnectionMode() === 'headed';
|
||||||
if (headed || tunnelActive) {
|
if (headed || tunnelActive) {
|
||||||
console.log(`[browse] Parent process ${BROWSE_PARENT_PID} exited in ${headed ? 'headed' : 'tunnel'} mode, shutting down`);
|
console.log(`[browse] Parent process ${BROWSE_PARENT_PID} exited in ${headed ? 'headed' : 'tunnel'} mode, shutting down`);
|
||||||
@@ -785,6 +789,7 @@ if (BROWSE_PARENT_PID > 0) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─── Command Sets (from commands.ts — single source of truth) ───
|
// ─── Command Sets (from commands.ts — single source of truth) ───
|
||||||
|
import { hasActivePicker } from './cookie-picker-routes';
|
||||||
import { READ_COMMANDS, WRITE_COMMANDS, META_COMMANDS } from './commands';
|
import { READ_COMMANDS, WRITE_COMMANDS, META_COMMANDS } from './commands';
|
||||||
export { READ_COMMANDS, WRITE_COMMANDS, META_COMMANDS };
|
export { READ_COMMANDS, WRITE_COMMANDS, META_COMMANDS };
|
||||||
|
|
||||||
@@ -1250,6 +1255,10 @@ process.on('SIGINT', shutdown);
|
|||||||
// SIGTERM so external tooling (systemd, supervisord, CI) can shut cleanly
|
// SIGTERM so external tooling (systemd, supervisord, CI) can shut cleanly
|
||||||
// without waiting forever. Ctrl+C and /stop still work either way.
|
// without waiting forever. Ctrl+C and /stop still work either way.
|
||||||
process.on('SIGTERM', () => {
|
process.on('SIGTERM', () => {
|
||||||
|
if (hasActivePicker()) {
|
||||||
|
console.log('[browse] Received SIGTERM but cookie picker is active, ignoring to avoid stranding the picker UI');
|
||||||
|
return;
|
||||||
|
}
|
||||||
const headed = browserManager.getConnectionMode() === 'headed';
|
const headed = browserManager.getConnectionMode() === 'headed';
|
||||||
if (headed || tunnelActive) {
|
if (headed || tunnelActive) {
|
||||||
console.log(`[browse] Received SIGTERM in ${headed ? 'headed' : 'tunnel'} mode, shutting down`);
|
console.log(`[browse] Received SIGTERM in ${headed ? 'headed' : 'tunnel'} mode, shutting down`);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, test, expect } from 'bun:test';
|
import { describe, test, expect } from 'bun:test';
|
||||||
import { handleCookiePickerRoute, generatePickerCode } from '../src/cookie-picker-routes';
|
import { handleCookiePickerRoute, generatePickerCode, hasActivePicker } from '../src/cookie-picker-routes';
|
||||||
|
|
||||||
// ─── Mock BrowserManager ──────────────────────────────────────
|
// ─── Mock BrowserManager ──────────────────────────────────────
|
||||||
|
|
||||||
@@ -284,6 +284,57 @@ describe('cookie-picker-routes', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('active picker tracking', () => {
|
||||||
|
test('one-time codes keep the picker active until consumed', async () => {
|
||||||
|
const realNow = Date.now;
|
||||||
|
Date.now = () => realNow() + 3_700_000;
|
||||||
|
try {
|
||||||
|
expect(hasActivePicker()).toBe(false); // clears any stale state from prior tests
|
||||||
|
} finally {
|
||||||
|
Date.now = realNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { bm } = mockBrowserManager();
|
||||||
|
const code = generatePickerCode();
|
||||||
|
expect(hasActivePicker()).toBe(true);
|
||||||
|
|
||||||
|
const res = await handleCookiePickerRoute(
|
||||||
|
makeUrl(`/cookie-picker?code=${code}`),
|
||||||
|
new Request('http://127.0.0.1:9470', { method: 'GET' }),
|
||||||
|
bm,
|
||||||
|
'test-token',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(302);
|
||||||
|
expect(hasActivePicker()).toBe(true); // session is now active
|
||||||
|
});
|
||||||
|
|
||||||
|
test('picker becomes inactive after an invalid session probe clears expired state', async () => {
|
||||||
|
const { bm } = mockBrowserManager();
|
||||||
|
const session = await getSessionCookie(bm, 'test-token');
|
||||||
|
expect(hasActivePicker()).toBe(true);
|
||||||
|
|
||||||
|
const realNow = Date.now;
|
||||||
|
Date.now = () => realNow() + 3_700_000;
|
||||||
|
try {
|
||||||
|
const res = await handleCookiePickerRoute(
|
||||||
|
makeUrl('/cookie-picker'),
|
||||||
|
new Request('http://127.0.0.1:9470', {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { 'Cookie': `gstack_picker=${session}` },
|
||||||
|
}),
|
||||||
|
bm,
|
||||||
|
'test-token',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
expect(hasActivePicker()).toBe(false);
|
||||||
|
} finally {
|
||||||
|
Date.now = realNow;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('session cookie auth', () => {
|
describe('session cookie auth', () => {
|
||||||
test('valid session cookie grants HTML access', async () => {
|
test('valid session cookie grants HTML access', async () => {
|
||||||
const { bm } = mockBrowserManager();
|
const { bm } = mockBrowserManager();
|
||||||
|
|||||||
Reference in New Issue
Block a user