@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.
- package/LICENSE +21 -0
- package/README.md +13 -0
- package/package.json +32 -0
- package/src/__tests__/clear-command.test.ts +214 -0
- package/src/__tests__/command-handler.test.ts +169 -0
- package/src/__tests__/compact-command.test.ts +80 -0
- package/src/__tests__/config-command.test.ts +240 -0
- package/src/__tests__/config-loader.test.ts +1512 -0
- package/src/__tests__/config.test.ts +429 -0
- package/src/__tests__/cron-command.test.ts +418 -0
- package/src/__tests__/cron-parser.test.ts +259 -0
- package/src/__tests__/daemon.test.ts +346 -0
- package/src/__tests__/dedup.test.ts +404 -0
- package/src/__tests__/e2e-sanitization.ts +168 -0
- package/src/__tests__/e2e-skill-loader.test.ts +176 -0
- package/src/__tests__/fixtures/AGENTS.md +4 -0
- package/src/__tests__/fixtures/IDENTITY.md +2 -0
- package/src/__tests__/fixtures/SOUL.md +3 -0
- package/src/__tests__/fixtures/moons/athena/IDENTITY.md +2 -0
- package/src/__tests__/fixtures/moons/athena/SOUL.md +3 -0
- package/src/__tests__/fixtures/moons/hermes/SOUL.md +3 -0
- package/src/__tests__/fixtures/skills/brain/SKILL.md +6 -0
- package/src/__tests__/fixtures/skills/empty/SKILL.md +3 -0
- package/src/__tests__/fixtures/skills/multiline/SKILL.md +7 -0
- package/src/__tests__/fixtures/skills/no-desc/SKILL.md +5 -0
- package/src/__tests__/fixtures/skills/notion/SKILL.md +6 -0
- package/src/__tests__/fixtures/skills/quoted/SKILL.md +6 -0
- package/src/__tests__/hook-runner.test.ts +1689 -0
- package/src/__tests__/input-sanitization.test.ts +367 -0
- package/src/__tests__/logger.test.ts +163 -0
- package/src/__tests__/memory-orchestrator.test.ts +552 -0
- package/src/__tests__/model-catalog.test.ts +215 -0
- package/src/__tests__/model-command.test.ts +185 -0
- package/src/__tests__/moon-loader.test.ts +398 -0
- package/src/__tests__/ping-command.test.ts +85 -0
- package/src/__tests__/plugin.test.ts +258 -0
- package/src/__tests__/remind-command.test.ts +368 -0
- package/src/__tests__/reset-command.test.ts +92 -0
- package/src/__tests__/router.test.ts +1246 -0
- package/src/__tests__/scheduler.test.ts +469 -0
- package/src/__tests__/security.test.ts +214 -0
- package/src/__tests__/session-meta.test.ts +101 -0
- package/src/__tests__/session-tracker.test.ts +389 -0
- package/src/__tests__/session.test.ts +241 -0
- package/src/__tests__/skill-loader.test.ts +153 -0
- package/src/__tests__/status-command.test.ts +153 -0
- package/src/__tests__/stop-command.test.ts +60 -0
- package/src/__tests__/think-command.test.ts +146 -0
- package/src/__tests__/usage-api.test.ts +222 -0
- package/src/__tests__/usage-command-api-fail.test.ts +48 -0
- package/src/__tests__/usage-command-no-oauth.test.ts +48 -0
- package/src/__tests__/usage-command.test.ts +173 -0
- package/src/__tests__/whoami-command.test.ts +124 -0
- package/src/index.ts +122 -0
- package/src/lib/command-handler.ts +135 -0
- package/src/lib/commands/clear.ts +69 -0
- package/src/lib/commands/compact.ts +14 -0
- package/src/lib/commands/config-show.ts +49 -0
- package/src/lib/commands/cron.ts +118 -0
- package/src/lib/commands/help.ts +26 -0
- package/src/lib/commands/model.ts +71 -0
- package/src/lib/commands/ping.ts +24 -0
- package/src/lib/commands/remind.ts +75 -0
- package/src/lib/commands/status.ts +118 -0
- package/src/lib/commands/stop.ts +18 -0
- package/src/lib/commands/think.ts +42 -0
- package/src/lib/commands/usage.ts +56 -0
- package/src/lib/commands/whoami.ts +23 -0
- package/src/lib/config-loader.ts +1449 -0
- package/src/lib/config.ts +202 -0
- package/src/lib/cron-parser.ts +388 -0
- package/src/lib/daemon.ts +216 -0
- package/src/lib/dedup.ts +414 -0
- package/src/lib/hook-runner.ts +1270 -0
- package/src/lib/logger.ts +55 -0
- package/src/lib/memory-orchestrator.ts +415 -0
- package/src/lib/model-catalog.ts +240 -0
- package/src/lib/moon-loader.ts +291 -0
- package/src/lib/plugin.ts +148 -0
- package/src/lib/router.ts +1135 -0
- package/src/lib/scheduler.ts +422 -0
- package/src/lib/security.ts +259 -0
- package/src/lib/session-tracker.ts +222 -0
- package/src/lib/session.ts +158 -0
- package/src/lib/skill-loader.ts +166 -0
- package/src/lib/usage-api.ts +145 -0
- package/src/types/agent.ts +86 -0
- package/src/types/channel.ts +93 -0
- package/src/types/index.ts +32 -0
- package/src/types/memory.ts +92 -0
- package/src/types/moon.ts +56 -0
- 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
|
+
})
|