mob-coordinator 0.2.1

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.
@@ -0,0 +1,14 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Mob — Claude Instance Dashboard</title>
7
+ <link rel="icon" href="/favicon.png" />
8
+ <script type="module" crossorigin src="/assets/index-DkdPImCW.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/assets/index-BE5nxcXU.css">
10
+ </head>
11
+ <body>
12
+ <div id="app"></div>
13
+ </body>
14
+ </html>
@@ -0,0 +1,154 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { shellQuote, isValidModel, isValidPermissionMode, isValidSessionId, isValidCwd, stripControlChars, validateLaunchPayload, validateHookPayload, } from '../util/sanitize.js';
3
+ describe('shellQuote', () => {
4
+ it('wraps simple string in single quotes', () => {
5
+ expect(shellQuote('hello')).toBe("'hello'");
6
+ });
7
+ it('escapes single quotes', () => {
8
+ expect(shellQuote("it's")).toBe("'it'\\''s'");
9
+ });
10
+ it('handles empty string', () => {
11
+ expect(shellQuote('')).toBe("''");
12
+ });
13
+ it('neutralizes command injection attempts', () => {
14
+ const malicious = '$(rm -rf /)';
15
+ const quoted = shellQuote(malicious);
16
+ expect(quoted).toBe("'$(rm -rf /)'");
17
+ // Inside single quotes, $() is literal — not executed by the shell
18
+ expect(quoted.startsWith("'")).toBe(true);
19
+ expect(quoted.endsWith("'")).toBe(true);
20
+ });
21
+ it('neutralizes backtick injection', () => {
22
+ expect(shellQuote('`whoami`')).toBe("'`whoami`'");
23
+ });
24
+ });
25
+ describe('isValidModel', () => {
26
+ it('accepts valid model names', () => {
27
+ expect(isValidModel('claude-opus-4-6')).toBe(true);
28
+ expect(isValidModel('claude-sonnet-4-6')).toBe(true);
29
+ expect(isValidModel('claude-haiku-4-5-20251001')).toBe(true);
30
+ expect(isValidModel('gpt-4o')).toBe(true);
31
+ expect(isValidModel('model/v1.0')).toBe(true);
32
+ });
33
+ it('rejects injection attempts', () => {
34
+ expect(isValidModel('$(rm -rf /)')).toBe(false);
35
+ expect(isValidModel('model; echo pwned')).toBe(false);
36
+ expect(isValidModel('model`whoami`')).toBe(false);
37
+ expect(isValidModel('')).toBe(false);
38
+ });
39
+ it('rejects overly long values', () => {
40
+ expect(isValidModel('a'.repeat(101))).toBe(false);
41
+ });
42
+ });
43
+ describe('isValidPermissionMode', () => {
44
+ it('accepts valid modes', () => {
45
+ expect(isValidPermissionMode('default')).toBe(true);
46
+ expect(isValidPermissionMode('plan')).toBe(true);
47
+ expect(isValidPermissionMode('bypassPermissions')).toBe(true);
48
+ expect(isValidPermissionMode('full')).toBe(true);
49
+ });
50
+ it('rejects invalid modes', () => {
51
+ expect(isValidPermissionMode('$(evil)')).toBe(false);
52
+ expect(isValidPermissionMode('admin')).toBe(false);
53
+ expect(isValidPermissionMode('')).toBe(false);
54
+ });
55
+ });
56
+ describe('isValidSessionId', () => {
57
+ it('accepts valid session IDs', () => {
58
+ expect(isValidSessionId('abc-123_def')).toBe(true);
59
+ expect(isValidSessionId('session-id-001')).toBe(true);
60
+ });
61
+ it('rejects injection attempts', () => {
62
+ expect(isValidSessionId('$(whoami)')).toBe(false);
63
+ expect(isValidSessionId('id; rm -rf /')).toBe(false);
64
+ });
65
+ });
66
+ describe('isValidCwd', () => {
67
+ it('accepts absolute paths', () => {
68
+ expect(isValidCwd('/home/user/project')).toBe(true);
69
+ expect(isValidCwd('~/project')).toBe(true);
70
+ });
71
+ it('rejects relative paths', () => {
72
+ expect(isValidCwd('relative/path')).toBe(false);
73
+ expect(isValidCwd('../escape')).toBe(false);
74
+ });
75
+ it('rejects null bytes', () => {
76
+ expect(isValidCwd('/home/user\0/evil')).toBe(false);
77
+ });
78
+ it('rejects overly long paths', () => {
79
+ expect(isValidCwd('/' + 'a'.repeat(1024))).toBe(false);
80
+ });
81
+ it('rejects empty', () => {
82
+ expect(isValidCwd('')).toBe(false);
83
+ });
84
+ });
85
+ describe('stripControlChars', () => {
86
+ it('removes control characters', () => {
87
+ expect(stripControlChars('hello\x00world')).toBe('helloworld');
88
+ expect(stripControlChars('test\x1B[31m')).toBe('test[31m');
89
+ });
90
+ it('preserves newlines and tabs', () => {
91
+ expect(stripControlChars('hello\nworld\ttab')).toBe('hello\nworld\ttab');
92
+ });
93
+ });
94
+ describe('validateLaunchPayload', () => {
95
+ it('accepts valid payload', () => {
96
+ const result = validateLaunchPayload({
97
+ cwd: '~/project',
98
+ name: 'my-instance',
99
+ model: 'claude-opus-4-6',
100
+ permissionMode: 'default',
101
+ });
102
+ expect(result.valid).toBe(true);
103
+ });
104
+ it('accepts minimal payload', () => {
105
+ const result = validateLaunchPayload({ cwd: '/home/user' });
106
+ expect(result.valid).toBe(true);
107
+ });
108
+ it('rejects missing cwd', () => {
109
+ const result = validateLaunchPayload({ name: 'test' });
110
+ expect(result.valid).toBe(false);
111
+ });
112
+ it('rejects invalid model', () => {
113
+ const result = validateLaunchPayload({ cwd: '/home', model: '$(evil)' });
114
+ expect(result.valid).toBe(false);
115
+ });
116
+ it('rejects invalid permissionMode', () => {
117
+ const result = validateLaunchPayload({ cwd: '/home', permissionMode: 'hacker' });
118
+ expect(result.valid).toBe(false);
119
+ });
120
+ it('rejects non-object payload', () => {
121
+ expect(validateLaunchPayload(null).valid).toBe(false);
122
+ expect(validateLaunchPayload('string').valid).toBe(false);
123
+ expect(validateLaunchPayload(42).valid).toBe(false);
124
+ });
125
+ });
126
+ describe('validateHookPayload', () => {
127
+ it('accepts valid hook data', () => {
128
+ const result = validateHookPayload({
129
+ id: 'mob-abc123',
130
+ cwd: '/home/user',
131
+ state: 'running',
132
+ });
133
+ expect(result.valid).toBe(true);
134
+ });
135
+ it('rejects missing id', () => {
136
+ const result = validateHookPayload({ cwd: '/home', state: 'running' });
137
+ expect(result.valid).toBe(false);
138
+ });
139
+ it('rejects overly long id', () => {
140
+ const result = validateHookPayload({ id: 'a'.repeat(201) });
141
+ expect(result.valid).toBe(false);
142
+ });
143
+ it('rejects null bytes in cwd', () => {
144
+ const result = validateHookPayload({ id: 'test', cwd: '/home\0/evil' });
145
+ expect(result.valid).toBe(false);
146
+ });
147
+ it('strips control chars from name', () => {
148
+ const result = validateHookPayload({ id: 'test', name: 'hello\x00world' });
149
+ expect(result.valid).toBe(true);
150
+ if (result.valid) {
151
+ expect(result.data.name).toBe('helloworld');
152
+ }
153
+ });
154
+ });
@@ -0,0 +1,36 @@
1
+ import chokidar from 'chokidar';
2
+ import path from 'path';
3
+ import { EventEmitter } from 'events';
4
+ import { readStatusFile } from './status-reader.js';
5
+ import { getInstancesDir } from './util/platform.js';
6
+ import { ensureDir } from './util/platform.js';
7
+ export class DiscoveryService extends EventEmitter {
8
+ watcher = null;
9
+ dir;
10
+ constructor() {
11
+ super();
12
+ this.dir = getInstancesDir();
13
+ }
14
+ start() {
15
+ ensureDir(this.dir);
16
+ this.watcher = chokidar.watch(path.join(this.dir, '*.json'), {
17
+ ignoreInitial: false,
18
+ awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 50 },
19
+ });
20
+ this.watcher.on('add', (fp) => this.handleFileChange(fp));
21
+ this.watcher.on('change', (fp) => this.handleFileChange(fp));
22
+ this.watcher.on('unlink', (fp) => {
23
+ const id = path.basename(fp, '.json');
24
+ this.emit('remove', id);
25
+ });
26
+ }
27
+ handleFileChange(filePath) {
28
+ const status = readStatusFile(filePath);
29
+ if (status) {
30
+ this.emit('update', status, filePath);
31
+ }
32
+ }
33
+ stop() {
34
+ this.watcher?.close();
35
+ }
36
+ }
@@ -0,0 +1,173 @@
1
+ import express from 'express';
2
+ import { execFile } from 'child_process';
3
+ import fs from 'fs';
4
+ import os from 'os';
5
+ import path from 'path';
6
+ import { fileURLToPath } from 'url';
7
+ import { isPathWithinHome, validateHookPayload } from './util/sanitize.js';
8
+ import { createLogger } from './util/logger.js';
9
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
10
+ const log = createLogger('http');
11
+ export function createApp(instanceManager, settingsManager) {
12
+ const app = express();
13
+ app.use(express.json());
14
+ // Request logging for API routes
15
+ app.use('/api', (req, _res, next) => {
16
+ log.info(`${req.method} ${req.originalUrl}`);
17
+ next();
18
+ });
19
+ // Serve static frontend in production (only if built)
20
+ const clientDir = path.join(__dirname, '..', 'client');
21
+ const indexHtml = path.join(clientDir, 'index.html');
22
+ const hasBuiltClient = fs.existsSync(indexHtml);
23
+ if (hasBuiltClient) {
24
+ app.use(express.static(clientDir));
25
+ }
26
+ // Directory autocomplete for launch dialog (restricted to home directory)
27
+ app.get('/api/completions/dirs', (req, res) => {
28
+ const partial = req.query.q || '';
29
+ if (!partial) {
30
+ res.json([]);
31
+ return;
32
+ }
33
+ // Expand ~ to home dir
34
+ const home = os.homedir();
35
+ const expanded = partial.startsWith('~')
36
+ ? path.join(home, partial.slice(1))
37
+ : partial;
38
+ // Restrict to home directory tree
39
+ const resolved = path.resolve(expanded);
40
+ if (!isPathWithinHome(resolved)) {
41
+ res.json([]);
42
+ return;
43
+ }
44
+ const dir = expanded.endsWith('/') ? expanded : path.dirname(expanded);
45
+ const prefix = expanded.endsWith('/') ? '' : path.basename(expanded);
46
+ // Verify dir is also within home
47
+ const resolvedDir = path.resolve(dir);
48
+ if (!isPathWithinHome(resolvedDir)) {
49
+ res.json([]);
50
+ return;
51
+ }
52
+ try {
53
+ const entries = fs.readdirSync(resolvedDir, { withFileTypes: true });
54
+ const matches = entries
55
+ .filter((e) => e.isDirectory() && !e.name.startsWith('.') && e.name.toLowerCase().startsWith(prefix.toLowerCase()))
56
+ .slice(0, 20)
57
+ .map((e) => {
58
+ const full = path.join(resolvedDir, e.name);
59
+ const display = full.startsWith(home) ? '~' + full.slice(home.length) : full;
60
+ return { path: full, display: display + '/' };
61
+ });
62
+ res.json(matches);
63
+ }
64
+ catch {
65
+ res.json([]);
66
+ }
67
+ });
68
+ // Browse for directory — opens native folder picker
69
+ app.post('/api/browse-dir', (req, res) => {
70
+ const startDir = req.body?.startDir || process.env.HOME || 'C:\\';
71
+ const expanded = startDir.startsWith('~')
72
+ ? path.join(process.env.HOME || 'C:\\', startDir.slice(1))
73
+ : startDir;
74
+ // Validate path has no null bytes or newlines
75
+ if (expanded.includes('\0') || expanded.includes('\n') || expanded.includes('\r')) {
76
+ res.status(400).json({ error: 'Invalid path' });
77
+ return;
78
+ }
79
+ log.info('browse-dir requested, startDir:', expanded);
80
+ if (process.platform === 'win32') {
81
+ const psPath = path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
82
+ const scriptPath = path.join(__dirname, '..', '..', 'scripts', 'browse-dir.ps1');
83
+ execFile(psPath, ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptPath, '-StartPath', expanded], { timeout: 60000, windowsHide: false }, (err, stdout, stderr) => {
84
+ if (err) {
85
+ log.error('browse-dir error:', err.message, stderr);
86
+ res.json({ cancelled: true });
87
+ return;
88
+ }
89
+ const selected = stdout.trim();
90
+ log.info('browse-dir result:', selected || '(cancelled)');
91
+ if (selected) {
92
+ res.json({ path: selected });
93
+ }
94
+ else {
95
+ res.json({ cancelled: true });
96
+ }
97
+ });
98
+ }
99
+ else if (process.platform === 'darwin') {
100
+ // Shell-quote the path within the AppleScript string to prevent injection
101
+ const escapedPath = expanded.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
102
+ execFile('osascript', ['-e', `choose folder with prompt "Select working directory" default location POSIX file "${escapedPath}"`], { timeout: 60000 }, (err, stdout) => {
103
+ if (err) {
104
+ res.json({ cancelled: true });
105
+ return;
106
+ }
107
+ const alias = stdout.trim();
108
+ const posix = alias.replace(/^alias [^:]+:/, '/').replace(/:/g, '/');
109
+ res.json({ path: posix });
110
+ });
111
+ }
112
+ else {
113
+ execFile('zenity', ['--file-selection', '--directory', `--filename=${expanded}/`], { timeout: 60000 }, (err, stdout) => {
114
+ if (err) {
115
+ res.json({ cancelled: true });
116
+ return;
117
+ }
118
+ res.json({ path: stdout.trim() });
119
+ });
120
+ }
121
+ });
122
+ // Platform info for client feature detection
123
+ app.get('/api/platform', (_req, res) => {
124
+ res.json({ platform: process.platform });
125
+ });
126
+ // Settings API
127
+ app.get('/api/settings', (_req, res) => {
128
+ res.json(settingsManager.getRedacted());
129
+ });
130
+ app.put('/api/settings', (req, res) => {
131
+ try {
132
+ settingsManager.update(req.body);
133
+ instanceManager.refreshTicketFields();
134
+ res.json(settingsManager.getRedacted());
135
+ }
136
+ catch (err) {
137
+ res.status(400).json({ error: err.message || 'Invalid settings' });
138
+ }
139
+ });
140
+ // Hook endpoint — receives status updates from hook scripts
141
+ app.post('/api/hook', (req, res) => {
142
+ const result = validateHookPayload(req.body);
143
+ if (!result.valid) {
144
+ res.status(400).json({ error: result.error });
145
+ return;
146
+ }
147
+ const data = result.data;
148
+ log.info(`hook update: id=${data.id} state=${data.state} topic=${data.topic || '(none)'}`);
149
+ data.lastUpdated = data.lastUpdated || Date.now();
150
+ instanceManager.handleHookUpdate(data);
151
+ res.json({ ok: true });
152
+ });
153
+ // REST: list instances
154
+ app.get('/api/instances', (_req, res) => {
155
+ res.json(instanceManager.getAll());
156
+ });
157
+ // REST: get single instance
158
+ app.get('/api/instances/:id', (req, res) => {
159
+ const info = instanceManager.get(req.params.id);
160
+ if (!info) {
161
+ res.status(404).json({ error: 'Not found' });
162
+ return;
163
+ }
164
+ res.json(info);
165
+ });
166
+ // Fallback to index.html for SPA routing (production only)
167
+ if (hasBuiltClient) {
168
+ app.get('*', (_req, res) => {
169
+ res.sendFile(indexHtml);
170
+ });
171
+ }
172
+ return app;
173
+ }
@@ -0,0 +1,54 @@
1
+ import http from 'http';
2
+ import { createApp } from './express-app.js';
3
+ import { createWsServer } from './ws-server.js';
4
+ import { InstanceManager } from './instance-manager.js';
5
+ import { PtyManager } from './pty-manager.js';
6
+ import { DiscoveryService } from './discovery.js';
7
+ import { SessionStore } from './session-store.js';
8
+ import { ScrollbackBuffer } from './scrollback-buffer.js';
9
+ import { SettingsManager } from './settings-manager.js';
10
+ import { ensureDir, getMobDir, getInstancesDir, getSessionsDir, getScrollbackDir } from './util/platform.js';
11
+ import { DEFAULT_PORT } from '../shared/constants.js';
12
+ const port = parseInt(process.env.MOB_PORT || '', 10) || DEFAULT_PORT;
13
+ const host = process.env.MOB_HOST || '127.0.0.1';
14
+ // Ensure directories exist
15
+ ensureDir(getMobDir());
16
+ ensureDir(getInstancesDir());
17
+ ensureDir(getSessionsDir());
18
+ ensureDir(getScrollbackDir());
19
+ const settingsManager = new SettingsManager();
20
+ const ptyManager = new PtyManager();
21
+ const discovery = new DiscoveryService();
22
+ const sessionStore = new SessionStore();
23
+ const scrollbackBuffer = new ScrollbackBuffer();
24
+ scrollbackBuffer.start();
25
+ sessionStore.pruneExpired();
26
+ const instanceManager = new InstanceManager(ptyManager, discovery, sessionStore, scrollbackBuffer, settingsManager);
27
+ const app = createApp(instanceManager, settingsManager);
28
+ const server = http.createServer(app);
29
+ createWsServer(server, instanceManager, ptyManager);
30
+ discovery.start();
31
+ instanceManager.startStaleCheck();
32
+ server.listen(port, host, () => {
33
+ console.log(`Mob dashboard running at http://${host}:${port}`);
34
+ console.log(`WebSocket endpoint: ws://${host}:${port}/mob-ws`);
35
+ });
36
+ // Graceful shutdown
37
+ function shutdown() {
38
+ console.log('\nShutting down...');
39
+ instanceManager.saveAllAsStopped();
40
+ // Kill all PTY processes
41
+ for (const [id] of ptyManager.getAll()) {
42
+ console.log(`Killing PTY: ${id}`);
43
+ ptyManager.kill(id);
44
+ }
45
+ scrollbackBuffer.stop();
46
+ instanceManager.stop();
47
+ // Brief grace period for clean exit
48
+ setTimeout(() => {
49
+ server.close();
50
+ process.exit(0);
51
+ }, 500);
52
+ }
53
+ process.on('SIGINT', shutdown);
54
+ process.on('SIGTERM', shutdown);