@geekbeer/minion 3.55.1 → 3.58.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
+ }
@@ -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()