nothumanallowed 15.1.38 → 15.1.40

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nothumanallowed",
3
- "version": "15.1.38",
3
+ "version": "15.1.40",
4
4
  "description": "NotHumanAllowed — 38 AI agents, 80 tools, Studio (visual agentic workflows). Email, calendar, browser automation, screen capture, canvas, cron/heartbeat, Alexandria E2E messaging, GitHub, Notion, Slack, voice chat, free AI (Liara), 28 languages. Zero-dependency CLI.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/constants.mjs CHANGED
@@ -5,7 +5,7 @@ import { fileURLToPath } from 'url';
5
5
  const __filename = fileURLToPath(import.meta.url);
6
6
  const __dirname = path.dirname(__filename);
7
7
 
8
- export const VERSION = '15.1.38';
8
+ export const VERSION = '15.1.40';
9
9
  export const BASE_URL = 'https://nothumanallowed.com/cli';
10
10
  export const API_BASE = 'https://nothumanallowed.com/api/v1';
11
11
 
@@ -2,19 +2,31 @@
2
2
  * Connectors — visual workflow automation backend
3
3
  * Workflows stored in ~/.nha/workflows/*.json
4
4
  * Execution: each node runs a real NHA tool or AI call
5
+ *
6
+ * 15.1.40 — closes the gap vs n8n:
7
+ * • Workflow versioning (auto-snapshot on each save)
8
+ * • Webhook triggers (HTTP endpoint per workflow)
9
+ * • Credentials manager (AES-256-GCM at rest, ${cred.NAME} interpolation)
10
+ * • Subworkflows (one workflow can call another)
5
11
  */
6
12
  import fs from 'fs';
13
+ import os from 'os';
7
14
  import path from 'path';
15
+ import crypto from 'crypto';
8
16
  import { NHA_DIR } from '../../constants.mjs';
9
17
  import { loadConfig } from '../../config.mjs';
10
18
  import { sendJSON, sendError, parseBody } from '../index.mjs';
11
19
  import { executeTool } from '../../services/tool-executor.mjs';
12
20
  import { callLLM } from '../../services/llm.mjs';
13
21
 
14
- const WORKFLOWS_DIR = path.join(NHA_DIR, 'workflows');
22
+ const WORKFLOWS_DIR = path.join(NHA_DIR, 'workflows');
23
+ const VERSIONS_DIR = path.join(NHA_DIR, 'workflows', '_versions');
24
+ const CREDS_FILE = path.join(NHA_DIR, 'credentials.json');
25
+ const MAX_VERSIONS_PER_WF = 50;
15
26
 
16
27
  function ensureDir() {
17
28
  if (!fs.existsSync(WORKFLOWS_DIR)) fs.mkdirSync(WORKFLOWS_DIR, { recursive: true });
29
+ if (!fs.existsSync(VERSIONS_DIR)) fs.mkdirSync(VERSIONS_DIR, { recursive: true });
18
30
  }
19
31
 
20
32
  function listWorkflows() {
@@ -30,12 +42,144 @@ function listWorkflows() {
30
42
 
31
43
  function saveWorkflow(wf) {
32
44
  ensureDir();
33
- fs.writeFileSync(path.join(WORKFLOWS_DIR, `${wf.id}.json`), JSON.stringify(wf, null, 2));
45
+ const file = path.join(WORKFLOWS_DIR, `${wf.id}.json`);
46
+ // 1. Snapshot previous version BEFORE overwriting — that's how we get
47
+ // a real history viewer like n8n's. Pruned to MAX_VERSIONS_PER_WF.
48
+ if (fs.existsSync(file)) {
49
+ const wfVersionsDir = path.join(VERSIONS_DIR, wf.id);
50
+ if (!fs.existsSync(wfVersionsDir)) fs.mkdirSync(wfVersionsDir, { recursive: true });
51
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
52
+ try {
53
+ const prev = fs.readFileSync(file, 'utf-8');
54
+ fs.writeFileSync(path.join(wfVersionsDir, `${ts}.json`), prev);
55
+ // Prune oldest
56
+ const versions = fs.readdirSync(wfVersionsDir).filter((f) => f.endsWith('.json')).sort();
57
+ while (versions.length > MAX_VERSIONS_PER_WF) {
58
+ try { fs.unlinkSync(path.join(wfVersionsDir, versions.shift())); } catch {}
59
+ }
60
+ } catch { /* snapshot failure shouldn't block the save */ }
61
+ }
62
+ fs.writeFileSync(file, JSON.stringify(wf, null, 2));
34
63
  }
35
64
 
36
65
  function deleteWorkflow(id) {
37
66
  const p = path.join(WORKFLOWS_DIR, `${id}.json`);
38
67
  if (fs.existsSync(p)) fs.unlinkSync(p);
68
+ // Also delete versions
69
+ const vd = path.join(VERSIONS_DIR, id);
70
+ if (fs.existsSync(vd)) fs.rmSync(vd, { recursive: true, force: true });
71
+ }
72
+
73
+ function listVersions(id) {
74
+ const vd = path.join(VERSIONS_DIR, id);
75
+ if (!fs.existsSync(vd)) return [];
76
+ return fs.readdirSync(vd)
77
+ .filter((f) => f.endsWith('.json'))
78
+ .map((f) => {
79
+ const ts = f.replace(/\.json$/, '');
80
+ try {
81
+ const stat = fs.statSync(path.join(vd, f));
82
+ return { ts, mtime: stat.mtimeMs, size: stat.size };
83
+ } catch { return null; }
84
+ })
85
+ .filter(Boolean)
86
+ .sort((a, b) => b.mtime - a.mtime);
87
+ }
88
+
89
+ function loadVersion(id, ts) {
90
+ const p = path.join(VERSIONS_DIR, id, `${ts}.json`);
91
+ if (!fs.existsSync(p)) return null;
92
+ try { return JSON.parse(fs.readFileSync(p, 'utf-8')); } catch { return null; }
93
+ }
94
+
95
+ // ── Credentials manager (AES-256-GCM at rest, machine-bound key) ────────────
96
+ //
97
+ // Why this implementation: storing API keys plain-text in config.json is the
98
+ // #1 footgun for self-hosted automation tools. n8n encrypts credentials with
99
+ // an encryption key (env var). We do the same but derive the key from the
100
+ // machine identity (username + hostname + a stable salt file in ~/.nha/) so
101
+ // the user doesn't have to set ENV vars. Tamper-detection via GCM auth tag.
102
+
103
+ function _credKey() {
104
+ const saltFile = path.join(NHA_DIR, '.cred-salt');
105
+ let salt;
106
+ if (fs.existsSync(saltFile)) {
107
+ salt = fs.readFileSync(saltFile);
108
+ } else {
109
+ salt = crypto.randomBytes(32);
110
+ fs.mkdirSync(path.dirname(saltFile), { recursive: true });
111
+ fs.writeFileSync(saltFile, salt, { mode: 0o600 });
112
+ }
113
+ const identity = `${os.userInfo().username}|${os.hostname()}|nha-creds-v1`;
114
+ return crypto.scryptSync(identity, salt, 32);
115
+ }
116
+
117
+ function _encrypt(plaintext) {
118
+ const key = _credKey();
119
+ const iv = crypto.randomBytes(12);
120
+ const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
121
+ const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf-8'), cipher.final()]);
122
+ const tag = cipher.getAuthTag();
123
+ return Buffer.concat([iv, tag, ciphertext]).toString('base64');
124
+ }
125
+
126
+ function _decrypt(b64) {
127
+ const buf = Buffer.from(b64, 'base64');
128
+ const iv = buf.subarray(0, 12);
129
+ const tag = buf.subarray(12, 28);
130
+ const ciphertext = buf.subarray(28);
131
+ const decipher = crypto.createDecipheriv('aes-256-gcm', _credKey(), iv);
132
+ decipher.setAuthTag(tag);
133
+ return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf-8');
134
+ }
135
+
136
+ function loadCredentials() {
137
+ if (!fs.existsSync(CREDS_FILE)) return {};
138
+ try {
139
+ const raw = JSON.parse(fs.readFileSync(CREDS_FILE, 'utf-8'));
140
+ const out = {};
141
+ for (const [name, entry] of Object.entries(raw)) {
142
+ try { out[name] = { value: _decrypt(entry.enc), updatedAt: entry.updatedAt, description: entry.description || '' }; }
143
+ catch { out[name] = { value: '', updatedAt: entry.updatedAt, error: 'decryption failed (machine changed?)' }; }
144
+ }
145
+ return out;
146
+ } catch { return {}; }
147
+ }
148
+
149
+ function saveCredential(name, value, description = '') {
150
+ if (!/^[A-Za-z_][A-Za-z0-9_]{0,63}$/.test(name)) throw new Error('Invalid credential name (letters, digits, _ only)');
151
+ const raw = fs.existsSync(CREDS_FILE) ? JSON.parse(fs.readFileSync(CREDS_FILE, 'utf-8')) : {};
152
+ raw[name] = { enc: _encrypt(String(value)), updatedAt: new Date().toISOString(), description };
153
+ if (!fs.existsSync(path.dirname(CREDS_FILE))) fs.mkdirSync(path.dirname(CREDS_FILE), { recursive: true });
154
+ fs.writeFileSync(CREDS_FILE, JSON.stringify(raw, null, 2), { mode: 0o600 });
155
+ }
156
+
157
+ function deleteCredential(name) {
158
+ if (!fs.existsSync(CREDS_FILE)) return;
159
+ const raw = JSON.parse(fs.readFileSync(CREDS_FILE, 'utf-8'));
160
+ delete raw[name];
161
+ fs.writeFileSync(CREDS_FILE, JSON.stringify(raw, null, 2), { mode: 0o600 });
162
+ }
163
+
164
+ /**
165
+ * Interpolate ${cred.NAME} placeholders in any string value within a config.
166
+ * Non-strings are returned as-is. Missing creds resolve to '' and emit a warning.
167
+ */
168
+ function interpolateCredentials(value, creds) {
169
+ if (typeof value === 'string') {
170
+ return value.replace(/\$\{cred\.([A-Za-z_][A-Za-z0-9_]{0,63})\}/g, (_, name) => {
171
+ const c = creds[name];
172
+ if (!c) return '';
173
+ return c.value || '';
174
+ });
175
+ }
176
+ if (Array.isArray(value)) return value.map((v) => interpolateCredentials(v, creds));
177
+ if (value && typeof value === 'object') {
178
+ const out = {};
179
+ for (const [k, v] of Object.entries(value)) out[k] = interpolateCredentials(v, creds);
180
+ return out;
181
+ }
182
+ return value;
39
183
  }
40
184
 
41
185
  /** Seed example workflows on first run */
@@ -202,6 +346,22 @@ async function executeNode(node, nodeDef, ctx, config) {
202
346
  // Error handler wraps previous node execution — handled in runWorkflow
203
347
  return ctx.output || cfg.fallback || '';
204
348
  }
349
+ if (node.defId === 'logic_subworkflow') {
350
+ // Run another workflow inline. The target workflow's final node output
351
+ // becomes this node's output. inputMapping allows passing the parent's
352
+ // output (default) or a custom expression.
353
+ const targetId = cfg.workflowId;
354
+ if (!targetId) throw new Error('Subworkflow: workflowId not configured');
355
+ const wfPath = path.join(WORKFLOWS_DIR, `${targetId}.json`);
356
+ if (!fs.existsSync(wfPath)) throw new Error(`Subworkflow: target "${targetId}" not found`);
357
+ const subWf = JSON.parse(fs.readFileSync(wfPath, 'utf-8'));
358
+ const subInput = (cfg.inputMapping && cfg.inputMapping !== '{{output}}')
359
+ ? cfg.inputMapping.replace(/\{\{output\}\}/g, ctx.output || '')
360
+ : (ctx.output || '');
361
+ const subSteps = await runWorkflow(subWf, subInput, ctx.__config || config, { depth: (ctx.__depth ?? 0) + 1 });
362
+ const lastStep = subSteps.filter((s) => s.nodeId !== '__error').pop();
363
+ return lastStep?.output ?? '';
364
+ }
205
365
  return ctx.output || '';
206
366
  }
207
367
 
@@ -236,6 +396,12 @@ async function executeNode(node, nodeDef, ctx, config) {
236
396
 
237
397
  // Trigger nodes
238
398
  if (nodeDef.type === 'trigger') {
399
+ // For webhook triggers, prefer the live HTTP payload if present
400
+ if (node.defId === 'trigger_webhook' && ctx.triggerPayload !== undefined) {
401
+ return typeof ctx.triggerPayload === 'string'
402
+ ? ctx.triggerPayload
403
+ : JSON.stringify(ctx.triggerPayload);
404
+ }
239
405
  return ctx.output || cfg.input || '';
240
406
  }
241
407
 
@@ -245,10 +411,31 @@ async function executeNode(node, nodeDef, ctx, config) {
245
411
  /**
246
412
  * Run a workflow from start to finish.
247
413
  * Returns array of { nodeId, output, error? } step results.
414
+ *
415
+ * @param {object} wf - workflow definition
416
+ * @param {string} initialInput - text input piped into start node
417
+ * @param {object} config - NHA config
418
+ * @param {object} [opts] - { depth: number, triggerPayload: any }
419
+ * depth: protects against subworkflow recursion (max 5).
420
+ * triggerPayload: when the workflow was started by a webhook, this is the
421
+ * raw request body, injected into ctx.trigger for downstream nodes.
248
422
  */
249
- async function runWorkflow(wf, initialInput, config) {
423
+ async function runWorkflow(wf, initialInput, config, opts = {}) {
424
+ const depth = opts.depth ?? 0;
425
+ if (depth > 5) return [{ nodeId: '__error', nodeLabel: 'Error', nodeIcon: '❌', output: 'Subworkflow recursion depth limit (5) exceeded.' }];
426
+
427
+ // Resolve credentials placeholders ${cred.NAME} in every node config BEFORE
428
+ // executing. This way every executeNode call receives plain values — the
429
+ // node implementation doesn't need to know about the credentials system.
430
+ const creds = loadCredentials();
431
+ const interpolatedNodes = wf.nodes.map((n) => ({
432
+ ...n,
433
+ config: interpolateCredentials(n.config || {}, creds),
434
+ }));
435
+ const interpolatedWf = { ...wf, nodes: interpolatedNodes };
436
+
250
437
  const steps = [];
251
- const nodeMap = Object.fromEntries(wf.nodes.map((n) => [n.id, n]));
438
+ const nodeMap = Object.fromEntries(interpolatedWf.nodes.map((n) => [n.id, n]));
252
439
  const defMap = Object.fromEntries((wf.nodeDefs || []).map((d) => [d.id, d]));
253
440
 
254
441
  // Build adjacency: from → [{to, fromPort}]
@@ -270,7 +457,14 @@ async function runWorkflow(wf, initialInput, config) {
270
457
  if (startCandidates.length === 0) return [{ nodeId: '__error', nodeLabel: 'Error', nodeIcon: '❌', output: 'No start node found.' }];
271
458
 
272
459
  // BFS execution with branching support
273
- const queue = startCandidates.map((n) => ({ nodeId: n.id, ctx: { output: initialInput || '', input: initialInput || '' } }));
460
+ const baseCtx = {
461
+ output: initialInput || '',
462
+ input: initialInput || '',
463
+ triggerPayload: opts.triggerPayload,
464
+ __depth: depth,
465
+ __config: config,
466
+ };
467
+ const queue = startCandidates.map((n) => ({ nodeId: n.id, ctx: { ...baseCtx } }));
274
468
  const visited = new Set();
275
469
 
276
470
  while (queue.length > 0) {
@@ -442,4 +636,94 @@ export function register(router) {
442
636
  sendError(res, 500, e.message);
443
637
  }
444
638
  });
639
+
640
+ // ── Versioning ───────────────────────────────────────────────────────────
641
+ router.get('/api/workflows/:id/versions', async (req, res) => {
642
+ try { sendJSON(res, 200, { versions: listVersions(req.params?.id) }); }
643
+ catch (e) { sendError(res, 500, e.message); }
644
+ });
645
+
646
+ router.get('/api/workflows/:id/versions/:ts', async (req, res) => {
647
+ try {
648
+ const wf = loadVersion(req.params?.id, req.params?.ts);
649
+ if (!wf) return sendError(res, 404, 'Version not found');
650
+ sendJSON(res, 200, { workflow: wf });
651
+ } catch (e) { sendError(res, 500, e.message); }
652
+ });
653
+
654
+ router.post('/api/workflows/:id/restore/:ts', async (req, res) => {
655
+ try {
656
+ const wf = loadVersion(req.params?.id, req.params?.ts);
657
+ if (!wf) return sendError(res, 404, 'Version not found');
658
+ saveWorkflow(wf); // creates a new snapshot of current state automatically
659
+ sendJSON(res, 200, { ok: true, workflow: wf });
660
+ } catch (e) { sendError(res, 500, e.message); }
661
+ });
662
+
663
+ // ── Credentials manager ─────────────────────────────────────────────────
664
+ router.get('/api/credentials', async (_req, res) => {
665
+ try {
666
+ const creds = loadCredentials();
667
+ // NEVER return raw values — only names + metadata
668
+ const list = Object.entries(creds).map(([name, c]) => ({
669
+ name, updatedAt: c.updatedAt, description: c.description, hasError: !!c.error,
670
+ }));
671
+ sendJSON(res, 200, { credentials: list });
672
+ } catch (e) { sendError(res, 500, e.message); }
673
+ });
674
+
675
+ router.post('/api/credentials', async (req, res) => {
676
+ try {
677
+ const body = await parseBody(req);
678
+ if (!body.name || !body.value) return sendError(res, 400, 'name and value required');
679
+ saveCredential(body.name, body.value, body.description || '');
680
+ sendJSON(res, 200, { ok: true });
681
+ } catch (e) { sendError(res, 400, e.message); }
682
+ });
683
+
684
+ router.delete('/api/credentials/:name', async (req, res) => {
685
+ try {
686
+ deleteCredential(req.params?.name);
687
+ sendJSON(res, 200, { ok: true });
688
+ } catch (e) { sendError(res, 500, e.message); }
689
+ });
690
+
691
+ // ── Webhook triggers ─────────────────────────────────────────────────────
692
+ //
693
+ // ANY method to /api/webhooks/<slug> triggers a workflow whose start node
694
+ // is a trigger_webhook with config.slug matching. The HTTP body is injected
695
+ // as triggerPayload — text/plain stays as string, application/json gets
696
+ // parsed. We respond with the last node's output (or 200 OK if no output).
697
+ const _handleWebhook = async (req, res) => {
698
+ try {
699
+ const slug = req.params?.slug;
700
+ if (!slug) return sendError(res, 400, 'webhook slug required');
701
+ const all = listWorkflows();
702
+ const wf = all.find((w) => (w.nodes || []).some((n) =>
703
+ n.defId === 'trigger_webhook' && (n.config?.slug === slug || n.config?.path === slug),
704
+ ));
705
+ if (!wf) return sendError(res, 404, `No workflow registered for webhook /${slug}`);
706
+ if (wf.enabled === false) return sendError(res, 403, 'Workflow is disabled');
707
+
708
+ // Parse body — try JSON, fall back to text
709
+ let payload = '';
710
+ try { payload = await parseBody(req); }
711
+ catch { payload = ''; }
712
+
713
+ const config = loadConfig();
714
+ const steps = await runWorkflow(wf, typeof payload === 'string' ? payload : JSON.stringify(payload), config, { triggerPayload: payload });
715
+ const last = steps.filter((s) => s.nodeId !== '__error').pop();
716
+ const output = last?.output ?? '';
717
+ // If the output looks like JSON, return it as JSON; otherwise as text
718
+ try { sendJSON(res, 200, JSON.parse(output)); }
719
+ catch {
720
+ res.writeHead(200, { 'Content-Type': 'text/plain', 'Access-Control-Allow-Origin': '*' });
721
+ res.end(String(output));
722
+ }
723
+ } catch (e) { sendError(res, 500, e.message); }
724
+ };
725
+ router.post('/api/webhooks/:slug', _handleWebhook);
726
+ router.get( '/api/webhooks/:slug', _handleWebhook);
727
+ router.put( '/api/webhooks/:slug', _handleWebhook);
728
+ router.delete('/api/webhooks/:slug', _handleWebhook);
445
729
  }
@@ -296,7 +296,7 @@ TOOLS:
296
296
 
297
297
  41. slack_send(channel: string, text: string, threadTs?: string)
298
298
  Send a message to a Slack channel. ALWAYS confirm before sending.
299
- If `threadTs` is provided, post as a thread reply instead of a top-level message.
299
+ If threadTs is provided, post as a thread reply instead of a top-level message.
300
300
 
301
301
  41b. slack_search(query: string, count?: number)
302
302
  Full-text search messages across the whole Slack workspace. Returns the most
@@ -305,18 +305,18 @@ TOOLS:
305
305
  su X", "did anyone post about the release", etc.
306
306
 
307
307
  41c. slack_dm(user: string, text?: string)
308
- Open or send a direct message to a user. `user` accepts Slack user ID (Uxxx),
309
- username, real name, or email — the tool resolves them automatically.
310
- If `text` is provided, the message is sent immediately; otherwise the DM
311
- channel is just opened.
308
+ Open or send a direct message to a user. The user parameter accepts a Slack
309
+ user ID (Uxxx), username, real name, or email — the tool resolves them
310
+ automatically. If text is provided, the message is sent immediately; otherwise
311
+ the DM channel is just opened.
312
312
 
313
313
  41d. slack_thread(channel: string, ts: string)
314
- List all replies in a thread. `ts` is the parent message timestamp (returned
314
+ List all replies in a thread. ts is the parent message timestamp (returned
315
315
  by slack_messages or slack_search). Use this before posting a contextual
316
316
  reply with slack_send + threadTs.
317
317
 
318
318
  41e. slack_react(channel: string, ts: string, emoji: string)
319
- Add an emoji reaction to a message. `emoji` is the name without colons
319
+ Add an emoji reaction to a message. emoji is the name without colons
320
320
  (e.g. "thumbsup", "rocket", "white_check_mark"). Confirm before reacting on
321
321
  someone else's message.
322
322