mirror of
https://github.com/obra/superpowers.git
synced 2026-06-13 14:19:05 +08:00
Isolate companion fallback tokens
This commit is contained in:
@@ -113,16 +113,26 @@ let ownerPid = process.env.BRAINSTORM_OWNER_PID ? Number(process.env.BRAINSTORM_
|
||||
// Persisted alongside the port (BRAINSTORM_TOKEN_FILE) so a restart keeps the
|
||||
// same key and an already-open tab's cookie still validates.
|
||||
const TOKEN_FILE = process.env.BRAINSTORM_TOKEN_FILE || null;
|
||||
const TOKEN = (() => {
|
||||
if (process.env.BRAINSTORM_TOKEN) return process.env.BRAINSTORM_TOKEN;
|
||||
function generateToken() {
|
||||
return crypto.randomBytes(32).toString('hex');
|
||||
}
|
||||
|
||||
function initialToken() {
|
||||
if (process.env.BRAINSTORM_TOKEN) {
|
||||
return { value: process.env.BRAINSTORM_TOKEN, source: 'env' };
|
||||
}
|
||||
if (TOKEN_FILE) {
|
||||
try {
|
||||
const t = fs.readFileSync(TOKEN_FILE, 'utf-8').trim();
|
||||
if (/^[0-9a-f]{32,}$/i.test(t)) return t;
|
||||
if (/^[0-9a-f]{32,}$/i.test(t)) return { value: t, source: 'file' };
|
||||
} catch (e) { /* no prior token recorded */ }
|
||||
}
|
||||
return crypto.randomBytes(32).toString('hex');
|
||||
})();
|
||||
return { value: generateToken(), source: 'generated' };
|
||||
}
|
||||
|
||||
const tokenInfo = initialToken();
|
||||
let TOKEN = tokenInfo.value;
|
||||
let tokenSource = tokenInfo.source;
|
||||
let COOKIE_NAME = 'brainstorm-key-' + PORT; // refined to the actual bound port in onListen
|
||||
|
||||
const MIME_TYPES = {
|
||||
@@ -594,8 +604,16 @@ function startServer() {
|
||||
|
||||
server.on('error', (err) => {
|
||||
if (err.code === 'EADDRINUSE' && !triedFallback) {
|
||||
if (tokenSource === 'env') {
|
||||
console.error('Server failed to bind: preferred port is in use and BRAINSTORM_TOKEN is set; refusing fallback with explicit token');
|
||||
process.exit(1);
|
||||
}
|
||||
triedFallback = true;
|
||||
PORT = randomPort();
|
||||
if (tokenSource === 'file') {
|
||||
TOKEN = generateToken();
|
||||
tokenSource = 'generated-fallback';
|
||||
}
|
||||
server.listen(PORT, HOST, onListen);
|
||||
} else {
|
||||
console.error('Server failed to bind:', err.message);
|
||||
|
||||
@@ -66,6 +66,18 @@ function openCaptureCommand(dir, marker) {
|
||||
return `node ${JSON.stringify(scriptPath)} ${JSON.stringify(markerPath)}`;
|
||||
}
|
||||
|
||||
function httpStatus(port, key) {
|
||||
return new Promise(resolve => {
|
||||
const pathWithKey = key ? '/?key=' + encodeURIComponent(key) : '/';
|
||||
require('http')
|
||||
.get({ hostname: '127.0.0.1', port, path: pathWithKey }, res => {
|
||||
res.resume();
|
||||
resolve(res.statusCode);
|
||||
})
|
||||
.on('error', () => resolve(0));
|
||||
});
|
||||
}
|
||||
|
||||
async function runTests() {
|
||||
let passed = 0, failed = 0;
|
||||
async function test(name, fn) {
|
||||
@@ -246,6 +258,104 @@ async function runTests() {
|
||||
assert.strictEqual(persisted, '3415', 'fallback must not overwrite .last-port');
|
||||
});
|
||||
|
||||
await test('fallback with persisted token generates a fresh unpersisted key', async () => {
|
||||
const dir = fs.mkdtempSync('/tmp/bs-port-');
|
||||
const portFile = path.join(dir, '.last-port');
|
||||
const tokenFile = path.join(dir, '.last-token');
|
||||
const preferredToken = 'abababababababababababababababab';
|
||||
let a = null, b = null;
|
||||
|
||||
try {
|
||||
a = spawn('node', [SERVER], {
|
||||
env: {
|
||||
...process.env,
|
||||
BRAINSTORM_DIR: path.join(dir, 'a'),
|
||||
BRAINSTORM_PORT: 3422,
|
||||
BRAINSTORM_TOKEN: preferredToken,
|
||||
BRAINSTORM_LIFECYCLE_CHECK_MS: 100000
|
||||
}
|
||||
});
|
||||
let outA = ''; a.stdout.on('data', d => outA += d.toString());
|
||||
for (let i = 0; i < 60 && !outA.includes('server-started'); i++) await sleep(50);
|
||||
assert(outA.includes('server-started'), 'preferred-port server should start');
|
||||
|
||||
fs.writeFileSync(portFile, '3422');
|
||||
fs.writeFileSync(tokenFile, preferredToken, { mode: 0o600 });
|
||||
|
||||
b = spawn('node', [SERVER], {
|
||||
env: {
|
||||
...process.env,
|
||||
BRAINSTORM_DIR: path.join(dir, 'b'),
|
||||
BRAINSTORM_PORT_FILE: portFile,
|
||||
BRAINSTORM_TOKEN_FILE: tokenFile,
|
||||
BRAINSTORM_LIFECYCLE_CHECK_MS: 100000
|
||||
}
|
||||
});
|
||||
let outB = ''; b.stdout.on('data', d => outB += d.toString());
|
||||
for (let i = 0; i < 60 && !outB.includes('server-started'); i++) await sleep(50);
|
||||
const infoB = firstServerStarted(outB);
|
||||
const fallbackKey = new URL(infoB.url).searchParams.get('key');
|
||||
const persistedAfter = fs.readFileSync(tokenFile, 'utf8').trim();
|
||||
const originalStatus = await httpStatus(3422, fallbackKey);
|
||||
|
||||
assert.notStrictEqual(infoB.port, 3422, 'fallback should use a different port');
|
||||
assert.notStrictEqual(fallbackKey, preferredToken, 'fallback must not reuse persisted key');
|
||||
assert.strictEqual(persistedAfter, preferredToken, 'fallback must not overwrite .last-token');
|
||||
assert.strictEqual(originalStatus, 403, 'fallback key must not authenticate to original server');
|
||||
} finally {
|
||||
await killAndWait(a);
|
||||
await killAndWait(b);
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
await test('fallback with explicit BRAINSTORM_TOKEN fails closed', async () => {
|
||||
const dir = fs.mkdtempSync('/tmp/bs-port-');
|
||||
const portFile = path.join(dir, '.last-port');
|
||||
const explicitToken = 'cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd';
|
||||
let a = null, b = null;
|
||||
|
||||
try {
|
||||
a = spawn('node', [SERVER], {
|
||||
env: {
|
||||
...process.env,
|
||||
BRAINSTORM_DIR: path.join(dir, 'a'),
|
||||
BRAINSTORM_PORT: 3423,
|
||||
BRAINSTORM_TOKEN: explicitToken,
|
||||
BRAINSTORM_LIFECYCLE_CHECK_MS: 100000
|
||||
}
|
||||
});
|
||||
let outA = ''; a.stdout.on('data', d => outA += d.toString());
|
||||
for (let i = 0; i < 60 && !outA.includes('server-started'); i++) await sleep(50);
|
||||
assert(outA.includes('server-started'), 'preferred-port server should start');
|
||||
|
||||
fs.writeFileSync(portFile, '3423');
|
||||
b = spawn('node', [SERVER], {
|
||||
env: {
|
||||
...process.env,
|
||||
BRAINSTORM_DIR: path.join(dir, 'b'),
|
||||
BRAINSTORM_PORT_FILE: portFile,
|
||||
BRAINSTORM_TOKEN: explicitToken,
|
||||
BRAINSTORM_LIFECYCLE_CHECK_MS: 100000
|
||||
}
|
||||
});
|
||||
let outB = ''; let errB = '';
|
||||
b.stdout.on('data', d => outB += d.toString());
|
||||
b.stderr.on('data', d => errB += d.toString());
|
||||
for (let i = 0; i < 60 && !outB.includes('server-started') && b.exitCode === null; i++) await sleep(50);
|
||||
const exited = await waitForExit(b, 1500);
|
||||
|
||||
assert(exited, 'explicit-token fallback process should exit');
|
||||
assert.notStrictEqual(b.exitCode, 0, 'explicit-token fallback should fail non-zero');
|
||||
assert(!outB.includes('server-started'), 'explicit-token fallback must not start on a random port');
|
||||
assert(/BRAINSTORM_TOKEN/.test(errB), `stderr should explain explicit token fallback refusal, got: ${errB}`);
|
||||
} finally {
|
||||
await killAndWait(a);
|
||||
await killAndWait(b);
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
await test('auto-opens the browser once, on the first screen', async () => {
|
||||
const dir = fs.mkdtempSync('/tmp/bs-open-');
|
||||
const marker = path.join(dir, 'opened.log');
|
||||
|
||||
Reference in New Issue
Block a user