@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,422 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scheduler — Cron-based job system for proactive AI behavior.
|
|
3
|
+
*
|
|
4
|
+
* Spec (ARCHITECTURE.md §16):
|
|
5
|
+
* - Polls SQLite jobs table every pollInterval (default 60s)
|
|
6
|
+
* - Three schedule types: cron (5-field), at (one-shot ISO-8601), every (interval)
|
|
7
|
+
* - Two payload types: query (agent), message (static)
|
|
8
|
+
* - Dispatches via Router.dispatchScheduled()
|
|
9
|
+
* - Jobs defined in config.yaml and/or created at runtime via slash commands
|
|
10
|
+
* - Timezone-aware (Intl.DateTimeFormat)
|
|
11
|
+
*
|
|
12
|
+
* Lifecycle:
|
|
13
|
+
* daemon.start() -> scheduler.start()
|
|
14
|
+
* daemon.stop() -> scheduler.stop()
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { type CronFields, nextOccurrence, parseCron } from './cron-parser'
|
|
18
|
+
import { log } from './logger'
|
|
19
|
+
|
|
20
|
+
// ════════════════════════════════════════════════════════════
|
|
21
|
+
// Types
|
|
22
|
+
// ════════════════════════════════════════════════════════════
|
|
23
|
+
|
|
24
|
+
export type Schedule =
|
|
25
|
+
| { kind: 'cron'; expr: string; tz?: string }
|
|
26
|
+
| { kind: 'at'; at: string }
|
|
27
|
+
| { kind: 'every'; intervalMs: number }
|
|
28
|
+
|
|
29
|
+
export type JobPayload = { kind: 'query'; prompt: string } | { kind: 'message'; text: string }
|
|
30
|
+
|
|
31
|
+
export interface Job {
|
|
32
|
+
id: string
|
|
33
|
+
name?: string
|
|
34
|
+
schedule: Schedule
|
|
35
|
+
payload: JobPayload
|
|
36
|
+
channel: string
|
|
37
|
+
moon?: string
|
|
38
|
+
enabled: boolean
|
|
39
|
+
oneShot: boolean
|
|
40
|
+
lastRun?: number
|
|
41
|
+
nextRun?: number
|
|
42
|
+
createdAt: number
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface SchedulerConfig {
|
|
46
|
+
enabled: boolean
|
|
47
|
+
pollIntervalMs: number
|
|
48
|
+
timezone: string
|
|
49
|
+
jobs: Record<string, JobConfig>
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface JobConfig {
|
|
53
|
+
schedule: Schedule
|
|
54
|
+
channel: string
|
|
55
|
+
payload: JobPayload
|
|
56
|
+
moon?: string
|
|
57
|
+
enabled?: boolean
|
|
58
|
+
oneShot?: boolean
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Dispatch function signature — provided by the Router */
|
|
62
|
+
export type DispatchFn = (
|
|
63
|
+
channel: string,
|
|
64
|
+
payload: JobPayload,
|
|
65
|
+
options?: { moon?: string; jobId?: string; jobName?: string },
|
|
66
|
+
) => Promise<void>
|
|
67
|
+
|
|
68
|
+
// ════════════════════════════════════════════════════════════
|
|
69
|
+
// JobStore — SQLite persistence layer
|
|
70
|
+
// ════════════════════════════════════════════════════════════
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* JobStore wraps the SQLite database for job CRUD operations.
|
|
74
|
+
* Uses the `jobs` table already created by SessionStore.
|
|
75
|
+
*/
|
|
76
|
+
export class JobStore {
|
|
77
|
+
private db: import('bun:sqlite').Database
|
|
78
|
+
|
|
79
|
+
constructor(db: import('bun:sqlite').Database) {
|
|
80
|
+
this.db = db
|
|
81
|
+
this.ensureSchema()
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private ensureSchema(): void {
|
|
85
|
+
// Add columns that may not exist in the v0.1 schema
|
|
86
|
+
// SQLite doesn't have IF NOT EXISTS for columns, so we check first
|
|
87
|
+
try {
|
|
88
|
+
this.db.exec(`
|
|
89
|
+
ALTER TABLE jobs ADD COLUMN channel TEXT NOT NULL DEFAULT '';
|
|
90
|
+
`)
|
|
91
|
+
} catch {
|
|
92
|
+
/* column already exists */
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
this.db.exec(`
|
|
97
|
+
ALTER TABLE jobs ADD COLUMN moon TEXT;
|
|
98
|
+
`)
|
|
99
|
+
} catch {
|
|
100
|
+
/* column already exists */
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
this.db.exec(`
|
|
105
|
+
ALTER TABLE jobs ADD COLUMN one_shot INTEGER NOT NULL DEFAULT 0;
|
|
106
|
+
`)
|
|
107
|
+
} catch {
|
|
108
|
+
/* column already exists */
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Add a new job */
|
|
113
|
+
add(job: Job): void {
|
|
114
|
+
this.db
|
|
115
|
+
.query(`
|
|
116
|
+
INSERT OR REPLACE INTO jobs (id, name, schedule, payload, channel, moon, enabled, one_shot, last_run, next_run, created_at)
|
|
117
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
118
|
+
`)
|
|
119
|
+
.run(
|
|
120
|
+
job.id,
|
|
121
|
+
job.name ?? null,
|
|
122
|
+
JSON.stringify(job.schedule),
|
|
123
|
+
JSON.stringify(job.payload),
|
|
124
|
+
job.channel,
|
|
125
|
+
job.moon ?? null,
|
|
126
|
+
job.enabled ? 1 : 0,
|
|
127
|
+
job.oneShot ? 1 : 0,
|
|
128
|
+
job.lastRun ?? null,
|
|
129
|
+
job.nextRun ?? null,
|
|
130
|
+
job.createdAt,
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Get a job by ID or name */
|
|
135
|
+
get(idOrName: string): Job | null {
|
|
136
|
+
const row = this.db
|
|
137
|
+
.query('SELECT * FROM jobs WHERE id = ? OR name = ?')
|
|
138
|
+
.get(idOrName, idOrName) as JobRow | null
|
|
139
|
+
return row ? this.mapRow(row) : null
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** List all jobs (optionally only enabled) */
|
|
143
|
+
list(enabledOnly = false): Job[] {
|
|
144
|
+
const query = enabledOnly
|
|
145
|
+
? 'SELECT * FROM jobs WHERE enabled = 1 ORDER BY next_run ASC'
|
|
146
|
+
: 'SELECT * FROM jobs ORDER BY created_at DESC'
|
|
147
|
+
const rows = this.db.query(query).all() as JobRow[]
|
|
148
|
+
return rows.map((r) => this.mapRow(r))
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Get due jobs (next_run <= now and enabled) */
|
|
152
|
+
getDue(now: number): Job[] {
|
|
153
|
+
const rows = this.db
|
|
154
|
+
.query('SELECT * FROM jobs WHERE enabled = 1 AND next_run IS NOT NULL AND next_run <= ?')
|
|
155
|
+
.all(now) as JobRow[]
|
|
156
|
+
return rows.map((r) => this.mapRow(r))
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Update last_run and next_run */
|
|
160
|
+
updateRun(id: string, lastRun: number, nextRun: number | null): void {
|
|
161
|
+
this.db
|
|
162
|
+
.query('UPDATE jobs SET last_run = ?, next_run = ? WHERE id = ?')
|
|
163
|
+
.run(lastRun, nextRun, id)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Disable a job */
|
|
167
|
+
disable(id: string): void {
|
|
168
|
+
this.db.query('UPDATE jobs SET enabled = 0 WHERE id = ?').run(id)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Enable a job */
|
|
172
|
+
enable(id: string): void {
|
|
173
|
+
this.db.query('UPDATE jobs SET enabled = 1 WHERE id = ?').run(id)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Remove a job */
|
|
177
|
+
remove(id: string): boolean {
|
|
178
|
+
const result = this.db.query('DELETE FROM jobs WHERE id = ? OR name = ?').run(id, id)
|
|
179
|
+
return result.changes > 0
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Map a database row to a Job object */
|
|
183
|
+
private mapRow(row: JobRow): Job {
|
|
184
|
+
return {
|
|
185
|
+
id: row.id,
|
|
186
|
+
name: row.name ?? undefined,
|
|
187
|
+
schedule: JSON.parse(row.schedule) as Schedule,
|
|
188
|
+
payload: JSON.parse(row.payload) as JobPayload,
|
|
189
|
+
channel: row.channel,
|
|
190
|
+
moon: row.moon ?? undefined,
|
|
191
|
+
enabled: row.enabled === 1,
|
|
192
|
+
oneShot: row.one_shot === 1,
|
|
193
|
+
lastRun: row.last_run ?? undefined,
|
|
194
|
+
nextRun: row.next_run ?? undefined,
|
|
195
|
+
createdAt: row.created_at,
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
interface JobRow {
|
|
201
|
+
id: string
|
|
202
|
+
name: string | null
|
|
203
|
+
schedule: string
|
|
204
|
+
payload: string
|
|
205
|
+
channel: string
|
|
206
|
+
moon: string | null
|
|
207
|
+
enabled: number
|
|
208
|
+
one_shot: number
|
|
209
|
+
last_run: number | null
|
|
210
|
+
next_run: number | null
|
|
211
|
+
created_at: number
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ════════════════════════════════════════════════════════════
|
|
215
|
+
// Scheduler — Poll loop + dispatch
|
|
216
|
+
// ════════════════════════════════════════════════════════════
|
|
217
|
+
|
|
218
|
+
export class Scheduler {
|
|
219
|
+
private store: JobStore
|
|
220
|
+
private dispatch: DispatchFn
|
|
221
|
+
private config: SchedulerConfig
|
|
222
|
+
private intervalHandle?: ReturnType<typeof setInterval>
|
|
223
|
+
private running = false
|
|
224
|
+
/** Cache parsed cron fields to avoid re-parsing every tick */
|
|
225
|
+
private cronCache = new Map<string, CronFields>()
|
|
226
|
+
|
|
227
|
+
constructor(store: JobStore, dispatch: DispatchFn, config: SchedulerConfig) {
|
|
228
|
+
this.store = store
|
|
229
|
+
this.dispatch = dispatch
|
|
230
|
+
this.config = config
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Start the scheduler.
|
|
235
|
+
* 1. Sync config-defined jobs to the database
|
|
236
|
+
* 2. Compute next_run for all enabled jobs
|
|
237
|
+
* 3. Start the poll interval
|
|
238
|
+
*/
|
|
239
|
+
async start(): Promise<void> {
|
|
240
|
+
if (this.running) return
|
|
241
|
+
|
|
242
|
+
// Sync config jobs -> database
|
|
243
|
+
this.syncConfigJobs()
|
|
244
|
+
|
|
245
|
+
// Compute initial next_run for all enabled jobs
|
|
246
|
+
this.computeAllNextRuns()
|
|
247
|
+
|
|
248
|
+
// Start poll loop
|
|
249
|
+
this.running = true
|
|
250
|
+
this.intervalHandle = setInterval(() => {
|
|
251
|
+
this.tick().catch((err) => {
|
|
252
|
+
log.error({ err }, 'Scheduler tick error')
|
|
253
|
+
})
|
|
254
|
+
}, this.config.pollIntervalMs)
|
|
255
|
+
|
|
256
|
+
const jobs = this.store.list(true)
|
|
257
|
+
log.info(
|
|
258
|
+
{ pollMs: this.config.pollIntervalMs, tz: this.config.timezone, enabledJobs: jobs.length },
|
|
259
|
+
'Scheduler started',
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
// Log next runs
|
|
263
|
+
for (const job of jobs) {
|
|
264
|
+
if (job.nextRun) {
|
|
265
|
+
log.debug(
|
|
266
|
+
{ name: job.name ?? job.id, nextRun: new Date(job.nextRun * 1000).toISOString() },
|
|
267
|
+
'Job scheduled',
|
|
268
|
+
)
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Stop the scheduler */
|
|
274
|
+
async stop(): Promise<void> {
|
|
275
|
+
if (!this.running) return
|
|
276
|
+
|
|
277
|
+
this.running = false
|
|
278
|
+
if (this.intervalHandle) {
|
|
279
|
+
clearInterval(this.intervalHandle)
|
|
280
|
+
this.intervalHandle = undefined
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
log.info('Scheduler stopped')
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Get the job store for external access (slash commands, etc.) */
|
|
287
|
+
getStore(): JobStore {
|
|
288
|
+
return this.store
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ────────────────────────────────────────────────────────
|
|
292
|
+
|
|
293
|
+
/** One poll cycle: find and execute due jobs */
|
|
294
|
+
async tick(): Promise<void> {
|
|
295
|
+
const nowSec = Math.floor(Date.now() / 1000)
|
|
296
|
+
const dueJobs = this.store.getDue(nowSec)
|
|
297
|
+
|
|
298
|
+
if (dueJobs.length === 0) return
|
|
299
|
+
|
|
300
|
+
log.debug({ count: dueJobs.length }, 'Scheduler: due jobs found')
|
|
301
|
+
|
|
302
|
+
for (const job of dueJobs) {
|
|
303
|
+
try {
|
|
304
|
+
log.info(
|
|
305
|
+
{ id: job.id, name: job.name, channel: job.channel, kind: job.payload.kind },
|
|
306
|
+
'Executing scheduled job',
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
await this.dispatch(job.channel, job.payload, {
|
|
310
|
+
moon: job.moon,
|
|
311
|
+
jobId: job.id,
|
|
312
|
+
jobName: job.name,
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
// Update last_run and compute next_run
|
|
316
|
+
const nextRun = this.computeNextRun(job.schedule, nowSec)
|
|
317
|
+
this.store.updateRun(job.id, nowSec, nextRun)
|
|
318
|
+
|
|
319
|
+
// Auto-disable one-shot jobs
|
|
320
|
+
if (job.oneShot) {
|
|
321
|
+
this.store.disable(job.id)
|
|
322
|
+
log.info({ id: job.id, name: job.name }, 'One-shot job disabled after execution')
|
|
323
|
+
}
|
|
324
|
+
} catch (err) {
|
|
325
|
+
log.error({ err, id: job.id, name: job.name }, 'Failed to execute scheduled job')
|
|
326
|
+
// Don't re-throw — continue with other jobs
|
|
327
|
+
// Still update last_run to avoid retrying immediately
|
|
328
|
+
const nextRun = this.computeNextRun(job.schedule, nowSec)
|
|
329
|
+
this.store.updateRun(job.id, nowSec, nextRun)
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ────────────────────────────────────────────────────────
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Sync config-defined jobs to the database.
|
|
338
|
+
* Config is the source of truth for static jobs.
|
|
339
|
+
* Runtime-created jobs (no matching config name) are preserved.
|
|
340
|
+
*/
|
|
341
|
+
private syncConfigJobs(): void {
|
|
342
|
+
const existingJobs = this.store.list()
|
|
343
|
+
const existingByName = new Map(existingJobs.filter((j) => j.name).map((j) => [j.name!, j]))
|
|
344
|
+
|
|
345
|
+
for (const [name, config] of Object.entries(this.config.jobs)) {
|
|
346
|
+
const existing = existingByName.get(name)
|
|
347
|
+
|
|
348
|
+
const job: Job = {
|
|
349
|
+
id: existing?.id ?? crypto.randomUUID(),
|
|
350
|
+
name,
|
|
351
|
+
schedule: config.schedule,
|
|
352
|
+
payload: config.payload,
|
|
353
|
+
channel: config.channel,
|
|
354
|
+
moon: config.moon,
|
|
355
|
+
enabled: config.enabled !== false,
|
|
356
|
+
oneShot: config.oneShot === true,
|
|
357
|
+
lastRun: existing?.lastRun,
|
|
358
|
+
nextRun: existing?.nextRun,
|
|
359
|
+
createdAt: existing?.createdAt ?? Math.floor(Date.now() / 1000),
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
this.store.add(job)
|
|
363
|
+
log.debug({ name, id: job.id }, 'Config job synced')
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** Compute next_run for all enabled jobs that don't have one */
|
|
368
|
+
private computeAllNextRuns(): void {
|
|
369
|
+
const jobs = this.store.list(true)
|
|
370
|
+
const nowSec = Math.floor(Date.now() / 1000)
|
|
371
|
+
|
|
372
|
+
for (const job of jobs) {
|
|
373
|
+
if (!job.nextRun || job.nextRun <= nowSec) {
|
|
374
|
+
const nextRun = this.computeNextRun(job.schedule, nowSec)
|
|
375
|
+
if (nextRun !== null) {
|
|
376
|
+
this.store.updateRun(job.id, job.lastRun ?? 0, nextRun)
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Compute the next run time for a schedule.
|
|
384
|
+
* @returns Unix timestamp (seconds) or null if no next run
|
|
385
|
+
*/
|
|
386
|
+
computeNextRun(schedule: Schedule, afterSec: number): number | null {
|
|
387
|
+
const afterMs = afterSec * 1000
|
|
388
|
+
|
|
389
|
+
switch (schedule.kind) {
|
|
390
|
+
case 'cron': {
|
|
391
|
+
const tz = schedule.tz ?? this.config.timezone
|
|
392
|
+
// Use cached parsed fields
|
|
393
|
+
const cacheKey = `${schedule.expr}|${tz}`
|
|
394
|
+
let fields = this.cronCache.get(cacheKey)
|
|
395
|
+
if (!fields) {
|
|
396
|
+
fields = parseCron(schedule.expr)
|
|
397
|
+
this.cronCache.set(cacheKey, fields)
|
|
398
|
+
}
|
|
399
|
+
const next = nextOccurrence(fields, afterMs, tz)
|
|
400
|
+
return next ? Math.floor(next.getTime() / 1000) : null
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
case 'at': {
|
|
404
|
+
const atMs = new Date(schedule.at).getTime()
|
|
405
|
+
if (Number.isNaN(atMs)) {
|
|
406
|
+
log.warn({ at: schedule.at }, 'Invalid "at" timestamp')
|
|
407
|
+
return null
|
|
408
|
+
}
|
|
409
|
+
// Only fire if in the future
|
|
410
|
+
return atMs > afterMs ? Math.floor(atMs / 1000) : null
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
case 'every': {
|
|
414
|
+
return afterSec + Math.floor(schedule.intervalMs / 1000)
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
default:
|
|
418
|
+
log.warn({ schedule }, 'Unknown schedule kind')
|
|
419
|
+
return null
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import type { InputSanitizationConfig, SecurityConfig } from './config-loader'
|
|
2
|
+
import { log } from './logger'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Default regex patterns that are ALWAYS active for output redaction.
|
|
6
|
+
* User patterns from config.yaml are added on top of these.
|
|
7
|
+
*/
|
|
8
|
+
const DEFAULT_REDACT_PATTERNS = [
|
|
9
|
+
/sk-[a-zA-Z0-9]{20,}/g, // OpenAI API keys
|
|
10
|
+
/ghp_[a-zA-Z0-9]{36}/g, // GitHub PATs
|
|
11
|
+
/github_pat_[a-zA-Z0-9_]{82}/g, // GitHub fine-grained PATs
|
|
12
|
+
/xox[bpas]-[a-zA-Z0-9-]+/g, // Slack tokens
|
|
13
|
+
/\b[A-Za-z0-9]{24}\.[A-Za-z0-9_-]{6}\.[A-Za-z0-9_-]{27,}/g, // Discord bot tokens
|
|
14
|
+
/ghu_[a-zA-Z0-9]{36}/g, // GitHub user tokens
|
|
15
|
+
/ghs_[a-zA-Z0-9]{36}/g, // GitHub server tokens
|
|
16
|
+
/AKIA[0-9A-Z]{16}/g, // AWS access keys
|
|
17
|
+
/AIza[0-9A-Za-z_-]{35}/g, // Google API keys
|
|
18
|
+
/sk_live_[a-zA-Z0-9]{24,}/g, // Stripe live keys
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Build a safe environment for the agent subprocess.
|
|
23
|
+
*
|
|
24
|
+
* Strategy: ALLOWLIST (default deny).
|
|
25
|
+
* Only variables in envDefaults + envPassthrough are included.
|
|
26
|
+
* If envPassthroughAll is true, passes everything (dangerous).
|
|
27
|
+
*/
|
|
28
|
+
export function buildSafeEnv(
|
|
29
|
+
processEnv: Record<string, string | undefined>,
|
|
30
|
+
security: SecurityConfig,
|
|
31
|
+
extraEnv?: Record<string, string>,
|
|
32
|
+
): Record<string, string> {
|
|
33
|
+
// Dangerous mode: pass everything
|
|
34
|
+
if (security.envPassthroughAll) {
|
|
35
|
+
log.warn('envPassthroughAll is enabled — ALL env vars will be passed to agent subprocess')
|
|
36
|
+
const env: Record<string, string> = {}
|
|
37
|
+
for (const [key, value] of Object.entries(processEnv)) {
|
|
38
|
+
if (value !== undefined) env[key] = value
|
|
39
|
+
}
|
|
40
|
+
if (extraEnv) Object.assign(env, extraEnv)
|
|
41
|
+
return env
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Build allowlist from defaults + user passthrough
|
|
45
|
+
const allowed = new Set([...security.envDefaults, ...security.envPassthrough])
|
|
46
|
+
|
|
47
|
+
const env: Record<string, string> = {}
|
|
48
|
+
for (const key of allowed) {
|
|
49
|
+
const value = processEnv[key]
|
|
50
|
+
if (value !== undefined) {
|
|
51
|
+
env[key] = value
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Apply programmatic overrides (these always take priority)
|
|
56
|
+
if (extraEnv) {
|
|
57
|
+
Object.assign(env, extraEnv)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
log.debug(
|
|
61
|
+
{
|
|
62
|
+
allowed: allowed.size,
|
|
63
|
+
resolved: Object.keys(env).length,
|
|
64
|
+
keys: Object.keys(env).sort(),
|
|
65
|
+
},
|
|
66
|
+
'Agent env built (allowlist)',
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
return env
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Create a reusable redaction function from security config.
|
|
74
|
+
* Combines default patterns with user-configured patterns.
|
|
75
|
+
*/
|
|
76
|
+
export function createRedactor(security: SecurityConfig): (text: string) => string {
|
|
77
|
+
// Compile user patterns
|
|
78
|
+
const userPatterns: RegExp[] = []
|
|
79
|
+
for (const pattern of security.outputRedactPatterns) {
|
|
80
|
+
try {
|
|
81
|
+
userPatterns.push(new RegExp(pattern, 'g'))
|
|
82
|
+
} catch (error) {
|
|
83
|
+
log.warn({ pattern, err: error }, 'Invalid redaction regex pattern, skipping')
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const allPatterns = [...DEFAULT_REDACT_PATTERNS, ...userPatterns]
|
|
88
|
+
|
|
89
|
+
log.debug(
|
|
90
|
+
{ defaultPatterns: DEFAULT_REDACT_PATTERNS.length, userPatterns: userPatterns.length },
|
|
91
|
+
'Output redactor initialized',
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
return (text: string): string => {
|
|
95
|
+
let redacted = text
|
|
96
|
+
for (const pattern of allPatterns) {
|
|
97
|
+
// Reset lastIndex for global regexes
|
|
98
|
+
pattern.lastIndex = 0
|
|
99
|
+
redacted = redacted.replace(pattern, '[REDACTED]')
|
|
100
|
+
}
|
|
101
|
+
return redacted
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ════════════════════════════════════════════════════════════
|
|
106
|
+
// Input Sanitization — Defense against prompt injection
|
|
107
|
+
// ════════════════════════════════════════════════════════════
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Tier 1 — Markers to STRIP silently from input.
|
|
111
|
+
* These are system-prompt-like markers that should never appear in user messages.
|
|
112
|
+
*/
|
|
113
|
+
const STRIP_PATTERNS: RegExp[] = [
|
|
114
|
+
// XML-style system markers
|
|
115
|
+
/<\/?system>/gi,
|
|
116
|
+
/<\/?instructions>/gi,
|
|
117
|
+
/<\/?prompt>/gi,
|
|
118
|
+
/<\/?assistant>/gi,
|
|
119
|
+
/<\/?human>/gi,
|
|
120
|
+
|
|
121
|
+
// Bracket-style markers
|
|
122
|
+
/\[SYSTEM\]/gi,
|
|
123
|
+
/\[INST\]/gi,
|
|
124
|
+
/\[\/INST\]/gi,
|
|
125
|
+
/\[INSTRUCTIONS?\]/gi,
|
|
126
|
+
|
|
127
|
+
// Separator-style injection attempts
|
|
128
|
+
/^-{3,}\s*system\s*-{3,}$/gim,
|
|
129
|
+
/^={3,}\s*system\s*={3,}$/gim,
|
|
130
|
+
|
|
131
|
+
// "Ignore previous" family — the most common prompt injection
|
|
132
|
+
/ignore (?:all )?(?:previous|above|prior) (?:instructions?|prompts?|rules?)/gi,
|
|
133
|
+
/disregard (?:all )?(?:previous|above|prior) (?:instructions?|prompts?|rules?)/gi,
|
|
134
|
+
/forget (?:all )?(?:previous|above) (?:instructions?|context)/gi,
|
|
135
|
+
]
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Tier 2 — Patterns to DETECT and LOG (not stripped).
|
|
139
|
+
* These could be malicious or could be legitimate use.
|
|
140
|
+
* We log them for review but don't alter the message.
|
|
141
|
+
*/
|
|
142
|
+
const SUSPICIOUS_PATTERNS: Array<{ pattern: RegExp; label: string }> = [
|
|
143
|
+
// Role override attempts
|
|
144
|
+
{ pattern: /you are now (?:a |an |the )/gi, label: 'role-override' },
|
|
145
|
+
{ pattern: /from now on,? (?:you |your )/gi, label: 'behavior-override' },
|
|
146
|
+
{ pattern: /new instructions?:/gi, label: 'instruction-inject' },
|
|
147
|
+
|
|
148
|
+
// System prompt extraction
|
|
149
|
+
{
|
|
150
|
+
pattern:
|
|
151
|
+
/(?:reveal|show|tell me|print|output|display|repeat) (?:your |the )?(?:system ?prompt|instructions|rules|secrets|initial prompt)/gi,
|
|
152
|
+
label: 'extraction-attempt',
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
pattern: /what (?:are|is|were) your (?:system ?prompt|instructions|rules|original prompt)/gi,
|
|
156
|
+
label: 'extraction-attempt',
|
|
157
|
+
},
|
|
158
|
+
|
|
159
|
+
// Encoding evasion
|
|
160
|
+
{
|
|
161
|
+
pattern:
|
|
162
|
+
/(?:encode|decode|convert) (?:this |it )?(?:in |to |as )?(?:base64|rot13|hex|binary)/gi,
|
|
163
|
+
label: 'encoding-evasion',
|
|
164
|
+
},
|
|
165
|
+
|
|
166
|
+
// Zero-width characters (can hide instructions)
|
|
167
|
+
{ pattern: /\u200b|\u200c|\u200d|\ufeff/g, label: 'zero-width-chars' },
|
|
168
|
+
]
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Result of input sanitization.
|
|
172
|
+
*/
|
|
173
|
+
export interface SanitizationResult {
|
|
174
|
+
/** Cleaned text */
|
|
175
|
+
text: string
|
|
176
|
+
/** Number of strip operations performed */
|
|
177
|
+
stripped: number
|
|
178
|
+
/** Labels of suspicious patterns detected */
|
|
179
|
+
suspicious: string[]
|
|
180
|
+
/** Whether any sanitization was applied */
|
|
181
|
+
wasSanitized: boolean
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Sanitize user input against prompt injection attempts.
|
|
186
|
+
*
|
|
187
|
+
* Two tiers:
|
|
188
|
+
* 1. STRIP — remove known system-prompt markers silently
|
|
189
|
+
* 2. DETECT — log suspicious patterns without modifying text
|
|
190
|
+
*
|
|
191
|
+
* This is defense-in-depth, not a complete solution.
|
|
192
|
+
* Real protection comes from env isolation (L1 security).
|
|
193
|
+
*
|
|
194
|
+
* @param text - Raw user input
|
|
195
|
+
* @param config - Sanitization config from security section
|
|
196
|
+
* @returns SanitizationResult with cleaned text and detection info
|
|
197
|
+
*/
|
|
198
|
+
export function sanitizeInput(text: string, config: InputSanitizationConfig): SanitizationResult {
|
|
199
|
+
if (!config.enabled) {
|
|
200
|
+
return { text, stripped: 0, suspicious: [], wasSanitized: false }
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
let cleaned = text
|
|
204
|
+
let stripped = 0
|
|
205
|
+
|
|
206
|
+
// Tier 1: Strip markers
|
|
207
|
+
if (config.stripMarkers) {
|
|
208
|
+
for (const pattern of STRIP_PATTERNS) {
|
|
209
|
+
pattern.lastIndex = 0
|
|
210
|
+
const before = cleaned
|
|
211
|
+
cleaned = cleaned.replace(pattern, '')
|
|
212
|
+
if (cleaned !== before) stripped++
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// User-defined custom patterns
|
|
216
|
+
for (const patternStr of config.customPatterns) {
|
|
217
|
+
try {
|
|
218
|
+
const regex = new RegExp(patternStr, 'gi')
|
|
219
|
+
const before = cleaned
|
|
220
|
+
cleaned = cleaned.replace(regex, '')
|
|
221
|
+
if (cleaned !== before) stripped++
|
|
222
|
+
} catch {
|
|
223
|
+
// Invalid regex — skip silently (already warned at config load)
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Clean up leftover whitespace from stripping
|
|
228
|
+
if (stripped > 0) {
|
|
229
|
+
cleaned = cleaned.replace(/\n{3,}/g, '\n\n').trim()
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Tier 2: Detect suspicious patterns (on original text, not cleaned)
|
|
234
|
+
const suspicious: string[] = []
|
|
235
|
+
if (config.logSuspicious) {
|
|
236
|
+
for (const { pattern, label } of SUSPICIOUS_PATTERNS) {
|
|
237
|
+
pattern.lastIndex = 0
|
|
238
|
+
if (pattern.test(text)) {
|
|
239
|
+
if (!suspicious.includes(label)) {
|
|
240
|
+
suspicious.push(label)
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const wasSanitized = stripped > 0 || suspicious.length > 0
|
|
247
|
+
|
|
248
|
+
return { text: cleaned, stripped, suspicious, wasSanitized }
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Create a reusable sanitizer function from config.
|
|
253
|
+
* Analogous to createRedactor() for output — this is for input.
|
|
254
|
+
*/
|
|
255
|
+
export function createSanitizer(
|
|
256
|
+
config: InputSanitizationConfig,
|
|
257
|
+
): (text: string) => SanitizationResult {
|
|
258
|
+
return (text: string) => sanitizeInput(text, config)
|
|
259
|
+
}
|