@openpalm/lib 0.9.4
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/README.md +83 -0
- package/package.json +30 -0
- package/src/control-plane/audit.ts +40 -0
- package/src/control-plane/channels.ts +196 -0
- package/src/control-plane/connection-mapping.ts +191 -0
- package/src/control-plane/connection-migration-flags.ts +40 -0
- package/src/control-plane/connection-profiles.ts +317 -0
- package/src/control-plane/core-asset-provider.ts +20 -0
- package/src/control-plane/core-assets.ts +292 -0
- package/src/control-plane/docker.ts +448 -0
- package/src/control-plane/env.ts +70 -0
- package/src/control-plane/fs-asset-provider.ts +61 -0
- package/src/control-plane/fs-registry-provider.ts +46 -0
- package/src/control-plane/lifecycle.ts +373 -0
- package/src/control-plane/memory-config.ts +424 -0
- package/src/control-plane/model-runner.ts +101 -0
- package/src/control-plane/paths.ts +77 -0
- package/src/control-plane/registry-provider.ts +19 -0
- package/src/control-plane/scheduler.ts +498 -0
- package/src/control-plane/secrets.ts +177 -0
- package/src/control-plane/setup-status.ts +31 -0
- package/src/control-plane/setup.test.ts +476 -0
- package/src/control-plane/setup.ts +474 -0
- package/src/control-plane/staging.ts +376 -0
- package/src/control-plane/types.ts +165 -0
- package/src/index.ts +295 -0
- package/src/logger.ts +14 -0
- package/src/provider-constants.ts +106 -0
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-process automation scheduler — replaces system cron.
|
|
3
|
+
*
|
|
4
|
+
* Uses Croner for cron job scheduling within the Node.js process.
|
|
5
|
+
* Automations are .yml files in STATE_HOME/automations/ with four
|
|
6
|
+
* action types: api (admin API call), http (any URL), shell (execFile),
|
|
7
|
+
* assistant (OpenCode session message).
|
|
8
|
+
*
|
|
9
|
+
* Security: shell actions use execFile with argument arrays — no shell
|
|
10
|
+
* interpolation. API actions auto-inject the admin token. Assistant
|
|
11
|
+
* actions validate the session ID before URL interpolation.
|
|
12
|
+
*/
|
|
13
|
+
import { Cron } from "croner";
|
|
14
|
+
import { parse as parseYaml } from "yaml";
|
|
15
|
+
import { execFile } from "node:child_process";
|
|
16
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import { createLogger } from "../logger.js";
|
|
19
|
+
|
|
20
|
+
const logger = createLogger("scheduler");
|
|
21
|
+
|
|
22
|
+
// ── Types ─────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
export type ActionType = "api" | "http" | "shell" | "assistant";
|
|
25
|
+
|
|
26
|
+
export type AutomationAction = {
|
|
27
|
+
type: ActionType;
|
|
28
|
+
method?: string;
|
|
29
|
+
path?: string;
|
|
30
|
+
url?: string;
|
|
31
|
+
body?: unknown;
|
|
32
|
+
headers?: Record<string, string>;
|
|
33
|
+
command?: string[];
|
|
34
|
+
timeout?: number;
|
|
35
|
+
/** The prompt text to send to the assistant (assistant action only). */
|
|
36
|
+
content?: string;
|
|
37
|
+
/** OpenCode agent label for the session (assistant action only, optional).
|
|
38
|
+
* Currently used in the session title for identification/audit purposes.
|
|
39
|
+
* Will be forwarded as an API parameter when OpenCode adds agent selection support. */
|
|
40
|
+
agent?: string;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export type AutomationConfig = {
|
|
44
|
+
name: string;
|
|
45
|
+
description: string;
|
|
46
|
+
schedule: string;
|
|
47
|
+
timezone: string;
|
|
48
|
+
enabled: boolean;
|
|
49
|
+
action: AutomationAction;
|
|
50
|
+
on_failure: "log" | "audit";
|
|
51
|
+
fileName: string;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
type ActiveJob = {
|
|
55
|
+
cron: Cron;
|
|
56
|
+
config: AutomationConfig;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// ── Execution Log ─────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
export type ExecutionLogEntry = {
|
|
62
|
+
at: string;
|
|
63
|
+
ok: boolean;
|
|
64
|
+
durationMs: number;
|
|
65
|
+
error?: string;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const MAX_LOG_ENTRIES = 50;
|
|
69
|
+
const executionLogs = new Map<string, ExecutionLogEntry[]>();
|
|
70
|
+
|
|
71
|
+
function recordExecution(fileName: string, entry: ExecutionLogEntry): void {
|
|
72
|
+
let entries = executionLogs.get(fileName);
|
|
73
|
+
if (!entries) {
|
|
74
|
+
entries = [];
|
|
75
|
+
executionLogs.set(fileName, entries);
|
|
76
|
+
}
|
|
77
|
+
entries.push(entry);
|
|
78
|
+
if (entries.length > MAX_LOG_ENTRIES) {
|
|
79
|
+
executionLogs.set(fileName, entries.slice(-MAX_LOG_ENTRIES));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Return recent execution log entries for an automation (newest first). */
|
|
84
|
+
export function getExecutionLog(fileName: string): ExecutionLogEntry[] {
|
|
85
|
+
return [...(executionLogs.get(fileName) ?? [])].reverse();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Return all execution logs keyed by fileName. */
|
|
89
|
+
export function getAllExecutionLogs(): Record<string, ExecutionLogEntry[]> {
|
|
90
|
+
const result: Record<string, ExecutionLogEntry[]> = {};
|
|
91
|
+
for (const [fileName, entries] of executionLogs) {
|
|
92
|
+
result[fileName] = [...entries].reverse();
|
|
93
|
+
}
|
|
94
|
+
return result;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ── Schedule Presets ──────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
export const SCHEDULE_PRESETS: Record<string, string> = {
|
|
100
|
+
"every-minute": "* * * * *",
|
|
101
|
+
"every-5-minutes": "*/5 * * * *",
|
|
102
|
+
"every-15-minutes": "*/15 * * * *",
|
|
103
|
+
"every-hour": "0 * * * *",
|
|
104
|
+
"daily": "0 0 * * *",
|
|
105
|
+
"daily-8am": "0 8 * * *",
|
|
106
|
+
"weekly": "0 0 * * 0",
|
|
107
|
+
"weekly-sunday-3am": "0 3 * * 0",
|
|
108
|
+
"weekly-sunday-4am": "0 4 * * 0"
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Resolve a schedule string: if it matches a preset name, return the
|
|
113
|
+
* cron expression; otherwise pass through as-is (assumed cron syntax).
|
|
114
|
+
*/
|
|
115
|
+
export function resolveSchedule(schedule: string): string {
|
|
116
|
+
return SCHEDULE_PRESETS[schedule] ?? schedule;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ── YAML Parsing ──────────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Parse and validate a YAML automation file.
|
|
123
|
+
* Returns null if the content is invalid (with a warning logged).
|
|
124
|
+
*/
|
|
125
|
+
export function parseAutomationYaml(
|
|
126
|
+
content: string,
|
|
127
|
+
fileName: string
|
|
128
|
+
): AutomationConfig | null {
|
|
129
|
+
let doc: Record<string, unknown>;
|
|
130
|
+
try {
|
|
131
|
+
doc = parseYaml(content) as Record<string, unknown>;
|
|
132
|
+
} catch (err) {
|
|
133
|
+
logger.warn("failed to parse automation YAML", { fileName, error: String(err) });
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (!doc || typeof doc !== "object") {
|
|
138
|
+
logger.warn("automation YAML is not an object", { fileName });
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// schedule is required
|
|
143
|
+
const rawSchedule = doc.schedule;
|
|
144
|
+
if (typeof rawSchedule !== "string" || !rawSchedule.trim()) {
|
|
145
|
+
logger.warn("automation missing or empty 'schedule'", { fileName });
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// action is required and must be an object with a valid type
|
|
150
|
+
const action = doc.action;
|
|
151
|
+
if (!action || typeof action !== "object") {
|
|
152
|
+
logger.warn("automation missing or invalid 'action'", { fileName });
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const actionObj = action as Record<string, unknown>;
|
|
157
|
+
const actionType = actionObj.type as string | undefined;
|
|
158
|
+
if (!actionType || !["api", "http", "shell", "assistant"].includes(actionType)) {
|
|
159
|
+
logger.warn("automation action has invalid 'type'", {
|
|
160
|
+
fileName,
|
|
161
|
+
type: String(actionType)
|
|
162
|
+
});
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Validate action-specific required fields
|
|
167
|
+
if (actionType === "api" && typeof actionObj.path !== "string") {
|
|
168
|
+
logger.warn("api action missing 'path'", { fileName });
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
if (actionType === "http" && typeof actionObj.url !== "string") {
|
|
172
|
+
logger.warn("http action missing 'url'", { fileName });
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
if (actionType === "shell") {
|
|
176
|
+
if (!Array.isArray(actionObj.command) || actionObj.command.length === 0) {
|
|
177
|
+
logger.warn("shell action missing or empty 'command' array", { fileName });
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
if (actionType === "assistant") {
|
|
182
|
+
if (typeof actionObj.content !== "string" || !actionObj.content.trim()) {
|
|
183
|
+
logger.warn("assistant action missing or empty 'content'", { fileName });
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const schedule = resolveSchedule(rawSchedule.trim());
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
name: typeof doc.name === "string" ? doc.name : fileName.replace(/\.yml$/, ""),
|
|
192
|
+
description: typeof doc.description === "string" ? doc.description : "",
|
|
193
|
+
schedule,
|
|
194
|
+
timezone: typeof doc.timezone === "string" ? doc.timezone : "UTC",
|
|
195
|
+
enabled: doc.enabled !== false,
|
|
196
|
+
action: {
|
|
197
|
+
type: actionType as ActionType,
|
|
198
|
+
method: typeof actionObj.method === "string" ? actionObj.method : "GET",
|
|
199
|
+
path: typeof actionObj.path === "string" ? actionObj.path : undefined,
|
|
200
|
+
url: typeof actionObj.url === "string" ? actionObj.url : undefined,
|
|
201
|
+
body: actionObj.body,
|
|
202
|
+
headers: isStringRecord(actionObj.headers) ? actionObj.headers : undefined,
|
|
203
|
+
command: Array.isArray(actionObj.command)
|
|
204
|
+
? actionObj.command.map(String)
|
|
205
|
+
: undefined,
|
|
206
|
+
content: typeof actionObj.content === "string" ? actionObj.content : undefined,
|
|
207
|
+
agent: typeof actionObj.agent === "string" ? actionObj.agent : undefined,
|
|
208
|
+
timeout:
|
|
209
|
+
typeof actionObj.timeout === "number"
|
|
210
|
+
? actionObj.timeout
|
|
211
|
+
: actionType === "assistant" ? 120_000 : 30_000
|
|
212
|
+
},
|
|
213
|
+
on_failure:
|
|
214
|
+
doc.on_failure === "audit" ? "audit" : "log",
|
|
215
|
+
fileName
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function isStringRecord(v: unknown): v is Record<string, string> {
|
|
220
|
+
if (!v || typeof v !== "object") return false;
|
|
221
|
+
return Object.values(v as Record<string, unknown>).every(
|
|
222
|
+
(val) => typeof val === "string"
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ── Load Automations ──────────────────────────────────────────────────
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Read and parse all .yml automation files from STATE_HOME/automations/.
|
|
230
|
+
*/
|
|
231
|
+
export function loadAutomations(stateDir: string): AutomationConfig[] {
|
|
232
|
+
const dir = join(stateDir, "automations");
|
|
233
|
+
if (!existsSync(dir)) return [];
|
|
234
|
+
|
|
235
|
+
const files = readdirSync(dir, { withFileTypes: true });
|
|
236
|
+
const configs: AutomationConfig[] = [];
|
|
237
|
+
|
|
238
|
+
for (const entry of files) {
|
|
239
|
+
if (!entry.isFile()) continue;
|
|
240
|
+
if (!entry.name.endsWith(".yml")) {
|
|
241
|
+
logger.warn("non-.yml file in automations dir (ignored)", {
|
|
242
|
+
file: entry.name,
|
|
243
|
+
hint: "automation files must use .yml extension"
|
|
244
|
+
});
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const content = readFileSync(join(dir, entry.name), "utf-8");
|
|
249
|
+
const config = parseAutomationYaml(content, entry.name);
|
|
250
|
+
if (config) configs.push(config);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return configs;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ── Action Execution ──────────────────────────────────────────────────
|
|
257
|
+
|
|
258
|
+
export const SAFE_PATH_RE = /^\/admin\/[a-zA-Z0-9/._-]+$/;
|
|
259
|
+
|
|
260
|
+
/** Execute an API action — auto-injects admin token and base URL. */
|
|
261
|
+
async function executeApiAction(
|
|
262
|
+
action: AutomationAction,
|
|
263
|
+
adminToken: string
|
|
264
|
+
): Promise<void> {
|
|
265
|
+
if (!action.path || !SAFE_PATH_RE.test(action.path) || action.path.includes('..')) {
|
|
266
|
+
logger.warn(`Scheduler: rejecting unsafe action path: ${action.path}`);
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
const adminUrl = process.env.OPENPALM_ADMIN_API_URL || "http://admin:8100";
|
|
270
|
+
const url = `${adminUrl}${action.path}`;
|
|
271
|
+
const { "x-admin-token": _dropped, "authorization": _dropped2, ...safeHeaders } = action.headers ?? {};
|
|
272
|
+
const headers: Record<string, string> = {
|
|
273
|
+
...safeHeaders,
|
|
274
|
+
"x-admin-token": adminToken,
|
|
275
|
+
"x-requested-by": "automation",
|
|
276
|
+
};
|
|
277
|
+
if (action.body) {
|
|
278
|
+
headers["content-type"] = "application/json";
|
|
279
|
+
}
|
|
280
|
+
const controller = new AbortController();
|
|
281
|
+
const timer = setTimeout(() => controller.abort(), action.timeout ?? 30_000);
|
|
282
|
+
try {
|
|
283
|
+
const resp = await fetch(url, {
|
|
284
|
+
method: action.method ?? "GET",
|
|
285
|
+
headers,
|
|
286
|
+
body: action.body ? JSON.stringify(action.body) : undefined,
|
|
287
|
+
signal: controller.signal
|
|
288
|
+
});
|
|
289
|
+
if (!resp.ok) {
|
|
290
|
+
throw new Error(`HTTP ${resp.status} ${resp.statusText}`);
|
|
291
|
+
}
|
|
292
|
+
} finally {
|
|
293
|
+
clearTimeout(timer);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Execute an HTTP action — no auto-auth. */
|
|
298
|
+
async function executeHttpAction(action: AutomationAction): Promise<void> {
|
|
299
|
+
const headers: Record<string, string> = { ...action.headers };
|
|
300
|
+
if (action.body) {
|
|
301
|
+
headers["content-type"] = headers["content-type"] ?? "application/json";
|
|
302
|
+
}
|
|
303
|
+
const controller = new AbortController();
|
|
304
|
+
const timer = setTimeout(() => controller.abort(), action.timeout ?? 30_000);
|
|
305
|
+
try {
|
|
306
|
+
const resp = await fetch(action.url!, {
|
|
307
|
+
method: action.method ?? "GET",
|
|
308
|
+
headers,
|
|
309
|
+
body: action.body ? JSON.stringify(action.body) : undefined,
|
|
310
|
+
signal: controller.signal
|
|
311
|
+
});
|
|
312
|
+
if (!resp.ok) {
|
|
313
|
+
throw new Error(`HTTP ${resp.status} ${resp.statusText}`);
|
|
314
|
+
}
|
|
315
|
+
} finally {
|
|
316
|
+
clearTimeout(timer);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Safe env vars allowlisted for shell automation actions. */
|
|
321
|
+
const SHELL_SAFE_ENV_KEYS = [
|
|
322
|
+
"PATH", "HOME", "LANG", "LC_ALL", "TZ", "NODE_ENV",
|
|
323
|
+
"OPENPALM_CONFIG_HOME", "OPENPALM_STATE_HOME", "OPENPALM_DATA_HOME",
|
|
324
|
+
];
|
|
325
|
+
|
|
326
|
+
/** Execute a shell action — uses execFile with argument array (no shell interpolation). */
|
|
327
|
+
function executeShellAction(action: AutomationAction): Promise<void> {
|
|
328
|
+
const cmd = action.command!;
|
|
329
|
+
|
|
330
|
+
// Build a minimal env from the allowlist — never leak secrets to shell commands
|
|
331
|
+
const safeEnv: Record<string, string> = {};
|
|
332
|
+
for (const key of SHELL_SAFE_ENV_KEYS) {
|
|
333
|
+
if (process.env[key]) safeEnv[key] = process.env[key]!;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return new Promise((resolve, reject) => {
|
|
337
|
+
execFile(
|
|
338
|
+
cmd[0],
|
|
339
|
+
cmd.slice(1),
|
|
340
|
+
{ env: safeEnv, timeout: action.timeout ?? 30_000 },
|
|
341
|
+
(error, _stdout, stderr) => {
|
|
342
|
+
if (error) {
|
|
343
|
+
reject(new Error(`shell command failed: ${stderr || error.message}`));
|
|
344
|
+
} else {
|
|
345
|
+
resolve();
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
);
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Execute an assistant action — creates an OpenCode session and sends the
|
|
354
|
+
* prompt directly via the OpenCode REST API (no guardian needed).
|
|
355
|
+
*/
|
|
356
|
+
async function executeAssistantAction(action: AutomationAction): Promise<void> {
|
|
357
|
+
if (!action.content) {
|
|
358
|
+
throw new Error("assistant action requires a non-empty 'content' field");
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const baseUrl = process.env.OPENCODE_API_URL ?? "http://localhost:4096";
|
|
362
|
+
const password = process.env.OPENCODE_SERVER_PASSWORD;
|
|
363
|
+
const headers: Record<string, string> = { "content-type": "application/json" };
|
|
364
|
+
if (password) {
|
|
365
|
+
headers["authorization"] = `Basic ${Buffer.from(`opencode:${password}`, "utf8").toString("base64")}`;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Create session
|
|
369
|
+
const sessionRes = await fetch(`${baseUrl}/session`, {
|
|
370
|
+
method: "POST",
|
|
371
|
+
headers,
|
|
372
|
+
signal: AbortSignal.timeout(10_000),
|
|
373
|
+
body: JSON.stringify({ title: `automation/${action.agent ?? "default"}` }),
|
|
374
|
+
});
|
|
375
|
+
if (!sessionRes.ok) {
|
|
376
|
+
const body = await sessionRes.text().catch(() => "");
|
|
377
|
+
throw new Error(`OpenCode POST /session ${sessionRes.status}: ${body}`);
|
|
378
|
+
}
|
|
379
|
+
const { id: sessionId } = (await sessionRes.json()) as { id: string };
|
|
380
|
+
if (typeof sessionId !== "string" || !/^[a-zA-Z0-9_-]+$/.test(sessionId)) {
|
|
381
|
+
throw new Error("Invalid session ID from assistant");
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Send message
|
|
385
|
+
const msgRes = await fetch(`${baseUrl}/session/${sessionId}/message`, {
|
|
386
|
+
method: "POST",
|
|
387
|
+
headers,
|
|
388
|
+
signal: AbortSignal.timeout(action.timeout ?? 120_000),
|
|
389
|
+
body: JSON.stringify({ parts: [{ type: "text", text: action.content }] }),
|
|
390
|
+
});
|
|
391
|
+
if (!msgRes.ok) {
|
|
392
|
+
const body = await msgRes.text().catch(() => "");
|
|
393
|
+
throw new Error(`OpenCode POST /session/${sessionId}/message ${msgRes.status}: ${body}`);
|
|
394
|
+
}
|
|
395
|
+
logger.info("assistant action completed");
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** Dispatch to the correct action executor. */
|
|
399
|
+
export async function executeAction(
|
|
400
|
+
action: AutomationAction,
|
|
401
|
+
adminToken: string
|
|
402
|
+
): Promise<void> {
|
|
403
|
+
switch (action.type) {
|
|
404
|
+
case "api":
|
|
405
|
+
return executeApiAction(action, adminToken);
|
|
406
|
+
case "http":
|
|
407
|
+
return executeHttpAction(action);
|
|
408
|
+
case "shell":
|
|
409
|
+
return executeShellAction(action);
|
|
410
|
+
case "assistant":
|
|
411
|
+
return executeAssistantAction(action);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// ── Scheduler Lifecycle ───────────────────────────────────────────────
|
|
416
|
+
|
|
417
|
+
let activeJobs: ActiveJob[] = [];
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Start the in-process scheduler. Reads automations from STATE_HOME,
|
|
421
|
+
* creates Croner jobs for each enabled one.
|
|
422
|
+
*/
|
|
423
|
+
export function startScheduler(stateDir: string, adminToken: string): void {
|
|
424
|
+
const configs = loadAutomations(stateDir);
|
|
425
|
+
const enabled = configs.filter((c) => c.enabled);
|
|
426
|
+
|
|
427
|
+
for (const config of enabled) {
|
|
428
|
+
try {
|
|
429
|
+
const cron = new Cron(config.schedule, {
|
|
430
|
+
timezone: config.timezone,
|
|
431
|
+
protect: true // over-run protection
|
|
432
|
+
}, async () => {
|
|
433
|
+
const start = Date.now();
|
|
434
|
+
try {
|
|
435
|
+
await executeAction(config.action, adminToken);
|
|
436
|
+
const durationMs = Date.now() - start;
|
|
437
|
+
recordExecution(config.fileName, { at: new Date().toISOString(), ok: true, durationMs });
|
|
438
|
+
logger.info("automation executed", { name: config.name, fileName: config.fileName, durationMs });
|
|
439
|
+
} catch (err) {
|
|
440
|
+
const durationMs = Date.now() - start;
|
|
441
|
+
const errorMsg = String(err);
|
|
442
|
+
recordExecution(config.fileName, { at: new Date().toISOString(), ok: false, durationMs, error: errorMsg });
|
|
443
|
+
logger.error("automation failed", {
|
|
444
|
+
name: config.name,
|
|
445
|
+
fileName: config.fileName,
|
|
446
|
+
error: errorMsg
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
activeJobs.push({ cron, config });
|
|
452
|
+
} catch (err) {
|
|
453
|
+
logger.error("failed to schedule automation", {
|
|
454
|
+
name: config.name,
|
|
455
|
+
fileName: config.fileName,
|
|
456
|
+
schedule: config.schedule,
|
|
457
|
+
error: String(err)
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
logger.info(`scheduler started with ${activeJobs.length} automation(s)`);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** Stop all active Croner jobs. */
|
|
466
|
+
export function stopScheduler(): void {
|
|
467
|
+
for (const job of activeJobs) {
|
|
468
|
+
job.cron.stop();
|
|
469
|
+
}
|
|
470
|
+
const count = activeJobs.length;
|
|
471
|
+
activeJobs = [];
|
|
472
|
+
executionLogs.clear();
|
|
473
|
+
if (count > 0) {
|
|
474
|
+
logger.info(`scheduler stopped (${count} job(s) cleared)`);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** Reload: stop all jobs, then start fresh. */
|
|
479
|
+
export function reloadScheduler(stateDir: string, adminToken: string): void {
|
|
480
|
+
stopScheduler();
|
|
481
|
+
startScheduler(stateDir, adminToken);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** Return current scheduler status for debugging. */
|
|
485
|
+
export function getSchedulerStatus(): {
|
|
486
|
+
jobCount: number;
|
|
487
|
+
jobs: { name: string; fileName: string; schedule: string; running: boolean }[];
|
|
488
|
+
} {
|
|
489
|
+
return {
|
|
490
|
+
jobCount: activeJobs.length,
|
|
491
|
+
jobs: activeJobs.map((j) => ({
|
|
492
|
+
name: j.config.name,
|
|
493
|
+
fileName: j.config.fileName,
|
|
494
|
+
schedule: j.config.schedule,
|
|
495
|
+
running: j.cron.isRunning()
|
|
496
|
+
}))
|
|
497
|
+
};
|
|
498
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secrets and connection key management for the OpenPalm control plane.
|
|
3
|
+
*/
|
|
4
|
+
import { mkdirSync, writeFileSync, readFileSync, existsSync } from "node:fs";
|
|
5
|
+
import { randomBytes } from "node:crypto";
|
|
6
|
+
import { parseEnvFile, mergeEnvContent } from './env.js';
|
|
7
|
+
import type { ControlPlaneState } from "./types.js";
|
|
8
|
+
import { resolveConfigHome } from "./paths.js";
|
|
9
|
+
|
|
10
|
+
const OPENCODE_STARTER_CONFIG = JSON.stringify({ $schema: "https://opencode.ai/config.json" }, null, 2) + "\n";
|
|
11
|
+
|
|
12
|
+
// ── Connection Key Management ───────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
export const ALLOWED_CONNECTION_KEYS = new Set([
|
|
15
|
+
"OPENAI_API_KEY",
|
|
16
|
+
"ANTHROPIC_API_KEY",
|
|
17
|
+
"GROQ_API_KEY",
|
|
18
|
+
"MISTRAL_API_KEY",
|
|
19
|
+
"GOOGLE_API_KEY",
|
|
20
|
+
"SYSTEM_LLM_PROVIDER",
|
|
21
|
+
"SYSTEM_LLM_BASE_URL",
|
|
22
|
+
"SYSTEM_LLM_MODEL",
|
|
23
|
+
"OPENAI_BASE_URL",
|
|
24
|
+
"EMBEDDING_MODEL",
|
|
25
|
+
"EMBEDDING_DIMS",
|
|
26
|
+
"MEMORY_USER_ID",
|
|
27
|
+
"MEMORY_AUTH_TOKEN",
|
|
28
|
+
"OWNER_NAME",
|
|
29
|
+
"OWNER_EMAIL",
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
export const REQUIRED_LLM_PROVIDER_KEYS = [
|
|
33
|
+
"OPENAI_API_KEY",
|
|
34
|
+
"ANTHROPIC_API_KEY",
|
|
35
|
+
"GROQ_API_KEY",
|
|
36
|
+
"MISTRAL_API_KEY",
|
|
37
|
+
"GOOGLE_API_KEY"
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
/** Keys that are non-secret config — returned unmasked in connection responses. */
|
|
41
|
+
export const PLAIN_CONFIG_KEYS = new Set([
|
|
42
|
+
"SYSTEM_LLM_PROVIDER",
|
|
43
|
+
"SYSTEM_LLM_BASE_URL",
|
|
44
|
+
"SYSTEM_LLM_MODEL",
|
|
45
|
+
"OPENAI_BASE_URL",
|
|
46
|
+
"EMBEDDING_MODEL",
|
|
47
|
+
"EMBEDDING_DIMS",
|
|
48
|
+
"MEMORY_USER_ID",
|
|
49
|
+
"OWNER_NAME",
|
|
50
|
+
"OWNER_EMAIL",
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
// ── Secrets Management ──────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
export function ensureSecrets(state: ControlPlaneState): void {
|
|
56
|
+
mkdirSync(state.configDir, { recursive: true });
|
|
57
|
+
const secretsPath = `${state.configDir}/secrets.env`;
|
|
58
|
+
if (existsSync(secretsPath)) {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const secretLines: string[] = [];
|
|
63
|
+
secretLines.push("# OpenPalm Secrets");
|
|
64
|
+
secretLines.push("# Edit this file to update admin token and LLM keys.");
|
|
65
|
+
secretLines.push("# System-managed secrets (database + channel HMAC) do not belong here.");
|
|
66
|
+
secretLines.push("");
|
|
67
|
+
secretLines.push("export OPENPALM_ADMIN_TOKEN=");
|
|
68
|
+
secretLines.push("export ADMIN_TOKEN=");
|
|
69
|
+
secretLines.push("");
|
|
70
|
+
secretLines.push("# LLM provider keys");
|
|
71
|
+
secretLines.push("export OPENAI_API_KEY=");
|
|
72
|
+
secretLines.push("export OPENAI_BASE_URL=");
|
|
73
|
+
secretLines.push("export ANTHROPIC_API_KEY=");
|
|
74
|
+
secretLines.push("export GROQ_API_KEY=");
|
|
75
|
+
secretLines.push("export MISTRAL_API_KEY=");
|
|
76
|
+
secretLines.push("export GOOGLE_API_KEY=");
|
|
77
|
+
secretLines.push("");
|
|
78
|
+
secretLines.push("# Memory");
|
|
79
|
+
secretLines.push(`export MEMORY_USER_ID=${process.env.MEMORY_USER_ID ?? process.env.OPENMEMORY_USER_ID ?? "default_user"}`);
|
|
80
|
+
secretLines.push("");
|
|
81
|
+
secretLines.push("# Service auth tokens (auto-generated)");
|
|
82
|
+
secretLines.push(`export MEMORY_AUTH_TOKEN=${randomBytes(32).toString("hex")}`);
|
|
83
|
+
secretLines.push("");
|
|
84
|
+
secretLines.push("# Owner");
|
|
85
|
+
secretLines.push(`export OWNER_NAME=${process.env.OWNER_NAME ?? ""}`);
|
|
86
|
+
secretLines.push(`export OWNER_EMAIL=${process.env.OWNER_EMAIL ?? ""}`);
|
|
87
|
+
writeFileSync(secretsPath, secretLines.join("\n") + "\n");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function updateSecretsEnv(
|
|
91
|
+
state: ControlPlaneState,
|
|
92
|
+
updates: Record<string, string>
|
|
93
|
+
): void {
|
|
94
|
+
const secretsPath = `${state.configDir}/secrets.env`;
|
|
95
|
+
if (!existsSync(secretsPath)) {
|
|
96
|
+
throw new Error("secrets.env does not exist — run setup first");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const raw = readFileSync(secretsPath, "utf-8");
|
|
100
|
+
writeFileSync(secretsPath, mergeEnvContent(raw, updates, { uncomment: true }));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function readSecretsEnvFile(configDir: string): Record<string, string> {
|
|
104
|
+
const parsed = parseEnvFile(`${configDir}/secrets.env`);
|
|
105
|
+
const result: Record<string, string> = {};
|
|
106
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
107
|
+
if (ALLOWED_CONNECTION_KEYS.has(key)) result[key] = value;
|
|
108
|
+
}
|
|
109
|
+
return result;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function patchSecretsEnvFile(
|
|
113
|
+
configDir: string,
|
|
114
|
+
patches: Record<string, string>
|
|
115
|
+
): void {
|
|
116
|
+
const allowed: Record<string, string> = {};
|
|
117
|
+
for (const [key, value] of Object.entries(patches)) {
|
|
118
|
+
if (ALLOWED_CONNECTION_KEYS.has(key)) {
|
|
119
|
+
allowed[key] = value;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (Object.keys(allowed).length === 0) return;
|
|
123
|
+
|
|
124
|
+
const secretsPath = `${configDir}/secrets.env`;
|
|
125
|
+
mkdirSync(configDir, { recursive: true });
|
|
126
|
+
|
|
127
|
+
let existingContent = "";
|
|
128
|
+
try {
|
|
129
|
+
if (existsSync(secretsPath)) {
|
|
130
|
+
existingContent = readFileSync(secretsPath, "utf-8");
|
|
131
|
+
}
|
|
132
|
+
} catch {
|
|
133
|
+
// start fresh
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
let result = mergeEnvContent(existingContent, allowed);
|
|
137
|
+
if (!result.endsWith("\n")) result += "\n";
|
|
138
|
+
writeFileSync(secretsPath, result);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ── Connection Value Masking ────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
export function maskConnectionValue(key: string, value: string): string {
|
|
144
|
+
if (!value) return "";
|
|
145
|
+
if (PLAIN_CONFIG_KEYS.has(key)) return value;
|
|
146
|
+
if (value.length <= 4) return "****";
|
|
147
|
+
return "*".repeat(value.length - 4) + value.slice(-4);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ── Secrets Loading ────────────────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
export function loadSecretsEnvFile(configDir?: string): Record<string, string> {
|
|
153
|
+
const base = configDir ?? resolveConfigHome();
|
|
154
|
+
const parsed = parseEnvFile(`${base}/secrets.env`);
|
|
155
|
+
const result: Record<string, string> = {};
|
|
156
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
157
|
+
if (/^[A-Z0-9_]+$/.test(key)) result[key] = value;
|
|
158
|
+
}
|
|
159
|
+
return result;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ── OpenCode Config ────────────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
export function ensureOpenCodeConfig(): void {
|
|
165
|
+
const configHome = resolveConfigHome();
|
|
166
|
+
const opencodePath = `${configHome}/assistant`;
|
|
167
|
+
mkdirSync(opencodePath, { recursive: true });
|
|
168
|
+
|
|
169
|
+
const configFile = `${opencodePath}/opencode.json`;
|
|
170
|
+
if (!existsSync(configFile)) {
|
|
171
|
+
writeFileSync(configFile, OPENCODE_STARTER_CONFIG);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
for (const subdir of ["tools", "plugins", "skills"]) {
|
|
175
|
+
mkdirSync(`${opencodePath}/${subdir}`, { recursive: true });
|
|
176
|
+
}
|
|
177
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { userInfo } from "node:os";
|
|
2
|
+
import { parseEnvFile } from './env.js';
|
|
3
|
+
|
|
4
|
+
export function readSecretsKeys(configDir: string): Record<string, boolean> {
|
|
5
|
+
const parsed = parseEnvFile(`${configDir}/secrets.env`);
|
|
6
|
+
const result: Record<string, boolean> = {};
|
|
7
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
8
|
+
result[key] = value.length > 0;
|
|
9
|
+
}
|
|
10
|
+
return result;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function detectUserId(): string {
|
|
14
|
+
const envUser = process.env.USER ?? process.env.LOGNAME ?? "";
|
|
15
|
+
if (envUser) return envUser;
|
|
16
|
+
try {
|
|
17
|
+
return userInfo().username || "default_user";
|
|
18
|
+
} catch {
|
|
19
|
+
return "default_user";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function isSetupComplete(stateDir: string, configDir: string): boolean {
|
|
24
|
+
const parsed = parseEnvFile(`${stateDir}/artifacts/stack.env`);
|
|
25
|
+
if ("OPENPALM_SETUP_COMPLETE" in parsed) {
|
|
26
|
+
return parsed.OPENPALM_SETUP_COMPLETE.toLowerCase() === "true";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const keys = readSecretsKeys(configDir);
|
|
30
|
+
return keys.ADMIN_TOKEN === true;
|
|
31
|
+
}
|