wogiflow 2.6.0 → 2.6.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.
|
@@ -247,7 +247,14 @@ function processSuggestedTask(workspaceRoot, message) {
|
|
|
247
247
|
|
|
248
248
|
try {
|
|
249
249
|
const ready = JSON.parse(fs.readFileSync(readyPath, 'utf-8'));
|
|
250
|
-
|
|
250
|
+
// Use generateTaskId when available, fallback to random (finding-008)
|
|
251
|
+
let taskId;
|
|
252
|
+
try {
|
|
253
|
+
const { generateTaskId } = require('../scripts/flow-utils');
|
|
254
|
+
taskId = generateTaskId(message.suggestedTask.title || message.subject);
|
|
255
|
+
} catch (_err) {
|
|
256
|
+
taskId = 'wf-' + crypto.randomBytes(4).toString('hex');
|
|
257
|
+
}
|
|
251
258
|
|
|
252
259
|
const task = {
|
|
253
260
|
id: taskId,
|
package/lib/workspace.js
CHANGED
|
@@ -22,6 +22,22 @@ const path = require('node:path');
|
|
|
22
22
|
// Constants
|
|
23
23
|
// ============================================================
|
|
24
24
|
|
|
25
|
+
// Proto-pollution safe JSON parse (finding-007)
|
|
26
|
+
const DANGEROUS_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
27
|
+
function safeParseJson(str, fallback) {
|
|
28
|
+
try {
|
|
29
|
+
const obj = JSON.parse(str);
|
|
30
|
+
if (obj && typeof obj === 'object') {
|
|
31
|
+
for (const key of Object.keys(obj)) {
|
|
32
|
+
if (DANGEROUS_KEYS.has(key)) delete obj[key];
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return obj;
|
|
36
|
+
} catch (_err) {
|
|
37
|
+
return fallback;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
25
41
|
const WORKSPACE_CONFIG_FILE = 'wogi-workspace.json';
|
|
26
42
|
const WORKSPACE_DIR = '.workspace';
|
|
27
43
|
const WORKSPACE_DIRS = [
|
|
@@ -102,7 +118,7 @@ function readMemberMetadata(workflowPath) {
|
|
|
102
118
|
try {
|
|
103
119
|
if (fs.existsSync(filePath)) {
|
|
104
120
|
const content = fs.readFileSync(filePath, 'utf-8');
|
|
105
|
-
metadata[key] = json ?
|
|
121
|
+
metadata[key] = json ? safeParseJson(content, {}) : content;
|
|
106
122
|
}
|
|
107
123
|
} catch (_err) {
|
|
108
124
|
// Non-critical — skip unreadable files
|
|
@@ -114,7 +130,7 @@ function readMemberMetadata(workflowPath) {
|
|
|
114
130
|
const filePath = path.join(statePath, file);
|
|
115
131
|
try {
|
|
116
132
|
if (fs.existsSync(filePath)) {
|
|
117
|
-
metadata[key] =
|
|
133
|
+
metadata[key] = safeParseJson(fs.readFileSync(filePath, 'utf-8'), {});
|
|
118
134
|
}
|
|
119
135
|
} catch (_err) {
|
|
120
136
|
// Non-critical
|
|
@@ -227,7 +243,7 @@ function detectStack(metadata, memberPath) {
|
|
|
227
243
|
const pkgPath = path.join(memberPath, 'package.json');
|
|
228
244
|
if (fs.existsSync(pkgPath)) {
|
|
229
245
|
try {
|
|
230
|
-
const pkg =
|
|
246
|
+
const pkg = safeParseJson(fs.readFileSync(pkgPath, 'utf-8'), {});
|
|
231
247
|
if (pkg.dependencies?.typescript || pkg.devDependencies?.typescript) {
|
|
232
248
|
stack.language = 'TypeScript';
|
|
233
249
|
} else {
|
|
@@ -910,10 +926,24 @@ function generateMemberMcpConfigs(workspaceRoot, config) {
|
|
|
910
926
|
continue;
|
|
911
927
|
}
|
|
912
928
|
|
|
929
|
+
// Reserved name check (finding-003) — 'manager' is used as the orchestrator identity
|
|
930
|
+
if (name === 'manager') {
|
|
931
|
+
console.error(` ✗ ${name}: 'manager' is a reserved name — rename this member repo`);
|
|
932
|
+
continue;
|
|
933
|
+
}
|
|
934
|
+
|
|
913
935
|
const memberPath = path.resolve(workspaceRoot, config.members[name]?.path || `./${name}`);
|
|
914
936
|
|
|
915
|
-
// Path traversal guard
|
|
916
|
-
|
|
937
|
+
// Path traversal guard with symlink resolution (finding-006)
|
|
938
|
+
let realRoot, realMember;
|
|
939
|
+
try {
|
|
940
|
+
realRoot = fs.realpathSync(workspaceRoot);
|
|
941
|
+
realMember = fs.realpathSync(memberPath);
|
|
942
|
+
} catch (_err) {
|
|
943
|
+
realRoot = workspaceRoot;
|
|
944
|
+
realMember = memberPath;
|
|
945
|
+
}
|
|
946
|
+
if (!realMember.startsWith(realRoot + path.sep) && realMember !== realRoot) {
|
|
917
947
|
console.error(` ✗ ${name}: path escapes workspace root (${config.members[name]?.path}) — skipping`);
|
|
918
948
|
continue;
|
|
919
949
|
}
|
|
@@ -990,22 +1020,32 @@ description: "Workspace worker rules — reply to manager after every channel ta
|
|
|
990
1020
|
|
|
991
1021
|
You are **${name}** in a Wogi Workspace managed by an orchestrator.
|
|
992
1022
|
|
|
993
|
-
## CRITICAL: Always Reply to the Manager
|
|
1023
|
+
## CRITICAL: Always Reply to the Manager (port ${config.channels.managerPort || (config.channels.basePort - 1)})
|
|
994
1024
|
|
|
995
|
-
When you receive a message via the workspace channel
|
|
1025
|
+
When you receive a message via the workspace channel, you MUST send results back to the manager after completing the work. **The user only sees the manager terminal — if you don't reply, they never see your results.**
|
|
996
1026
|
|
|
997
|
-
|
|
1027
|
+
**PRIMARY method — curl to manager port (always works, even if MCP tools haven't loaded yet):**
|
|
1028
|
+
|
|
1029
|
+
\`\`\`bash
|
|
1030
|
+
curl -s -X POST http://localhost:${config.channels.managerPort || (config.channels.basePort - 1)} -H "Content-Type: text/plain" -H "X-Wogi-From: ${name}" -d "## Results
|
|
1031
|
+
|
|
1032
|
+
[your findings/summary here]"
|
|
1033
|
+
\`\`\`
|
|
1034
|
+
|
|
1035
|
+
**ALTERNATIVE — workspace_send_message MCP tool (if available):**
|
|
998
1036
|
|
|
999
1037
|
\`\`\`
|
|
1000
1038
|
workspace_send_message(to: "manager", message: "## Results\\n\\n[your findings/summary here]")
|
|
1001
1039
|
\`\`\`
|
|
1002
1040
|
|
|
1003
|
-
|
|
1041
|
+
If the MCP tool is not available (shows "MCP servers still connecting"), use the curl command. **Do NOT write JSON files to .workspace/messages/ directly — that doesn't notify the manager.**
|
|
1042
|
+
|
|
1043
|
+
This is NON-OPTIONAL. Every channel-dispatched task MUST end with a reply to the manager.
|
|
1004
1044
|
|
|
1005
1045
|
## Peer Communication
|
|
1006
1046
|
|
|
1007
|
-
You can talk to peer repos
|
|
1008
|
-
${peerNames.map(p => `- \`
|
|
1047
|
+
You can talk to peer repos via curl or the MCP tool:
|
|
1048
|
+
${peerNames.map(p => `- \`curl -s -X POST http://localhost:${channelMembers[p]?.port || '???'} -H "X-Wogi-From: ${name}" -d "your message"\``).join('\n')}
|
|
1009
1049
|
|
|
1010
1050
|
## Autonomous Mode — Auto-Approve Everything
|
|
1011
1051
|
|
|
@@ -1024,7 +1064,7 @@ Only send a question to the manager (instead of results) when:
|
|
|
1024
1064
|
- The task requires **deleting user data** or making irreversible changes
|
|
1025
1065
|
- You are genuinely **stuck** and cannot proceed
|
|
1026
1066
|
|
|
1027
|
-
To escalate: \`
|
|
1067
|
+
To escalate: \`curl -s -X POST http://localhost:${config.channels.managerPort || (config.channels.basePort - 1)} -H "X-Wogi-From: ${name}" -d "## Need Decision: [describe the choice and options]"\`
|
|
1028
1068
|
|
|
1029
1069
|
For everything else — just do the work and report results.
|
|
1030
1070
|
|
package/package.json
CHANGED
|
@@ -282,62 +282,8 @@ async function handleTaskCompleted(input) {
|
|
|
282
282
|
} catch (_err) {
|
|
283
283
|
// Non-critical - registry manager may not be available
|
|
284
284
|
}
|
|
285
|
-
//
|
|
286
|
-
|
|
287
|
-
try {
|
|
288
|
-
const workspaceRoot = process.env.WOGI_WORKSPACE_ROOT;
|
|
289
|
-
const repoName = process.env.WOGI_REPO_NAME || path.basename(process.cwd());
|
|
290
|
-
const messagesDir = path.join(workspaceRoot, '.workspace', 'messages');
|
|
291
|
-
fs.mkdirSync(messagesDir, { recursive: true });
|
|
292
|
-
|
|
293
|
-
// Build a summary from available task data
|
|
294
|
-
const summary = [];
|
|
295
|
-
if (completedTask.title) summary.push(`**Task**: ${completedTask.title}`);
|
|
296
|
-
if (completedTask.type) summary.push(`**Type**: ${completedTask.type}`);
|
|
297
|
-
if (input.changedFiles?.length) summary.push(`**Files changed**: ${input.changedFiles.join(', ')}`);
|
|
298
|
-
if (input.summary) summary.push(`**Summary**: ${input.summary}`);
|
|
299
|
-
|
|
300
|
-
// Read the last request-log entry for richer context
|
|
301
|
-
try {
|
|
302
|
-
const logPath = path.join(PATHS.root, 'request-log.md');
|
|
303
|
-
if (fs.existsSync(logPath)) {
|
|
304
|
-
const logContent = fs.readFileSync(logPath, 'utf-8');
|
|
305
|
-
const lastEntry = logContent.split(/^### R-/m).pop();
|
|
306
|
-
if (lastEntry && lastEntry.length < 2000) {
|
|
307
|
-
summary.push(`**Log entry**:\n${lastEntry.trim()}`);
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
} catch (_err) {
|
|
311
|
-
// Non-critical
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
const message = {
|
|
315
|
-
id: 'msg-' + require('node:crypto').randomBytes(4).toString('hex'),
|
|
316
|
-
from: repoName,
|
|
317
|
-
to: 'manager',
|
|
318
|
-
type: 'task-complete',
|
|
319
|
-
priority: 'medium',
|
|
320
|
-
timestamp: new Date().toISOString(),
|
|
321
|
-
subject: `Task completed: ${completedTask.title || completedTask.id}`,
|
|
322
|
-
body: summary.join('\n') || `Task ${completedTask.id} completed successfully.`,
|
|
323
|
-
taskId: completedTask.id,
|
|
324
|
-
actionRequired: false,
|
|
325
|
-
status: 'pending'
|
|
326
|
-
};
|
|
327
|
-
|
|
328
|
-
const msgPath = path.join(messagesDir, `${message.id}.json`);
|
|
329
|
-
fs.writeFileSync(msgPath, JSON.stringify(message, null, 2));
|
|
330
|
-
|
|
331
|
-
if (process.env.DEBUG) {
|
|
332
|
-
console.error(`[Task Completed] Workspace message written: ${msgPath}`);
|
|
333
|
-
}
|
|
334
|
-
} catch (err) {
|
|
335
|
-
// Non-critical — workspace messaging is best-effort
|
|
336
|
-
if (process.env.DEBUG) {
|
|
337
|
-
console.error(`[Task Completed] Workspace message failed: ${err.message}`);
|
|
338
|
-
}
|
|
339
|
-
}
|
|
340
|
-
}
|
|
285
|
+
// Workspace notifications are handled by the Stop hook (via HTTP to manager port).
|
|
286
|
+
// Removed duplicate file-based notification here to prevent double messages (finding-004).
|
|
341
287
|
|
|
342
288
|
// Check pending queue — notify user if items are waiting
|
|
343
289
|
try {
|
|
@@ -61,113 +61,61 @@ runHook('Stop', async ({ parsedInput }) => {
|
|
|
61
61
|
};
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
// Workspace worker:
|
|
65
|
-
//
|
|
66
|
-
|
|
67
|
-
// hook fires before the AI gets a chance, or the AI forgets.
|
|
68
|
-
if (process.env.WOGI_WORKSPACE_ROOT && process.env.WOGI_REPO_NAME) {
|
|
64
|
+
// Workspace worker: send results to manager via HTTP when stopping.
|
|
65
|
+
// Uses execFileSync with array args to avoid shell injection (finding-001).
|
|
66
|
+
if (process.env.WOGI_MANAGER_PORT && process.env.WOGI_REPO_NAME && process.env.WOGI_REPO_NAME !== 'manager') {
|
|
69
67
|
try {
|
|
70
|
-
const
|
|
68
|
+
const { execFileSync, execSync } = require('node:child_process');
|
|
71
69
|
const path = require('node:path');
|
|
72
|
-
const
|
|
73
|
-
const workspaceRoot = process.env.WOGI_WORKSPACE_ROOT;
|
|
70
|
+
const VALID_NAME = /^[a-zA-Z0-9_-]{1,64}$/;
|
|
74
71
|
const repoName = process.env.WOGI_REPO_NAME;
|
|
75
|
-
const
|
|
76
|
-
|
|
72
|
+
const managerPort = parseInt(process.env.WOGI_MANAGER_PORT, 10);
|
|
73
|
+
|
|
74
|
+
// Validate inputs before using them (finding-001, finding-002)
|
|
75
|
+
if (!VALID_NAME.test(repoName) || !Number.isInteger(managerPort) || managerPort < 1024 || managerPort > 65535) {
|
|
76
|
+
throw new Error(`Invalid WOGI_REPO_NAME or WOGI_MANAGER_PORT`);
|
|
77
|
+
}
|
|
77
78
|
|
|
78
79
|
// Build summary from available state
|
|
79
|
-
const
|
|
80
|
+
const summaryParts = [];
|
|
80
81
|
const { PATHS, safeJsonParse } = require('../../flow-utils');
|
|
81
82
|
|
|
82
|
-
// Get current/recently completed task info
|
|
83
83
|
const ready = safeJsonParse(path.join(PATHS.state, 'ready.json'), {});
|
|
84
84
|
const recentTask = (ready.recentlyCompleted || [])[0];
|
|
85
85
|
const inProgressTask = (ready.inProgress || [])[0];
|
|
86
86
|
const task = recentTask || inProgressTask;
|
|
87
87
|
|
|
88
88
|
if (task) {
|
|
89
|
-
|
|
90
|
-
if (task.type)
|
|
89
|
+
summaryParts.push(`**Task**: ${task.title || task.id}`);
|
|
90
|
+
if (task.type) summaryParts.push(`**Type**: ${task.type}`);
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
-
// Get last request-log entry
|
|
94
|
-
try {
|
|
95
|
-
const logPath = path.join(PATHS.root, 'request-log.md');
|
|
96
|
-
if (fs.existsSync(logPath)) {
|
|
97
|
-
const stat = fs.statSync(logPath);
|
|
98
|
-
if (stat.size < 200 * 1024) {
|
|
99
|
-
const logContent = fs.readFileSync(logPath, 'utf-8');
|
|
100
|
-
const parts = logContent.split(/^### R-/m);
|
|
101
|
-
if (parts.length > 1) {
|
|
102
|
-
const lastEntry = parts[parts.length - 1];
|
|
103
|
-
if (lastEntry.length < 2000) {
|
|
104
|
-
summary.push(`**Log entry**:\n### R-${lastEntry.trim()}`);
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
} catch (_err) { /* non-critical */ }
|
|
110
|
-
|
|
111
|
-
// Get git status for changed files
|
|
112
93
|
try {
|
|
113
|
-
const { execSync } = require('node:child_process');
|
|
114
94
|
const diff = execSync('git diff --name-only HEAD 2>/dev/null || true', { cwd: PATHS.root, encoding: 'utf-8' }).trim();
|
|
115
95
|
const staged = execSync('git diff --name-only --staged 2>/dev/null || true', { cwd: PATHS.root, encoding: 'utf-8' }).trim();
|
|
116
96
|
const allChanged = [...new Set([...diff.split('\n'), ...staged.split('\n')].filter(Boolean))];
|
|
117
97
|
if (allChanged.length > 0) {
|
|
118
|
-
|
|
98
|
+
summaryParts.push(`**Files changed**: ${allChanged.join(', ')}`);
|
|
119
99
|
}
|
|
120
100
|
} catch (_err) { /* non-critical */ }
|
|
121
101
|
|
|
122
|
-
const
|
|
123
|
-
const message = {
|
|
124
|
-
id: msgId,
|
|
125
|
-
from: repoName,
|
|
126
|
-
to: 'manager',
|
|
127
|
-
type: 'task-complete',
|
|
128
|
-
priority: 'medium',
|
|
129
|
-
timestamp: new Date().toISOString(),
|
|
130
|
-
subject: task ? `Completed: ${task.title || task.id}` : `Work completed by ${repoName}`,
|
|
131
|
-
body: summary.join('\n') || `${repoName} finished processing.`,
|
|
132
|
-
taskId: task?.id || null,
|
|
133
|
-
actionRequired: false,
|
|
134
|
-
status: 'pending'
|
|
135
|
-
};
|
|
102
|
+
const body = summaryParts.join('\n') || `Work completed by ${repoName}.`;
|
|
136
103
|
|
|
137
|
-
//
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
hostname: '127.0.0.1',
|
|
149
|
-
port: parseInt(managerPort, 10),
|
|
150
|
-
path: '/',
|
|
151
|
-
method: 'POST',
|
|
152
|
-
headers: {
|
|
153
|
-
'Content-Type': 'text/plain',
|
|
154
|
-
'Content-Length': body.byteLength,
|
|
155
|
-
'X-Wogi-From': repoName
|
|
156
|
-
}
|
|
157
|
-
});
|
|
158
|
-
req.on('error', () => { /* best effort — file is the fallback */ });
|
|
159
|
-
req.write(body);
|
|
160
|
-
req.end();
|
|
161
|
-
} catch (_err) { /* non-critical */ }
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
if (process.env.DEBUG) {
|
|
165
|
-
console.error(`[Stop] Workspace message written: ${msgId}${managerPort ? ` + HTTP to manager:${managerPort}` : ''}`);
|
|
104
|
+
// execFileSync with array args — no shell interpretation (finding-001 fix)
|
|
105
|
+
try {
|
|
106
|
+
execFileSync('curl', [
|
|
107
|
+
'-s', '-X', 'POST',
|
|
108
|
+
`http://127.0.0.1:${managerPort}`,
|
|
109
|
+
'-H', 'Content-Type: text/plain',
|
|
110
|
+
'-H', `X-Wogi-From: ${repoName}`,
|
|
111
|
+
'--data-binary', '@-'
|
|
112
|
+
], { input: body, timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
113
|
+
} catch (_err) {
|
|
114
|
+
// Manager might be offline — that's OK
|
|
166
115
|
}
|
|
167
116
|
} catch (err) {
|
|
168
|
-
// Non-critical — best effort
|
|
169
117
|
if (process.env.DEBUG) {
|
|
170
|
-
console.error(`[Stop] Workspace
|
|
118
|
+
console.error(`[Stop] Workspace notification failed: ${err.message}`);
|
|
171
119
|
}
|
|
172
120
|
}
|
|
173
121
|
}
|