agent-relay-server 0.3.11 → 0.4.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/README.md +237 -22
- package/bin/agent-relay-codex.ts +79 -6
- package/codex/README.md +18 -3
- package/codex/hooks/session-start.ts +2 -2
- package/codex/live-sidecar.ts +2 -0
- package/codex/plugin/.codex-plugin/plugin.json +1 -1
- package/codex/plugin/skills/agent-relay/SKILL.md +1 -0
- package/codex/relay.ts +8 -3
- package/examples/integrations/github-issue.ts +54 -0
- package/examples/integrations/ops-alert.sh +27 -0
- package/examples/integrations/prometheus-alertmanager.ts +61 -0
- package/examples/integrations/support-ticket.sh +28 -0
- package/package.json +5 -4
- package/public/dashboard.js +701 -0
- package/public/index.html +143 -504
- package/src/cli.ts +217 -0
- package/src/config.ts +38 -0
- package/src/daemon.ts +453 -0
- package/src/db.ts +442 -16
- package/src/index.ts +96 -70
- package/src/routes.ts +334 -17
- package/src/security.ts +103 -0
- package/src/setup.ts +187 -0
- package/src/sse.ts +18 -2
- package/src/types.ts +67 -1
package/src/security.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { AUTH_TOKEN, CORS_ORIGINS, getIntegrationTokens, type IntegrationTokenConfig } from "./config";
|
|
2
|
+
|
|
3
|
+
const LOOPBACK_HOSTS = new Set(["127.0.0.1", "::1", "localhost"]);
|
|
4
|
+
|
|
5
|
+
export function isLoopbackHost(hostname: string): boolean {
|
|
6
|
+
return LOOPBACK_HOSTS.has(hostname.toLowerCase());
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function assertSafeNetworkConfig(host: string): void {
|
|
10
|
+
if (authToken() || process.env.AGENT_RELAY_ALLOW_UNAUTH === "1") return;
|
|
11
|
+
if (isLoopbackHost(host)) return;
|
|
12
|
+
throw new Error(
|
|
13
|
+
`Refusing to bind unauthenticated relay on ${host}. Set AGENT_RELAY_TOKEN or AGENT_RELAY_ALLOW_UNAUTH=1.`,
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function isOriginAllowed(req: Request): boolean {
|
|
18
|
+
const origin = req.headers.get("origin");
|
|
19
|
+
if (!origin) return true;
|
|
20
|
+
const origins = corsOrigins();
|
|
21
|
+
if (origins.includes("*")) return true;
|
|
22
|
+
|
|
23
|
+
const url = new URL(req.url);
|
|
24
|
+
const sameOrigin = `${url.protocol}//${url.host}`;
|
|
25
|
+
return origin === sameOrigin || origins.includes(origin);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function applyCors(req: Request, response: Response): Response {
|
|
29
|
+
const origin = req.headers.get("origin");
|
|
30
|
+
if (!origin || !isOriginAllowed(req)) return response;
|
|
31
|
+
|
|
32
|
+
response.headers.set(
|
|
33
|
+
"Access-Control-Allow-Origin",
|
|
34
|
+
corsOrigins().includes("*") ? "*" : origin,
|
|
35
|
+
);
|
|
36
|
+
response.headers.set("Vary", "Origin");
|
|
37
|
+
return response;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function corsPreflight(req: Request): Response {
|
|
41
|
+
if (!isOriginAllowed(req)) {
|
|
42
|
+
return Response.json({ error: "origin not allowed" }, { status: 403 });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const response = new Response(null, {
|
|
46
|
+
headers: {
|
|
47
|
+
"Access-Control-Allow-Methods": "GET, POST, PATCH, DELETE, OPTIONS",
|
|
48
|
+
"Access-Control-Allow-Headers": "Content-Type, Authorization, X-Agent-Relay-Token",
|
|
49
|
+
"Access-Control-Max-Age": "600",
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
return applyCors(req, response);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function isAuthorized(req: Request): boolean {
|
|
56
|
+
const token = authToken();
|
|
57
|
+
if (!token) return true;
|
|
58
|
+
|
|
59
|
+
return extractToken(req) === token;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
type IntegrationAuth = IntegrationTokenConfig;
|
|
63
|
+
|
|
64
|
+
export function getIntegrationAuth(req: Request): IntegrationAuth | null {
|
|
65
|
+
const token = extractToken(req);
|
|
66
|
+
if (!token) return null;
|
|
67
|
+
return getIntegrationTokens().find((integration) => integration.token === token) ?? null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function hasIntegrationScope(auth: IntegrationAuth, scope: string): boolean {
|
|
71
|
+
return auth.scopes.includes(scope) || auth.scopes.includes("*");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function isIntegrationAllowed(
|
|
75
|
+
auth: IntegrationAuth,
|
|
76
|
+
opts: { target?: string; channel?: string },
|
|
77
|
+
): boolean {
|
|
78
|
+
if (auth.targets?.length && opts.target && !auth.targets.includes(opts.target)) return false;
|
|
79
|
+
if (auth.channels?.length && opts.channel && !auth.channels.includes(opts.channel)) return false;
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function unauthorized(req: Request): Response {
|
|
84
|
+
const response = Response.json({ error: "unauthorized" }, { status: 401 });
|
|
85
|
+
response.headers.set("WWW-Authenticate", "Bearer");
|
|
86
|
+
return applyCors(req, response);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function authToken(): string {
|
|
90
|
+
return process.env.AGENT_RELAY_TOKEN || AUTH_TOKEN;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function extractToken(req: Request): string | null {
|
|
94
|
+
const auth = req.headers.get("authorization") ?? "";
|
|
95
|
+
const bearer = auth.match(/^Bearer\s+(.+)$/i)?.[1];
|
|
96
|
+
return bearer ?? req.headers.get("x-agent-relay-token") ?? new URL(req.url).searchParams.get("token");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function corsOrigins(): string[] {
|
|
100
|
+
const raw = process.env.AGENT_RELAY_CORS_ORIGINS;
|
|
101
|
+
if (raw === undefined) return CORS_ORIGINS;
|
|
102
|
+
return raw.split(",").map((origin) => origin.trim()).filter(Boolean);
|
|
103
|
+
}
|
package/src/setup.ts
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { constants } from "node:fs";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { dirname, join, resolve } from "node:path";
|
|
6
|
+
|
|
7
|
+
const SETUP_MARKER = "agent-relay-managed-env";
|
|
8
|
+
export const DEFAULT_PORT = 4850;
|
|
9
|
+
export const DEFAULT_HOST = "127.0.0.1";
|
|
10
|
+
|
|
11
|
+
export type SetupOptions = {
|
|
12
|
+
envFile?: string;
|
|
13
|
+
host?: string;
|
|
14
|
+
port?: number;
|
|
15
|
+
dbPath?: string;
|
|
16
|
+
token?: string;
|
|
17
|
+
generateToken?: boolean;
|
|
18
|
+
force?: boolean;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type SetupEnvironment = {
|
|
22
|
+
platform?: NodeJS.Platform;
|
|
23
|
+
homeDir?: string;
|
|
24
|
+
xdgConfigHome?: string;
|
|
25
|
+
xdgStateHome?: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type SetupPlan = {
|
|
29
|
+
envFile: string;
|
|
30
|
+
configDir: string;
|
|
31
|
+
stateDir: string;
|
|
32
|
+
logDir: string;
|
|
33
|
+
values: Record<string, string>;
|
|
34
|
+
content: string;
|
|
35
|
+
warnings: string[];
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export function defaultConfigDir(env: SetupEnvironment = {}): string {
|
|
39
|
+
const home = env.homeDir ?? homedir();
|
|
40
|
+
const platform = env.platform ?? process.platform;
|
|
41
|
+
if (platform === "darwin") return join(home, "Library", "Application Support", "agent-relay");
|
|
42
|
+
return join(env.xdgConfigHome ?? join(home, ".config"), "agent-relay");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function defaultStateDir(env: SetupEnvironment = {}): string {
|
|
46
|
+
const home = env.homeDir ?? homedir();
|
|
47
|
+
const platform = env.platform ?? process.platform;
|
|
48
|
+
if (platform === "darwin") return join(home, "Library", "Application Support", "agent-relay");
|
|
49
|
+
return join(env.xdgStateHome ?? join(home, ".local", "state"), "agent-relay");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function defaultLogDir(env: SetupEnvironment = {}): string {
|
|
53
|
+
const home = env.homeDir ?? homedir();
|
|
54
|
+
const platform = env.platform ?? process.platform;
|
|
55
|
+
if (platform === "darwin") return join(home, "Library", "Logs", "agent-relay");
|
|
56
|
+
return join(defaultStateDir(env), "logs");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function defaultEnvFile(env: SetupEnvironment = {}): string {
|
|
60
|
+
return join(defaultConfigDir(env), "env");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function generateToken(): string {
|
|
64
|
+
return randomBytes(24).toString("hex");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function createSetupPlan(
|
|
68
|
+
options: SetupOptions = {},
|
|
69
|
+
env: SetupEnvironment = {},
|
|
70
|
+
): SetupPlan {
|
|
71
|
+
const configDir = defaultConfigDir(env);
|
|
72
|
+
const stateDir = defaultStateDir(env);
|
|
73
|
+
const logDir = defaultLogDir(env);
|
|
74
|
+
const envFile = resolve(options.envFile ?? join(configDir, "env"));
|
|
75
|
+
const host = options.host ?? DEFAULT_HOST;
|
|
76
|
+
const port = normalizePort(options.port);
|
|
77
|
+
const token = options.token ?? (options.generateToken === false ? "" : generateToken());
|
|
78
|
+
const dbPath = resolve(options.dbPath ?? join(stateDir, "agent-relay.db"));
|
|
79
|
+
const warnings: string[] = [];
|
|
80
|
+
|
|
81
|
+
if (host !== DEFAULT_HOST && !token) {
|
|
82
|
+
warnings.push("Remote binds should set AGENT_RELAY_TOKEN; setup will not create an unauthenticated remote relay.");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const values: Record<string, string> = {
|
|
86
|
+
PORT: String(port),
|
|
87
|
+
HOST: host,
|
|
88
|
+
DB_PATH: dbPath,
|
|
89
|
+
RETENTION_DAYS: "30",
|
|
90
|
+
};
|
|
91
|
+
if (token) values.AGENT_RELAY_TOKEN = token;
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
envFile,
|
|
95
|
+
configDir: dirname(envFile),
|
|
96
|
+
stateDir,
|
|
97
|
+
logDir,
|
|
98
|
+
values,
|
|
99
|
+
content: renderEnvFile(values),
|
|
100
|
+
warnings,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function executeSetupPlan(
|
|
105
|
+
plan: SetupPlan,
|
|
106
|
+
options: { dryRun?: boolean; force?: boolean } = {},
|
|
107
|
+
): Promise<string> {
|
|
108
|
+
if (options.dryRun) return formatSetupPlan(plan);
|
|
109
|
+
|
|
110
|
+
const existing = await readIfExists(plan.envFile);
|
|
111
|
+
if (existing && !existing.includes(SETUP_MARKER) && !options.force) {
|
|
112
|
+
throw new Error(`Refusing to overwrite unmanaged env file: ${plan.envFile}`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
await mkdir(plan.configDir, { recursive: true });
|
|
116
|
+
await mkdir(plan.stateDir, { recursive: true });
|
|
117
|
+
await mkdir(plan.logDir, { recursive: true });
|
|
118
|
+
await writeFile(plan.envFile, plan.content, "utf-8");
|
|
119
|
+
|
|
120
|
+
return [
|
|
121
|
+
`Wrote ${plan.envFile}`,
|
|
122
|
+
`State: ${plan.stateDir}`,
|
|
123
|
+
`Logs: ${plan.logDir}`,
|
|
124
|
+
`URL: http://${plan.values.HOST}:${plan.values.PORT}`,
|
|
125
|
+
].join("\n");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function formatSetupPlan(plan: SetupPlan): string {
|
|
129
|
+
const lines = [
|
|
130
|
+
"Agent Relay setup plan",
|
|
131
|
+
`Env file: ${plan.envFile}`,
|
|
132
|
+
`State: ${plan.stateDir}`,
|
|
133
|
+
`Logs: ${plan.logDir}`,
|
|
134
|
+
`URL: http://${plan.values.HOST}:${plan.values.PORT}`,
|
|
135
|
+
];
|
|
136
|
+
if (plan.warnings.length > 0) {
|
|
137
|
+
lines.push("", "Warnings:");
|
|
138
|
+
for (const warning of plan.warnings) lines.push(`- ${warning}`);
|
|
139
|
+
}
|
|
140
|
+
lines.push("", `Would write ${plan.envFile}:`, redactEnv(plan.content).trimEnd());
|
|
141
|
+
return lines.join("\n");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function renderEnvFile(values: Record<string, string>): string {
|
|
145
|
+
const lines = [
|
|
146
|
+
`# ${SETUP_MARKER}`,
|
|
147
|
+
"# Managed by `agent-relay setup`. Edit carefully; daemon services source this file.",
|
|
148
|
+
];
|
|
149
|
+
for (const key of Object.keys(values).sort()) {
|
|
150
|
+
lines.push(`${key}=${shellQuote(values[key]!)}`);
|
|
151
|
+
}
|
|
152
|
+
lines.push("");
|
|
153
|
+
return lines.join("\n");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export async function pathExists(path: string): Promise<boolean> {
|
|
157
|
+
try {
|
|
158
|
+
await access(path, constants.F_OK);
|
|
159
|
+
return true;
|
|
160
|
+
} catch {
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function readIfExists(path: string): Promise<string | undefined> {
|
|
166
|
+
try {
|
|
167
|
+
return await readFile(path, "utf-8");
|
|
168
|
+
} catch {
|
|
169
|
+
return undefined;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function normalizePort(port: number | undefined): number {
|
|
174
|
+
const value = port ?? DEFAULT_PORT;
|
|
175
|
+
if (!Number.isInteger(value) || value < 1 || value > 65535) {
|
|
176
|
+
throw new Error("--port must be an integer from 1 to 65535");
|
|
177
|
+
}
|
|
178
|
+
return value;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function shellQuote(value: string): string {
|
|
182
|
+
return `'${value.replace(/'/g, "'\\''")}'`;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function redactEnv(content: string): string {
|
|
186
|
+
return content.replace(/^(AGENT_RELAY_TOKEN=).+$/m, "$1'<generated-token>'");
|
|
187
|
+
}
|
package/src/sse.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { getAgent } from "./db";
|
|
2
|
-
import type { Message } from "./types";
|
|
2
|
+
import type { Message, Task } from "./types";
|
|
3
3
|
|
|
4
4
|
interface Connection {
|
|
5
5
|
id: string;
|
|
@@ -38,7 +38,6 @@ export function createSSEStream(agentId: string | null): Response {
|
|
|
38
38
|
"Content-Type": "text/event-stream",
|
|
39
39
|
"Cache-Control": "no-cache",
|
|
40
40
|
"Connection": "keep-alive",
|
|
41
|
-
"Access-Control-Allow-Origin": "*",
|
|
42
41
|
},
|
|
43
42
|
});
|
|
44
43
|
}
|
|
@@ -110,6 +109,23 @@ export function emitMessageDeleted(messageId: number) {
|
|
|
110
109
|
}
|
|
111
110
|
}
|
|
112
111
|
|
|
112
|
+
export function emitTaskChanged(task: Task, eventType = "task.updated") {
|
|
113
|
+
for (const conn of connections.values()) {
|
|
114
|
+
if (conn.agentId && !targetMatchesAgent(task.target, conn.agentId)) continue;
|
|
115
|
+
send(conn, eventType, task);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
113
119
|
export function getConnectionCount(): number {
|
|
114
120
|
return connections.size;
|
|
115
121
|
}
|
|
122
|
+
|
|
123
|
+
function targetMatchesAgent(target: string, agentId: string): boolean {
|
|
124
|
+
const agent = getAgent(agentId);
|
|
125
|
+
if (!agent) return false;
|
|
126
|
+
if (target === agentId || target === "broadcast") return true;
|
|
127
|
+
if (target.startsWith("tag:") && agent.tags.includes(target.slice(4))) return true;
|
|
128
|
+
if (target.startsWith("cap:") && agent.capabilities.includes(target.slice(4))) return true;
|
|
129
|
+
if (target.startsWith("label:") && agent.label === target.slice(6)) return true;
|
|
130
|
+
return false;
|
|
131
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -57,7 +57,7 @@ export interface PollQuery {
|
|
|
57
57
|
export interface RegisterAgentInput {
|
|
58
58
|
id: string;
|
|
59
59
|
name: string;
|
|
60
|
-
label?: string;
|
|
60
|
+
label?: string | null;
|
|
61
61
|
tags?: string[];
|
|
62
62
|
machine?: string;
|
|
63
63
|
rig?: string;
|
|
@@ -66,3 +66,69 @@ export interface RegisterAgentInput {
|
|
|
66
66
|
status?: AgentCard["status"];
|
|
67
67
|
meta?: Record<string, unknown>;
|
|
68
68
|
}
|
|
69
|
+
|
|
70
|
+
export type TaskSeverity = "info" | "warning" | "critical";
|
|
71
|
+
export type TaskStatus =
|
|
72
|
+
| "open"
|
|
73
|
+
| "claimed"
|
|
74
|
+
| "in_progress"
|
|
75
|
+
| "blocked"
|
|
76
|
+
| "done"
|
|
77
|
+
| "failed"
|
|
78
|
+
| "canceled";
|
|
79
|
+
|
|
80
|
+
export interface Task {
|
|
81
|
+
id: number;
|
|
82
|
+
source: string;
|
|
83
|
+
title: string;
|
|
84
|
+
body: string;
|
|
85
|
+
severity: TaskSeverity;
|
|
86
|
+
status: TaskStatus;
|
|
87
|
+
target: string;
|
|
88
|
+
channel?: string;
|
|
89
|
+
dedupeKey?: string;
|
|
90
|
+
externalUrl?: string;
|
|
91
|
+
occurrenceCount: number;
|
|
92
|
+
claimedBy?: string;
|
|
93
|
+
claimedAt?: number;
|
|
94
|
+
messageId?: number;
|
|
95
|
+
result?: string;
|
|
96
|
+
metadata: Record<string, unknown>;
|
|
97
|
+
createdAt: number;
|
|
98
|
+
updatedAt: number;
|
|
99
|
+
lastSeenAt: number;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface TaskEvent {
|
|
103
|
+
id: number;
|
|
104
|
+
taskId: number;
|
|
105
|
+
source: string;
|
|
106
|
+
type: string;
|
|
107
|
+
severity: TaskSeverity;
|
|
108
|
+
title: string;
|
|
109
|
+
body: string;
|
|
110
|
+
metadata: Record<string, unknown>;
|
|
111
|
+
createdAt: number;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface IntegrationEventInput {
|
|
115
|
+
source?: string;
|
|
116
|
+
type?: string;
|
|
117
|
+
severity?: TaskSeverity;
|
|
118
|
+
status?: TaskStatus | "resolved";
|
|
119
|
+
title: string;
|
|
120
|
+
body: string;
|
|
121
|
+
target: string;
|
|
122
|
+
channel?: string;
|
|
123
|
+
dedupeKey?: string;
|
|
124
|
+
externalUrl?: string;
|
|
125
|
+
metadata?: Record<string, unknown>;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface TaskStatusInput {
|
|
129
|
+
status: TaskStatus;
|
|
130
|
+
agentId?: string;
|
|
131
|
+
result?: string;
|
|
132
|
+
body?: string;
|
|
133
|
+
metadata?: Record<string, unknown>;
|
|
134
|
+
}
|