ai-runtime-engine 2.9.0 → 3.0.1
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/CHANGELOG.md +108 -0
- package/README.md +30 -0
- package/dist/agents/admit.d.ts +9 -1
- package/dist/agents/admit.js +10 -2
- package/dist/agents/envelope.d.ts +21 -0
- package/dist/agents/envelope.js +39 -5
- package/dist/agents/finding.d.ts +9 -3
- package/dist/agents/finding.js +14 -3
- package/dist/agents/worker.d.ts +3 -0
- package/dist/agents/worker.js +4 -1
- package/dist/cli/cli.js +8 -1
- package/dist/cli/commands/cleanup.js +11 -3
- package/dist/cli/commands/doctor.js +1 -1
- package/dist/cli/commands/run.js +6 -0
- package/dist/cli/commands/skills.js +9 -2
- package/dist/cli/interactive/repl.js +12 -2
- package/dist/cli/interactive/session.d.ts +2 -0
- package/dist/cli/interactive/session.js +6 -2
- package/dist/config/schema.js +19 -1
- package/dist/conversations/conversations.d.ts +6 -1
- package/dist/conversations/conversations.js +15 -8
- package/dist/core/fallback/fallback.d.ts +7 -0
- package/dist/core/fallback/fallback.js +15 -2
- package/dist/core/health/monitor.d.ts +6 -0
- package/dist/core/health/monitor.js +15 -2
- package/dist/core/router/confidence.js +10 -5
- package/dist/core/router/dimensions.d.ts +3 -1
- package/dist/core/router/dimensions.js +15 -5
- package/dist/core/router/filter.js +25 -6
- package/dist/core/router/normalize.js +2 -0
- package/dist/core/router/router.js +16 -2
- package/dist/core/router/scorer.d.ts +3 -0
- package/dist/core/router/scorer.js +17 -2
- package/dist/discovery/openapi.js +3 -2
- package/dist/executions/agentTasks.d.ts +4 -4
- package/dist/generation/generateAdapter.js +3 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.js +3 -2
- package/dist/mcp/protocol.js +4 -1
- package/dist/memory/bm25.d.ts +7 -0
- package/dist/memory/bm25.js +17 -1
- package/dist/memory/memory.d.ts +7 -1
- package/dist/memory/memory.js +18 -4
- package/dist/orchestration/orchestrator.d.ts +2 -1
- package/dist/orchestration/planner.d.ts +2 -1
- package/dist/plugin/ai.d.ts +6 -0
- package/dist/plugin/ai.js +17 -2
- package/dist/providers/estimate.d.ts +25 -0
- package/dist/providers/estimate.js +55 -0
- package/dist/providers/factory.d.ts +3 -0
- package/dist/providers/factory.js +26 -5
- package/dist/providers/httpClient.js +4 -0
- package/dist/providers/httpProvider.js +4 -3
- package/dist/providers/mock/mockProvider.js +4 -3
- package/dist/runtime/config.d.ts +4 -3
- package/dist/runtime/config.js +14 -23
- package/dist/runtime/events.d.ts +6 -0
- package/dist/runtime/runtime.d.ts +43 -5
- package/dist/runtime/runtime.js +133 -25
- package/dist/runtime/types.d.ts +8 -1
- package/dist/store/area.d.ts +1 -1
- package/dist/store/area.js +34 -10
- package/dist/store/crypto.d.ts +27 -13
- package/dist/store/crypto.js +101 -23
- package/dist/store/errors.d.ts +11 -0
- package/dist/store/errors.js +14 -0
- package/dist/store/store.d.ts +21 -1
- package/dist/store/store.js +74 -19
- package/dist/telemetry/sinks/file.js +4 -2
- package/dist/telemetry/sinks/otlp.d.ts +12 -2
- package/dist/telemetry/sinks/otlp.js +39 -24
- package/dist/telemetry/telemetry.d.ts +5 -0
- package/dist/telemetry/telemetry.js +4 -0
- package/dist/tools/builtins/shell.d.ts +30 -3
- package/dist/tools/builtins/shell.js +218 -7
- package/dist/tools/untrusted.d.ts +1 -1
- package/dist/tools/untrusted.js +5 -3
- package/dist/types.d.ts +14 -0
- package/dist/verification/verify.js +10 -3
- package/docs/GUIDE.md +66 -1
- package/docs/README.md +1 -1
- package/docs/architecture.md +5 -1
- package/docs/router.md +1 -1
- package/docs/security.md +26 -7
- package/package.json +4 -2
package/dist/runtime/runtime.js
CHANGED
|
@@ -55,6 +55,9 @@ import { ExecutionStore } from '../executions/store.js';
|
|
|
55
55
|
import { RESUMABLE, TERMINAL } from '../executions/execution.js';
|
|
56
56
|
import { captureCheckpoint, reconcile } from '../executions/checkpoint.js';
|
|
57
57
|
import { AGENT_TERMINAL, AGENT_RESUMABLE, agentTaskView } from '../agents/task.js';
|
|
58
|
+
import { synthesizeAgents } from '../agents/synthesize.js';
|
|
59
|
+
import { isDerivedAgentId } from '../agents/roles.js';
|
|
60
|
+
import { resolveConflicts } from '../agents/finding.js';
|
|
58
61
|
import { parseAgentTasks } from '../executions/agentTasks.js';
|
|
59
62
|
import { stepIdentity } from '../agents/worker.js';
|
|
60
63
|
import { hashOf } from '../util/hash.js';
|
|
@@ -91,7 +94,7 @@ import { loadModelProfile, resolveModelDirective, directiveToOverrides } from '.
|
|
|
91
94
|
import { HashEmbedder } from '../memory/embedders/hash.js';
|
|
92
95
|
import { HttpEmbedder } from '../memory/embedders/http.js';
|
|
93
96
|
import { Credential } from '../security/credentials.js';
|
|
94
|
-
import { makeCodec
|
|
97
|
+
import { makeCodec } from '../store/crypto.js';
|
|
95
98
|
import { AIError } from '../core/fallback/errors.js';
|
|
96
99
|
import { tokenize } from '../memory/bm25.js';
|
|
97
100
|
let runCounter = 0;
|
|
@@ -211,7 +214,7 @@ export class Runtime {
|
|
|
211
214
|
});
|
|
212
215
|
this._modelProfile = loadModelProfile(root); // models.md (per-mode/task routing); undefined if absent
|
|
213
216
|
this._memory = new MemoryStore(this._store, clock, this.buildEmbedder(options.ai?.fetchImpl));
|
|
214
|
-
this._conversations = new ConversationStore(this._store.conversations(), clock);
|
|
217
|
+
this._conversations = new ConversationStore(this._store.conversations(), clock, { withLock: (fn) => this._store.withLock(fn) });
|
|
215
218
|
this._executions = new ExecutionStore(this._store.executions(), { clock });
|
|
216
219
|
this._artifacts = new ArtifactStore(this._store.artifacts(), clock);
|
|
217
220
|
this._learning = new LearningStore(this._store.learning());
|
|
@@ -288,7 +291,9 @@ export class Runtime {
|
|
|
288
291
|
const key = new Credential(cfg.keyEnv, this.env).use();
|
|
289
292
|
if (!key)
|
|
290
293
|
throw new AIError(`storage encryption is enabled but the key env var ${cfg.keyEnv} is not set`, { category: 'CONFIG' });
|
|
291
|
-
|
|
294
|
+
// Pass the SECRET (not a pre-derived key) so the codec uses the v2 scrypt scheme; it still reads any
|
|
295
|
+
// legacy aienc1 records transparently and upgrades them to aienc2 as they are rewritten.
|
|
296
|
+
return makeCodec(key);
|
|
292
297
|
}
|
|
293
298
|
/** Build a Runtime from a workspace: load config (.ai-runtime/config.yaml > root fallback), detect workspace. */
|
|
294
299
|
static async load(options = {}) {
|
|
@@ -460,6 +465,12 @@ export class Runtime {
|
|
|
460
465
|
registerAgent(id, def) {
|
|
461
466
|
if (!/^[a-z0-9][a-z0-9_-]{0,32}$/.test(id))
|
|
462
467
|
throw new AIError(`invalid agent id '${id}' — use lowercase letters, digits, '_' or '-' (max 33 chars)`, { category: 'CONFIG' });
|
|
468
|
+
// The same reservation the config schema enforces. Without it here, a host could register
|
|
469
|
+
// `auto_investigate` and — with decompose on — produce two catalog rows with one id, where the
|
|
470
|
+
// derived one wins both lookups and the SAME step resolves to a different envelope depending on a
|
|
471
|
+
// flag. Reserving a namespace in only one of its two doors reserves nothing.
|
|
472
|
+
if (isDerivedAgentId(id))
|
|
473
|
+
throw new AIError(`agent id '${id}' uses the reserved 'auto_' prefix — that namespace belongs to agents the runtime derives`, { category: 'CONFIG' });
|
|
463
474
|
this.agentDefs.set(id, def);
|
|
464
475
|
return this;
|
|
465
476
|
}
|
|
@@ -471,13 +482,34 @@ export class Runtime {
|
|
|
471
482
|
* This run's agent envelopes. THE ONLY call site of `narrowEnvelope` — never re-derive an inner
|
|
472
483
|
* catalog, a permission clamp, or a reservation anywhere else (see the header of agents/envelope.ts).
|
|
473
484
|
*/
|
|
474
|
-
|
|
475
|
-
|
|
485
|
+
/**
|
|
486
|
+
* Agents synthesized from the registry for this goal (Phase 3.7). Empty unless
|
|
487
|
+
* `runtime.agents.decompose` is on — so with the flag off nothing about planning changes.
|
|
488
|
+
*
|
|
489
|
+
* Deterministic and offline: no model call, no clock, no randomness. That is a requirement, not a
|
|
490
|
+
* preference — a derived definition is hashed into `agentDefHash`, and a resume that synthesized
|
|
491
|
+
* even slightly differently would discard every persisted inner plan as stale.
|
|
492
|
+
*/
|
|
493
|
+
derivedAgents() {
|
|
494
|
+
if (!this.agentsEnabled || this.settingsValue.agents?.decompose !== true)
|
|
495
|
+
return [];
|
|
496
|
+
const capabilities = this._capabilities.list().map((c) => ({ id: c.id, effects: c.effects, providers: this._capabilities.providersOf(c.id).map((p) => p.providerId) }));
|
|
497
|
+
return synthesizeAgents({ capabilities, parentTools: this._tools.ids() });
|
|
498
|
+
}
|
|
499
|
+
agentEnvelopes(policy, derived = []) {
|
|
500
|
+
// Derived definitions are passed IN and never stored: `agentDefs` lives for the process, so writing
|
|
501
|
+
// a per-goal agent into it would leak that agent into every later run on this Runtime.
|
|
502
|
+
const entries = [
|
|
503
|
+
...[...this.agentDefs].map(([id, d]) => [id, d, 'authored']),
|
|
504
|
+
...derived.map((d) => [d.id, d.definition, 'derived']),
|
|
505
|
+
];
|
|
506
|
+
if (!this.agentsEnabled || entries.length === 0)
|
|
476
507
|
return [];
|
|
477
508
|
const routing = this.effectiveRouting();
|
|
478
|
-
return
|
|
509
|
+
return entries.map(([id, definition, provenance]) => narrowEnvelope({
|
|
479
510
|
agentId: id,
|
|
480
511
|
definition,
|
|
512
|
+
provenance,
|
|
481
513
|
parentTools: this._tools.ids(),
|
|
482
514
|
parentSkills: this.skills().map((sk) => ({ id: sk.id, ...(sk.tools ? { tools: sk.tools } : {}) })),
|
|
483
515
|
parentPermissions: policy.permissions,
|
|
@@ -594,11 +626,13 @@ export class Runtime {
|
|
|
594
626
|
return [...this.mcpWarnings, ...this._mcp.warningsList()];
|
|
595
627
|
}
|
|
596
628
|
/**
|
|
597
|
-
* Release long-lived resources
|
|
598
|
-
*
|
|
629
|
+
* Release long-lived resources: MCP stdio child processes, and any batching telemetry sink (so a
|
|
630
|
+
* short-lived run does not drop OTLP events buffered below the batch threshold). A one-shot CLI command
|
|
631
|
+
* and the REPL both call this on completion/exit.
|
|
599
632
|
*/
|
|
600
633
|
async close() {
|
|
601
634
|
await this._mcp.close();
|
|
635
|
+
await this._ai.close();
|
|
602
636
|
}
|
|
603
637
|
/** Skills whose required tools are all registered. */
|
|
604
638
|
skills() {
|
|
@@ -621,9 +655,9 @@ export class Runtime {
|
|
|
621
655
|
if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(manifest.id))
|
|
622
656
|
throw new AIError(`unsafe skill id: ${JSON.stringify(manifest.id)}`, { category: 'CONFIG' });
|
|
623
657
|
const dir = join(this.workspaceRoot, '.ai-runtime', 'skills');
|
|
624
|
-
mkdirSync(dir, { recursive: true });
|
|
658
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
625
659
|
const path = join(dir, `${manifest.id}.skill.yaml`);
|
|
626
|
-
writeFileSync(path, stringifyYaml(manifest));
|
|
660
|
+
writeFileSync(path, stringifyYaml(manifest), { mode: 0o600 });
|
|
627
661
|
return path;
|
|
628
662
|
}
|
|
629
663
|
/** The ACTION-capability registry (Phase 3.1): what this runtime can DO, and who provides it. */
|
|
@@ -800,6 +834,12 @@ export class Runtime {
|
|
|
800
834
|
streamedText += chunk;
|
|
801
835
|
this.emitter.emit({ type: 'response.delta', runId, text: chunk });
|
|
802
836
|
};
|
|
837
|
+
// A failed streamed attempt's partial text is discarded: reset the accumulator so the survivor's
|
|
838
|
+
// stream is what `streamed` is compared against, and tell the host to clear the abandoned segment.
|
|
839
|
+
runRequest.onStreamAbandoned = ({ providerId, model }) => {
|
|
840
|
+
streamedText = '';
|
|
841
|
+
this.emitter.emit({ type: 'response.stream_abandoned', runId, providerId, model });
|
|
842
|
+
};
|
|
803
843
|
}
|
|
804
844
|
const runResult = await this._ai.run(runRequest);
|
|
805
845
|
this.calibrate(runRequest.system, text, runResult);
|
|
@@ -1028,6 +1068,10 @@ export class Runtime {
|
|
|
1028
1068
|
exec.completedSteps = [...exec.completedSteps, record.stepId];
|
|
1029
1069
|
checkpointIfMoved();
|
|
1030
1070
|
}
|
|
1071
|
+
// A finished task is the only moment new findings can appear, so it is the only moment two of
|
|
1072
|
+
// them can start disagreeing.
|
|
1073
|
+
if (AGENT_TERMINAL.has(record.state))
|
|
1074
|
+
this.resolveFindingConflicts(exec);
|
|
1031
1075
|
commit();
|
|
1032
1076
|
},
|
|
1033
1077
|
/**
|
|
@@ -1070,9 +1114,10 @@ export class Runtime {
|
|
|
1070
1114
|
...(routing ? { routing } : {}),
|
|
1071
1115
|
...(partial ? { partial: true } : {}),
|
|
1072
1116
|
...(this.approval ? { approval: this.approval } : {}),
|
|
1073
|
-
// Phase 3.1
|
|
1074
|
-
// always on but only fires on a validation failure, adding metadata to an unchanged error.
|
|
1075
|
-
|
|
1117
|
+
// Phase 3.1, ON by default from 3.0.0 (`runtime.capabilities.catalog: false` removes it); the gap
|
|
1118
|
+
// resolver is always on but only fires on a validation failure, adding metadata to an unchanged error.
|
|
1119
|
+
// 3.0.0: ON by default. `catalog: false` removes it — the only way to get the 2.9.0 prompt back.
|
|
1120
|
+
...(this.settingsValue.capabilities?.catalog !== false ? { capabilityCatalog: this.capabilityCatalogText() } : {}),
|
|
1076
1121
|
// Phase 3.3: the derived-requirement block (opt-in, pre-rendered + clamped). Absent ⇒ the
|
|
1077
1122
|
// OrchestrateInput/PlannerInput objects are key-identical to 2.5.1.
|
|
1078
1123
|
...(requiredCapabilities ? { requiredCapabilities } : {}),
|
|
@@ -1102,11 +1147,19 @@ export class Runtime {
|
|
|
1102
1147
|
this._learning.record({ goalType: this.goalType(goal), mode, ok: outcome.status === 'completed', skills: this.planSkillRefs(outcome.plan) });
|
|
1103
1148
|
}
|
|
1104
1149
|
/**
|
|
1105
|
-
* A capped, FENCED action-capability snapshot for the planner prompt (Phase 3.1
|
|
1106
|
-
*
|
|
1107
|
-
*
|
|
1150
|
+
* A capped, FENCED action-capability snapshot for the planner prompt (Phase 3.1; ON by default from
|
|
1151
|
+
* 3.0.0 — set `runtime.capabilities.catalog: false` to remove it).
|
|
1152
|
+
*
|
|
1153
|
+
* The fence is real as of 3.0.0 and was not before: this block carries ids that come from MCP servers
|
|
1154
|
+
* and third-party skills, and it renders them as trusted-looking prompt structure on every planning
|
|
1155
|
+
* iteration of every run. Flattening (`promptSafe`) bounds their shape but says nothing about their
|
|
1156
|
+
* provenance, so the whole block is wrapped as untrusted data. Three comments claimed "fenced" while
|
|
1157
|
+
* no fence existed; shipping that ON by default would have made a false safety claim load-bearing.
|
|
1158
|
+
*
|
|
1159
|
+
* Both halves are bounded. The blocked-skill list had no cap at all — measured at ~24k characters
|
|
1160
|
+
* with 300 blocked skills, silently, in every prompt.
|
|
1108
1161
|
*/
|
|
1109
|
-
capabilityCatalogText(maxEntries = 40) {
|
|
1162
|
+
capabilityCatalogText(maxEntries = 40, maxBlocked = 15) {
|
|
1110
1163
|
const caps = this._capabilities.list();
|
|
1111
1164
|
if (caps.length === 0)
|
|
1112
1165
|
return '';
|
|
@@ -1116,17 +1169,21 @@ export class Runtime {
|
|
|
1116
1169
|
.providersOf(c.id)
|
|
1117
1170
|
.map((p) => `${promptSafe(p.providerId)}${p.availability === 'available' ? '' : ` (${p.availability})`}`)
|
|
1118
1171
|
.join(', ');
|
|
1119
|
-
|
|
1172
|
+
// Every segment is clamped, `effects` included: it is an array off a declaration, so a hostile or
|
|
1173
|
+
// simply careless source can make one row arbitrarily long.
|
|
1174
|
+
lines.push(` - capability "${promptSafe(c.id)}" [${promptSafe(c.effects.join('/'), 40)}] → ${promptSafe(providers, 200)}`);
|
|
1120
1175
|
}
|
|
1121
1176
|
const more = caps.length > maxEntries ? `\n …and ${caps.length - maxEntries} more (see /capabilities)` : '';
|
|
1122
1177
|
// Skills hidden by a missing tool — the "why can't you do this" answer the planner needs.
|
|
1123
1178
|
const usable = new Set(this.skills().map((sk) => sk.id));
|
|
1124
|
-
const
|
|
1125
|
-
|
|
1126
|
-
.
|
|
1179
|
+
const blockedAll = this._skills.list().filter((sk) => !usable.has(sk.id));
|
|
1180
|
+
const blocked = blockedAll
|
|
1181
|
+
.slice(0, maxBlocked)
|
|
1127
1182
|
.map((sk) => ` - skill "${promptSafe(sk.id)}" needs tool(s) ${promptSafe((sk.tools ?? []).filter((t) => !this._tools.ids().includes(t)).join(', '), 200)} (not registered)`);
|
|
1128
|
-
const
|
|
1129
|
-
|
|
1183
|
+
const blockedMore = blockedAll.length > maxBlocked ? `\n …and ${blockedAll.length - maxBlocked} more` : '';
|
|
1184
|
+
const unavailable = blocked.length ? `\nUnavailable (do not use):\n${blocked.join('\n')}${blockedMore}` : '';
|
|
1185
|
+
// The ids inside come from MCP servers and third-party skills. Fenced as data, not structure.
|
|
1186
|
+
return wrapUntrusted('capability-catalog', `Action capabilities:\n${lines.join('\n')}${more}${unavailable}`);
|
|
1130
1187
|
}
|
|
1131
1188
|
/** Any call/cost ceiling declared in the config file's `budget:` block (router-level, not policy). */
|
|
1132
1189
|
configBudget() {
|
|
@@ -1503,8 +1560,12 @@ export class Runtime {
|
|
|
1503
1560
|
*/
|
|
1504
1561
|
orchestrateRunners(policy, opts) {
|
|
1505
1562
|
const { runId, signal, provenance, onRecord, agentResume } = opts;
|
|
1506
|
-
const
|
|
1563
|
+
const derived = this.derivedAgents();
|
|
1564
|
+
const envelopes = policy ? this.agentEnvelopes(policy, derived) : [];
|
|
1507
1565
|
const byId = new Map(envelopes.map((e) => [e.agentId, e]));
|
|
1566
|
+
// Definitions come from a LOCAL map, not the process-lifetime field: a derived agent exists for
|
|
1567
|
+
// this run only, and must not be findable by any later one.
|
|
1568
|
+
const defsById = new Map([...this.agentDefs, ...derived.map((d) => [d.id, d.definition])]);
|
|
1508
1569
|
return {
|
|
1509
1570
|
runSkill: (skillId, input) => this.runSkill(skillId, input).then((o) => ({ result: o.result, validation: o.validation })),
|
|
1510
1571
|
runTool: (toolId, input) => this.runTool(toolId, input),
|
|
@@ -1514,13 +1575,14 @@ export class Runtime {
|
|
|
1514
1575
|
reserve: (step) => (step.agent ? byId.get(step.agent)?.reservation ?? 1 : 0),
|
|
1515
1576
|
runAgent: async (step, ctx) => {
|
|
1516
1577
|
const envelope = byId.get(step.agent ?? '');
|
|
1517
|
-
const definition =
|
|
1578
|
+
const definition = defsById.get(step.agent ?? '');
|
|
1518
1579
|
if (!envelope || !definition)
|
|
1519
1580
|
return { stepId: step.id, ok: false, code: 'agent-not-enabled', error: `no agent definition '${step.agent ?? ''}'` };
|
|
1520
1581
|
const innerSkills = this._skills.list().filter((sk) => envelope.skills.includes(sk.id));
|
|
1521
1582
|
// Phase 3.5: continue a persisted task for THIS step, if one exists.
|
|
1522
1583
|
const prior = agentResume?.(step);
|
|
1523
1584
|
const out = await runAgentTask(step, envelope, definition, {
|
|
1585
|
+
...(derived.some((d) => d.id === step.agent) ? { derived: true } : {}),
|
|
1524
1586
|
...(prior?.record ? { resume: prior.record } : {}),
|
|
1525
1587
|
...(prior?.answer ? { resumeAnswer: prior.answer } : {}),
|
|
1526
1588
|
ai: this._ai,
|
|
@@ -1579,6 +1641,52 @@ export class Runtime {
|
|
|
1579
1641
|
: {}),
|
|
1580
1642
|
};
|
|
1581
1643
|
}
|
|
1644
|
+
/**
|
|
1645
|
+
* Reconcile findings that contradict each other, across ALL of this execution's agent tasks
|
|
1646
|
+
* (Phase 3.7).
|
|
1647
|
+
*
|
|
1648
|
+
* `resolveConflicts` has existed since 3.4 with no caller, so two agents reaching opposite
|
|
1649
|
+
* conclusions about the same subject both stayed `active` — and both were rendered into the next
|
|
1650
|
+
* planning prompt, as if the runtime had no opinion about which was better supported. It does: it
|
|
1651
|
+
* weighs evidence-based `confidence`, with `executionCoverage` only as a tiebreak.
|
|
1652
|
+
*
|
|
1653
|
+
* Runs at the ONE point new findings can appear — a task reaching a terminal state — and writes the
|
|
1654
|
+
* outcome back onto the owning records, so a supersession survives a restart rather than being
|
|
1655
|
+
* recomputed (and possibly recomputed differently) on every read.
|
|
1656
|
+
*
|
|
1657
|
+
* EVERY finding is passed in, not just the active ones. Resolving over the active subset makes the
|
|
1658
|
+
* result depend on the order tasks happen to finish: a finding that beat a weak rival in wave 1 can
|
|
1659
|
+
* itself lose in wave 2, and the wave-1 loser is then left pointing at a superseded finding — a
|
|
1660
|
+
* broken chain nothing heals. Re-resolving the whole set each round is order-independent and gives
|
|
1661
|
+
* the same answer as one pass over the final set.
|
|
1662
|
+
*/
|
|
1663
|
+
resolveFindingConflicts(exec) {
|
|
1664
|
+
const { tasks } = parseAgentTasks(exec);
|
|
1665
|
+
const all = tasks.flatMap((t) => t.findings);
|
|
1666
|
+
if (all.length < 2)
|
|
1667
|
+
return;
|
|
1668
|
+
const resolved = new Map(resolveConflicts(all).map((f) => [f.id, f]));
|
|
1669
|
+
let changed = false;
|
|
1670
|
+
const next = tasks.map((t) => {
|
|
1671
|
+
const findings = t.findings.map((f) => {
|
|
1672
|
+
const r = resolved.get(f.id);
|
|
1673
|
+
if (!r || (r.status === f.status && r.supersededBy === f.supersededBy))
|
|
1674
|
+
return f;
|
|
1675
|
+
changed = true;
|
|
1676
|
+
const next = { ...f, status: r.status };
|
|
1677
|
+
if (r.supersededBy)
|
|
1678
|
+
next.supersededBy = r.supersededBy;
|
|
1679
|
+
else
|
|
1680
|
+
delete next.supersededBy;
|
|
1681
|
+
return next;
|
|
1682
|
+
});
|
|
1683
|
+
return changed ? { ...t, findings } : t;
|
|
1684
|
+
});
|
|
1685
|
+
if (!changed)
|
|
1686
|
+
return;
|
|
1687
|
+
const byId = new Map(next.map((t) => [t.agentTaskId, t]));
|
|
1688
|
+
exec.agentTasks = (exec.agentTasks ?? []).map((entry) => byId.get(entry.agentTaskId ?? '') ?? entry);
|
|
1689
|
+
}
|
|
1582
1690
|
/**
|
|
1583
1691
|
* A bounded, fenced brief of what the agents have already established (Phase 3.5).
|
|
1584
1692
|
*
|
package/dist/runtime/types.d.ts
CHANGED
|
@@ -90,7 +90,8 @@ export interface RuntimeSettings {
|
|
|
90
90
|
encrypt: boolean;
|
|
91
91
|
keyEnv: string;
|
|
92
92
|
};
|
|
93
|
-
/** Action capabilities (Phase 3.1/3.3). `catalog` enriches the planner catalog (default
|
|
93
|
+
/** Action capabilities (Phase 3.1/3.3). `catalog` enriches the planner catalog (default ON since 3.0.0;
|
|
94
|
+
* set `catalog: false` to remove the block);
|
|
94
95
|
* `planning` (3.3, default OFF) derives the capabilities a goal needs BEFORE planning — offline BM25
|
|
95
96
|
* first, one model call only when that finds nothing AND no call budget is set AND this is not a dry
|
|
96
97
|
* run — resolves them against the run's permissions, and reports gaps. ADVISORY: it never blocks a
|
|
@@ -120,6 +121,12 @@ export interface RuntimeSettings {
|
|
|
120
121
|
* the worker's hard inner-model-call ceiling. */
|
|
121
122
|
agents?: {
|
|
122
123
|
enabled?: boolean;
|
|
124
|
+
/** Phase 3.7, default OFF: offer the goal a bounded, read-shaped agent per shipped ROLE, with no
|
|
125
|
+
* operator-authored definition. The roles and their objectives are in-tree; nothing the model
|
|
126
|
+
* writes becomes an objective, a tool id or a permission.
|
|
127
|
+
*
|
|
128
|
+
* REQUIRES `enabled: true` — on its own this does nothing, because agent execution is off. */
|
|
129
|
+
decompose?: boolean;
|
|
123
130
|
maxToolCalls?: number;
|
|
124
131
|
maxDurationMs?: number;
|
|
125
132
|
maxInnerCalls?: number;
|
package/dist/store/area.d.ts
CHANGED
package/dist/store/area.js
CHANGED
|
@@ -6,8 +6,19 @@
|
|
|
6
6
|
import { createHash } from 'node:crypto';
|
|
7
7
|
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync, appendFileSync } from 'node:fs';
|
|
8
8
|
import { join } from 'node:path';
|
|
9
|
+
import { StoreDecryptError } from './errors.js';
|
|
9
10
|
const STORE_VERSION = 1;
|
|
10
11
|
const ID_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/;
|
|
12
|
+
/** Classify a caught read error into an IntegrityIssue problem code (decrypt errors are distinguished). */
|
|
13
|
+
function classifyProblem(err) {
|
|
14
|
+
if (err instanceof StoreDecryptError) {
|
|
15
|
+
if (err.code === 'AUTH_FAILED')
|
|
16
|
+
return 'decrypt-auth-failed';
|
|
17
|
+
if (err.code === 'UNSUPPORTED_VERSION')
|
|
18
|
+
return 'unsupported-version';
|
|
19
|
+
}
|
|
20
|
+
return 'invalid-json';
|
|
21
|
+
}
|
|
11
22
|
/** Matches an orphaned atomic-write temp file: `<id>.json.tmp-<pid>-<n>` / `<id>.jsonl.tmp-…`. */
|
|
12
23
|
const TEMP_RE = /\.(?:json|jsonl)\.tmp-/;
|
|
13
24
|
function assertId(id) {
|
|
@@ -37,7 +48,9 @@ export class FileArea {
|
|
|
37
48
|
}
|
|
38
49
|
ensure() {
|
|
39
50
|
if (!this.ensured) {
|
|
40
|
-
|
|
51
|
+
// 0700: store dirs hold conversation/memory/execution/agent records — owner-only on shared hosts.
|
|
52
|
+
// (mode applies only to dirs this call creates; POSIX-only, a no-op on Windows.)
|
|
53
|
+
mkdirSync(this.dir, { recursive: true, mode: 0o700 });
|
|
41
54
|
this.ensured = true;
|
|
42
55
|
}
|
|
43
56
|
}
|
|
@@ -51,7 +64,8 @@ export class FileArea {
|
|
|
51
64
|
this.ensure();
|
|
52
65
|
this.counter += 1;
|
|
53
66
|
const tmp = `${path}.tmp-${process.pid}-${this.counter}`;
|
|
54
|
-
|
|
67
|
+
// 0600: rename preserves the tmp file's mode, so the final record is owner-only too.
|
|
68
|
+
writeFileSync(tmp, this.encode(contents), { mode: 0o600 });
|
|
55
69
|
renameSync(tmp, path);
|
|
56
70
|
}
|
|
57
71
|
readJson(id) {
|
|
@@ -68,8 +82,14 @@ export class FileArea {
|
|
|
68
82
|
try {
|
|
69
83
|
return this.readJson(id);
|
|
70
84
|
}
|
|
71
|
-
catch {
|
|
72
|
-
|
|
85
|
+
catch (err) {
|
|
86
|
+
// A wrong key makes EVERY encrypted record fail — soft-skipping it would present the whole store as
|
|
87
|
+
// empty, a silent data-loss illusion. Rethrow AUTH_FAILED so the caller sees a real error; genuine
|
|
88
|
+
// per-record corruption (checksum/JSON/unsupported-version) still soft-skips so one bad record can't
|
|
89
|
+
// brick bulk listing/search.
|
|
90
|
+
if (err instanceof StoreDecryptError && err.code === 'AUTH_FAILED')
|
|
91
|
+
throw err;
|
|
92
|
+
return undefined;
|
|
73
93
|
}
|
|
74
94
|
}
|
|
75
95
|
writeJson(id, data) {
|
|
@@ -96,7 +116,8 @@ export class FileArea {
|
|
|
96
116
|
assertId(id);
|
|
97
117
|
this.ensure();
|
|
98
118
|
// Each JSONL line is encrypted independently (its own IV), so appends stay append-only.
|
|
99
|
-
|
|
119
|
+
// 0600 applies when the file is first created; an existing file keeps its mode (append doesn't chmod).
|
|
120
|
+
appendFileSync(this.logPath(id), this.encode(JSON.stringify(obj)) + '\n', { mode: 0o600 });
|
|
100
121
|
}
|
|
101
122
|
readLines(id) {
|
|
102
123
|
assertId(id);
|
|
@@ -110,8 +131,11 @@ export class FileArea {
|
|
|
110
131
|
try {
|
|
111
132
|
out.push(JSON.parse(this.decode(line)));
|
|
112
133
|
}
|
|
113
|
-
catch {
|
|
114
|
-
|
|
134
|
+
catch (err) {
|
|
135
|
+
// Wrong key fails every line — surface it rather than returning a silently-empty log; a single
|
|
136
|
+
// corrupt line is still skipped (surfaced by check()).
|
|
137
|
+
if (err instanceof StoreDecryptError && err.code === 'AUTH_FAILED')
|
|
138
|
+
throw err;
|
|
115
139
|
}
|
|
116
140
|
}
|
|
117
141
|
return out;
|
|
@@ -133,7 +157,7 @@ export class FileArea {
|
|
|
133
157
|
issues.push({ file: path, problem: 'checksum-mismatch' });
|
|
134
158
|
}
|
|
135
159
|
catch (err) {
|
|
136
|
-
issues.push({ file: path, problem:
|
|
160
|
+
issues.push({ file: path, problem: classifyProblem(err), detail: err instanceof Error ? err.message : String(err) });
|
|
137
161
|
}
|
|
138
162
|
}
|
|
139
163
|
else if (f.endsWith('.jsonl')) {
|
|
@@ -142,8 +166,8 @@ export class FileArea {
|
|
|
142
166
|
try {
|
|
143
167
|
JSON.parse(this.decode(line));
|
|
144
168
|
}
|
|
145
|
-
catch {
|
|
146
|
-
issues.push({ file: path, problem:
|
|
169
|
+
catch (err) {
|
|
170
|
+
issues.push({ file: path, problem: classifyProblem(err), detail: `line ${i + 1}` });
|
|
147
171
|
}
|
|
148
172
|
});
|
|
149
173
|
}
|
package/dist/store/crypto.d.ts
CHANGED
|
@@ -1,21 +1,35 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Transparent encryption at rest
|
|
3
|
-
* transforms the STORED STRING of a store file: `encode` wraps plaintext as a self-identifying envelope
|
|
4
|
-
* `
|
|
5
|
-
*
|
|
6
|
-
* reading after it is enabled (new writes are always encrypted). A wrong key / tampered payload makes `decode`
|
|
7
|
-
* THROW, which the store maps onto its existing corruption path (soft-skip in bulk reads, reported by check()).
|
|
2
|
+
* Transparent encryption at rest — AES-256-GCM via `node:crypto` (zero new deps). A `ContentCodec`
|
|
3
|
+
* transforms the STORED STRING of a store file: `encode` wraps plaintext as a self-identifying envelope,
|
|
4
|
+
* `decode` reverses it. `decode` is lenient on the way IN — a string WITHOUT a known envelope prefix is
|
|
5
|
+
* returned unchanged, so a store that predates encryption keeps reading after it is enabled.
|
|
8
6
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
7
|
+
* TWO envelope versions coexist:
|
|
8
|
+
* - `aienc1:` (v1, legacy) — key = sha256(secret). Still READ transparently; never written by 3.0.1+.
|
|
9
|
+
* - `aienc2:` (v2, current) — key = scrypt(secret, per-record salt). A proper KDF (memory-hard) so a
|
|
10
|
+
* weak/passphrase secret is not brute-forceable at raw-hash speed. Records upgrade to v2 lazily as
|
|
11
|
+
* they are rewritten (no bulk migration). The scrypt key is CACHED per salt inside the codec, so a
|
|
12
|
+
* bulk read does not pay a fresh derivation per record.
|
|
13
|
+
*
|
|
14
|
+
* A decrypt failure throws a typed `StoreDecryptError` (AUTH_FAILED / UNSUPPORTED_VERSION / CORRUPT) —
|
|
15
|
+
* honest about the fact that a GCM auth failure is wrong-key OR tampering OR corruption, not certainly a
|
|
16
|
+
* wrong key. SECURITY: derived keys are Buffers held only inside the codec closure; never serialized,
|
|
17
|
+
* logged, or placed in an error.
|
|
11
18
|
*/
|
|
12
19
|
import type { ContentCodec } from './area.js';
|
|
13
20
|
export declare const ENVELOPE_PREFIX = "aienc1:";
|
|
14
|
-
|
|
21
|
+
export declare const ENVELOPE_PREFIX_V2 = "aienc2:";
|
|
22
|
+
/** Derive the 32-byte v1 AES key (sha256 → fixed length). Retained for reading legacy `aienc1:` records. */
|
|
15
23
|
export declare function deriveKey(secret: string): Buffer;
|
|
16
|
-
/**
|
|
24
|
+
/** v1 encrypt into the `aienc1:` envelope (legacy; retained for the exported API and Buffer-key codec). */
|
|
17
25
|
export declare function encryptString(plain: string, key: Buffer): string;
|
|
18
|
-
/**
|
|
26
|
+
/** v1 decrypt of an `aienc1:` envelope; a non-envelope string passes through (mixed/legacy store). */
|
|
19
27
|
export declare function decryptString(stored: string, key: Buffer): string;
|
|
20
|
-
/**
|
|
21
|
-
export declare function
|
|
28
|
+
/** v2 encrypt into the `aienc2:` envelope (salt‖iv‖tag‖ciphertext). */
|
|
29
|
+
export declare function encryptStringV2(plain: string, key: Buffer, salt: Buffer): string;
|
|
30
|
+
/**
|
|
31
|
+
* Build the codec `FileArea` uses. A **secret string** yields the current v2 codec (scrypt; writes
|
|
32
|
+
* `aienc2:`, reads every version). A **Buffer** yields the legacy v1 codec (sha256; writes `aienc1:`) —
|
|
33
|
+
* retained so existing `makeCodec(deriveKey(secret))` callers behave exactly as before.
|
|
34
|
+
*/
|
|
35
|
+
export declare function makeCodec(keyOrSecret: Buffer | string): ContentCodec;
|
package/dist/store/crypto.js
CHANGED
|
@@ -1,23 +1,53 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Transparent encryption at rest
|
|
3
|
-
* transforms the STORED STRING of a store file: `encode` wraps plaintext as a self-identifying envelope
|
|
4
|
-
* `
|
|
5
|
-
*
|
|
6
|
-
* reading after it is enabled (new writes are always encrypted). A wrong key / tampered payload makes `decode`
|
|
7
|
-
* THROW, which the store maps onto its existing corruption path (soft-skip in bulk reads, reported by check()).
|
|
2
|
+
* Transparent encryption at rest — AES-256-GCM via `node:crypto` (zero new deps). A `ContentCodec`
|
|
3
|
+
* transforms the STORED STRING of a store file: `encode` wraps plaintext as a self-identifying envelope,
|
|
4
|
+
* `decode` reverses it. `decode` is lenient on the way IN — a string WITHOUT a known envelope prefix is
|
|
5
|
+
* returned unchanged, so a store that predates encryption keeps reading after it is enabled.
|
|
8
6
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
7
|
+
* TWO envelope versions coexist:
|
|
8
|
+
* - `aienc1:` (v1, legacy) — key = sha256(secret). Still READ transparently; never written by 3.0.1+.
|
|
9
|
+
* - `aienc2:` (v2, current) — key = scrypt(secret, per-record salt). A proper KDF (memory-hard) so a
|
|
10
|
+
* weak/passphrase secret is not brute-forceable at raw-hash speed. Records upgrade to v2 lazily as
|
|
11
|
+
* they are rewritten (no bulk migration). The scrypt key is CACHED per salt inside the codec, so a
|
|
12
|
+
* bulk read does not pay a fresh derivation per record.
|
|
13
|
+
*
|
|
14
|
+
* A decrypt failure throws a typed `StoreDecryptError` (AUTH_FAILED / UNSUPPORTED_VERSION / CORRUPT) —
|
|
15
|
+
* honest about the fact that a GCM auth failure is wrong-key OR tampering OR corruption, not certainly a
|
|
16
|
+
* wrong key. SECURITY: derived keys are Buffers held only inside the codec closure; never serialized,
|
|
17
|
+
* logged, or placed in an error.
|
|
11
18
|
*/
|
|
12
|
-
import { createHash, randomBytes, createCipheriv, createDecipheriv } from 'node:crypto';
|
|
13
|
-
|
|
19
|
+
import { createHash, randomBytes, createCipheriv, createDecipheriv, scryptSync } from 'node:crypto';
|
|
20
|
+
import { StoreDecryptError } from './errors.js';
|
|
21
|
+
export const ENVELOPE_PREFIX = 'aienc1:'; // v1 (legacy)
|
|
22
|
+
export const ENVELOPE_PREFIX_V2 = 'aienc2:'; // v2 (current)
|
|
14
23
|
const IV_LEN = 12; // GCM standard nonce
|
|
15
24
|
const TAG_LEN = 16; // GCM auth tag
|
|
16
|
-
|
|
25
|
+
const SALT_LEN = 16; // per-record scrypt salt (v2)
|
|
26
|
+
const KEY_LEN = 32; // AES-256
|
|
27
|
+
// scrypt cost: N=2^14, r=8, p=1 → ~16 MiB working set (under node's 32 MiB default maxmem), ~tens of ms.
|
|
28
|
+
// A change to these parameters requires a new envelope version (aienc3), since the salt alone does not
|
|
29
|
+
// record them.
|
|
30
|
+
const SCRYPT_PARAMS = { N: 16384, r: 8, p: 1 };
|
|
31
|
+
/** Derive the 32-byte v1 AES key (sha256 → fixed length). Retained for reading legacy `aienc1:` records. */
|
|
17
32
|
export function deriveKey(secret) {
|
|
18
33
|
return createHash('sha256').update(secret).digest();
|
|
19
34
|
}
|
|
20
|
-
/**
|
|
35
|
+
/** Derive the 32-byte v2 AES key via scrypt over (secret, salt). Memory-hard — resists brute force. */
|
|
36
|
+
function deriveKeyScrypt(secret, salt) {
|
|
37
|
+
return scryptSync(secret, salt, KEY_LEN, SCRYPT_PARAMS);
|
|
38
|
+
}
|
|
39
|
+
/** GCM-decrypt; a failed auth tag becomes a typed AUTH_FAILED (wrong key OR tampering OR corruption). */
|
|
40
|
+
function gcmDecrypt(key, iv, tag, ct) {
|
|
41
|
+
const decipher = createDecipheriv('aes-256-gcm', key, iv);
|
|
42
|
+
decipher.setAuthTag(tag);
|
|
43
|
+
try {
|
|
44
|
+
return Buffer.concat([decipher.update(ct), decipher.final()]).toString('utf8');
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
throw new StoreDecryptError('AUTH_FAILED', 'store record failed authentication (wrong key, tampering, or corruption)');
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/** v1 encrypt into the `aienc1:` envelope (legacy; retained for the exported API and Buffer-key codec). */
|
|
21
51
|
export function encryptString(plain, key) {
|
|
22
52
|
const iv = randomBytes(IV_LEN);
|
|
23
53
|
const cipher = createCipheriv('aes-256-gcm', key, iv);
|
|
@@ -25,25 +55,73 @@ export function encryptString(plain, key) {
|
|
|
25
55
|
const tag = cipher.getAuthTag();
|
|
26
56
|
return ENVELOPE_PREFIX + Buffer.concat([iv, tag, ct]).toString('base64');
|
|
27
57
|
}
|
|
28
|
-
/**
|
|
58
|
+
/** v1 decrypt of an `aienc1:` envelope; a non-envelope string passes through (mixed/legacy store). */
|
|
29
59
|
export function decryptString(stored, key) {
|
|
30
60
|
if (!stored.startsWith(ENVELOPE_PREFIX))
|
|
31
|
-
return stored;
|
|
61
|
+
return stored;
|
|
32
62
|
const buf = Buffer.from(stored.slice(ENVELOPE_PREFIX.length), 'base64');
|
|
33
63
|
if (buf.length < IV_LEN + TAG_LEN)
|
|
34
|
-
throw new
|
|
64
|
+
throw new StoreDecryptError('CORRUPT', 'encrypted record is truncated');
|
|
35
65
|
const iv = buf.subarray(0, IV_LEN);
|
|
36
66
|
const tag = buf.subarray(IV_LEN, IV_LEN + TAG_LEN);
|
|
37
67
|
const ct = buf.subarray(IV_LEN + TAG_LEN);
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
68
|
+
return gcmDecrypt(key, iv, tag, ct);
|
|
69
|
+
}
|
|
70
|
+
/** v2 encrypt into the `aienc2:` envelope (salt‖iv‖tag‖ciphertext). */
|
|
71
|
+
export function encryptStringV2(plain, key, salt) {
|
|
72
|
+
const iv = randomBytes(IV_LEN);
|
|
73
|
+
const cipher = createCipheriv('aes-256-gcm', key, iv);
|
|
74
|
+
const ct = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]);
|
|
75
|
+
const tag = cipher.getAuthTag();
|
|
76
|
+
return ENVELOPE_PREFIX_V2 + Buffer.concat([salt, iv, tag, ct]).toString('base64');
|
|
42
77
|
}
|
|
43
|
-
/**
|
|
44
|
-
|
|
78
|
+
/**
|
|
79
|
+
* Decrypt ANY known envelope with the given secret: v2 (scrypt per embedded salt), v1 (sha256), or
|
|
80
|
+
* plaintext passthrough. An `aiencN:` prefix this build does not know is UNSUPPORTED_VERSION. `keyFor`
|
|
81
|
+
* supplies (and caches) the scrypt key for a v2 salt.
|
|
82
|
+
*/
|
|
83
|
+
function decryptEnvelope(stored, secret, keyFor) {
|
|
84
|
+
if (stored.startsWith(ENVELOPE_PREFIX_V2)) {
|
|
85
|
+
const buf = Buffer.from(stored.slice(ENVELOPE_PREFIX_V2.length), 'base64');
|
|
86
|
+
if (buf.length < SALT_LEN + IV_LEN + TAG_LEN)
|
|
87
|
+
throw new StoreDecryptError('CORRUPT', 'encrypted record is truncated');
|
|
88
|
+
const salt = buf.subarray(0, SALT_LEN);
|
|
89
|
+
const iv = buf.subarray(SALT_LEN, SALT_LEN + IV_LEN);
|
|
90
|
+
const tag = buf.subarray(SALT_LEN + IV_LEN, SALT_LEN + IV_LEN + TAG_LEN);
|
|
91
|
+
const ct = buf.subarray(SALT_LEN + IV_LEN + TAG_LEN);
|
|
92
|
+
return gcmDecrypt(keyFor(salt), iv, tag, ct);
|
|
93
|
+
}
|
|
94
|
+
if (stored.startsWith(ENVELOPE_PREFIX))
|
|
95
|
+
return decryptString(stored, deriveKey(secret)); // v1 legacy
|
|
96
|
+
const m = stored.match(/^aienc(\d+):/);
|
|
97
|
+
if (m)
|
|
98
|
+
throw new StoreDecryptError('UNSUPPORTED_VERSION', `store record uses an unsupported envelope version 'aienc${m[1]}'`);
|
|
99
|
+
return stored; // plaintext (pre-encryption) record — read as-is
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Build the codec `FileArea` uses. A **secret string** yields the current v2 codec (scrypt; writes
|
|
103
|
+
* `aienc2:`, reads every version). A **Buffer** yields the legacy v1 codec (sha256; writes `aienc1:`) —
|
|
104
|
+
* retained so existing `makeCodec(deriveKey(secret))` callers behave exactly as before.
|
|
105
|
+
*/
|
|
106
|
+
export function makeCodec(keyOrSecret) {
|
|
107
|
+
if (Buffer.isBuffer(keyOrSecret)) {
|
|
108
|
+
const key = keyOrSecret;
|
|
109
|
+
return { encode: (plain) => encryptString(plain, key), decode: (stored) => decryptString(stored, key) };
|
|
110
|
+
}
|
|
111
|
+
const secret = keyOrSecret;
|
|
112
|
+
const encodeSalt = randomBytes(SALT_LEN); // one salt per codec instance → writes share one derivation
|
|
113
|
+
const keyCache = new Map();
|
|
114
|
+
const keyFor = (salt) => {
|
|
115
|
+
const h = salt.toString('hex');
|
|
116
|
+
let k = keyCache.get(h);
|
|
117
|
+
if (!k) {
|
|
118
|
+
k = deriveKeyScrypt(secret, salt);
|
|
119
|
+
keyCache.set(h, k);
|
|
120
|
+
}
|
|
121
|
+
return k;
|
|
122
|
+
};
|
|
45
123
|
return {
|
|
46
|
-
encode: (plain) =>
|
|
47
|
-
decode: (stored) =>
|
|
124
|
+
encode: (plain) => encryptStringV2(plain, keyFor(encodeSalt), encodeSalt),
|
|
125
|
+
decode: (stored) => decryptEnvelope(stored, secret, keyFor),
|
|
48
126
|
};
|
|
49
127
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed store-layer errors. Decryption failures carry an HONEST code: an AEAD (GCM) authentication
|
|
3
|
+
* failure does NOT prove the key is wrong — it means wrong key OR tampering OR corruption, and the code
|
|
4
|
+
* says exactly that. Higher layers may infer "likely key mismatch" only from AGGREGATE evidence (most/
|
|
5
|
+
* all encrypted records failing at once), never from a single record.
|
|
6
|
+
*/
|
|
7
|
+
export type StoreDecryptCode = 'AUTH_FAILED' | 'UNSUPPORTED_VERSION' | 'CORRUPT';
|
|
8
|
+
export declare class StoreDecryptError extends Error {
|
|
9
|
+
readonly code: StoreDecryptCode;
|
|
10
|
+
constructor(code: StoreDecryptCode, message: string);
|
|
11
|
+
}
|