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.
@@ -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 {
@@ -2,5 +2,5 @@
2
2
  * Auto-generated by build. Do not edit manually.
3
3
  * Source of truth: root package.json → scripts/sync-version.mjs
4
4
  */
5
- export const VERSION = '4.9.32';
5
+ export const VERSION = '4.9.33';
6
6
  //# sourceMappingURL=version.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "moflo",
3
- "version": "4.9.32",
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.31",
100
+ "moflo": "^4.9.32",
101
101
  "tsx": "^4.21.0",
102
102
  "typescript": "^5.9.3",
103
103
  "vitest": "^4.0.0"