@securecoms/agent-runtime 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/PROVENANCE.md +28 -0
- package/README.md +126 -0
- package/dist/bin.js +1277 -0
- package/package.json +40 -0
- package/skills/echo/manifest.json +15 -0
package/dist/bin.js
ADDED
|
@@ -0,0 +1,1277 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { readFileSync as readFileSync4 } from "node:fs";
|
|
5
|
+
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
6
|
+
import { dirname as dirname3, join as join5 } from "node:path";
|
|
7
|
+
import { spawnSync } from "node:child_process";
|
|
8
|
+
import { accessSync, constants, existsSync as existsSync3, mkdirSync as mkdirSync2, realpathSync, rmSync, writeFileSync as writeFileSync2 } from "node:fs";
|
|
9
|
+
import { homedir as homedir3, userInfo } from "node:os";
|
|
10
|
+
|
|
11
|
+
// src/config.ts
|
|
12
|
+
import { homedir } from "node:os";
|
|
13
|
+
import { dirname, join } from "node:path";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
var MISSING_IDENTITY_MESSAGE = "Missing agent identity: pass --identity <id> or set SECURECOMS_IDENTITY";
|
|
16
|
+
var LOG_LEVELS = /* @__PURE__ */ new Set(["debug", "info", "warn", "error"]);
|
|
17
|
+
var TOOL_DEFAULTS = /* @__PURE__ */ new Set(["allow", "deny"]);
|
|
18
|
+
function parseIdentity(argv, env) {
|
|
19
|
+
for (let i = 0; i < argv.length; i++) {
|
|
20
|
+
const arg = argv[i];
|
|
21
|
+
if (arg === "--identity") {
|
|
22
|
+
const value = argv[i + 1];
|
|
23
|
+
if (value) return value;
|
|
24
|
+
} else if (arg.startsWith("--identity=")) {
|
|
25
|
+
const value = arg.slice("--identity=".length);
|
|
26
|
+
if (value) return value;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
if (env.SECURECOMS_IDENTITY) return env.SECURECOMS_IDENTITY;
|
|
30
|
+
throw new Error(MISSING_IDENTITY_MESSAGE);
|
|
31
|
+
}
|
|
32
|
+
function parseStringFlag(argv, flag, env, envVar) {
|
|
33
|
+
const prefix = `${flag}=`;
|
|
34
|
+
for (let i = 0; i < argv.length; i++) {
|
|
35
|
+
const arg = argv[i];
|
|
36
|
+
if (arg === flag) {
|
|
37
|
+
const value = argv[i + 1];
|
|
38
|
+
if (value) return value;
|
|
39
|
+
} else if (arg.startsWith(prefix)) {
|
|
40
|
+
const value = arg.slice(prefix.length);
|
|
41
|
+
if (value) return value;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return env[envVar] || void 0;
|
|
45
|
+
}
|
|
46
|
+
function parseList(value) {
|
|
47
|
+
if (value === void 0) return void 0;
|
|
48
|
+
const items = value.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
|
|
49
|
+
return items.length > 0 ? items : void 0;
|
|
50
|
+
}
|
|
51
|
+
function defaultSkillsDir() {
|
|
52
|
+
return join(dirname(fileURLToPath(import.meta.url)), "..", "skills");
|
|
53
|
+
}
|
|
54
|
+
function loadConfig(argv, env) {
|
|
55
|
+
const identityId = parseIdentity(argv, env);
|
|
56
|
+
const logLevel = env.SECURECOMS_LOG_LEVEL ?? "info";
|
|
57
|
+
if (!LOG_LEVELS.has(logLevel)) {
|
|
58
|
+
throw new Error(
|
|
59
|
+
`Invalid SECURECOMS_LOG_LEVEL "${logLevel}": expected one of debug, info, warn, error`
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
const toolDefault = env.SECURECOMS_TOOL_DEFAULT ?? "deny";
|
|
63
|
+
if (!TOOL_DEFAULTS.has(toolDefault)) {
|
|
64
|
+
throw new Error(
|
|
65
|
+
`Invalid SECURECOMS_TOOL_DEFAULT "${toolDefault}": expected one of allow, deny`
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
const parsedAllow = parseList(env.SECURECOMS_TOOL_ALLOW);
|
|
69
|
+
const allow = parsedAllow ?? ["echo"];
|
|
70
|
+
const deny = parseList(env.SECURECOMS_TOOL_DENY);
|
|
71
|
+
const toolGate = {
|
|
72
|
+
default: toolDefault,
|
|
73
|
+
...allow !== void 0 ? { allow } : {},
|
|
74
|
+
...deny !== void 0 ? { deny } : {}
|
|
75
|
+
};
|
|
76
|
+
return {
|
|
77
|
+
identityId,
|
|
78
|
+
llmEndpoint: env.SECURECOMS_LLM_ENDPOINT || "http://127.0.0.1:11434",
|
|
79
|
+
llmModel: env.SECURECOMS_LLM_MODEL || "llama3",
|
|
80
|
+
llmApiKey: env.SECURECOMS_LLM_API_KEY,
|
|
81
|
+
llmApiKeyFile: parseStringFlag(argv, "--llm-key-file", env, "SECURECOMS_LLM_API_KEY_FILE"),
|
|
82
|
+
skillsDir: env.SECURECOMS_SKILLS_DIR || defaultSkillsDir(),
|
|
83
|
+
dataDir: env.SECURECOMS_DATA_DIR || join(homedir(), ".securecoms", "agent-runtime"),
|
|
84
|
+
logLevel,
|
|
85
|
+
toolGate
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// src/llm-key.ts
|
|
90
|
+
var LlmKeyFileError = class extends Error {
|
|
91
|
+
};
|
|
92
|
+
function resolveLlmApiKey(cfg, readFile) {
|
|
93
|
+
const path = cfg.llmApiKeyFile;
|
|
94
|
+
if (!path) return cfg.llmApiKey;
|
|
95
|
+
let raw;
|
|
96
|
+
try {
|
|
97
|
+
raw = readFile(path);
|
|
98
|
+
} catch (e) {
|
|
99
|
+
throw new LlmKeyFileError(
|
|
100
|
+
`cannot read LLM key file ${path}: ${e instanceof Error ? e.message : String(e)}`
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
const key = raw.trim();
|
|
104
|
+
if (!key) throw new LlmKeyFileError(`cannot read LLM key file ${path}: file is empty`);
|
|
105
|
+
return key;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// src/service/args.ts
|
|
109
|
+
function parseVerb(argv) {
|
|
110
|
+
const v = argv[2];
|
|
111
|
+
return v === "install" || v === "uninstall" ? v : null;
|
|
112
|
+
}
|
|
113
|
+
function flagOrEnv(argv, flag, env, envVar) {
|
|
114
|
+
const prefix = `${flag}=`;
|
|
115
|
+
for (let i = 2; i < argv.length; i++) {
|
|
116
|
+
const arg = argv[i];
|
|
117
|
+
if (arg === flag) {
|
|
118
|
+
const value = argv[i + 1];
|
|
119
|
+
if (value) return value;
|
|
120
|
+
} else if (arg.startsWith(prefix)) {
|
|
121
|
+
const value = arg.slice(prefix.length);
|
|
122
|
+
if (value) return value;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return envVar ? env[envVar] || void 0 : void 0;
|
|
126
|
+
}
|
|
127
|
+
function parseServiceFlags(argv, env) {
|
|
128
|
+
const pairs = [
|
|
129
|
+
["identityId", "--identity", "SECURECOMS_IDENTITY"],
|
|
130
|
+
["llmEndpoint", "--llm-endpoint", "SECURECOMS_LLM_ENDPOINT"],
|
|
131
|
+
["llmModel", "--llm-model", "SECURECOMS_LLM_MODEL"],
|
|
132
|
+
["llmApiKeyFile", "--llm-key-file", "SECURECOMS_LLM_API_KEY_FILE"],
|
|
133
|
+
["skillsDir", "--skills-dir", "SECURECOMS_SKILLS_DIR"],
|
|
134
|
+
["dataDir", "--data-dir", "SECURECOMS_DATA_DIR"],
|
|
135
|
+
// The interpreter is never taken from the environment: baking a Node path
|
|
136
|
+
// into a service definition is an explicit operator decision.
|
|
137
|
+
["execPath", "--exec-path", void 0]
|
|
138
|
+
];
|
|
139
|
+
const out = {};
|
|
140
|
+
for (const [key, flag, envVar] of pairs) {
|
|
141
|
+
const value = flagOrEnv(argv, flag, env, envVar);
|
|
142
|
+
if (value !== void 0) out[key] = value;
|
|
143
|
+
}
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
var UNINSTALL_EXEC_PATH = "/usr/bin/node";
|
|
147
|
+
function chooseVerbExecPath(verb, override, resolveForInstall) {
|
|
148
|
+
if (verb === "uninstall") return UNINSTALL_EXEC_PATH;
|
|
149
|
+
return resolveForInstall();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// src/service/install.ts
|
|
153
|
+
import { dirname as dirname2 } from "node:path";
|
|
154
|
+
|
|
155
|
+
// src/service/config.ts
|
|
156
|
+
import { isAbsolute, resolve } from "node:path";
|
|
157
|
+
|
|
158
|
+
// src/service/resolve-node.ts
|
|
159
|
+
var REQUIRED_NODE_MAJOR = 22;
|
|
160
|
+
var VERSION_EMBEDDED = [
|
|
161
|
+
/[\\/]Cellar[\\/]/,
|
|
162
|
+
// Homebrew keg
|
|
163
|
+
/[\\/]node[\\/]v?\d+\.\d+/,
|
|
164
|
+
// nvm, generic .../node/v24.4.1/...
|
|
165
|
+
/[\\/]nodejs[\\/]\d/,
|
|
166
|
+
// asdf .../installs/nodejs/24.4.1/...
|
|
167
|
+
/[\\/]node-versions[\\/]/,
|
|
168
|
+
// fnm
|
|
169
|
+
/[\\/]\.volta[\\/]/,
|
|
170
|
+
// volta
|
|
171
|
+
/[\\/]\.fnm[\\/]/,
|
|
172
|
+
// fnm (alt layout)
|
|
173
|
+
/[\\/]\.nvm[\\/]/,
|
|
174
|
+
// nvm home
|
|
175
|
+
/[\\/]\.asdf[\\/]/,
|
|
176
|
+
// asdf home
|
|
177
|
+
/[\\/]n[\\/]versions[\\/]/
|
|
178
|
+
// n
|
|
179
|
+
];
|
|
180
|
+
function isVersionEmbeddedNodePath(p) {
|
|
181
|
+
return VERSION_EMBEDDED.some((re) => re.test(p));
|
|
182
|
+
}
|
|
183
|
+
var STABLE_LOCATIONS = [
|
|
184
|
+
"/usr/local/bin/node",
|
|
185
|
+
// nodejs.org pkg (mac + linux)
|
|
186
|
+
"/opt/homebrew/bin/node",
|
|
187
|
+
// Homebrew symlink (arm mac)
|
|
188
|
+
"/opt/homebrew/opt/node/bin/node",
|
|
189
|
+
"/usr/bin/node"
|
|
190
|
+
// system (linux)
|
|
191
|
+
];
|
|
192
|
+
function candidates(home) {
|
|
193
|
+
return home ? [...STABLE_LOCATIONS, `${home}/.local/bin/node`] : STABLE_LOCATIONS;
|
|
194
|
+
}
|
|
195
|
+
function usable(path, deps) {
|
|
196
|
+
if (!deps.fileExists(path)) return false;
|
|
197
|
+
if (isVersionEmbeddedNodePath(path)) return false;
|
|
198
|
+
const major = deps.nodeMajorOf(path);
|
|
199
|
+
if (major == null || major < REQUIRED_NODE_MAJOR) return false;
|
|
200
|
+
const a = deps.archOf(path);
|
|
201
|
+
if (a != null && a !== deps.arch()) return false;
|
|
202
|
+
return true;
|
|
203
|
+
}
|
|
204
|
+
function resolveStableNodePath(runningExecPath, deps, home) {
|
|
205
|
+
const locations = candidates(home);
|
|
206
|
+
let runningReal;
|
|
207
|
+
try {
|
|
208
|
+
runningReal = deps.realpath(runningExecPath);
|
|
209
|
+
} catch {
|
|
210
|
+
runningReal = runningExecPath;
|
|
211
|
+
}
|
|
212
|
+
for (const loc of locations) {
|
|
213
|
+
if (!deps.fileExists(loc)) continue;
|
|
214
|
+
let locReal;
|
|
215
|
+
try {
|
|
216
|
+
locReal = deps.realpath(loc);
|
|
217
|
+
} catch {
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
if (locReal === runningReal && usable(loc, deps)) return { path: loc, stable: true };
|
|
221
|
+
}
|
|
222
|
+
for (const loc of locations) {
|
|
223
|
+
if (usable(loc, deps)) return { path: loc, stable: true };
|
|
224
|
+
}
|
|
225
|
+
return { path: runningExecPath, stable: false };
|
|
226
|
+
}
|
|
227
|
+
function chooseInstallExecPath(runningExecPath, override, deps, home) {
|
|
228
|
+
if (override) return override;
|
|
229
|
+
const r = resolveStableNodePath(runningExecPath, deps, home);
|
|
230
|
+
if (!r.stable) {
|
|
231
|
+
throw new Error(
|
|
232
|
+
`install: no reboot-stable Node found (need major >= ${REQUIRED_NODE_MAJOR} at a path whose location does not encode a version). Version-manager Nodes (nvm/asdf/fnm/volta/n) and Homebrew kegs are relocated on upgrade and would break the service after a reboot. Install a system Node (nodejs.org pkg \u2192 /usr/local/bin/node) or pass --exec-path <stable-path> to override.`
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
return r.path;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// src/service/config.ts
|
|
239
|
+
function slugify(s) {
|
|
240
|
+
return s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "agent";
|
|
241
|
+
}
|
|
242
|
+
function abs(p) {
|
|
243
|
+
return isAbsolute(p) ? p : resolve(p);
|
|
244
|
+
}
|
|
245
|
+
function deriveServiceConfig(input) {
|
|
246
|
+
if (isVersionEmbeddedNodePath(input.execPath)) {
|
|
247
|
+
throw new Error(
|
|
248
|
+
`install: refusing to bake a version-embedded Node path into the service definition: ${input.execPath}. Re-run install under a system/Homebrew Node, or pass --exec-path <stable-path>.`
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
const head = input.identityId.slice(0, 8).toLowerCase();
|
|
252
|
+
const slug = /^[0-9a-f]{8}$/.test(head) ? head : slugify(input.identityId);
|
|
253
|
+
const env = {};
|
|
254
|
+
if (input.llmEndpoint) env.SECURECOMS_LLM_ENDPOINT = input.llmEndpoint;
|
|
255
|
+
if (input.llmModel) env.SECURECOMS_LLM_MODEL = input.llmModel;
|
|
256
|
+
if (input.llmApiKeyFile) env.SECURECOMS_LLM_API_KEY_FILE = abs(input.llmApiKeyFile);
|
|
257
|
+
if (input.skillsDir) env.SECURECOMS_SKILLS_DIR = abs(input.skillsDir);
|
|
258
|
+
if (input.dataDir) env.SECURECOMS_DATA_DIR = abs(input.dataDir);
|
|
259
|
+
return {
|
|
260
|
+
slug,
|
|
261
|
+
label: `com.securecoms.agent.${slug}`,
|
|
262
|
+
unitName: `securecoms-agent-${slug}`,
|
|
263
|
+
args: ["--identity", input.identityId],
|
|
264
|
+
env,
|
|
265
|
+
execPath: input.execPath,
|
|
266
|
+
binScript: input.binScript,
|
|
267
|
+
uid: input.uid,
|
|
268
|
+
plistPath: `${input.home}/Library/LaunchAgents/com.securecoms.agent.${slug}.plist`,
|
|
269
|
+
unitPath: `${input.home}/.config/systemd/user/securecoms-agent-${slug}.service`,
|
|
270
|
+
logPath: `${input.home}/Library/Logs/securecoms-agent-${slug}.log`
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// src/service/launchd.ts
|
|
275
|
+
function renderLaunchdPlist(cfg) {
|
|
276
|
+
const argv = [cfg.execPath, cfg.binScript, ...cfg.args];
|
|
277
|
+
const programArgs = argv.map((a) => ` <string>${escapeXml(a)}</string>`).join("\n");
|
|
278
|
+
const entries = Object.entries(cfg.env);
|
|
279
|
+
const envBlock = entries.length === 0 ? "" : ` <key>EnvironmentVariables</key>
|
|
280
|
+
<dict>
|
|
281
|
+
${entries.map(([k, v]) => ` <key>${escapeXml(k)}</key>
|
|
282
|
+
<string>${escapeXml(v)}</string>`).join("\n")}
|
|
283
|
+
</dict>
|
|
284
|
+
`;
|
|
285
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
286
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
287
|
+
<plist version="1.0">
|
|
288
|
+
<dict>
|
|
289
|
+
<key>Label</key>
|
|
290
|
+
<string>${cfg.label}</string>
|
|
291
|
+
<key>ProgramArguments</key>
|
|
292
|
+
<array>
|
|
293
|
+
${programArgs}
|
|
294
|
+
</array>
|
|
295
|
+
${envBlock} <key>RunAtLoad</key>
|
|
296
|
+
<true/>
|
|
297
|
+
<key>KeepAlive</key>
|
|
298
|
+
<true/>
|
|
299
|
+
<key>StandardOutPath</key>
|
|
300
|
+
<string>${escapeXml(cfg.logPath)}</string>
|
|
301
|
+
<key>StandardErrorPath</key>
|
|
302
|
+
<string>${escapeXml(cfg.logPath)}</string>
|
|
303
|
+
</dict>
|
|
304
|
+
</plist>
|
|
305
|
+
`;
|
|
306
|
+
}
|
|
307
|
+
function escapeXml(s) {
|
|
308
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// src/service/systemd.ts
|
|
312
|
+
function sdQuote(s) {
|
|
313
|
+
return s.includes(" ") || s.includes('"') ? `"${sdEscape(s)}"` : sdEscape(s);
|
|
314
|
+
}
|
|
315
|
+
function sdEscape(s) {
|
|
316
|
+
return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/%/g, "%%");
|
|
317
|
+
}
|
|
318
|
+
function renderSystemdUnit(cfg) {
|
|
319
|
+
const execStart = [cfg.execPath, cfg.binScript, ...cfg.args].map(sdQuote).join(" ");
|
|
320
|
+
const envLines = Object.entries(cfg.env).map(([k, v]) => `Environment="${sdEscape(k)}=${sdEscape(v)}"
|
|
321
|
+
`).join("");
|
|
322
|
+
return `[Unit]
|
|
323
|
+
Description=SecureComs agent (${cfg.slug})
|
|
324
|
+
After=network-online.target
|
|
325
|
+
Wants=network-online.target
|
|
326
|
+
|
|
327
|
+
[Service]
|
|
328
|
+
Type=simple
|
|
329
|
+
ExecStart=${execStart}
|
|
330
|
+
${envLines}Restart=always
|
|
331
|
+
RestartSec=5
|
|
332
|
+
|
|
333
|
+
[Install]
|
|
334
|
+
WantedBy=default.target
|
|
335
|
+
`;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// src/service/install.ts
|
|
339
|
+
var ServiceUnsupportedError = class extends Error {
|
|
340
|
+
};
|
|
341
|
+
function planService(input, platform) {
|
|
342
|
+
const cfg = deriveServiceConfig(input);
|
|
343
|
+
const keyFile = cfg.env.SECURECOMS_LLM_API_KEY_FILE;
|
|
344
|
+
const common = { label: cfg.label, execPath: cfg.execPath, ...keyFile ? { keyFile } : {} };
|
|
345
|
+
if (platform === "darwin") {
|
|
346
|
+
return { ...common, filePath: cfg.plistPath, logHint: cfg.logPath, contents: renderLaunchdPlist(cfg) };
|
|
347
|
+
}
|
|
348
|
+
if (platform === "linux") {
|
|
349
|
+
return {
|
|
350
|
+
...common,
|
|
351
|
+
label: cfg.unitName,
|
|
352
|
+
filePath: cfg.unitPath,
|
|
353
|
+
logHint: `journalctl --user -u ${cfg.unitName} -f`,
|
|
354
|
+
contents: renderSystemdUnit(cfg)
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
throw new ServiceUnsupportedError(
|
|
358
|
+
`Service install isn't supported on ${platform} yet. Run the agent in a terminal, or use a process manager.`
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
function requireKeyFileReadable(keyFile, deps) {
|
|
362
|
+
if (!keyFile) return;
|
|
363
|
+
if (!deps.canRead(keyFile)) {
|
|
364
|
+
throw new Error(
|
|
365
|
+
`LLM key file ${keyFile} is missing or unreadable. Create it (and chmod 600 it), or install without --llm-key-file.`
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
function requireOk(r, what) {
|
|
370
|
+
if (r.status !== 0) throw new Error(`${what} failed (exit ${r.status})${r.stderr ? `: ${r.stderr.trim()}` : ""}`);
|
|
371
|
+
}
|
|
372
|
+
function prepareInstall(input, platform, deps) {
|
|
373
|
+
const plan = planService(input, platform);
|
|
374
|
+
requireKeyFileReadable(plan.keyFile, deps);
|
|
375
|
+
return plan;
|
|
376
|
+
}
|
|
377
|
+
function installService(input, deps) {
|
|
378
|
+
const cfg = deriveServiceConfig(input);
|
|
379
|
+
const plan = prepareInstall(input, deps.platform, deps);
|
|
380
|
+
const keyFile = plan.keyFile;
|
|
381
|
+
if (deps.platform === "darwin") {
|
|
382
|
+
deps.mkdirp(dirname2(cfg.plistPath));
|
|
383
|
+
deps.mkdirp(dirname2(cfg.logPath));
|
|
384
|
+
deps.writeFile(cfg.plistPath, plan.contents);
|
|
385
|
+
deps.run("launchctl", ["bootout", `gui/${cfg.uid}/${cfg.label}`]);
|
|
386
|
+
requireOk(deps.run("launchctl", ["bootstrap", `gui/${cfg.uid}`, cfg.plistPath]), "launchctl bootstrap");
|
|
387
|
+
deps.run("launchctl", ["enable", `gui/${cfg.uid}/${cfg.label}`]);
|
|
388
|
+
return { label: cfg.label, filePath: cfg.plistPath, logHint: cfg.logPath, ...keyFile ? { keyFile } : {} };
|
|
389
|
+
}
|
|
390
|
+
if (deps.platform === "linux") {
|
|
391
|
+
deps.mkdirp(dirname2(cfg.unitPath));
|
|
392
|
+
deps.writeFile(cfg.unitPath, plan.contents);
|
|
393
|
+
deps.run("systemctl", ["--user", "daemon-reload"]);
|
|
394
|
+
deps.run("loginctl", ["enable-linger", deps.user]);
|
|
395
|
+
requireOk(deps.run("systemctl", ["--user", "enable", "--now", cfg.unitName]), "systemctl --user enable --now");
|
|
396
|
+
return {
|
|
397
|
+
label: cfg.unitName,
|
|
398
|
+
filePath: cfg.unitPath,
|
|
399
|
+
logHint: `journalctl --user -u ${cfg.unitName} -f`,
|
|
400
|
+
...keyFile ? { keyFile } : {}
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
throw new ServiceUnsupportedError(
|
|
404
|
+
`Service install isn't supported on ${deps.platform} yet. Run the agent in a terminal, or use a process manager.`
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
function uninstallService(input, deps) {
|
|
408
|
+
const cfg = deriveServiceConfig(input);
|
|
409
|
+
if (deps.platform === "darwin") {
|
|
410
|
+
const existed = deps.fileExists(cfg.plistPath);
|
|
411
|
+
deps.run("launchctl", ["bootout", `gui/${cfg.uid}/${cfg.label}`]);
|
|
412
|
+
if (existed) deps.removeFile(cfg.plistPath);
|
|
413
|
+
return { removed: existed, filePath: cfg.plistPath };
|
|
414
|
+
}
|
|
415
|
+
if (deps.platform === "linux") {
|
|
416
|
+
const existed = deps.fileExists(cfg.unitPath);
|
|
417
|
+
deps.run("systemctl", ["--user", "disable", "--now", cfg.unitName]);
|
|
418
|
+
if (existed) deps.removeFile(cfg.unitPath);
|
|
419
|
+
deps.run("systemctl", ["--user", "daemon-reload"]);
|
|
420
|
+
return { removed: existed, filePath: cfg.unitPath };
|
|
421
|
+
}
|
|
422
|
+
throw new ServiceUnsupportedError(`Service uninstall isn't supported on ${deps.platform} yet.`);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// src/llm-dispatch.ts
|
|
426
|
+
function normalizeEndpoint(endpoint) {
|
|
427
|
+
return endpoint.replace(/\/+$/, "").replace(/\/v1$/, "").replace(/\/+$/, "");
|
|
428
|
+
}
|
|
429
|
+
var LLMDispatcher = class {
|
|
430
|
+
endpoint;
|
|
431
|
+
model;
|
|
432
|
+
apiKey;
|
|
433
|
+
constructor(endpoint, model, opts = {}) {
|
|
434
|
+
this.endpoint = normalizeEndpoint(endpoint);
|
|
435
|
+
this.model = model;
|
|
436
|
+
this.apiKey = opts.apiKey;
|
|
437
|
+
}
|
|
438
|
+
async complete(messages, tools) {
|
|
439
|
+
const body = {
|
|
440
|
+
model: this.model,
|
|
441
|
+
messages,
|
|
442
|
+
stream: false
|
|
443
|
+
};
|
|
444
|
+
if (tools && tools.length > 0) {
|
|
445
|
+
body.tools = tools;
|
|
446
|
+
}
|
|
447
|
+
const url = `${this.endpoint}/v1/chat/completions`;
|
|
448
|
+
const headers = { "Content-Type": "application/json" };
|
|
449
|
+
if (this.apiKey) {
|
|
450
|
+
headers.Authorization = `Bearer ${this.apiKey}`;
|
|
451
|
+
}
|
|
452
|
+
const resp = await fetch(url, {
|
|
453
|
+
method: "POST",
|
|
454
|
+
headers,
|
|
455
|
+
body: JSON.stringify(body)
|
|
456
|
+
});
|
|
457
|
+
if (!resp.ok) {
|
|
458
|
+
const text = await resp.text();
|
|
459
|
+
throw new Error(`LLM request failed (${resp.status}): ${text}`);
|
|
460
|
+
}
|
|
461
|
+
const data = await resp.json();
|
|
462
|
+
const choice = data.choices?.[0];
|
|
463
|
+
if (!choice) {
|
|
464
|
+
throw new Error("LLM returned no choices");
|
|
465
|
+
}
|
|
466
|
+
return {
|
|
467
|
+
content: choice.message.content,
|
|
468
|
+
toolCalls: choice.message.tool_calls || [],
|
|
469
|
+
finishReason: choice.finish_reason
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
};
|
|
473
|
+
|
|
474
|
+
// src/memory.ts
|
|
475
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
|
476
|
+
import { join as join2 } from "node:path";
|
|
477
|
+
var MAX_HISTORY = 50;
|
|
478
|
+
var AgentMemory = class {
|
|
479
|
+
history = [];
|
|
480
|
+
memories = [];
|
|
481
|
+
dataDir;
|
|
482
|
+
constructor(dataDir) {
|
|
483
|
+
this.dataDir = dataDir;
|
|
484
|
+
this.loadHistory();
|
|
485
|
+
this.loadMemories();
|
|
486
|
+
}
|
|
487
|
+
/** Add a message to conversation history. */
|
|
488
|
+
addMessage(msg) {
|
|
489
|
+
this.history.push(msg);
|
|
490
|
+
if (this.history.length > MAX_HISTORY * 2) {
|
|
491
|
+
this.history = this.history.slice(-MAX_HISTORY);
|
|
492
|
+
}
|
|
493
|
+
this.saveHistory();
|
|
494
|
+
}
|
|
495
|
+
/** Get recent conversation history for LLM context. */
|
|
496
|
+
getHistory() {
|
|
497
|
+
return this.history.slice(-MAX_HISTORY);
|
|
498
|
+
}
|
|
499
|
+
/** Clear conversation history. */
|
|
500
|
+
clear() {
|
|
501
|
+
this.history = [];
|
|
502
|
+
this.saveHistory();
|
|
503
|
+
}
|
|
504
|
+
/**
|
|
505
|
+
* Recall relevant memories to prepend to the LLM context.
|
|
506
|
+
* Case-insensitive keyword-overlap match over captured entries: query is
|
|
507
|
+
* split into words of length >= 3, entries are scored by number of
|
|
508
|
+
* matching words, and the top 5 with score > 0 are returned.
|
|
509
|
+
*/
|
|
510
|
+
async recall(query) {
|
|
511
|
+
const queryWords = query.toLowerCase().split(/\W+/).filter((w) => w.length >= 3);
|
|
512
|
+
if (queryWords.length === 0) return null;
|
|
513
|
+
const scored = this.memories.map((m) => {
|
|
514
|
+
const contentLower = m.content.toLowerCase();
|
|
515
|
+
const score = queryWords.reduce(
|
|
516
|
+
(acc, w) => contentLower.includes(w) ? acc + 1 : acc,
|
|
517
|
+
0
|
|
518
|
+
);
|
|
519
|
+
return { memory: m, score };
|
|
520
|
+
}).filter((s) => s.score > 0).sort((a, b) => b.score - a.score).slice(0, 5);
|
|
521
|
+
if (scored.length === 0) return null;
|
|
522
|
+
const memories = scored.map((s) => `- ${s.memory.content}`).join("\n");
|
|
523
|
+
return `Relevant context from memory:
|
|
524
|
+
${memories}`;
|
|
525
|
+
}
|
|
526
|
+
/**
|
|
527
|
+
* Capture a notable fact to local memory (best-effort).
|
|
528
|
+
*/
|
|
529
|
+
async capture(content, kind) {
|
|
530
|
+
try {
|
|
531
|
+
this.memories.push({
|
|
532
|
+
content,
|
|
533
|
+
category: kind,
|
|
534
|
+
capturedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
535
|
+
});
|
|
536
|
+
this.saveMemories();
|
|
537
|
+
} catch {
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
get historyPath() {
|
|
541
|
+
return join2(this.dataDir, "conversation-history.json");
|
|
542
|
+
}
|
|
543
|
+
get memoriesPath() {
|
|
544
|
+
return join2(this.dataDir, "memories.json");
|
|
545
|
+
}
|
|
546
|
+
loadHistory() {
|
|
547
|
+
try {
|
|
548
|
+
if (existsSync(this.historyPath)) {
|
|
549
|
+
const raw = readFileSync(this.historyPath, "utf-8");
|
|
550
|
+
this.history = JSON.parse(raw);
|
|
551
|
+
}
|
|
552
|
+
} catch {
|
|
553
|
+
this.history = [];
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
saveHistory() {
|
|
557
|
+
try {
|
|
558
|
+
mkdirSync(this.dataDir, { recursive: true });
|
|
559
|
+
writeFileSync(this.historyPath, JSON.stringify(this.history));
|
|
560
|
+
} catch {
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
loadMemories() {
|
|
564
|
+
try {
|
|
565
|
+
if (existsSync(this.memoriesPath)) {
|
|
566
|
+
const raw = readFileSync(this.memoriesPath, "utf-8");
|
|
567
|
+
this.memories = JSON.parse(raw);
|
|
568
|
+
}
|
|
569
|
+
} catch {
|
|
570
|
+
this.memories = [];
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
saveMemories() {
|
|
574
|
+
try {
|
|
575
|
+
mkdirSync(this.dataDir, { recursive: true });
|
|
576
|
+
writeFileSync(this.memoriesPath, JSON.stringify(this.memories));
|
|
577
|
+
} catch {
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
};
|
|
581
|
+
|
|
582
|
+
// src/skill-invoker.ts
|
|
583
|
+
import { readFileSync as readFileSync2, readdirSync, existsSync as existsSync2, statSync } from "node:fs";
|
|
584
|
+
import { join as join3 } from "node:path";
|
|
585
|
+
var SkillInvoker = class {
|
|
586
|
+
skills = /* @__PURE__ */ new Map();
|
|
587
|
+
skillsDir;
|
|
588
|
+
constructor(skillsDir) {
|
|
589
|
+
this.skillsDir = skillsDir;
|
|
590
|
+
}
|
|
591
|
+
/**
|
|
592
|
+
* Load all skill manifests from the skills directory. Supports two
|
|
593
|
+
* layouts: flat `<skillsDir>/*.json` files, and one level of nesting via
|
|
594
|
+
* `<skillsDir>/<subdir>/manifest.json`.
|
|
595
|
+
*/
|
|
596
|
+
loadSkills() {
|
|
597
|
+
if (!existsSync2(this.skillsDir)) {
|
|
598
|
+
console.warn(`[SkillInvoker] Skills directory not found: ${this.skillsDir}`);
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
const entries = readdirSync(this.skillsDir, { withFileTypes: true });
|
|
602
|
+
for (const entry of entries) {
|
|
603
|
+
if (entry.isFile() && entry.name.endsWith(".json")) {
|
|
604
|
+
this.loadManifestFile(join3(this.skillsDir, entry.name));
|
|
605
|
+
continue;
|
|
606
|
+
}
|
|
607
|
+
if (entry.isDirectory()) {
|
|
608
|
+
const manifestPath = join3(this.skillsDir, entry.name, "manifest.json");
|
|
609
|
+
if (existsSync2(manifestPath) && statSync(manifestPath).isFile()) {
|
|
610
|
+
this.loadManifestFile(manifestPath);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
console.log(`[SkillInvoker] Loaded ${this.skills.size} skills`);
|
|
615
|
+
}
|
|
616
|
+
loadManifestFile(path) {
|
|
617
|
+
try {
|
|
618
|
+
const raw = readFileSync2(path, "utf-8");
|
|
619
|
+
const manifest = JSON.parse(raw);
|
|
620
|
+
if (typeof manifest.handler !== "string" || !(manifest.handler.startsWith("builtin:") || manifest.handler.startsWith("script:"))) {
|
|
621
|
+
console.warn(
|
|
622
|
+
`[SkillInvoker] Skipping manifest with invalid handler ${path}: expected "builtin:<name>" or "script:<path>"`
|
|
623
|
+
);
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
if (manifest.name && manifest.description) {
|
|
627
|
+
this.skills.set(manifest.name, manifest);
|
|
628
|
+
}
|
|
629
|
+
} catch (err) {
|
|
630
|
+
console.warn(`[SkillInvoker] Failed to load skill ${path}:`, err);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
/** Convert loaded skills to OpenAI tool definitions for LLM dispatch. */
|
|
634
|
+
getToolDefinitions() {
|
|
635
|
+
return Array.from(this.skills.values()).map((skill) => ({
|
|
636
|
+
type: "function",
|
|
637
|
+
function: {
|
|
638
|
+
name: skill.name,
|
|
639
|
+
description: skill.description,
|
|
640
|
+
parameters: skill.parameters
|
|
641
|
+
}
|
|
642
|
+
}));
|
|
643
|
+
}
|
|
644
|
+
/**
|
|
645
|
+
* Execute a skill by name with the given arguments.
|
|
646
|
+
*
|
|
647
|
+
* @param context Optional room/group binding for the in-flight message
|
|
648
|
+
* that triggered this call. Callers without room/group context simply
|
|
649
|
+
* omit it.
|
|
650
|
+
*/
|
|
651
|
+
async invoke(name, args, context) {
|
|
652
|
+
const skill = this.skills.get(name);
|
|
653
|
+
if (!skill) {
|
|
654
|
+
return JSON.stringify({ error: `Unknown skill: ${name}` });
|
|
655
|
+
}
|
|
656
|
+
const handler = skill.handler;
|
|
657
|
+
if (handler.startsWith("builtin:")) {
|
|
658
|
+
return this.invokeBuiltin(handler.slice(8), args);
|
|
659
|
+
}
|
|
660
|
+
if (handler.startsWith("script:")) {
|
|
661
|
+
return this.invokeScript(handler.slice(7), args);
|
|
662
|
+
}
|
|
663
|
+
return JSON.stringify({ error: `Unknown handler type: ${handler}` });
|
|
664
|
+
}
|
|
665
|
+
async invokeBuiltin(name, args) {
|
|
666
|
+
switch (name) {
|
|
667
|
+
case "echo":
|
|
668
|
+
return String(args.text ?? "");
|
|
669
|
+
case "timestamp":
|
|
670
|
+
return JSON.stringify({ timestamp: (/* @__PURE__ */ new Date()).toISOString() });
|
|
671
|
+
default:
|
|
672
|
+
return JSON.stringify({ error: `Unknown builtin: ${name}` });
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
async invokeScript(scriptPath, args) {
|
|
676
|
+
const { resolve: resolve2, relative } = await import("node:path");
|
|
677
|
+
const resolved = resolve2(this.skillsDir, scriptPath);
|
|
678
|
+
const rel = relative(this.skillsDir, resolved);
|
|
679
|
+
if (rel.startsWith("..") || !resolved.startsWith(resolve2(this.skillsDir))) {
|
|
680
|
+
return JSON.stringify({ error: "Script path must be within skills directory" });
|
|
681
|
+
}
|
|
682
|
+
const { realpathSync: realpathSync2, existsSync: fsExistsSync } = await import("node:fs");
|
|
683
|
+
if (fsExistsSync(resolved)) {
|
|
684
|
+
const realResolved = realpathSync2(resolved);
|
|
685
|
+
const realSkillsDir = realpathSync2(this.skillsDir);
|
|
686
|
+
const realRel = relative(realSkillsDir, realResolved);
|
|
687
|
+
if (realRel.startsWith("..") || !realResolved.startsWith(realSkillsDir)) {
|
|
688
|
+
return JSON.stringify({ error: "Script path must be within skills directory" });
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
const { accessSync: accessSync2, constants: constants2 } = await import("node:fs");
|
|
692
|
+
try {
|
|
693
|
+
accessSync2(resolved, constants2.X_OK);
|
|
694
|
+
} catch {
|
|
695
|
+
return JSON.stringify({ error: `Script not found or not executable: ${scriptPath}` });
|
|
696
|
+
}
|
|
697
|
+
const { execSync } = await import("node:child_process");
|
|
698
|
+
try {
|
|
699
|
+
const result = execSync(resolved, {
|
|
700
|
+
input: JSON.stringify(args),
|
|
701
|
+
timeout: 3e4,
|
|
702
|
+
maxBuffer: 1024 * 1024,
|
|
703
|
+
encoding: "utf-8",
|
|
704
|
+
cwd: this.skillsDir,
|
|
705
|
+
env: { PATH: "/usr/bin:/bin", HOME: "/tmp" }
|
|
706
|
+
});
|
|
707
|
+
return result;
|
|
708
|
+
} catch (err) {
|
|
709
|
+
return JSON.stringify({
|
|
710
|
+
error: `Script execution failed: ${err instanceof Error ? err.message : String(err)}`
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
};
|
|
715
|
+
|
|
716
|
+
// src/tool-gate.ts
|
|
717
|
+
function createLocalToolGate(policy) {
|
|
718
|
+
return {
|
|
719
|
+
gate(toolName) {
|
|
720
|
+
if (policy.deny && policy.deny.includes(toolName)) {
|
|
721
|
+
return {
|
|
722
|
+
allowed: false,
|
|
723
|
+
reason: `Tool "${toolName}" is denied by policy`
|
|
724
|
+
};
|
|
725
|
+
}
|
|
726
|
+
if (policy.allow && policy.allow.includes(toolName)) {
|
|
727
|
+
return {
|
|
728
|
+
allowed: true,
|
|
729
|
+
reason: `Tool "${toolName}" is allowed by allow-list`
|
|
730
|
+
};
|
|
731
|
+
}
|
|
732
|
+
if (policy.default === "allow") {
|
|
733
|
+
return {
|
|
734
|
+
allowed: true,
|
|
735
|
+
reason: `Tool "${toolName}" allowed by default policy`
|
|
736
|
+
};
|
|
737
|
+
} else {
|
|
738
|
+
return {
|
|
739
|
+
allowed: false,
|
|
740
|
+
reason: `Tool "${toolName}" not allowed by default policy`
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
};
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// src/data-plane.ts
|
|
748
|
+
import { readFileSync as readFileSync3 } from "node:fs";
|
|
749
|
+
import { WebSocket } from "node:http";
|
|
750
|
+
import { homedir as homedir2 } from "node:os";
|
|
751
|
+
import { join as join4 } from "node:path";
|
|
752
|
+
var SEEN_MAX = 5e3;
|
|
753
|
+
function errText(e) {
|
|
754
|
+
return e instanceof Error ? e.message : String(e);
|
|
755
|
+
}
|
|
756
|
+
var SecureComsDataPlane = class {
|
|
757
|
+
identityId;
|
|
758
|
+
connectionPath;
|
|
759
|
+
establishedMs;
|
|
760
|
+
initialDelayMs;
|
|
761
|
+
maxDelayMs;
|
|
762
|
+
log;
|
|
763
|
+
sleep;
|
|
764
|
+
random;
|
|
765
|
+
onMessage;
|
|
766
|
+
seen = /* @__PURE__ */ new Set();
|
|
767
|
+
stopped = false;
|
|
768
|
+
running = false;
|
|
769
|
+
socket = null;
|
|
770
|
+
/**
|
|
771
|
+
* The CURRENT attempt's establish timer, published here only so stop() can
|
|
772
|
+
* cancel it. Each attempt also holds its own local reference and clears only
|
|
773
|
+
* that one — see attemptOnce.
|
|
774
|
+
*/
|
|
775
|
+
settle = null;
|
|
776
|
+
/**
|
|
777
|
+
* The handle of the DEFAULT backoff sleep's timer, so stop() can cancel it.
|
|
778
|
+
* Without this, stopping during a 30 s backoff leaves a live timer holding
|
|
779
|
+
* the event loop open for the rest of that delay. It is deliberately NOT
|
|
780
|
+
* unref()'d: while the plane is running, a pending reconnect is a reason for
|
|
781
|
+
* the process to stay alive — cancelling on stop() is the right lever, not
|
|
782
|
+
* making the timer invisible.
|
|
783
|
+
*/
|
|
784
|
+
sleepTimer = null;
|
|
785
|
+
/** Resolvers for a wait() currently parked, released immediately by stop(). */
|
|
786
|
+
sleepWaiters = [];
|
|
787
|
+
/**
|
|
788
|
+
* Bumped by stop() and by start(). A loop only keeps running while its own
|
|
789
|
+
* generation is current, so a restart can never end up with two live loops.
|
|
790
|
+
*/
|
|
791
|
+
generation = 0;
|
|
792
|
+
constructor(cfg, onMessage) {
|
|
793
|
+
this.identityId = cfg.identityId;
|
|
794
|
+
const dir = cfg.connectionsDir ?? join4(homedir2(), ".securecoms", "connections");
|
|
795
|
+
this.connectionPath = join4(dir, `${cfg.identityId}.json`);
|
|
796
|
+
this.establishedMs = cfg.establishedMs ?? 5e3;
|
|
797
|
+
this.initialDelayMs = cfg.initialDelayMs ?? 250;
|
|
798
|
+
this.maxDelayMs = cfg.maxDelayMs ?? 3e4;
|
|
799
|
+
this.log = cfg.log ?? ((level, msg) => {
|
|
800
|
+
if (level === "info") console.log(msg);
|
|
801
|
+
else console.error(msg);
|
|
802
|
+
});
|
|
803
|
+
this.sleep = cfg.sleep ?? ((ms) => new Promise((resolve2) => {
|
|
804
|
+
this.sleepTimer = setTimeout(() => {
|
|
805
|
+
this.sleepTimer = null;
|
|
806
|
+
resolve2();
|
|
807
|
+
}, ms);
|
|
808
|
+
}));
|
|
809
|
+
this.random = cfg.random ?? Math.random;
|
|
810
|
+
this.onMessage = onMessage;
|
|
811
|
+
}
|
|
812
|
+
/**
|
|
813
|
+
* Resolves once the reconnect loop is running — NOT once attached. The loop
|
|
814
|
+
* runs in the background for the life of the process; every error inside it
|
|
815
|
+
* is logged, never thrown out of here.
|
|
816
|
+
*/
|
|
817
|
+
async start() {
|
|
818
|
+
if (this.running) return;
|
|
819
|
+
this.running = true;
|
|
820
|
+
this.stopped = false;
|
|
821
|
+
void this.loop(++this.generation);
|
|
822
|
+
}
|
|
823
|
+
stop() {
|
|
824
|
+
this.stopped = true;
|
|
825
|
+
this.running = false;
|
|
826
|
+
this.generation++;
|
|
827
|
+
this.clearSettle();
|
|
828
|
+
this.cancelSleep();
|
|
829
|
+
const ws = this.socket;
|
|
830
|
+
this.socket = null;
|
|
831
|
+
if (ws) {
|
|
832
|
+
try {
|
|
833
|
+
ws.close();
|
|
834
|
+
} catch (e) {
|
|
835
|
+
this.log("warn", `error closing socket: ${errText(e)}`);
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
/**
|
|
840
|
+
* Cancels the default sleep's timer and releases whatever is parked in
|
|
841
|
+
* wait(), so the loop observes `stopped` immediately rather than after the
|
|
842
|
+
* remaining backoff (up to maxDelayMs). An INJECTED sleep cannot be
|
|
843
|
+
* cancelled from here, so wait() races it against this release instead —
|
|
844
|
+
* that keeps the injectable signature exactly as the tests use it.
|
|
845
|
+
*/
|
|
846
|
+
cancelSleep() {
|
|
847
|
+
if (this.sleepTimer) {
|
|
848
|
+
clearTimeout(this.sleepTimer);
|
|
849
|
+
this.sleepTimer = null;
|
|
850
|
+
}
|
|
851
|
+
for (const release of this.sleepWaiters.splice(0)) release();
|
|
852
|
+
}
|
|
853
|
+
/** The backoff sleep, made interruptible by stop(). */
|
|
854
|
+
wait(ms) {
|
|
855
|
+
if (this.stopped) return Promise.resolve();
|
|
856
|
+
return new Promise((resolve2) => {
|
|
857
|
+
let settled = false;
|
|
858
|
+
const finish = () => {
|
|
859
|
+
if (settled) return;
|
|
860
|
+
settled = true;
|
|
861
|
+
this.sleepWaiters = this.sleepWaiters.filter((w) => w !== finish);
|
|
862
|
+
resolve2();
|
|
863
|
+
};
|
|
864
|
+
this.sleepWaiters.push(finish);
|
|
865
|
+
void this.sleep(ms).then(finish, finish);
|
|
866
|
+
});
|
|
867
|
+
}
|
|
868
|
+
clearSettle() {
|
|
869
|
+
if (this.settle) {
|
|
870
|
+
clearTimeout(this.settle);
|
|
871
|
+
this.settle = null;
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
remember(id) {
|
|
875
|
+
if (this.seen.size >= SEEN_MAX) {
|
|
876
|
+
const oldest = this.seen.values().next();
|
|
877
|
+
if (!oldest.done) this.seen.delete(oldest.value);
|
|
878
|
+
}
|
|
879
|
+
this.seen.add(id);
|
|
880
|
+
}
|
|
881
|
+
async loop(generation) {
|
|
882
|
+
const live = () => !this.stopped && this.generation === generation;
|
|
883
|
+
let delay = this.initialDelayMs;
|
|
884
|
+
while (live()) {
|
|
885
|
+
try {
|
|
886
|
+
await this.attemptOnce();
|
|
887
|
+
delay = this.initialDelayMs;
|
|
888
|
+
} catch (e) {
|
|
889
|
+
this.log("error", errText(e));
|
|
890
|
+
}
|
|
891
|
+
if (!live()) break;
|
|
892
|
+
await this.wait(this.random() * delay);
|
|
893
|
+
delay = Math.min(delay * 2, this.maxDelayMs);
|
|
894
|
+
}
|
|
895
|
+
if (this.generation === generation) this.running = false;
|
|
896
|
+
}
|
|
897
|
+
async attemptOnce() {
|
|
898
|
+
const conn = JSON.parse(readFileSync3(this.connectionPath, "utf8"));
|
|
899
|
+
const httpBase = conn.bridge_url.replace(/^ws/, "http").replace(/\/v1\/ws$/, "");
|
|
900
|
+
const token = readFileSync3(conn.token_file, "utf8").trim();
|
|
901
|
+
const health = await (await fetch(`${httpBase}/healthz`)).json();
|
|
902
|
+
if (conn.identity_id !== health.identity_id || this.identityId !== health.identity_id) {
|
|
903
|
+
throw new Error(
|
|
904
|
+
`gate refused: expected ${this.identityId}, connection file says ${conn.identity_id}, daemon on this port is ${health.identity_id}`
|
|
905
|
+
);
|
|
906
|
+
}
|
|
907
|
+
if (this.stopped) return;
|
|
908
|
+
const ws = new WebSocket(`${conn.bridge_url}?token=${encodeURIComponent(token)}`);
|
|
909
|
+
this.socket = ws;
|
|
910
|
+
let established = false;
|
|
911
|
+
let settle = null;
|
|
912
|
+
try {
|
|
913
|
+
await new Promise((resolve2, reject) => {
|
|
914
|
+
ws.addEventListener("error", (e) => {
|
|
915
|
+
reject(new Error(`socket error: ${errText(e?.message ?? e)}`));
|
|
916
|
+
});
|
|
917
|
+
ws.addEventListener("close", (e) => {
|
|
918
|
+
if (established) resolve2();
|
|
919
|
+
else {
|
|
920
|
+
reject(
|
|
921
|
+
new Error(
|
|
922
|
+
`closed with code ${e.code} before the connection settled \u2014 bad token, or another connector took this port`
|
|
923
|
+
)
|
|
924
|
+
);
|
|
925
|
+
}
|
|
926
|
+
});
|
|
927
|
+
ws.addEventListener("open", () => {
|
|
928
|
+
settle = setTimeout(() => {
|
|
929
|
+
established = true;
|
|
930
|
+
this.log("info", `attached to ${conn.bridge_url}`);
|
|
931
|
+
}, this.establishedMs);
|
|
932
|
+
this.settle = settle;
|
|
933
|
+
});
|
|
934
|
+
ws.addEventListener("message", (ev) => {
|
|
935
|
+
void this.handleFrame(ws, ev.data).catch((e) => {
|
|
936
|
+
this.log("error", `frame handling failed: ${errText(e)}`);
|
|
937
|
+
});
|
|
938
|
+
});
|
|
939
|
+
});
|
|
940
|
+
} finally {
|
|
941
|
+
if (settle !== null) clearTimeout(settle);
|
|
942
|
+
if (this.settle === settle) this.settle = null;
|
|
943
|
+
try {
|
|
944
|
+
ws.close();
|
|
945
|
+
} catch {
|
|
946
|
+
}
|
|
947
|
+
if (this.socket === ws) this.socket = null;
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
async handleFrame(ws, data) {
|
|
951
|
+
let frame;
|
|
952
|
+
try {
|
|
953
|
+
frame = JSON.parse(String(data));
|
|
954
|
+
} catch (e) {
|
|
955
|
+
this.log("error", `ignoring unparseable frame: ${errText(e)}`);
|
|
956
|
+
return;
|
|
957
|
+
}
|
|
958
|
+
if (frame?.type === "error") {
|
|
959
|
+
this.log("error", `daemon error frame: ${String(frame.error)}: ${String(frame.message)}`);
|
|
960
|
+
return;
|
|
961
|
+
}
|
|
962
|
+
if (frame?.type !== "message") return;
|
|
963
|
+
const messageId = frame.message_id;
|
|
964
|
+
if (typeof messageId !== "string") {
|
|
965
|
+
this.log("error", "ignoring message frame with no message_id");
|
|
966
|
+
return;
|
|
967
|
+
}
|
|
968
|
+
const channelId = String(frame.channel_id);
|
|
969
|
+
try {
|
|
970
|
+
if (!this.seen.has(messageId)) {
|
|
971
|
+
this.remember(messageId);
|
|
972
|
+
const reply = await this.onMessage({
|
|
973
|
+
text: String(frame.text ?? ""),
|
|
974
|
+
senderId: String(frame.sender_id ?? ""),
|
|
975
|
+
channelId,
|
|
976
|
+
messageId
|
|
977
|
+
});
|
|
978
|
+
if (reply != null) {
|
|
979
|
+
ws.send(JSON.stringify({ type: "send", channel_id: channelId, text: String(reply) }));
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
} catch (e) {
|
|
983
|
+
this.log("error", `handler threw: ${errText(e)}`);
|
|
984
|
+
} finally {
|
|
985
|
+
try {
|
|
986
|
+
ws.send(JSON.stringify({ type: "ack", message_id: messageId }));
|
|
987
|
+
} catch (e) {
|
|
988
|
+
this.log("error", `could not ack ${messageId}: ${errText(e)}`);
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
};
|
|
993
|
+
function createSecureComsDataPlane(cfg, onMessage) {
|
|
994
|
+
return new SecureComsDataPlane(cfg, onMessage);
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
// src/tool-runner.ts
|
|
998
|
+
async function runToolCalls(toolCalls, skills, context) {
|
|
999
|
+
const out = [];
|
|
1000
|
+
for (const call of toolCalls) {
|
|
1001
|
+
let args;
|
|
1002
|
+
try {
|
|
1003
|
+
args = JSON.parse(call.function.arguments);
|
|
1004
|
+
} catch {
|
|
1005
|
+
args = {};
|
|
1006
|
+
}
|
|
1007
|
+
let content;
|
|
1008
|
+
try {
|
|
1009
|
+
content = await skills.invoke(call.function.name, args, context);
|
|
1010
|
+
} catch (err) {
|
|
1011
|
+
content = JSON.stringify({
|
|
1012
|
+
error: "tool_failed",
|
|
1013
|
+
message: String(err instanceof Error ? err.message : err)
|
|
1014
|
+
});
|
|
1015
|
+
}
|
|
1016
|
+
out.push({ role: "tool", content, tool_call_id: call.id });
|
|
1017
|
+
}
|
|
1018
|
+
return out;
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
// src/turn-loop.ts
|
|
1022
|
+
var DEFAULT_SYSTEM_PROMPT = "You are a SecureComs agent. Respond helpfully and concisely.";
|
|
1023
|
+
var DEFAULT_MAX_ITERATIONS = 5;
|
|
1024
|
+
function buildHandler(deps) {
|
|
1025
|
+
const systemPrompt = deps.systemPrompt ?? DEFAULT_SYSTEM_PROMPT;
|
|
1026
|
+
const maxIterations = deps.maxIterations ?? DEFAULT_MAX_ITERATIONS;
|
|
1027
|
+
const log = deps.log ?? (() => {
|
|
1028
|
+
});
|
|
1029
|
+
const { llm, memoryFor, skills, gate } = deps;
|
|
1030
|
+
return async (m) => {
|
|
1031
|
+
const text = m.text?.trim() ? m.text : "";
|
|
1032
|
+
if (!text) return null;
|
|
1033
|
+
log("info", `message from ${m.senderId.slice(0, 8)}: ${text.slice(0, 80)}`);
|
|
1034
|
+
const memory = memoryFor(m.channelId);
|
|
1035
|
+
const messages = [{ role: "system", content: systemPrompt }];
|
|
1036
|
+
const recalled = await memory.recall(text);
|
|
1037
|
+
if (recalled) messages.push({ role: "system", content: recalled });
|
|
1038
|
+
messages.push(...memory.getHistory());
|
|
1039
|
+
messages.push({ role: "user", content: text });
|
|
1040
|
+
memory.addMessage({ role: "user", content: text });
|
|
1041
|
+
const tools = skills.getToolDefinitions();
|
|
1042
|
+
let response = null;
|
|
1043
|
+
for (let iteration = 0; iteration < maxIterations; iteration++) {
|
|
1044
|
+
const result = await llm.complete(messages, tools.length > 0 ? tools : void 0);
|
|
1045
|
+
if (result.toolCalls.length === 0) {
|
|
1046
|
+
response = result.content;
|
|
1047
|
+
break;
|
|
1048
|
+
}
|
|
1049
|
+
messages.push({
|
|
1050
|
+
role: "assistant",
|
|
1051
|
+
content: result.content ?? "",
|
|
1052
|
+
tool_calls: result.toolCalls
|
|
1053
|
+
});
|
|
1054
|
+
const verdicts = result.toolCalls.map((call) => ({
|
|
1055
|
+
call,
|
|
1056
|
+
verdict: gate.gate(call.function.name)
|
|
1057
|
+
}));
|
|
1058
|
+
const allowed = verdicts.filter((v) => v.verdict.allowed).map((v) => v.call);
|
|
1059
|
+
for (const v of verdicts) {
|
|
1060
|
+
if (!v.verdict.allowed) {
|
|
1061
|
+
log("warn", `tool call denied: ${v.call.function.name} \u2014 ${v.verdict.reason}`);
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
const executed = await runToolCalls(allowed, skills, { roomId: m.channelId });
|
|
1065
|
+
const byId = new Map(executed.map((r) => [r.tool_call_id, r]));
|
|
1066
|
+
for (const { call, verdict } of verdicts) {
|
|
1067
|
+
if (verdict.allowed) {
|
|
1068
|
+
const done = byId.get(call.id);
|
|
1069
|
+
if (done) {
|
|
1070
|
+
messages.push({ role: "tool", content: done.content, tool_call_id: done.tool_call_id });
|
|
1071
|
+
continue;
|
|
1072
|
+
}
|
|
1073
|
+
messages.push({
|
|
1074
|
+
role: "tool",
|
|
1075
|
+
content: JSON.stringify({ error: "tool_call_no_result" }),
|
|
1076
|
+
tool_call_id: call.id
|
|
1077
|
+
});
|
|
1078
|
+
continue;
|
|
1079
|
+
}
|
|
1080
|
+
messages.push({
|
|
1081
|
+
role: "tool",
|
|
1082
|
+
content: JSON.stringify({ error: "tool_call_denied", reason: verdict.reason }),
|
|
1083
|
+
tool_call_id: call.id
|
|
1084
|
+
});
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
if (!response) {
|
|
1088
|
+
log("warn", `no final answer after ${maxIterations} iterations; dropping the turn`);
|
|
1089
|
+
return null;
|
|
1090
|
+
}
|
|
1091
|
+
memory.addMessage({ role: "assistant", content: response });
|
|
1092
|
+
if (response.length > 50) {
|
|
1093
|
+
try {
|
|
1094
|
+
await memory.capture(
|
|
1095
|
+
`User asked: "${text.slice(0, 100)}". Agent responded: "${response.slice(0, 200)}"`,
|
|
1096
|
+
"conversation"
|
|
1097
|
+
);
|
|
1098
|
+
} catch (e) {
|
|
1099
|
+
log("warn", `memory capture failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
log("info", `response: ${response.slice(0, 80)}`);
|
|
1103
|
+
return response;
|
|
1104
|
+
};
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
// src/index.ts
|
|
1108
|
+
if (process.argv.includes("--version")) {
|
|
1109
|
+
const pkg = JSON.parse(readFileSync4(join5(dirname3(dirname3(fileURLToPath2(import.meta.url))), "package.json"), "utf8"));
|
|
1110
|
+
console.log(pkg.version);
|
|
1111
|
+
process.exit(0);
|
|
1112
|
+
}
|
|
1113
|
+
var LEVEL_RANK = { debug: 0, info: 1, warn: 2, error: 3 };
|
|
1114
|
+
function makeLogger(cfg) {
|
|
1115
|
+
const floor = LEVEL_RANK[cfg.logLevel];
|
|
1116
|
+
return (level, msg) => {
|
|
1117
|
+
if (LEVEL_RANK[level] < floor) return;
|
|
1118
|
+
const line = `[runtime] ${level}: ${msg}`;
|
|
1119
|
+
if (level === "warn" || level === "error") console.error(line);
|
|
1120
|
+
else console.log(line);
|
|
1121
|
+
};
|
|
1122
|
+
}
|
|
1123
|
+
function safeChannelSegment(channelId) {
|
|
1124
|
+
return channelId.replace(/[^A-Za-z0-9_-]/g, "_") || "_";
|
|
1125
|
+
}
|
|
1126
|
+
function endpointHost(endpoint) {
|
|
1127
|
+
try {
|
|
1128
|
+
return new URL(endpoint).host;
|
|
1129
|
+
} catch {
|
|
1130
|
+
return "(unparseable endpoint)";
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
function realServiceDeps() {
|
|
1134
|
+
return {
|
|
1135
|
+
platform: process.platform,
|
|
1136
|
+
run: (cmd, args) => {
|
|
1137
|
+
const r = spawnSync(cmd, args, { encoding: "utf8" });
|
|
1138
|
+
return { status: r.status ?? 1, stdout: r.stdout ?? "", stderr: r.stderr ?? "" };
|
|
1139
|
+
},
|
|
1140
|
+
writeFile: (p, data) => writeFileSync2(p, data, { mode: 420 }),
|
|
1141
|
+
removeFile: (p) => rmSync(p, { force: true }),
|
|
1142
|
+
mkdirp: (p) => mkdirSync2(p, { recursive: true }),
|
|
1143
|
+
fileExists: (p) => existsSync3(p),
|
|
1144
|
+
canRead: (p) => {
|
|
1145
|
+
try {
|
|
1146
|
+
accessSync(p, constants.R_OK);
|
|
1147
|
+
return true;
|
|
1148
|
+
} catch {
|
|
1149
|
+
return false;
|
|
1150
|
+
}
|
|
1151
|
+
},
|
|
1152
|
+
user: userInfo().username
|
|
1153
|
+
};
|
|
1154
|
+
}
|
|
1155
|
+
function realResolveNodeDeps() {
|
|
1156
|
+
return {
|
|
1157
|
+
realpath: (p) => realpathSync(p),
|
|
1158
|
+
fileExists: (p) => existsSync3(p),
|
|
1159
|
+
nodeMajorOf: (p) => {
|
|
1160
|
+
const r = spawnSync(p, ["--version"], { encoding: "utf8" });
|
|
1161
|
+
if (r.status !== 0) return null;
|
|
1162
|
+
const m = /v?(\d+)\./.exec((r.stdout ?? "").trim());
|
|
1163
|
+
return m ? Number(m[1]) : null;
|
|
1164
|
+
},
|
|
1165
|
+
arch: () => process.arch,
|
|
1166
|
+
archOf: (p) => {
|
|
1167
|
+
const r = spawnSync(p, ["-p", "process.arch"], { encoding: "utf8" });
|
|
1168
|
+
return r.status === 0 ? (r.stdout ?? "").trim() : null;
|
|
1169
|
+
}
|
|
1170
|
+
};
|
|
1171
|
+
}
|
|
1172
|
+
function runServiceVerb(verb) {
|
|
1173
|
+
const flags = parseServiceFlags(process.argv, process.env);
|
|
1174
|
+
if (!flags.identityId) {
|
|
1175
|
+
console.error(`securecoms-agent: ${MISSING_IDENTITY_MESSAGE}`);
|
|
1176
|
+
process.exit(2);
|
|
1177
|
+
}
|
|
1178
|
+
const home = homedir3();
|
|
1179
|
+
try {
|
|
1180
|
+
const execPath = chooseVerbExecPath(
|
|
1181
|
+
verb,
|
|
1182
|
+
flags.execPath,
|
|
1183
|
+
() => chooseInstallExecPath(process.execPath, flags.execPath, realResolveNodeDeps(), home)
|
|
1184
|
+
);
|
|
1185
|
+
const input = {
|
|
1186
|
+
...flags,
|
|
1187
|
+
identityId: flags.identityId,
|
|
1188
|
+
execPath,
|
|
1189
|
+
binScript: fileURLToPath2(import.meta.url),
|
|
1190
|
+
home,
|
|
1191
|
+
uid: userInfo().uid
|
|
1192
|
+
};
|
|
1193
|
+
if (verb === "install" && process.argv.includes("--dry-run")) {
|
|
1194
|
+
const plan = prepareInstall(input, process.platform, realServiceDeps());
|
|
1195
|
+
console.log(`would install ${plan.label}`);
|
|
1196
|
+
console.log(`exec path: ${plan.execPath}`);
|
|
1197
|
+
console.log(`service file: ${plan.filePath}`);
|
|
1198
|
+
console.log(`logs: ${plan.logHint}`);
|
|
1199
|
+
console.log(
|
|
1200
|
+
plan.keyFile ? `key file: ${plan.keyFile}` : "key file: (none \u2014 set SECURECOMS_LLM_API_KEY_FILE or pass --llm-key-file)"
|
|
1201
|
+
);
|
|
1202
|
+
console.log("--- rendered service definition (not written) ---");
|
|
1203
|
+
console.log(plan.contents);
|
|
1204
|
+
process.exit(0);
|
|
1205
|
+
}
|
|
1206
|
+
if (verb === "install") {
|
|
1207
|
+
const r = installService(input, realServiceDeps());
|
|
1208
|
+
console.log(`installed ${r.label}`);
|
|
1209
|
+
console.log(`exec path: ${input.execPath}`);
|
|
1210
|
+
console.log(`service file: ${r.filePath}`);
|
|
1211
|
+
console.log(`logs: ${r.logHint}`);
|
|
1212
|
+
console.log(
|
|
1213
|
+
r.keyFile ? `key file: ${r.keyFile}` : "key file: (none \u2014 set SECURECOMS_LLM_API_KEY_FILE or pass --llm-key-file)"
|
|
1214
|
+
);
|
|
1215
|
+
} else {
|
|
1216
|
+
const r = uninstallService(input, realServiceDeps());
|
|
1217
|
+
console.log(r.removed ? `removed ${r.filePath}` : "no service installed for this identity");
|
|
1218
|
+
}
|
|
1219
|
+
} catch (e) {
|
|
1220
|
+
const raw = e instanceof ServiceUnsupportedError;
|
|
1221
|
+
console.error(
|
|
1222
|
+
raw ? e.message : `securecoms-agent: ${verb} failed: ${e instanceof Error ? e.message : String(e)}`
|
|
1223
|
+
);
|
|
1224
|
+
process.exit(1);
|
|
1225
|
+
}
|
|
1226
|
+
process.exit(0);
|
|
1227
|
+
}
|
|
1228
|
+
async function main() {
|
|
1229
|
+
const verb = parseVerb(process.argv);
|
|
1230
|
+
if (verb) runServiceVerb(verb);
|
|
1231
|
+
let cfg;
|
|
1232
|
+
try {
|
|
1233
|
+
cfg = loadConfig(process.argv.slice(2), process.env);
|
|
1234
|
+
} catch (e) {
|
|
1235
|
+
console.error(`securecoms-agent: ${e instanceof Error ? e.message : String(e)}`);
|
|
1236
|
+
process.exit(2);
|
|
1237
|
+
return;
|
|
1238
|
+
}
|
|
1239
|
+
const log = makeLogger(cfg);
|
|
1240
|
+
let apiKey;
|
|
1241
|
+
try {
|
|
1242
|
+
apiKey = resolveLlmApiKey(cfg, (p) => readFileSync4(p, "utf8"));
|
|
1243
|
+
} catch (e) {
|
|
1244
|
+
console.error(`securecoms-agent: ${e instanceof Error ? e.message : String(e)}`);
|
|
1245
|
+
process.exit(2);
|
|
1246
|
+
return;
|
|
1247
|
+
}
|
|
1248
|
+
const llm = new LLMDispatcher(cfg.llmEndpoint, cfg.llmModel, { apiKey });
|
|
1249
|
+
const memories = /* @__PURE__ */ new Map();
|
|
1250
|
+
const memoryFor = (channelId) => {
|
|
1251
|
+
let mem = memories.get(channelId);
|
|
1252
|
+
if (!mem) {
|
|
1253
|
+
mem = new AgentMemory(join5(cfg.dataDir, "channels", safeChannelSegment(channelId)));
|
|
1254
|
+
memories.set(channelId, mem);
|
|
1255
|
+
}
|
|
1256
|
+
return mem;
|
|
1257
|
+
};
|
|
1258
|
+
const skills = new SkillInvoker(cfg.skillsDir);
|
|
1259
|
+
skills.loadSkills();
|
|
1260
|
+
const gate = createLocalToolGate(cfg.toolGate);
|
|
1261
|
+
const plane = createSecureComsDataPlane(
|
|
1262
|
+
{ identityId: cfg.identityId, log: (level, msg) => log(level, msg) },
|
|
1263
|
+
buildHandler({ llm, memoryFor, skills, gate, log })
|
|
1264
|
+
);
|
|
1265
|
+
console.log(
|
|
1266
|
+
`securecoms-agent ${cfg.identityId.slice(0, 8)} \u2192 llm ${endpointHost(cfg.llmEndpoint)} (${cfg.llmModel}), ${skills.getToolDefinitions().length} skills, tool gate default=${cfg.toolGate.default}`
|
|
1267
|
+
);
|
|
1268
|
+
await plane.start();
|
|
1269
|
+
const shutdown = () => {
|
|
1270
|
+
log("info", "shutting down");
|
|
1271
|
+
plane.stop();
|
|
1272
|
+
process.exit(0);
|
|
1273
|
+
};
|
|
1274
|
+
process.on("SIGINT", shutdown);
|
|
1275
|
+
process.on("SIGTERM", shutdown);
|
|
1276
|
+
}
|
|
1277
|
+
void main();
|