agent-relay-server 0.63.0 → 0.63.1

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/docs/openapi.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Agent Relay API",
5
- "version": "0.63.0",
5
+ "version": "0.63.1",
6
6
  "description": "Real-time message bus for inter-agent communication. Agent-first: this spec is designed for machine consumption — agents can self-discover the full API surface via GET /api/spec.",
7
7
  "license": {
8
8
  "name": "MIT",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay-server",
3
- "version": "0.63.0",
3
+ "version": "0.63.1",
4
4
  "description": "Lightweight HTTP message relay for inter-agent communication across machines",
5
5
  "module": "src/index.ts",
6
6
  "type": "module",
@@ -33,7 +33,7 @@
33
33
  "CONTRIBUTING.md"
34
34
  ],
35
35
  "dependencies": {
36
- "agent-relay-sdk": "0.2.41",
36
+ "agent-relay-sdk": "0.2.42",
37
37
  "ajv": "^8.20.0"
38
38
  },
39
39
  "scripts": {
@@ -92,6 +92,8 @@
92
92
  "author": "Edin Mujkanovic",
93
93
  "devDependencies": {
94
94
  "@types/bun": "latest",
95
+ "@types/madge": "^5.0.3",
96
+ "madge": "^8.0.0",
95
97
  "typescript": "^5"
96
98
  },
97
99
  "engines": {
@@ -1,6 +1,7 @@
1
1
  import type { AgentProfile, Message } from "agent-relay-sdk";
2
2
  import { isRecord } from "agent-relay-sdk";
3
3
  import type { SessionEvent } from "./session-insights";
4
+ import { messageBodyMaxCharsFromEnv } from "./config";
4
5
 
5
6
  export type SemanticStatus = "idle" | "busy" | "offline" | "error";
6
7
  type ProviderWorkKind = "provider-turn" | "subagent";
@@ -202,7 +203,7 @@ export const DEFAULT_PROVIDER_MESSAGE_BODY_MAX_CHARS = 24_000;
202
203
  // Resolve the delivered-body cap. Deployment-dependent (the right ceiling tracks the host's
203
204
  // context budget), so it's overridable via env — set on the orchestrator/host that spawns runners.
204
205
  export function providerMessageBodyMaxChars(): number {
205
- const raw = process.env.AGENT_RELAY_MESSAGE_BODY_MAX_CHARS;
206
+ const raw = messageBodyMaxCharsFromEnv();
206
207
  if (raw !== undefined) {
207
208
  const parsed = Number.parseInt(raw.trim(), 10);
208
209
  if (Number.isFinite(parsed) && parsed > 0) return parsed;
@@ -1,11 +1,26 @@
1
1
  import { execFileSync } from "node:child_process";
2
2
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { homedir, hostname } from "node:os";
4
- import { join, resolve } from "node:path";
4
+ import { dirname, join, resolve } from "node:path";
5
5
  import { DEFAULT_RELAY_URL, RELAY_TOKEN_HEADER, errMessage, stringValue } from "agent-relay-sdk";
6
6
  import type { SettingEntry, WorkspaceMetadata } from "agent-relay-sdk";
7
7
  import { sanitizeFsName } from "agent-relay-sdk/fs-name";
8
- import type { ProviderConfig } from "./adapter";
8
+
9
+ interface ProviderConfig {
10
+ command: string;
11
+ defaultArgs: string[];
12
+ env: Record<string, string>;
13
+ pluginDirs: string[];
14
+ defaultCapabilities: string[];
15
+ defaultApprovalMode: string;
16
+ defaultTags: string[];
17
+ chatCaptureMode: "final" | "full";
18
+ reasoningCapture?: boolean;
19
+ headless: {
20
+ tmuxPrefix: string;
21
+ shutdownTimeoutMs: number;
22
+ };
23
+ }
9
24
 
10
25
  interface GlobalRunnerConfig {
11
26
  relayUrl: string;
@@ -17,7 +32,7 @@ interface LoadedProviderConfig extends ProviderConfig {
17
32
  path: string;
18
33
  }
19
34
 
20
- function agentRelayHome(): string {
35
+ export function agentRelayHome(): string {
21
36
  return process.env.AGENT_RELAY_HOME || join(homedir(), ".agent-relay");
22
37
  }
23
38
 
@@ -48,12 +63,135 @@ export function loadGlobalConfig(home = agentRelayHome()): GlobalRunnerConfig {
48
63
  const path = join(home, "config.json");
49
64
  const parsed = readJson(path);
50
65
  return {
51
- relayUrl: stringValue(parsed.relayUrl) ?? process.env.AGENT_RELAY_URL ?? DEFAULT_RELAY_URL,
52
- token: stringValue(parsed.token) ?? process.env.AGENT_RELAY_TOKEN,
66
+ relayUrl: stringValue(parsed.relayUrl) ?? relayUrlFromEnv() ?? DEFAULT_RELAY_URL,
67
+ token: stringValue(parsed.token) ?? relayTokenFromEnv(),
53
68
  defaultCwd: stringValue(parsed.defaultCwd) ?? process.cwd(),
54
69
  };
55
70
  }
56
71
 
72
+ function relayUrlFromEnv(): string | undefined {
73
+ return process.env.AGENT_RELAY_URL;
74
+ }
75
+
76
+ export function relayTokenFromEnv(): string | undefined {
77
+ return process.env.AGENT_RELAY_TOKEN;
78
+ }
79
+
80
+ export function runtimeTokenProfileFromEnv(): string | undefined {
81
+ return process.env.AGENT_RELAY_TOKEN_PROFILE;
82
+ }
83
+
84
+ export function runtimeTokenJtiFromEnv(): string | undefined {
85
+ return process.env.AGENT_RELAY_TOKEN_JTI;
86
+ }
87
+
88
+ export function runtimeTokenExpiresAtFromEnv(): string | undefined {
89
+ return process.env.AGENT_RELAY_TOKEN_EXPIRES_AT;
90
+ }
91
+
92
+ export function agentProfileNameFromEnv(): string | undefined {
93
+ return process.env.AGENT_RELAY_AGENT_PROFILE;
94
+ }
95
+
96
+ export function agentProfileJsonFromEnv(): string | undefined {
97
+ return process.env.AGENT_RELAY_AGENT_PROFILE_JSON;
98
+ }
99
+
100
+ export function workspaceJsonFromEnv(): string | undefined {
101
+ return process.env.AGENT_RELAY_WORKSPACE_JSON;
102
+ }
103
+
104
+ export function tmuxSessionFromEnv(): string | undefined {
105
+ return process.env.AGENT_RELAY_TMUX_SESSION;
106
+ }
107
+
108
+ export function policyNameFromEnv(): string | undefined {
109
+ return process.env.AGENT_RELAY_POLICY;
110
+ }
111
+
112
+ export function spawnRequestIdFromEnv(): string | undefined {
113
+ return process.env.AGENT_RELAY_SPAWN_REQUEST_ID;
114
+ }
115
+
116
+ export function automationIdFromEnv(): string | undefined {
117
+ return process.env.AGENT_RELAY_AUTOMATION_ID;
118
+ }
119
+
120
+ export function automationRunIdFromEnv(): string | undefined {
121
+ return process.env.AGENT_RELAY_AUTOMATION_RUN_ID;
122
+ }
123
+
124
+ export function lifecycleFromEnv(): string | undefined {
125
+ return process.env.AGENT_RELAY_LIFECYCLE;
126
+ }
127
+
128
+ export function sessionDebugEnabled(): boolean {
129
+ return process.env.AGENT_RELAY_SESSION_DEBUG === "1";
130
+ }
131
+
132
+ export function logLevelFromEnv(): string | undefined {
133
+ return process.env.AGENT_RELAY_LOG_LEVEL;
134
+ }
135
+
136
+ export function mcpProxyEnabledFromEnv(): boolean {
137
+ return !["0", "false", "off"].includes((process.env.AGENT_RELAY_MCP_PROXY ?? "").trim().toLowerCase());
138
+ }
139
+
140
+ export function runnerInfoFileFromEnv(): string | undefined {
141
+ return process.env.AGENT_RELAY_RUNNER_INFO_FILE;
142
+ }
143
+
144
+ export function runnerOutboxDirFromEnv(): string | undefined {
145
+ return process.env.AGENT_RELAY_RUNNER_OUTBOX_DIR;
146
+ }
147
+
148
+ export function runnerOutboxDirWithInfoFallback(): string | undefined {
149
+ const dir = runnerOutboxDirFromEnv();
150
+ if (dir) return dir;
151
+ const infoFile = runnerInfoFileFromEnv();
152
+ return infoFile ? join(dirname(infoFile), "outbox") : undefined;
153
+ }
154
+
155
+ export function capsFromEnv(): string | undefined {
156
+ return process.env.AGENT_RELAY_CAPS;
157
+ }
158
+
159
+ export function tagsFromEnv(): string | undefined {
160
+ return process.env.AGENT_RELAY_TAGS;
161
+ }
162
+
163
+ export function workspaceModeFromEnv(): string | undefined {
164
+ return process.env.AGENT_RELAY_WORKSPACE_MODE;
165
+ }
166
+
167
+ export function orchestratorBaseDirFromEnv(): string | undefined {
168
+ return process.env.AGENT_RELAY_ORCHESTRATOR_BASE_DIR;
169
+ }
170
+
171
+ export function runnerLogFileFromEnv(): string | undefined {
172
+ return typeof process.env.AGENT_RELAY_LOG_FILE === "string" ? process.env.AGENT_RELAY_LOG_FILE : undefined;
173
+ }
174
+
175
+ export function orchestratorUrlFromEnv(): string | undefined {
176
+ return process.env.AGENT_RELAY_ORCHESTRATOR_URL;
177
+ }
178
+
179
+ export function contextStateDirFromEnv(): string | undefined {
180
+ return process.env.AGENT_RELAY_CONTEXT_STATE_DIR;
181
+ }
182
+
183
+ export function attachmentCacheDirFromEnv(): string | undefined {
184
+ return process.env.AGENT_RELAY_ATTACHMENT_CACHE_DIR;
185
+ }
186
+
187
+ export function providerHomeRootFromEnv(): string {
188
+ return process.env.AGENT_RELAY_PROVIDER_HOME_ROOT || join(homedir(), ".agent-relay", "provider-homes");
189
+ }
190
+
191
+ export function messageBodyMaxCharsFromEnv(): string | undefined {
192
+ return process.env.AGENT_RELAY_MESSAGE_BODY_MAX_CHARS;
193
+ }
194
+
57
195
  export function loadProviderConfig(provider: string, home = agentRelayHome()): LoadedProviderConfig {
58
196
  const path = join(providersDir(home), `${provider}.json`);
59
197
  const raw = readJson(path);
@@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
4
4
  import { dirname, join, relative, resolve } from "node:path";
5
5
  import { Readable } from "node:stream";
6
6
  import { expandTilde } from "./utils";
7
+ import { artifactsDir, maxArtifactBytesFromEnv } from "./config";
7
8
 
8
9
  interface ArtifactStorageBackend {
9
10
  readonly scheme: string;
@@ -20,14 +21,11 @@ interface ArtifactStorageBackend {
20
21
  const DEFAULT_MAX_ARTIFACT_BYTES = 50 * 1024 * 1024;
21
22
 
22
23
  function artifactRoot(): string {
23
- return expandTilde(process.env.AGENT_RELAY_ARTIFACTS_DIR || "~/.agent-relay/artifacts");
24
+ return expandTilde(artifactsDir());
24
25
  }
25
26
 
26
27
  export function maxArtifactBytes(): number {
27
- const raw = process.env.AGENT_RELAY_MAX_ARTIFACT_BYTES;
28
- if (!raw) return DEFAULT_MAX_ARTIFACT_BYTES;
29
- const parsed = Number(raw);
30
- return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : DEFAULT_MAX_ARTIFACT_BYTES;
28
+ return maxArtifactBytesFromEnv(DEFAULT_MAX_ARTIFACT_BYTES);
31
29
  }
32
30
 
33
31
  export function normalizeDigest(digest: string): string {
package/src/bus.ts CHANGED
@@ -15,6 +15,7 @@ import { applyCommandToRecipe } from "./recipe-runner";
15
15
  import { enqueueContinuationInjectionAfterClear } from "./self-resume";
16
16
  import { messageMatchesAgent, targetMatchesAgent } from "./agent-ref";
17
17
  import { probeWorkspaceOnTurnEnd } from "./workspace-probe";
18
+ import { busStaleGraceMs } from "./config";
18
19
  import {
19
20
  BusProtocolError,
20
21
  parseBusFrame,
@@ -107,11 +108,9 @@ export function getBusConnectionCount(): number {
107
108
  return busConnections.size;
108
109
  }
109
110
 
110
- export function expireStaleBusAgents(graceMs = Number(process.env.AGENT_RELAY_STALE_GRACE_MS) || 120_000): { agentIds: string[]; orphanedTasks: Task[] } {
111
+ export function expireStaleBusAgents(graceMs = busStaleGraceMs()): { agentIds: string[]; orphanedTasks: Task[] } {
111
112
  const cutoff = Date.now() - graceMs;
112
- const rows = getDb()
113
- .query("SELECT id FROM agents WHERE status = 'stale' AND last_seen < ? AND id NOT IN ('user', 'system')")
114
- .all(cutoff) as Array<{ id: string }>;
113
+ const rows = getDb().query("SELECT id FROM agents WHERE status = 'stale' AND last_seen < ? AND id NOT IN ('user', 'system')").all(cutoff) as Array<{ id: string }>;
115
114
  const orphanedTasks: Task[] = [];
116
115
  for (const row of rows) {
117
116
  // Bus-grace staleness across a relay restart/reconnect — the process may be alive.
@@ -5,11 +5,12 @@
5
5
  import { createInterface } from "node:readline/promises";
6
6
  import { stdin as input, stdout as output } from "node:process";
7
7
  import { RELAY_TOKEN_HEADER } from "agent-relay-sdk";
8
+ import { cliRelayToken, cliRelayUrl } from "../config";
8
9
 
9
10
  export async function apiRequest(method: string, path: string, body?: unknown): Promise<unknown> {
10
- const baseUrl = process.env.AGENT_RELAY_URL || "http://127.0.0.1:4850";
11
+ const baseUrl = cliRelayUrl();
11
12
  const headers: Record<string, string> = {};
12
- const token = process.env.AGENT_RELAY_TOKEN;
13
+ const token = cliRelayToken();
13
14
  if (token) headers[RELAY_TOKEN_HEADER] = token;
14
15
  if (body !== undefined) headers["Content-Type"] = "application/json";
15
16
  const response = await fetch(new URL(path, baseUrl), {
@@ -27,9 +28,9 @@ export async function apiRequest(method: string, path: string, body?: unknown):
27
28
  }
28
29
 
29
30
  export async function apiRawRequest(method: string, path: string, body: BodyInit, extraHeaders: Record<string, string> = {}): Promise<unknown> {
30
- const baseUrl = process.env.AGENT_RELAY_URL || "http://127.0.0.1:4850";
31
+ const baseUrl = cliRelayUrl();
31
32
  const headers: Record<string, string> = { ...extraHeaders };
32
- const token = process.env.AGENT_RELAY_TOKEN;
33
+ const token = cliRelayToken();
33
34
  if (token) headers[RELAY_TOKEN_HEADER] = token;
34
35
  const response = await fetch(new URL(path, baseUrl), { method, headers, body });
35
36
  const text = await response.text();
@@ -5,6 +5,7 @@
5
5
  import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
6
6
  import { join, resolve } from "node:path";
7
7
  import { apiRequest, uniqueStrings } from "./_shared";
8
+ import { agentIdFromEnv, codexStatePathFromEnv, contextPathFromEnv } from "../config";
8
9
 
9
10
  export async function detectActivePairId(agentId: string): Promise<string | undefined> {
10
11
  const pairs = await apiRequest("GET", `/api/pairs?agent=${encodeURIComponent(agentId)}&status=active`) as Array<{ id?: string }>;
@@ -12,15 +13,16 @@ export async function detectActivePairId(agentId: string): Promise<string | unde
12
13
  }
13
14
 
14
15
  export async function detectAgentId(): Promise<string | undefined> {
15
- const explicit = process.env.AGENT_RELAY_ID;
16
+ const explicit = agentIdFromEnv();
16
17
  if (explicit) return explicit;
17
18
 
18
19
  const contextMatch = currentAgentContextId();
19
20
  if (contextMatch) return contextMatch;
20
21
 
21
22
  const cwd = process.cwd();
22
- const explicitCodexState = process.env.AGENT_RELAY_CODEX_STATE_PATH
23
- ? readCodexState(process.env.AGENT_RELAY_CODEX_STATE_PATH)
23
+ const explicitCodexStatePath = codexStatePathFromEnv();
24
+ const explicitCodexState = explicitCodexStatePath
25
+ ? readCodexState(explicitCodexStatePath)
24
26
  : null;
25
27
  if (explicitCodexState?.agentId) return explicitCodexState.agentId;
26
28
 
@@ -48,7 +50,7 @@ export async function detectAgentId(): Promise<string | undefined> {
48
50
  }
49
51
 
50
52
  function currentAgentContextId(): string | undefined {
51
- const explicitPath = process.env.AGENT_RELAY_CONTEXT_PATH;
53
+ const explicitPath = contextPathFromEnv();
52
54
  if (explicitPath) {
53
55
  const explicit = readAgentContext(explicitPath);
54
56
  if (explicit?.agentId && contextMatchesCurrentProcess(explicit)) return explicit.agentId;
@@ -2,6 +2,7 @@
2
2
  // end-of-session self-view as a bounded, structured Insights observation (#185).
3
3
  import { apiRequest, readStdin } from "./_shared";
4
4
  import { detectAgentId } from "./agent-detect";
5
+ import { projectFromEnv, providerSessionIdFromEnv } from "../config";
5
6
 
6
7
  // Insights #185: capture the agent's end-of-session self-view as a bounded, structured
7
8
  // artifact (epic #183, docs/self-improvement.md). Manual rail in v0 — the agent or
@@ -61,8 +62,8 @@ export async function handleIntrospectCommand(args: string[]): Promise<void> {
61
62
  if (!from) throw new Error("Could not detect current Agent Relay ID. Pass --from AGENT_ID or set AGENT_RELAY_ID.");
62
63
 
63
64
  const observation = await apiRequest("POST", "/api/insights/observations", {
64
- sessionId: sessionId || process.env.AGENT_RELAY_PROVIDER_SESSION_ID || `manual-${from}`,
65
- project: project || process.env.AGENT_RELAY_PROJECT || process.cwd().split("/").filter(Boolean).at(-1) || process.cwd(),
65
+ sessionId: sessionId || providerSessionIdFromEnv() || `manual-${from}`,
66
+ project: project || projectFromEnv() || process.cwd().split("/").filter(Boolean).at(-1) || process.cwd(),
66
67
  agentId: from,
67
68
  signal: "introspection",
68
69
  source: "agent",
package/src/cli/token.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  // Token commands — auto-split from cli.ts (#294). create/list/revoke/verify for
2
2
  // component JWTs.
3
3
  import { apiRequest, splitTagArgs } from "./_shared";
4
+ import { cliRelayToken } from "../config";
4
5
 
5
6
  export async function handleTokenCommand(args: string[]): Promise<void> {
6
7
  const action = args[0] ?? "list";
@@ -45,7 +46,7 @@ export async function handleTokenCommand(args: string[]): Promise<void> {
45
46
  return;
46
47
  }
47
48
  if (action === "verify") {
48
- const token = rest.find((arg) => !arg.startsWith("--")) ?? process.env.AGENT_RELAY_TOKEN;
49
+ const token = rest.find((arg) => !arg.startsWith("--")) ?? cliRelayToken();
49
50
  if (!token) throw new Error("Usage: agent-relay token verify TOKEN");
50
51
  const payload = decodeJwtPayload(token);
51
52
  if (!payload) throw new Error("not a component JWT");
@@ -7,13 +7,14 @@ import { describeWorkspacePhase, readyContract, type WorkspacePhaseView } from "
7
7
  import type { WorkspaceDepsRefreshResult } from "agent-relay-sdk";
8
8
  import { AUTO_MERGE_POLICIES } from "../workspace-merge";
9
9
  import type { WorkspaceAutoMergePolicy } from "../types";
10
+ import { workspaceJsonFromEnv } from "../config";
10
11
 
11
12
  export const WORKSPACE_USAGE = "Usage: agent-relay workspace <status|diagnostics|ready|land|claim|release|list|cleanup-stale|deps> [--id ID] [--strategy ...] [--auto-merge on-green|on-approval|manual] [--reviewer HANDLE] [--purpose TEXT] [--repo PATH] [--wait] [--timeout SECONDS] [--check] [--execute] [--json]";
12
13
 
13
14
  // The agent's own isolated-workspace id, published in AGENT_RELAY_WORKSPACE_JSON
14
15
  // by the orchestrator at spawn. Undefined for shared-workspace / non-managed agents.
15
16
  function currentWorkspaceId(): string | undefined {
16
- const json = process.env.AGENT_RELAY_WORKSPACE_JSON;
17
+ const json = workspaceJsonFromEnv();
17
18
  if (!json) return undefined;
18
19
  try {
19
20
  const parsed = JSON.parse(json) as { id?: string };
package/src/config.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  // Shared runtime constants. Import from here rather than redefining.
2
2
 
3
3
  import { readFileSync } from "node:fs";
4
+ import { homedir } from "node:os";
4
5
  import { join, dirname } from "node:path";
5
6
  import { fileURLToPath } from "node:url";
7
+ import { DEFAULT_RELAY_URL, RELAY_TOKEN_HEADER } from "agent-relay-sdk";
6
8
  import { CONTRACT_VERSIONS } from "./contracts";
7
9
 
8
10
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -19,6 +21,31 @@ function envPositiveInt(name: string, fallback: number): number {
19
21
  return Math.floor(parsed);
20
22
  }
21
23
 
24
+ function envNumberOrDefault(name: string, fallback: number): number {
25
+ return Number(process.env[name]) || fallback;
26
+ }
27
+
28
+ function envNonNegativeNumber(name: string, fallback: number): number {
29
+ const parsed = Number(process.env[name]);
30
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
31
+ }
32
+
33
+ function envSafePositiveInteger(name: string, fallback: number): number {
34
+ const raw = process.env[name];
35
+ if (!raw) return fallback;
36
+ const parsed = Number(raw);
37
+ return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
38
+ }
39
+
40
+ function envTrimmed(name: string): string {
41
+ return (process.env[name] || "").trim();
42
+ }
43
+
44
+ function envTrimmedOrUndefined(name: string): string | undefined {
45
+ const value = process.env[name]?.trim();
46
+ return value || undefined;
47
+ }
48
+
22
49
  export const STALE_TTL_MS = envPositiveInt("STALE_TTL_MS", 120_000); // 2min without heartbeat → offline
23
50
  // #451 — default TTL for fire-and-forget FAN-OUT messages (broadcast/tag/cap/label/team) that the
24
51
  // sender didn't bound. A stale fan-out push must not wake an agent that matches the target hours
@@ -42,12 +69,160 @@ export const MAIN_MERGE_PAUSE_TTL_MS = envPositiveInt("AGENT_RELAY_MAIN_MERGE_PA
42
69
  export const MAX_BODY_BYTES = 64 * 1024;
43
70
  export const INTEGRATION_RATE_LIMIT_PER_MINUTE = envPositiveInt("AGENT_RELAY_INTEGRATION_RATE_LIMIT_PER_MINUTE", 120);
44
71
 
45
- export const AUTH_TOKEN = process.env.AGENT_RELAY_TOKEN || "";
72
+ export const AUTOMATION_INTERVAL_MS = envNumberOrDefault("AGENT_RELAY_AUTOMATION_INTERVAL_MS", 30 * 1000);
73
+ export const IDLE_TIMEOUT_SECONDS = envNumberOrDefault("AGENT_RELAY_IDLE_TIMEOUT_SECONDS", 255);
74
+ export const LOG_REQUESTS = process.env.AGENT_RELAY_LOG_REQUESTS === "1";
75
+
76
+ const AUTH_TOKEN = process.env.AGENT_RELAY_TOKEN || "";
46
77
  export const CORS_ORIGINS = (process.env.AGENT_RELAY_CORS_ORIGINS || "")
47
78
  .split(",")
48
79
  .map((origin) => origin.trim())
49
80
  .filter(Boolean);
50
81
 
82
+ export function relayToken(): string {
83
+ return process.env.AGENT_RELAY_TOKEN ?? AUTH_TOKEN;
84
+ }
85
+
86
+ export function relayAuthHeaders(base: Record<string, string> = {}): Record<string, string> {
87
+ const token = relayToken();
88
+ return token ? { ...base, [RELAY_TOKEN_HEADER]: token } : base;
89
+ }
90
+
91
+ export function allowUnauthenticated(): boolean {
92
+ return process.env.AGENT_RELAY_ALLOW_UNAUTH === "1";
93
+ }
94
+
95
+ export function tokenSecretFromEnv(): string | undefined {
96
+ return process.env.AGENT_RELAY_TOKEN_SECRET;
97
+ }
98
+
99
+ export function corsOriginsFromEnv(): string[] | undefined {
100
+ const raw = process.env.AGENT_RELAY_CORS_ORIGINS;
101
+ if (raw === undefined) return undefined;
102
+ return raw.split(",").map((origin) => origin.trim()).filter(Boolean);
103
+ }
104
+
105
+ export function connectorsDirFromEnv(): string | undefined {
106
+ return process.env.AGENT_RELAY_CONNECTORS_DIR;
107
+ }
108
+
109
+ export function connectorActionTimeoutMs(fallback: number): number {
110
+ const raw = Number(process.env.AGENT_RELAY_CONNECTOR_ACTION_TIMEOUT_MS);
111
+ return Number.isFinite(raw) && raw > 0 ? raw : fallback;
112
+ }
113
+
114
+ export function rateLimitFallbackMs(): number {
115
+ return envNumberOrDefault("AGENT_RELAY_RATE_LIMIT_FALLBACK_MS", 60 * 60 * 1000);
116
+ }
117
+
118
+ export function artifactsDir(): string {
119
+ return process.env.AGENT_RELAY_ARTIFACTS_DIR || "~/.agent-relay/artifacts";
120
+ }
121
+
122
+ export function maxArtifactBytesFromEnv(fallback: number): number {
123
+ return envSafePositiveInteger("AGENT_RELAY_MAX_ARTIFACT_BYTES", fallback);
124
+ }
125
+
126
+ export const OUTBOX_RETENTION_MS = envNumberOrDefault("AGENT_RELAY_OUTBOX_RETENTION_MS", 60 * 60 * 1000);
127
+ export const TOKEN_RECORD_RETENTION_SECONDS = envNumberOrDefault("AGENT_RELAY_TOKEN_RECORD_RETENTION_SECONDS", 7 * 24 * 60 * 60);
128
+ export const CONFLICT_SCAN_INTERVAL_MS = envNumberOrDefault("AGENT_RELAY_CONFLICT_SCAN_INTERVAL_MS", 10 * 60 * 1000);
129
+ export const WORKSPACE_RETENTION_MS = envNumberOrDefault("AGENT_RELAY_WORKSPACE_RETENTION_MS", DAY_MS);
130
+ export const DB_MAINTENANCE_INTERVAL_MS = envNumberOrDefault("AGENT_RELAY_DB_MAINTENANCE_INTERVAL_MS", DAY_MS);
131
+ export const DB_VACUUM_EVERY = envNumberOrDefault("AGENT_RELAY_DB_VACUUM_EVERY", 7);
132
+ export const WORKSPACE_REVIEW_TTL_MS = envNumberOrDefault("AGENT_RELAY_WORKSPACE_REVIEW_TTL_MS", 3 * DAY_MS);
133
+ export const WORKSPACE_GC_INTERVAL_MS = envNumberOrDefault("AGENT_RELAY_WORKSPACE_GC_INTERVAL_MS", 60 * 60 * 1000);
134
+ export const WORKSPACE_ORPHAN_REAPER_INTERVAL_MS = envNumberOrDefault("AGENT_RELAY_WORKSPACE_ORPHAN_REAPER_INTERVAL_MS", 30 * 60 * 1000);
135
+ export const WORKSPACE_AUTO_MERGE_INTERVAL_MS = envNumberOrDefault("AGENT_RELAY_WORKSPACE_AUTO_MERGE_INTERVAL_MS", CONFLICT_SCAN_INTERVAL_MS);
136
+ export const ORPHAN_REAPER_INTERVAL_MS = envNumberOrDefault("AGENT_RELAY_ORPHAN_REAPER_INTERVAL_MS", 5 * 60 * 1000);
137
+ export const STEWARD_WAKE_COOLDOWN_MS = envNumberOrDefault("AGENT_RELAY_STEWARD_WAKE_COOLDOWN_MS", 10 * 60 * 1000);
138
+ export const UNLANDED_FLAG_COOLDOWN_MS = envNumberOrDefault("AGENT_RELAY_ORPHAN_FLAG_COOLDOWN_MS", 6 * 60 * 60 * 1000);
139
+ export const STEWARD_CLAIM_TTL_MS = envNumberOrDefault("AGENT_RELAY_WORKSPACE_CLAIM_TTL_MS", 15 * 60_000);
140
+ export const DB_SLOW_QUERY_MS = envNumberOrDefault("AGENT_RELAY_DB_SLOW_MS", 0);
141
+
142
+ export function stewardEscalationMs(): number {
143
+ return envNumberOrDefault("AGENT_RELAY_WORKSPACE_STEWARD_ESCALATION_MS", 60 * 60 * 1000);
144
+ }
145
+
146
+ export function stewardFallbackTarget(): string {
147
+ return envTrimmed("AGENT_RELAY_WORKSPACE_STEWARD_FALLBACK");
148
+ }
149
+
150
+ export function envMsOrDefault(name: string, fallback: number): number {
151
+ return envNonNegativeNumber(name, fallback);
152
+ }
153
+
154
+ export function orphanGraceMs(): number {
155
+ return envMsOrDefault("AGENT_RELAY_ORPHAN_GRACE_MS", 30 * 60 * 1000);
156
+ }
157
+
158
+ export function orphanReapCooldownMs(): number {
159
+ return envMsOrDefault("AGENT_RELAY_ORPHAN_REAP_COOLDOWN_MS", 5 * 60 * 1000);
160
+ }
161
+
162
+ export function orphanReapEnabled(): boolean {
163
+ return process.env.AGENT_RELAY_ORPHAN_REAP !== "0";
164
+ }
165
+
166
+ export function orphanWorktreeReapEnabled(): boolean {
167
+ return process.env.AGENT_RELAY_ORPHAN_WORKTREE_REAP !== "0";
168
+ }
169
+
170
+ export function workspaceAutoMergeEnabled(): boolean {
171
+ return process.env.AGENT_RELAY_WORKSPACE_AUTO_MERGE !== "0";
172
+ }
173
+
174
+ export function continuationArchiveFtsEnabled(): boolean {
175
+ return process.env.AGENT_RELAY_DISABLE_CONTINUATION_ARCHIVE_FTS !== "1";
176
+ }
177
+
178
+ export function busStaleGraceMs(): number {
179
+ return envNumberOrDefault("AGENT_RELAY_STALE_GRACE_MS", 120_000);
180
+ }
181
+
182
+ export function defaultIssueRepo(): string | undefined {
183
+ return process.env.AGENT_RELAY_DEFAULT_ISSUE_REPO || process.env.GITHUB_REPOSITORY || undefined;
184
+ }
185
+
186
+ export function cliRelayUrl(): string {
187
+ return process.env.AGENT_RELAY_URL || DEFAULT_RELAY_URL;
188
+ }
189
+
190
+ export function cliRelayToken(): string | undefined {
191
+ return process.env.AGENT_RELAY_TOKEN;
192
+ }
193
+
194
+ export function workspaceJsonFromEnv(): string | undefined {
195
+ return process.env.AGENT_RELAY_WORKSPACE_JSON;
196
+ }
197
+
198
+ export function providerSessionIdFromEnv(): string | undefined {
199
+ return process.env.AGENT_RELAY_PROVIDER_SESSION_ID;
200
+ }
201
+
202
+ export function projectFromEnv(): string | undefined {
203
+ return process.env.AGENT_RELAY_PROJECT;
204
+ }
205
+
206
+ export function agentIdFromEnv(): string | undefined {
207
+ return process.env.AGENT_RELAY_ID;
208
+ }
209
+
210
+ export function codexStatePathFromEnv(): string | undefined {
211
+ return process.env.AGENT_RELAY_CODEX_STATE_PATH;
212
+ }
213
+
214
+ export function contextPathFromEnv(): string | undefined {
215
+ return process.env.AGENT_RELAY_CONTEXT_PATH;
216
+ }
217
+
218
+ export function localOrchestratorIdFromEnv(): string | undefined {
219
+ return envTrimmedOrUndefined("AGENT_RELAY_ORCHESTRATOR_ID");
220
+ }
221
+
222
+ export function providerConfigHomeFromEnv(): string {
223
+ return process.env.AGENT_RELAY_HOME || join(process.env.HOME || ".", ".agent-relay");
224
+ }
225
+
51
226
  export type IntegrationTokenConfig = {
52
227
  name: string;
53
228
  token: string;
package/src/connectors.ts CHANGED
@@ -5,6 +5,7 @@ import type { ConnectorAction, ConnectorActionResult, ConnectorManifest, Connect
5
5
  import { ValidationError } from "./db";
6
6
  import { isRecord } from "agent-relay-sdk";
7
7
  import { registerConnectorConfigSetting, validateConnectorConfig } from "./settings/modules";
8
+ import { connectorActionTimeoutMs as configuredConnectorActionTimeoutMs, connectorsDirFromEnv } from "./config";
8
9
 
9
10
  const CONNECTOR_SCHEMA = "agent-relay.connector.v1";
10
11
  const VALID_KINDS = new Set(["channel", "event", "provider", "orchestrator"]);
@@ -12,7 +13,7 @@ const VALID_ACTIONS = new Set(["install", "uninstall", "enable", "disable", "sta
12
13
  const DEFAULT_ACTION_TIMEOUT_MS = 30_000;
13
14
 
14
15
  function connectorRegistryDir(): string {
15
- const configured = process.env.AGENT_RELAY_CONNECTORS_DIR;
16
+ const configured = connectorsDirFromEnv();
16
17
  if (configured) return resolveHome(configured);
17
18
  return join(homedir(), ".agent-relay", "connectors");
18
19
  }
@@ -177,8 +178,7 @@ export function writeConnectorConfig(id: string, config: Record<string, unknown>
177
178
  }
178
179
 
179
180
  function connectorActionTimeoutMs(): number {
180
- const raw = Number(process.env.AGENT_RELAY_CONNECTOR_ACTION_TIMEOUT_MS);
181
- return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_ACTION_TIMEOUT_MS;
181
+ return configuredConnectorActionTimeoutMs(DEFAULT_ACTION_TIMEOUT_MS);
182
182
  }
183
183
 
184
184
  const LIFECYCLE_ACTIONS = new Set(["install", "uninstall", "start", "stop", "restart", "enable", "disable"]);
@@ -1,4 +1,5 @@
1
1
  import { Database } from "bun:sqlite";
2
+ import { DB_SLOW_QUERY_MS } from "../config";
2
3
 
3
4
  // The shared SQLite handle. Set once by initDb() (schema.ts) and read everywhere
4
5
  // else through getDb(). Kept module-private so the only mutation path is setDb().
@@ -33,7 +34,7 @@ export function applyConnectionPragmas(conn: Database): void {
33
34
  // Slow-query log. Off unless AGENT_RELAY_DB_SLOW_MS is set (>0). Wraps the
34
35
  // execution of the heavy dynamic queries so the next slow query surfaces before
35
36
  // it wedges something (see issue #197 — the 8.6s reply-obligation query).
36
- const SLOW_QUERY_MS = Number(process.env.AGENT_RELAY_DB_SLOW_MS) || 0;
37
+ const SLOW_QUERY_MS = DB_SLOW_QUERY_MS;
37
38
  export function timedQuery<T>(label: string, run: () => T): T {
38
39
  if (SLOW_QUERY_MS <= 0) return run();
39
40
  const start = performance.now();
@@ -1,4 +1,4 @@
1
- import { MAX_BODY_BYTES } from "../config";
1
+ import { MAX_BODY_BYTES, continuationArchiveFtsEnabled } from "../config";
2
2
  import { getContinuationEnvelope } from "./continuations.ts";
3
3
  import { getDb, ValidationError } from "./connection.ts";
4
4
 
@@ -28,7 +28,7 @@ interface ContinuationArchiveRow {
28
28
  const MAX_RECALL_LIMIT = 20;
29
29
 
30
30
  export function ensureContinuationArchiveSearchSchema(): void {
31
- if (process.env.AGENT_RELAY_DISABLE_CONTINUATION_ARCHIVE_FTS === "1") return;
31
+ if (!continuationArchiveFtsEnabled()) return;
32
32
  try {
33
33
  getDb().query(`
34
34
  CREATE VIRTUAL TABLE IF NOT EXISTS continuation_archive_segments
@@ -116,7 +116,7 @@ export function searchContinuationArchives(input: {
116
116
  }
117
117
 
118
118
  function continuationArchiveSearchMode(): "fts5" | "like" {
119
- if (process.env.AGENT_RELAY_DISABLE_CONTINUATION_ARCHIVE_FTS === "1") return "like";
119
+ if (!continuationArchiveFtsEnabled()) return "like";
120
120
  const row = getDb().query(`
121
121
  SELECT 1 FROM sqlite_master
122
122
  WHERE type = 'table' AND name = 'continuation_archive_segments'
package/src/index.ts CHANGED
@@ -15,7 +15,11 @@ import { startPlanLiveBindings } from "./services/plan-live-bindings";
15
15
  import { resolve, sep } from "path";
16
16
  import { gzipSync, brotliCompressSync, constants as zlibConstants } from "node:zlib";
17
17
  import {
18
+ AUTOMATION_INTERVAL_MS,
19
+ IDLE_TIMEOUT_SECONDS,
20
+ LOG_REQUESTS,
18
21
  VERSION,
22
+ relayToken,
19
23
  } from "./config";
20
24
  import { CONTRACT_VERSIONS } from "./contracts";
21
25
  import {
@@ -69,9 +73,6 @@ function startServer(): void {
69
73
  const PORT = Number(process.env.PORT) || 4850;
70
74
  const HOST = process.env.HOST || "127.0.0.1";
71
75
  const DB_PATH = process.env.DB_PATH || "agent-relay.db";
72
- const AUTOMATION_INTERVAL_MS = Number(process.env.AGENT_RELAY_AUTOMATION_INTERVAL_MS) || 30 * 1000;
73
- const IDLE_TIMEOUT_SECONDS = Number(process.env.AGENT_RELAY_IDLE_TIMEOUT_SECONDS) || 255;
74
- const LOG_REQUESTS = process.env.AGENT_RELAY_LOG_REQUESTS === "1";
75
76
 
76
77
  assertSafeNetworkConfig(HOST);
77
78
  initDb(DB_PATH);
@@ -246,9 +247,9 @@ export function createFetchHandler(
246
247
  upstream.protocol = upstream.protocol === "https:" ? "wss:" : "ws:";
247
248
  // Pass the relay token as a header (not a query param) so it never lands in
248
249
  // access/proxy logs (#149). The orchestrator accepts both, but headers are safer.
249
- const relayToken = process.env.AGENT_RELAY_TOKEN;
250
+ const token = relayToken();
250
251
  const upgraded = server.upgrade(req, {
251
- data: { kind: "terminal-proxy", upstreamUrl: upstream.toString(), upstreamToken: relayToken, pending: [] },
252
+ data: { kind: "terminal-proxy", upstreamUrl: upstream.toString(), upstreamToken: token, pending: [] },
252
253
  });
253
254
  if (!upgraded) return new Response("WebSocket upgrade failed", { status: 400 });
254
255
  return undefined;
@@ -1,4 +1,24 @@
1
- import { DAY_MS, REAP_INTERVAL_MS } from "../config";
1
+ import {
2
+ CONFLICT_SCAN_INTERVAL_MS,
3
+ DAY_MS,
4
+ DB_MAINTENANCE_INTERVAL_MS,
5
+ DB_VACUUM_EVERY,
6
+ ORPHAN_REAPER_INTERVAL_MS,
7
+ OUTBOX_RETENTION_MS,
8
+ REAP_INTERVAL_MS,
9
+ TOKEN_RECORD_RETENTION_SECONDS,
10
+ WORKSPACE_AUTO_MERGE_INTERVAL_MS,
11
+ WORKSPACE_GC_INTERVAL_MS,
12
+ WORKSPACE_ORPHAN_REAPER_INTERVAL_MS,
13
+ WORKSPACE_RETENTION_MS,
14
+ WORKSPACE_REVIEW_TTL_MS,
15
+ envMsOrDefault,
16
+ orphanReapCooldownMs,
17
+ orphanReapEnabled,
18
+ orphanGraceMs,
19
+ stewardEscalationMs,
20
+ stewardFallbackTarget,
21
+ } from "../config";
2
22
  import { READY_TO_LAND_STATUSES, TERMINAL_WORKSPACE_STATUSES } from "../workspace-phase";
3
23
  import type { WorkspaceStatus } from "../types";
4
24
 
@@ -6,30 +26,27 @@ export { DAY_MS, REAP_INTERVAL_MS, TERMINAL_WORKSPACE_STATUSES };
6
26
 
7
27
  export const DEFAULT_TIMEOUT_MS = 30_000;
8
28
  export const SCHEDULER_TICK_MS = 10_000;
9
- export const OUTBOX_RETENTION_MS = Number(process.env.AGENT_RELAY_OUTBOX_RETENTION_MS) || 60 * 60 * 1000;
10
- export const TOKEN_RECORD_RETENTION_SECONDS = Number(process.env.AGENT_RELAY_TOKEN_RECORD_RETENTION_SECONDS) || 7 * 24 * 60 * 60;
11
- export const CONFLICT_SCAN_INTERVAL_MS = Number(process.env.AGENT_RELAY_CONFLICT_SCAN_INTERVAL_MS) || 10 * 60 * 1000;
12
- export const WORKSPACE_RETENTION_MS = Number(process.env.AGENT_RELAY_WORKSPACE_RETENTION_MS) || DAY_MS;
13
- export const DB_MAINTENANCE_INTERVAL_MS = Number(process.env.AGENT_RELAY_DB_MAINTENANCE_INTERVAL_MS) || DAY_MS;
14
- export const DB_VACUUM_EVERY = Number(process.env.AGENT_RELAY_DB_VACUUM_EVERY) || 7;
15
- export const WORKSPACE_REVIEW_TTL_MS = Number(process.env.AGENT_RELAY_WORKSPACE_REVIEW_TTL_MS) || 3 * DAY_MS;
16
- export const WORKSPACE_GC_INTERVAL_MS = Number(process.env.AGENT_RELAY_WORKSPACE_GC_INTERVAL_MS) || 60 * 60 * 1000;
17
- export const WORKSPACE_ORPHAN_REAPER_INTERVAL_MS = Number(process.env.AGENT_RELAY_WORKSPACE_ORPHAN_REAPER_INTERVAL_MS) || 30 * 60 * 1000;
18
- export const WORKSPACE_AUTO_MERGE_INTERVAL_MS = Number(process.env.AGENT_RELAY_WORKSPACE_AUTO_MERGE_INTERVAL_MS) || CONFLICT_SCAN_INTERVAL_MS;
19
- export const ORPHAN_REAPER_INTERVAL_MS = Number(process.env.AGENT_RELAY_ORPHAN_REAPER_INTERVAL_MS) || 5 * 60 * 1000;
20
-
21
- export const stewardEscalationMs = () => Number(process.env.AGENT_RELAY_WORKSPACE_STEWARD_ESCALATION_MS) || 60 * 60 * 1000;
22
- export const stewardFallbackTarget = () => (process.env.AGENT_RELAY_WORKSPACE_STEWARD_FALLBACK || "").trim();
23
29
  export const STRANDABLE_STATUSES = new Set<WorkspaceStatus>([...READY_TO_LAND_STATUSES, "conflict"]);
24
30
  export const CONFLICT_SCAN_STATUSES = new Set<WorkspaceStatus>(["active", "ready", "review_requested", "merge_planned", "conflict"]);
25
31
  export const LANDED_RECONCILE_STATUSES = new Set<WorkspaceStatus>(["merge_planned", "review_requested", "conflict"]);
26
32
 
27
- export const envMsOrDefault = (name: string, fallback: number): number => {
28
- const v = Number(process.env[name]);
29
- return Number.isFinite(v) && v >= 0 ? v : fallback;
30
- };
31
- export const orphanGraceMs = () => envMsOrDefault("AGENT_RELAY_ORPHAN_GRACE_MS", 30 * 60 * 1000);
32
- export const orphanReapCooldownMs = () => envMsOrDefault("AGENT_RELAY_ORPHAN_REAP_COOLDOWN_MS", 5 * 60 * 1000);
33
33
  export const teamReapGraceMs = () => envMsOrDefault("TEAM_REAP_GRACE_MS", 30 * 60 * 1000);
34
34
  export const teamReapIntervalMs = () => envMsOrDefault("TEAM_REAP_INTERVAL_MS", 5 * 60 * 1000);
35
- export const orphanReapEnabled = () => process.env.AGENT_RELAY_ORPHAN_REAP !== "0";
35
+ export {
36
+ CONFLICT_SCAN_INTERVAL_MS,
37
+ DB_MAINTENANCE_INTERVAL_MS,
38
+ DB_VACUUM_EVERY,
39
+ ORPHAN_REAPER_INTERVAL_MS,
40
+ OUTBOX_RETENTION_MS,
41
+ TOKEN_RECORD_RETENTION_SECONDS,
42
+ WORKSPACE_AUTO_MERGE_INTERVAL_MS,
43
+ WORKSPACE_GC_INTERVAL_MS,
44
+ WORKSPACE_ORPHAN_REAPER_INTERVAL_MS,
45
+ WORKSPACE_RETENTION_MS,
46
+ WORKSPACE_REVIEW_TTL_MS,
47
+ orphanGraceMs,
48
+ orphanReapCooldownMs,
49
+ orphanReapEnabled,
50
+ stewardEscalationMs,
51
+ stewardFallbackTarget,
52
+ };
@@ -3,6 +3,7 @@ import { hostname } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { ValidationError, listOrchestrators } from "./db";
5
5
  import { getConfig, listConfig, setConfig } from "./config-store";
6
+ import { providerConfigHomeFromEnv } from "./config";
6
7
  import { cleanString, cleanStringArray, optionalEnum } from "./validation";
7
8
  import { emitConfigChanged } from "./sse";
8
9
  import {
@@ -234,5 +235,5 @@ function maskEnv(env: Record<string, string>): Record<string, string> {
234
235
  }
235
236
 
236
237
  function agentRelayHome(): string {
237
- return process.env.AGENT_RELAY_HOME || join(process.env.HOME || ".", ".agent-relay");
238
+ return providerConfigHomeFromEnv();
238
239
  }
@@ -1,4 +1,5 @@
1
1
  import { isRecord } from "agent-relay-sdk";
2
+ import { rateLimitFallbackMs } from "./config";
2
3
 
3
4
  // One server-side home for reading an agent's provider runtime state (the
4
5
  // `meta.providerState` that both Claude and Codex ride for blocked/approval
@@ -48,9 +49,6 @@ export function isRateLimitHold(agent: HasMeta | null | undefined): boolean {
48
49
  // rather than waiting the full (unknown) limit window. Re-entering the hold on a
49
50
  // still-limited retry is cheap, and a subscription limit almost always carries a
50
51
  // reset time. Read at call-time so operators/tests can tune it.
51
- const rateLimitFallbackMs = (): number =>
52
- Number(process.env.AGENT_RELAY_RATE_LIMIT_FALLBACK_MS) || 60 * 60 * 1000;
53
-
54
52
  /**
55
53
  * When a rate-limit hold should be lifted. The provider's `resetAt` is
56
54
  * authoritative; otherwise fall back to `enteredAt + fallback window`. Null only
@@ -14,6 +14,7 @@ import { authContextFromRequest } from "../services/auth-context";
14
14
  import { ShutdownAuthError, ShutdownTargetError, shutdownAgent } from "../services/shutdown-agent";
15
15
  import { type AgentCard, type SpawnApprovalMode } from "../types";
16
16
  import { type ProviderEffort } from "agent-relay-sdk/provider-catalog";
17
+ import { relayToken } from "../config";
17
18
 
18
19
  const CLAUDE_RESUME_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
19
20
 
@@ -486,8 +487,8 @@ async function proxyOrchestratorDelete(req: Request, orchestratorId: string, pat
486
487
  const incoming = new URL(req.url);
487
488
  const proxyUrl = `${orch.apiUrl}${path}${incoming.search}`;
488
489
  const headers: Record<string, string> = {};
489
- const relayToken = process.env.AGENT_RELAY_TOKEN;
490
- if (relayToken) headers[RELAY_TOKEN_HEADER] = relayToken;
490
+ const token = relayToken();
491
+ if (token) headers[RELAY_TOKEN_HEADER] = token;
491
492
  try {
492
493
  const res = await fetch(proxyUrl, {
493
494
  method: "DELETE",
@@ -507,8 +508,8 @@ async function proxyOrchestratorJson(orchestratorId: string, path: string, metho
507
508
  if (!orch.apiUrl) return error("orchestrator does not expose an API", 422);
508
509
  if (orch.status !== "online") return error("orchestrator is offline", 422);
509
510
  const headers: Record<string, string> = { "Content-Type": "application/json" };
510
- const relayToken = process.env.AGENT_RELAY_TOKEN;
511
- if (relayToken) headers[RELAY_TOKEN_HEADER] = relayToken;
511
+ const token = relayToken();
512
+ if (token) headers[RELAY_TOKEN_HEADER] = token;
512
513
  try {
513
514
  const res = await fetch(`${orch.apiUrl}${path}`, {
514
515
  method,
@@ -5,6 +5,7 @@ import { authorizeRoute, cleanJsonArray, error, json, type Handler } from "./_sh
5
5
  import { cleanStringArray } from "../validation";
6
6
  import { emitOrchestratorStatus } from "../sse";
7
7
  import { type OrchestratorRuntimeInput, type SpawnProvider } from "../types";
8
+ import { relayToken } from "../config";
8
9
 
9
10
  export const getOrchestratorDirectories: Handler = async (req, params) => {
10
11
  const orch = getOrchestrator(params.id!);
@@ -63,8 +64,8 @@ async function proxyOrchestratorGet(req: Request, orchestratorId: string, path:
63
64
  const incoming = new URL(req.url);
64
65
  const proxyUrl = `${orch.apiUrl}${path}${incoming.search}`;
65
66
  const headers: Record<string, string> = {};
66
- const relayToken = process.env.AGENT_RELAY_TOKEN;
67
- if (relayToken) headers[RELAY_TOKEN_HEADER] = relayToken;
67
+ const token = relayToken();
68
+ if (token) headers[RELAY_TOKEN_HEADER] = token;
68
69
  try {
69
70
  const res = await fetch(proxyUrl, { headers, signal: AbortSignal.timeout(10_000) });
70
71
  const contentType = res.headers.get("content-type") ?? "";
@@ -88,8 +89,8 @@ async function proxyOrchestratorPost(req: Request, orchestratorId: string, path:
88
89
  const headers: Record<string, string> = {
89
90
  "Content-Type": req.headers.get("content-type") ?? "application/json",
90
91
  };
91
- const relayToken = process.env.AGENT_RELAY_TOKEN;
92
- if (relayToken) headers[RELAY_TOKEN_HEADER] = relayToken;
92
+ const token = relayToken();
93
+ if (token) headers[RELAY_TOKEN_HEADER] = token;
93
94
  try {
94
95
  const body = await req.text();
95
96
  const res = await fetch(proxyUrl, {
@@ -13,6 +13,7 @@ import { resolve } from "node:path";
13
13
  import { type WorkspaceAutoMergePolicy, type WorkspaceDiagnostics, type WorkspaceGitState, type WorkspaceMergeStrategy, type WorkspaceRecord, type WorkspaceStatus } from "../types";
14
14
  import { AUTO_MERGE_POLICIES } from "../workspace-merge";
15
15
  import { workspaceActiveClaim } from "../workspace-claim";
16
+ import { relayToken } from "../config";
16
17
 
17
18
  export const getWorkspaces: Handler = (req) => {
18
19
  try {
@@ -50,8 +51,8 @@ async function proxyWorkspaceHostGet(workspaceId: string, hostPath: string, extr
50
51
  if (workspace.baseSha) query.set("baseSha", workspace.baseSha);
51
52
  for (const [key, value] of Object.entries(extraQuery ?? {})) query.set(key, value);
52
53
  const headers: Record<string, string> = {};
53
- const relayToken = process.env.AGENT_RELAY_TOKEN;
54
- if (relayToken) headers[RELAY_TOKEN_HEADER] = relayToken;
54
+ const token = relayToken();
55
+ if (token) headers[RELAY_TOKEN_HEADER] = token;
55
56
  try {
56
57
  const res = await fetch(`${orch.apiUrl}${hostPath}?${query.toString()}`, {
57
58
  headers,
@@ -145,8 +146,8 @@ async function fetchWorkspaceGitState(workspace: WorkspaceRecord): Promise<{ sta
145
146
  if (workspace.baseRef) query.set("baseRef", workspace.baseRef);
146
147
  if (workspace.baseSha) query.set("baseSha", workspace.baseSha);
147
148
  const headers: Record<string, string> = {};
148
- const relayToken = process.env.AGENT_RELAY_TOKEN;
149
- if (relayToken) headers[RELAY_TOKEN_HEADER] = relayToken;
149
+ const token = relayToken();
150
+ if (token) headers[RELAY_TOKEN_HEADER] = token;
150
151
  try {
151
152
  const res = await fetch(`${orch.apiUrl}/api/workspace/state?${query.toString()}`, { headers, signal: AbortSignal.timeout(10_000) });
152
153
  if (!res.ok) return { unavailable: `host returned ${res.status}` };
package/src/security.ts CHANGED
@@ -1,4 +1,12 @@
1
- import { AUTH_TOKEN, CORS_ORIGINS, getIntegrationTokens, type IntegrationTokenConfig } from "./config";
1
+ import {
2
+ CORS_ORIGINS,
3
+ allowUnauthenticated,
4
+ corsOriginsFromEnv,
5
+ getIntegrationTokens,
6
+ relayToken,
7
+ tokenSecretFromEnv,
8
+ type IntegrationTokenConfig,
9
+ } from "./config";
2
10
  import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
3
11
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
4
12
  import { dirname, join, resolve } from "node:path";
@@ -14,7 +22,7 @@ export function isLoopbackHost(hostname: string): boolean {
14
22
  }
15
23
 
16
24
  export function assertSafeNetworkConfig(host: string): void {
17
- if (authToken() || process.env.AGENT_RELAY_ALLOW_UNAUTH === "1") return;
25
+ if (authToken() || allowUnauthenticated()) return;
18
26
  if (isLoopbackHost(host)) return;
19
27
  throw new Error(
20
28
  `Refusing to bind unauthenticated relay on ${host}. Set AGENT_RELAY_TOKEN or AGENT_RELAY_ALLOW_UNAUTH=1.`,
@@ -407,7 +415,7 @@ export function unauthorized(req: Request): Response {
407
415
  }
408
416
 
409
417
  function authToken(): string {
410
- return process.env.AGENT_RELAY_TOKEN ?? AUTH_TOKEN;
418
+ return relayToken();
411
419
  }
412
420
 
413
421
  function extractToken(req: Request): string | null {
@@ -417,7 +425,7 @@ function extractToken(req: Request): string | null {
417
425
  }
418
426
 
419
427
  function tokenSecret(): string {
420
- const env = process.env.AGENT_RELAY_TOKEN_SECRET;
428
+ const env = tokenSecretFromEnv();
421
429
  if (env) return env;
422
430
  const path = join(process.env.HOME || ".", ".agent-relay", "token-secret");
423
431
  if (existsSync(path)) return readFileSync(path, "utf8").trim();
@@ -542,7 +550,5 @@ function isTokenRevoked(jti: string): boolean {
542
550
  }
543
551
 
544
552
  function corsOrigins(): string[] {
545
- const raw = process.env.AGENT_RELAY_CORS_ORIGINS;
546
- if (raw === undefined) return CORS_ORIGINS;
547
- return raw.split(",").map((origin) => origin.trim()).filter(Boolean);
553
+ return corsOriginsFromEnv() ?? CORS_ORIGINS;
548
554
  }
@@ -9,6 +9,7 @@ import {
9
9
  import { getIssueRef, resolveIssueRefByExternalKey, resolveIssueRefFromString, updateIssueRef } from "../db/issue-refs";
10
10
  import { associateIssue } from "../db/issue-associations";
11
11
  import { resolveIssueTrackerAdapter } from "../issue-tracker";
12
+ import { defaultIssueRepo } from "../config";
12
13
  import type { IssueTrackerAdapter } from "../issue-tracker";
13
14
  import { recordAndEmitIssueActivity } from "./issue-activity";
14
15
  import type { IssueAssociationEntityType, IssueProvider, IssueRef, WorkspaceRecord } from "../types";
@@ -78,10 +79,6 @@ function logFailure(opts: IssueLifecycleOptions, message: string): void {
78
79
  else console.warn(message);
79
80
  }
80
81
 
81
- function defaultIssueRepo(): string | undefined {
82
- return process.env.AGENT_RELAY_DEFAULT_ISSUE_REPO || process.env.GITHUB_REPOSITORY || undefined;
83
- }
84
-
85
82
  /**
86
83
  * `owner/repo` out of any git remote URL form: `git@github.com:owner/repo.git`,
87
84
  * `https://github.com/owner/repo.git`, `ssh://git@host/owner/repo`, trailing slash/`.git`
@@ -4,6 +4,7 @@ import { associateIssue, planNodeIssueEntityId, removeAssociation } from "../db/
4
4
  import { resolveIssueRefFromString } from "../db/issue-refs";
5
5
  import { emitRelayEvent } from "../events";
6
6
  import { cleanString, cleanStringArray, optionalEnum } from "../validation";
7
+ import { defaultIssueRepo } from "../config";
7
8
  import type { AuthContext } from "./auth-context";
8
9
  import type { AgentCard, Plan, PlanEdge, PlanNode, PlanOwnerRef, PlanStatus } from "../types";
9
10
 
@@ -86,10 +87,6 @@ function cleanRefs(value: unknown): PlanNode["refs"] | undefined {
86
87
  return Object.keys(out).length ? out : undefined;
87
88
  }
88
89
 
89
- function defaultIssueRepo(): string | undefined {
90
- return process.env.AGENT_RELAY_DEFAULT_ISSUE_REPO || process.env.GITHUB_REPOSITORY || undefined;
91
- }
92
-
93
90
  function syncPlanNodeIssueAssociation(planId: string, node: PlanNode): void {
94
91
  if (!node.refs?.issueId) return;
95
92
  associateIssue(node.refs.issueId, "plan-node", planNodeIssueEntityId(planId, node.id));
package/src/upgrade.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { homedir, hostname as osHostname } from "node:os";
3
3
  import { join, resolve } from "node:path";
4
- import { VERSION } from "./config";
4
+ import { VERSION, cliRelayUrl, localOrchestratorIdFromEnv, relayAuthHeaders } from "./config";
5
5
  import type { RuntimeContracts, RuntimePackageMetadata } from "./contracts";
6
6
  import { defaultRuntimePrefix, runtimeBinPath } from "./runtime-prefix";
7
- import { errMessage, RELAY_TOKEN_HEADER } from "agent-relay-sdk";
7
+ import { errMessage } from "agent-relay-sdk";
8
8
  import { shellEscape as shellQuote } from "agent-relay-sdk/shell-utils";
9
9
 
10
10
  export type UpgradeProvider = "auto" | "all" | "codex" | "claude" | "orchestrator";
@@ -462,9 +462,8 @@ async function npmViewVersion(spec: string): Promise<string | undefined> {
462
462
 
463
463
  async function runningServerVersion(): Promise<string | undefined> {
464
464
  try {
465
- const headers: Record<string, string> = {};
466
- if (process.env.AGENT_RELAY_TOKEN) headers[RELAY_TOKEN_HEADER] = process.env.AGENT_RELAY_TOKEN;
467
- const relayUrl = (process.env.AGENT_RELAY_URL || "http://127.0.0.1:4850").replace(/\/+$/, "");
465
+ const headers = relayAuthHeaders();
466
+ const relayUrl = cliRelayUrl().replace(/\/+$/, "");
468
467
  const response = await fetch(`${relayUrl}/api/stats`, { headers });
469
468
  if (!response.ok) return undefined;
470
469
  const payload = await response.json() as { version?: string };
@@ -476,9 +475,8 @@ async function runningServerVersion(): Promise<string | undefined> {
476
475
 
477
476
  async function runningOrchestrators(): Promise<UpgradeSnapshot["runningOrchestrators"]> {
478
477
  try {
479
- const headers: Record<string, string> = {};
480
- if (process.env.AGENT_RELAY_TOKEN) headers[RELAY_TOKEN_HEADER] = process.env.AGENT_RELAY_TOKEN;
481
- const relayUrl = (process.env.AGENT_RELAY_URL || "http://127.0.0.1:4850").replace(/\/+$/, "");
478
+ const headers = relayAuthHeaders();
479
+ const relayUrl = cliRelayUrl().replace(/\/+$/, "");
482
480
  const response = await fetch(`${relayUrl}/api/orchestrators`, { headers });
483
481
  if (!response.ok) return [];
484
482
  const payload = await response.json() as Array<{
@@ -588,7 +586,7 @@ export function resolveLocalOrchestratorId(homeDirOverride?: string): string {
588
586
  // Unparsable config — fall through to env/hostname.
589
587
  }
590
588
  }
591
- const envId = process.env.AGENT_RELAY_ORCHESTRATOR_ID?.trim();
589
+ const envId = localOrchestratorIdFromEnv();
592
590
  if (envId) return envId;
593
591
  return osHostname().replace(/\./g, "-");
594
592
  }
@@ -36,11 +36,10 @@ import { workspaceActiveClaim } from "./workspace-claim";
36
36
  import { DIRTY_WORKTREE_LAND_SKIP_REASON, READY_TO_LAND_STATUSES } from "./workspace-phase";
37
37
  import { fetchHostMergePreview, workspacePathWithinBase } from "./workspace-probe";
38
38
  import type { WorkspaceMergePreview, WorkspaceRecord } from "./types";
39
+ import { STEWARD_WAKE_COOLDOWN_MS, workspaceAutoMergeEnabled } from "./config";
39
40
 
40
41
  // Don't re-wake the managed steward for the same workspace more than once per this
41
42
  // window — a persistent conflict/behind row would otherwise re-ping every sweep.
42
- const STEWARD_WAKE_COOLDOWN_MS = Number(process.env.AGENT_RELAY_STEWARD_WAKE_COOLDOWN_MS) || 10 * 60 * 1000;
43
-
44
43
  // Wake the managed per-repo steward (issue #167) for a workspace it should handle:
45
44
  // auto-provision the policy from global steward config, then queue a `policy:` wake
46
45
  // message (which also spawns the on-demand agent now via onMessageForPolicy). Honors a
@@ -176,7 +175,7 @@ const repoMergeRunning = new Map<string, { rerun: boolean }>();
176
175
 
177
176
  export function scheduleRepoMerge(repoRoot: string | undefined): void {
178
177
  if (!repoRoot) return;
179
- if (process.env.AGENT_RELAY_WORKSPACE_AUTO_MERGE === "0") return;
178
+ if (!workspaceAutoMergeEnabled()) return;
180
179
  const existing = repoMergeRunning.get(repoRoot);
181
180
  if (existing) { existing.rerun = true; return; }
182
181
  const state = { rerun: false };
@@ -200,7 +199,7 @@ export function resetRepoMergeSchedulerForTests(): void { repoMergeRunning.clear
200
199
  // Drain the landable rows for ONE repo (the event-driven fast path). Scoped to the
201
200
  // repo whose ready/settle event fired so it doesn't re-probe every other repo's worktrees.
202
201
  async function autoMergeForRepo(repoRoot: string): Promise<LandSweepResult> {
203
- if (process.env.AGENT_RELAY_WORKSPACE_AUTO_MERGE === "0") return emptyLandSweep();
202
+ if (!workspaceAutoMergeEnabled()) return emptyLandSweep();
204
203
  const orchestrators = listOrchestrators().filter((orch) => orch.status === "online" && orch.apiUrl);
205
204
  if (!orchestrators.length) return emptyLandSweep();
206
205
  const candidates = listWorkspaces({ repoRoot }).filter(
@@ -215,7 +214,7 @@ async function autoMergeForRepo(repoRoot: string): Promise<LandSweepResult> {
215
214
  // with no agent turn. Scans EVERY repo's landable rows; the per-repo lease still caps
216
215
  // it at one dispatch per repo per sweep, and the on-settle re-trigger drains the rest fast.
217
216
  export async function autoMergeCleanFastForwards(): Promise<Record<string, unknown>> {
218
- if (process.env.AGENT_RELAY_WORKSPACE_AUTO_MERGE === "0") return { skipped: "disabled" };
217
+ if (!workspaceAutoMergeEnabled()) return { skipped: "disabled" };
219
218
  const orchestrators = listOrchestrators().filter((orch) => orch.status === "online" && orch.apiUrl);
220
219
  if (!orchestrators.length) return { scanned: 0, skipped: "no online orchestrators" };
221
220
 
@@ -1,11 +1,10 @@
1
1
  import type { WorkspaceRecord } from "./types";
2
+ import { STEWARD_CLAIM_TTL_MS } from "./config";
2
3
 
3
4
  // A steward claims a workspace before validating/landing so the deterministic
4
5
  // auto-merge (Layer 0) doesn't race it (#208 / steward report §1). The claim is a
5
6
  // TTL'd lease stored in row metadata, so a dead steward can't block the workspace
6
7
  // forever — it expires and auto-merge resumes. Renew by re-claiming.
7
- const STEWARD_CLAIM_TTL_MS = Number(process.env.AGENT_RELAY_WORKSPACE_CLAIM_TTL_MS) || 15 * 60_000;
8
-
9
8
  interface WorkspaceClaim {
10
9
  by?: string;
11
10
  purpose?: string;
@@ -28,14 +28,13 @@ import { isPathWithinBase } from "./utils";
28
28
  import { TERMINAL_WORKSPACE_STATUSES, worktreeReapable, type WorktreeReapState } from "./workspace-phase";
29
29
  import { isOwnerAlive, liveAgentCwds, worktreeInUseByLiveAgent } from "./workspace-merge";
30
30
  import { applyWorkspaceAction } from "./workspace-actions";
31
+ import { UNLANDED_FLAG_COOLDOWN_MS, orphanGraceMs, orphanWorktreeReapEnabled, relayToken } from "./config";
31
32
 
32
33
  // Don't re-flag the same un-landed orphan every sweep — surface it once, then
33
34
  // stay quiet for this window. In-memory (keyed by worktree path) like the
34
35
  // orphaned-session reaper: a restart re-announces, which is acceptable noise.
35
- const UNLANDED_FLAG_COOLDOWN_MS = Number(process.env.AGENT_RELAY_ORPHAN_FLAG_COOLDOWN_MS) || 6 * 60 * 60 * 1000;
36
36
  // Set AGENT_RELAY_ORPHAN_WORKTREE_REAP=0 to detect + report orphans but never
37
37
  // remove them (parity with the session reaper's detect-only switch).
38
- const orphanWorktreeReapEnabled = () => process.env.AGENT_RELAY_ORPHAN_WORKTREE_REAP !== "0";
39
38
  const flaggedAt = new Map<string, number>();
40
39
  const IN_FLIGHT_WORKSPACE_STATUSES = new Set<WorkspaceStatus>(["merge_planned", "cleanup_requested"]);
41
40
 
@@ -43,10 +42,6 @@ const IN_FLIGHT_WORKSPACE_STATUSES = new Set<WorkspaceStatus>(["merge_planned",
43
42
  // reaped after the owner is observed dead continuously for this window, so a
44
43
  // reconnecting agent isn't raced. Keyed by resolved worktree path; in-memory like
45
44
  // the session reaper's tracker (a restart re-arms the clock — conservative).
46
- const orphanGraceMs = (): number => {
47
- const v = Number(process.env.AGENT_RELAY_ORPHAN_GRACE_MS);
48
- return Number.isFinite(v) && v >= 0 ? v : 30 * 60 * 1000;
49
- };
50
45
  const deadOwnerTracker = new Map<string, { firstSeenDeadAt: number }>();
51
46
 
52
47
  export function resetOrphanWorktreeStateForTests(): void {
@@ -63,7 +58,7 @@ interface OnlineOrchestrator {
63
58
 
64
59
  function relayHeaders(): Record<string, string> {
65
60
  const headers: Record<string, string> = {};
66
- const token = process.env.AGENT_RELAY_TOKEN;
61
+ const token = relayToken();
67
62
  if (token) headers[RELAY_TOKEN_HEADER] = token;
68
63
  return headers;
69
64
  }
@@ -19,6 +19,7 @@ import {
19
19
  import { isPathWithinBase } from "./utils";
20
20
  import { deriveBranchState } from "./workspace-phase";
21
21
  import type { AgentCard, WorkspaceMergePreview, WorkspaceRecord } from "./types";
22
+ import { relayToken } from "./config";
22
23
 
23
24
  /** True when `path` is the base dir itself or nested inside it (containment via
24
25
  * resolve+relative, never string startsWith — see CLAUDE.md path-containment rule). */
@@ -37,7 +38,7 @@ export async function fetchHostMergePreview(
37
38
  if (workspace.baseRef) query.set("baseRef", workspace.baseRef);
38
39
  if (workspace.baseSha) query.set("baseSha", workspace.baseSha);
39
40
  const headers: Record<string, string> = {};
40
- const token = process.env.AGENT_RELAY_TOKEN;
41
+ const token = relayToken();
41
42
  if (token) headers[RELAY_TOKEN_HEADER] = token;
42
43
  try {
43
44
  const res = await fetch(`${apiUrl}/api/workspace/merge-preview?${query.toString()}`, { headers, signal: AbortSignal.timeout(8_000) });