@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.
@@ -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 or transform execution based on node_type.
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
+ }
@@ -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
- // Merge order: minion vars < project vars < workflow vars (template_vars already merged by HQ)
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.getAll('variables')
142
- const mergedVars = { ...minionVars, ...(template_vars || {}) }
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 minion variables, writes back.
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.getAll('variables')
54
+ async function expandSkillTemplates(skillNames, extraVars = {}, workspaceId = '') {
55
+ const minionVars = variableStore.getEffectiveVariables(workspaceId)
56
+ const mergedVars = { ...extraVars, ...minionVars }
43
57
 
44
- // If no variables defined, skip
45
- if (Object.keys(minionVars).length === 0) return new Map()
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, minionVars)
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')
@@ -32,14 +32,26 @@ function isValidValue(value) {
32
32
  }
33
33
 
34
34
  function variableRoutes(fastify, _opts, done) {
35
- // ─── Variables (non-sensitive) ───────────────────────────────────────
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 variables = variableStore.getAll('variables')
42
- return { success: true, variables }
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 value = variableStore.get('variables', key)
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.set('variables', key, value)
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 removed = variableStore.remove('variables', key)
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
- // ─── Secrets (sensitive) ─────────────────────────────────────────────
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
- // Return keys only — never expose secret values via API
94
- const keys = variableStore.listKeys('secrets')
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.set('secrets', key, value)
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 removed = variableStore.remove('secrets', key)
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()