agent-relay-server 0.4.39 → 0.6.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.
@@ -0,0 +1,256 @@
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ import type { ConnectorAction, ConnectorActionResult, ConnectorManifest, ConnectorSummary } from "./types";
5
+ import { ValidationError } from "./db";
6
+
7
+ const CONNECTOR_SCHEMA = "agent-relay.connector.v1";
8
+ const VALID_KINDS = new Set(["channel", "event", "provider", "orchestrator"]);
9
+ const VALID_ACTIONS = new Set(["install", "uninstall", "enable", "disable", "start", "stop", "restart", "status", "doctor"]);
10
+
11
+ function connectorRegistryDir(): string {
12
+ const configured = process.env.AGENT_RELAY_CONNECTORS_DIR;
13
+ if (configured) return resolveHome(configured);
14
+ return join(homedir(), ".agent-relay", "connectors");
15
+ }
16
+
17
+ function resolveHome(path: string): string {
18
+ return path.startsWith("~") ? join(homedir(), path.slice(1)) : path;
19
+ }
20
+
21
+ function connectorDir(id: string): string {
22
+ validateConnectorId(id);
23
+ return join(connectorRegistryDir(), id);
24
+ }
25
+
26
+ function readJsonFile(path: string): unknown {
27
+ return JSON.parse(readFileSync(path, "utf8"));
28
+ }
29
+
30
+ function readRecordFile(path: string): Record<string, unknown> | undefined {
31
+ if (!existsSync(path)) return undefined;
32
+ const parsed = readJsonFile(path);
33
+ return isRecord(parsed) ? parsed : undefined;
34
+ }
35
+
36
+ function isRecord(value: unknown): value is Record<string, unknown> {
37
+ return typeof value === "object" && value !== null && !Array.isArray(value);
38
+ }
39
+
40
+ function validateConnectorId(id: string): void {
41
+ if (!/^[a-z0-9][a-z0-9._-]{0,79}$/.test(id)) {
42
+ throw new ValidationError("connector id must be lowercase alphanumeric plus dot, underscore, or dash");
43
+ }
44
+ }
45
+
46
+ function normalizeConnectorManifest(value: unknown): ConnectorManifest {
47
+ if (!isRecord(value)) throw new ValidationError("connector manifest must be an object");
48
+ if (value.schema !== CONNECTOR_SCHEMA) throw new ValidationError(`connector manifest schema must be ${CONNECTOR_SCHEMA}`);
49
+ const id = stringField(value, "id");
50
+ validateConnectorId(id);
51
+ const kind = stringField(value, "kind");
52
+ if (!VALID_KINDS.has(kind)) throw new ValidationError("connector kind must be channel, event, provider, or orchestrator");
53
+ const binary = stringField(value, "binary");
54
+ const displayName = stringField(value, "displayName");
55
+ const version = stringField(value, "version");
56
+ const commands = value.commands;
57
+ if (!isRecord(commands)) throw new ValidationError("connector commands must be an object");
58
+
59
+ const normalizedCommands: ConnectorManifest["commands"] = {};
60
+ for (const [action, command] of Object.entries(commands)) {
61
+ if (!VALID_ACTIONS.has(action)) throw new ValidationError(`unsupported connector action: ${action}`);
62
+ if (!Array.isArray(command) || !command.every((part) => typeof part === "string" && part.trim())) {
63
+ throw new ValidationError(`connector command ${action} must be a non-empty string array`);
64
+ }
65
+ normalizedCommands[action as ConnectorAction] = command;
66
+ }
67
+
68
+ return {
69
+ schema: CONNECTOR_SCHEMA,
70
+ id,
71
+ kind: kind as ConnectorManifest["kind"],
72
+ packageName: optionalString(value.packageName),
73
+ binary,
74
+ displayName,
75
+ description: optionalString(value.description),
76
+ version,
77
+ capabilities: stringArray(value.capabilities, "capabilities"),
78
+ commands: normalizedCommands,
79
+ configSchema: isRecord(value.configSchema) ? value.configSchema : undefined,
80
+ };
81
+ }
82
+
83
+ function stringField(record: Record<string, unknown>, field: string): string {
84
+ const value = record[field];
85
+ if (typeof value !== "string" || !value.trim()) throw new ValidationError(`connector ${field} required`);
86
+ if (value.length > 500) throw new ValidationError(`connector ${field} is too long`);
87
+ return value;
88
+ }
89
+
90
+ function optionalString(value: unknown): string | undefined {
91
+ return typeof value === "string" && value.trim() ? value : undefined;
92
+ }
93
+
94
+ function stringArray(value: unknown, field: string): string[] {
95
+ if (value === undefined) return [];
96
+ if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) {
97
+ throw new ValidationError(`connector ${field} must be an array of strings`);
98
+ }
99
+ return [...new Set(value.map((item) => item.trim()).filter(Boolean))];
100
+ }
101
+
102
+ export function registerConnectorManifest(manifest: ConnectorManifest, opts: { config?: Record<string, unknown>; state?: Record<string, unknown> } = {}): ConnectorSummary {
103
+ const normalized = normalizeConnectorManifest(manifest);
104
+ const dir = connectorDir(normalized.id);
105
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
106
+ writeFileSync(join(dir, "manifest.json"), JSON.stringify(normalized, null, 2) + "\n", { mode: 0o600 });
107
+ if (opts.config) writeConnectorConfig(normalized.id, opts.config);
108
+ if (opts.state) writeFileSync(join(dir, "state.json"), JSON.stringify(opts.state, null, 2) + "\n", { mode: 0o600 });
109
+ return getConnector(normalized.id)!;
110
+ }
111
+
112
+ export function listConnectors(): ConnectorSummary[] {
113
+ const root = connectorRegistryDir();
114
+ if (!existsSync(root)) return [];
115
+ return readdirSync(root, { withFileTypes: true })
116
+ .filter((entry) => entry.isDirectory())
117
+ .flatMap((entry) => {
118
+ try {
119
+ const connector = getConnector(entry.name);
120
+ return connector ? [connector] : [];
121
+ } catch {
122
+ return [];
123
+ }
124
+ })
125
+ .sort((a, b) => a.displayName.localeCompare(b.displayName));
126
+ }
127
+
128
+ export function getConnector(id: string): ConnectorSummary | null {
129
+ const dir = connectorDir(id);
130
+ const manifestPath = join(dir, "manifest.json");
131
+ if (!existsSync(manifestPath)) return null;
132
+ const manifest = normalizeConnectorManifest(readJsonFile(manifestPath));
133
+ const config = readRecordFile(join(dir, "config.json"));
134
+ const state = readRecordFile(join(dir, "state.json"));
135
+ return {
136
+ id: manifest.id,
137
+ kind: manifest.kind,
138
+ displayName: manifest.displayName,
139
+ description: manifest.description,
140
+ version: manifest.version,
141
+ packageName: manifest.packageName,
142
+ binary: manifest.binary,
143
+ capabilities: manifest.capabilities,
144
+ registryPath: dir,
145
+ manifest,
146
+ config,
147
+ state,
148
+ runtime: summarizeRuntime(manifest, state),
149
+ };
150
+ }
151
+
152
+ function summarizeRuntime(manifest: ConnectorManifest, state?: Record<string, unknown>): ConnectorSummary["runtime"] {
153
+ return {
154
+ installed: true,
155
+ enabled: typeof state?.enabled === "boolean" ? state.enabled : undefined,
156
+ running: typeof state?.running === "boolean" ? state.running : undefined,
157
+ status: typeof state?.status === "string" && ["ok", "warn", "error", "unknown"].includes(state.status) ? state.status as any : "unknown",
158
+ detail: typeof state?.detail === "string" ? state.detail : undefined,
159
+ updatedAt: typeof state?.updatedAt === "string" ? state.updatedAt : undefined,
160
+ raw: state?.raw ?? (manifest.commands.status ? undefined : { detail: "status command not declared" }),
161
+ };
162
+ }
163
+
164
+ export function readConnectorConfig(id: string): Record<string, unknown> {
165
+ return readRecordFile(join(connectorDir(id), "config.json")) ?? {};
166
+ }
167
+
168
+ export function writeConnectorConfig(id: string, config: Record<string, unknown>): Record<string, unknown> {
169
+ if (!isRecord(config)) throw new ValidationError("connector config must be an object");
170
+ const path = join(connectorDir(id), "config.json");
171
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
172
+ writeFileSync(path, JSON.stringify(config, null, 2) + "\n", { mode: 0o600 });
173
+ return config;
174
+ }
175
+
176
+ export function runConnectorAction(id: string, action: ConnectorAction): ConnectorActionResult {
177
+ if (!VALID_ACTIONS.has(action)) throw new ValidationError("unsupported connector action");
178
+ const connector = getConnector(id);
179
+ if (!connector) throw new ValidationError(`connector ${id} not found`);
180
+ const command = connector.manifest.commands[action];
181
+ if (!command?.length) throw new ValidationError(`connector ${id} does not support ${action}`);
182
+
183
+ const proc = Bun.spawnSync({
184
+ cmd: command,
185
+ stdout: "pipe",
186
+ stderr: "pipe",
187
+ env: { ...process.env, AGENT_RELAY_CONNECTOR_ID: id, AGENT_RELAY_CONNECTORS_DIR: connectorRegistryDir() },
188
+ });
189
+ const stdout = new TextDecoder().decode(proc.stdout).trim();
190
+ const stderr = new TextDecoder().decode(proc.stderr).trim();
191
+ const parsed = parseJsonMaybe(stdout);
192
+ const ok = proc.success;
193
+ const result: ConnectorActionResult = {
194
+ connectorId: id,
195
+ action,
196
+ command,
197
+ ok,
198
+ exitCode: proc.exitCode,
199
+ stdout,
200
+ stderr,
201
+ parsed,
202
+ };
203
+ if ((action === "status" || action === "doctor") && ok) {
204
+ writeActionState(id, action, parsed ?? stdout);
205
+ }
206
+ return result;
207
+ }
208
+
209
+ function parseJsonMaybe(value: string): unknown {
210
+ if (!value) return undefined;
211
+ try {
212
+ return JSON.parse(value);
213
+ } catch {
214
+ return undefined;
215
+ }
216
+ }
217
+
218
+ function writeActionState(id: string, action: ConnectorAction, raw: unknown): void {
219
+ const current = readRecordFile(join(connectorDir(id), "state.json")) ?? {};
220
+ const next = {
221
+ ...current,
222
+ status: action === "doctor" ? doctorStatus(raw) : statusValue(raw),
223
+ detail: detailValue(raw),
224
+ running: runningValue(raw),
225
+ enabled: enabledValue(raw),
226
+ updatedAt: new Date().toISOString(),
227
+ raw,
228
+ };
229
+ writeFileSync(join(connectorDir(id), "state.json"), JSON.stringify(next, null, 2) + "\n", { mode: 0o600 });
230
+ }
231
+
232
+ function statusValue(raw: unknown): ConnectorSummary["runtime"]["status"] {
233
+ if (isRecord(raw) && typeof raw.status === "string" && ["ok", "warn", "error", "unknown"].includes(raw.status)) return raw.status as any;
234
+ return "unknown";
235
+ }
236
+
237
+ function doctorStatus(raw: unknown): ConnectorSummary["runtime"]["status"] {
238
+ if (!isRecord(raw) || !Array.isArray(raw.checks)) return statusValue(raw);
239
+ if (raw.checks.some((check) => isRecord(check) && check.status === "error")) return "error";
240
+ if (raw.checks.some((check) => isRecord(check) && check.status === "warn")) return "warn";
241
+ return "ok";
242
+ }
243
+
244
+ function detailValue(raw: unknown): string | undefined {
245
+ if (isRecord(raw) && typeof raw.detail === "string") return raw.detail;
246
+ if (isRecord(raw) && typeof raw.summary === "string") return raw.summary;
247
+ return undefined;
248
+ }
249
+
250
+ function runningValue(raw: unknown): boolean | undefined {
251
+ return isRecord(raw) && typeof raw.running === "boolean" ? raw.running : undefined;
252
+ }
253
+
254
+ function enabledValue(raw: unknown): boolean | undefined {
255
+ return isRecord(raw) && typeof raw.enabled === "boolean" ? raw.enabled : undefined;
256
+ }