mirror of
https://github.com/garrytan/gstack.git
synced 2026-08-08 07:18:38 +02:00
feat(config): add resolveGstackHome, resolveChromiumProfile, cleanSingletonLocks
Three new exported helpers in browse/src/config.ts: - resolveGstackHome(): honors GSTACK_HOME env, falls back to os.homedir()/.gstack Matches the existing convention in browse/src/telemetry.ts:26 and browse/src/domain-skills.ts:66. - resolveChromiumProfile(explicit?): explicit arg wins -> CHROMIUM_PROFILE env -> resolveGstackHome()/chromium-profile. Lets gbrowser pass per-workspace profile paths through ServerConfig instead of relying on ambient env state. - cleanSingletonLocks(dir): removes SingletonLock/Socket/Cookie via safeUnlinkQuiet. Defensive guard refuses to operate unless dir basename is 'chromium-profile' OR matches explicit CHROMIUM_PROFILE env value, preventing accidental deletion in unrelated directories. Extends browse/test/config.test.ts with 12 tests covering env precedence, guard behavior, ENOENT swallowing, and CHROMIUM_PROFILE override. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
74895062fb
commit
bed1a9f5ed
@@ -11,8 +11,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
|
import * as os from 'os';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { mkdirSecure } from './file-permissions';
|
import { mkdirSecure } from './file-permissions';
|
||||||
|
import { safeUnlinkQuiet } from './error-handling';
|
||||||
|
|
||||||
export interface BrowseConfig {
|
export interface BrowseConfig {
|
||||||
projectDir: string;
|
projectDir: string;
|
||||||
@@ -151,3 +153,57 @@ export function readVersionHash(execPath: string = process.execPath): string | n
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the gstack home directory.
|
||||||
|
*
|
||||||
|
* Honors the existing convention used by telemetry.ts and domain-skills.ts:
|
||||||
|
* 1. GSTACK_HOME env (explicit override)
|
||||||
|
* 2. $HOME/.gstack (default)
|
||||||
|
*/
|
||||||
|
export function resolveGstackHome(): string {
|
||||||
|
return process.env.GSTACK_HOME || path.join(os.homedir(), '.gstack');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the Chromium profile directory.
|
||||||
|
*
|
||||||
|
* Resolution order:
|
||||||
|
* 1. `explicit` arg (passed via ServerConfig.chromiumProfile by embedders)
|
||||||
|
* 2. CHROMIUM_PROFILE env (used by gbrowser's gbd per-workspace)
|
||||||
|
* 3. <resolveGstackHome()>/chromium-profile (default)
|
||||||
|
*/
|
||||||
|
export function resolveChromiumProfile(explicit?: string): string {
|
||||||
|
if (explicit && explicit.length > 0) return explicit;
|
||||||
|
const env = process.env.CHROMIUM_PROFILE;
|
||||||
|
if (env && env.length > 0) return env;
|
||||||
|
return path.join(resolveGstackHome(), 'chromium-profile');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pre-launch / shutdown cleanup of stale Chromium singleton lockfiles
|
||||||
|
* (SingletonLock, SingletonSocket, SingletonCookie). Chromium's
|
||||||
|
* ProcessSingleton refuses to start when these exist from a prior crash
|
||||||
|
* (SIGKILL, hard crash, etc.) since they point at a PID that no longer exists.
|
||||||
|
*
|
||||||
|
* Defensive guard: refuses to operate unless the directory basename is
|
||||||
|
* 'chromium-profile' OR the path matches the explicit CHROMIUM_PROFILE env
|
||||||
|
* value. Prevents accidentally deleting lock files from an unrelated
|
||||||
|
* directory if profile resolution is misconfigured upstream.
|
||||||
|
*
|
||||||
|
* Caller MUST ensure external coordination has already guaranteed no live
|
||||||
|
* peer is using this profile (gbd.lock for gbrowser; single-instance CLI
|
||||||
|
* check for gstack).
|
||||||
|
*/
|
||||||
|
export function cleanSingletonLocks(userDataDir: string): void {
|
||||||
|
const basename = path.basename(userDataDir);
|
||||||
|
const explicitProfile = process.env.CHROMIUM_PROFILE;
|
||||||
|
const isSafe = basename === 'chromium-profile' || userDataDir === explicitProfile;
|
||||||
|
if (!isSafe) {
|
||||||
|
console.warn(`[browse] cleanSingletonLocks: refusing to clean unrecognized profile dir: ${userDataDir}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const lockFile of ['SingletonLock', 'SingletonSocket', 'SingletonCookie']) {
|
||||||
|
safeUnlinkQuiet(path.join(userDataDir, lockFile));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+130
-1
@@ -1,5 +1,5 @@
|
|||||||
import { describe, test, expect } from 'bun:test';
|
import { describe, test, expect } from 'bun:test';
|
||||||
import { resolveConfig, ensureStateDir, readVersionHash, getGitRoot, getRemoteSlug } from '../src/config';
|
import { resolveConfig, ensureStateDir, readVersionHash, getGitRoot, getRemoteSlug, resolveGstackHome, resolveChromiumProfile, cleanSingletonLocks } from '../src/config';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
@@ -314,3 +314,132 @@ describe('startup error log', () => {
|
|||||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('resolveGstackHome', () => {
|
||||||
|
test('honors GSTACK_HOME env var when set', () => {
|
||||||
|
const orig = process.env.GSTACK_HOME;
|
||||||
|
process.env.GSTACK_HOME = '/tmp/custom-gstack-home';
|
||||||
|
try {
|
||||||
|
expect(resolveGstackHome()).toBe('/tmp/custom-gstack-home');
|
||||||
|
} finally {
|
||||||
|
if (orig === undefined) delete process.env.GSTACK_HOME;
|
||||||
|
else process.env.GSTACK_HOME = orig;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('falls back to os.homedir() + /.gstack when env unset', () => {
|
||||||
|
const orig = process.env.GSTACK_HOME;
|
||||||
|
delete process.env.GSTACK_HOME;
|
||||||
|
try {
|
||||||
|
expect(resolveGstackHome()).toBe(path.join(os.homedir(), '.gstack'));
|
||||||
|
} finally {
|
||||||
|
if (orig !== undefined) process.env.GSTACK_HOME = orig;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveChromiumProfile', () => {
|
||||||
|
test('explicit arg wins over env and default', () => {
|
||||||
|
const orig = process.env.CHROMIUM_PROFILE;
|
||||||
|
process.env.CHROMIUM_PROFILE = '/tmp/env-profile';
|
||||||
|
try {
|
||||||
|
expect(resolveChromiumProfile('/tmp/explicit-profile')).toBe('/tmp/explicit-profile');
|
||||||
|
} finally {
|
||||||
|
if (orig === undefined) delete process.env.CHROMIUM_PROFILE;
|
||||||
|
else process.env.CHROMIUM_PROFILE = orig;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CHROMIUM_PROFILE env honored when no explicit arg', () => {
|
||||||
|
const orig = process.env.CHROMIUM_PROFILE;
|
||||||
|
process.env.CHROMIUM_PROFILE = '/tmp/env-profile';
|
||||||
|
try {
|
||||||
|
expect(resolveChromiumProfile()).toBe('/tmp/env-profile');
|
||||||
|
} finally {
|
||||||
|
if (orig === undefined) delete process.env.CHROMIUM_PROFILE;
|
||||||
|
else process.env.CHROMIUM_PROFILE = orig;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('falls back to resolveGstackHome()/chromium-profile when nothing set', () => {
|
||||||
|
const origEnv = process.env.CHROMIUM_PROFILE;
|
||||||
|
const origHome = process.env.GSTACK_HOME;
|
||||||
|
delete process.env.CHROMIUM_PROFILE;
|
||||||
|
process.env.GSTACK_HOME = '/tmp/fallback-gstack';
|
||||||
|
try {
|
||||||
|
expect(resolveChromiumProfile()).toBe('/tmp/fallback-gstack/chromium-profile');
|
||||||
|
} finally {
|
||||||
|
if (origEnv !== undefined) process.env.CHROMIUM_PROFILE = origEnv;
|
||||||
|
if (origHome === undefined) delete process.env.GSTACK_HOME;
|
||||||
|
else process.env.GSTACK_HOME = origHome;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ignores empty-string explicit arg, falls through to env/default', () => {
|
||||||
|
const orig = process.env.CHROMIUM_PROFILE;
|
||||||
|
process.env.CHROMIUM_PROFILE = '/tmp/env-profile';
|
||||||
|
try {
|
||||||
|
expect(resolveChromiumProfile('')).toBe('/tmp/env-profile');
|
||||||
|
} finally {
|
||||||
|
if (orig === undefined) delete process.env.CHROMIUM_PROFILE;
|
||||||
|
else process.env.CHROMIUM_PROFILE = orig;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('cleanSingletonLocks', () => {
|
||||||
|
test('removes SingletonLock/Socket/Cookie when basename is chromium-profile', () => {
|
||||||
|
const tmpDir = path.join(os.tmpdir(), `clean-locks-${Date.now()}`, 'chromium-profile');
|
||||||
|
fs.mkdirSync(tmpDir, { recursive: true });
|
||||||
|
for (const f of ['SingletonLock', 'SingletonSocket', 'SingletonCookie']) {
|
||||||
|
fs.writeFileSync(path.join(tmpDir, f), 'stale');
|
||||||
|
}
|
||||||
|
cleanSingletonLocks(tmpDir);
|
||||||
|
for (const f of ['SingletonLock', 'SingletonSocket', 'SingletonCookie']) {
|
||||||
|
expect(fs.existsSync(path.join(tmpDir, f))).toBe(false);
|
||||||
|
}
|
||||||
|
fs.rmSync(path.dirname(tmpDir), { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refuses to clean unrecognized profile dir basename', () => {
|
||||||
|
const tmpDir = path.join(os.tmpdir(), `unrelated-${Date.now()}`);
|
||||||
|
fs.mkdirSync(tmpDir, { recursive: true });
|
||||||
|
const lockFile = path.join(tmpDir, 'SingletonLock');
|
||||||
|
fs.writeFileSync(lockFile, 'should-survive');
|
||||||
|
const origWarn = console.warn;
|
||||||
|
let warned = '';
|
||||||
|
console.warn = (msg: string) => { warned = msg; };
|
||||||
|
try {
|
||||||
|
cleanSingletonLocks(tmpDir);
|
||||||
|
expect(warned).toContain('refusing to clean unrecognized profile dir');
|
||||||
|
expect(fs.existsSync(lockFile)).toBe(true); // not deleted
|
||||||
|
} finally {
|
||||||
|
console.warn = origWarn;
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('respects explicit CHROMIUM_PROFILE env even with non-standard basename', () => {
|
||||||
|
const tmpDir = path.join(os.tmpdir(), `custom-name-${Date.now()}`);
|
||||||
|
fs.mkdirSync(tmpDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(tmpDir, 'SingletonLock'), 'stale');
|
||||||
|
const orig = process.env.CHROMIUM_PROFILE;
|
||||||
|
process.env.CHROMIUM_PROFILE = tmpDir;
|
||||||
|
try {
|
||||||
|
cleanSingletonLocks(tmpDir);
|
||||||
|
expect(fs.existsSync(path.join(tmpDir, 'SingletonLock'))).toBe(false);
|
||||||
|
} finally {
|
||||||
|
if (orig === undefined) delete process.env.CHROMIUM_PROFILE;
|
||||||
|
else process.env.CHROMIUM_PROFILE = orig;
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('second call on empty dir does not throw (ENOENT swallowed)', () => {
|
||||||
|
const tmpDir = path.join(os.tmpdir(), `empty-locks-${Date.now()}`, 'chromium-profile');
|
||||||
|
fs.mkdirSync(tmpDir, { recursive: true });
|
||||||
|
expect(() => cleanSingletonLocks(tmpDir)).not.toThrow();
|
||||||
|
expect(() => cleanSingletonLocks(tmpDir)).not.toThrow();
|
||||||
|
fs.rmSync(path.dirname(tmpDir), { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user