myrlin-workbook 0.9.0 → 0.9.2

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/README.md CHANGED
@@ -54,6 +54,8 @@ CWM_PASSWORD=mypassword npx myrlin-workbook
54
54
 
55
55
  Password lookup order: `CWM_PASSWORD` env var > `~/.myrlin/config.json` > `./state/config.json` > auto-generate.
56
56
 
57
+ On startup, the console prints a clickable URL with a one-time token (e.g., `http://127.0.0.1:3456?token=<random>`). Click it to auto-login — the token is single-use and expires after 60 seconds, so it's safe even if it appears in terminal logs. The token is stripped from the URL bar immediately after login.
58
+
57
59
  ### Prerequisites
58
60
 
59
61
  - **Node.js 18+** ([download](https://nodejs.org))
package/package.json CHANGED
@@ -1,64 +1,64 @@
1
- {
2
- "name": "myrlin-workbook",
3
- "version": "0.9.0",
4
- "description": "Browser-based project manager for Claude Code sessions - session discovery, multi-terminal, cost tracking, docs, and kanban board",
5
- "main": "src/index.js",
6
- "bin": {
7
- "myrlin-workbook": "./src/gui.js",
8
- "myrlin": "./src/gui.js",
9
- "myrlin-tui": "./src/index.js",
10
- "cwm": "./src/index.js"
11
- },
12
- "scripts": {
13
- "start": "node src/index.js",
14
- "demo": "node src/demo.js",
15
- "gui": "node src/supervisor.js",
16
- "gui:bare": "node src/gui.js",
17
- "gui:demo": "node src/supervisor.js --demo",
18
- "test": "node test/run.js",
19
- "mcp:visual-qa": "node src/mcp/visual-qa.js",
20
- "gui:cdp": "node src/supervisor.js --cdp",
21
- "postinstall": "node scripts/postinstall.js",
22
- "restart": "bash scripts/restart-gui.sh"
23
- },
24
- "repository": {
25
- "type": "git",
26
- "url": "https://github.com/therealarthur/myrlin-workbook.git"
27
- },
28
- "homepage": "https://github.com/therealarthur/myrlin-workbook",
29
- "engines": {
30
- "node": ">=18.0.0"
31
- },
32
- "keywords": [
33
- "claude",
34
- "workspace",
35
- "manager",
36
- "terminal",
37
- "tui",
38
- "ai",
39
- "coding-assistant",
40
- "session-manager",
41
- "developer-tools",
42
- "xterm",
43
- "myrlin"
44
- ],
45
- "author": "Arthur",
46
- "license": "AGPL-3.0-only",
47
- "dependencies": {
48
- "blessed": "^0.1.81",
49
- "blessed-contrib": "^4.11.0",
50
- "chalk": "^5.6.2",
51
- "chrome-remote-interface": "^0.34.0",
52
- "express": "^5.2.1",
53
- "node-pty": "^1.1.0",
54
- "ws": "^8.19.0"
55
- },
56
- "devDependencies": {
57
- "@playwright/test": "^1.58.2",
58
- "@xterm/addon-fit": "^0.11.0",
59
- "@xterm/addon-web-links": "^0.12.0",
60
- "@xterm/xterm": "^6.0.0",
61
- "ffmpeg-static": "^5.3.0",
62
- "sharp": "^0.34.5"
63
- }
64
- }
1
+ {
2
+ "name": "myrlin-workbook",
3
+ "version": "0.9.2",
4
+ "description": "Browser-based project manager for Claude Code sessions - session discovery, multi-terminal, cost tracking, docs, and kanban board",
5
+ "main": "src/index.js",
6
+ "bin": {
7
+ "myrlin-workbook": "./src/gui.js",
8
+ "myrlin": "./src/gui.js",
9
+ "myrlin-tui": "./src/index.js",
10
+ "cwm": "./src/index.js"
11
+ },
12
+ "scripts": {
13
+ "start": "node src/index.js",
14
+ "demo": "node src/demo.js",
15
+ "gui": "node src/supervisor.js",
16
+ "gui:bare": "node src/gui.js",
17
+ "gui:demo": "node src/supervisor.js --demo",
18
+ "test": "node test/run.js",
19
+ "mcp:visual-qa": "node src/mcp/visual-qa.js",
20
+ "gui:cdp": "node src/supervisor.js --cdp",
21
+ "postinstall": "node scripts/postinstall.js",
22
+ "restart": "bash scripts/restart-gui.sh"
23
+ },
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "https://github.com/therealarthur/myrlin-workbook.git"
27
+ },
28
+ "homepage": "https://github.com/therealarthur/myrlin-workbook",
29
+ "engines": {
30
+ "node": ">=18.0.0"
31
+ },
32
+ "keywords": [
33
+ "claude",
34
+ "workspace",
35
+ "manager",
36
+ "terminal",
37
+ "tui",
38
+ "ai",
39
+ "coding-assistant",
40
+ "session-manager",
41
+ "developer-tools",
42
+ "xterm",
43
+ "myrlin"
44
+ ],
45
+ "author": "Arthur",
46
+ "license": "AGPL-3.0-only",
47
+ "dependencies": {
48
+ "blessed": "^0.1.81",
49
+ "blessed-contrib": "^4.11.0",
50
+ "chalk": "^5.6.2",
51
+ "chrome-remote-interface": "^0.34.0",
52
+ "express": "^5.2.1",
53
+ "node-pty": "^1.1.0",
54
+ "ws": "^8.19.0"
55
+ },
56
+ "devDependencies": {
57
+ "@playwright/test": "^1.58.2",
58
+ "@xterm/addon-fit": "^0.11.0",
59
+ "@xterm/addon-web-links": "^0.12.0",
60
+ "@xterm/xterm": "^6.0.0",
61
+ "ffmpeg-static": "^5.3.0",
62
+ "sharp": "^0.34.5"
63
+ }
64
+ }
package/src/gui.js CHANGED
@@ -16,6 +16,7 @@
16
16
  const { getStore } = require('./state/store');
17
17
  const { startServer, getPtyManager } = require('./web/server');
18
18
  const { backupFrontend } = require('./web/backup');
19
+ const { generateStartupToken } = require('./web/auth');
19
20
 
20
21
  // ─── Initialize Store ──────────────────────────────────────
21
22
 
@@ -92,7 +93,9 @@ const port = parseInt(process.env.PORT, 10) || 3456;
92
93
  const host = process.env.CWM_HOST || '127.0.0.1';
93
94
  const server = startServer(port, host);
94
95
 
95
- console.log(`CWM GUI running at http://${host}:${port}`);
96
+ const startupToken = generateStartupToken();
97
+ const authUrl = `http://${host}:${port}?token=${encodeURIComponent(startupToken)}`;
98
+ console.log(`CWM GUI running at ${authUrl}`);
96
99
  console.log('Press Ctrl+C to stop.');
97
100
 
98
101
  // Snapshot frontend files as "last known good" on successful start
@@ -167,7 +170,7 @@ function openBrowserWithCDP(url, cdpPort) {
167
170
  if (!process.env.CWM_NO_OPEN) {
168
171
  const cdpEnabled = process.argv.includes('--cdp');
169
172
  const cdpPort = parseInt(process.env.CDP_PORT, 10) || 9222;
170
- const url = `http://localhost:${port}`;
173
+ const url = authUrl;
171
174
 
172
175
  if (cdpEnabled) {
173
176
  openBrowserWithCDP(url, cdpPort);
package/src/web/auth.js CHANGED
@@ -1,308 +1,399 @@
1
- /**
2
- * Authentication module for Claude Workspace Manager Web API.
3
- * Uses a simple in-memory token approach with Bearer token auth.
4
- *
5
- * - POST /api/auth/login - Validates password, returns a Bearer token
6
- * - POST /api/auth/logout - Invalidates the token
7
- * - GET /api/auth/check - Validates current token
8
- *
9
- * Protected routes use the requireAuth middleware which checks
10
- * the Authorization: Bearer <token> header.
11
- *
12
- * Password is loaded from (in priority order):
13
- * 1. CWM_PASSWORD environment variable
14
- * 2. ~/.myrlin/config.json (persists across npx updates/reinstalls)
15
- * 3. ./state/config.json (local project config)
16
- * 4. Auto-generated on first run (saved to both locations)
17
- *
18
- * SPDX-License-Identifier: AGPL-3.0-only
19
- */
20
-
21
- const crypto = require('crypto');
22
- const fs = require('fs');
23
- const path = require('path');
24
- const os = require('os');
25
-
26
- // ─── Configuration ─────────────────────────────────────────
27
- const TOKEN_BYTE_LENGTH = 32;
28
- const HOME_CONFIG_DIR = path.join(os.homedir(), '.myrlin');
29
- const HOME_CONFIG_FILE = path.join(HOME_CONFIG_DIR, 'config.json');
30
- const LOCAL_CONFIG_DIR = path.join(__dirname, '..', '..', 'state');
31
- const LOCAL_CONFIG_FILE = path.join(LOCAL_CONFIG_DIR, 'config.json');
32
-
33
- // ─── Rate Limiting ─────────────────────────────────────────
34
- // Simple in-memory rate limiter: max 5 login attempts per IP per 60 seconds
35
- const LOGIN_RATE_LIMIT = 5;
36
- const LOGIN_RATE_WINDOW_MS = 60 * 1000; // 1 minute
37
- const loginAttempts = new Map(); // IP -> { count, resetAt }
38
-
39
- /**
40
- * Check if a login attempt from this IP should be rate-limited.
41
- * @param {string} ip - Client IP address
42
- * @returns {boolean} true if rate limited (should reject)
43
- */
44
- function isRateLimited(ip) {
45
- const now = Date.now();
46
- const entry = loginAttempts.get(ip);
47
-
48
- if (!entry || now > entry.resetAt) {
49
- // Window expired or new IP - start fresh
50
- loginAttempts.set(ip, { count: 1, resetAt: now + LOGIN_RATE_WINDOW_MS });
51
- return false;
52
- }
53
-
54
- entry.count++;
55
- if (entry.count > LOGIN_RATE_LIMIT) {
56
- return true;
57
- }
58
- return false;
59
- }
60
-
61
- // Clean up stale rate limit entries every 5 minutes
62
- setInterval(() => {
63
- const now = Date.now();
64
- for (const [ip, entry] of loginAttempts) {
65
- if (now > entry.resetAt) {
66
- loginAttempts.delete(ip);
67
- }
68
- }
69
- }, 5 * 60 * 1000).unref();
70
-
71
- // ─── Password Management ──────────────────────────────────
72
-
73
- /**
74
- * Read password from a config file, returns null if not found.
75
- * @param {string} filePath - Path to config.json
76
- * @returns {string|null}
77
- */
78
- function readPasswordFromFile(filePath) {
79
- try {
80
- if (fs.existsSync(filePath)) {
81
- const config = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
82
- if (config.password && typeof config.password === 'string') {
83
- return config.password;
84
- }
85
- }
86
- } catch (_) {
87
- // Corrupted config - skip
88
- }
89
- return null;
90
- }
91
-
92
- /**
93
- * Save password to a config file (merges with existing keys).
94
- * @param {string} dir - Config directory path
95
- * @param {string} filePath - Config file path
96
- * @param {string} password - Password to save
97
- */
98
- function savePasswordToFile(dir, filePath, password) {
99
- try {
100
- if (!fs.existsSync(dir)) {
101
- fs.mkdirSync(dir, { recursive: true });
102
- }
103
- const config = {};
104
- try {
105
- if (fs.existsSync(filePath)) {
106
- Object.assign(config, JSON.parse(fs.readFileSync(filePath, 'utf-8')));
107
- }
108
- } catch (_) {}
109
- config.password = password;
110
- fs.writeFileSync(filePath, JSON.stringify(config, null, 2), 'utf-8');
111
- } catch (err) {
112
- // Non-fatal: password still works in memory for this session
113
- }
114
- }
115
-
116
- /**
117
- * Load or generate the auth password.
118
- * Priority: env var > ~/.myrlin/config.json > ./state/config.json > auto-generate.
119
- * When auto-generating, saves to both ~/.myrlin/ and ./state/ so the password
120
- * persists across npx cache clears and project reinstalls.
121
- * @returns {string}
122
- */
123
- function loadPassword() {
124
- // 1. Environment variable (highest priority, always wins)
125
- if (process.env.CWM_PASSWORD) {
126
- return process.env.CWM_PASSWORD;
127
- }
128
-
129
- // 2. Home directory config (~/.myrlin/config.json) — persists across reinstalls
130
- const homePassword = readPasswordFromFile(HOME_CONFIG_FILE);
131
- if (homePassword) {
132
- // Also sync to local config so it's visible in the project
133
- savePasswordToFile(LOCAL_CONFIG_DIR, LOCAL_CONFIG_FILE, homePassword);
134
- return homePassword;
135
- }
136
-
137
- // 3. Local project config (./state/config.json)
138
- const localPassword = readPasswordFromFile(LOCAL_CONFIG_FILE);
139
- if (localPassword) {
140
- // Promote to home config for persistence across reinstalls
141
- savePasswordToFile(HOME_CONFIG_DIR, HOME_CONFIG_FILE, localPassword);
142
- return localPassword;
143
- }
144
-
145
- // 4. Auto-generate and save to both locations
146
- const generated = crypto.randomBytes(16).toString('base64url');
147
- savePasswordToFile(HOME_CONFIG_DIR, HOME_CONFIG_FILE, generated);
148
- savePasswordToFile(LOCAL_CONFIG_DIR, LOCAL_CONFIG_FILE, generated);
149
-
150
- console.log('');
151
- console.log('══════════════════════════════════════════════════');
152
- console.log(' CWM auto-generated password: ' + generated);
153
- console.log(' Saved to: ~/.myrlin/config.json');
154
- console.log(' Set CWM_PASSWORD env var to override.');
155
- console.log('══════════════════════════════════════════════════');
156
- console.log('');
157
-
158
- return generated;
159
- }
160
-
161
- const AUTH_PASSWORD = loadPassword();
162
-
163
- // In-memory set of valid tokens. Tokens survive for the lifetime of
164
- // the server process. A restart invalidates all tokens (acceptable
165
- // for a local dev-tool).
166
- const activeTokens = new Set();
167
-
168
- // ─── Helpers ───────────────────────────────────────────────
169
-
170
- /**
171
- * Generate a cryptographically random hex token.
172
- * @returns {string} 64-character hex string
173
- */
174
- function generateToken() {
175
- return crypto.randomBytes(TOKEN_BYTE_LENGTH).toString('hex');
176
- }
177
-
178
- /**
179
- * Extract the Bearer token from an Authorization header value.
180
- * Returns null if the header is missing or malformed.
181
- * @param {string|undefined} headerValue - The raw Authorization header
182
- * @returns {string|null}
183
- */
184
- function extractBearerToken(headerValue) {
185
- if (!headerValue || typeof headerValue !== 'string') return null;
186
- const parts = headerValue.split(' ');
187
- if (parts.length !== 2 || parts[0] !== 'Bearer') return null;
188
- return parts[1];
189
- }
190
-
191
- // ─── Middleware ─────────────────────────────────────────────
192
-
193
- /**
194
- * Express middleware that requires a valid Bearer token.
195
- * Responds with 401 if the token is missing or invalid.
196
- */
197
- function requireAuth(req, res, next) {
198
- const token = extractBearerToken(req.headers.authorization);
199
-
200
- if (!token || !activeTokens.has(token)) {
201
- return res.status(401).json({
202
- error: 'Unauthorized',
203
- message: 'Valid Bearer token required. POST /api/auth/login to authenticate.',
204
- });
205
- }
206
-
207
- // Attach token to request for downstream use (e.g. logout)
208
- req.authToken = token;
209
- next();
210
- }
211
-
212
- // ─── Route Setup ───────────────────────────────────────────
213
-
214
- /**
215
- * Mount authentication routes on the Express app.
216
- * These routes are NOT protected by requireAuth - they are public.
217
- *
218
- * @param {import('express').Express} app - The Express application
219
- */
220
- function setupAuth(app) {
221
- /**
222
- * POST /api/auth/login
223
- * Body: { password: string }
224
- * Returns: { success: true, token: string } or { success: false, error: string }
225
- */
226
- app.post('/api/auth/login', (req, res) => {
227
- // Rate limiting
228
- const clientIp = req.ip || req.connection.remoteAddress || 'unknown';
229
- if (isRateLimited(clientIp)) {
230
- return res.status(429).json({
231
- success: false,
232
- error: 'Too many login attempts. Try again in 1 minute.',
233
- });
234
- }
235
-
236
- const { password } = req.body || {};
237
-
238
- if (!password || typeof password !== 'string') {
239
- return res.status(400).json({
240
- success: false,
241
- error: 'Missing or invalid password field in request body.',
242
- });
243
- }
244
-
245
- // Constant-time comparison to mitigate timing attacks
246
- const passwordBuffer = Buffer.from(password, 'utf-8');
247
- const expectedBuffer = Buffer.from(AUTH_PASSWORD, 'utf-8');
248
- const isValid =
249
- passwordBuffer.length === expectedBuffer.length &&
250
- crypto.timingSafeEqual(passwordBuffer, expectedBuffer);
251
-
252
- if (!isValid) {
253
- return res.status(403).json({
254
- success: false,
255
- error: 'Invalid password.',
256
- });
257
- }
258
-
259
- const token = generateToken();
260
- activeTokens.add(token);
261
-
262
- return res.json({ success: true, token });
263
- });
264
-
265
- /**
266
- * POST /api/auth/logout
267
- * Requires Authorization: Bearer <token>
268
- * Removes the token from the active set.
269
- */
270
- app.post('/api/auth/logout', (req, res) => {
271
- const token = extractBearerToken(req.headers.authorization);
272
-
273
- if (token) {
274
- activeTokens.delete(token);
275
- }
276
-
277
- return res.json({ success: true });
278
- });
279
-
280
- /**
281
- * GET /api/auth/check
282
- * Returns whether the provided Bearer token is still valid.
283
- */
284
- app.get('/api/auth/check', (req, res) => {
285
- const token = extractBearerToken(req.headers.authorization);
286
- const authenticated = !!token && activeTokens.has(token);
287
-
288
- return res.json({ authenticated });
289
- });
290
- }
291
-
292
- /**
293
- * Check if a raw token string is valid (exists in activeTokens).
294
- * Used by SSE endpoint which can't use requireAuth middleware.
295
- * @param {string} token - The raw token string
296
- * @returns {boolean}
297
- */
298
- function isValidToken(token) {
299
- return !!token && activeTokens.has(token);
300
- }
301
-
302
- // ─── Exports ───────────────────────────────────────────────
303
-
304
- module.exports = {
305
- setupAuth,
306
- requireAuth,
307
- isValidToken,
308
- };
1
+ /**
2
+ * Authentication module for Claude Workspace Manager Web API.
3
+ * Uses a simple in-memory token approach with Bearer token auth.
4
+ *
5
+ * - POST /api/auth/login - Validates password, returns a Bearer token
6
+ * - POST /api/auth/logout - Invalidates the token
7
+ * - GET /api/auth/check - Validates current token
8
+ *
9
+ * Protected routes use the requireAuth middleware which checks
10
+ * the Authorization: Bearer <token> header.
11
+ *
12
+ * Password is loaded from (in priority order):
13
+ * 1. CWM_PASSWORD environment variable
14
+ * 2. ~/.myrlin/config.json (persists across npx updates/reinstalls)
15
+ * 3. ./state/config.json (local project config)
16
+ * 4. Auto-generated on first run (saved to both locations)
17
+ *
18
+ * SPDX-License-Identifier: AGPL-3.0-only
19
+ */
20
+
21
+ const crypto = require('crypto');
22
+ const fs = require('fs');
23
+ const path = require('path');
24
+ const os = require('os');
25
+
26
+ // ─── Configuration ─────────────────────────────────────────
27
+ const TOKEN_BYTE_LENGTH = 32;
28
+ const STARTUP_TOKEN_TTL_MS = 60 * 1000; // 60 seconds
29
+ const HOME_CONFIG_DIR = path.join(os.homedir(), '.myrlin');
30
+ const HOME_CONFIG_FILE = path.join(HOME_CONFIG_DIR, 'config.json');
31
+ const LOCAL_CONFIG_DIR = path.join(__dirname, '..', '..', 'state');
32
+ const LOCAL_CONFIG_FILE = path.join(LOCAL_CONFIG_DIR, 'config.json');
33
+
34
+ // ─── Rate Limiting ─────────────────────────────────────────
35
+ // Simple in-memory rate limiter: max 5 login attempts per IP per 60 seconds
36
+ const LOGIN_RATE_LIMIT = 5;
37
+ const LOGIN_RATE_WINDOW_MS = 60 * 1000; // 1 minute
38
+ const loginAttempts = new Map(); // IP -> { count, resetAt }
39
+
40
+ /**
41
+ * Check if a login attempt from this IP should be rate-limited.
42
+ * @param {string} ip - Client IP address
43
+ * @returns {boolean} true if rate limited (should reject)
44
+ */
45
+ function isRateLimited(ip) {
46
+ const now = Date.now();
47
+ const entry = loginAttempts.get(ip);
48
+
49
+ if (!entry || now > entry.resetAt) {
50
+ // Window expired or new IP - start fresh
51
+ loginAttempts.set(ip, { count: 1, resetAt: now + LOGIN_RATE_WINDOW_MS });
52
+ return false;
53
+ }
54
+
55
+ entry.count++;
56
+ if (entry.count > LOGIN_RATE_LIMIT) {
57
+ return true;
58
+ }
59
+ return false;
60
+ }
61
+
62
+ // Clean up stale rate limit entries every 5 minutes
63
+ setInterval(() => {
64
+ const now = Date.now();
65
+ for (const [ip, entry] of loginAttempts) {
66
+ if (now > entry.resetAt) {
67
+ loginAttempts.delete(ip);
68
+ }
69
+ }
70
+ }, 5 * 60 * 1000).unref();
71
+
72
+ // ─── Password Management ──────────────────────────────────
73
+
74
+ /**
75
+ * Read password from a config file, returns null if not found.
76
+ * @param {string} filePath - Path to config.json
77
+ * @returns {string|null}
78
+ */
79
+ function readPasswordFromFile(filePath) {
80
+ try {
81
+ if (fs.existsSync(filePath)) {
82
+ const config = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
83
+ if (config.password && typeof config.password === 'string') {
84
+ return config.password;
85
+ }
86
+ }
87
+ } catch (_) {
88
+ // Corrupted config - skip
89
+ }
90
+ return null;
91
+ }
92
+
93
+ /**
94
+ * Save password to a config file (merges with existing keys).
95
+ * @param {string} dir - Config directory path
96
+ * @param {string} filePath - Config file path
97
+ * @param {string} password - Password to save
98
+ */
99
+ function savePasswordToFile(dir, filePath, password) {
100
+ try {
101
+ if (!fs.existsSync(dir)) {
102
+ fs.mkdirSync(dir, { recursive: true });
103
+ }
104
+ const config = {};
105
+ try {
106
+ if (fs.existsSync(filePath)) {
107
+ Object.assign(config, JSON.parse(fs.readFileSync(filePath, 'utf-8')));
108
+ }
109
+ } catch (_) {}
110
+ config.password = password;
111
+ fs.writeFileSync(filePath, JSON.stringify(config, null, 2), 'utf-8');
112
+ } catch (err) {
113
+ // Non-fatal: password still works in memory for this session
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Load or generate the auth password.
119
+ * Priority: env var > ~/.myrlin/config.json > ./state/config.json > auto-generate.
120
+ * When auto-generating, saves to both ~/.myrlin/ and ./state/ so the password
121
+ * persists across npx cache clears and project reinstalls.
122
+ * @returns {string}
123
+ */
124
+ function loadPassword() {
125
+ // 1. Environment variable (highest priority, always wins)
126
+ if (process.env.CWM_PASSWORD) {
127
+ return process.env.CWM_PASSWORD;
128
+ }
129
+
130
+ // 2. Home directory config (~/.myrlin/config.json) — persists across reinstalls
131
+ const homePassword = readPasswordFromFile(HOME_CONFIG_FILE);
132
+ if (homePassword) {
133
+ // Also sync to local config so it's visible in the project
134
+ savePasswordToFile(LOCAL_CONFIG_DIR, LOCAL_CONFIG_FILE, homePassword);
135
+ return homePassword;
136
+ }
137
+
138
+ // 3. Local project config (./state/config.json)
139
+ const localPassword = readPasswordFromFile(LOCAL_CONFIG_FILE);
140
+ if (localPassword) {
141
+ // Promote to home config for persistence across reinstalls
142
+ savePasswordToFile(HOME_CONFIG_DIR, HOME_CONFIG_FILE, localPassword);
143
+ return localPassword;
144
+ }
145
+
146
+ // 4. Auto-generate and save to both locations
147
+ const generated = crypto.randomBytes(16).toString('base64url');
148
+ savePasswordToFile(HOME_CONFIG_DIR, HOME_CONFIG_FILE, generated);
149
+ savePasswordToFile(LOCAL_CONFIG_DIR, LOCAL_CONFIG_FILE, generated);
150
+
151
+ console.log('');
152
+ console.log('══════════════════════════════════════════════════');
153
+ console.log(' CWM auto-generated password: ' + generated);
154
+ console.log(' Saved to: ~/.myrlin/config.json');
155
+ console.log(' Set CWM_PASSWORD env var to override.');
156
+ console.log('══════════════════════════════════════════════════');
157
+ console.log('');
158
+
159
+ return generated;
160
+ }
161
+
162
+ const AUTH_PASSWORD = loadPassword();
163
+
164
+ // In-memory set of valid tokens. Tokens survive for the lifetime of
165
+ // the server process. A restart invalidates all tokens (acceptable
166
+ // for a local dev-tool).
167
+ const activeTokens = new Set();
168
+
169
+ // ─── One-Time Startup Tokens ──────────────────────────────
170
+ // Map of token → { createdAt, used }. Single-use, short-lived tokens
171
+ // embedded in the startup URL so the browser can auto-login without
172
+ // exposing the actual password.
173
+ const startupTokens = new Map();
174
+
175
+ /**
176
+ * Generate a one-time startup token for URL-based auto-login.
177
+ * The token is single-use and expires after STARTUP_TOKEN_TTL_MS.
178
+ * @returns {string} The generated token
179
+ */
180
+ function generateStartupToken() {
181
+ const token = crypto.randomBytes(TOKEN_BYTE_LENGTH).toString('hex');
182
+ startupTokens.set(token, { createdAt: Date.now(), used: false });
183
+ return token;
184
+ }
185
+
186
+ // Clean up expired/used startup tokens every 5 minutes
187
+ setInterval(() => {
188
+ const now = Date.now();
189
+ for (const [token, entry] of startupTokens) {
190
+ if (entry.used || now - entry.createdAt > STARTUP_TOKEN_TTL_MS) {
191
+ startupTokens.delete(token);
192
+ }
193
+ }
194
+ }, 5 * 60 * 1000).unref();
195
+
196
+ // ─── Helpers ───────────────────────────────────────────────
197
+
198
+ /**
199
+ * Generate a cryptographically random hex token.
200
+ * @returns {string} 64-character hex string
201
+ */
202
+ function generateToken() {
203
+ return crypto.randomBytes(TOKEN_BYTE_LENGTH).toString('hex');
204
+ }
205
+
206
+ /**
207
+ * Extract the Bearer token from an Authorization header value.
208
+ * Returns null if the header is missing or malformed.
209
+ * @param {string|undefined} headerValue - The raw Authorization header
210
+ * @returns {string|null}
211
+ */
212
+ function extractBearerToken(headerValue) {
213
+ if (!headerValue || typeof headerValue !== 'string') return null;
214
+ const parts = headerValue.split(' ');
215
+ if (parts.length !== 2 || parts[0] !== 'Bearer') return null;
216
+ return parts[1];
217
+ }
218
+
219
+ // ─── Middleware ─────────────────────────────────────────────
220
+
221
+ /**
222
+ * Express middleware that requires a valid Bearer token.
223
+ * Responds with 401 if the token is missing or invalid.
224
+ */
225
+ function requireAuth(req, res, next) {
226
+ const token = extractBearerToken(req.headers.authorization);
227
+
228
+ if (!token || !activeTokens.has(token)) {
229
+ return res.status(401).json({
230
+ error: 'Unauthorized',
231
+ message: 'Valid Bearer token required. POST /api/auth/login to authenticate.',
232
+ });
233
+ }
234
+
235
+ // Attach token to request for downstream use (e.g. logout)
236
+ req.authToken = token;
237
+ next();
238
+ }
239
+
240
+ // ─── Route Setup ───────────────────────────────────────────
241
+
242
+ /**
243
+ * Mount authentication routes on the Express app.
244
+ * These routes are NOT protected by requireAuth - they are public.
245
+ *
246
+ * @param {import('express').Express} app - The Express application
247
+ */
248
+ function setupAuth(app) {
249
+ /**
250
+ * POST /api/auth/login
251
+ * Body: { password: string }
252
+ * Returns: { success: true, token: string } or { success: false, error: string }
253
+ */
254
+ app.post('/api/auth/login', (req, res) => {
255
+ // Rate limiting
256
+ const clientIp = req.ip || req.connection.remoteAddress || 'unknown';
257
+ if (isRateLimited(clientIp)) {
258
+ return res.status(429).json({
259
+ success: false,
260
+ error: 'Too many login attempts. Try again in 1 minute.',
261
+ });
262
+ }
263
+
264
+ const { password } = req.body || {};
265
+
266
+ if (!password || typeof password !== 'string') {
267
+ return res.status(400).json({
268
+ success: false,
269
+ error: 'Missing or invalid password field in request body.',
270
+ });
271
+ }
272
+
273
+ // Constant-time comparison to mitigate timing attacks
274
+ const passwordBuffer = Buffer.from(password, 'utf-8');
275
+ const expectedBuffer = Buffer.from(AUTH_PASSWORD, 'utf-8');
276
+ const isValid =
277
+ passwordBuffer.length === expectedBuffer.length &&
278
+ crypto.timingSafeEqual(passwordBuffer, expectedBuffer);
279
+
280
+ if (!isValid) {
281
+ return res.status(403).json({
282
+ success: false,
283
+ error: 'Invalid password.',
284
+ });
285
+ }
286
+
287
+ const token = generateToken();
288
+ activeTokens.add(token);
289
+
290
+ return res.json({ success: true, token });
291
+ });
292
+
293
+ /**
294
+ * POST /api/auth/token-login
295
+ * Body: { token: string }
296
+ * Exchanges a one-time startup token for a session Bearer token.
297
+ * The startup token must exist, not be expired (60s TTL), and not already used.
298
+ * Returns: { success: true, token: string } or { success: false, error: string }
299
+ */
300
+ app.post('/api/auth/token-login', (req, res) => {
301
+ // Rate limiting (same as login)
302
+ const clientIp = req.ip || req.connection.remoteAddress || 'unknown';
303
+ if (isRateLimited(clientIp)) {
304
+ return res.status(429).json({
305
+ success: false,
306
+ error: 'Too many login attempts. Try again in 1 minute.',
307
+ });
308
+ }
309
+
310
+ const { token: startupToken } = req.body || {};
311
+
312
+ if (!startupToken || typeof startupToken !== 'string') {
313
+ return res.status(400).json({
314
+ success: false,
315
+ error: 'Missing or invalid token field in request body.',
316
+ });
317
+ }
318
+
319
+ const entry = startupTokens.get(startupToken);
320
+ if (!entry) {
321
+ return res.status(403).json({
322
+ success: false,
323
+ error: 'Invalid or expired startup token.',
324
+ });
325
+ }
326
+
327
+ // Check expiry
328
+ if (Date.now() - entry.createdAt > STARTUP_TOKEN_TTL_MS) {
329
+ startupTokens.delete(startupToken);
330
+ return res.status(403).json({
331
+ success: false,
332
+ error: 'Startup token has expired.',
333
+ });
334
+ }
335
+
336
+ // Check single-use
337
+ if (entry.used) {
338
+ return res.status(403).json({
339
+ success: false,
340
+ error: 'Startup token has already been used.',
341
+ });
342
+ }
343
+
344
+ // Mark as used and remove from map (single-use)
345
+ startupTokens.delete(startupToken);
346
+
347
+ // Issue a session token
348
+ const sessionToken = generateToken();
349
+ activeTokens.add(sessionToken);
350
+
351
+ return res.json({ success: true, token: sessionToken });
352
+ });
353
+
354
+ /**
355
+ * POST /api/auth/logout
356
+ * Requires Authorization: Bearer <token>
357
+ * Removes the token from the active set.
358
+ */
359
+ app.post('/api/auth/logout', (req, res) => {
360
+ const token = extractBearerToken(req.headers.authorization);
361
+
362
+ if (token) {
363
+ activeTokens.delete(token);
364
+ }
365
+
366
+ return res.json({ success: true });
367
+ });
368
+
369
+ /**
370
+ * GET /api/auth/check
371
+ * Returns whether the provided Bearer token is still valid.
372
+ */
373
+ app.get('/api/auth/check', (req, res) => {
374
+ const token = extractBearerToken(req.headers.authorization);
375
+ const authenticated = !!token && activeTokens.has(token);
376
+
377
+ return res.json({ authenticated });
378
+ });
379
+ }
380
+
381
+ /**
382
+ * Check if a raw token string is valid (exists in activeTokens).
383
+ * Used by SSE endpoint which can't use requireAuth middleware.
384
+ * @param {string} token - The raw token string
385
+ * @returns {boolean}
386
+ */
387
+ function isValidToken(token) {
388
+ return !!token && activeTokens.has(token);
389
+ }
390
+
391
+ // ─── Exports ───────────────────────────────────────────────
392
+
393
+ module.exports = {
394
+ setupAuth,
395
+ requireAuth,
396
+ isValidToken,
397
+ generateStartupToken,
398
+ _startupTokens: startupTokens,
399
+ };
@@ -186,7 +186,10 @@ class PtySessionManager {
186
186
  fullCommand += ' --verbose';
187
187
  }
188
188
  if (model) {
189
- fullCommand += ' --model ' + model;
189
+ // Single-quote the model value so shell glob characters in aliases like
190
+ // sonnet[1m] are not expanded by bash before being passed to claude.
191
+ const safeModel = "'" + model.replace(/'/g, "'\\''") + "'";
192
+ fullCommand += ' --model ' + safeModel;
190
193
  }
191
194
  // Extra flags (e.g. from worktree task flags checkboxes), validated upstream
192
195
  if (Array.isArray(flags)) {
@@ -1816,25 +1816,42 @@ class CWMApp {
1816
1816
 
1817
1817
 
1818
1818
 
1819
+ async _initializeApp() {
1820
+ this.showApp();
1821
+ this.initDragAndDrop();
1822
+ this.initTerminalResize();
1823
+ this.initTerminalGroups();
1824
+ this.initTerminalPaneSwipe();
1825
+ this.initNotesEditor();
1826
+ this.initAIInsights();
1827
+ await this.loadAll();
1828
+ this.connectSSE();
1829
+ this.startConflictChecks();
1830
+ this.checkForUpdates();
1831
+ }
1832
+
1819
1833
  async init() {
1820
1834
  // Restore sidebar width & collapse state from localStorage
1821
1835
  this.restoreSidebarState();
1822
1836
 
1837
+ // Auto-login via URL ?token=xxx parameter (one-time startup token)
1838
+ const params = new URLSearchParams(window.location.search);
1839
+ const urlToken = params.get('token');
1840
+ if (urlToken) {
1841
+ // Always strip token from URL to avoid leaking in browser history/referrer
1842
+ window.history.replaceState({}, '', window.location.pathname);
1843
+ try {
1844
+ await this.tokenLogin(urlToken);
1845
+ return; // tokenLogin() handles showApp/loadAll/connectSSE
1846
+ } catch {
1847
+ // Fall through to normal login form
1848
+ }
1849
+ }
1850
+
1823
1851
  if (this.state.token) {
1824
1852
  const valid = await this.checkAuth();
1825
1853
  if (valid) {
1826
- this.showApp();
1827
- this.initDragAndDrop();
1828
- this.initTerminalResize();
1829
- this.initTerminalGroups();
1830
- // Initialize mobile swipe gestures for pane switching
1831
- this.initTerminalPaneSwipe();
1832
- this.initNotesEditor();
1833
- this.initAIInsights();
1834
- await this.loadAll();
1835
- this.connectSSE();
1836
- this.startConflictChecks();
1837
- this.checkForUpdates();
1854
+ await this._initializeApp();
1838
1855
  } else {
1839
1856
  this.state.token = null;
1840
1857
  localStorage.removeItem('cwm_token');
@@ -1911,18 +1928,7 @@ class CWMApp {
1911
1928
  if (data.success && data.token) {
1912
1929
  this.state.token = data.token;
1913
1930
  localStorage.setItem('cwm_token', data.token);
1914
- this.showApp();
1915
- this.initDragAndDrop();
1916
- this.initTerminalResize();
1917
- this.initTerminalGroups();
1918
- // Initialize mobile swipe gestures for pane switching
1919
- this.initTerminalPaneSwipe();
1920
- this.initNotesEditor();
1921
- this.initAIInsights();
1922
- await this.loadAll();
1923
- this.connectSSE();
1924
- this.startConflictChecks();
1925
- this.checkForUpdates();
1931
+ await this._initializeApp();
1926
1932
  } else {
1927
1933
  this.els.loginError.textContent = 'Invalid password. Please try again.';
1928
1934
  }
@@ -1934,6 +1940,17 @@ class CWMApp {
1934
1940
  }
1935
1941
  }
1936
1942
 
1943
+ async tokenLogin(startupToken) {
1944
+ const data = await this.api('POST', '/api/auth/token-login', { token: startupToken });
1945
+ if (data.success && data.token) {
1946
+ this.state.token = data.token;
1947
+ localStorage.setItem('cwm_token', data.token);
1948
+ await this._initializeApp();
1949
+ } else {
1950
+ throw new Error(data.error || 'Token login failed');
1951
+ }
1952
+ }
1953
+
1937
1954
  async logout() {
1938
1955
  try {
1939
1956
  await this.api('POST', '/api/auth/logout');
@@ -2787,9 +2804,11 @@ class CWMApp {
2787
2804
  const currentModel = session.model || null;
2788
2805
 
2789
2806
  const modelOptions = [
2790
- { id: 'claude-opus-4-6', label: 'Opus' },
2791
- { id: 'claude-sonnet-4-5-20250929', label: 'Sonnet' },
2792
- { id: 'claude-haiku-4-5-20251001', label: 'Haiku' },
2807
+ { id: 'opus', label: 'Opus' },
2808
+ { id: 'sonnet', label: 'Sonnet' },
2809
+ { id: 'haiku', label: 'Haiku' },
2810
+ { id: 'sonnet[1m]', label: 'Sonnet 1M' },
2811
+ { id: 'opusplan', label: 'OpusPlan' },
2793
2812
  ];
2794
2813
 
2795
2814
  const items = [];
@@ -3569,8 +3588,8 @@ class CWMApp {
3569
3588
  { key: 'enableTd', label: 'td Task Management', description: 'Show td issue tracking integration (github.com/marcus/td). When disabled, hides all td UI including the docs panel section and sidebar toggle.', category: 'Advanced' },
3570
3589
  { key: 'tdBinary', label: 'td Binary Path', description: 'Optional. td is an alternative task management system (github.com/marcus/td) — Myrlin works fine without it. If installed, set the absolute path to the binary here, or leave blank to use the TD_BINARY environment variable or "td" from PATH. Example: /home/user/go/bin/td', category: 'Advanced', type: 'server-text', placeholder: 'e.g. /home/user/go/bin/td', apiEndpoint: '/api/td/binary', apiField: 'binary' },
3571
3590
  { key: 'maxConcurrentTasks', label: 'Max Concurrent Tasks', description: 'Maximum number of worktree tasks that can run simultaneously (1-8)', category: 'Advanced', type: 'number', min: 1, max: 8 },
3572
- { key: 'defaultModelPlanning', label: 'Default Model (Planning)', description: 'Auto-assign when tasks enter Planning. Haiku is fast/cheap for exploration. Only applies to tasks without a model set.', category: 'Advanced', type: 'select', options: [{ value: '', label: 'None' }, { value: 'claude-haiku-4-5-20251001', label: 'Haiku (fast, cheap)' }, { value: 'claude-sonnet-4-6', label: 'Sonnet (balanced)' }, { value: 'claude-opus-4-6', label: 'Opus (thorough)' }] },
3573
- { key: 'defaultModelRunning', label: 'Default Model (Running)', description: 'Auto-assign when tasks enter Running. Sonnet balances speed and quality for implementation. Only applies to tasks without a model set.', category: 'Advanced', type: 'select', options: [{ value: '', label: 'None' }, { value: 'claude-haiku-4-5-20251001', label: 'Haiku (fast, cheap)' }, { value: 'claude-sonnet-4-6', label: 'Sonnet (balanced)' }, { value: 'claude-opus-4-6', label: 'Opus (thorough)' }] },
3591
+ { key: 'defaultModelPlanning', label: 'Default Model (Planning)', description: 'Auto-assign when tasks enter Planning. Haiku is fast/cheap for exploration. Only applies to tasks without a model set.', category: 'Advanced', type: 'select', options: [{ value: '', label: 'None' }, { value: 'haiku', label: 'Haiku (fast, cheap)' }, { value: 'sonnet', label: 'Sonnet (balanced)' }, { value: 'opus', label: 'Opus (thorough)' }, { value: 'sonnet[1m]', label: 'Sonnet 1M' }, { value: 'opusplan', label: 'OpusPlan' }] },
3592
+ { key: 'defaultModelRunning', label: 'Default Model (Running)', description: 'Auto-assign when tasks enter Running. Sonnet balances speed and quality for implementation. Only applies to tasks without a model set.', category: 'Advanced', type: 'select', options: [{ value: '', label: 'None' }, { value: 'haiku', label: 'Haiku (fast, cheap)' }, { value: 'sonnet', label: 'Sonnet (balanced)' }, { value: 'opus', label: 'Opus (thorough)' }, { value: 'sonnet[1m]', label: 'Sonnet 1M' }, { value: 'opusplan', label: 'OpusPlan' }] },
3574
3593
  { key: 'anthropicApiKey', label: 'Anthropic API Key', description: 'Required for AI-powered session finder. Uses Claude Haiku for fast, low-cost semantic search across your projects and sessions. Get a key at console.anthropic.com.', category: 'AI', type: 'server-text', placeholder: 'sk-ant-...', apiEndpoint: '/api/keys/anthropic', apiField: 'key' },
3575
3594
  { key: 'cfNamedTunnel', label: 'Cloudflare Named Tunnel', description: 'Expose Myrlin on the internet via your own domain. Go to one.dash.cloudflare.com → Networks → Tunnels → Create a tunnel, then copy the token from the install command (the long eyJ… string).', category: 'Remote Access', type: 'tunnel' },
3576
3595
  ];
@@ -5226,10 +5245,12 @@ class CWMApp {
5226
5245
 
5227
5246
  // Model selection submenu
5228
5247
  const modelOptions = [
5229
- { id: '', label: 'Default' },
5230
- { id: 'claude-opus-4-6', label: 'Opus' },
5231
- { id: 'claude-sonnet-4-6', label: 'Sonnet' },
5232
- { id: 'claude-haiku-4-5-20251001', label: 'Haiku' },
5248
+ { id: '', label: 'Default' },
5249
+ { id: 'opus', label: 'Opus' },
5250
+ { id: 'sonnet', label: 'Sonnet' },
5251
+ { id: 'haiku', label: 'Haiku' },
5252
+ { id: 'sonnet[1m]', label: 'Sonnet 1M' },
5253
+ { id: 'opusplan', label: 'OpusPlan' },
5233
5254
  ];
5234
5255
  const currentTaskModel = task.model || '';
5235
5256
  const currentModelLabel = currentTaskModel ? (modelOptions.find(m => m.id === currentTaskModel)?.label || 'Custom') : 'Default';
@@ -13733,10 +13754,12 @@ class CWMApp {
13733
13754
 
13734
13755
  // Add model selector
13735
13756
  fields.push({ key: 'model', label: 'Model', type: 'select', options: [
13736
- { value: '', label: 'Default' },
13737
- { value: 'claude-opus-4-6', label: 'Opus' },
13738
- { value: 'claude-sonnet-4-6', label: 'Sonnet' },
13739
- { value: 'claude-haiku-4-5-20251001', label: 'Haiku' },
13757
+ { value: '', label: 'Default' },
13758
+ { value: 'opus', label: 'Opus' },
13759
+ { value: 'sonnet', label: 'Sonnet' },
13760
+ { value: 'haiku', label: 'Haiku' },
13761
+ { value: 'sonnet[1m]', label: 'Sonnet 1M' },
13762
+ { value: 'opusplan', label: 'OpusPlan' },
13740
13763
  ]});
13741
13764
 
13742
13765
  const result = await this.showPromptModal({
@@ -1467,9 +1467,11 @@
1467
1467
  <label class="launcher-form-label" for="launcher-model">Model</label>
1468
1468
  <select id="launcher-model" class="launcher-form-input">
1469
1469
  <option value="">Default</option>
1470
- <option value="claude-sonnet-4-20250514">Sonnet</option>
1471
- <option value="claude-opus-4-20250514">Opus</option>
1472
- <option value="claude-haiku-3-5-20241022">Haiku</option>
1470
+ <option value="opus">Opus</option>
1471
+ <option value="sonnet">Sonnet</option>
1472
+ <option value="haiku">Haiku</option>
1473
+ <option value="sonnet[1m]">Sonnet 1M (long context)</option>
1474
+ <option value="opusplan">OpusPlan (plan with Opus, run with Sonnet)</option>
1473
1475
  </select>
1474
1476
  </div>
1475
1477
  </div>
@@ -252,6 +252,11 @@ class TerminalPane {
252
252
  this._needsInput = false; // Whether a question was detected that wasn't auto-answered
253
253
  this._needsInputTimer = null; // Timer to clear needsInput after new output
254
254
  this._autoTrustEnabled = false; // Set by app layer for worktree task terminals
255
+ // Write batching buffers — must be initialized here so _status() calls in
256
+ // mount() (before connectWs runs) don't produce "undefined" prefixes.
257
+ this._writeBuf = '';
258
+ this._activitySample = '';
259
+ this._writeRaf = null;
255
260
  }
256
261
 
257
262
  _log(msg) {
package/src/web/server.js CHANGED
@@ -2846,10 +2846,21 @@ app.get('/api/cost/dashboard', requireAuth, (req, res) => {
2846
2846
  }
2847
2847
  }
2848
2848
 
2849
- // Check if within period for periodCost
2850
- if (cutoffDate && costData.lastMessage && costData.lastMessage >= cutoffDate) {
2849
+ // Apportion session cost to the period using per-message cost and
2850
+ // message timestamps, not the full session cost. This ensures "Last 24h"
2851
+ // only counts cost from messages sent in the last 24 hours, not the
2852
+ // entire lifetime of any session that was recently active.
2853
+ if (!cutoffDate) {
2851
2854
  periodCost += sessionCost;
2852
- } else if (!cutoffDate) {
2855
+ } else if (samples && samples.length > 0 && costData.messageCount > 0) {
2856
+ const perMsgCost = sessionCost / costData.messageCount;
2857
+ let periodMessages = 0;
2858
+ for (const sample of samples) {
2859
+ if (sample.ts && sample.ts >= cutoffDate) periodMessages++;
2860
+ }
2861
+ periodCost += perMsgCost * periodMessages;
2862
+ } else if (costData.lastMessage && costData.lastMessage >= cutoffDate) {
2863
+ // Fallback: no timestamp samples available, use full cost
2853
2864
  periodCost += sessionCost;
2854
2865
  }
2855
2866