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.
- package/README.md +207 -0
- package/bin/mob.js +20 -0
- package/dist/client/assets/index-BE5nxcXU.css +32 -0
- package/dist/client/assets/index-DkdPImCW.js +11 -0
- package/dist/client/index.html +14 -0
- package/dist/server/server/__tests__/sanitize.test.js +154 -0
- package/dist/server/server/discovery.js +36 -0
- package/dist/server/server/express-app.js +173 -0
- package/dist/server/server/index.js +54 -0
- package/dist/server/server/instance-manager.js +434 -0
- package/dist/server/server/jira-client.js +33 -0
- package/dist/server/server/pty-manager.js +98 -0
- package/dist/server/server/scrollback-buffer.js +102 -0
- package/dist/server/server/session-store.js +127 -0
- package/dist/server/server/settings-manager.js +87 -0
- package/dist/server/server/status-reader.js +29 -0
- package/dist/server/server/terminal-state-detector.js +42 -0
- package/dist/server/server/types.js +1 -0
- package/dist/server/server/util/id.js +4 -0
- package/dist/server/server/util/logger.js +34 -0
- package/dist/server/server/util/platform.js +48 -0
- package/dist/server/server/util/sanitize.js +126 -0
- package/dist/server/server/ws-server.js +197 -0
- package/dist/server/shared/constants.js +8 -0
- package/dist/server/shared/protocol.js +1 -0
- package/dist/server/shared/settings.js +54 -0
- package/package.json +68 -0
- package/scripts/postinstall.cjs +68 -0
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { getSessionsDir, getScrollbackDir, getGitBranch } from './util/platform.js';
|
|
4
|
+
import { SESSION_MAX_AGE_MS } from '../shared/constants.js';
|
|
5
|
+
function log(...args) {
|
|
6
|
+
const ts = new Date().toISOString().slice(11, 23);
|
|
7
|
+
console.log(`[${ts}] [session-store]`, ...args);
|
|
8
|
+
}
|
|
9
|
+
export class SessionStore {
|
|
10
|
+
save(instance, opts) {
|
|
11
|
+
const data = {
|
|
12
|
+
id: instance.id,
|
|
13
|
+
name: instance.name,
|
|
14
|
+
cwd: instance.cwd,
|
|
15
|
+
gitBranch: instance.gitBranch,
|
|
16
|
+
model: instance.model,
|
|
17
|
+
permissionMode: instance.permissionMode,
|
|
18
|
+
claudeSessionId: instance.claudeSessionId,
|
|
19
|
+
previousInstanceId: instance.previousInstanceId,
|
|
20
|
+
createdAt: instance.createdAt || Date.now(),
|
|
21
|
+
stoppedAt: instance.stoppedAt,
|
|
22
|
+
ticket: instance.ticket,
|
|
23
|
+
subtask: instance.subtask,
|
|
24
|
+
autoResume: opts?.autoResume,
|
|
25
|
+
};
|
|
26
|
+
const filePath = path.join(getSessionsDir(), `${instance.id}.json`);
|
|
27
|
+
try {
|
|
28
|
+
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf8');
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
log(`Failed to save session ${instance.id}:`, err);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
loadAll() {
|
|
35
|
+
const dir = getSessionsDir();
|
|
36
|
+
const cutoff = Date.now() - SESSION_MAX_AGE_MS;
|
|
37
|
+
const instances = [];
|
|
38
|
+
let files;
|
|
39
|
+
try {
|
|
40
|
+
files = fs.readdirSync(dir).filter(f => f.endsWith('.json'));
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return [];
|
|
44
|
+
}
|
|
45
|
+
for (const file of files) {
|
|
46
|
+
try {
|
|
47
|
+
const filePath = path.join(dir, file);
|
|
48
|
+
const raw = fs.readFileSync(filePath, 'utf8');
|
|
49
|
+
const data = JSON.parse(raw);
|
|
50
|
+
// Skip expired sessions
|
|
51
|
+
const lastActive = data.stoppedAt || data.createdAt;
|
|
52
|
+
if (lastActive < cutoff) {
|
|
53
|
+
this.removeFiles(data.id);
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
instances.push({
|
|
57
|
+
id: data.id,
|
|
58
|
+
name: data.name,
|
|
59
|
+
managed: true,
|
|
60
|
+
cwd: data.cwd,
|
|
61
|
+
gitBranch: getGitBranch(data.cwd) || data.gitBranch,
|
|
62
|
+
state: 'stopped',
|
|
63
|
+
lastUpdated: data.stoppedAt || data.createdAt,
|
|
64
|
+
model: data.model,
|
|
65
|
+
permissionMode: data.permissionMode,
|
|
66
|
+
claudeSessionId: data.claudeSessionId,
|
|
67
|
+
previousInstanceId: data.previousInstanceId,
|
|
68
|
+
createdAt: data.createdAt,
|
|
69
|
+
stoppedAt: data.stoppedAt,
|
|
70
|
+
historical: true,
|
|
71
|
+
ticket: data.ticket,
|
|
72
|
+
subtask: data.subtask,
|
|
73
|
+
autoResume: data.autoResume,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
catch (err) {
|
|
77
|
+
log(`Failed to load session from ${file}:`, err);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
log(`Loaded ${instances.length} previous session(s)`);
|
|
81
|
+
return instances;
|
|
82
|
+
}
|
|
83
|
+
pruneExpired() {
|
|
84
|
+
const dir = getSessionsDir();
|
|
85
|
+
const cutoff = Date.now() - SESSION_MAX_AGE_MS;
|
|
86
|
+
let pruned = 0;
|
|
87
|
+
let files;
|
|
88
|
+
try {
|
|
89
|
+
files = fs.readdirSync(dir).filter(f => f.endsWith('.json'));
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
for (const file of files) {
|
|
95
|
+
try {
|
|
96
|
+
const filePath = path.join(dir, file);
|
|
97
|
+
const raw = fs.readFileSync(filePath, 'utf8');
|
|
98
|
+
const data = JSON.parse(raw);
|
|
99
|
+
const lastActive = data.stoppedAt || data.createdAt;
|
|
100
|
+
if (lastActive < cutoff) {
|
|
101
|
+
this.removeFiles(data.id);
|
|
102
|
+
pruned++;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
// Skip malformed files
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (pruned > 0)
|
|
110
|
+
log(`Pruned ${pruned} expired session(s)`);
|
|
111
|
+
}
|
|
112
|
+
remove(instanceId) {
|
|
113
|
+
this.removeFiles(instanceId);
|
|
114
|
+
}
|
|
115
|
+
removeFiles(instanceId) {
|
|
116
|
+
const sessionPath = path.join(getSessionsDir(), `${instanceId}.json`);
|
|
117
|
+
const scrollbackPath = path.join(getScrollbackDir(), `${instanceId}.log`);
|
|
118
|
+
try {
|
|
119
|
+
fs.unlinkSync(sessionPath);
|
|
120
|
+
}
|
|
121
|
+
catch { /* ok */ }
|
|
122
|
+
try {
|
|
123
|
+
fs.unlinkSync(scrollbackPath);
|
|
124
|
+
}
|
|
125
|
+
catch { /* ok */ }
|
|
126
|
+
}
|
|
127
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { getMobDir } from './util/platform.js';
|
|
4
|
+
import { DEFAULT_SETTINGS, mergeWithDefaults } from '../shared/settings.js';
|
|
5
|
+
import { createLogger } from './util/logger.js';
|
|
6
|
+
const log = createLogger('settings');
|
|
7
|
+
function clamp(val, min, max) {
|
|
8
|
+
return Math.max(min, Math.min(max, val));
|
|
9
|
+
}
|
|
10
|
+
function validate(settings) {
|
|
11
|
+
settings.terminal.fontSize = clamp(Math.round(settings.terminal.fontSize), 8, 24);
|
|
12
|
+
settings.terminal.scrollbackLines = clamp(Math.round(settings.terminal.scrollbackLines), 100, 100_000);
|
|
13
|
+
settings.general.maxCachedTerminals = clamp(Math.round(settings.general.maxCachedTerminals), 1, 100);
|
|
14
|
+
settings.general.staleThresholdSecs = clamp(Math.round(settings.general.staleThresholdSecs), 5, 300);
|
|
15
|
+
const validCursorStyles = ['block', 'underline', 'bar'];
|
|
16
|
+
if (!validCursorStyles.includes(settings.terminal.cursorStyle)) {
|
|
17
|
+
settings.terminal.cursorStyle = 'block';
|
|
18
|
+
}
|
|
19
|
+
// Strip trailing slashes from JIRA base URL
|
|
20
|
+
if (settings.jira?.baseUrl) {
|
|
21
|
+
settings.jira.baseUrl = settings.jira.baseUrl.replace(/\/+$/, '');
|
|
22
|
+
}
|
|
23
|
+
return settings;
|
|
24
|
+
}
|
|
25
|
+
export class SettingsManager {
|
|
26
|
+
settings;
|
|
27
|
+
filePath;
|
|
28
|
+
constructor() {
|
|
29
|
+
this.filePath = path.join(getMobDir(), 'settings.json');
|
|
30
|
+
this.settings = this.load();
|
|
31
|
+
}
|
|
32
|
+
load() {
|
|
33
|
+
try {
|
|
34
|
+
if (fs.existsSync(this.filePath)) {
|
|
35
|
+
const raw = fs.readFileSync(this.filePath, 'utf-8');
|
|
36
|
+
const parsed = JSON.parse(raw);
|
|
37
|
+
const merged = mergeWithDefaults(parsed);
|
|
38
|
+
return validate(merged);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
catch (err) {
|
|
42
|
+
log.error('Failed to load settings, using defaults:', err);
|
|
43
|
+
}
|
|
44
|
+
return structuredClone(DEFAULT_SETTINGS);
|
|
45
|
+
}
|
|
46
|
+
save() {
|
|
47
|
+
const tmpPath = this.filePath + '.tmp';
|
|
48
|
+
try {
|
|
49
|
+
fs.writeFileSync(tmpPath, JSON.stringify(this.settings, null, 2), 'utf-8');
|
|
50
|
+
fs.renameSync(tmpPath, this.filePath);
|
|
51
|
+
}
|
|
52
|
+
catch (err) {
|
|
53
|
+
log.error('Failed to save settings:', err);
|
|
54
|
+
// Clean up tmp file if rename failed
|
|
55
|
+
try {
|
|
56
|
+
fs.unlinkSync(tmpPath);
|
|
57
|
+
}
|
|
58
|
+
catch { }
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
get() {
|
|
62
|
+
return structuredClone(this.settings);
|
|
63
|
+
}
|
|
64
|
+
getRedacted() {
|
|
65
|
+
const copy = structuredClone(this.settings);
|
|
66
|
+
if (copy.jira) {
|
|
67
|
+
copy.jira.apiToken = this.settings.jira?.apiToken ? '••••' : '';
|
|
68
|
+
}
|
|
69
|
+
return copy;
|
|
70
|
+
}
|
|
71
|
+
update(partial) {
|
|
72
|
+
// If jira.apiToken comes in as the redacted sentinel, preserve existing token
|
|
73
|
+
if (partial.jira && partial.jira.apiToken === '••••') {
|
|
74
|
+
partial.jira.apiToken = this.settings.jira.apiToken;
|
|
75
|
+
}
|
|
76
|
+
const merged = mergeWithDefaults({ ...this.settings, ...partial });
|
|
77
|
+
// Deep merge: for each section in partial, overlay onto current
|
|
78
|
+
for (const section of Object.keys(partial)) {
|
|
79
|
+
if (typeof partial[section] === 'object' && section in merged) {
|
|
80
|
+
Object.assign(merged[section], partial[section]);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
this.settings = validate(merged);
|
|
84
|
+
this.save();
|
|
85
|
+
return this.get();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
export function readStatusFile(filePath) {
|
|
3
|
+
try {
|
|
4
|
+
const raw = fs.readFileSync(filePath, 'utf-8');
|
|
5
|
+
const data = JSON.parse(raw);
|
|
6
|
+
if (!data.id || !data.cwd || !data.lastUpdated) {
|
|
7
|
+
return null;
|
|
8
|
+
}
|
|
9
|
+
return {
|
|
10
|
+
id: data.id,
|
|
11
|
+
cwd: data.cwd,
|
|
12
|
+
gitBranch: data.gitBranch || undefined,
|
|
13
|
+
state: data.state || 'running',
|
|
14
|
+
ticket: data.ticket || undefined,
|
|
15
|
+
ticketStatus: data.ticketStatus || undefined,
|
|
16
|
+
subtask: data.subtask || undefined,
|
|
17
|
+
progress: typeof data.progress === 'number' ? data.progress : undefined,
|
|
18
|
+
currentTool: data.currentTool || undefined,
|
|
19
|
+
lastUpdated: data.lastUpdated,
|
|
20
|
+
sessionId: data.sessionId || undefined,
|
|
21
|
+
model: data.model || undefined,
|
|
22
|
+
topic: data.topic || undefined,
|
|
23
|
+
hookEvent: data.hookEvent || undefined,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Braille spinner characters used by Claude CLI
|
|
2
|
+
const SPINNER_CHARS = '⠋⠙⠚⠞⠖⠦⠴⠵⠸⠇⠏';
|
|
3
|
+
// Patterns checked in priority order: waiting > running > idle
|
|
4
|
+
const WAITING_PATTERNS = [
|
|
5
|
+
/\(y\/n\)/i,
|
|
6
|
+
/\[y\/n\]/i,
|
|
7
|
+
/\bapprove\b/i,
|
|
8
|
+
/\ballow\b/i,
|
|
9
|
+
/Do you want to proceed/i,
|
|
10
|
+
/\byes\b.*\bno\b/i,
|
|
11
|
+
/\baccept\b/i,
|
|
12
|
+
/\bdeny\b/i,
|
|
13
|
+
];
|
|
14
|
+
const RUNNING_PATTERNS = [
|
|
15
|
+
/esc to interrupt/i,
|
|
16
|
+
new RegExp(`[${SPINNER_CHARS}]`),
|
|
17
|
+
];
|
|
18
|
+
// Idle: bare prompt at end of output
|
|
19
|
+
const IDLE_PATTERNS = [
|
|
20
|
+
/>\s*$/, // Claude's `> ` prompt
|
|
21
|
+
/\$\s*$/, // Shell `$ ` prompt
|
|
22
|
+
];
|
|
23
|
+
export function detectStateFromTerminal(scrollbackTail) {
|
|
24
|
+
if (!scrollbackTail || scrollbackTail.length === 0)
|
|
25
|
+
return null;
|
|
26
|
+
// Check waiting patterns first (highest priority)
|
|
27
|
+
for (const pattern of WAITING_PATTERNS) {
|
|
28
|
+
if (pattern.test(scrollbackTail))
|
|
29
|
+
return 'waiting';
|
|
30
|
+
}
|
|
31
|
+
// Check running patterns
|
|
32
|
+
for (const pattern of RUNNING_PATTERNS) {
|
|
33
|
+
if (pattern.test(scrollbackTail))
|
|
34
|
+
return 'running';
|
|
35
|
+
}
|
|
36
|
+
// Check idle patterns (only match end of output)
|
|
37
|
+
for (const pattern of IDLE_PATTERNS) {
|
|
38
|
+
if (pattern.test(scrollbackTail))
|
|
39
|
+
return 'idle';
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
const logFile = path.join(os.homedir(), '.mob', 'server.log');
|
|
5
|
+
function appendToFile(line) {
|
|
6
|
+
try {
|
|
7
|
+
fs.appendFileSync(logFile, line + '\n');
|
|
8
|
+
}
|
|
9
|
+
catch { }
|
|
10
|
+
}
|
|
11
|
+
export function createLogger(module) {
|
|
12
|
+
function format(...args) {
|
|
13
|
+
const ts = new Date().toISOString().slice(11, 23);
|
|
14
|
+
return [`[${ts}] [${module}]`, ...args];
|
|
15
|
+
}
|
|
16
|
+
function formatString(...args) {
|
|
17
|
+
const ts = new Date().toISOString().slice(11, 23);
|
|
18
|
+
return `[${ts}] [${module}] ${args.map(a => typeof a === 'string' ? a : JSON.stringify(a)).join(' ')}`;
|
|
19
|
+
}
|
|
20
|
+
return {
|
|
21
|
+
info(...args) {
|
|
22
|
+
console.log(...format(...args));
|
|
23
|
+
appendToFile(formatString(...args));
|
|
24
|
+
},
|
|
25
|
+
warn(...args) {
|
|
26
|
+
console.warn(...format(...args));
|
|
27
|
+
appendToFile('WARN ' + formatString(...args));
|
|
28
|
+
},
|
|
29
|
+
error(...args) {
|
|
30
|
+
console.error(...format(...args));
|
|
31
|
+
appendToFile('ERROR ' + formatString(...args));
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import os from 'os';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import { execFileSync } from 'child_process';
|
|
5
|
+
export function getDefaultShell() {
|
|
6
|
+
if (process.platform === 'win32') {
|
|
7
|
+
return process.env.COMSPEC || 'powershell.exe';
|
|
8
|
+
}
|
|
9
|
+
return process.env.SHELL || '/bin/bash';
|
|
10
|
+
}
|
|
11
|
+
export function getShellArgs(shell) {
|
|
12
|
+
if (shell.includes('powershell') || shell.includes('pwsh')) {
|
|
13
|
+
return ['-NoLogo'];
|
|
14
|
+
}
|
|
15
|
+
return [];
|
|
16
|
+
}
|
|
17
|
+
export function ensureDir(dir) {
|
|
18
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
19
|
+
}
|
|
20
|
+
export function getMobDir() {
|
|
21
|
+
return path.join(os.homedir(), '.mob');
|
|
22
|
+
}
|
|
23
|
+
export function getInstancesDir() {
|
|
24
|
+
return path.join(getMobDir(), 'instances');
|
|
25
|
+
}
|
|
26
|
+
export function getSessionsDir() {
|
|
27
|
+
return path.join(getMobDir(), 'sessions');
|
|
28
|
+
}
|
|
29
|
+
export function getScrollbackDir() {
|
|
30
|
+
return path.join(getMobDir(), 'scrollback');
|
|
31
|
+
}
|
|
32
|
+
export function getGitBranch(cwd) {
|
|
33
|
+
try {
|
|
34
|
+
let resolved = cwd;
|
|
35
|
+
if (resolved.startsWith('~/') || resolved === '~') {
|
|
36
|
+
resolved = os.homedir() + resolved.slice(1);
|
|
37
|
+
}
|
|
38
|
+
return execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
|
|
39
|
+
cwd: resolved,
|
|
40
|
+
encoding: 'utf-8',
|
|
41
|
+
timeout: 3000,
|
|
42
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
43
|
+
}).trim() || undefined;
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import os from 'os';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
// --- Shell quoting ---
|
|
4
|
+
/** Wrap a value in single quotes with proper escaping for safe shell interpolation. */
|
|
5
|
+
export function shellQuote(s) {
|
|
6
|
+
return "'" + s.replace(/'/g, "'\\''") + "'";
|
|
7
|
+
}
|
|
8
|
+
// --- Field validators ---
|
|
9
|
+
const MODEL_PATTERN = /^[a-zA-Z0-9._:/-]+$/;
|
|
10
|
+
const SESSION_ID_PATTERN = /^[a-zA-Z0-9_-]+$/;
|
|
11
|
+
const INSTANCE_ID_PATTERN = /^[a-zA-Z0-9_-]+$/;
|
|
12
|
+
const VALID_PERMISSION_MODES = ['default', 'plan', 'bypassPermissions', 'full'];
|
|
13
|
+
export function isValidModel(s) {
|
|
14
|
+
return MODEL_PATTERN.test(s) && s.length <= 100;
|
|
15
|
+
}
|
|
16
|
+
export function isValidPermissionMode(s) {
|
|
17
|
+
return VALID_PERMISSION_MODES.includes(s);
|
|
18
|
+
}
|
|
19
|
+
export function isValidSessionId(s) {
|
|
20
|
+
return SESSION_ID_PATTERN.test(s) && s.length <= 200;
|
|
21
|
+
}
|
|
22
|
+
export function isValidInstanceId(s) {
|
|
23
|
+
return INSTANCE_ID_PATTERN.test(s) && s.length <= 100;
|
|
24
|
+
}
|
|
25
|
+
export function isValidCwd(s) {
|
|
26
|
+
if (!s || s.length > 1024)
|
|
27
|
+
return false;
|
|
28
|
+
if (s.includes('\0'))
|
|
29
|
+
return false;
|
|
30
|
+
// Must be absolute path or start with ~
|
|
31
|
+
return s.startsWith('/') || s.startsWith('~');
|
|
32
|
+
}
|
|
33
|
+
export function isValidName(s) {
|
|
34
|
+
return s.length <= 256;
|
|
35
|
+
}
|
|
36
|
+
/** Strip control characters (except newline/tab) from a string. */
|
|
37
|
+
export function stripControlChars(s) {
|
|
38
|
+
// eslint-disable-next-line no-control-regex
|
|
39
|
+
return s.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '');
|
|
40
|
+
}
|
|
41
|
+
// --- Path safety ---
|
|
42
|
+
/** Check if a resolved path is within the user's home directory. */
|
|
43
|
+
export function isPathWithinHome(resolved) {
|
|
44
|
+
const home = os.homedir();
|
|
45
|
+
const normalized = path.resolve(resolved);
|
|
46
|
+
return normalized === home || normalized.startsWith(home + path.sep);
|
|
47
|
+
}
|
|
48
|
+
export function validateLaunchPayload(payload) {
|
|
49
|
+
if (!payload || typeof payload !== 'object') {
|
|
50
|
+
return { valid: false, error: 'Payload must be an object' };
|
|
51
|
+
}
|
|
52
|
+
const p = payload;
|
|
53
|
+
// cwd: required, valid path
|
|
54
|
+
if (typeof p.cwd !== 'string' || !isValidCwd(p.cwd)) {
|
|
55
|
+
return { valid: false, error: 'Invalid or missing cwd (must be absolute path or start with ~, max 1024 chars)' };
|
|
56
|
+
}
|
|
57
|
+
// name: optional string, max 256 chars
|
|
58
|
+
const name = typeof p.name === 'string' ? stripControlChars(p.name).slice(0, 256) : '';
|
|
59
|
+
// model: optional, must match pattern
|
|
60
|
+
if (p.model !== undefined && p.model !== '') {
|
|
61
|
+
if (typeof p.model !== 'string' || !isValidModel(p.model)) {
|
|
62
|
+
return { valid: false, error: 'Invalid model (alphanumeric, dots, colons, hyphens, slashes only)' };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
// permissionMode: optional, must be in allowlist
|
|
66
|
+
if (p.permissionMode !== undefined && p.permissionMode !== '') {
|
|
67
|
+
if (typeof p.permissionMode !== 'string' || !isValidPermissionMode(p.permissionMode)) {
|
|
68
|
+
return { valid: false, error: `Invalid permissionMode (must be one of: ${VALID_PERMISSION_MODES.join(', ')})` };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
valid: true,
|
|
73
|
+
data: {
|
|
74
|
+
name,
|
|
75
|
+
autoName: !!p.autoName,
|
|
76
|
+
cwd: p.cwd,
|
|
77
|
+
model: (typeof p.model === 'string' && p.model) || undefined,
|
|
78
|
+
permissionMode: (typeof p.permissionMode === 'string' && p.permissionMode) || undefined,
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
export function validateHookPayload(data) {
|
|
83
|
+
if (!data || typeof data !== 'object') {
|
|
84
|
+
return { valid: false, error: 'Payload must be an object' };
|
|
85
|
+
}
|
|
86
|
+
const d = data;
|
|
87
|
+
// id: required
|
|
88
|
+
if (typeof d.id !== 'string' || !d.id) {
|
|
89
|
+
return { valid: false, error: 'Missing instance id' };
|
|
90
|
+
}
|
|
91
|
+
if (d.id.length > 200) {
|
|
92
|
+
return { valid: false, error: 'Instance id too long' };
|
|
93
|
+
}
|
|
94
|
+
// cwd: optional but if present must be valid
|
|
95
|
+
if (d.cwd !== undefined && typeof d.cwd === 'string') {
|
|
96
|
+
if (d.cwd.includes('\0') || d.cwd.length > 1024) {
|
|
97
|
+
return { valid: false, error: 'Invalid cwd' };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
// sessionId: optional, alphanumeric
|
|
101
|
+
if (d.sessionId !== undefined && typeof d.sessionId === 'string') {
|
|
102
|
+
if (!isValidSessionId(d.sessionId)) {
|
|
103
|
+
return { valid: false, error: 'Invalid sessionId format' };
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
// model: optional
|
|
107
|
+
if (d.model !== undefined && typeof d.model === 'string' && d.model !== '') {
|
|
108
|
+
if (!isValidModel(d.model)) {
|
|
109
|
+
return { valid: false, error: 'Invalid model format' };
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
// Strip control chars from string fields
|
|
113
|
+
if (typeof d.name === 'string')
|
|
114
|
+
d.name = stripControlChars(d.name).slice(0, 256);
|
|
115
|
+
if (typeof d.subtask === 'string')
|
|
116
|
+
d.subtask = stripControlChars(d.subtask).slice(0, 500);
|
|
117
|
+
if (typeof d.topic === 'string')
|
|
118
|
+
d.topic = stripControlChars(d.topic).slice(0, 500);
|
|
119
|
+
if (typeof d.ticket === 'string')
|
|
120
|
+
d.ticket = stripControlChars(d.ticket).slice(0, 200);
|
|
121
|
+
if (typeof d.ticketStatus === 'string')
|
|
122
|
+
d.ticketStatus = stripControlChars(d.ticketStatus).slice(0, 100);
|
|
123
|
+
if (typeof d.hookEvent === 'string')
|
|
124
|
+
d.hookEvent = stripControlChars(d.hookEvent).slice(0, 50);
|
|
125
|
+
return { valid: true, data: d };
|
|
126
|
+
}
|