@geekbeer/minion 3.53.1 → 3.57.0
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/core/db/migrations/20260510100000_secrets_workspace.js +36 -0
- package/core/db/migrations/20260511100000_variables_workspace.js +37 -0
- package/core/lib/dag-step-poller.js +245 -1
- package/core/lib/session-env.js +96 -0
- package/core/lib/step-poller.js +8 -3
- package/core/lib/template-expander.js +20 -6
- package/core/routes/variables.js +67 -17
- package/core/stores/variable-store.js +278 -76
- package/docs/api-reference.md +55 -297
- package/docs/task-guides.md +76 -75
- package/linux/routes/chat.js +1 -16
- package/linux/routine-runner.js +49 -11
- package/linux/workflow-runner.js +19 -8
- package/package.json +1 -1
- package/rules/core.md +8 -10
- package/win/routes/chat.js +1 -14
- package/win/routine-runner.js +35 -8
- package/win/workflow-runner.js +12 -5
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Move minion secrets from `.env.secrets` (flat file) into SQLite with
|
|
3
|
+
* workspace scoping.
|
|
4
|
+
*
|
|
5
|
+
* Design:
|
|
6
|
+
* - PRIMARY KEY (workspace_id, key); workspace_id='' is the minion-wide bucket.
|
|
7
|
+
* - Same key can exist at multiple scopes; at execution time the runner merges
|
|
8
|
+
* minion-wide + current-workspace secrets, with the WS-scoped value winning.
|
|
9
|
+
* - The actual data migration from `.env.secrets` is performed by
|
|
10
|
+
* variable-store at server start (it needs filesystem access which sits
|
|
11
|
+
* outside the DB transaction). See `migrateLegacySecretsFile()`.
|
|
12
|
+
*
|
|
13
|
+
* Secrets stay minion-local — they are NEVER persisted in HQ DB, by design.
|
|
14
|
+
* HQ→minion APIs only pass values through to the minion's local store.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
module.exports = {
|
|
18
|
+
version: 9,
|
|
19
|
+
name: 'secrets_workspace',
|
|
20
|
+
|
|
21
|
+
up(db, { tableExists }) {
|
|
22
|
+
if (tableExists(db, 'secrets')) return
|
|
23
|
+
|
|
24
|
+
db.exec(`
|
|
25
|
+
CREATE TABLE secrets (
|
|
26
|
+
workspace_id TEXT NOT NULL DEFAULT '',
|
|
27
|
+
key TEXT NOT NULL,
|
|
28
|
+
value TEXT NOT NULL,
|
|
29
|
+
updated_at INTEGER NOT NULL,
|
|
30
|
+
PRIMARY KEY (workspace_id, key)
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
CREATE INDEX IF NOT EXISTS idx_secrets_workspace ON secrets(workspace_id);
|
|
34
|
+
`)
|
|
35
|
+
},
|
|
36
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Move minion variables from `.env.variables` (flat file) into SQLite with
|
|
3
|
+
* workspace scoping — mirrors the secrets workspace migration.
|
|
4
|
+
*
|
|
5
|
+
* Design:
|
|
6
|
+
* - PRIMARY KEY (workspace_id, key); workspace_id='' is the minion-wide bucket.
|
|
7
|
+
* - Same key can exist at multiple scopes; at execution time the
|
|
8
|
+
* template-expander merges minion-wide + current-workspace variables, with
|
|
9
|
+
* the WS-scoped value winning.
|
|
10
|
+
* - Variables are non-sensitive — values are returned by the API (unlike
|
|
11
|
+
* secrets) — but they still benefit from scoping so a single minion serving
|
|
12
|
+
* multiple workspaces doesn't leak per-tenant config across runs.
|
|
13
|
+
* - Actual data migration from `.env.variables` is performed by variable-store
|
|
14
|
+
* at first use (filesystem access lives outside this transaction). See
|
|
15
|
+
* `migrateLegacyVariablesFile()`.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
module.exports = {
|
|
19
|
+
version: 10,
|
|
20
|
+
name: 'variables_workspace',
|
|
21
|
+
|
|
22
|
+
up(db, { tableExists }) {
|
|
23
|
+
if (tableExists(db, 'variables')) return
|
|
24
|
+
|
|
25
|
+
db.exec(`
|
|
26
|
+
CREATE TABLE variables (
|
|
27
|
+
workspace_id TEXT NOT NULL DEFAULT '',
|
|
28
|
+
key TEXT NOT NULL,
|
|
29
|
+
value TEXT NOT NULL,
|
|
30
|
+
updated_at INTEGER NOT NULL,
|
|
31
|
+
PRIMARY KEY (workspace_id, key)
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
CREATE INDEX IF NOT EXISTS idx_variables_workspace ON variables(workspace_id);
|
|
35
|
+
`)
|
|
36
|
+
},
|
|
37
|
+
}
|
|
@@ -119,12 +119,15 @@ async function pollOnce() {
|
|
|
119
119
|
|
|
120
120
|
/**
|
|
121
121
|
* Execute a single pending DAG node.
|
|
122
|
-
* Routes to skill
|
|
122
|
+
* Routes to skill / transform / script execution based on node_type.
|
|
123
123
|
*/
|
|
124
124
|
async function executeNode(node) {
|
|
125
125
|
if (node.node_type === 'transform') {
|
|
126
126
|
return executeTransformNode(node)
|
|
127
127
|
}
|
|
128
|
+
if (node.node_type === 'script') {
|
|
129
|
+
return executeScriptNode(node)
|
|
130
|
+
}
|
|
128
131
|
return executeSkillNode(node)
|
|
129
132
|
}
|
|
130
133
|
|
|
@@ -467,6 +470,247 @@ function appendContractTable(lines, fields) {
|
|
|
467
470
|
}
|
|
468
471
|
}
|
|
469
472
|
|
|
473
|
+
/**
|
|
474
|
+
* Execute a script node:
|
|
475
|
+
* 1. Claim the node
|
|
476
|
+
* 2. Spawn python3 / node as a child process
|
|
477
|
+
* 3. Pipe input_data as JSON on stdin
|
|
478
|
+
* 4. Parse stdout as JSON → output_data, report completion
|
|
479
|
+
*
|
|
480
|
+
* Failure modes (all → status 'failed'):
|
|
481
|
+
* - Unsupported runtime
|
|
482
|
+
* - Non-zero exit code (stderr → error_message)
|
|
483
|
+
* - stdout is not parseable as JSON
|
|
484
|
+
* - Wall-clock timeout exceeded (process is SIGKILL'd)
|
|
485
|
+
*
|
|
486
|
+
* No tmux session is involved — this is a synchronous in-process child.
|
|
487
|
+
* The Output Contract on the outgoing edge is enforced by HQ on
|
|
488
|
+
* /node-complete, identical to skill / transform nodes.
|
|
489
|
+
*/
|
|
490
|
+
async function executeScriptNode(node) {
|
|
491
|
+
const {
|
|
492
|
+
node_execution_id,
|
|
493
|
+
node_id,
|
|
494
|
+
dag_workflow_name,
|
|
495
|
+
scope_path,
|
|
496
|
+
assigned_role,
|
|
497
|
+
input_data,
|
|
498
|
+
script_runtime,
|
|
499
|
+
script_source,
|
|
500
|
+
script_timeout_seconds,
|
|
501
|
+
} = node
|
|
502
|
+
|
|
503
|
+
console.log(
|
|
504
|
+
`[DagPoller] Executing script node "${node_id}" of DAG "${dag_workflow_name}" ` +
|
|
505
|
+
`(runtime: ${script_runtime}, scope: "${scope_path || 'root'}", role: ${assigned_role})`
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
try {
|
|
509
|
+
try {
|
|
510
|
+
await dagRequest('/claim-node', {
|
|
511
|
+
method: 'POST',
|
|
512
|
+
body: JSON.stringify({ node_execution_id }),
|
|
513
|
+
})
|
|
514
|
+
} catch (claimErr) {
|
|
515
|
+
if (claimErr.statusCode === 409) {
|
|
516
|
+
console.log(`[DagPoller] Script node ${node_id} already claimed, skipping`)
|
|
517
|
+
return
|
|
518
|
+
}
|
|
519
|
+
throw claimErr
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
if (!script_source || !script_source.trim()) {
|
|
523
|
+
await reportNodeComplete(node_execution_id, 'failed', null, 'Script node has empty script_source')
|
|
524
|
+
return
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
let interpreter
|
|
528
|
+
if (script_runtime === 'python') {
|
|
529
|
+
interpreter = 'python3'
|
|
530
|
+
} else if (script_runtime === 'node') {
|
|
531
|
+
interpreter = 'node'
|
|
532
|
+
} else {
|
|
533
|
+
await reportNodeComplete(
|
|
534
|
+
node_execution_id,
|
|
535
|
+
'failed',
|
|
536
|
+
null,
|
|
537
|
+
`Unsupported script_runtime: ${script_runtime} (allowed: python, node)`,
|
|
538
|
+
)
|
|
539
|
+
return
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
const timeoutMs = clampTimeout(script_timeout_seconds) * 1000
|
|
543
|
+
|
|
544
|
+
const result = await runScriptProcess(interpreter, script_source, input_data || {}, timeoutMs)
|
|
545
|
+
|
|
546
|
+
if (result.timedOut) {
|
|
547
|
+
await reportNodeComplete(
|
|
548
|
+
node_execution_id,
|
|
549
|
+
'failed',
|
|
550
|
+
null,
|
|
551
|
+
`Script timed out after ${timeoutMs / 1000}s. stderr: ${truncate(result.stderr, 2000)}`,
|
|
552
|
+
)
|
|
553
|
+
return
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
if (result.exitCode !== 0) {
|
|
557
|
+
await reportNodeComplete(
|
|
558
|
+
node_execution_id,
|
|
559
|
+
'failed',
|
|
560
|
+
null,
|
|
561
|
+
`Script exited with code ${result.exitCode}. stderr: ${truncate(result.stderr, 2000)}`,
|
|
562
|
+
)
|
|
563
|
+
return
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
let outputData
|
|
567
|
+
try {
|
|
568
|
+
outputData = JSON.parse(result.stdout)
|
|
569
|
+
} catch (parseErr) {
|
|
570
|
+
await reportNodeComplete(
|
|
571
|
+
node_execution_id,
|
|
572
|
+
'failed',
|
|
573
|
+
null,
|
|
574
|
+
`Script stdout is not valid JSON: ${parseErr.message}. stdout (first 500 chars): ${truncate(result.stdout, 500)}`,
|
|
575
|
+
)
|
|
576
|
+
return
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
if (typeof outputData !== 'object' || outputData === null || Array.isArray(outputData)) {
|
|
580
|
+
await reportNodeComplete(
|
|
581
|
+
node_execution_id,
|
|
582
|
+
'failed',
|
|
583
|
+
null,
|
|
584
|
+
`Script output_data must be a JSON object (got ${Array.isArray(outputData) ? 'array' : typeof outputData})`,
|
|
585
|
+
)
|
|
586
|
+
return
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
await reportNodeComplete(
|
|
590
|
+
node_execution_id,
|
|
591
|
+
'completed',
|
|
592
|
+
outputData,
|
|
593
|
+
buildScriptSummary(outputData, result.stderr),
|
|
594
|
+
)
|
|
595
|
+
console.log(`[DagPoller] Script node "${node_id}" completed`)
|
|
596
|
+
|
|
597
|
+
} catch (err) {
|
|
598
|
+
console.error(`[DagPoller] Failed to execute script node ${node_id}: ${err.message}`)
|
|
599
|
+
try {
|
|
600
|
+
await reportNodeComplete(node_execution_id, 'failed', null, err.message)
|
|
601
|
+
} catch {
|
|
602
|
+
// best-effort
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* Build the output_summary text shown in the HQ execution detail UI for a
|
|
609
|
+
* successful script node. The UI renders output_summary as a collapsible
|
|
610
|
+
* code block under "Output"; output_data itself is only visible from the
|
|
611
|
+
* Pin-fixture dialog. Always emit at least the JSON output so users can
|
|
612
|
+
* inspect what the script returned without round-tripping through fixtures.
|
|
613
|
+
*/
|
|
614
|
+
function buildScriptSummary(outputData, stderr) {
|
|
615
|
+
const lines = []
|
|
616
|
+
let serialized
|
|
617
|
+
try {
|
|
618
|
+
serialized = JSON.stringify(outputData, null, 2)
|
|
619
|
+
} catch {
|
|
620
|
+
serialized = '(failed to serialize output_data)'
|
|
621
|
+
}
|
|
622
|
+
lines.push('output_data:')
|
|
623
|
+
lines.push('```json')
|
|
624
|
+
lines.push(truncate(serialized, 8000))
|
|
625
|
+
lines.push('```')
|
|
626
|
+
if (stderr && stderr.trim()) {
|
|
627
|
+
lines.push('')
|
|
628
|
+
lines.push('stderr:')
|
|
629
|
+
lines.push(truncate(stderr, 2000))
|
|
630
|
+
}
|
|
631
|
+
return lines.join('\n')
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function clampTimeout(seconds) {
|
|
635
|
+
const n = typeof seconds === 'number' && Number.isFinite(seconds) ? seconds : 60
|
|
636
|
+
if (n < 1) return 1
|
|
637
|
+
if (n > 600) return 600
|
|
638
|
+
return Math.floor(n)
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function truncate(str, max) {
|
|
642
|
+
if (!str) return ''
|
|
643
|
+
return str.length > max ? str.slice(0, max) + '…(truncated)' : str
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* Spawn the interpreter, write the script to stdin via `-` / `-c` is not
|
|
648
|
+
* portable (python requires `-`, node requires `-e` with the source as an
|
|
649
|
+
* argument and no stdin). Instead we feed the script via stdin for both:
|
|
650
|
+
* - python3 reads source from stdin when invoked with `-`
|
|
651
|
+
* - node reads source from stdin when invoked with no args, but stdin is
|
|
652
|
+
* then consumed by the script — so for node we must use `-e` with the
|
|
653
|
+
* source as a flag value. To keep input_data on stdin for both, we
|
|
654
|
+
* write the script to a temp file and pass the path as argv[1].
|
|
655
|
+
*/
|
|
656
|
+
function runScriptProcess(interpreter, source, inputData, timeoutMs) {
|
|
657
|
+
const { spawn } = require('child_process')
|
|
658
|
+
const fs = require('fs')
|
|
659
|
+
const os = require('os')
|
|
660
|
+
const path = require('path')
|
|
661
|
+
const crypto = require('crypto')
|
|
662
|
+
|
|
663
|
+
const ext = interpreter === 'python3' ? 'py' : 'js'
|
|
664
|
+
const tmpFile = path.join(
|
|
665
|
+
os.tmpdir(),
|
|
666
|
+
`dag-script-${crypto.randomBytes(8).toString('hex')}.${ext}`,
|
|
667
|
+
)
|
|
668
|
+
fs.writeFileSync(tmpFile, source, 'utf-8')
|
|
669
|
+
|
|
670
|
+
return new Promise(resolve => {
|
|
671
|
+
const child = spawn(interpreter, [tmpFile], {
|
|
672
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
673
|
+
})
|
|
674
|
+
|
|
675
|
+
let stdout = ''
|
|
676
|
+
let stderr = ''
|
|
677
|
+
let timedOut = false
|
|
678
|
+
let killed = false
|
|
679
|
+
|
|
680
|
+
const timer = setTimeout(() => {
|
|
681
|
+
timedOut = true
|
|
682
|
+
killed = true
|
|
683
|
+
try { child.kill('SIGKILL') } catch { /* noop */ }
|
|
684
|
+
}, timeoutMs)
|
|
685
|
+
|
|
686
|
+
child.stdout.on('data', chunk => { stdout += chunk.toString('utf-8') })
|
|
687
|
+
child.stderr.on('data', chunk => { stderr += chunk.toString('utf-8') })
|
|
688
|
+
|
|
689
|
+
child.on('error', err => {
|
|
690
|
+
clearTimeout(timer)
|
|
691
|
+
cleanup(tmpFile)
|
|
692
|
+
resolve({ exitCode: -1, stdout, stderr: stderr + `\nspawn error: ${err.message}`, timedOut })
|
|
693
|
+
})
|
|
694
|
+
|
|
695
|
+
child.on('close', code => {
|
|
696
|
+
clearTimeout(timer)
|
|
697
|
+
cleanup(tmpFile)
|
|
698
|
+
resolve({ exitCode: killed ? -1 : (code ?? -1), stdout, stderr, timedOut })
|
|
699
|
+
})
|
|
700
|
+
|
|
701
|
+
try {
|
|
702
|
+
child.stdin.write(JSON.stringify(inputData))
|
|
703
|
+
child.stdin.end()
|
|
704
|
+
} catch (writeErr) {
|
|
705
|
+
stderr += `\nstdin write error: ${writeErr.message}`
|
|
706
|
+
}
|
|
707
|
+
})
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
function cleanup(tmpFile) {
|
|
711
|
+
try { require('fs').unlinkSync(tmpFile) } catch { /* noop */ }
|
|
712
|
+
}
|
|
713
|
+
|
|
470
714
|
/**
|
|
471
715
|
* Resolve skill_version_id to skill name via HQ API.
|
|
472
716
|
*/
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session environment builder
|
|
3
|
+
*
|
|
4
|
+
* Builds workspace-scoped environment variable injection for runners.
|
|
5
|
+
* Resolves minion-wide + workspace-scoped secrets, and exposes both
|
|
6
|
+
* `tmux -e` flags (Linux/macOS) and a node-pty env object (Windows).
|
|
7
|
+
*
|
|
8
|
+
* Why this exists:
|
|
9
|
+
* Before this module, all secrets were loaded once into `process.env`
|
|
10
|
+
* at server start and inherited by every child process — fine for a
|
|
11
|
+
* single-workspace minion but a leakage path for multi-workspace
|
|
12
|
+
* setups. Each runner now passes the effective env explicitly for the
|
|
13
|
+
* workspace it is running on; workspace-scoped values override
|
|
14
|
+
* minion-wide ones at this layer.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const variableStore = require('../stores/variable-store')
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Shell-escape a value for a `KEY=VALUE` argument passed via `-e` to tmux.
|
|
21
|
+
* We use single-quote wrapping; the surrounding shell sees one literal arg.
|
|
22
|
+
* @param {string} s
|
|
23
|
+
* @returns {string}
|
|
24
|
+
*/
|
|
25
|
+
function singleQuote(s) {
|
|
26
|
+
return "'" + String(s).replace(/'/g, "'\\''") + "'"
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Build an array of `-e 'KEY=VALUE'` shell-safe flags for tmux new-session.
|
|
31
|
+
*
|
|
32
|
+
* @param {string|null|undefined} workspaceId - The session's workspace scope
|
|
33
|
+
* @param {Record<string,string>} [extraEnv] - Per-execution identifiers (e.g.
|
|
34
|
+
* MINION_EXECUTION_ID). Layered on top so they always win.
|
|
35
|
+
* @returns {string[]} List of `-e <quoted>` fragments to splice into a shell command
|
|
36
|
+
*/
|
|
37
|
+
function buildTmuxEnvFlags(workspaceId, extraEnv = {}) {
|
|
38
|
+
const merged = { ...variableStore.getEffectiveSecrets(workspaceId), ...extraEnv }
|
|
39
|
+
const flags = []
|
|
40
|
+
for (const [k, v] of Object.entries(merged)) {
|
|
41
|
+
if (v == null) continue
|
|
42
|
+
flags.push(`-e ${singleQuote(`${k}=${v}`)}`)
|
|
43
|
+
}
|
|
44
|
+
return flags
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Build a `tmux new-session` invocation line including all secret env vars.
|
|
49
|
+
*
|
|
50
|
+
* Caller typically does:
|
|
51
|
+
* `${buildTmuxNewSessionCommand({sessionName, workspaceId, extraEnv, width, height})}`
|
|
52
|
+
*
|
|
53
|
+
* The session is created detached (`-d`) with no command so we can configure
|
|
54
|
+
* `remain-on-exit` and `pipe-pane` before injecting the real workload via
|
|
55
|
+
* `send-keys`.
|
|
56
|
+
*
|
|
57
|
+
* @param {object} opts
|
|
58
|
+
* @param {string} opts.sessionName
|
|
59
|
+
* @param {string|null|undefined} opts.workspaceId
|
|
60
|
+
* @param {Record<string,string>} [opts.extraEnv]
|
|
61
|
+
* @param {number} [opts.width]
|
|
62
|
+
* @param {number} [opts.height]
|
|
63
|
+
* @returns {string}
|
|
64
|
+
*/
|
|
65
|
+
function buildTmuxNewSessionCommand({ sessionName, workspaceId, extraEnv = {}, width = 200, height = 50 }) {
|
|
66
|
+
const envFlags = buildTmuxEnvFlags(workspaceId, extraEnv)
|
|
67
|
+
return [
|
|
68
|
+
'tmux new-session -d',
|
|
69
|
+
`-s "${sessionName}"`,
|
|
70
|
+
`-x ${width} -y ${height}`,
|
|
71
|
+
...envFlags,
|
|
72
|
+
].join(' ')
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Build an env object for node-pty / child_process.spawn that contains the
|
|
77
|
+
* caller's `process.env` plus the workspace-effective secrets and any extra
|
|
78
|
+
* per-execution identifiers (extraEnv wins last).
|
|
79
|
+
*
|
|
80
|
+
* @param {string|null|undefined} workspaceId
|
|
81
|
+
* @param {Record<string,string>} [extraEnv]
|
|
82
|
+
* @returns {Record<string,string>}
|
|
83
|
+
*/
|
|
84
|
+
function buildSpawnEnv(workspaceId, extraEnv = {}) {
|
|
85
|
+
return {
|
|
86
|
+
...process.env,
|
|
87
|
+
...variableStore.getEffectiveSecrets(workspaceId),
|
|
88
|
+
...extraEnv,
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
module.exports = {
|
|
93
|
+
buildTmuxEnvFlags,
|
|
94
|
+
buildTmuxNewSessionCommand,
|
|
95
|
+
buildSpawnEnv,
|
|
96
|
+
}
|
package/core/lib/step-poller.js
CHANGED
|
@@ -104,6 +104,7 @@ async function executeStep(step) {
|
|
|
104
104
|
skill_name,
|
|
105
105
|
revision_feedback,
|
|
106
106
|
template_vars,
|
|
107
|
+
workspace_id,
|
|
107
108
|
} = step
|
|
108
109
|
|
|
109
110
|
console.log(
|
|
@@ -135,11 +136,15 @@ async function executeStep(step) {
|
|
|
135
136
|
|
|
136
137
|
// 2. Fetch the skill from HQ to ensure it's deployed locally
|
|
137
138
|
// Merge minion variables into template_vars so HQ expands {{VAR_NAME}} in SKILL.md.
|
|
138
|
-
//
|
|
139
|
+
// Resolution order: workspace < project < workflow < minion (minion wins).
|
|
140
|
+
// template_vars already merges workspace/project/workflow on HQ side; we
|
|
141
|
+
// layer effective minion vars (minion-wide ∪ WS-scoped) on top so any
|
|
142
|
+
// conflicting key resolves to the minion-local value before HQ rewrites
|
|
143
|
+
// the SKILL.md.
|
|
139
144
|
if (skill_name) {
|
|
140
145
|
try {
|
|
141
|
-
const minionVars = variableStore.
|
|
142
|
-
const mergedVars = { ...
|
|
146
|
+
const minionVars = variableStore.getEffectiveVariables(workspace_id || '')
|
|
147
|
+
const mergedVars = { ...(template_vars || {}), ...minionVars }
|
|
143
148
|
const varsParam = Object.keys(mergedVars).length > 0
|
|
144
149
|
? `?vars=${Buffer.from(JSON.stringify(mergedVars)).toString('base64')}`
|
|
145
150
|
: ''
|
|
@@ -32,17 +32,31 @@ function expandTemplateVars(content, vars) {
|
|
|
32
32
|
|
|
33
33
|
/**
|
|
34
34
|
* Expand templates in SKILL.md files for the given skill names.
|
|
35
|
-
* Reads each SKILL.md, expands {{VAR}} with
|
|
35
|
+
* Reads each SKILL.md, expands {{VAR}} with the merged variable set, writes back.
|
|
36
36
|
* Returns the original contents for restoration after execution.
|
|
37
37
|
*
|
|
38
|
+
* Resolution order (later entries override earlier ones):
|
|
39
|
+
* extraVars (e.g., HQ workspace vars passed by the caller)
|
|
40
|
+
* < minion-wide variables
|
|
41
|
+
* < workspace-scoped minion variables for the given workspaceId
|
|
42
|
+
*
|
|
43
|
+
* The minion-local layer always wins at this stage, which mirrors the
|
|
44
|
+
* cross-scope rule (workspace < project < workflow < minion). For workflow
|
|
45
|
+
* execution, project/workflow scopes are pre-expanded by HQ before the
|
|
46
|
+
* SKILL.md reaches the minion; for routine execution the caller can pass
|
|
47
|
+
* workspace-scoped HQ vars via `extraVars`.
|
|
48
|
+
*
|
|
38
49
|
* @param {string[]} skillNames - Skill slugs to expand
|
|
50
|
+
* @param {Record<string, string>} [extraVars] - Additional vars (lowest priority of the three layers)
|
|
51
|
+
* @param {string|null|undefined} [workspaceId] - Workspace context for resolving minion variables (defaults to minion-wide only)
|
|
39
52
|
* @returns {Promise<Map<string, string>>} Map of skillName -> original content (for restoration)
|
|
40
53
|
*/
|
|
41
|
-
async function expandSkillTemplates(skillNames) {
|
|
42
|
-
const minionVars = variableStore.
|
|
54
|
+
async function expandSkillTemplates(skillNames, extraVars = {}, workspaceId = '') {
|
|
55
|
+
const minionVars = variableStore.getEffectiveVariables(workspaceId)
|
|
56
|
+
const mergedVars = { ...extraVars, ...minionVars }
|
|
43
57
|
|
|
44
|
-
// If
|
|
45
|
-
if (Object.keys(
|
|
58
|
+
// If nothing to expand, skip
|
|
59
|
+
if (Object.keys(mergedVars).length === 0) return new Map()
|
|
46
60
|
|
|
47
61
|
// originals key: `${root}::${name}` so we restore the right copy in each
|
|
48
62
|
// plugin's skill dir (multi-plugin distribution).
|
|
@@ -55,7 +69,7 @@ async function expandSkillTemplates(skillNames) {
|
|
|
55
69
|
const skillMdPath = path.join(root, name, 'SKILL.md')
|
|
56
70
|
try {
|
|
57
71
|
const original = await fs.readFile(skillMdPath, 'utf-8')
|
|
58
|
-
const expanded = expandTemplateVars(original,
|
|
72
|
+
const expanded = expandTemplateVars(original, mergedVars)
|
|
59
73
|
if (expanded !== original) {
|
|
60
74
|
originals.set(`${root}::${name}`, { skillMdPath, original })
|
|
61
75
|
await fs.writeFile(skillMdPath, expanded, 'utf-8')
|
package/core/routes/variables.js
CHANGED
|
@@ -32,14 +32,26 @@ function isValidValue(value) {
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
function variableRoutes(fastify, _opts, done) {
|
|
35
|
-
|
|
35
|
+
function readWorkspaceId(request) {
|
|
36
|
+
const rawWs = request.query?.workspace_id
|
|
37
|
+
return (typeof rawWs === 'string') ? rawWs : ''
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ─── Variables (non-sensitive, workspace-scoped) ──────────────────────
|
|
41
|
+
//
|
|
42
|
+
// Variables are scoped per workspace just like secrets. Pass
|
|
43
|
+
// ?workspace_id=<uuid> to target a specific workspace's bucket; omit it
|
|
44
|
+
// (or pass '') for the minion-wide bucket. At skill execution time the
|
|
45
|
+
// template-expander merges minion-wide + current-workspace variables, with
|
|
46
|
+
// WS-scoped values overriding minion-wide ones.
|
|
36
47
|
|
|
37
48
|
fastify.get('/api/variables', async (request, reply) => {
|
|
38
49
|
if (!verifyToken(request)) {
|
|
39
50
|
return reply.code(401).send({ error: 'Unauthorized' })
|
|
40
51
|
}
|
|
41
|
-
const
|
|
42
|
-
|
|
52
|
+
const workspaceId = readWorkspaceId(request)
|
|
53
|
+
const variables = variableStore.getVariablesForScope(workspaceId)
|
|
54
|
+
return { success: true, workspace_id: workspaceId, variables }
|
|
43
55
|
})
|
|
44
56
|
|
|
45
57
|
fastify.get('/api/variables/:key', async (request, reply) => {
|
|
@@ -47,11 +59,12 @@ function variableRoutes(fastify, _opts, done) {
|
|
|
47
59
|
return reply.code(401).send({ error: 'Unauthorized' })
|
|
48
60
|
}
|
|
49
61
|
const { key } = request.params
|
|
50
|
-
const
|
|
62
|
+
const workspaceId = readWorkspaceId(request)
|
|
63
|
+
const value = variableStore.getVariable(workspaceId, key)
|
|
51
64
|
if (value === null) {
|
|
52
65
|
return reply.code(404).send({ error: `Variable not found: ${key}` })
|
|
53
66
|
}
|
|
54
|
-
return { success: true, key, value }
|
|
67
|
+
return { success: true, key, value, workspace_id: workspaceId }
|
|
55
68
|
})
|
|
56
69
|
|
|
57
70
|
fastify.put('/api/variables/:key', async (request, reply) => {
|
|
@@ -60,6 +73,7 @@ function variableRoutes(fastify, _opts, done) {
|
|
|
60
73
|
}
|
|
61
74
|
const { key } = request.params
|
|
62
75
|
const { value } = request.body || {}
|
|
76
|
+
const workspaceId = readWorkspaceId(request)
|
|
63
77
|
|
|
64
78
|
if (!isValidKey(key)) {
|
|
65
79
|
return reply.code(400).send({ error: 'Invalid key. Use alphanumeric characters and underscores (1-100 chars).' })
|
|
@@ -68,8 +82,8 @@ function variableRoutes(fastify, _opts, done) {
|
|
|
68
82
|
return reply.code(400).send({ error: 'Invalid value. Must be a string, no newlines, max 2000 chars.' })
|
|
69
83
|
}
|
|
70
84
|
|
|
71
|
-
variableStore.
|
|
72
|
-
return { success: true, key, value }
|
|
85
|
+
variableStore.setVariable(workspaceId, key, value)
|
|
86
|
+
return { success: true, key, value, workspace_id: workspaceId }
|
|
73
87
|
})
|
|
74
88
|
|
|
75
89
|
fastify.delete('/api/variables/:key', async (request, reply) => {
|
|
@@ -77,22 +91,45 @@ function variableRoutes(fastify, _opts, done) {
|
|
|
77
91
|
return reply.code(401).send({ error: 'Unauthorized' })
|
|
78
92
|
}
|
|
79
93
|
const { key } = request.params
|
|
80
|
-
const
|
|
94
|
+
const workspaceId = readWorkspaceId(request)
|
|
95
|
+
const removed = variableStore.removeVariable(workspaceId, key)
|
|
81
96
|
if (!removed) {
|
|
82
97
|
return reply.code(404).send({ error: `Variable not found: ${key}` })
|
|
83
98
|
}
|
|
84
|
-
return { success: true, key }
|
|
99
|
+
return { success: true, key, workspace_id: workspaceId }
|
|
85
100
|
})
|
|
86
101
|
|
|
87
|
-
//
|
|
102
|
+
// List the workspace scopes (workspace_ids) that currently hold any
|
|
103
|
+
// variable. '' represents the minion-wide bucket.
|
|
104
|
+
fastify.get('/api/variables/scopes', async (request, reply) => {
|
|
105
|
+
if (!verifyToken(request)) {
|
|
106
|
+
return reply.code(401).send({ error: 'Unauthorized' })
|
|
107
|
+
}
|
|
108
|
+
return { success: true, scopes: variableStore.listVariableScopes() }
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
// ─── Secrets (sensitive, workspace-scoped) ────────────────────────────
|
|
112
|
+
//
|
|
113
|
+
// Secrets are scoped per workspace. Pass ?workspace_id=<uuid> to target a
|
|
114
|
+
// specific workspace's bucket; omit it (or pass '') for the minion-wide
|
|
115
|
+
// bucket. At skill execution time, the runner merges minion-wide +
|
|
116
|
+
// current-workspace secrets, with WS-scoped values overriding.
|
|
117
|
+
//
|
|
118
|
+
// Values are never returned via the API by design — only key names. Secrets
|
|
119
|
+
// never leave the minion: the HQ proxy is a pure pass-through.
|
|
120
|
+
|
|
121
|
+
function readWorkspaceId(request) {
|
|
122
|
+
const rawWs = request.query?.workspace_id
|
|
123
|
+
return (typeof rawWs === 'string') ? rawWs : ''
|
|
124
|
+
}
|
|
88
125
|
|
|
89
126
|
fastify.get('/api/secrets', async (request, reply) => {
|
|
90
127
|
if (!verifyToken(request)) {
|
|
91
128
|
return reply.code(401).send({ error: 'Unauthorized' })
|
|
92
129
|
}
|
|
93
|
-
|
|
94
|
-
const keys = variableStore.
|
|
95
|
-
return { success: true, keys }
|
|
130
|
+
const workspaceId = readWorkspaceId(request)
|
|
131
|
+
const keys = variableStore.listSecretKeys(workspaceId)
|
|
132
|
+
return { success: true, workspace_id: workspaceId, keys }
|
|
96
133
|
})
|
|
97
134
|
|
|
98
135
|
fastify.put('/api/secrets/:key', async (request, reply) => {
|
|
@@ -101,6 +138,7 @@ function variableRoutes(fastify, _opts, done) {
|
|
|
101
138
|
}
|
|
102
139
|
const { key } = request.params
|
|
103
140
|
const { value } = request.body || {}
|
|
141
|
+
const workspaceId = readWorkspaceId(request)
|
|
104
142
|
|
|
105
143
|
if (!isValidKey(key)) {
|
|
106
144
|
return reply.code(400).send({ error: 'Invalid key. Use alphanumeric characters and underscores (1-100 chars).' })
|
|
@@ -109,8 +147,8 @@ function variableRoutes(fastify, _opts, done) {
|
|
|
109
147
|
return reply.code(400).send({ error: 'Invalid value. Must be a string, no newlines, max 2000 chars.' })
|
|
110
148
|
}
|
|
111
149
|
|
|
112
|
-
variableStore.
|
|
113
|
-
return { success: true, key }
|
|
150
|
+
variableStore.setSecret(workspaceId, key, value)
|
|
151
|
+
return { success: true, key, workspace_id: workspaceId }
|
|
114
152
|
})
|
|
115
153
|
|
|
116
154
|
fastify.delete('/api/secrets/:key', async (request, reply) => {
|
|
@@ -118,11 +156,23 @@ function variableRoutes(fastify, _opts, done) {
|
|
|
118
156
|
return reply.code(401).send({ error: 'Unauthorized' })
|
|
119
157
|
}
|
|
120
158
|
const { key } = request.params
|
|
121
|
-
const
|
|
159
|
+
const workspaceId = readWorkspaceId(request)
|
|
160
|
+
const removed = variableStore.removeSecret(workspaceId, key)
|
|
122
161
|
if (!removed) {
|
|
123
162
|
return reply.code(404).send({ error: `Secret not found: ${key}` })
|
|
124
163
|
}
|
|
125
|
-
return { success: true, key }
|
|
164
|
+
return { success: true, key, workspace_id: workspaceId }
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
// List the scopes (workspace_ids) that currently hold any secret.
|
|
168
|
+
// '' represents the minion-wide bucket. Useful for the dashboard secrets
|
|
169
|
+
// editor to populate the workspace selector even when a workspace has no
|
|
170
|
+
// active minion membership yet.
|
|
171
|
+
fastify.get('/api/secrets/scopes', async (request, reply) => {
|
|
172
|
+
if (!verifyToken(request)) {
|
|
173
|
+
return reply.code(401).send({ error: 'Unauthorized' })
|
|
174
|
+
}
|
|
175
|
+
return { success: true, scopes: variableStore.listSecretScopes() }
|
|
126
176
|
})
|
|
127
177
|
|
|
128
178
|
done()
|