mirror of
https://github.com/obra/superpowers.git
synced 2026-06-28 21:49: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
|
// Persisted alongside the port (BRAINSTORM_TOKEN_FILE) so a restart keeps the
|
||||||
// same key and an already-open tab's cookie still validates.
|
// same key and an already-open tab's cookie still validates.
|
||||||
const TOKEN_FILE = process.env.BRAINSTORM_TOKEN_FILE || null;
|
const TOKEN_FILE = process.env.BRAINSTORM_TOKEN_FILE || null;
|
||||||
const TOKEN = (() => {
|
function generateToken() {
|
||||||
if (process.env.BRAINSTORM_TOKEN) return process.env.BRAINSTORM_TOKEN;
|
return crypto.randomBytes(32).toString('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function initialToken() {
|
||||||
|
if (process.env.BRAINSTORM_TOKEN) {
|
||||||
|
return { value: process.env.BRAINSTORM_TOKEN, source: 'env' };
|
||||||
|
}
|
||||||
if (TOKEN_FILE) {
|
if (TOKEN_FILE) {
|
||||||
try {
|
try {
|
||||||
const t = fs.readFileSync(TOKEN_FILE, 'utf-8').trim();
|
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 */ }
|
} 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
|
let COOKIE_NAME = 'brainstorm-key-' + PORT; // refined to the actual bound port in onListen
|
||||||
|
|
||||||
const MIME_TYPES = {
|
const MIME_TYPES = {
|
||||||
@@ -594,8 +604,16 @@ function startServer() {
|
|||||||
|
|
||||||
server.on('error', (err) => {
|
server.on('error', (err) => {
|
||||||
if (err.code === 'EADDRINUSE' && !triedFallback) {
|
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;
|
triedFallback = true;
|
||||||
PORT = randomPort();
|
PORT = randomPort();
|
||||||
|
if (tokenSource === 'file') {
|
||||||
|
TOKEN = generateToken();
|
||||||
|
tokenSource = 'generated-fallback';
|
||||||
|
}
|
||||||
server.listen(PORT, HOST, onListen);
|
server.listen(PORT, HOST, onListen);
|
||||||
} else {
|
} else {
|
||||||
console.error('Server failed to bind:', err.message);
|
console.error('Server failed to bind:', err.message);
|
||||||
|
|||||||
@@ -66,6 +66,18 @@ function openCaptureCommand(dir, marker) {
|
|||||||
return `node ${JSON.stringify(scriptPath)} ${JSON.stringify(markerPath)}`;
|
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() {
|
async function runTests() {
|
||||||
let passed = 0, failed = 0;
|
let passed = 0, failed = 0;
|
||||||
async function test(name, fn) {
|
async function test(name, fn) {
|
||||||
@@ -246,6 +258,104 @@ async function runTests() {
|
|||||||
assert.strictEqual(persisted, '3415', 'fallback must not overwrite .last-port');
|
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 () => {
|
await test('auto-opens the browser once, on the first screen', async () => {
|
||||||
const dir = fs.mkdtempSync('/tmp/bs-open-');
|
const dir = fs.mkdtempSync('/tmp/bs-open-');
|
||||||
const marker = path.join(dir, 'opened.log');
|
const marker = path.join(dir, 'opened.log');
|
||||||
|
|||||||
Reference in New Issue
Block a user