moflo 4.9.32 → 4.9.33
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/dist/src/cli/commands/spell-schedule.js +11 -0
- package/dist/src/cli/services/daemon-spell-executor.js +8 -0
- package/dist/src/cli/services/schedule-acceptance-check.js +68 -0
- package/dist/src/cli/spells/credentials/credential-store.js +64 -1
- package/dist/src/cli/version.js +1 -1
- package/package.json +2 -2
|
@@ -15,6 +15,7 @@ import { callMCPTool } from '../mcp-client.js';
|
|
|
15
15
|
import { TOOL_MEMORY_STORE, TOOL_MEMORY_LIST, TOOL_MEMORY_RETRIEVE } from '../mcp-tools/tool-names.js';
|
|
16
16
|
import { handleMCPError } from '../services/cli-formatters.js';
|
|
17
17
|
import { ensureDaemonForScheduling } from '../services/daemon-readiness.js';
|
|
18
|
+
import { checkScheduleAcceptance } from '../services/schedule-acceptance-check.js';
|
|
18
19
|
import { reconcileDaemonAutostart } from '../services/daemon-autostart-lifecycle.js';
|
|
19
20
|
import { isDaemonInstalled } from '../services/daemon-service.js';
|
|
20
21
|
import { validateSchedule, computeNextRun } from '../spells/scheduler/cron-parser.js';
|
|
@@ -123,6 +124,16 @@ const createCommand = {
|
|
|
123
124
|
for (const warning of readiness.warnings) {
|
|
124
125
|
output.printWarning(warning);
|
|
125
126
|
}
|
|
127
|
+
// Permission-acceptance check (#1037): scheduled fires run in the daemon's
|
|
128
|
+
// non-interactive context and have no way to prompt for permissions. If
|
|
129
|
+
// this spell hasn't been manually cast yet, the user needs to know NOW so
|
|
130
|
+
// they can run `flo spell cast -n <name>` once before relying on the
|
|
131
|
+
// schedule. This is a warning, never a block — the user may have a legit
|
|
132
|
+
// reason (about to cast, scripted setup, etc.).
|
|
133
|
+
const acceptance = await checkScheduleAcceptance(projectRoot, name);
|
|
134
|
+
if (acceptance.message) {
|
|
135
|
+
output.printWarning(acceptance.message);
|
|
136
|
+
}
|
|
126
137
|
// Always create the schedule, regardless of daemon state
|
|
127
138
|
const id = `sched-adhoc-${now}-${Math.random().toString(36).slice(2, 8)}`;
|
|
128
139
|
const record = {
|
|
@@ -24,9 +24,17 @@ export class DaemonSpellExecutor {
|
|
|
24
24
|
this.explicitSandbox = opts.sandboxConfig;
|
|
25
25
|
}
|
|
26
26
|
exists(spellName) {
|
|
27
|
+
// Invalidate before resolve so newly-added yamls are visible to the
|
|
28
|
+
// poll loop. Without this, stale-false from exists() causes the
|
|
29
|
+
// scheduler to auto-disable schedules whose spell was added on disk
|
|
30
|
+
// after daemon boot (#1034).
|
|
31
|
+
this.registry.invalidate();
|
|
27
32
|
return this.registry.resolve(spellName) !== undefined;
|
|
28
33
|
}
|
|
29
34
|
async execute(spellName, args, signal, mofloLevel) {
|
|
35
|
+
// Invalidate before resolve so yaml edits on disk reach the next fire
|
|
36
|
+
// without needing a daemon restart (#1034).
|
|
37
|
+
this.registry.invalidate();
|
|
30
38
|
const loaded = this.registry.resolve(spellName);
|
|
31
39
|
if (!loaded) {
|
|
32
40
|
return failedResult(`scheduled-${spellName}-${Date.now()}`, 'STEP_EXECUTION_FAILED', `Spell not found in grimoire: ${spellName}`);
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schedule Acceptance Check
|
|
3
|
+
*
|
|
4
|
+
* Resolves a spell, computes its current permission hash, and checks whether
|
|
5
|
+
* `.moflo/accepted-permissions/<name>.json` records a valid prior acceptance.
|
|
6
|
+
*
|
|
7
|
+
* The schedule-create command consumes the result to warn — never block — when
|
|
8
|
+
* the spell is missing acceptance. Without it, scheduled fires running in the
|
|
9
|
+
* non-interactive daemon context fail with `Missing credentials` and the user
|
|
10
|
+
* has no signal at create time that a one-time manual cast was the missing
|
|
11
|
+
* step (#1037).
|
|
12
|
+
*/
|
|
13
|
+
import { buildGrimoire } from './grimoire-builder.js';
|
|
14
|
+
import { checkAcceptance } from '../spells/core/permission-acceptance.js';
|
|
15
|
+
/**
|
|
16
|
+
* Resolve `spellName` via the Grimoire, hash its permissions, compare against
|
|
17
|
+
* any stored acceptance under `<projectRoot>/.moflo/accepted-permissions/`.
|
|
18
|
+
*
|
|
19
|
+
* Always returns — never throws. A check failure (e.g. Grimoire unavailable)
|
|
20
|
+
* resolves to `check-failed` with an empty message so callers don't surface
|
|
21
|
+
* noise; the schedule create proceeds either way.
|
|
22
|
+
*/
|
|
23
|
+
export async function checkScheduleAcceptance(projectRoot, spellName) {
|
|
24
|
+
try {
|
|
25
|
+
const { registry } = await buildGrimoire(projectRoot);
|
|
26
|
+
const loaded = registry.resolve(spellName);
|
|
27
|
+
if (!loaded) {
|
|
28
|
+
return {
|
|
29
|
+
state: 'spell-not-found',
|
|
30
|
+
message: `Spell "${spellName}" was not found in the grimoire. The schedule will be created, but the daemon will auto-disable it on the first fire. Check the spell name (try \`flo spell list\`).`,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
const [{ analyzeSpellPermissions }, { StepCommandRegistry }, { builtinCommands },] = await Promise.all([
|
|
34
|
+
import('../spells/core/permission-disclosure.js'),
|
|
35
|
+
import('../spells/core/step-command-registry.js'),
|
|
36
|
+
import('../spells/commands/index.js'),
|
|
37
|
+
]);
|
|
38
|
+
const stepRegistry = new StepCommandRegistry();
|
|
39
|
+
for (const cmd of builtinCommands) {
|
|
40
|
+
stepRegistry.register(cmd, 'built-in');
|
|
41
|
+
}
|
|
42
|
+
const report = analyzeSpellPermissions(loaded.definition, stepRegistry);
|
|
43
|
+
const result = await checkAcceptance(projectRoot, loaded.definition.name, report.permissionHash);
|
|
44
|
+
if (result.accepted) {
|
|
45
|
+
return { state: 'accepted', message: '' };
|
|
46
|
+
}
|
|
47
|
+
if (result.reason === 'no-acceptance') {
|
|
48
|
+
return {
|
|
49
|
+
state: 'never-accepted',
|
|
50
|
+
message: `Spell "${loaded.definition.name}" has not been accepted yet. Scheduled fires run non-interactively, so the first run will fail with "missing credentials". Run \`flo spell cast -n ${loaded.definition.name}\` once manually to accept permissions, then this schedule will work on the next fire.`,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
state: 'hash-mismatch',
|
|
55
|
+
message: `Spell "${loaded.definition.name}" permissions have changed since you last accepted them. Re-run \`flo spell cast -n ${loaded.definition.name}\` once to review and re-accept the new permissions; otherwise scheduled fires will fail.`,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
// Soft-fail: a Grimoire load error or permission analysis failure must
|
|
60
|
+
// never block schedule creation. Return a quiet check-failed state and
|
|
61
|
+
// let the create proceed. Surface the cause via console.debug so a
|
|
62
|
+
// developer chasing a regression can see why the check degraded
|
|
63
|
+
// without polluting normal CLI output.
|
|
64
|
+
console.debug(`[schedule-acceptance-check] check failed for ${spellName}: ${err.message}`);
|
|
65
|
+
return { state: 'check-failed', message: '' };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
//# sourceMappingURL=schedule-acceptance-check.js.map
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* Story #106: Encrypted Credential Storage
|
|
9
9
|
*/
|
|
10
10
|
import { createCipheriv, createDecipheriv, randomBytes, pbkdf2Sync, } from 'node:crypto';
|
|
11
|
-
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
11
|
+
import { readFileSync, writeFileSync, mkdirSync, statSync } from 'node:fs';
|
|
12
12
|
import { dirname } from 'node:path';
|
|
13
13
|
// ============================================================================
|
|
14
14
|
// Constants
|
|
@@ -55,6 +55,11 @@ export class CredentialStore {
|
|
|
55
55
|
filePath;
|
|
56
56
|
derivedKey = null;
|
|
57
57
|
data = null;
|
|
58
|
+
// Tracks the file mtime that produced `this.data`. `null` means the file
|
|
59
|
+
// didn't exist when we last read. refreshIfStale() compares against the
|
|
60
|
+
// current mtime to detect external writes (e.g. CLI subprocesses calling
|
|
61
|
+
// `flo spell credentials set` while the daemon's instance is alive — #1035).
|
|
62
|
+
lastReadMtimeMs = null;
|
|
58
63
|
constructor(options) {
|
|
59
64
|
this.filePath = options.filePath;
|
|
60
65
|
if (options.passphrase) {
|
|
@@ -70,6 +75,7 @@ export class CredentialStore {
|
|
|
70
75
|
throw new CredentialStoreError(`Passphrase must be at least ${MIN_PASSPHRASE_LENGTH} characters`, 'WEAK_PASSPHRASE');
|
|
71
76
|
}
|
|
72
77
|
this.data = this.readFile();
|
|
78
|
+
this.lastReadMtimeMs = this.fileMtimeMs();
|
|
73
79
|
const salt = Buffer.from(this.data.salt, 'hex');
|
|
74
80
|
this.derivedKey = deriveKey(passphrase, salt);
|
|
75
81
|
}
|
|
@@ -85,9 +91,17 @@ export class CredentialStore {
|
|
|
85
91
|
}
|
|
86
92
|
/**
|
|
87
93
|
* Store an encrypted credential.
|
|
94
|
+
*
|
|
95
|
+
* The refreshIfStale() call rebases on the latest on-disk state so we don't
|
|
96
|
+
* write back a snapshot that's missing concurrent additions. It is NOT a
|
|
97
|
+
* mutual-exclusion primitive: two processes calling store() on the same key
|
|
98
|
+
* concurrently still race, and the last writer wins. Cross-process locking
|
|
99
|
+
* is out of scope; the file write is small and the typical layout (one
|
|
100
|
+
* daemon reader + occasional CLI writers) makes the race window vanishing.
|
|
88
101
|
*/
|
|
89
102
|
async store(name, value, description) {
|
|
90
103
|
this.ensureUnlocked();
|
|
104
|
+
this.refreshIfStale();
|
|
91
105
|
const now = new Date().toISOString();
|
|
92
106
|
const encrypted = encrypt(value, this.derivedKey);
|
|
93
107
|
const existing = this.data.credentials[name];
|
|
@@ -105,6 +119,7 @@ export class CredentialStore {
|
|
|
105
119
|
*/
|
|
106
120
|
async get(name) {
|
|
107
121
|
this.ensureUnlocked();
|
|
122
|
+
this.refreshIfStale();
|
|
108
123
|
const entry = this.data.credentials[name];
|
|
109
124
|
if (!entry)
|
|
110
125
|
return undefined;
|
|
@@ -121,6 +136,7 @@ export class CredentialStore {
|
|
|
121
136
|
*/
|
|
122
137
|
async has(name) {
|
|
123
138
|
this.ensureUnlocked();
|
|
139
|
+
this.refreshIfStale();
|
|
124
140
|
return name in this.data.credentials;
|
|
125
141
|
}
|
|
126
142
|
/**
|
|
@@ -128,6 +144,7 @@ export class CredentialStore {
|
|
|
128
144
|
*/
|
|
129
145
|
async delete(name) {
|
|
130
146
|
this.ensureUnlocked();
|
|
147
|
+
this.refreshIfStale();
|
|
131
148
|
if (!(name in this.data.credentials))
|
|
132
149
|
return false;
|
|
133
150
|
delete this.data.credentials[name];
|
|
@@ -141,6 +158,7 @@ export class CredentialStore {
|
|
|
141
158
|
*/
|
|
142
159
|
async clear() {
|
|
143
160
|
this.ensureUnlocked();
|
|
161
|
+
this.refreshIfStale();
|
|
144
162
|
const count = Object.keys(this.data.credentials).length;
|
|
145
163
|
if (count === 0)
|
|
146
164
|
return 0;
|
|
@@ -153,6 +171,7 @@ export class CredentialStore {
|
|
|
153
171
|
*/
|
|
154
172
|
async list() {
|
|
155
173
|
this.ensureUnlocked();
|
|
174
|
+
this.refreshIfStale();
|
|
156
175
|
return Object.entries(this.data.credentials).map(([name, entry]) => ({
|
|
157
176
|
name,
|
|
158
177
|
description: entry.description,
|
|
@@ -166,6 +185,7 @@ export class CredentialStore {
|
|
|
166
185
|
*/
|
|
167
186
|
async allValues() {
|
|
168
187
|
this.ensureUnlocked();
|
|
188
|
+
this.refreshIfStale();
|
|
169
189
|
const values = [];
|
|
170
190
|
for (const entry of Object.values(this.data.credentials)) {
|
|
171
191
|
try {
|
|
@@ -265,6 +285,49 @@ export class CredentialStore {
|
|
|
265
285
|
writeFile(data) {
|
|
266
286
|
mkdirSync(dirname(this.filePath), { recursive: true });
|
|
267
287
|
writeFileSync(this.filePath, JSON.stringify(data, null, 2), { encoding: 'utf-8', mode: 0o600 });
|
|
288
|
+
// Adopt the just-written mtime so refreshIfStale() doesn't trigger an
|
|
289
|
+
// unnecessary re-read on the next operation through this instance.
|
|
290
|
+
this.lastReadMtimeMs = this.fileMtimeMs();
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Return the file's mtime in ms, or null when the file doesn't exist.
|
|
294
|
+
* Other errors (permissions, etc.) are surfaced — they signal a real problem
|
|
295
|
+
* worth raising rather than silently treating as "no file".
|
|
296
|
+
*/
|
|
297
|
+
fileMtimeMs() {
|
|
298
|
+
try {
|
|
299
|
+
return statSync(this.filePath).mtimeMs;
|
|
300
|
+
}
|
|
301
|
+
catch (err) {
|
|
302
|
+
if (err.code === 'ENOENT')
|
|
303
|
+
return null;
|
|
304
|
+
throw err;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* Reload `this.data` from disk when the file's mtime differs from what we
|
|
309
|
+
* last read. This is the per-call hook that keeps long-lived instances
|
|
310
|
+
* (the daemon's singleton CredentialStore — see #1035) consistent with
|
|
311
|
+
* writes made by CLI subprocesses.
|
|
312
|
+
*
|
|
313
|
+
* Limitations:
|
|
314
|
+
* - If another process rotated the passphrase, the salt in the reloaded
|
|
315
|
+
* data will mismatch our derivedKey. Subsequent decrypt() calls throw
|
|
316
|
+
* DECRYPTION_FAILED, which the resolver treats as missing — same UX as
|
|
317
|
+
* today's stale-daemon failure mode and only resolved by daemon restart.
|
|
318
|
+
* Rotation-aware reload would need the new passphrase, which we don't
|
|
319
|
+
* have post-construction; out of scope here.
|
|
320
|
+
* - Designed for local filesystems. Network mounts (NFS/SMB) can return
|
|
321
|
+
* coarse or stale mtimes via client caching, which would weaken the
|
|
322
|
+
* detection. The credentials file lives at `~/.moflo/credentials.json`
|
|
323
|
+
* and is expected to be local; network-mounted homedirs aren't supported.
|
|
324
|
+
*/
|
|
325
|
+
refreshIfStale() {
|
|
326
|
+
const current = this.fileMtimeMs();
|
|
327
|
+
if (current === this.lastReadMtimeMs)
|
|
328
|
+
return;
|
|
329
|
+
this.data = this.readFile();
|
|
330
|
+
this.lastReadMtimeMs = current;
|
|
268
331
|
}
|
|
269
332
|
}
|
|
270
333
|
export class CredentialStoreError extends Error {
|
package/dist/src/cli/version.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "moflo",
|
|
3
|
-
"version": "4.9.
|
|
3
|
+
"version": "4.9.33",
|
|
4
4
|
"description": "MoFlo — AI agent orchestration for Claude Code. A standalone, opinionated toolkit with semantic memory, learned routing, gates, spells, and the /flo issue-execution skill.",
|
|
5
5
|
"main": "dist/src/cli/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -97,7 +97,7 @@
|
|
|
97
97
|
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
|
98
98
|
"@typescript-eslint/parser": "^7.18.0",
|
|
99
99
|
"eslint": "^8.0.0",
|
|
100
|
-
"moflo": "^4.9.
|
|
100
|
+
"moflo": "^4.9.32",
|
|
101
101
|
"tsx": "^4.21.0",
|
|
102
102
|
"typescript": "^5.9.3",
|
|
103
103
|
"vitest": "^4.0.0"
|