@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.
- package/core/db/migrations/20260510100000_secrets_workspace.js +36 -0
- package/core/db/migrations/20260511100000_variables_workspace.js +37 -0
- package/core/lib/session-env.js +96 -0
- package/core/lib/step-poller.js +8 -3
- package/core/lib/template-expander.js +20 -6
- package/core/routes/variables.js +67 -17
- package/core/stores/variable-store.js +278 -76
- package/docs/api-reference.md +20 -5
- package/linux/routine-runner.js +49 -11
- package/linux/workflow-runner.js +19 -8
- package/package.json +1 -1
- package/rules/core.md +3 -2
- package/win/routine-runner.js +35 -8
- package/win/workflow-runner.js +12 -5
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Move minion secrets from `.env.secrets` (flat file) into SQLite with
|
|
3
|
+
* workspace scoping.
|
|
4
|
+
*
|
|
5
|
+
* Design:
|
|
6
|
+
* - PRIMARY KEY (workspace_id, key); workspace_id='' is the minion-wide bucket.
|
|
7
|
+
* - Same key can exist at multiple scopes; at execution time the runner merges
|
|
8
|
+
* minion-wide + current-workspace secrets, with the WS-scoped value winning.
|
|
9
|
+
* - The actual data migration from `.env.secrets` is performed by
|
|
10
|
+
* variable-store at server start (it needs filesystem access which sits
|
|
11
|
+
* outside the DB transaction). See `migrateLegacySecretsFile()`.
|
|
12
|
+
*
|
|
13
|
+
* Secrets stay minion-local — they are NEVER persisted in HQ DB, by design.
|
|
14
|
+
* HQ→minion APIs only pass values through to the minion's local store.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
module.exports = {
|
|
18
|
+
version: 9,
|
|
19
|
+
name: 'secrets_workspace',
|
|
20
|
+
|
|
21
|
+
up(db, { tableExists }) {
|
|
22
|
+
if (tableExists(db, 'secrets')) return
|
|
23
|
+
|
|
24
|
+
db.exec(`
|
|
25
|
+
CREATE TABLE secrets (
|
|
26
|
+
workspace_id TEXT NOT NULL DEFAULT '',
|
|
27
|
+
key TEXT NOT NULL,
|
|
28
|
+
value TEXT NOT NULL,
|
|
29
|
+
updated_at INTEGER NOT NULL,
|
|
30
|
+
PRIMARY KEY (workspace_id, key)
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
CREATE INDEX IF NOT EXISTS idx_secrets_workspace ON secrets(workspace_id);
|
|
34
|
+
`)
|
|
35
|
+
},
|
|
36
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Move minion variables from `.env.variables` (flat file) into SQLite with
|
|
3
|
+
* workspace scoping — mirrors the secrets workspace migration.
|
|
4
|
+
*
|
|
5
|
+
* Design:
|
|
6
|
+
* - PRIMARY KEY (workspace_id, key); workspace_id='' is the minion-wide bucket.
|
|
7
|
+
* - Same key can exist at multiple scopes; at execution time the
|
|
8
|
+
* template-expander merges minion-wide + current-workspace variables, with
|
|
9
|
+
* the WS-scoped value winning.
|
|
10
|
+
* - Variables are non-sensitive — values are returned by the API (unlike
|
|
11
|
+
* secrets) — but they still benefit from scoping so a single minion serving
|
|
12
|
+
* multiple workspaces doesn't leak per-tenant config across runs.
|
|
13
|
+
* - Actual data migration from `.env.variables` is performed by variable-store
|
|
14
|
+
* at first use (filesystem access lives outside this transaction). See
|
|
15
|
+
* `migrateLegacyVariablesFile()`.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
module.exports = {
|
|
19
|
+
version: 10,
|
|
20
|
+
name: 'variables_workspace',
|
|
21
|
+
|
|
22
|
+
up(db, { tableExists }) {
|
|
23
|
+
if (tableExists(db, 'variables')) return
|
|
24
|
+
|
|
25
|
+
db.exec(`
|
|
26
|
+
CREATE TABLE variables (
|
|
27
|
+
workspace_id TEXT NOT NULL DEFAULT '',
|
|
28
|
+
key TEXT NOT NULL,
|
|
29
|
+
value TEXT NOT NULL,
|
|
30
|
+
updated_at INTEGER NOT NULL,
|
|
31
|
+
PRIMARY KEY (workspace_id, key)
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
CREATE INDEX IF NOT EXISTS idx_variables_workspace ON variables(workspace_id);
|
|
35
|
+
`)
|
|
36
|
+
},
|
|
37
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session environment builder
|
|
3
|
+
*
|
|
4
|
+
* Builds workspace-scoped environment variable injection for runners.
|
|
5
|
+
* Resolves minion-wide + workspace-scoped secrets, and exposes both
|
|
6
|
+
* `tmux -e` flags (Linux/macOS) and a node-pty env object (Windows).
|
|
7
|
+
*
|
|
8
|
+
* Why this exists:
|
|
9
|
+
* Before this module, all secrets were loaded once into `process.env`
|
|
10
|
+
* at server start and inherited by every child process — fine for a
|
|
11
|
+
* single-workspace minion but a leakage path for multi-workspace
|
|
12
|
+
* setups. Each runner now passes the effective env explicitly for the
|
|
13
|
+
* workspace it is running on; workspace-scoped values override
|
|
14
|
+
* minion-wide ones at this layer.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const variableStore = require('../stores/variable-store')
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Shell-escape a value for a `KEY=VALUE` argument passed via `-e` to tmux.
|
|
21
|
+
* We use single-quote wrapping; the surrounding shell sees one literal arg.
|
|
22
|
+
* @param {string} s
|
|
23
|
+
* @returns {string}
|
|
24
|
+
*/
|
|
25
|
+
function singleQuote(s) {
|
|
26
|
+
return "'" + String(s).replace(/'/g, "'\\''") + "'"
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Build an array of `-e 'KEY=VALUE'` shell-safe flags for tmux new-session.
|
|
31
|
+
*
|
|
32
|
+
* @param {string|null|undefined} workspaceId - The session's workspace scope
|
|
33
|
+
* @param {Record<string,string>} [extraEnv] - Per-execution identifiers (e.g.
|
|
34
|
+
* MINION_EXECUTION_ID). Layered on top so they always win.
|
|
35
|
+
* @returns {string[]} List of `-e <quoted>` fragments to splice into a shell command
|
|
36
|
+
*/
|
|
37
|
+
function buildTmuxEnvFlags(workspaceId, extraEnv = {}) {
|
|
38
|
+
const merged = { ...variableStore.getEffectiveSecrets(workspaceId), ...extraEnv }
|
|
39
|
+
const flags = []
|
|
40
|
+
for (const [k, v] of Object.entries(merged)) {
|
|
41
|
+
if (v == null) continue
|
|
42
|
+
flags.push(`-e ${singleQuote(`${k}=${v}`)}`)
|
|
43
|
+
}
|
|
44
|
+
return flags
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Build a `tmux new-session` invocation line including all secret env vars.
|
|
49
|
+
*
|
|
50
|
+
* Caller typically does:
|
|
51
|
+
* `${buildTmuxNewSessionCommand({sessionName, workspaceId, extraEnv, width, height})}`
|
|
52
|
+
*
|
|
53
|
+
* The session is created detached (`-d`) with no command so we can configure
|
|
54
|
+
* `remain-on-exit` and `pipe-pane` before injecting the real workload via
|
|
55
|
+
* `send-keys`.
|
|
56
|
+
*
|
|
57
|
+
* @param {object} opts
|
|
58
|
+
* @param {string} opts.sessionName
|
|
59
|
+
* @param {string|null|undefined} opts.workspaceId
|
|
60
|
+
* @param {Record<string,string>} [opts.extraEnv]
|
|
61
|
+
* @param {number} [opts.width]
|
|
62
|
+
* @param {number} [opts.height]
|
|
63
|
+
* @returns {string}
|
|
64
|
+
*/
|
|
65
|
+
function buildTmuxNewSessionCommand({ sessionName, workspaceId, extraEnv = {}, width = 200, height = 50 }) {
|
|
66
|
+
const envFlags = buildTmuxEnvFlags(workspaceId, extraEnv)
|
|
67
|
+
return [
|
|
68
|
+
'tmux new-session -d',
|
|
69
|
+
`-s "${sessionName}"`,
|
|
70
|
+
`-x ${width} -y ${height}`,
|
|
71
|
+
...envFlags,
|
|
72
|
+
].join(' ')
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Build an env object for node-pty / child_process.spawn that contains the
|
|
77
|
+
* caller's `process.env` plus the workspace-effective secrets and any extra
|
|
78
|
+
* per-execution identifiers (extraEnv wins last).
|
|
79
|
+
*
|
|
80
|
+
* @param {string|null|undefined} workspaceId
|
|
81
|
+
* @param {Record<string,string>} [extraEnv]
|
|
82
|
+
* @returns {Record<string,string>}
|
|
83
|
+
*/
|
|
84
|
+
function buildSpawnEnv(workspaceId, extraEnv = {}) {
|
|
85
|
+
return {
|
|
86
|
+
...process.env,
|
|
87
|
+
...variableStore.getEffectiveSecrets(workspaceId),
|
|
88
|
+
...extraEnv,
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
module.exports = {
|
|
93
|
+
buildTmuxEnvFlags,
|
|
94
|
+
buildTmuxNewSessionCommand,
|
|
95
|
+
buildSpawnEnv,
|
|
96
|
+
}
|
package/core/lib/step-poller.js
CHANGED
|
@@ -104,6 +104,7 @@ async function executeStep(step) {
|
|
|
104
104
|
skill_name,
|
|
105
105
|
revision_feedback,
|
|
106
106
|
template_vars,
|
|
107
|
+
workspace_id,
|
|
107
108
|
} = step
|
|
108
109
|
|
|
109
110
|
console.log(
|
|
@@ -135,11 +136,15 @@ async function executeStep(step) {
|
|
|
135
136
|
|
|
136
137
|
// 2. Fetch the skill from HQ to ensure it's deployed locally
|
|
137
138
|
// Merge minion variables into template_vars so HQ expands {{VAR_NAME}} in SKILL.md.
|
|
138
|
-
//
|
|
139
|
+
// Resolution order: workspace < project < workflow < minion (minion wins).
|
|
140
|
+
// template_vars already merges workspace/project/workflow on HQ side; we
|
|
141
|
+
// layer effective minion vars (minion-wide ∪ WS-scoped) on top so any
|
|
142
|
+
// conflicting key resolves to the minion-local value before HQ rewrites
|
|
143
|
+
// the SKILL.md.
|
|
139
144
|
if (skill_name) {
|
|
140
145
|
try {
|
|
141
|
-
const minionVars = variableStore.
|
|
142
|
-
const mergedVars = { ...
|
|
146
|
+
const minionVars = variableStore.getEffectiveVariables(workspace_id || '')
|
|
147
|
+
const mergedVars = { ...(template_vars || {}), ...minionVars }
|
|
143
148
|
const varsParam = Object.keys(mergedVars).length > 0
|
|
144
149
|
? `?vars=${Buffer.from(JSON.stringify(mergedVars)).toString('base64')}`
|
|
145
150
|
: ''
|
|
@@ -32,17 +32,31 @@ function expandTemplateVars(content, vars) {
|
|
|
32
32
|
|
|
33
33
|
/**
|
|
34
34
|
* Expand templates in SKILL.md files for the given skill names.
|
|
35
|
-
* Reads each SKILL.md, expands {{VAR}} with
|
|
35
|
+
* Reads each SKILL.md, expands {{VAR}} with the merged variable set, writes back.
|
|
36
36
|
* Returns the original contents for restoration after execution.
|
|
37
37
|
*
|
|
38
|
+
* Resolution order (later entries override earlier ones):
|
|
39
|
+
* extraVars (e.g., HQ workspace vars passed by the caller)
|
|
40
|
+
* < minion-wide variables
|
|
41
|
+
* < workspace-scoped minion variables for the given workspaceId
|
|
42
|
+
*
|
|
43
|
+
* The minion-local layer always wins at this stage, which mirrors the
|
|
44
|
+
* cross-scope rule (workspace < project < workflow < minion). For workflow
|
|
45
|
+
* execution, project/workflow scopes are pre-expanded by HQ before the
|
|
46
|
+
* SKILL.md reaches the minion; for routine execution the caller can pass
|
|
47
|
+
* workspace-scoped HQ vars via `extraVars`.
|
|
48
|
+
*
|
|
38
49
|
* @param {string[]} skillNames - Skill slugs to expand
|
|
50
|
+
* @param {Record<string, string>} [extraVars] - Additional vars (lowest priority of the three layers)
|
|
51
|
+
* @param {string|null|undefined} [workspaceId] - Workspace context for resolving minion variables (defaults to minion-wide only)
|
|
39
52
|
* @returns {Promise<Map<string, string>>} Map of skillName -> original content (for restoration)
|
|
40
53
|
*/
|
|
41
|
-
async function expandSkillTemplates(skillNames) {
|
|
42
|
-
const minionVars = variableStore.
|
|
54
|
+
async function expandSkillTemplates(skillNames, extraVars = {}, workspaceId = '') {
|
|
55
|
+
const minionVars = variableStore.getEffectiveVariables(workspaceId)
|
|
56
|
+
const mergedVars = { ...extraVars, ...minionVars }
|
|
43
57
|
|
|
44
|
-
// If
|
|
45
|
-
if (Object.keys(
|
|
58
|
+
// If nothing to expand, skip
|
|
59
|
+
if (Object.keys(mergedVars).length === 0) return new Map()
|
|
46
60
|
|
|
47
61
|
// originals key: `${root}::${name}` so we restore the right copy in each
|
|
48
62
|
// plugin's skill dir (multi-plugin distribution).
|
|
@@ -55,7 +69,7 @@ async function expandSkillTemplates(skillNames) {
|
|
|
55
69
|
const skillMdPath = path.join(root, name, 'SKILL.md')
|
|
56
70
|
try {
|
|
57
71
|
const original = await fs.readFile(skillMdPath, 'utf-8')
|
|
58
|
-
const expanded = expandTemplateVars(original,
|
|
72
|
+
const expanded = expandTemplateVars(original, mergedVars)
|
|
59
73
|
if (expanded !== original) {
|
|
60
74
|
originals.set(`${root}::${name}`, { skillMdPath, original })
|
|
61
75
|
await fs.writeFile(skillMdPath, expanded, 'utf-8')
|
package/core/routes/variables.js
CHANGED
|
@@ -32,14 +32,26 @@ function isValidValue(value) {
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
function variableRoutes(fastify, _opts, done) {
|
|
35
|
-
|
|
35
|
+
function readWorkspaceId(request) {
|
|
36
|
+
const rawWs = request.query?.workspace_id
|
|
37
|
+
return (typeof rawWs === 'string') ? rawWs : ''
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ─── Variables (non-sensitive, workspace-scoped) ──────────────────────
|
|
41
|
+
//
|
|
42
|
+
// Variables are scoped per workspace just like secrets. Pass
|
|
43
|
+
// ?workspace_id=<uuid> to target a specific workspace's bucket; omit it
|
|
44
|
+
// (or pass '') for the minion-wide bucket. At skill execution time the
|
|
45
|
+
// template-expander merges minion-wide + current-workspace variables, with
|
|
46
|
+
// WS-scoped values overriding minion-wide ones.
|
|
36
47
|
|
|
37
48
|
fastify.get('/api/variables', async (request, reply) => {
|
|
38
49
|
if (!verifyToken(request)) {
|
|
39
50
|
return reply.code(401).send({ error: 'Unauthorized' })
|
|
40
51
|
}
|
|
41
|
-
const
|
|
42
|
-
|
|
52
|
+
const workspaceId = readWorkspaceId(request)
|
|
53
|
+
const variables = variableStore.getVariablesForScope(workspaceId)
|
|
54
|
+
return { success: true, workspace_id: workspaceId, variables }
|
|
43
55
|
})
|
|
44
56
|
|
|
45
57
|
fastify.get('/api/variables/:key', async (request, reply) => {
|
|
@@ -47,11 +59,12 @@ function variableRoutes(fastify, _opts, done) {
|
|
|
47
59
|
return reply.code(401).send({ error: 'Unauthorized' })
|
|
48
60
|
}
|
|
49
61
|
const { key } = request.params
|
|
50
|
-
const
|
|
62
|
+
const workspaceId = readWorkspaceId(request)
|
|
63
|
+
const value = variableStore.getVariable(workspaceId, key)
|
|
51
64
|
if (value === null) {
|
|
52
65
|
return reply.code(404).send({ error: `Variable not found: ${key}` })
|
|
53
66
|
}
|
|
54
|
-
return { success: true, key, value }
|
|
67
|
+
return { success: true, key, value, workspace_id: workspaceId }
|
|
55
68
|
})
|
|
56
69
|
|
|
57
70
|
fastify.put('/api/variables/:key', async (request, reply) => {
|
|
@@ -60,6 +73,7 @@ function variableRoutes(fastify, _opts, done) {
|
|
|
60
73
|
}
|
|
61
74
|
const { key } = request.params
|
|
62
75
|
const { value } = request.body || {}
|
|
76
|
+
const workspaceId = readWorkspaceId(request)
|
|
63
77
|
|
|
64
78
|
if (!isValidKey(key)) {
|
|
65
79
|
return reply.code(400).send({ error: 'Invalid key. Use alphanumeric characters and underscores (1-100 chars).' })
|
|
@@ -68,8 +82,8 @@ function variableRoutes(fastify, _opts, done) {
|
|
|
68
82
|
return reply.code(400).send({ error: 'Invalid value. Must be a string, no newlines, max 2000 chars.' })
|
|
69
83
|
}
|
|
70
84
|
|
|
71
|
-
variableStore.
|
|
72
|
-
return { success: true, key, value }
|
|
85
|
+
variableStore.setVariable(workspaceId, key, value)
|
|
86
|
+
return { success: true, key, value, workspace_id: workspaceId }
|
|
73
87
|
})
|
|
74
88
|
|
|
75
89
|
fastify.delete('/api/variables/:key', async (request, reply) => {
|
|
@@ -77,22 +91,45 @@ function variableRoutes(fastify, _opts, done) {
|
|
|
77
91
|
return reply.code(401).send({ error: 'Unauthorized' })
|
|
78
92
|
}
|
|
79
93
|
const { key } = request.params
|
|
80
|
-
const
|
|
94
|
+
const workspaceId = readWorkspaceId(request)
|
|
95
|
+
const removed = variableStore.removeVariable(workspaceId, key)
|
|
81
96
|
if (!removed) {
|
|
82
97
|
return reply.code(404).send({ error: `Variable not found: ${key}` })
|
|
83
98
|
}
|
|
84
|
-
return { success: true, key }
|
|
99
|
+
return { success: true, key, workspace_id: workspaceId }
|
|
85
100
|
})
|
|
86
101
|
|
|
87
|
-
//
|
|
102
|
+
// List the workspace scopes (workspace_ids) that currently hold any
|
|
103
|
+
// variable. '' represents the minion-wide bucket.
|
|
104
|
+
fastify.get('/api/variables/scopes', async (request, reply) => {
|
|
105
|
+
if (!verifyToken(request)) {
|
|
106
|
+
return reply.code(401).send({ error: 'Unauthorized' })
|
|
107
|
+
}
|
|
108
|
+
return { success: true, scopes: variableStore.listVariableScopes() }
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
// ─── Secrets (sensitive, workspace-scoped) ────────────────────────────
|
|
112
|
+
//
|
|
113
|
+
// Secrets are scoped per workspace. Pass ?workspace_id=<uuid> to target a
|
|
114
|
+
// specific workspace's bucket; omit it (or pass '') for the minion-wide
|
|
115
|
+
// bucket. At skill execution time, the runner merges minion-wide +
|
|
116
|
+
// current-workspace secrets, with WS-scoped values overriding.
|
|
117
|
+
//
|
|
118
|
+
// Values are never returned via the API by design — only key names. Secrets
|
|
119
|
+
// never leave the minion: the HQ proxy is a pure pass-through.
|
|
120
|
+
|
|
121
|
+
function readWorkspaceId(request) {
|
|
122
|
+
const rawWs = request.query?.workspace_id
|
|
123
|
+
return (typeof rawWs === 'string') ? rawWs : ''
|
|
124
|
+
}
|
|
88
125
|
|
|
89
126
|
fastify.get('/api/secrets', async (request, reply) => {
|
|
90
127
|
if (!verifyToken(request)) {
|
|
91
128
|
return reply.code(401).send({ error: 'Unauthorized' })
|
|
92
129
|
}
|
|
93
|
-
|
|
94
|
-
const keys = variableStore.
|
|
95
|
-
return { success: true, keys }
|
|
130
|
+
const workspaceId = readWorkspaceId(request)
|
|
131
|
+
const keys = variableStore.listSecretKeys(workspaceId)
|
|
132
|
+
return { success: true, workspace_id: workspaceId, keys }
|
|
96
133
|
})
|
|
97
134
|
|
|
98
135
|
fastify.put('/api/secrets/:key', async (request, reply) => {
|
|
@@ -101,6 +138,7 @@ function variableRoutes(fastify, _opts, done) {
|
|
|
101
138
|
}
|
|
102
139
|
const { key } = request.params
|
|
103
140
|
const { value } = request.body || {}
|
|
141
|
+
const workspaceId = readWorkspaceId(request)
|
|
104
142
|
|
|
105
143
|
if (!isValidKey(key)) {
|
|
106
144
|
return reply.code(400).send({ error: 'Invalid key. Use alphanumeric characters and underscores (1-100 chars).' })
|
|
@@ -109,8 +147,8 @@ function variableRoutes(fastify, _opts, done) {
|
|
|
109
147
|
return reply.code(400).send({ error: 'Invalid value. Must be a string, no newlines, max 2000 chars.' })
|
|
110
148
|
}
|
|
111
149
|
|
|
112
|
-
variableStore.
|
|
113
|
-
return { success: true, key }
|
|
150
|
+
variableStore.setSecret(workspaceId, key, value)
|
|
151
|
+
return { success: true, key, workspace_id: workspaceId }
|
|
114
152
|
})
|
|
115
153
|
|
|
116
154
|
fastify.delete('/api/secrets/:key', async (request, reply) => {
|
|
@@ -118,11 +156,23 @@ function variableRoutes(fastify, _opts, done) {
|
|
|
118
156
|
return reply.code(401).send({ error: 'Unauthorized' })
|
|
119
157
|
}
|
|
120
158
|
const { key } = request.params
|
|
121
|
-
const
|
|
159
|
+
const workspaceId = readWorkspaceId(request)
|
|
160
|
+
const removed = variableStore.removeSecret(workspaceId, key)
|
|
122
161
|
if (!removed) {
|
|
123
162
|
return reply.code(404).send({ error: `Secret not found: ${key}` })
|
|
124
163
|
}
|
|
125
|
-
return { success: true, key }
|
|
164
|
+
return { success: true, key, workspace_id: workspaceId }
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
// List the scopes (workspace_ids) that currently hold any secret.
|
|
168
|
+
// '' represents the minion-wide bucket. Useful for the dashboard secrets
|
|
169
|
+
// editor to populate the workspace selector even when a workspace has no
|
|
170
|
+
// active minion membership yet.
|
|
171
|
+
fastify.get('/api/secrets/scopes', async (request, reply) => {
|
|
172
|
+
if (!verifyToken(request)) {
|
|
173
|
+
return reply.code(401).send({ error: 'Unauthorized' })
|
|
174
|
+
}
|
|
175
|
+
return { success: true, scopes: variableStore.listSecretScopes() }
|
|
126
176
|
})
|
|
127
177
|
|
|
128
178
|
done()
|
|
@@ -1,34 +1,69 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Variable Store
|
|
3
3
|
*
|
|
4
|
-
* Manages minion-local
|
|
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
|
-
*
|
|
9
|
-
*
|
|
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
|
|
19
|
-
* @param {'secrets' | 'variables'} type
|
|
32
|
+
* Resolve the legacy `.env.variables` path (for one-time migration).
|
|
20
33
|
* @returns {string}
|
|
21
34
|
*/
|
|
22
|
-
function
|
|
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,
|
|
38
|
+
return path.join(DATA_DIR, '.env.variables')
|
|
27
39
|
} catch {
|
|
28
|
-
return path.join(config.HOME_DIR, '.minion',
|
|
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
|
-
*
|
|
59
|
-
*
|
|
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
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
67
|
-
|
|
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
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
87
|
-
|
|
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
|
-
|
|
309
|
+
setSecret('', key, value)
|
|
310
|
+
return
|
|
106
311
|
}
|
|
107
|
-
|
|
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
|
-
|
|
118
|
-
|
|
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
|
-
|
|
321
|
+
if (type === 'secrets') return listSecretKeys('')
|
|
322
|
+
return listVariableKeys('')
|
|
137
323
|
}
|
|
138
324
|
|
|
139
325
|
/**
|
|
140
|
-
*
|
|
141
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
}
|
package/docs/api-reference.md
CHANGED
|
@@ -849,15 +849,30 @@ Response:
|
|
|
849
849
|
|
|
850
850
|
#### 変数とシークレットの違い
|
|
851
851
|
|
|
852
|
-
|
|
852
|
+
**変数**(ワークスペース変数・プロジェクト変数・ワークフロー変数・ミニオン変数)はスキル本文の `{{VAR_NAME}}` テンプレートとして実行時に展開される。スキル作成時にパラメータ化したい値は `{{変数名}}` で記述する。
|
|
853
853
|
|
|
854
|
-
|
|
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
|
|
package/linux/routine-runner.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
|
package/linux/workflow-runner.js
CHANGED
|
@@ -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
|
|
204
|
-
//
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
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
|
-
|
|
225
|
+
**変数**(ワークスペース変数・プロジェクト変数・ワークフロー変数・ミニオン変数)はスキル本文の `{{VAR_NAME}}` テンプレートとして実行時に展開される。スキル作成時にパラメータ化したい値は `{{変数名}}` で記述すること。展開優先順位: ワークスペース < プロジェクト < ワークフロー < ミニオン(後者が優先)。ミニオンが最終オーバーライドとなる設計で、ミニオン運用者がHQ側のデフォルトを差し替えられる経路を保証する。ミニオン変数はさらにミニオン全体スコープとワークスペース別スコープに分かれ、当該ワークスペースのコンテキストで動く実行時はWS別がミニオン全体を上書きする。`/api/variables/*` は `?workspace_id=<uuid>` でスコープ指定(省略時はミニオン全体)。
|
|
225
226
|
|
|
226
|
-
|
|
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
|
|
package/win/routine-runner.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
|
105
|
-
//
|
|
106
|
-
|
|
107
|
-
|
|
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 })
|
package/win/workflow-runner.js
CHANGED
|
@@ -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
|
|
182
|
-
//
|
|
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:
|
|
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
|