@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.
@@ -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
  }
@@ -504,7 +504,7 @@ POST `/api/threads` body (プロジェクト紐づきディスカッション):
504
504
  | `thread_type` | string | No | `help`(デフォルト)or `discussion` |
505
505
  | `title` | string | Yes | スレッドの要約 |
506
506
  | `content` | string | Yes | スレッド本文(thread_messagesの最初のメッセージとして保存) |
507
- | `mentions` | string[] | No | メンション対象。形式: `role:engineer`, `role:pm`, `minion:<minion_id>`, `user` |
507
+ | `mentions` | string[] | No | メンション対象。形式: `user:<auth_user_id>` (個別指名・推奨), `role:engineer`, `role:pm`, `role:accountant`, `minion:<minion_id>`, `user` (誰でも良い場合のフォールバック) |
508
508
  | `context` | object | No | 任意のメタデータ(category, urgency, dag_execution_id等) |
509
509
 
510
510
  **プロジェクト紐づけの使い分け:**
@@ -520,7 +520,11 @@ POST `/api/threads` body (プロジェクト紐づきディスカッション):
520
520
  - メンションされたミニオンは優先的にスレッドを評価する
521
521
  - メンションがない場合、全チームメンバーがLLMで関連性を判定してから参加
522
522
  - トークン消費を抑えるため、当事者が明確な場合はメンションを推奨
523
- - ミニオンが自力で解決できない場合、`@user` メンション付きで返信して人間に助けを求める
523
+
524
+ **人間へのメンション (重要):**
525
+ 1. **個別指名 `user:<auth_user_id>`** を最優先で使う。`/api/minion/me/project/:id/members` または `/api/minion/workspaces/:id/members` で人間メンバーの `user_id` を引いてから指定する。同名 `display_name` が複数いるワークスペースでも誤通知しない
526
+ 2. **`role:pm` / `role:engineer` / `role:accountant`** はプロジェクトの該当ロール全員に届く(プロジェクト紐づきスレッドのみ)
527
+ 3. **`user` (generic)** はプロジェクトメンバーの人間全員に届く。プロジェクト紐づきでないワークスペーススレッドでは workspace_members 全員。「誰でも良い」場合のフォールバック扱いで、原則 1 か 2 を先に検討する
524
528
 
525
529
  POST `/api/threads/:id/messages` body:
526
530
  ```json
@@ -811,6 +815,46 @@ Response:
811
815
 
812
816
  `role` is one of `"pm"` (project manager), `"engineer"`, or `"accountant"`.
813
817
 
818
+ ### Project Members
819
+
820
+ | Method | Endpoint | Description |
821
+ |--------|----------|-------------|
822
+ | GET | `/api/minion/me/project/[id]/members` | プロジェクトのメンバー一覧(ミニオン+人間) |
823
+
824
+ Response:
825
+ ```json
826
+ {
827
+ "minions": [
828
+ { "minion_id": "uuid", "name": "Mary", "status": "online", "role": "pm", "joined_at": "..." }
829
+ ],
830
+ "humans": [
831
+ { "user_id": "uuid", "display_name": "yunoda", "email": "yunoda@example.com", "role": "engineer", "joined_at": "..." }
832
+ ]
833
+ }
834
+ ```
835
+
836
+ スレッドに `user:<auth_user_id>` で個別指名する前に、このエンドポイントで `user_id` を引いてくる。`display_name` が重複するワークスペースで誤通知を避けるため、ミニオンは必ず `email` または既知の `user_id` で当人を特定すること。`role` はプロジェクトロール (`pm` / `engineer` / `accountant`)。
837
+
838
+ ### Workspace Members
839
+
840
+ | Method | Endpoint | Description |
841
+ |--------|----------|-------------|
842
+ | GET | `/api/minion/workspaces/[id]/members` | ワークスペースのメンバー一覧(人間+ミニオン) |
843
+
844
+ Response:
845
+ ```json
846
+ {
847
+ "humans": [
848
+ { "user_id": "uuid", "display_name": "yunoda", "email": "yunoda@example.com", "role": "owner", "joined_at": "..." }
849
+ ],
850
+ "minions": [
851
+ { "minion_id": "uuid", "name": "Mary", "status": "online", "joined_at": "..." }
852
+ ]
853
+ }
854
+ ```
855
+
856
+ プロジェクト紐づきがないワークスペーススレッド(朝作業ルーティンのブロッカー報告など)でメンション先を引きたいときに使う。`role` はワークスペースロール (`owner` / `admin` / `member`)。
857
+
814
858
  ### Project Context
815
859
 
816
860
  | Method | Endpoint | Description |
@@ -849,15 +893,30 @@ Response:
849
893
 
850
894
  #### 変数とシークレットの違い
851
895
 
852
- **変数**(ミニオン変数・プロジェクト変数)はスキル本文の `{{VAR_NAME}}` テンプレートとして実行時に展開される。スキル作成時にパラメータ化したい値は `{{変数名}}` で記述する。
896
+ **変数**(ワークスペース変数・プロジェクト変数・ワークフロー変数・ミニオン変数)はスキル本文の `{{VAR_NAME}}` テンプレートとして実行時に展開される。スキル作成時にパラメータ化したい値は `{{変数名}}` で記述する。
853
897
 
854
- **シークレット**(ミニオンシークレット)は環境変数 `$SECRET_NAME` としてプロセスに注入される。APIキーやパスワード等の機密情報に使用する。テンプレート展開は行われない。
898
+ **シークレット**は環境変数 `$SECRET_NAME` としてプロセスに注入される。APIキーやパスワード等の機密情報に使用する。テンプレート展開は行われない。ワークスペース別にスコープ可能(後述)。
855
899
 
856
900
  #### テンプレート変数の展開優先順位
857
901
 
858
- 同名の変数が複数のスコープで定義されている場合、以下の順序で上書きされる:
859
- 1. ミニオン変数(最低優先)
860
- 2. プロジェクト変数(最優先)
902
+ 同名の変数が複数のスコープで定義されている場合、以下の順序で上書きされる(後者が優先):
903
+ 1. ワークスペース変数(最低優先・全ワークスペース配下で共有)
904
+ 2. プロジェクト変数
905
+ 3. ワークフロー変数
906
+ 4. ミニオン変数(最優先・実行マシン固有の最終オーバーライド)
907
+
908
+ ワークスペース/プロジェクト/ワークフロー変数はHQ側で展開された SKILL.md がミニオンに配信され、最後にミニオンローカルでミニオン変数が展開される。
909
+
910
+ #### シークレットのワークスペーススコープ
911
+
912
+ 1つのミニオンが複数ワークスペースを担当する場合、シークレットはワークスペース別に管理される:
913
+
914
+ | スコープ | 保存形式 | 注入先のセッション |
915
+ |---------|---------|---------------------|
916
+ | ミニオン全体 | SQLite `secrets` テーブル `workspace_id=''` | すべて(サーバー起動時 `process.env` にロード、子プロセスが継承) |
917
+ | ワークスペース別 | SQLite `secrets` テーブル `workspace_id=<uuid>` | そのワークスペースのコンテキストで動くランナーのみ(実行時注入、`process.env` には載らない) |
918
+
919
+ 同名キーがある場合はワークスペース別が優先される。`/api/secrets/*` エンドポイントは `?workspace_id=<uuid>` クエリパラメータでスコープを指定する。省略または空文字でミニオン全体を操作する。シークレット値はHQ DBには保存されず、HQ APIはpass-throughとして中継するのみ。
861
920
 
862
921
  ### Project Tasks
863
922
 
@@ -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.58.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": {