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.
- package/.mindforge/config.json +1 -1
- package/CHANGELOG.md +267 -8
- package/MINDFORGE.md +2 -2
- package/README.md +32 -11
- package/RELEASENOTES.md +167 -0
- package/SECURITY.md +2 -2
- package/bin/browser/browser-daemon.js +84 -48
- package/bin/browser/daemon-auth.js +36 -0
- package/bin/browser/daemon-manager.js +53 -22
- package/bin/dashboard/frontend/app.js +206 -101
- package/bin/skills-builder/learn-cli.js +26 -14
- package/bin/utils/readiness-gate.js +19 -3
- package/changelogs/v11.9.4.md +31 -8
- package/changelogs/v11.9.5.md +104 -0
- package/changelogs/v11.9.6.md +135 -0
- package/docs/References/commands.md +13 -8
- package/docs/References/decimal-phase-calculation.md +5 -5
- package/docs/References/git-integration.md +4 -4
- package/docs/References/git-planning-commit.md +2 -2
- package/docs/References/model-profile-resolution.md +1 -1
- package/docs/faq.md +5 -4
- package/docs/getting-started.md +3 -3
- package/docs/sdk-reference.md +17 -11
- package/docs/troubleshooting.md +1 -1
- package/docs/user-guide.md +4 -4
- package/examples/sdk-integration/index.js +1 -1
- package/package.json +7 -3
|
@@ -5,37 +5,33 @@
|
|
|
5
5
|
*/
|
|
6
6
|
'use strict';
|
|
7
7
|
|
|
8
|
-
const http
|
|
9
|
-
const crypto
|
|
8
|
+
const http = require('http');
|
|
9
|
+
const crypto = require('crypto');
|
|
10
10
|
const playwright = require('playwright-core');
|
|
11
|
-
const fs
|
|
12
|
-
const path
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
const { requiresAuth, isAuthValid } = require('./daemon-auth');
|
|
13
14
|
|
|
14
|
-
const PORT
|
|
15
|
-
const HEADLESS
|
|
16
|
-
const TIMEOUT
|
|
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(
|
|
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
|
-
|
|
28
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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({
|
|
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
|
|
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 =
|
|
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')
|
|
138
|
-
|
|
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()
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
|
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(
|
|
29
|
-
|
|
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(
|
|
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
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
});
|