mindforge-cc 11.9.4 → 11.9.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,37 +5,33 @@
5
5
  */
6
6
  'use strict';
7
7
 
8
- const http = require('http');
9
- const crypto = require('crypto');
8
+ const http = require('http');
9
+ const crypto = require('crypto');
10
10
  const playwright = require('playwright-core');
11
- const fs = require('fs');
12
- const path = require('path');
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+ const { requiresAuth, isAuthValid } = require('./daemon-auth');
13
14
 
14
- const PORT = process.env.BROWSER_PORT || 7338;
15
- const HEADLESS = process.env.BROWSER_HEADLESS !== 'false';
16
- const TIMEOUT = (parseInt(process.env.BROWSER_IDLE_TIMEOUT_MINUTES) || 30) * 60 * 1000;
15
+ const PORT = process.env.BROWSER_PORT || 7338;
16
+ const HEADLESS = process.env.BROWSER_HEADLESS !== 'false';
17
+ const TIMEOUT =
18
+ (parseInt(process.env.BROWSER_IDLE_TIMEOUT_MINUTES) || 30) * 60 * 1000;
17
19
 
18
20
  // ── Bearer token authentication ──────────────────────────────────────────────
19
21
  const DAEMON_TOKEN = crypto.randomBytes(32).toString('hex');
20
- const DAEMON_TOKEN_FILE = path.join(process.cwd(), '.mindforge', '.browser-daemon-token');
22
+ const DAEMON_TOKEN_FILE = path.join(
23
+ process.cwd(),
24
+ '.mindforge',
25
+ '.browser-daemon-token',
26
+ );
21
27
 
22
28
  // Write token to file with restrictive permissions (owner-only read/write)
23
29
  fs.mkdirSync(path.dirname(DAEMON_TOKEN_FILE), { recursive: true });
24
30
  fs.writeFileSync(DAEMON_TOKEN_FILE, DAEMON_TOKEN, { mode: 0o600 });
25
31
 
26
- /**
27
- * Validate bearer token from Authorization header.
28
- * Returns true if valid, false otherwise.
29
- */
30
- function isAuthValid(req) {
31
- const authHeader = req.headers.authorization;
32
- if (!authHeader || !authHeader.startsWith('Bearer ')) return false;
33
- const provided = authHeader.slice(7);
34
- if (provided.length !== DAEMON_TOKEN.length) return false;
35
- return crypto.timingSafeEqual(Buffer.from(provided), Buffer.from(DAEMON_TOKEN));
36
- }
37
-
38
- let browser, lastActionAt = Date.now(), isLaunching = false;
32
+ let browser,
33
+ lastActionAt = Date.now(),
34
+ isLaunching = false;
39
35
  const sessions = new Map(); // name -> { context, page }
40
36
 
41
37
  async function init() {
@@ -44,7 +40,7 @@ async function init() {
44
40
  try {
45
41
  browser = await playwright.chromium.launch({
46
42
  headless: HEADLESS,
47
- args: ['--no-sandbox', '--disable-setuid-sandbox', '--no-first-run']
43
+ args: ['--no-sandbox', '--disable-setuid-sandbox', '--no-first-run'],
48
44
  });
49
45
  setInterval(checkIdle, 60000);
50
46
  } finally {
@@ -55,14 +51,14 @@ async function init() {
55
51
  function checkIdle() {
56
52
  if (Date.now() - lastActionAt > TIMEOUT) {
57
53
  console.log('[daemon] Idle timeout reached. Shutting down.');
58
- process.exit(0);
54
+ shutdown();
59
55
  }
60
56
  }
61
57
 
62
58
  async function getOrCreateSession(name = 'default') {
63
59
  if (sessions.has(name)) return sessions.get(name);
64
60
  const context = await browser.newContext();
65
- const page = await context.newPage();
61
+ const page = await context.newPage();
66
62
  const s = { context, page };
67
63
  sessions.set(name, s);
68
64
  return s;
@@ -77,33 +73,64 @@ const server = http.createServer(async (req, res) => {
77
73
 
78
74
  // Only allow localhost
79
75
  const remote = req.socket.remoteAddress;
80
- if (remote !== '127.0.0.1' && remote !== '::1' && remote !== '::ffff:127.0.0.1') {
76
+ if (
77
+ remote !== '127.0.0.1' &&
78
+ remote !== '::1' &&
79
+ remote !== '::ffff:127.0.0.1'
80
+ ) {
81
81
  return send({ error: 'Forbidden: Localhost only' }, 403);
82
82
  }
83
83
 
84
84
  let body = '';
85
- req.on('data', chunk => body += chunk);
85
+ req.on('data', (chunk) => (body += chunk));
86
86
  req.on('end', async () => {
87
87
  try {
88
- const { url, session: sessionName, selector, text, script, type, expected_text, name } = body ? JSON.parse(body) : {};
88
+ if (
89
+ requiresAuth(req.url) &&
90
+ !isAuthValid(req.headers.authorization, DAEMON_TOKEN)
91
+ ) {
92
+ return send(
93
+ {
94
+ error:
95
+ 'Authentication required. Use the token written to the daemon token file at startup.',
96
+ },
97
+ 401,
98
+ );
99
+ }
100
+ const {
101
+ url,
102
+ session: sessionName,
103
+ selector,
104
+ text,
105
+ script,
106
+ type,
107
+ expected_text,
108
+ name,
109
+ } = body ? JSON.parse(body) : {};
89
110
  const { page, context } = await getOrCreateSession(sessionName);
90
111
 
91
112
  if (req.url === '/status' && req.method === 'GET') {
92
- return send({ alive: true, sessions: Array.from(sessions.keys()), uptime: process.uptime() });
113
+ return send({
114
+ alive: true,
115
+ sessions: Array.from(sessions.keys()),
116
+ uptime: process.uptime(),
117
+ });
93
118
  }
94
119
 
95
120
  if (req.url === '/navigate' && req.method === 'POST') {
96
121
  const start = Date.now();
97
122
  const r = await page.goto(url, { waitUntil: 'load', timeout: 30000 });
98
- return send({
99
- success: true,
100
- status_code: r ? r.status() : 200,
101
- load_time_ms: Date.now() - start
123
+ return send({
124
+ success: true,
125
+ status_code: r ? r.status() : 200,
126
+ load_time_ms: Date.now() - start,
102
127
  });
103
128
  }
104
129
 
105
130
  if (req.url === '/click' && req.method === 'POST') {
106
- const target = selector ? page.locator(selector) : page.getByText(text, { exact: false });
131
+ const target = selector
132
+ ? page.locator(selector)
133
+ : page.getByText(text, { exact: false });
107
134
  await target.first().click({ timeout: 5000 });
108
135
  return send({ success: true, element_found: true });
109
136
  }
@@ -119,9 +146,6 @@ const server = http.createServer(async (req, res) => {
119
146
  }
120
147
 
121
148
  if (req.url === '/evaluate' && req.method === 'POST') {
122
- if (!isAuthValid(req)) {
123
- return send({ error: 'Authentication required. Use the token printed at daemon startup.' }, 401);
124
- }
125
149
  const result = await page.evaluate(script);
126
150
  return send({ success: true, result });
127
151
  }
@@ -131,11 +155,20 @@ const server = http.createServer(async (req, res) => {
131
155
  const loc = page.locator(selector).first();
132
156
  const visible = await loc.isVisible();
133
157
  const actual = visible ? await loc.innerText() : '';
134
- const passed = visible && (!expected_text || actual.includes(expected_text));
158
+ const passed =
159
+ visible && (!expected_text || actual.includes(expected_text));
135
160
  return send({ passed, actual_text: actual });
136
161
  }
137
- if (type === 'url') return send({ passed: page.url().includes(expected_text), actual_url: page.url() });
138
- if (type === 'title') return send({ passed: (await page.title()).includes(expected_text), actual_title: await page.title() });
162
+ if (type === 'url')
163
+ return send({
164
+ passed: page.url().includes(expected_text),
165
+ actual_url: page.url(),
166
+ });
167
+ if (type === 'title')
168
+ return send({
169
+ passed: (await page.title()).includes(expected_text),
170
+ actual_title: await page.title(),
171
+ });
139
172
  if (type === 'no_console_errors') return send({ passed: true }); // simplified
140
173
  }
141
174
 
@@ -146,16 +179,19 @@ const server = http.createServer(async (req, res) => {
146
179
  });
147
180
  });
148
181
 
149
- init().then(() => {
150
- server.listen(PORT, '127.0.0.1', () => {
151
- console.log(`[BrowserDaemon] Listening on port ${PORT}`);
152
- console.log(`[BrowserDaemon] Auth token: ${DAEMON_TOKEN}`);
153
- console.log(`[BrowserDaemon] Token file: ${DAEMON_TOKEN_FILE}`);
182
+ init()
183
+ .then(() => {
184
+ server.listen(PORT, '127.0.0.1', () => {
185
+ console.log(`[BrowserDaemon] Listening on port ${PORT}`);
186
+ console.log(
187
+ `[BrowserDaemon] Auth token written to: ${DAEMON_TOKEN_FILE}`,
188
+ );
189
+ });
190
+ })
191
+ .catch((err) => {
192
+ console.error('[daemon] Initialization failed:', err);
193
+ process.exit(1);
154
194
  });
155
- }).catch(err => {
156
- console.error('[daemon] Initialization failed:', err);
157
- process.exit(1);
158
- });
159
195
 
160
196
  async function shutdown() {
161
197
  console.log('[daemon] Shutting down gracefully...');
@@ -0,0 +1,36 @@
1
+ /**
2
+ * MindForge v2 — Browser Daemon Auth
3
+ * Pure bearer-token validation extracted from browser-daemon.js so it can be
4
+ * unit-tested without booting Chromium. See ADR-024 / ADR-026.
5
+ */
6
+ 'use strict';
7
+
8
+ const crypto = require('crypto');
9
+
10
+ /**
11
+ * Endpoints that MUST present a valid bearer token. /status is intentionally
12
+ * excluded: it is the unauthenticated liveness probe daemon-manager.js#isRunning
13
+ * relies on, it leaks only session names + uptime, and it is already covered by
14
+ * ADR-024's localhost bind + remoteAddress check.
15
+ */
16
+ const AUTH_REQUIRED_PATHS = new Set([
17
+ '/navigate',
18
+ '/click',
19
+ '/type',
20
+ '/screenshot',
21
+ '/evaluate',
22
+ '/assert',
23
+ ]);
24
+
25
+ function requiresAuth(urlPath) {
26
+ return AUTH_REQUIRED_PATHS.has(urlPath);
27
+ }
28
+
29
+ function isAuthValid(authHeader, token) {
30
+ if (!authHeader || !authHeader.startsWith('Bearer ')) return false;
31
+ const provided = authHeader.slice(7);
32
+ if (provided.length !== token.length) return false;
33
+ return crypto.timingSafeEqual(Buffer.from(provided), Buffer.from(token));
34
+ }
35
+
36
+ module.exports = { AUTH_REQUIRED_PATHS, requiresAuth, isAuthValid };
@@ -6,15 +6,28 @@
6
6
 
7
7
  const { spawn, execSync } = require('child_process');
8
8
  const path = require('path');
9
- const fs = require('fs');
9
+ const fs = require('fs');
10
10
  const http = require('http');
11
11
 
12
12
  const PORT = process.env.BROWSER_PORT || 7338;
13
13
  const DAEMON_SCRIPT = path.join(__dirname, 'browser-daemon.js');
14
+ const TOKEN_FILE = path.join(
15
+ process.cwd(),
16
+ '.mindforge',
17
+ '.browser-daemon-token',
18
+ );
19
+
20
+ function readToken() {
21
+ try {
22
+ return fs.readFileSync(TOKEN_FILE, 'utf8').trim();
23
+ } catch {
24
+ return null;
25
+ }
26
+ }
14
27
 
15
28
  async function isRunning() {
16
- return new Promise(resolve => {
17
- const req = http.get(`http://127.0.0.1:${PORT}/status`, res => {
29
+ return new Promise((resolve) => {
30
+ const req = http.get(`http://127.0.0.1:${PORT}/status`, (res) => {
18
31
  resolve(res.statusCode === 200);
19
32
  });
20
33
  req.on('error', () => resolve(false));
@@ -25,13 +38,19 @@ async function isRunning() {
25
38
  async function start() {
26
39
  if (await isRunning()) return;
27
40
 
28
- const out = fs.openSync(path.join(process.cwd(), '.planning/browser-daemon.log'), 'a');
29
- const err = fs.openSync(path.join(process.cwd(), '.planning/browser-daemon.log'), 'a');
41
+ const out = fs.openSync(
42
+ path.join(process.cwd(), '.planning/browser-daemon.log'),
43
+ 'a',
44
+ );
45
+ const err = fs.openSync(
46
+ path.join(process.cwd(), '.planning/browser-daemon.log'),
47
+ 'a',
48
+ );
30
49
 
31
50
  const child = spawn(process.execPath, [DAEMON_SCRIPT], {
32
51
  detached: true,
33
52
  stdio: ['ignore', out, err],
34
- env: { ...process.env, BROWSER_HEADLESS: 'true' }
53
+ env: { ...process.env, BROWSER_HEADLESS: 'true' },
35
54
  });
36
55
 
37
56
  child.unref();
@@ -39,7 +58,7 @@ async function start() {
39
58
 
40
59
  // Wait for it to wake up
41
60
  for (let i = 0; i < 10; i++) {
42
- await new Promise(r => setTimeout(r, 500));
61
+ await new Promise((r) => setTimeout(r, 500));
43
62
  if (await isRunning()) {
44
63
  console.log('[manager] Browser daemon ready.');
45
64
  return;
@@ -53,7 +72,9 @@ async function stop() {
53
72
  // Simple way to kill on localhost
54
73
  try {
55
74
  if (process.platform === 'win32') {
56
- execSync(`for /f "tokens=5" %a in ('netstat -aon ^| find ":${PORT}" ^| find "LISTENING"') do taskkill /f /pid %a`);
75
+ execSync(
76
+ `for /f "tokens=5" %a in ('netstat -aon ^| find ":${PORT}" ^| find "LISTENING"') do taskkill /f /pid %a`,
77
+ );
57
78
  } else {
58
79
  execSync(`lsof -t -i:${PORT} | xargs kill -9`);
59
80
  }
@@ -69,20 +90,30 @@ async function ensureRunning(opts = {}) {
69
90
 
70
91
  async function request(method, endpoint, body = null) {
71
92
  return new Promise((resolve, reject) => {
72
- const req = http.request({
73
- hostname: '127.0.0.1',
74
- port: PORT,
75
- path: endpoint,
76
- method: method,
77
- headers: { 'Content-Type': 'application/json' }
78
- }, res => {
79
- let data = '';
80
- res.on('data', chunk => data += chunk);
81
- res.on('end', () => {
82
- try { resolve(JSON.parse(data)); } catch { resolve({ success: false, error: 'Invalid JSON response' }); }
83
- });
84
- });
85
- req.on('error', err => reject(err));
93
+ const headers = { 'Content-Type': 'application/json' };
94
+ const token = readToken();
95
+ if (token) headers.Authorization = `Bearer ${token}`;
96
+ const req = http.request(
97
+ {
98
+ hostname: '127.0.0.1',
99
+ port: PORT,
100
+ path: endpoint,
101
+ method: method,
102
+ headers,
103
+ },
104
+ (res) => {
105
+ let data = '';
106
+ res.on('data', (chunk) => (data += chunk));
107
+ res.on('end', () => {
108
+ try {
109
+ resolve(JSON.parse(data));
110
+ } catch {
111
+ resolve({ success: false, error: 'Invalid JSON response' });
112
+ }
113
+ });
114
+ },
115
+ );
116
+ req.on('error', (err) => reject(err));
86
117
  if (body) req.write(JSON.stringify(body));
87
118
  req.end();
88
119
  });