@onmars/lunar-core 0.1.0

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 (92) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +13 -0
  3. package/package.json +32 -0
  4. package/src/__tests__/clear-command.test.ts +214 -0
  5. package/src/__tests__/command-handler.test.ts +169 -0
  6. package/src/__tests__/compact-command.test.ts +80 -0
  7. package/src/__tests__/config-command.test.ts +240 -0
  8. package/src/__tests__/config-loader.test.ts +1512 -0
  9. package/src/__tests__/config.test.ts +429 -0
  10. package/src/__tests__/cron-command.test.ts +418 -0
  11. package/src/__tests__/cron-parser.test.ts +259 -0
  12. package/src/__tests__/daemon.test.ts +346 -0
  13. package/src/__tests__/dedup.test.ts +404 -0
  14. package/src/__tests__/e2e-sanitization.ts +168 -0
  15. package/src/__tests__/e2e-skill-loader.test.ts +176 -0
  16. package/src/__tests__/fixtures/AGENTS.md +4 -0
  17. package/src/__tests__/fixtures/IDENTITY.md +2 -0
  18. package/src/__tests__/fixtures/SOUL.md +3 -0
  19. package/src/__tests__/fixtures/moons/athena/IDENTITY.md +2 -0
  20. package/src/__tests__/fixtures/moons/athena/SOUL.md +3 -0
  21. package/src/__tests__/fixtures/moons/hermes/SOUL.md +3 -0
  22. package/src/__tests__/fixtures/skills/brain/SKILL.md +6 -0
  23. package/src/__tests__/fixtures/skills/empty/SKILL.md +3 -0
  24. package/src/__tests__/fixtures/skills/multiline/SKILL.md +7 -0
  25. package/src/__tests__/fixtures/skills/no-desc/SKILL.md +5 -0
  26. package/src/__tests__/fixtures/skills/notion/SKILL.md +6 -0
  27. package/src/__tests__/fixtures/skills/quoted/SKILL.md +6 -0
  28. package/src/__tests__/hook-runner.test.ts +1689 -0
  29. package/src/__tests__/input-sanitization.test.ts +367 -0
  30. package/src/__tests__/logger.test.ts +163 -0
  31. package/src/__tests__/memory-orchestrator.test.ts +552 -0
  32. package/src/__tests__/model-catalog.test.ts +215 -0
  33. package/src/__tests__/model-command.test.ts +185 -0
  34. package/src/__tests__/moon-loader.test.ts +398 -0
  35. package/src/__tests__/ping-command.test.ts +85 -0
  36. package/src/__tests__/plugin.test.ts +258 -0
  37. package/src/__tests__/remind-command.test.ts +368 -0
  38. package/src/__tests__/reset-command.test.ts +92 -0
  39. package/src/__tests__/router.test.ts +1246 -0
  40. package/src/__tests__/scheduler.test.ts +469 -0
  41. package/src/__tests__/security.test.ts +214 -0
  42. package/src/__tests__/session-meta.test.ts +101 -0
  43. package/src/__tests__/session-tracker.test.ts +389 -0
  44. package/src/__tests__/session.test.ts +241 -0
  45. package/src/__tests__/skill-loader.test.ts +153 -0
  46. package/src/__tests__/status-command.test.ts +153 -0
  47. package/src/__tests__/stop-command.test.ts +60 -0
  48. package/src/__tests__/think-command.test.ts +146 -0
  49. package/src/__tests__/usage-api.test.ts +222 -0
  50. package/src/__tests__/usage-command-api-fail.test.ts +48 -0
  51. package/src/__tests__/usage-command-no-oauth.test.ts +48 -0
  52. package/src/__tests__/usage-command.test.ts +173 -0
  53. package/src/__tests__/whoami-command.test.ts +124 -0
  54. package/src/index.ts +122 -0
  55. package/src/lib/command-handler.ts +135 -0
  56. package/src/lib/commands/clear.ts +69 -0
  57. package/src/lib/commands/compact.ts +14 -0
  58. package/src/lib/commands/config-show.ts +49 -0
  59. package/src/lib/commands/cron.ts +118 -0
  60. package/src/lib/commands/help.ts +26 -0
  61. package/src/lib/commands/model.ts +71 -0
  62. package/src/lib/commands/ping.ts +24 -0
  63. package/src/lib/commands/remind.ts +75 -0
  64. package/src/lib/commands/status.ts +118 -0
  65. package/src/lib/commands/stop.ts +18 -0
  66. package/src/lib/commands/think.ts +42 -0
  67. package/src/lib/commands/usage.ts +56 -0
  68. package/src/lib/commands/whoami.ts +23 -0
  69. package/src/lib/config-loader.ts +1449 -0
  70. package/src/lib/config.ts +202 -0
  71. package/src/lib/cron-parser.ts +388 -0
  72. package/src/lib/daemon.ts +216 -0
  73. package/src/lib/dedup.ts +414 -0
  74. package/src/lib/hook-runner.ts +1270 -0
  75. package/src/lib/logger.ts +55 -0
  76. package/src/lib/memory-orchestrator.ts +415 -0
  77. package/src/lib/model-catalog.ts +240 -0
  78. package/src/lib/moon-loader.ts +291 -0
  79. package/src/lib/plugin.ts +148 -0
  80. package/src/lib/router.ts +1135 -0
  81. package/src/lib/scheduler.ts +422 -0
  82. package/src/lib/security.ts +259 -0
  83. package/src/lib/session-tracker.ts +222 -0
  84. package/src/lib/session.ts +158 -0
  85. package/src/lib/skill-loader.ts +166 -0
  86. package/src/lib/usage-api.ts +145 -0
  87. package/src/types/agent.ts +86 -0
  88. package/src/types/channel.ts +93 -0
  89. package/src/types/index.ts +32 -0
  90. package/src/types/memory.ts +92 -0
  91. package/src/types/moon.ts +56 -0
  92. package/src/types/voice.ts +74 -0
@@ -0,0 +1,469 @@
1
+ /**
2
+ * Scheduler — Test Suite
3
+ *
4
+ * Tests the cron-based job scheduler.
5
+ * Covers: JobStore CRUD, schedule computation, tick execution,
6
+ * config sync, one-shot behavior, and error handling.
7
+ */
8
+
9
+ import { Database } from 'bun:sqlite'
10
+ import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
11
+ import {
12
+ type DispatchFn,
13
+ type Job,
14
+ type JobPayload,
15
+ JobStore,
16
+ Scheduler,
17
+ type SchedulerConfig,
18
+ } from '../lib/scheduler'
19
+
20
+ // ════════════════════════════════════════════════════════════
21
+ // Helpers
22
+ // ════════════════════════════════════════════════════════════
23
+
24
+ function createTestDb(): Database {
25
+ const db = new Database(':memory:')
26
+ db.exec('PRAGMA journal_mode = WAL')
27
+ db.exec(`
28
+ CREATE TABLE IF NOT EXISTS jobs (
29
+ id TEXT PRIMARY KEY,
30
+ name TEXT,
31
+ schedule TEXT NOT NULL,
32
+ payload TEXT NOT NULL,
33
+ channel TEXT NOT NULL DEFAULT '',
34
+ moon TEXT,
35
+ enabled INTEGER NOT NULL DEFAULT 1,
36
+ one_shot INTEGER NOT NULL DEFAULT 0,
37
+ last_run INTEGER,
38
+ next_run INTEGER,
39
+ created_at INTEGER NOT NULL DEFAULT (unixepoch())
40
+ );
41
+ CREATE INDEX IF NOT EXISTS idx_jobs_next ON jobs(next_run) WHERE enabled = 1;
42
+ `)
43
+ return db
44
+ }
45
+
46
+ function makeJob(overrides: Partial<Job> = {}): Job {
47
+ return {
48
+ id: crypto.randomUUID(),
49
+ name: 'test-job',
50
+ schedule: { kind: 'cron', expr: '0 9 * * *' },
51
+ payload: { kind: 'message', text: 'Hello' },
52
+ channel: 'orbit',
53
+ enabled: true,
54
+ oneShot: false,
55
+ createdAt: Math.floor(Date.now() / 1000),
56
+ ...overrides,
57
+ }
58
+ }
59
+
60
+ function makeConfig(overrides: Partial<SchedulerConfig> = {}): SchedulerConfig {
61
+ return {
62
+ enabled: true,
63
+ pollIntervalMs: 60000,
64
+ timezone: 'UTC',
65
+ jobs: {},
66
+ ...overrides,
67
+ }
68
+ }
69
+
70
+ // ════════════════════════════════════════════════════════════
71
+ // JobStore — CRUD
72
+ // ════════════════════════════════════════════════════════════
73
+
74
+ describe('JobStore — persistence layer', () => {
75
+ let db: Database
76
+ let store: JobStore
77
+
78
+ beforeEach(() => {
79
+ db = createTestDb()
80
+ store = new JobStore(db)
81
+ })
82
+
83
+ afterEach(() => {
84
+ db.close()
85
+ })
86
+
87
+ test('add and get a job', () => {
88
+ const job = makeJob()
89
+ store.add(job)
90
+ const retrieved = store.get(job.id)
91
+ expect(retrieved).not.toBeNull()
92
+ expect(retrieved!.id).toBe(job.id)
93
+ expect(retrieved!.name).toBe('test-job')
94
+ expect(retrieved!.channel).toBe('orbit')
95
+ expect(retrieved!.payload).toEqual({ kind: 'message', text: 'Hello' })
96
+ })
97
+
98
+ test('get by name', () => {
99
+ const job = makeJob({ name: 'morning-checkin' })
100
+ store.add(job)
101
+ const retrieved = store.get('morning-checkin')
102
+ expect(retrieved).not.toBeNull()
103
+ expect(retrieved!.id).toBe(job.id)
104
+ })
105
+
106
+ test('get returns null for missing job', () => {
107
+ expect(store.get('nonexistent')).toBeNull()
108
+ })
109
+
110
+ test('list all jobs', () => {
111
+ store.add(makeJob({ name: 'job-1' }))
112
+ store.add(makeJob({ id: crypto.randomUUID(), name: 'job-2' }))
113
+ const all = store.list()
114
+ expect(all.length).toBe(2)
115
+ })
116
+
117
+ test('list enabled only', () => {
118
+ store.add(makeJob({ name: 'enabled', enabled: true }))
119
+ store.add(makeJob({ id: crypto.randomUUID(), name: 'disabled', enabled: false }))
120
+ const enabled = store.list(true)
121
+ expect(enabled.length).toBe(1)
122
+ expect(enabled[0].name).toBe('enabled')
123
+ })
124
+
125
+ test('getDue returns jobs with next_run <= now', () => {
126
+ const pastJob = makeJob({ name: 'past', nextRun: 1000 })
127
+ const futureJob = makeJob({ id: crypto.randomUUID(), name: 'future', nextRun: 9999999999 })
128
+ store.add(pastJob)
129
+ store.add(futureJob)
130
+ const due = store.getDue(2000)
131
+ expect(due.length).toBe(1)
132
+ expect(due[0].name).toBe('past')
133
+ })
134
+
135
+ test('updateRun updates last_run and next_run', () => {
136
+ const job = makeJob()
137
+ store.add(job)
138
+ store.updateRun(job.id, 5000, 6000)
139
+ const updated = store.get(job.id)
140
+ expect(updated!.lastRun).toBe(5000)
141
+ expect(updated!.nextRun).toBe(6000)
142
+ })
143
+
144
+ test('disable and enable', () => {
145
+ const job = makeJob()
146
+ store.add(job)
147
+ store.disable(job.id)
148
+ expect(store.get(job.id)!.enabled).toBe(false)
149
+ store.enable(job.id)
150
+ expect(store.get(job.id)!.enabled).toBe(true)
151
+ })
152
+
153
+ test('remove by id', () => {
154
+ const job = makeJob()
155
+ store.add(job)
156
+ expect(store.remove(job.id)).toBe(true)
157
+ expect(store.get(job.id)).toBeNull()
158
+ })
159
+
160
+ test('remove by name', () => {
161
+ const job = makeJob({ name: 'removable' })
162
+ store.add(job)
163
+ expect(store.remove('removable')).toBe(true)
164
+ expect(store.get(job.id)).toBeNull()
165
+ })
166
+
167
+ test('remove returns false for missing job', () => {
168
+ expect(store.remove('nonexistent')).toBe(false)
169
+ })
170
+
171
+ test('oneShot flag persists', () => {
172
+ const job = makeJob({ oneShot: true })
173
+ store.add(job)
174
+ expect(store.get(job.id)!.oneShot).toBe(true)
175
+ })
176
+
177
+ test('moon field persists', () => {
178
+ const job = makeJob({ moon: 'athena' })
179
+ store.add(job)
180
+ expect(store.get(job.id)!.moon).toBe('athena')
181
+ })
182
+ })
183
+
184
+ // ════════════════════════════════════════════════════════════
185
+ // Scheduler — Schedule computation
186
+ // ════════════════════════════════════════════════════════════
187
+
188
+ describe('Scheduler — computeNextRun', () => {
189
+ let db: Database
190
+ let store: JobStore
191
+ let scheduler: Scheduler
192
+
193
+ beforeEach(() => {
194
+ db = createTestDb()
195
+ store = new JobStore(db)
196
+ const dispatch: DispatchFn = async () => {}
197
+ scheduler = new Scheduler(store, dispatch, makeConfig())
198
+ })
199
+
200
+ afterEach(() => {
201
+ db.close()
202
+ })
203
+
204
+ test('cron schedule computes next occurrence', () => {
205
+ // "0 9 * * *" — every day at 09:00 UTC
206
+ const afterSec = Math.floor(new Date('2026-03-01T08:00:00Z').getTime() / 1000)
207
+ const next = scheduler.computeNextRun({ kind: 'cron', expr: '0 9 * * *' }, afterSec)
208
+ expect(next).not.toBeNull()
209
+ const nextDate = new Date(next! * 1000)
210
+ expect(nextDate.getUTCHours()).toBe(9)
211
+ expect(nextDate.getUTCMinutes()).toBe(0)
212
+ })
213
+
214
+ test('at schedule returns timestamp if in future', () => {
215
+ const afterSec = Math.floor(new Date('2026-03-01T08:00:00Z').getTime() / 1000)
216
+ const next = scheduler.computeNextRun({ kind: 'at', at: '2026-03-15T14:00:00Z' }, afterSec)
217
+ expect(next).not.toBeNull()
218
+ expect(new Date(next! * 1000).toISOString()).toBe('2026-03-15T14:00:00.000Z')
219
+ })
220
+
221
+ test('at schedule returns null if in past', () => {
222
+ const afterSec = Math.floor(new Date('2026-03-20T00:00:00Z').getTime() / 1000)
223
+ const next = scheduler.computeNextRun({ kind: 'at', at: '2026-03-15T14:00:00Z' }, afterSec)
224
+ expect(next).toBeNull()
225
+ })
226
+
227
+ test('every schedule adds interval', () => {
228
+ const afterSec = 1000
229
+ const next = scheduler.computeNextRun(
230
+ { kind: 'every', intervalMs: 300000 }, // 5 minutes
231
+ afterSec,
232
+ )
233
+ expect(next).toBe(1000 + 300) // 300000ms = 300s
234
+ })
235
+
236
+ test('cron with timezone', () => {
237
+ const config = makeConfig({ timezone: 'Europe/Madrid' })
238
+ const dispatch: DispatchFn = async () => {}
239
+ const tzScheduler = new Scheduler(store, dispatch, config)
240
+
241
+ const afterSec = Math.floor(new Date('2026-03-01T04:00:00Z').getTime() / 1000)
242
+ const next = tzScheduler.computeNextRun({ kind: 'cron', expr: '45 6 * * *' }, afterSec)
243
+ expect(next).not.toBeNull()
244
+ // 06:45 Madrid (CET UTC+1 winter) = 05:45 UTC
245
+ const nextDate = new Date(next! * 1000)
246
+ expect(nextDate.getUTCHours()).toBe(5)
247
+ expect(nextDate.getUTCMinutes()).toBe(45)
248
+ })
249
+ })
250
+
251
+ // ════════════════════════════════════════════════════════════
252
+ // Scheduler — Tick execution
253
+ // ════════════════════════════════════════════════════════════
254
+
255
+ describe('Scheduler — tick execution', () => {
256
+ let db: Database
257
+ let store: JobStore
258
+ let dispatched: Array<{ channel: string; payload: JobPayload }>
259
+
260
+ beforeEach(() => {
261
+ db = createTestDb()
262
+ store = new JobStore(db)
263
+ dispatched = []
264
+ })
265
+
266
+ afterEach(() => {
267
+ db.close()
268
+ })
269
+
270
+ test('tick dispatches due jobs', async () => {
271
+ const dispatch: DispatchFn = async (channel, payload) => {
272
+ dispatched.push({ channel, payload })
273
+ }
274
+ const scheduler = new Scheduler(store, dispatch, makeConfig())
275
+
276
+ const job = makeJob({
277
+ nextRun: Math.floor(Date.now() / 1000) - 10, // due 10s ago
278
+ })
279
+ store.add(job)
280
+
281
+ await scheduler.tick()
282
+
283
+ expect(dispatched.length).toBe(1)
284
+ expect(dispatched[0].channel).toBe('orbit')
285
+ expect(dispatched[0].payload).toEqual({ kind: 'message', text: 'Hello' })
286
+ })
287
+
288
+ test('tick skips future jobs', async () => {
289
+ const dispatch: DispatchFn = async (channel, payload) => {
290
+ dispatched.push({ channel, payload })
291
+ }
292
+ const scheduler = new Scheduler(store, dispatch, makeConfig())
293
+
294
+ const job = makeJob({
295
+ nextRun: Math.floor(Date.now() / 1000) + 3600, // due in 1h
296
+ })
297
+ store.add(job)
298
+
299
+ await scheduler.tick()
300
+ expect(dispatched.length).toBe(0)
301
+ })
302
+
303
+ test('tick disables one-shot jobs after execution', async () => {
304
+ const dispatch: DispatchFn = async () => {}
305
+ const scheduler = new Scheduler(store, dispatch, makeConfig())
306
+
307
+ const job = makeJob({
308
+ oneShot: true,
309
+ nextRun: Math.floor(Date.now() / 1000) - 10,
310
+ })
311
+ store.add(job)
312
+
313
+ await scheduler.tick()
314
+
315
+ const updated = store.get(job.id)
316
+ expect(updated!.enabled).toBe(false)
317
+ })
318
+
319
+ test('tick updates last_run and next_run', async () => {
320
+ const dispatch: DispatchFn = async () => {}
321
+ const scheduler = new Scheduler(store, dispatch, makeConfig())
322
+
323
+ const job = makeJob({
324
+ schedule: { kind: 'every', intervalMs: 60000 },
325
+ nextRun: Math.floor(Date.now() / 1000) - 10,
326
+ })
327
+ store.add(job)
328
+
329
+ await scheduler.tick()
330
+
331
+ const updated = store.get(job.id)
332
+ expect(updated!.lastRun).toBeGreaterThan(0)
333
+ expect(updated!.nextRun).toBeGreaterThan(updated!.lastRun!)
334
+ })
335
+
336
+ test('tick continues on dispatch error', async () => {
337
+ let callCount = 0
338
+ const dispatch: DispatchFn = async () => {
339
+ callCount++
340
+ if (callCount === 1) throw new Error('Dispatch failed')
341
+ }
342
+ const scheduler = new Scheduler(store, dispatch, makeConfig())
343
+
344
+ // Two due jobs
345
+ store.add(
346
+ makeJob({
347
+ name: 'will-fail',
348
+ nextRun: Math.floor(Date.now() / 1000) - 10,
349
+ }),
350
+ )
351
+ store.add(
352
+ makeJob({
353
+ id: crypto.randomUUID(),
354
+ name: 'will-succeed',
355
+ nextRun: Math.floor(Date.now() / 1000) - 10,
356
+ }),
357
+ )
358
+
359
+ await scheduler.tick()
360
+ expect(callCount).toBe(2) // both attempted
361
+ })
362
+
363
+ test('tick skips disabled jobs', async () => {
364
+ const dispatch: DispatchFn = async (channel, payload) => {
365
+ dispatched.push({ channel, payload })
366
+ }
367
+ const scheduler = new Scheduler(store, dispatch, makeConfig())
368
+
369
+ const job = makeJob({
370
+ enabled: false,
371
+ nextRun: Math.floor(Date.now() / 1000) - 10,
372
+ })
373
+ store.add(job)
374
+
375
+ await scheduler.tick()
376
+ expect(dispatched.length).toBe(0)
377
+ })
378
+ })
379
+
380
+ // ════════════════════════════════════════════════════════════
381
+ // Scheduler — Config sync
382
+ // ════════════════════════════════════════════════════════════
383
+
384
+ describe('Scheduler — config sync and lifecycle', () => {
385
+ let db: Database
386
+ let store: JobStore
387
+
388
+ beforeEach(() => {
389
+ db = createTestDb()
390
+ store = new JobStore(db)
391
+ })
392
+
393
+ afterEach(() => {
394
+ db.close()
395
+ })
396
+
397
+ test('start syncs config jobs to database', async () => {
398
+ const config = makeConfig({
399
+ jobs: {
400
+ 'morning-checkin': {
401
+ schedule: { kind: 'cron', expr: '45 6 * * 1-5' },
402
+ channel: 'orbit',
403
+ payload: { kind: 'query', prompt: 'Good morning!' },
404
+ },
405
+ 'evening-close': {
406
+ schedule: { kind: 'cron', expr: '0 21 * * *' },
407
+ channel: 'orbit',
408
+ payload: { kind: 'query', prompt: 'How was your day?' },
409
+ },
410
+ },
411
+ })
412
+
413
+ const dispatch: DispatchFn = async () => {}
414
+ const scheduler = new Scheduler(store, dispatch, config)
415
+ await scheduler.start()
416
+
417
+ const jobs = store.list()
418
+ expect(jobs.length).toBe(2)
419
+ expect(jobs.some((j) => j.name === 'morning-checkin')).toBe(true)
420
+ expect(jobs.some((j) => j.name === 'evening-close')).toBe(true)
421
+
422
+ // All jobs should have next_run computed
423
+ for (const job of jobs) {
424
+ expect(job.nextRun).toBeGreaterThan(0)
425
+ }
426
+
427
+ await scheduler.stop()
428
+ })
429
+
430
+ test('start preserves existing runtime jobs', async () => {
431
+ // Add a runtime job first
432
+ store.add(makeJob({ name: 'runtime-job', nextRun: 9999999999 }))
433
+
434
+ const config = makeConfig({
435
+ jobs: {
436
+ 'config-job': {
437
+ schedule: { kind: 'cron', expr: '0 12 * * *' },
438
+ channel: 'research',
439
+ payload: { kind: 'message', text: 'Noon reminder' },
440
+ },
441
+ },
442
+ })
443
+
444
+ const dispatch: DispatchFn = async () => {}
445
+ const scheduler = new Scheduler(store, dispatch, config)
446
+ await scheduler.start()
447
+
448
+ const jobs = store.list()
449
+ expect(jobs.length).toBe(2)
450
+ expect(jobs.some((j) => j.name === 'runtime-job')).toBe(true)
451
+ expect(jobs.some((j) => j.name === 'config-job')).toBe(true)
452
+
453
+ await scheduler.stop()
454
+ })
455
+
456
+ test('start and stop lifecycle', async () => {
457
+ const dispatch: DispatchFn = async () => {}
458
+ const scheduler = new Scheduler(store, dispatch, makeConfig())
459
+
460
+ await scheduler.start()
461
+ // Should be running (no error)
462
+
463
+ await scheduler.stop()
464
+ // Should be stopped (no error)
465
+
466
+ // Double stop is safe
467
+ await scheduler.stop()
468
+ })
469
+ })
@@ -0,0 +1,214 @@
1
+ /**
2
+ * # Security Module — Functional Specification
3
+ *
4
+ * Two responsibilities:
5
+ *
6
+ * ## 1. buildSafeEnv — Environment Variable Isolation
7
+ * Controls which env vars reach the agent subprocess.
8
+ * Default strategy: DENY ALL, then allow via `envDefaults` + `envPassthrough`.
9
+ * Escape hatch: `envPassthroughAll: true` passes everything (dangerous).
10
+ * `extraEnv` provides programmatic overrides that always win.
11
+ *
12
+ * ## 2. createRedactor — Output Secret Redaction
13
+ * Returns a reusable function that scrubs known secret patterns from text.
14
+ * 10 built-in patterns (OpenAI, GitHub PAT, Slack, Discord, AWS, Google, Stripe).
15
+ * User-defined patterns from config are appended on top.
16
+ * Invalid regex patterns are skipped with a warning, never crash.
17
+ */
18
+ import { describe, expect, it } from 'bun:test'
19
+ import type { SecurityConfig } from '../lib/config-loader'
20
+ import { buildSafeEnv, createRedactor } from '../lib/security'
21
+
22
+ // ─── Shared fixtures ───────────────────────────────────────────────
23
+
24
+ const baseConfig: SecurityConfig = {
25
+ isolation: 'process',
26
+ envDefaults: ['HOME', 'PATH', 'USER'],
27
+ envPassthrough: [],
28
+ envPassthroughAll: false,
29
+ outputRedactPatterns: [],
30
+ inputSanitization: {
31
+ enabled: true,
32
+ stripMarkers: true,
33
+ logSuspicious: false,
34
+ notifyAgent: false,
35
+ customPatterns: [],
36
+ },
37
+ }
38
+
39
+ const mockEnv: Record<string, string> = {
40
+ HOME: '/home/user',
41
+ PATH: '/usr/bin',
42
+ USER: 'testuser',
43
+ DISCORD_TOKEN: 'secret-discord-token',
44
+ OPENAI_API_KEY: 'sk-abc123secretkeyvalue',
45
+ BRAIN_URL: 'http://localhost:8082',
46
+ SECRET_VAR: 'super-secret',
47
+ }
48
+
49
+ // ═══════════════════════════════════════════════════════════════════
50
+ // buildSafeEnv — Environment variable isolation for agent subprocesses
51
+ // ═══════════════════════════════════════════════════════════════════
52
+
53
+ describe('buildSafeEnv', () => {
54
+ // --- Core behavior: allowlist-based filtering ---
55
+
56
+ it('ONLY passes variables listed in envDefaults (deny-all default)', () => {
57
+ const env = buildSafeEnv(mockEnv, baseConfig)
58
+
59
+ expect(env.HOME).toBe('/home/user')
60
+ expect(env.PATH).toBe('/usr/bin')
61
+ expect(env.USER).toBe('testuser')
62
+ // Everything else is blocked
63
+ expect(env.DISCORD_TOKEN).toBeUndefined()
64
+ expect(env.OPENAI_API_KEY).toBeUndefined()
65
+ expect(env.SECRET_VAR).toBeUndefined()
66
+ })
67
+
68
+ it('envPassthrough adds specific vars to the allowlist', () => {
69
+ const config = { ...baseConfig, envPassthrough: ['BRAIN_URL'] }
70
+ const env = buildSafeEnv(mockEnv, config)
71
+
72
+ expect(env.BRAIN_URL).toBe('http://localhost:8082')
73
+ // Non-listed vars remain blocked
74
+ expect(env.DISCORD_TOKEN).toBeUndefined()
75
+ })
76
+
77
+ // --- Escape hatch: passthrough all ---
78
+
79
+ it('envPassthroughAll=true passes EVERYTHING (dangerous mode)', () => {
80
+ const config = { ...baseConfig, envPassthroughAll: true }
81
+ const env = buildSafeEnv(mockEnv, config)
82
+
83
+ expect(env.HOME).toBe('/home/user')
84
+ expect(env.DISCORD_TOKEN).toBe('secret-discord-token')
85
+ expect(env.SECRET_VAR).toBe('super-secret')
86
+ })
87
+
88
+ // --- Programmatic overrides via extraEnv ---
89
+
90
+ it('extraEnv injects vars regardless of allowlist', () => {
91
+ const env = buildSafeEnv(mockEnv, baseConfig, { BRAIN_URL: 'http://brain:8082' })
92
+
93
+ // BRAIN_URL not in envDefaults, but extraEnv overrides
94
+ expect(env.BRAIN_URL).toBe('http://brain:8082')
95
+ expect(env.DISCORD_TOKEN).toBeUndefined()
96
+ })
97
+
98
+ it('extraEnv takes priority over processEnv for same key', () => {
99
+ const config = { ...baseConfig, envPassthrough: ['BRAIN_URL'] }
100
+ const env = buildSafeEnv(mockEnv, config, { BRAIN_URL: 'http://override:9999' })
101
+
102
+ expect(env.BRAIN_URL).toBe('http://override:9999')
103
+ })
104
+
105
+ // --- Edge cases ---
106
+
107
+ it('silently skips undefined/missing env vars', () => {
108
+ const config = { ...baseConfig, envDefaults: ['HOME', 'PATH', 'NONEXISTENT'] }
109
+ const env = buildSafeEnv(mockEnv, config)
110
+
111
+ expect(env.NONEXISTENT).toBeUndefined()
112
+ expect(Object.keys(env)).not.toContain('NONEXISTENT')
113
+ })
114
+
115
+ it('returns empty object when no allowed vars exist in env', () => {
116
+ const config = { ...baseConfig, envDefaults: ['NOTHING_HERE'] }
117
+ const env = buildSafeEnv(mockEnv, config)
118
+
119
+ expect(Object.keys(env)).toHaveLength(0)
120
+ })
121
+
122
+ it('deduplicates when same var is in envDefaults AND envPassthrough', () => {
123
+ const config = { ...baseConfig, envDefaults: ['HOME', 'PATH'], envPassthrough: ['HOME'] }
124
+ const env = buildSafeEnv(mockEnv, config)
125
+
126
+ // Should work fine — no duplicate keys in output
127
+ expect(env.HOME).toBe('/home/user')
128
+ })
129
+ })
130
+
131
+ // ═══════════════════════════════════════════════════════════════════
132
+ // createRedactor — Secret pattern redaction for agent output
133
+ // ═══════════════════════════════════════════════════════════════════
134
+
135
+ describe('createRedactor', () => {
136
+ // --- Built-in patterns: major cloud provider secrets ---
137
+ // These 10 patterns are ALWAYS active, even with no user config.
138
+
139
+ it('redacts OpenAI API keys (sk-...)', () => {
140
+ const redact = createRedactor(baseConfig)
141
+ const input = 'My key is sk-abc123defghijklmnopqrst and stuff'
142
+
143
+ expect(redact(input)).toContain('[REDACTED]')
144
+ expect(redact(input)).not.toContain('sk-abc123')
145
+ })
146
+
147
+ it('redacts GitHub Personal Access Tokens (ghp_... 36 chars)', () => {
148
+ const redact = createRedactor(baseConfig)
149
+ // ghp_ followed by exactly 36 alphanumeric chars
150
+ const input = 'Token: ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghij'
151
+
152
+ expect(redact(input)).toContain('[REDACTED]')
153
+ expect(redact(input)).not.toContain('ghp_')
154
+ })
155
+
156
+ it('redacts Discord bot tokens (base64.base64.base64)', () => {
157
+ const redact = createRedactor(baseConfig)
158
+ // Format: 24 alphanum . 6 alphanum . 27+ alphanum/dash/underscore
159
+ const input = 'MTQ3NzI5Mjc5MTI1MDk0NDA3.GMNyiM.hGYmeP29JZdc0KBhz7T0HOnyP9qU9N2MhWIEA'
160
+
161
+ expect(redact(input)).toContain('[REDACTED]')
162
+ })
163
+
164
+ it('redacts AWS access keys (AKIA...)', () => {
165
+ const redact = createRedactor(baseConfig)
166
+ const input = 'Access key: AKIAIOSFODNN7EXAMPLE'
167
+
168
+ expect(redact(input)).toContain('[REDACTED]')
169
+ expect(redact(input)).not.toContain('AKIA')
170
+ })
171
+
172
+ // --- User-defined patterns (from config.yaml) ---
173
+
174
+ it('applies user-defined regex patterns from config', () => {
175
+ const config = { ...baseConfig, outputRedactPatterns: ['password=[^\\s]+'] }
176
+ const redact = createRedactor(config)
177
+
178
+ expect(redact('Connect with password=SuperSecret123 now')).toContain('[REDACTED]')
179
+ expect(redact('Connect with password=SuperSecret123 now')).not.toContain('SuperSecret123')
180
+ })
181
+
182
+ // --- Multiple matches and clean passthrough ---
183
+
184
+ it('redacts ALL matching occurrences in one pass', () => {
185
+ const redact = createRedactor(baseConfig)
186
+ const input = 'Keys: sk-aaaabbbbccccddddeeeefffff and sk-111122223333444455556666'
187
+
188
+ expect(redact(input)).toBe('Keys: [REDACTED] and [REDACTED]')
189
+ })
190
+
191
+ it('passes through clean text unchanged', () => {
192
+ const redact = createRedactor(baseConfig)
193
+ const input = 'Hello, this is a normal message without any secrets.'
194
+
195
+ expect(redact(input)).toBe(input)
196
+ })
197
+
198
+ // --- Robustness ---
199
+
200
+ it('skips invalid regex patterns gracefully (no crash)', () => {
201
+ const config = { ...baseConfig, outputRedactPatterns: ['[invalid', 'valid-pattern'] }
202
+ // Must not throw — invalid patterns are logged and skipped
203
+ const redact = createRedactor(config)
204
+
205
+ expect(redact('test text')).toBe('test text')
206
+ })
207
+
208
+ it('redactor is reusable across multiple calls (stateless)', () => {
209
+ const redact = createRedactor(baseConfig)
210
+
211
+ expect(redact('sk-aaaabbbbccccddddeeeefffff')).toContain('[REDACTED]')
212
+ expect(redact('sk-111122223333444455556666z')).toContain('[REDACTED]')
213
+ })
214
+ })