@geekbeer/minion 3.55.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
+ }
@@ -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()
@@ -1,34 +1,69 @@
1
1
  /**
2
2
  * Variable Store
3
3
  *
4
- * Manages minion-local secrets and variables stored in .env-style files.
5
- * - Secrets: ~/.minion/.env.secrets (or DATA_DIR/.env.secrets)
6
- * - Variables: ~/.minion/.env.variables (or DATA_DIR/.env.variables)
4
+ * Manages minion-local variables and secrets both workspace-scoped.
7
5
  *
8
- * Files use standard .env format: KEY=value (one per line, # for comments).
9
- * Secrets never leave the minion; variables are non-sensitive configuration.
6
+ * Storage: SQLite tables `variables(workspace_id, key, value, updated_at)`
7
+ * and `secrets(workspace_id, key, value, updated_at)`. `workspace_id=''` is
8
+ * the minion-wide bucket; any other value is a workspace-specific bucket.
9
+ *
10
+ * At skill execution time the runner / template-expander merges
11
+ * minion-wide + current-workspace entries for the relevant scope, with the
12
+ * WS-scoped value winning on conflict.
13
+ *
14
+ * Migration: on first read after the SQLite tables are created, any legacy
15
+ * `.env.variables` / `.env.secrets` flat files are imported into the
16
+ * minion-wide bucket and renamed to `.env.variables.migrated` /
17
+ * `.env.secrets.migrated` (kept as one-time backups).
18
+ *
19
+ * Secrets never leave the minion and are never persisted in HQ DB. Variables
20
+ * are non-sensitive and may also be defined at HQ scopes (workspace /
21
+ * project / workflow); see packages/docs-internal/src/content/docs/design/
22
+ * variables-and-secrets.md for the full resolution model.
10
23
  */
11
24
 
12
25
  const fs = require('fs')
13
26
  const path = require('path')
14
27
  const { DATA_DIR } = require('../lib/platform')
15
28
  const { config } = require('../config')
29
+ const { getDb } = require('../db')
16
30
 
17
31
  /**
18
- * Resolve file path for a given store type.
19
- * @param {'secrets' | 'variables'} type
32
+ * Resolve the legacy `.env.variables` path (for one-time migration).
20
33
  * @returns {string}
21
34
  */
22
- function getFilePath(type) {
23
- const filename = type === 'secrets' ? '.env.secrets' : '.env.variables'
35
+ function getLegacyVariablesFilePath() {
24
36
  try {
25
37
  fs.accessSync(DATA_DIR, fs.constants.W_OK)
26
- return path.join(DATA_DIR, filename)
38
+ return path.join(DATA_DIR, '.env.variables')
27
39
  } catch {
28
- return path.join(config.HOME_DIR, '.minion', filename)
40
+ return path.join(config.HOME_DIR, '.minion', '.env.variables')
29
41
  }
30
42
  }
31
43
 
44
+ /**
45
+ * Resolve the legacy `.env.secrets` path (for one-time migration).
46
+ * @returns {string}
47
+ */
48
+ function getLegacySecretsFilePath() {
49
+ try {
50
+ fs.accessSync(DATA_DIR, fs.constants.W_OK)
51
+ return path.join(DATA_DIR, '.env.secrets')
52
+ } catch {
53
+ return path.join(config.HOME_DIR, '.minion', '.env.secrets')
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Normalize a workspace identifier to its canonical string form.
59
+ * '' (empty string) represents the minion-wide bucket.
60
+ * @param {string|null|undefined} workspaceId
61
+ * @returns {string}
62
+ */
63
+ function normalizeWorkspaceId(workspaceId) {
64
+ return workspaceId == null ? '' : String(workspaceId)
65
+ }
66
+
32
67
  /**
33
68
  * Parse a .env file into a key-value object.
34
69
  * @param {string} filePath
@@ -54,105 +89,272 @@ function parseEnvFile(filePath) {
54
89
  return result
55
90
  }
56
91
 
92
+ // ─── Legacy flat-file → SQLite migration ────────────────────────────────────
93
+
94
+ let _legacyVariablesMigrationDone = false
95
+ let _legacySecretsMigrationDone = false
96
+
97
+ function migrateLegacyFile(legacyPath, table, doneFlagRef) {
98
+ if (doneFlagRef.done) return
99
+ doneFlagRef.done = true
100
+
101
+ let entries
102
+ try {
103
+ entries = parseEnvFile(legacyPath)
104
+ } catch {
105
+ return
106
+ }
107
+ const keys = Object.keys(entries)
108
+ if (keys.length === 0) return
109
+
110
+ const db = getDb()
111
+ const now = Date.now()
112
+ const insert = db.prepare(
113
+ `INSERT OR IGNORE INTO ${table} (workspace_id, key, value, updated_at) VALUES (?, ?, ?, ?)`
114
+ )
115
+ const tx = db.transaction(() => {
116
+ for (const k of keys) {
117
+ insert.run('', k, String(entries[k] ?? ''), now)
118
+ }
119
+ })
120
+ tx()
121
+
122
+ try {
123
+ fs.renameSync(legacyPath, `${legacyPath}.migrated`)
124
+ console.log(`[VariableStore] Migrated ${keys.length} legacy ${table} entry/ies from ${legacyPath} into SQLite (minion-wide scope)`)
125
+ } catch (err) {
126
+ console.error(`[VariableStore] Migrated ${table} to SQLite but failed to rename ${legacyPath}: ${err.message}`)
127
+ }
128
+ }
129
+
130
+ const _variablesFlag = { done: false }
131
+ const _secretsFlag = { done: false }
132
+
133
+ function migrateLegacyVariablesFile() {
134
+ migrateLegacyFile(getLegacyVariablesFilePath(), 'variables', _variablesFlag)
135
+ }
136
+
137
+ function migrateLegacySecretsFile() {
138
+ migrateLegacyFile(getLegacySecretsFilePath(), 'secrets', _secretsFlag)
139
+ }
140
+
141
+ // ─── Generic table operations (variables and secrets share the shape) ───────
142
+
143
+ function getRowsForScope(table, workspaceId) {
144
+ const wsId = normalizeWorkspaceId(workspaceId)
145
+ const db = getDb()
146
+ return db.prepare(`SELECT key, value FROM ${table} WHERE workspace_id = ? ORDER BY key`).all(wsId)
147
+ }
148
+
149
+ function getScopeAsObject(table, workspaceId) {
150
+ const rows = getRowsForScope(table, workspaceId)
151
+ const out = {}
152
+ for (const r of rows) out[r.key] = r.value
153
+ return out
154
+ }
155
+
156
+ function getEffective(table, workspaceId) {
157
+ const wide = getScopeAsObject(table, '')
158
+ if (!workspaceId) return wide
159
+ const scoped = getScopeAsObject(table, workspaceId)
160
+ return { ...wide, ...scoped }
161
+ }
162
+
163
+ function upsertEntry(table, workspaceId, key, value) {
164
+ const wsId = normalizeWorkspaceId(workspaceId)
165
+ const db = getDb()
166
+ db.prepare(`
167
+ INSERT INTO ${table} (workspace_id, key, value, updated_at) VALUES (?, ?, ?, ?)
168
+ ON CONFLICT(workspace_id, key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
169
+ `).run(wsId, key, String(value ?? ''), Date.now())
170
+ return wsId
171
+ }
172
+
173
+ function deleteEntry(table, workspaceId, key) {
174
+ const wsId = normalizeWorkspaceId(workspaceId)
175
+ const db = getDb()
176
+ const info = db.prepare(`DELETE FROM ${table} WHERE workspace_id = ? AND key = ?`).run(wsId, key)
177
+ return { removed: info.changes > 0, wsId }
178
+ }
179
+
180
+ function listScopes(table) {
181
+ const db = getDb()
182
+ const rows = db.prepare(`SELECT DISTINCT workspace_id FROM ${table}`).all()
183
+ return rows.map(r => r.workspace_id)
184
+ }
185
+
186
+ // ─── Variables (workspace-scoped) ────────────────────────────────────────────
187
+
188
+ function getVariablesForScope(workspaceId) {
189
+ migrateLegacyVariablesFile()
190
+ return getScopeAsObject('variables', workspaceId)
191
+ }
192
+
57
193
  /**
58
- * Write a key-value object to a .env file.
59
- * @param {string} filePath
60
- * @param {Record<string, string>} data
194
+ * Merge minion-wide and workspace-scoped variables. Workspace-scoped values
195
+ * override minion-wide values when the key is the same.
61
196
  */
62
- function writeEnvFile(filePath, data) {
63
- const dir = path.dirname(filePath)
64
- fs.mkdirSync(dir, { recursive: true })
197
+ function getEffectiveVariables(workspaceId) {
198
+ migrateLegacyVariablesFile()
199
+ return getEffective('variables', workspaceId)
200
+ }
201
+
202
+ function getVariable(workspaceId, key) {
203
+ const all = getVariablesForScope(workspaceId)
204
+ return all[key] ?? null
205
+ }
65
206
 
66
- const lines = Object.entries(data).map(([key, value]) => `${key}=${value}`)
67
- fs.writeFileSync(filePath, lines.join('\n') + '\n', 'utf-8')
207
+ function setVariable(workspaceId, key, value) {
208
+ migrateLegacyVariablesFile()
209
+ const wsId = upsertEntry('variables', workspaceId, key, value)
210
+ console.log(`[VariableStore] Set variable (scope=${wsId || 'minion-wide'}): ${key}`)
211
+ }
212
+
213
+ function removeVariable(workspaceId, key) {
214
+ migrateLegacyVariablesFile()
215
+ const { removed, wsId } = deleteEntry('variables', workspaceId, key)
216
+ if (removed) {
217
+ console.log(`[VariableStore] Removed variable (scope=${wsId || 'minion-wide'}): ${key}`)
218
+ }
219
+ return removed
220
+ }
221
+
222
+ function listVariableKeys(workspaceId) {
223
+ migrateLegacyVariablesFile()
224
+ const rows = getRowsForScope('variables', workspaceId)
225
+ return rows.map(r => r.key)
226
+ }
227
+
228
+ function listVariableScopes() {
229
+ migrateLegacyVariablesFile()
230
+ return listScopes('variables')
231
+ }
232
+
233
+ // ─── Secrets (workspace-scoped, sensitive) ───────────────────────────────────
234
+
235
+ function getSecretsForScope(workspaceId) {
236
+ migrateLegacySecretsFile()
237
+ return getScopeAsObject('secrets', workspaceId)
238
+ }
239
+
240
+ function getEffectiveSecrets(workspaceId) {
241
+ migrateLegacySecretsFile()
242
+ return getEffective('secrets', workspaceId)
243
+ }
244
+
245
+ function listSecretKeys(workspaceId) {
246
+ migrateLegacySecretsFile()
247
+ const rows = getRowsForScope('secrets', workspaceId)
248
+ return rows.map(r => r.key)
68
249
  }
69
250
 
70
251
  /**
71
- * Get all key-value pairs for a store type.
72
- * @param {'secrets' | 'variables'} type
73
- * @returns {Record<string, string>}
252
+ * Set (insert or update) a secret value in the given scope.
253
+ *
254
+ * For minion-wide secrets (workspace_id=''), the value is also synced into
255
+ * `process.env` so in-process consumers (chat plugin sessions, ad-hoc Bash
256
+ * commands) see the change immediately without restarting the server.
257
+ *
258
+ * WS-scoped secrets are NEVER written to `process.env` to prevent cross-WS
259
+ * leakage; runners must call `getEffectiveSecrets(workspaceId)` and inject
260
+ * them explicitly into the session env.
74
261
  */
262
+ function setSecret(workspaceId, key, value) {
263
+ migrateLegacySecretsFile()
264
+ const wsId = upsertEntry('secrets', workspaceId, key, value)
265
+ if (wsId === '') {
266
+ process.env[key] = String(value ?? '')
267
+ }
268
+ console.log(`[VariableStore] Set secret (scope=${wsId || 'minion-wide'}): ${key}`)
269
+ }
270
+
271
+ function removeSecret(workspaceId, key) {
272
+ migrateLegacySecretsFile()
273
+ const { removed, wsId } = deleteEntry('secrets', workspaceId, key)
274
+ if (removed) {
275
+ if (wsId === '') {
276
+ delete process.env[key]
277
+ }
278
+ console.log(`[VariableStore] Removed secret (scope=${wsId || 'minion-wide'}): ${key}`)
279
+ }
280
+ return removed
281
+ }
282
+
283
+ function listSecretScopes() {
284
+ migrateLegacySecretsFile()
285
+ return listScopes('secrets')
286
+ }
287
+
288
+ // ─── Generic / legacy dispatchers (back-compat) ─────────────────────────────
289
+ //
290
+ // The original API used `getAll('variables')` / `getAll('secrets')` etc. with
291
+ // no workspace awareness. We retain the same names but route them to the
292
+ // minion-wide bucket so any straggler caller keeps working.
293
+
75
294
  function getAll(type) {
76
- return parseEnvFile(getFilePath(type))
295
+ if (type === 'secrets') return getSecretsForScope('')
296
+ return getVariablesForScope('')
77
297
  }
78
298
 
79
- /**
80
- * Get a single value by key.
81
- * @param {'secrets' | 'variables'} type
82
- * @param {string} key
83
- * @returns {string | null}
84
- */
85
299
  function get(type, key) {
86
- const data = getAll(type)
87
- return data[key] ?? null
300
+ if (type === 'secrets') {
301
+ const all = getSecretsForScope('')
302
+ return all[key] ?? null
303
+ }
304
+ return getVariable('', key)
88
305
  }
89
306
 
90
- /**
91
- * Set a key-value pair (creates or updates).
92
- * Only secrets are synced to process.env (for child process inheritance).
93
- * Variables use {{VAR}} template expansion in skill content instead.
94
- * @param {'secrets' | 'variables'} type
95
- * @param {string} key
96
- * @param {string} value
97
- */
98
307
  function set(type, key, value) {
99
- const filePath = getFilePath(type)
100
- const data = parseEnvFile(filePath)
101
- data[key] = value
102
- writeEnvFile(filePath, data)
103
- // Only sync secrets to process.env; variables use template expansion instead
104
308
  if (type === 'secrets') {
105
- process.env[key] = value
309
+ setSecret('', key, value)
310
+ return
106
311
  }
107
- console.log(`[VariableStore] Set ${type} key: ${key}`)
312
+ setVariable('', key, value)
108
313
  }
109
314
 
110
- /**
111
- * Remove a key.
112
- * @param {'secrets' | 'variables'} type
113
- * @param {string} key
114
- * @returns {boolean} true if key existed
115
- */
116
315
  function remove(type, key) {
117
- const filePath = getFilePath(type)
118
- const data = parseEnvFile(filePath)
119
- if (!(key in data)) return false
120
- delete data[key]
121
- writeEnvFile(filePath, data)
122
- // Only sync secrets to process.env; variables use template expansion instead
123
- if (type === 'secrets') {
124
- delete process.env[key]
125
- }
126
- console.log(`[VariableStore] Removed ${type} key: ${key}`)
127
- return true
316
+ if (type === 'secrets') return removeSecret('', key)
317
+ return removeVariable('', key)
128
318
  }
129
319
 
130
- /**
131
- * List all keys for a store type.
132
- * @param {'secrets' | 'variables'} type
133
- * @returns {string[]}
134
- */
135
320
  function listKeys(type) {
136
- return Object.keys(getAll(type))
321
+ if (type === 'secrets') return listSecretKeys('')
322
+ return listVariableKeys('')
137
323
  }
138
324
 
139
325
  /**
140
- * Build environment object from minion secrets only.
141
- * Variables are no longer injected as env vars; they use {{VAR}} template
142
- * expansion in skill content (same mechanism as project/workflow variables).
143
- *
144
- * @returns {Record<string, string>} Secret key-value pairs for process.env
326
+ * @deprecated Use `getEffectiveSecrets(workspaceId)` instead. Retained so any
327
+ * stragglers continue compiling; returns minion-wide secrets only.
145
328
  */
146
329
  function buildEnv() {
147
- return getAll('secrets')
330
+ return getSecretsForScope('')
148
331
  }
149
332
 
150
333
  module.exports = {
334
+ // Generic (back-compat)
151
335
  getAll,
152
336
  get,
153
337
  set,
154
338
  remove,
155
339
  listKeys,
156
340
  buildEnv,
157
- getFilePath,
341
+
342
+ // Variables (workspace-scoped)
343
+ getVariablesForScope,
344
+ getEffectiveVariables,
345
+ getVariable,
346
+ setVariable,
347
+ removeVariable,
348
+ listVariableKeys,
349
+ listVariableScopes,
350
+ migrateLegacyVariablesFile,
351
+
352
+ // Secrets (workspace-scoped)
353
+ getSecretsForScope,
354
+ getEffectiveSecrets,
355
+ listSecretKeys,
356
+ setSecret,
357
+ removeSecret,
358
+ listSecretScopes,
359
+ migrateLegacySecretsFile,
158
360
  }
@@ -849,15 +849,30 @@ Response:
849
849
 
850
850
  #### 変数とシークレットの違い
851
851
 
852
- **変数**(ミニオン変数・プロジェクト変数)はスキル本文の `{{VAR_NAME}}` テンプレートとして実行時に展開される。スキル作成時にパラメータ化したい値は `{{変数名}}` で記述する。
852
+ **変数**(ワークスペース変数・プロジェクト変数・ワークフロー変数・ミニオン変数)はスキル本文の `{{VAR_NAME}}` テンプレートとして実行時に展開される。スキル作成時にパラメータ化したい値は `{{変数名}}` で記述する。
853
853
 
854
- **シークレット**(ミニオンシークレット)は環境変数 `$SECRET_NAME` としてプロセスに注入される。APIキーやパスワード等の機密情報に使用する。テンプレート展開は行われない。
854
+ **シークレット**は環境変数 `$SECRET_NAME` としてプロセスに注入される。APIキーやパスワード等の機密情報に使用する。テンプレート展開は行われない。ワークスペース別にスコープ可能(後述)。
855
855
 
856
856
  #### テンプレート変数の展開優先順位
857
857
 
858
- 同名の変数が複数のスコープで定義されている場合、以下の順序で上書きされる:
859
- 1. ミニオン変数(最低優先)
860
- 2. プロジェクト変数(最優先)
858
+ 同名の変数が複数のスコープで定義されている場合、以下の順序で上書きされる(後者が優先):
859
+ 1. ワークスペース変数(最低優先・全ワークスペース配下で共有)
860
+ 2. プロジェクト変数
861
+ 3. ワークフロー変数
862
+ 4. ミニオン変数(最優先・実行マシン固有の最終オーバーライド)
863
+
864
+ ワークスペース/プロジェクト/ワークフロー変数はHQ側で展開された SKILL.md がミニオンに配信され、最後にミニオンローカルでミニオン変数が展開される。
865
+
866
+ #### シークレットのワークスペーススコープ
867
+
868
+ 1つのミニオンが複数ワークスペースを担当する場合、シークレットはワークスペース別に管理される:
869
+
870
+ | スコープ | 保存形式 | 注入先のセッション |
871
+ |---------|---------|---------------------|
872
+ | ミニオン全体 | SQLite `secrets` テーブル `workspace_id=''` | すべて(サーバー起動時 `process.env` にロード、子プロセスが継承) |
873
+ | ワークスペース別 | SQLite `secrets` テーブル `workspace_id=<uuid>` | そのワークスペースのコンテキストで動くランナーのみ(実行時注入、`process.env` には載らない) |
874
+
875
+ 同名キーがある場合はワークスペース別が優先される。`/api/secrets/*` エンドポイントは `?workspace_id=<uuid>` クエリパラメータでスコープを指定する。省略または空文字でミニオン全体を操作する。シークレット値はHQ DBには保存されず、HQ APIはpass-throughとして中継するのみ。
861
876
 
862
877
  ### Project Tasks
863
878
 
@@ -16,12 +16,14 @@ const crypto = require('crypto')
16
16
  const fs = require('fs').promises
17
17
  const execAsync = promisify(exec)
18
18
 
19
- const { config } = require('../core/config')
19
+ const { config, isHqConfigured } = require('../core/config')
20
+ const api = require('../core/api')
20
21
  const executionStore = require('../core/stores/execution-store')
21
22
  const routineStore = require('../core/stores/routine-store')
22
23
  const logManager = require('../core/lib/log-manager')
23
24
  const runningTasks = require('../core/lib/running-tasks')
24
25
  const { expandSkillTemplates, restoreSkillTemplates } = require('../core/lib/template-expander')
26
+ const { buildTmuxNewSessionCommand } = require('../core/lib/session-env')
25
27
  const { getActivePrimary } = require('../core/llm-plugins/lib/active')
26
28
  const os = require('os')
27
29
  const path = require('path')
@@ -40,6 +42,32 @@ function sleep(ms) {
40
42
  return new Promise((resolve) => setTimeout(resolve, ms))
41
43
  }
42
44
 
45
+ /**
46
+ * Fetch workspace-scoped variables for a routine from HQ.
47
+ * Returns an empty object when the routine isn't bound to a workspace or HQ
48
+ * is unreachable; the runner continues with minion-local vars only.
49
+ *
50
+ * Workspace secrets are NOT fetched — secrets remain minion-local only (the
51
+ * HQ DB never stores secret values, by design).
52
+ *
53
+ * @param {string} workspaceId - Routine.workspace_id (may be falsy)
54
+ * @returns {Promise<Record<string, string>>}
55
+ */
56
+ async function fetchWorkspaceVars(workspaceId) {
57
+ if (!workspaceId || !isHqConfigured()) return {}
58
+ try {
59
+ const result = await api.request(`/workspaces/${workspaceId}/variables`)
60
+ const vars = {}
61
+ for (const v of (result?.variables || [])) {
62
+ if (v && typeof v.key === 'string') vars[v.key] = String(v.value ?? '')
63
+ }
64
+ return vars
65
+ } catch (err) {
66
+ console.error(`[RoutineRunner] Failed to fetch workspace vars (${workspaceId}): ${err.message}`)
67
+ return {}
68
+ }
69
+ }
70
+
43
71
  /**
44
72
  * Generate tmux session name from routine ID and execution ID
45
73
  * Format: rt-{routineId first 8}-{executionId first 4}
@@ -90,10 +118,15 @@ async function executeRoutineSession(routine, executionId, skillNames) {
90
118
  console.log(`[RoutineRunner] tmux session: ${sessionName}`)
91
119
  console.log(`[RoutineRunner] Log file: ${logFile}`)
92
120
 
93
- // Expand {{VAR}} templates in SKILL.md files with minion variables
121
+ // Fetch HQ workspace variables when the routine is bound to a workspace.
122
+ // Minion variables (minion-wide ∪ WS-scoped, resolved inside the expander
123
+ // via workspaceId) override these as the final layer.
124
+ const workspaceVars = await fetchWorkspaceVars(routine.workspace_id)
125
+
126
+ // Expand {{VAR}} templates in SKILL.md files
94
127
  let expandedOriginals = new Map()
95
128
  try {
96
- expandedOriginals = await expandSkillTemplates(skillNames)
129
+ expandedOriginals = await expandSkillTemplates(skillNames, workspaceVars, routine.workspace_id || '')
97
130
  if (expandedOriginals.size > 0) {
98
131
  console.log(`[RoutineRunner] Expanded templates in ${expandedOriginals.size} skill(s)`)
99
132
  }
@@ -142,14 +175,19 @@ async function executeRoutineSession(routine, executionId, skillNames) {
142
175
  )
143
176
  await execAsync(`chmod +x "${execScript}"`)
144
177
 
145
- const tmuxCommand = [
146
- 'tmux new-session -d',
147
- `-s "${sessionName}"`,
148
- '-x 200 -y 50',
149
- `-e "MINION_EXECUTION_ID=${executionId}"`,
150
- `-e "MINION_ROUTINE_ID=${routine.id}"`,
151
- `-e "MINION_ROUTINE_NAME=${routine.name.replace(/"/g, '\\"')}"`,
152
- ].join(' ')
178
+ // Build tmux invocation with workspace-effective secrets + per-execution
179
+ // identifiers. Secrets come from variable-store (minion-wide ∪ WS-scoped,
180
+ // with WS values winning); identifiers always override on top.
181
+ const tmuxCommand = buildTmuxNewSessionCommand({
182
+ sessionName,
183
+ workspaceId: routine.workspace_id || '',
184
+ extraEnv: {
185
+ MINION_EXECUTION_ID: executionId,
186
+ MINION_ROUTINE_ID: routine.id,
187
+ MINION_ROUTINE_NAME: routine.name,
188
+ MINION_ROUTINE_WORKSPACE_ID: routine.workspace_id || '',
189
+ },
190
+ })
153
191
 
154
192
  await execAsync(tmuxCommand, { cwd: homeDir })
155
193
 
@@ -22,6 +22,7 @@ const workflowStore = require('../core/stores/workflow-store')
22
22
  const logManager = require('../core/lib/log-manager')
23
23
  const runningTasks = require('../core/lib/running-tasks')
24
24
  const { expandSkillTemplates, restoreSkillTemplates } = require('../core/lib/template-expander')
25
+ const { buildTmuxNewSessionCommand } = require('../core/lib/session-env')
25
26
  const { getActivePrimary } = require('../core/llm-plugins/lib/active')
26
27
  const os = require('os')
27
28
  const path = require('path')
@@ -139,10 +140,13 @@ async function executeWorkflowSession(workflow, executionId, skillNames, options
139
140
  console.log(`[WorkflowRunner] Log file: ${logFile}`)
140
141
  console.log(`[WorkflowRunner] HOME: ${homeDir}`)
141
142
 
142
- // Expand {{VAR}} templates in SKILL.md files with minion variables
143
+ // Expand {{VAR}} templates in SKILL.md files with minion variables effective
144
+ // for this workflow's workspace (minion-wide ∪ WS-scoped, WS wins). HQ has
145
+ // already expanded workspace/project/workflow scopes before delivering the
146
+ // SKILL.md, so this layer only resolves remaining placeholders.
143
147
  let expandedOriginals = new Map()
144
148
  try {
145
- expandedOriginals = await expandSkillTemplates(skillNames)
149
+ expandedOriginals = await expandSkillTemplates(skillNames, {}, workflow.workspace_id || '')
146
150
  if (expandedOriginals.size > 0) {
147
151
  console.log(`[WorkflowRunner] Expanded templates in ${expandedOriginals.size} skill(s)`)
148
152
  }
@@ -200,12 +204,19 @@ async function executeWorkflowSession(workflow, executionId, skillNames, options
200
204
  )
201
205
  await execAsync(`chmod +x "${execScript}"`)
202
206
 
203
- // PATH, HOME, DISPLAY, and minion secrets are already set in
204
- // process.env at server startup, so child processes inherit them automatically.
205
- await execAsync(
206
- `tmux new-session -d -s "${sessionName}" -x 200 -y 50`,
207
- { cwd: homeDir },
208
- )
207
+ // PATH, HOME and DISPLAY are inherited from process.env (set at startup);
208
+ // workspace-scoped secrets are passed explicitly via `-e` here so each
209
+ // session only sees secrets relevant to its workspace context.
210
+ const tmuxCommand = buildTmuxNewSessionCommand({
211
+ sessionName,
212
+ workspaceId: workflow.workspace_id || '',
213
+ extraEnv: {
214
+ MINION_EXECUTION_ID: executionId,
215
+ MINION_WORKFLOW_ID: workflow.id || '',
216
+ MINION_WORKFLOW_WORKSPACE_ID: workflow.workspace_id || '',
217
+ },
218
+ })
219
+ await execAsync(tmuxCommand, { cwd: homeDir })
209
220
 
210
221
  // Keep session alive after command completes (for debugging via terminal mirror)
211
222
  await execAsync(`tmux set-option -t "${sessionName}" remain-on-exit on`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geekbeer/minion",
3
- "version": "3.55.1",
3
+ "version": "3.57.0",
4
4
  "description": "AI Agent runtime for Minion - manages status and skill deployment on VPS",
5
5
  "main": "linux/server.js",
6
6
  "bin": {
package/rules/core.md CHANGED
@@ -220,10 +220,11 @@ Routine 実行中は以下もtmuxセッション環境で利用可能:
220
220
  - `MINION_EXECUTION_ID` — 実行UUID
221
221
  - `MINION_ROUTINE_ID` — ルーティンUUID
222
222
  - `MINION_ROUTINE_NAME` — ルーティン名
223
+ - `MINION_ROUTINE_WORKSPACE_ID` — ルーティンが特定ワークスペースにバインドされている場合のワークスペースUUID(未バインドなら空文字)
223
224
 
224
- **変数**(ミニオン変数・プロジェクト変数)はスキル本文の `{{VAR_NAME}}` テンプレートとして実行時に展開される。スキル作成時にパラメータ化したい値は `{{変数名}}` で記述すること。展開優先順位: ミニオン変数 < プロジェクト変数(後者が優先)。
225
+ **変数**(ワークスペース変数・プロジェクト変数・ワークフロー変数・ミニオン変数)はスキル本文の `{{VAR_NAME}}` テンプレートとして実行時に展開される。スキル作成時にパラメータ化したい値は `{{変数名}}` で記述すること。展開優先順位: ワークスペース < プロジェクト < ワークフロー < ミニオン(後者が優先)。ミニオンが最終オーバーライドとなる設計で、ミニオン運用者がHQ側のデフォルトを差し替えられる経路を保証する。ミニオン変数はさらにミニオン全体スコープとワークスペース別スコープに分かれ、当該ワークスペースのコンテキストで動く実行時はWS別がミニオン全体を上書きする。`/api/variables/*` は `?workspace_id=<uuid>` でスコープ指定(省略時はミニオン全体)。
225
226
 
226
- **シークレット**(ミニオンシークレット)はサーバー起動時に `process.env` にロードされ、全子プロセスで環境変数 `$SECRET_NAME` として利用可能。APIキーやパスワード等の機密情報に使用する。シークレットは `{{VAR}}` テンプレートでは展開されない。
227
+ **シークレット**はワークスペース別にスコープされ、ミニオンローカルのSQLite (`secrets(workspace_id, key, value)`) に保存される。ワークスペース未指定(`workspace_id=''`)はミニオン全体のスコープで、サーバー起動時に `process.env` にロードされ全子プロセスで `$SECRET_NAME` として利用可能。ワークスペース別シークレット(`workspace_id=<uuid>`)は `process.env` にはロードされず、当該ワークスペースのコンテキストで動くランナー(ワークフロー/WSバインドされたルーティン/チャットセッション)にのみ実行時注入される。同名キーが両スコープに存在する場合はワークスペース別が優先。スキル側は常に `$KEY` で参照すればよく、どちらのスコープから来た値かを意識する必要はない。シークレット値はHQ DBに保存されることはなく、HQはpass-throughとして中継するのみ。
227
228
 
228
229
  デイリーログやメモリーから変数・シークレットの値を推測して使用してはならない。変数は `{{VAR_NAME}}` テンプレートとして定義し、シークレットは環境変数として参照すること。
229
230
 
@@ -12,12 +12,14 @@ const { stripAnsi } = require('../core/lib/strip-ansi')
12
12
  const fs = require('fs').promises
13
13
  const fsSync = require('fs')
14
14
 
15
- const { config } = require('../core/config')
15
+ const { config, isHqConfigured } = require('../core/config')
16
+ const api = require('../core/api')
16
17
  const executionStore = require('../core/stores/execution-store')
17
18
  const routineStore = require('../core/stores/routine-store')
18
19
  const logManager = require('../core/lib/log-manager')
19
20
  const { activeSessions } = require('./workflow-runner')
20
21
  const { expandSkillTemplates, restoreSkillTemplates } = require('../core/lib/template-expander')
22
+ const { buildSpawnEnv } = require('../core/lib/session-env')
21
23
  const { getActivePrimary } = require('../core/llm-plugins/lib/active')
22
24
  const os = require('os')
23
25
 
@@ -28,6 +30,25 @@ function sleep(ms) {
28
30
  return new Promise((resolve) => setTimeout(resolve, ms))
29
31
  }
30
32
 
33
+ /**
34
+ * Fetch workspace-scoped variables for a routine from HQ.
35
+ * Mirrors the Linux runner; see that file for full docs.
36
+ */
37
+ async function fetchWorkspaceVars(workspaceId) {
38
+ if (!workspaceId || !isHqConfigured()) return {}
39
+ try {
40
+ const result = await api.request(`/workspaces/${workspaceId}/variables`)
41
+ const vars = {}
42
+ for (const v of (result?.variables || [])) {
43
+ if (v && typeof v.key === 'string') vars[v.key] = String(v.value ?? '')
44
+ }
45
+ return vars
46
+ } catch (err) {
47
+ console.error(`[RoutineRunner] Failed to fetch workspace vars (${workspaceId}): ${err.message}`)
48
+ return {}
49
+ }
50
+ }
51
+
31
52
  function generateSessionName(routineId, executionId) {
32
53
  const routineShort = routineId ? routineId.substring(0, 8) : 'manual'
33
54
  const execShort = executionId ? executionId.substring(0, 4) : ''
@@ -67,10 +88,15 @@ async function executeRoutineSession(routine, executionId, skillNames) {
67
88
  console.log(`[RoutineRunner] Session: ${sessionName}`)
68
89
  console.log(`[RoutineRunner] Log file: ${logFile}`)
69
90
 
70
- // Expand {{VAR}} templates in SKILL.md files with minion variables
91
+ // Fetch HQ workspace variables when the routine is bound to a workspace.
92
+ // Minion variables (minion-wide ∪ WS-scoped, resolved inside the expander
93
+ // via workspaceId) override these as the final layer.
94
+ const workspaceVars = await fetchWorkspaceVars(routine.workspace_id)
95
+
96
+ // Expand {{VAR}} templates in SKILL.md files
71
97
  let expandedOriginals = new Map()
72
98
  try {
73
- expandedOriginals = await expandSkillTemplates(skillNames)
99
+ expandedOriginals = await expandSkillTemplates(skillNames, workspaceVars, routine.workspace_id || '')
74
100
  if (expandedOriginals.size > 0) {
75
101
  console.log(`[RoutineRunner] Expanded templates in ${expandedOriginals.size} skill(s)`)
76
102
  }
@@ -101,14 +127,15 @@ async function executeRoutineSession(routine, executionId, skillNames) {
101
127
  throw new Error('No LLM configured. Set a Primary plugin via /api/llm/config or LLM_COMMAND in minion.env')
102
128
  }
103
129
 
104
- // PATH, HOME, USERPROFILE, and minion secrets are already set in
105
- // process.env at server startup. Per-execution identifiers are added here.
106
- const env = {
107
- ...process.env,
130
+ // PATH, HOME, USERPROFILE are inherited; workspace-scoped secrets are
131
+ // merged in via buildSpawnEnv() so a routine only sees secrets relevant
132
+ // to the workspace it is bound to.
133
+ const env = buildSpawnEnv(routine.workspace_id || '', {
108
134
  MINION_EXECUTION_ID: executionId,
109
135
  MINION_ROUTINE_ID: routine.id,
110
136
  MINION_ROUTINE_NAME: routine.name,
111
- }
137
+ MINION_ROUTINE_WORKSPACE_ID: routine.workspace_id || '',
138
+ })
112
139
 
113
140
  const logDir = path.dirname(logFile)
114
141
  await fs.mkdir(logDir, { recursive: true })
@@ -24,6 +24,7 @@ const executionStore = require('../core/stores/execution-store')
24
24
  const workflowStore = require('../core/stores/workflow-store')
25
25
  const logManager = require('../core/lib/log-manager')
26
26
  const { expandSkillTemplates, restoreSkillTemplates } = require('../core/lib/template-expander')
27
+ const { buildSpawnEnv } = require('../core/lib/session-env')
27
28
  const { getActivePrimary } = require('../core/llm-plugins/lib/active')
28
29
  const os = require('os')
29
30
 
@@ -142,10 +143,11 @@ async function executeWorkflowSession(workflow, executionId, skillNames, options
142
143
  console.log(`[WorkflowRunner] Log file: ${logFile}`)
143
144
  console.log(`[WorkflowRunner] HOME: ${homeDir}`)
144
145
 
145
- // Expand {{VAR}} templates in SKILL.md files with minion variables
146
+ // Expand {{VAR}} templates in SKILL.md files with minion variables effective
147
+ // for this workflow's workspace (minion-wide ∪ WS-scoped, WS wins).
146
148
  let expandedOriginals = new Map()
147
149
  try {
148
- expandedOriginals = await expandSkillTemplates(skillNames)
150
+ expandedOriginals = await expandSkillTemplates(skillNames, {}, workflow.workspace_id || '')
149
151
  if (expandedOriginals.size > 0) {
150
152
  console.log(`[WorkflowRunner] Expanded templates in ${expandedOriginals.size} skill(s)`)
151
153
  }
@@ -178,8 +180,9 @@ async function executeWorkflowSession(workflow, executionId, skillNames, options
178
180
  throw new Error('No LLM configured. Set a Primary plugin via /api/llm/config or LLM_COMMAND in minion.env')
179
181
  }
180
182
 
181
- // PATH, HOME, USERPROFILE, and minion secrets are already set in
182
- // process.env at server startup, so child processes inherit them automatically.
183
+ // PATH, HOME, USERPROFILE are inherited from process.env (set at startup);
184
+ // workspace-scoped secrets are merged in via buildSpawnEnv() so this
185
+ // session only sees secrets relevant to its workspace context.
183
186
 
184
187
  // Open log file for streaming writes
185
188
  const logDir = path.dirname(logFile)
@@ -195,7 +198,11 @@ async function executeWorkflowSession(workflow, executionId, skillNames, options
195
198
  cols: 200,
196
199
  rows: 50,
197
200
  cwd: homeDir,
198
- env: process.env,
201
+ env: buildSpawnEnv(workflow.workspace_id || '', {
202
+ MINION_EXECUTION_ID: executionId,
203
+ MINION_WORKFLOW_ID: workflow.id || '',
204
+ MINION_WORKFLOW_WORKSPACE_ID: workflow.workspace_id || '',
205
+ }),
199
206
  })
200
207
 
201
208
  // Track session