moflo 4.9.29 → 4.9.31

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.
@@ -132,6 +132,20 @@ export class FlagEmbedding {
132
132
  const session = await InferenceSession.create(modelPath, {
133
133
  executionProviders: ['cpu'],
134
134
  graphOptimizationLevel: 'all',
135
+ // Suppress ORT's WARNING-level chatter on session bring-up. ORT 1.26.0
136
+ // emits a `[W:onnxruntime ... GetPciBusId] Skipping pci_bus_id` line on
137
+ // Linux Azure VMs whose `/sys/devices/...` filenames don't match the
138
+ // `[0-9a-f]+:[0-9a-f]+:[0-9a-f]+.[0-9a-f]+` PCI pattern; the warning
139
+ // is harmless (we run on the CPU EP only) but leaks to stderr and
140
+ // confuses users into thinking moflo is broken. 0=verbose, 1=info,
141
+ // 2=warning (default), 3=error, 4=fatal — error is the right level
142
+ // because session bring-up genuine failures still surface.
143
+ //
144
+ // Re-audit when bumping fastembed or onnxruntime-node: ORT
145
+ // occasionally promotes deprecation / model-compatibility notices to
146
+ // WARNING that would now be hidden. If a model upgrade ever lands
147
+ // alongside this suppression, drop to 2 once to scan the output.
148
+ logSeverityLevel: 3,
135
149
  });
136
150
  return new FlagEmbedding(tokenizer, session);
137
151
  }
@@ -112,6 +112,30 @@ export async function bridgeStoreEntry(options) {
112
112
  const now = Date.now();
113
113
  const guardResult = await guardValidate(registry, 'store', { key, namespace, size: value.length });
114
114
  if (!guardResult.allowed) {
115
+ // Dedupe rejection means the same `(op, params)` write just succeeded
116
+ // — the caller's data is already durable. Look up the existing row so
117
+ // we can return its id with success:true; this matches what the
118
+ // dedupe semantically means (a no-op, not a failure). Other rejection
119
+ // reasons (rate limit, etc.) remain real failures. Match the literal
120
+ // reason string rather than a substring regex so a future rejection
121
+ // worded with "duplicate mutation" but different semantics doesn't
122
+ // get silently swallowed.
123
+ if (guardResult.reason === 'duplicate mutation within dedupe window') {
124
+ let existingId = null;
125
+ const probe = ctx.db.prepare(`SELECT id FROM memory_entries WHERE namespace = ? AND key = ? AND status = 'active' LIMIT 1`);
126
+ try {
127
+ probe.bind([namespace, key]);
128
+ if (probe.step()) {
129
+ existingId = String(probe.getAsObject().id);
130
+ }
131
+ }
132
+ finally {
133
+ probe.free();
134
+ }
135
+ if (existingId) {
136
+ return { success: true, id: existingId };
137
+ }
138
+ }
115
139
  return { success: false, id, error: `MutationGuard rejected: ${guardResult.reason}` };
116
140
  }
117
141
  const resolved = await resolveBridgeEmbedding(value, options.precomputedEmbedding, options.generateEmbeddingFlag, namespace);
@@ -120,6 +144,48 @@ export async function bridgeStoreEntry(options) {
120
144
  }
121
145
  const { json: embeddingJson, dimensions, model } = resolved;
122
146
  const embeddingResponse = embeddingResponseFrom(resolved);
147
+ // Idempotency guard, mirrors the one in `memory-initializer.ts`'s raw-
148
+ // sql.js fallback. When the daemon route just wrote this exact row but
149
+ // the client missed the ack, we land here with the row already on disk;
150
+ // a plain INSERT would trip UNIQUE and surface as `[moflo] bridge
151
+ // operation failed:` stderr noise even though the data is durable.
152
+ // Probe first so withDb never sees the throw.
153
+ //
154
+ // Limitations carried forward: only `content` is compared, not `tags`
155
+ // or `ttl`. The targeted scenario is the same caller's request being
156
+ // processed twice (daemon write + client retry), where every option is
157
+ // identical by definition — a different caller varying `tags` after a
158
+ // missed-ack would still see this as an idempotent no-op rather than
159
+ // an update. `cached: false, attested: false` because the prior writer
160
+ // already ran post-persist bookkeeping; this process's in-memory cache
161
+ // stays cold for one retrieve until the read path warms it (perf only,
162
+ // not correctness).
163
+ if (!options.upsert) {
164
+ let existingId = null;
165
+ let existingContent = null;
166
+ const probe = ctx.db.prepare(`SELECT id, content FROM memory_entries WHERE namespace = ? AND key = ? AND status = 'active' LIMIT 1`);
167
+ try {
168
+ probe.bind([namespace, key]);
169
+ if (probe.step()) {
170
+ const row = probe.getAsObject();
171
+ existingId = String(row.id);
172
+ existingContent = row.content;
173
+ }
174
+ }
175
+ finally {
176
+ probe.free();
177
+ }
178
+ if (existingId && existingContent === value) {
179
+ return {
180
+ success: true,
181
+ id: existingId,
182
+ embedding: embeddingResponse,
183
+ guarded: true,
184
+ cached: false,
185
+ attested: false,
186
+ };
187
+ }
188
+ }
123
189
  const insertSql = options.upsert
124
190
  ? `INSERT OR REPLACE INTO memory_entries (
125
191
  id, key, namespace, content, type,
@@ -1650,6 +1650,42 @@ export async function storeEntry(options) {
1650
1650
  embeddingModel = embResult.model;
1651
1651
  }
1652
1652
  }
1653
+ // Idempotency guard. By the time we reach the raw-sql.js fallback, an
1654
+ // earlier write attempt — daemon route via `tryDaemonStore`, or bridge
1655
+ // via `bridgeStoreEntry` — may have already persisted this exact row to
1656
+ // disk. If a post-persist throw escaped the bridge's inner guards (#994,
1657
+ // #982), `bridgeStoreEntry` returned null and we landed here. Re-running
1658
+ // a plain INSERT would then trip the UNIQUE constraint on `(namespace,
1659
+ // key)` and surface as `exit 1` even though the data is durable on disk
1660
+ // — exactly the cascade described in `bridge-entries.ts:205`. If the
1661
+ // existing row matches the value the caller asked us to write, treat
1662
+ // this as a successful no-op and propagate the existing id instead of
1663
+ // re-inserting. If the content differs, fall through to INSERT — the
1664
+ // UNIQUE error is then a real "key already taken with other content"
1665
+ // signal that the caller deserves to see.
1666
+ if (!upsert) {
1667
+ let existingRow = null;
1668
+ const probe = db.prepare(`SELECT id, content FROM memory_entries WHERE namespace = ? AND key = ? AND status = 'active' LIMIT 1`);
1669
+ try {
1670
+ probe.bind([namespace, key]);
1671
+ if (probe.step()) {
1672
+ existingRow = probe.getAsObject();
1673
+ }
1674
+ }
1675
+ finally {
1676
+ probe.free();
1677
+ }
1678
+ if (existingRow && existingRow.content === value) {
1679
+ db.close();
1680
+ return {
1681
+ success: true,
1682
+ id: String(existingRow.id),
1683
+ embedding: embeddingJson
1684
+ ? { dimensions: embeddingDimensions, model: embeddingModel }
1685
+ : undefined,
1686
+ };
1687
+ }
1688
+ }
1653
1689
  // Insert or update entry (upsert mode uses REPLACE)
1654
1690
  const insertSql = upsert
1655
1691
  ? `INSERT OR REPLACE INTO memory_entries (
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Credential Validation
3
+ *
4
+ * Shape checks applied to values pulled from the encrypted credential store
5
+ * before they are promoted to `process.env`. Two layers:
6
+ *
7
+ * 1. **Author-declared format** (preferred): the YAML prereq sets
8
+ * `format: jwt`, and the validator enforces JWT shape + expiry. Any
9
+ * non-JWT value (e.g. a value with no dots) is rejected outright,
10
+ * catching the failure mode where a stored value isn't even a JWT
11
+ * and the spell would otherwise fail mid-cast with a 401.
12
+ *
13
+ * 2. **Conservative heuristics** (fallback when no format is declared):
14
+ * - JWT-shaped values (3 base64url segments) get their `exp` claim
15
+ * parsed and rejected when expired.
16
+ * - Env keys ending in `_URL` must parse via the WHATWG `URL`
17
+ * constructor and have a non-empty host.
18
+ * Anything else passes through.
19
+ *
20
+ * Story #1007: catch expired JWTs that survived past their TTL.
21
+ * Story #1009: extend to catch values that aren't even JWT-shaped when
22
+ * the prereq has declared `format: jwt`.
23
+ */
24
+ const VALID_JWT_SEGMENT = /^[A-Za-z0-9_-]+$/;
25
+ export function validateStoredCredential(envKey, value, format) {
26
+ if (format === 'jwt') {
27
+ return validateJwtFormat(value);
28
+ }
29
+ if (envKey.endsWith('_URL')) {
30
+ return validateUrlValue(value);
31
+ }
32
+ if (looksLikeJwt(value)) {
33
+ return validateJwtExpiry(value);
34
+ }
35
+ return { valid: true };
36
+ }
37
+ function validateJwtFormat(value) {
38
+ if (!looksLikeJwt(value)) {
39
+ return {
40
+ valid: false,
41
+ reason: 'stored value is not a JWT (expected three base64url segments separated by ".")',
42
+ };
43
+ }
44
+ return validateJwtExpiry(value);
45
+ }
46
+ function validateUrlValue(value) {
47
+ try {
48
+ const parsed = new URL(value);
49
+ if (!parsed.host) {
50
+ return { valid: false, reason: 'stored value is not a valid URL (missing host)' };
51
+ }
52
+ return { valid: true };
53
+ }
54
+ catch {
55
+ return { valid: false, reason: 'stored value is not a valid URL' };
56
+ }
57
+ }
58
+ function looksLikeJwt(value) {
59
+ const parts = value.split('.');
60
+ if (parts.length !== 3)
61
+ return false;
62
+ return parts.every(p => p.length > 0 && VALID_JWT_SEGMENT.test(p));
63
+ }
64
+ function validateJwtExpiry(value) {
65
+ const exp = readJwtExp(value);
66
+ if (exp == null)
67
+ return { valid: true };
68
+ const expiryMs = exp * 1000;
69
+ const now = Date.now();
70
+ if (expiryMs >= now)
71
+ return { valid: true };
72
+ return { valid: false, reason: `JWT expired ${formatDuration(now - expiryMs)} ago` };
73
+ }
74
+ function readJwtExp(value) {
75
+ try {
76
+ const payload = value.split('.')[1];
77
+ const padLen = (4 - (payload.length % 4)) % 4;
78
+ const b64 = payload.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat(padLen);
79
+ const decoded = Buffer.from(b64, 'base64').toString('utf-8');
80
+ const parsed = JSON.parse(decoded);
81
+ if (typeof parsed === 'object' && parsed !== null) {
82
+ const exp = parsed.exp;
83
+ if (typeof exp === 'number' && Number.isFinite(exp))
84
+ return exp;
85
+ }
86
+ return null;
87
+ }
88
+ catch {
89
+ return null;
90
+ }
91
+ }
92
+ function formatDuration(ms) {
93
+ const s = Math.floor(ms / 1000);
94
+ if (s < 60)
95
+ return `${s}s`;
96
+ const m = Math.floor(s / 60);
97
+ if (m < 60)
98
+ return `${m}m`;
99
+ const h = Math.floor(m / 60);
100
+ if (h < 24)
101
+ return `${h}h ${m % 60}m`;
102
+ const d = Math.floor(h / 24);
103
+ return `${d}d ${h % 24}h`;
104
+ }
105
+ //# sourceMappingURL=credential-validation.js.map
@@ -18,6 +18,7 @@ import { access } from 'node:fs/promises';
18
18
  import { promisify } from 'node:util';
19
19
  import { acquireTTYLock } from './tty-lock.js';
20
20
  import { readLineFromStdin } from './stdin-reader.js';
21
+ import { validateStoredCredential } from './credential-validation.js';
21
22
  const execFileAsync = promisify(execFile);
22
23
  /**
23
24
  * Check whether a CLI command is available on the system PATH.
@@ -71,6 +72,7 @@ export function compilePrerequisiteSpec(spec) {
71
72
  description: spec.description,
72
73
  promptOnMissing,
73
74
  envKey,
75
+ format: spec.format,
74
76
  };
75
77
  }
76
78
  function defaultHintForDetect(spec) {
@@ -190,19 +192,28 @@ export async function resolveUnmetPrerequisites(prerequisites, options = {}) {
190
192
  // lets us skip a re-check of the cheap env detector.
191
193
  // `forceCredentialReprompt` bypasses this step so users can rotate or
192
194
  // correct stored credentials by re-running through the prompt path.
195
+ // Stored values are shape-validated (#1007): expired JWTs and malformed
196
+ // _URL values are rejected and surface in the preflight banner, so the
197
+ // resolver re-prompts instead of silently feeding garbage to the spell.
193
198
  const resolvedFromStoreNames = [];
194
199
  const storeResolved = new Set();
200
+ const rejectedFromStore = [];
195
201
  if (credentials && !options.forceCredentialReprompt) {
196
202
  await Promise.all(unmetIndices.map(async (i) => {
197
203
  const prereq = prerequisites[i];
198
204
  if (!prereq.envKey)
199
205
  return;
200
206
  const stored = await credentials.get(prereq.envKey);
201
- if (typeof stored === 'string' && stored.length > 0) {
202
- process.env[prereq.envKey] = stored;
203
- storeResolved.add(i);
204
- resolvedFromStoreNames.push(prereq.name);
207
+ if (typeof stored !== 'string' || stored.length === 0)
208
+ return;
209
+ const validation = validateStoredCredential(prereq.envKey, stored, prereq.format);
210
+ if (!validation.valid) {
211
+ rejectedFromStore.push({ envKey: prereq.envKey, reason: validation.reason });
212
+ return;
205
213
  }
214
+ process.env[prereq.envKey] = stored;
215
+ storeResolved.add(i);
216
+ resolvedFromStoreNames.push(prereq.name);
206
217
  }));
207
218
  }
208
219
  const stillUnmetIdx = unmetIndices.filter(i => !storeResolved.has(i));
@@ -218,7 +229,7 @@ export async function resolveUnmetPrerequisites(prerequisites, options = {}) {
218
229
  if (promptableEnvKeys.length > 0) {
219
230
  return {
220
231
  ok: false,
221
- message: formatMissingCredentialMessage(promptableEnvKeys, stillUnmet),
232
+ message: formatMissingCredentialMessage(promptableEnvKeys, stillUnmet, rejectedFromStore),
222
233
  resolvedNames: resolvedFromStoreNames,
223
234
  errorCode: 'MISSING_CREDENTIAL',
224
235
  missingCredentials: promptableEnvKeys,
@@ -231,6 +242,12 @@ export async function resolveUnmetPrerequisites(prerequisites, options = {}) {
231
242
  };
232
243
  }
233
244
  printPreflightBanner(log, stillUnmet.length);
245
+ if (rejectedFromStore.length > 0) {
246
+ for (const r of rejectedFromStore) {
247
+ log(` Stored ${r.envKey} rejected — ${r.reason}. Re-enter below.`);
248
+ }
249
+ log('');
250
+ }
234
251
  const promptLine = options.promptLine ?? readLineFromStdin;
235
252
  const promptedNames = [];
236
253
  const promptableSet = new Set(promptable);
@@ -309,13 +326,20 @@ export async function resolveUnmetPrerequisites(prerequisites, options = {}) {
309
326
  }
310
327
  return { ok: true, resolvedNames: [...resolvedFromStoreNames, ...promptedNames] };
311
328
  }
312
- function formatMissingCredentialMessage(envKeys, prereqs) {
329
+ function formatMissingCredentialMessage(envKeys, prereqs, rejectedFromStore) {
313
330
  const lines = ['Missing credentials (cannot prompt — non-interactive run):'];
314
331
  for (const key of envKeys) {
315
332
  const prereq = prereqs.find(p => p.envKey === key);
316
333
  const label = `${prereq?.name ?? key} (${key})`;
317
334
  appendPrereqLine(lines, label, prereq?.installHint, prereq?.url);
318
335
  }
336
+ if (rejectedFromStore.length > 0) {
337
+ lines.push('');
338
+ lines.push('Stored value(s) rejected as stale or invalid:');
339
+ for (const r of rejectedFromStore) {
340
+ lines.push(` - ${r.envKey}: ${r.reason}`);
341
+ }
342
+ }
319
343
  lines.push('');
320
344
  lines.push('Prime these by casting the spell once interactively, or run:');
321
345
  lines.push(' flo spell credentials set <name>');
@@ -6,6 +6,7 @@
6
6
  * deliberately small so step validation can delegate here.
7
7
  */
8
8
  const VALID_DETECT_TYPES = ['env', 'command', 'file'];
9
+ const VALID_FORMATS = ['jwt'];
9
10
  export function validatePrerequisites(prereqs, errors, path) {
10
11
  if (!Array.isArray(prereqs)) {
11
12
  errors.push({ path, message: 'prerequisites must be an array' });
@@ -39,6 +40,13 @@ export function validatePrerequisites(prereqs, errors, path) {
39
40
  if (p.promptOnMissing !== undefined && typeof p.promptOnMissing !== 'boolean') {
40
41
  errors.push({ path: `${pPath}.promptOnMissing`, message: 'promptOnMissing must be a boolean' });
41
42
  }
43
+ if (p.format !== undefined
44
+ && !VALID_FORMATS.includes(p.format)) {
45
+ errors.push({
46
+ path: `${pPath}.format`,
47
+ message: `format must be one of: ${VALID_FORMATS.join(', ')}`,
48
+ });
49
+ }
42
50
  const detect = p.detect;
43
51
  if (!detect || typeof detect !== 'object') {
44
52
  errors.push({ path: `${pPath}.detect`, message: 'detect is required and must be an object' });
@@ -66,6 +74,14 @@ export function validatePrerequisites(prereqs, errors, path) {
66
74
  errors.push({ path: `${pPath}.detect.path`, message: 'detect.path is required for file detector' });
67
75
  }
68
76
  }
77
+ // `format` only applies to stored env values — silently ignoring it on
78
+ // command/file detectors would mask author mistakes.
79
+ if (p.format !== undefined && detect.type !== 'env') {
80
+ errors.push({
81
+ path: `${pPath}.format`,
82
+ message: 'format is only valid on env-type prerequisites',
83
+ });
84
+ }
69
85
  });
70
86
  }
71
87
  //# sourceMappingURL=prerequisites.js.map
@@ -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.29';
5
+ export const VERSION = '4.9.31';
6
6
  //# sourceMappingURL=version.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "moflo",
3
- "version": "4.9.29",
3
+ "version": "4.9.31",
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",
@@ -84,7 +84,7 @@
84
84
  "@typescript-eslint/eslint-plugin": "^7.18.0",
85
85
  "@typescript-eslint/parser": "^7.18.0",
86
86
  "eslint": "^8.0.0",
87
- "moflo": "^4.9.28",
87
+ "moflo": "^4.9.30",
88
88
  "tsx": "^4.21.0",
89
89
  "typescript": "^5.9.3",
90
90
  "vitest": "^4.0.0"