ai-runtime-engine 3.0.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 +63 -0
- 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/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 +13 -22
- package/dist/runtime/events.d.ts +6 -0
- package/dist/runtime/runtime.d.ts +3 -2
- package/dist/runtime/runtime.js +17 -7
- package/dist/runtime/types.d.ts +2 -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
|
@@ -219,8 +219,9 @@ export declare class Runtime {
|
|
|
219
219
|
/** Warnings from MCP wiring (id collisions) plus the manager's own (invalid store files, etc.). */
|
|
220
220
|
mcpWarningsList(): string[];
|
|
221
221
|
/**
|
|
222
|
-
* Release long-lived resources
|
|
223
|
-
*
|
|
222
|
+
* Release long-lived resources: MCP stdio child processes, and any batching telemetry sink (so a
|
|
223
|
+
* short-lived run does not drop OTLP events buffered below the batch threshold). A one-shot CLI command
|
|
224
|
+
* and the REPL both call this on completion/exit.
|
|
224
225
|
*/
|
|
225
226
|
close(): Promise<void>;
|
|
226
227
|
/** Skills whose required tools are all registered. */
|
package/dist/runtime/runtime.js
CHANGED
|
@@ -94,7 +94,7 @@ import { loadModelProfile, resolveModelDirective, directiveToOverrides } from '.
|
|
|
94
94
|
import { HashEmbedder } from '../memory/embedders/hash.js';
|
|
95
95
|
import { HttpEmbedder } from '../memory/embedders/http.js';
|
|
96
96
|
import { Credential } from '../security/credentials.js';
|
|
97
|
-
import { makeCodec
|
|
97
|
+
import { makeCodec } from '../store/crypto.js';
|
|
98
98
|
import { AIError } from '../core/fallback/errors.js';
|
|
99
99
|
import { tokenize } from '../memory/bm25.js';
|
|
100
100
|
let runCounter = 0;
|
|
@@ -214,7 +214,7 @@ export class Runtime {
|
|
|
214
214
|
});
|
|
215
215
|
this._modelProfile = loadModelProfile(root); // models.md (per-mode/task routing); undefined if absent
|
|
216
216
|
this._memory = new MemoryStore(this._store, clock, this.buildEmbedder(options.ai?.fetchImpl));
|
|
217
|
-
this._conversations = new ConversationStore(this._store.conversations(), clock);
|
|
217
|
+
this._conversations = new ConversationStore(this._store.conversations(), clock, { withLock: (fn) => this._store.withLock(fn) });
|
|
218
218
|
this._executions = new ExecutionStore(this._store.executions(), { clock });
|
|
219
219
|
this._artifacts = new ArtifactStore(this._store.artifacts(), clock);
|
|
220
220
|
this._learning = new LearningStore(this._store.learning());
|
|
@@ -291,7 +291,9 @@ export class Runtime {
|
|
|
291
291
|
const key = new Credential(cfg.keyEnv, this.env).use();
|
|
292
292
|
if (!key)
|
|
293
293
|
throw new AIError(`storage encryption is enabled but the key env var ${cfg.keyEnv} is not set`, { category: 'CONFIG' });
|
|
294
|
-
|
|
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);
|
|
295
297
|
}
|
|
296
298
|
/** Build a Runtime from a workspace: load config (.ai-runtime/config.yaml > root fallback), detect workspace. */
|
|
297
299
|
static async load(options = {}) {
|
|
@@ -624,11 +626,13 @@ export class Runtime {
|
|
|
624
626
|
return [...this.mcpWarnings, ...this._mcp.warningsList()];
|
|
625
627
|
}
|
|
626
628
|
/**
|
|
627
|
-
* Release long-lived resources
|
|
628
|
-
*
|
|
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.
|
|
629
632
|
*/
|
|
630
633
|
async close() {
|
|
631
634
|
await this._mcp.close();
|
|
635
|
+
await this._ai.close();
|
|
632
636
|
}
|
|
633
637
|
/** Skills whose required tools are all registered. */
|
|
634
638
|
skills() {
|
|
@@ -651,9 +655,9 @@ export class Runtime {
|
|
|
651
655
|
if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(manifest.id))
|
|
652
656
|
throw new AIError(`unsafe skill id: ${JSON.stringify(manifest.id)}`, { category: 'CONFIG' });
|
|
653
657
|
const dir = join(this.workspaceRoot, '.ai-runtime', 'skills');
|
|
654
|
-
mkdirSync(dir, { recursive: true });
|
|
658
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
655
659
|
const path = join(dir, `${manifest.id}.skill.yaml`);
|
|
656
|
-
writeFileSync(path, stringifyYaml(manifest));
|
|
660
|
+
writeFileSync(path, stringifyYaml(manifest), { mode: 0o600 });
|
|
657
661
|
return path;
|
|
658
662
|
}
|
|
659
663
|
/** The ACTION-capability registry (Phase 3.1): what this runtime can DO, and who provides it. */
|
|
@@ -830,6 +834,12 @@ export class Runtime {
|
|
|
830
834
|
streamedText += chunk;
|
|
831
835
|
this.emitter.emit({ type: 'response.delta', runId, text: chunk });
|
|
832
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
|
+
};
|
|
833
843
|
}
|
|
834
844
|
const runResult = await this._ai.run(runRequest);
|
|
835
845
|
this.calibrate(runRequest.system, text, runResult);
|
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
|
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
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
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 class StoreDecryptError extends Error {
|
|
8
|
+
code;
|
|
9
|
+
constructor(code, message) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.name = 'StoreDecryptError';
|
|
12
|
+
this.code = code;
|
|
13
|
+
}
|
|
14
|
+
}
|
package/dist/store/store.d.ts
CHANGED
|
@@ -64,8 +64,28 @@ export declare class RuntimeStore {
|
|
|
64
64
|
/**
|
|
65
65
|
* Advisory per-project lock. Best-effort: acquires an exclusive lock file, steals a stale one, and
|
|
66
66
|
* otherwise proceeds anyway (advisory). Hard mutual exclusion for executions is a Phase 7 lease.
|
|
67
|
+
*
|
|
68
|
+
* The lock carries an ownership TOKEN so stealing a stale lock is atomic (unlink + exclusive re-create —
|
|
69
|
+
* exactly one racer wins) and release only removes a lock we still hold, never a stealer's.
|
|
67
70
|
*/
|
|
68
71
|
withLock<T>(fn: () => T, staleMs?: number): T;
|
|
69
|
-
/**
|
|
72
|
+
/**
|
|
73
|
+
* @internal Acquire the lock file exclusively, stealing it only if stale. Returns whether WE hold it
|
|
74
|
+
* (a live foreign lock ⇒ false ⇒ the caller proceeds advisory, unlocked). Exposed for deterministic
|
|
75
|
+
* concurrency tests; `hooks.afterStaleUnlink` runs between removing a stale lock and re-creating it,
|
|
76
|
+
* letting a test interleave a competing stealer in that exact window.
|
|
77
|
+
*/
|
|
78
|
+
acquireLock(lockPath: string, token: string, staleMs?: number, hooks?: {
|
|
79
|
+
afterStaleUnlink?: () => void;
|
|
80
|
+
}): boolean;
|
|
81
|
+
/** @internal Release a lock ONLY if its on-disk token matches ours — never delete a stealer's lock. */
|
|
82
|
+
releaseLock(lockPath: string, token: string): void;
|
|
83
|
+
/**
|
|
84
|
+
* Aggregate integrity check across the project's DURABLE areas (plus the org area when configured).
|
|
85
|
+
* Covers memory (all scopes), conversations, indexes, and the record stores that hold real state —
|
|
86
|
+
* mcp, executions, artifacts. `cache` is intentionally excluded (disposable — `cleanup` clears it
|
|
87
|
+
* wholesale, so a checksum issue there is noise); `learning`/`preferences` are store/user-wide rather
|
|
88
|
+
* than project-scoped and are left to a future store-wide check.
|
|
89
|
+
*/
|
|
70
90
|
check(): IntegrityIssue[];
|
|
71
91
|
}
|
package/dist/store/store.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* per-project lock, and an aggregate integrity check. In stateless mode every area is a NullArea.
|
|
5
5
|
*/
|
|
6
6
|
import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { randomBytes } from 'node:crypto';
|
|
7
8
|
import { join } from 'node:path';
|
|
8
9
|
import { systemClock } from '../util/clock.js';
|
|
9
10
|
import { FileArea, NullArea } from './area.js';
|
|
@@ -98,10 +99,10 @@ export class RuntimeStore {
|
|
|
98
99
|
stampMeta() {
|
|
99
100
|
if (!this.enabled || this.metaStamped)
|
|
100
101
|
return;
|
|
101
|
-
mkdirSync(this.projectDir, { recursive: true });
|
|
102
|
+
mkdirSync(this.projectDir, { recursive: true, mode: 0o700 });
|
|
102
103
|
const metaPath = join(this.projectDir, 'meta.json');
|
|
103
104
|
if (!existsSync(metaPath))
|
|
104
|
-
writeFileSync(metaPath, JSON.stringify({ version: STORE_VERSION, projectId: this.projectId }));
|
|
105
|
+
writeFileSync(metaPath, JSON.stringify({ version: STORE_VERSION, projectId: this.projectId }), { mode: 0o600 });
|
|
105
106
|
this.metaStamped = true;
|
|
106
107
|
}
|
|
107
108
|
/** The stored version, or undefined when absent — a migration hook seam for future versions. */
|
|
@@ -119,43 +120,97 @@ export class RuntimeStore {
|
|
|
119
120
|
/**
|
|
120
121
|
* Advisory per-project lock. Best-effort: acquires an exclusive lock file, steals a stale one, and
|
|
121
122
|
* otherwise proceeds anyway (advisory). Hard mutual exclusion for executions is a Phase 7 lease.
|
|
123
|
+
*
|
|
124
|
+
* The lock carries an ownership TOKEN so stealing a stale lock is atomic (unlink + exclusive re-create —
|
|
125
|
+
* exactly one racer wins) and release only removes a lock we still hold, never a stealer's.
|
|
122
126
|
*/
|
|
123
127
|
withLock(fn, staleMs = 30_000) {
|
|
124
128
|
if (!this.enabled)
|
|
125
129
|
return fn();
|
|
126
|
-
mkdirSync(this.projectDir, { recursive: true });
|
|
130
|
+
mkdirSync(this.projectDir, { recursive: true, mode: 0o700 });
|
|
127
131
|
const lockPath = join(this.projectDir, '.lock');
|
|
128
|
-
|
|
132
|
+
const token = randomBytes(16).toString('hex');
|
|
133
|
+
const held = this.acquireLock(lockPath, token, staleMs);
|
|
129
134
|
try {
|
|
130
|
-
|
|
131
|
-
|
|
135
|
+
return fn();
|
|
136
|
+
}
|
|
137
|
+
finally {
|
|
138
|
+
if (held)
|
|
139
|
+
this.releaseLock(lockPath, token);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* @internal Acquire the lock file exclusively, stealing it only if stale. Returns whether WE hold it
|
|
144
|
+
* (a live foreign lock ⇒ false ⇒ the caller proceeds advisory, unlocked). Exposed for deterministic
|
|
145
|
+
* concurrency tests; `hooks.afterStaleUnlink` runs between removing a stale lock and re-creating it,
|
|
146
|
+
* letting a test interleave a competing stealer in that exact window.
|
|
147
|
+
*/
|
|
148
|
+
acquireLock(lockPath, token, staleMs = 30_000, hooks) {
|
|
149
|
+
const content = JSON.stringify({ pid: process.pid, token, acquiredAt: this.clock.now() });
|
|
150
|
+
try {
|
|
151
|
+
writeFileSync(lockPath, content, { flag: 'wx', mode: 0o600 }); // exclusive create — no lock held
|
|
152
|
+
return true;
|
|
132
153
|
}
|
|
133
154
|
catch {
|
|
134
|
-
//
|
|
155
|
+
// A lock exists. Steal ONLY if stale, and atomically: remove it, then race an exclusive re-create —
|
|
156
|
+
// exactly one concurrent stealer's `wx` can succeed.
|
|
157
|
+
let stale;
|
|
158
|
+
try {
|
|
159
|
+
stale = this.clock.now() - statSync(lockPath).mtimeMs > staleMs;
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
return false; // vanished/unreadable mid-check — proceed advisory, unlocked
|
|
163
|
+
}
|
|
164
|
+
if (!stale)
|
|
165
|
+
return false; // a live lock — do not steal; proceed advisory, unlocked
|
|
135
166
|
try {
|
|
136
|
-
|
|
137
|
-
if (age > staleMs) {
|
|
138
|
-
writeFileSync(lockPath, String(process.pid));
|
|
139
|
-
held = true;
|
|
140
|
-
}
|
|
167
|
+
rmSync(lockPath); // drop the stale lock (a competing stealer may have already removed it)
|
|
141
168
|
}
|
|
142
169
|
catch {
|
|
143
|
-
/*
|
|
170
|
+
/* already gone — still race the exclusive create below */
|
|
171
|
+
}
|
|
172
|
+
hooks?.afterStaleUnlink?.();
|
|
173
|
+
try {
|
|
174
|
+
writeFileSync(lockPath, content, { flag: 'wx', mode: 0o600 });
|
|
175
|
+
return true; // won the steal
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
return false; // a competing stealer won the race — proceed advisory, unlocked
|
|
144
179
|
}
|
|
145
180
|
}
|
|
181
|
+
}
|
|
182
|
+
/** @internal Release a lock ONLY if its on-disk token matches ours — never delete a stealer's lock. */
|
|
183
|
+
releaseLock(lockPath, token) {
|
|
146
184
|
try {
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
finally {
|
|
150
|
-
if (held && existsSync(lockPath))
|
|
185
|
+
const cur = JSON.parse(readFileSync(lockPath, 'utf8'));
|
|
186
|
+
if (cur.token === token)
|
|
151
187
|
rmSync(lockPath);
|
|
152
188
|
}
|
|
189
|
+
catch {
|
|
190
|
+
/* lock gone or unreadable — nothing of ours to release */
|
|
191
|
+
}
|
|
153
192
|
}
|
|
154
|
-
/**
|
|
193
|
+
/**
|
|
194
|
+
* Aggregate integrity check across the project's DURABLE areas (plus the org area when configured).
|
|
195
|
+
* Covers memory (all scopes), conversations, indexes, and the record stores that hold real state —
|
|
196
|
+
* mcp, executions, artifacts. `cache` is intentionally excluded (disposable — `cleanup` clears it
|
|
197
|
+
* wholesale, so a checksum issue there is noise); `learning`/`preferences` are store/user-wide rather
|
|
198
|
+
* than project-scoped and are left to a future store-wide check.
|
|
199
|
+
*/
|
|
155
200
|
check() {
|
|
156
201
|
if (!this.enabled)
|
|
157
202
|
return [];
|
|
158
203
|
const org = this.orgDir ? this.memory('organization').check() : [];
|
|
159
|
-
return [
|
|
204
|
+
return [
|
|
205
|
+
...this.memory('project').check(),
|
|
206
|
+
...this.memory('user').check(),
|
|
207
|
+
...this.memory('repository').check(),
|
|
208
|
+
...org,
|
|
209
|
+
...this.conversations().check(),
|
|
210
|
+
...this.indexes().check(),
|
|
211
|
+
...this.mcp().check(),
|
|
212
|
+
...this.executions().check(),
|
|
213
|
+
...this.artifacts().check(),
|
|
214
|
+
];
|
|
160
215
|
}
|
|
161
216
|
}
|
|
@@ -11,7 +11,9 @@ export class FileSink {
|
|
|
11
11
|
constructor(path) {
|
|
12
12
|
this.path = path;
|
|
13
13
|
try {
|
|
14
|
-
|
|
14
|
+
// Owner-only by default (0700 dir / 0600 file): the spool commonly lives under `.ai-runtime/` and
|
|
15
|
+
// carries routing/perf metadata. Mode applies only on creation; POSIX-only, a no-op on Windows.
|
|
16
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
15
17
|
}
|
|
16
18
|
catch {
|
|
17
19
|
/* ignore — a telemetry setup failure must not break the router */
|
|
@@ -19,7 +21,7 @@ export class FileSink {
|
|
|
19
21
|
}
|
|
20
22
|
emit(event) {
|
|
21
23
|
try {
|
|
22
|
-
appendFileSync(this.path, `${JSON.stringify(redact(event))}\n
|
|
24
|
+
appendFileSync(this.path, `${JSON.stringify(redact(event))}\n`, { mode: 0o600 });
|
|
23
25
|
}
|
|
24
26
|
catch {
|
|
25
27
|
/* a telemetry write must never fail a run */
|