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.
Files changed (74) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/dist/cli/cli.js +8 -1
  3. package/dist/cli/commands/cleanup.js +11 -3
  4. package/dist/cli/commands/doctor.js +1 -1
  5. package/dist/cli/commands/run.js +6 -0
  6. package/dist/cli/commands/skills.js +9 -2
  7. package/dist/cli/interactive/repl.js +12 -2
  8. package/dist/cli/interactive/session.d.ts +2 -0
  9. package/dist/cli/interactive/session.js +6 -2
  10. package/dist/config/schema.js +19 -1
  11. package/dist/conversations/conversations.d.ts +6 -1
  12. package/dist/conversations/conversations.js +15 -8
  13. package/dist/core/fallback/fallback.d.ts +7 -0
  14. package/dist/core/fallback/fallback.js +15 -2
  15. package/dist/core/health/monitor.d.ts +6 -0
  16. package/dist/core/health/monitor.js +15 -2
  17. package/dist/core/router/confidence.js +10 -5
  18. package/dist/core/router/dimensions.d.ts +3 -1
  19. package/dist/core/router/dimensions.js +15 -5
  20. package/dist/core/router/filter.js +25 -6
  21. package/dist/core/router/normalize.js +2 -0
  22. package/dist/core/router/router.js +16 -2
  23. package/dist/core/router/scorer.d.ts +3 -0
  24. package/dist/core/router/scorer.js +17 -2
  25. package/dist/discovery/openapi.js +3 -2
  26. package/dist/executions/agentTasks.d.ts +4 -4
  27. package/dist/generation/generateAdapter.js +3 -1
  28. package/dist/index.d.ts +4 -2
  29. package/dist/index.js +3 -2
  30. package/dist/mcp/protocol.js +4 -1
  31. package/dist/memory/bm25.d.ts +7 -0
  32. package/dist/memory/bm25.js +17 -1
  33. package/dist/memory/memory.d.ts +7 -1
  34. package/dist/memory/memory.js +18 -4
  35. package/dist/plugin/ai.d.ts +6 -0
  36. package/dist/plugin/ai.js +17 -2
  37. package/dist/providers/estimate.d.ts +25 -0
  38. package/dist/providers/estimate.js +55 -0
  39. package/dist/providers/factory.d.ts +3 -0
  40. package/dist/providers/factory.js +26 -5
  41. package/dist/providers/httpClient.js +4 -0
  42. package/dist/providers/httpProvider.js +4 -3
  43. package/dist/providers/mock/mockProvider.js +4 -3
  44. package/dist/runtime/config.d.ts +4 -3
  45. package/dist/runtime/config.js +13 -22
  46. package/dist/runtime/events.d.ts +6 -0
  47. package/dist/runtime/runtime.d.ts +3 -2
  48. package/dist/runtime/runtime.js +17 -7
  49. package/dist/runtime/types.d.ts +2 -1
  50. package/dist/store/area.d.ts +1 -1
  51. package/dist/store/area.js +34 -10
  52. package/dist/store/crypto.d.ts +27 -13
  53. package/dist/store/crypto.js +101 -23
  54. package/dist/store/errors.d.ts +11 -0
  55. package/dist/store/errors.js +14 -0
  56. package/dist/store/store.d.ts +21 -1
  57. package/dist/store/store.js +74 -19
  58. package/dist/telemetry/sinks/file.js +4 -2
  59. package/dist/telemetry/sinks/otlp.d.ts +12 -2
  60. package/dist/telemetry/sinks/otlp.js +39 -24
  61. package/dist/telemetry/telemetry.d.ts +5 -0
  62. package/dist/telemetry/telemetry.js +4 -0
  63. package/dist/tools/builtins/shell.d.ts +30 -3
  64. package/dist/tools/builtins/shell.js +218 -7
  65. package/dist/tools/untrusted.d.ts +1 -1
  66. package/dist/tools/untrusted.js +5 -3
  67. package/dist/types.d.ts +14 -0
  68. package/dist/verification/verify.js +10 -3
  69. package/docs/GUIDE.md +66 -1
  70. package/docs/README.md +1 -1
  71. package/docs/architecture.md +5 -1
  72. package/docs/router.md +1 -1
  73. package/docs/security.md +26 -7
  74. 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 — today: MCP stdio child processes. A one-shot CLI command and the REPL
223
- * both call this on completion/exit; without it a spawned server keeps the event loop alive.
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. */
@@ -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, deriveKey } from '../store/crypto.js';
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
- return makeCodec(deriveKey(key));
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 — today: MCP stdio child processes. A one-shot CLI command and the REPL
628
- * both call this on completion/exit; without it a spawned server keeps the event loop alive.
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);
@@ -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 off);
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
@@ -5,7 +5,7 @@
5
5
  */
6
6
  export interface IntegrityIssue {
7
7
  file: string;
8
- problem: 'unreadable' | 'checksum-mismatch' | 'invalid-json';
8
+ problem: 'unreadable' | 'checksum-mismatch' | 'invalid-json' | 'decrypt-auth-failed' | 'unsupported-version';
9
9
  detail?: string;
10
10
  }
11
11
  /**
@@ -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
- mkdirSync(this.dir, { recursive: true });
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
- writeFileSync(tmp, this.encode(contents));
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
- return undefined; // a corrupt record must not brick bulk listing/search
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
- appendFileSync(this.logPath(id), this.encode(JSON.stringify(obj)) + '\n');
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
- /* skip a corrupt/undecryptable line rather than failing the whole log (surfaced by check()) */
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: 'invalid-json', detail: err instanceof Error ? err.message : String(err) });
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: 'invalid-json', detail: `line ${i + 1}` });
169
+ catch (err) {
170
+ issues.push({ file: path, problem: classifyProblem(err), detail: `line ${i + 1}` });
147
171
  }
148
172
  });
149
173
  }
@@ -1,21 +1,35 @@
1
1
  /**
2
- * Transparent encryption at rest (Phase 18) — 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
- * `aienc1:<base64(iv|tag|ciphertext)>`; `decode` reverses it. `decode` is deliberately lenient on the way IN —
5
- * a string WITHOUT the `aienc1:` prefix is returned unchanged, so a store that predates encryption keeps
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
- * SECURITY: the derived key is a Buffer held only inside the codec closure; it is never serialized, logged, or
10
- * placed in an error. The AES key is sha256(secret), so the raw secret length is irrelevant.
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
- /** Derive the 32-byte AES-256 key from an arbitrary secret (sha256 → fixed length, secret length irrelevant). */
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
- /** Encrypt a UTF-8 string into the self-identifying `aienc1:` envelope. A fresh random IV per call. */
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
- /** Decrypt an `aienc1:` envelope; a string without the prefix is returned unchanged (mixed/legacy store). */
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
- /** Build the codec `FileArea` uses to encrypt/decrypt its file contents. */
21
- export declare function makeCodec(key: Buffer): ContentCodec;
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;
@@ -1,23 +1,53 @@
1
1
  /**
2
- * Transparent encryption at rest (Phase 18) — 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
- * `aienc1:<base64(iv|tag|ciphertext)>`; `decode` reverses it. `decode` is deliberately lenient on the way IN —
5
- * a string WITHOUT the `aienc1:` prefix is returned unchanged, so a store that predates encryption keeps
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
- * SECURITY: the derived key is a Buffer held only inside the codec closure; it is never serialized, logged, or
10
- * placed in an error. The AES key is sha256(secret), so the raw secret length is irrelevant.
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
- export const ENVELOPE_PREFIX = 'aienc1:';
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
- /** Derive the 32-byte AES-256 key from an arbitrary secret (sha256 → fixed length, secret length irrelevant). */
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
- /** Encrypt a UTF-8 string into the self-identifying `aienc1:` envelope. A fresh random IV per call. */
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
- /** Decrypt an `aienc1:` envelope; a string without the prefix is returned unchanged (mixed/legacy store). */
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; // plaintext (pre-encryption) record — read as-is
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 Error('encrypted record is truncated');
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
- const decipher = createDecipheriv('aes-256-gcm', key, iv);
39
- decipher.setAuthTag(tag);
40
- // GCM `final()` throws on a wrong key or tampered ciphertext — surfaces as store corruption to the caller.
41
- return Buffer.concat([decipher.update(ct), decipher.final()]).toString('utf8');
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
- /** Build the codec `FileArea` uses to encrypt/decrypt its file contents. */
44
- export function makeCodec(key) {
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) => encryptString(plain, key),
47
- decode: (stored) => decryptString(stored, key),
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
+ }
@@ -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
- /** Aggregate integrity check across the project areas (plus the org area when configured). */
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
  }
@@ -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
- let held = false;
132
+ const token = randomBytes(16).toString('hex');
133
+ const held = this.acquireLock(lockPath, token, staleMs);
129
134
  try {
130
- writeFileSync(lockPath, String(process.pid), { flag: 'wx' });
131
- held = true;
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
- // Held already steal if stale.
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
- const age = this.clock.now() - statSync(lockPath).mtimeMs;
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
- /* proceed advisory */
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
- return fn();
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
- /** Aggregate integrity check across the project areas (plus the org area when configured). */
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 [...this.memory('project').check(), ...this.memory('user').check(), ...this.memory('repository').check(), ...org, ...this.conversations().check(), ...this.indexes().check()];
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
- mkdirSync(dirname(path), { recursive: true });
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 */