agent-relay-server 0.18.0 → 0.19.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/src/daemon.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { execFile } from "node:child_process";
2
+ import { shellQuote } from "agent-relay-sdk/shell-utils";
2
3
  import { constants, existsSync } from "node:fs";
3
4
  import { access, mkdir, readFile, rm, writeFile } from "node:fs/promises";
4
5
  import { homedir } from "node:os";
@@ -460,10 +461,6 @@ function quoteSystemdArg(value: string): string {
460
461
  return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
461
462
  }
462
463
 
463
- function shellQuote(value: string): string {
464
- return `'${value.replace(/'/g, "'\\''")}'`;
465
- }
466
-
467
464
  function xmlEscape(value: string): string {
468
465
  return value
469
466
  .replace(/&/g, "&")
package/src/db.ts CHANGED
@@ -5079,14 +5079,21 @@ function upsertWorkspaceFromManagedAgent(agent: ManagedAgent): WorkspaceRecord |
5079
5079
  // preserved and metadata is merged, not replaced.
5080
5080
  const existing = getWorkspace(workspace.id);
5081
5081
  const preserveStatus = existing != null && existing.status !== "active";
5082
+ // The branch (and advanced base) change ONLY via the relay's own land-and-continue
5083
+ // recycle (setWorkspaceBranch repoints to `<branch>-N` and bumps base_sha). The
5084
+ // runner keeps re-reporting its spawn-time branch on every heartbeat, and the
5085
+ // recycle returns status to "active" — so without this the next heartbeat clobbers
5086
+ // the repoint back to the original branch, and the next land targets a deleted
5087
+ // branch and strands the work (vent #62 follow-up). Trust the existing row's
5088
+ // branch/base over registration; only a brand-new row takes the runner's values.
5082
5089
  return upsertWorkspace({
5083
5090
  id: workspace.id,
5084
5091
  repoRoot: workspace.repoRoot,
5085
5092
  sourceCwd: workspace.sourceCwd ?? agent.cwd,
5086
5093
  worktreePath: workspace.worktreePath,
5087
- branch: workspace.branch,
5094
+ branch: existing?.branch ?? workspace.branch,
5088
5095
  baseRef: workspace.baseRef,
5089
- baseSha: workspace.baseSha,
5096
+ baseSha: existing?.baseSha ?? workspace.baseSha,
5090
5097
  mode: workspace.mode,
5091
5098
  requestedMode: workspace.requestedMode,
5092
5099
  status: preserveStatus ? existing!.status : (workspace.status ?? "active"),
package/src/dev.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { execFile } from "node:child_process";
2
+ import { shellQuote } from "agent-relay-sdk/shell-utils";
2
3
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
4
  import { mkdir, rm, writeFile } from "node:fs/promises";
4
5
  import { homedir, hostname as osHostname } from "node:os";
@@ -504,7 +505,3 @@ function quote(value: string): string {
504
505
  if (/^[A-Za-z0-9_@%+=:,./-]+$/.test(value)) return value;
505
506
  return `"${value.replace(/(["\\$`])/g, "\\$1")}"`;
506
507
  }
507
-
508
- function shellQuote(value: string): string {
509
- return `'${value.replace(/'/g, "'\\''")}'`;
510
- }
@@ -0,0 +1,49 @@
1
+ /** Concatenate body chunks into a single contiguous Uint8Array. */
2
+ export function concatBytes(chunks: Uint8Array[]): Uint8Array {
3
+ const total = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0);
4
+ const output = new Uint8Array(total);
5
+ let offset = 0;
6
+ for (const chunk of chunks) {
7
+ output.set(chunk, offset);
8
+ offset += chunk.byteLength;
9
+ }
10
+ return output;
11
+ }
12
+
13
+ /** Wrap bytes in a single-chunk ReadableStream (e.g. for artifact `storage.store`). */
14
+ export function bytesToStream(bytes: Uint8Array): ReadableStream<Uint8Array> {
15
+ return new ReadableStream<Uint8Array>({
16
+ start(controller) {
17
+ controller.enqueue(bytes);
18
+ controller.close();
19
+ },
20
+ });
21
+ }
22
+
23
+ /**
24
+ * Drain a request body stream into a single Uint8Array, enforcing a byte cap.
25
+ * Returns a 413 result the moment the running total exceeds `maxBytes` (cancels
26
+ * the reader). Single home for the read-loop that `parseBody` (routes) and
27
+ * `parseJsonRpcRequest` (mcp) each open-coded — they differed only in the cap.
28
+ * Callers handle body-absence and decoding.
29
+ */
30
+ export async function readBodyBytes(
31
+ body: ReadableStream<Uint8Array>,
32
+ maxBytes: number,
33
+ ): Promise<{ ok: true; bytes: Uint8Array } | { ok: false; status: 413; error: string }> {
34
+ const reader = body.getReader();
35
+ const chunks: Uint8Array[] = [];
36
+ let total = 0;
37
+ while (true) {
38
+ const { done, value } = await reader.read();
39
+ if (done) break;
40
+ if (!value) continue;
41
+ total += value.byteLength;
42
+ if (total > maxBytes) {
43
+ await reader.cancel().catch(() => {});
44
+ return { ok: false, status: 413, error: `request body exceeds ${maxBytes} bytes` };
45
+ }
46
+ chunks.push(value);
47
+ }
48
+ return { ok: true, bytes: concatBytes(chunks) };
49
+ }
package/src/index.ts CHANGED
@@ -30,6 +30,7 @@ import {
30
30
  } from "./security";
31
31
  import { handleCli } from "./cli";
32
32
  import { startMaintenanceScheduler } from "./maintenance";
33
+ import { errMessage } from "agent-relay-sdk";
33
34
 
34
35
  async function main(): Promise<void> {
35
36
  const result = await handleCli(process.argv.slice(2));
@@ -393,7 +394,7 @@ function staticContentType(pathname: string): string | undefined {
393
394
 
394
395
  if (import.meta.main) {
395
396
  main().catch((error) => {
396
- console.error(error instanceof Error ? error.message : String(error));
397
+ console.error(errMessage(error));
397
398
  process.exit(1);
398
399
  });
399
400
  }
@@ -34,7 +34,7 @@ import {
34
34
  import type { WorkspaceMergePreview, WorkspaceRecord, WorkspaceStatus } from "./types";
35
35
  import { requestWorkspaceMerge } from "./workspace-merge";
36
36
  import { workspaceActiveClaim } from "./workspace-claim";
37
- import { RELAY_TOKEN_HEADER } from "agent-relay-sdk";
37
+ import { errMessage, RELAY_TOKEN_HEADER } from "agent-relay-sdk";
38
38
  import { getStewardConfig } from "./config-store";
39
39
  import { ensureRepoSteward } from "./steward";
40
40
  import { emitRelayEvent } from "./events";
@@ -931,7 +931,7 @@ async function runDueMaintenanceJobs(): Promise<void> {
931
931
  for (const row of rows) {
932
932
  const definition = definitions.find((job) => job.id === row.id);
933
933
  if (definition) void runJob(definition).catch((error) => {
934
- console.warn(`maintenance job ${definition.id} failed: ${error instanceof Error ? error.message : String(error)}`);
934
+ console.warn(`maintenance job ${definition.id} failed: ${errMessage(error)}`);
935
935
  });
936
936
  }
937
937
  }
@@ -999,7 +999,7 @@ async function runJob(definition: MaintenanceJobDefinition, options: { force?: b
999
999
  } catch (error) {
1000
1000
  const finishedAt = Date.now();
1001
1001
  const durationMs = finishedAt - startedAt;
1002
- const message = error instanceof Error ? error.message : String(error);
1002
+ const message = errMessage(error);
1003
1003
  db.query(`
1004
1004
  UPDATE maintenance_jobs
1005
1005
  SET last_run_at = ?, next_run_at = ?, last_duration_ms = ?, last_status = 'failed',
package/src/mcp.ts CHANGED
@@ -3,6 +3,9 @@ import { getArtifactStorage, maxArtifactBytes, normalizeDigest } from "./artifac
3
3
  import { createCommand } from "./commands-db";
4
4
  import { buildSpawnCommand, generateSpawnRequestId, resolveSpawnModelParams, type SpawnModelParams } from "./spawn-command";
5
5
  import { isPathWithinBase } from "./utils";
6
+ import { optionalEnum } from "./validation";
7
+ import { listManagedOrchestratorsForAgent } from "./orchestrator-lookup";
8
+ import { bytesToStream, readBodyBytes } from "./http-body";
6
9
  import { MAX_BODY_BYTES, VERSION } from "./config";
7
10
  import { getManagedAgentState, getSpawnPolicy, listSpawnPolicies } from "./config-store";
8
11
  import {
@@ -34,7 +37,7 @@ import {
34
37
  } from "./security";
35
38
  import type { ActivityKind, AgentCard, ArtifactKind, ArtifactSensitivity, AttachmentRef, Command, SendMessageInput, Message, SpawnApprovalMode, SpawnProvider } from "./types";
36
39
  import { type ProviderEffort } from "agent-relay-sdk/provider-catalog";
37
- import { isRecord, SPAWN_PROVIDERS, APPROVAL_MODES, VALID_EFFORTS } from "agent-relay-sdk";
40
+ import { errMessage, isRecord, SPAWN_PROVIDERS, APPROVAL_MODES, VALID_EFFORTS } from "agent-relay-sdk";
38
41
  import { childRunnerRuntimeTokenEnv, runnerRuntimeTokenEnv } from "./runtime-tokens";
39
42
 
40
43
  type JsonRpcId = string | number | null;
@@ -272,7 +275,7 @@ export async function postMcp(req: Request): Promise<Response> {
272
275
  }
273
276
  return Response.json(jsonRpcError(id, -32601, `unknown MCP method: ${method}`));
274
277
  } catch (error) {
275
- const message = error instanceof Error ? error.message : String(error);
278
+ const message = errMessage(error);
276
279
  const status = error instanceof McpAuthError ? 403 : error instanceof McpNotFoundError ? 404 : 400;
277
280
  const code = error instanceof McpAuthError ? -32001 : error instanceof McpNotFoundError ? -32004 : -32602;
278
281
  return Response.json(jsonRpcError(id, code, message, { status }));
@@ -284,23 +287,11 @@ async function parseJsonRpcRequest(req: Request): Promise<
284
287
  | { ok: false; status: number; error: string }
285
288
  > {
286
289
  if (!req.body) return { ok: false, status: 400, error: "JSON-RPC body required" };
287
- const reader = req.body.getReader();
288
- const chunks: Uint8Array[] = [];
289
- let total = 0;
290
- while (true) {
291
- const { done, value } = await reader.read();
292
- if (done) break;
293
- if (!value) continue;
294
- total += value.byteLength;
295
- const maxBodyBytes = Math.max(MAX_BODY_BYTES, maxArtifactBytes() + MCP_BODY_OVERHEAD_BYTES);
296
- if (total > maxBodyBytes) {
297
- await reader.cancel().catch(() => {});
298
- return { ok: false, status: 413, error: `request body exceeds ${maxBodyBytes} bytes` };
299
- }
300
- chunks.push(value);
301
- }
290
+ const maxBodyBytes = Math.max(MAX_BODY_BYTES, maxArtifactBytes() + MCP_BODY_OVERHEAD_BYTES);
291
+ const read = await readBodyBytes(req.body, maxBodyBytes);
292
+ if (!read.ok) return read;
302
293
  try {
303
- const body = JSON.parse(new TextDecoder().decode(concatBytes(chunks))) as unknown;
294
+ const body = JSON.parse(new TextDecoder().decode(read.bytes)) as unknown;
304
295
  if (!isRecord(body)) return { ok: false, status: 400, error: "JSON-RPC body must be an object" };
305
296
  return { ok: true, request: body as JsonRpcRequest };
306
297
  } catch {
@@ -308,17 +299,6 @@ async function parseJsonRpcRequest(req: Request): Promise<
308
299
  }
309
300
  }
310
301
 
311
- function concatBytes(chunks: Uint8Array[]): Uint8Array {
312
- const total = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0);
313
- const output = new Uint8Array(total);
314
- let offset = 0;
315
- for (const chunk of chunks) {
316
- output.set(chunk, offset);
317
- offset += chunk.byteLength;
318
- }
319
- return output;
320
- }
321
-
322
302
  function mcpAuthContext(req: Request): McpAuthContext {
323
303
  const component = getComponentAuth(req);
324
304
  if (component) return { actor: component.sub, kind: "component", scopes: component.scope, component };
@@ -354,7 +334,7 @@ async function callTool(auth: McpAuthContext, params: unknown): Promise<Record<s
354
334
  auditToolCall(auth, name, "ok", result);
355
335
  return toolResult(result);
356
336
  } catch (error) {
357
- const message = error instanceof Error ? error.message : String(error);
337
+ const message = errMessage(error);
358
338
  auditToolCall(auth, name, error instanceof McpAuthError ? "denied" : "error", undefined, message);
359
339
  throw error;
360
340
  }
@@ -732,7 +712,7 @@ function selectControlOrchestrator(input: {
732
712
  return orchestrator;
733
713
  }
734
714
  const agent = input.agentId ? getAgent(input.agentId) : null;
735
- const orchestrator = agent ? managedControlOrchestrator(agent, input) : findManagedOrchestrator(input);
715
+ const orchestrator = agent ? managedControlOrchestrator(agent, input) : (listManagedOrchestratorsForAgent(input)[0] ?? null);
736
716
  if (!orchestrator) throw new McpNotFoundError("no orchestrator found for agent control target");
737
717
  if (orchestrator.status !== "online") throw new ValidationError("orchestrator is offline");
738
718
  return orchestrator;
@@ -742,29 +722,13 @@ function managedControlOrchestrator(
742
722
  agent: AgentCard,
743
723
  input: { policyName?: string; spawnRequestId?: string; tmuxSession?: string },
744
724
  ): NonNullable<ReturnType<typeof getOrchestrator>> | null {
745
- const metaTmuxSession = typeof agent.meta?.tmuxSession === "string" ? agent.meta.tmuxSession : "";
746
- const metaPolicyName = input.policyName ?? (typeof agent.meta?.policyName === "string" ? agent.meta.policyName : "");
747
- const metaSpawnRequestId = input.spawnRequestId ?? (typeof agent.meta?.spawnRequestId === "string" ? agent.meta.spawnRequestId : "");
748
- return findManagedOrchestrator({
725
+ const str = (v: unknown): string | undefined => (typeof v === "string" ? v : undefined);
726
+ return listManagedOrchestratorsForAgent({
749
727
  agentId: agent.id,
750
- policyName: metaPolicyName,
751
- spawnRequestId: metaSpawnRequestId,
752
- tmuxSession: input.tmuxSession ?? metaTmuxSession,
753
- }) ?? (agent.machine ? getOrchestrator(agent.machine) : null);
754
- }
755
-
756
- function findManagedOrchestrator(input: {
757
- agentId?: string;
758
- policyName?: string;
759
- spawnRequestId?: string;
760
- tmuxSession?: string;
761
- }): NonNullable<ReturnType<typeof getOrchestrator>> | null {
762
- return listOrchestrators().find((orchestrator) => orchestrator.managedAgents.some((managed) =>
763
- (!!input.agentId && managed.agentId === input.agentId) ||
764
- (!!input.tmuxSession && managed.tmuxSession === input.tmuxSession) ||
765
- (!!input.policyName && managed.policyName === input.policyName) ||
766
- (!!input.spawnRequestId && managed.spawnRequestId === input.spawnRequestId)
767
- )) ?? null;
728
+ policyName: input.policyName ?? str(agent.meta?.policyName),
729
+ spawnRequestId: input.spawnRequestId ?? str(agent.meta?.spawnRequestId),
730
+ tmuxSession: input.tmuxSession ?? str(agent.meta?.tmuxSession),
731
+ })[0] ?? (agent.machine ? getOrchestrator(agent.machine) : null);
768
732
  }
769
733
 
770
734
  function providerSelection(provider: SpawnProvider, args: Record<string, unknown>): SpawnModelParams {
@@ -773,15 +737,6 @@ function providerSelection(provider: SpawnProvider, args: Record<string, unknown
773
737
  return resolveSpawnModelParams(provider, model, effort);
774
738
  }
775
739
 
776
- function bytesToStream(bytes: Uint8Array): ReadableStream<Uint8Array> {
777
- return new ReadableStream<Uint8Array>({
778
- start(controller) {
779
- controller.enqueue(bytes);
780
- controller.close();
781
- },
782
- });
783
- }
784
-
785
740
  function artifactBytes(args: Record<string, unknown>): Uint8Array {
786
741
  const hasContent = args.content !== undefined && args.content !== null;
787
742
  const hasBase64 = args.base64 !== undefined && args.base64 !== null;
@@ -956,12 +911,6 @@ function optionalBoolean(value: unknown, field: string): boolean | undefined {
956
911
  return value;
957
912
  }
958
913
 
959
- function optionalEnum<T extends readonly string[]>(value: unknown, field: string, valid: T): T[number] | undefined {
960
- if (value === undefined || value === null) return undefined;
961
- if (typeof value !== "string" || !valid.includes(value as T[number])) throw new ValidationError(`${field} must be one of: ${valid.join(", ")}`);
962
- return value as T[number];
963
- }
964
-
965
914
  function enumField<T extends readonly string[]>(value: unknown, field: string, valid: T): T[number] {
966
915
  const cleaned = optionalEnum(value, field, valid);
967
916
  if (!cleaned) throw new ValidationError(`${field} required`);
@@ -1,4 +1,4 @@
1
- import { RELAY_TOKEN_HEADER } from "agent-relay-sdk";
1
+ import { errMessage, RELAY_TOKEN_HEADER } from "agent-relay-sdk";
2
2
 
3
3
  interface MemoryBrokerSmokeOptions {
4
4
  baseUrl?: string;
@@ -137,7 +137,7 @@ async function step<T>(steps: MemoryBrokerSmokeStep[], name: string, detail: str
137
137
  steps.push({ name, ok: true, detail });
138
138
  return result;
139
139
  } catch (error) {
140
- const message = error instanceof Error ? error.message : String(error);
140
+ const message = errMessage(error);
141
141
  steps.push({ name, ok: false, detail: message });
142
142
  throw error;
143
143
  }
@@ -20,7 +20,7 @@ import {
20
20
  normalizeMemorySearchResult,
21
21
  } from "./memory-broker-contract";
22
22
  import { normalizeContextPackage } from "./memory-http-broker";
23
- import { isRecord } from "agent-relay-sdk";
23
+ import { errMessage, isRecord } from "agent-relay-sdk";
24
24
 
25
25
  const DEFAULT_TIMEOUT_MS = 10_000;
26
26
 
@@ -105,7 +105,7 @@ export class CommandMemoryBroker implements MemoryBroker {
105
105
  return unwrapResult(parseJson(stdout, operation));
106
106
  } catch (error) {
107
107
  if (error instanceof MemoryBrokerContractError) throw error;
108
- throw new MemoryBrokerContractError(`command memory broker ${operation} failed: ${error instanceof Error ? error.message : String(error)}`);
108
+ throw new MemoryBrokerContractError(`command memory broker ${operation} failed: ${errMessage(error)}`);
109
109
  } finally {
110
110
  clearTimeout(timer);
111
111
  }
@@ -19,7 +19,7 @@ import {
19
19
  normalizeMemoryBrokerCapabilities,
20
20
  normalizeMemorySearchResult,
21
21
  } from "./memory-broker-contract";
22
- import { isRecord } from "agent-relay-sdk";
22
+ import { errMessage, isRecord } from "agent-relay-sdk";
23
23
 
24
24
  const DEFAULT_TIMEOUT_MS = 10_000;
25
25
 
@@ -99,7 +99,7 @@ export class HttpMemoryBroker implements MemoryBroker {
99
99
  if (error instanceof DOMException && error.name === "AbortError") {
100
100
  throw new MemoryBrokerContractError(`http memory broker ${operation} timed out after ${this.timeoutMs}ms`);
101
101
  }
102
- throw new MemoryBrokerContractError(`http memory broker ${operation} failed: ${error instanceof Error ? error.message : String(error)}`);
102
+ throw new MemoryBrokerContractError(`http memory broker ${operation} failed: ${errMessage(error)}`);
103
103
  } finally {
104
104
  clearTimeout(timer);
105
105
  }
@@ -0,0 +1,29 @@
1
+ import { listOrchestrators } from "./db";
2
+
3
+ export interface ManagedAgentMatch {
4
+ agentId?: string;
5
+ sessionName?: string;
6
+ tmuxSession?: string;
7
+ policyName?: string;
8
+ spawnRequestId?: string;
9
+ }
10
+
11
+ /**
12
+ * Every orchestrator that owns a managed agent matching ANY provided identity
13
+ * key. Single home for the managed-agent→orchestrator lookup that routes.ts and
14
+ * mcp.ts each open-coded. routes also matches `sessionName`; mcp never passed it,
15
+ * so leaving the key undefined reproduces mcp's behavior exactly. Callers layer
16
+ * their own online / runner-managed / machine-fallback policy on top — routes
17
+ * picks the first online candidate, mcp takes the first match.
18
+ */
19
+ export function listManagedOrchestratorsForAgent(match: ManagedAgentMatch): ReturnType<typeof listOrchestrators> {
20
+ return listOrchestrators().filter((orch) =>
21
+ orch.managedAgents.some((managed) =>
22
+ (!!match.agentId && managed.agentId === match.agentId) ||
23
+ (!!match.sessionName && managed.sessionName === match.sessionName) ||
24
+ (!!match.tmuxSession && managed.tmuxSession === match.tmuxSession) ||
25
+ (!!match.policyName && managed.policyName === match.policyName) ||
26
+ (!!match.spawnRequestId && managed.spawnRequestId === match.spawnRequestId),
27
+ ),
28
+ );
29
+ }
@@ -1,6 +1,6 @@
1
1
  import type { MemoryType, Recipe, RecipeAgent, RecipeMemoryPolicy } from "./types";
2
2
  import { resolveProviderSelection, type ProviderEffort } from "agent-relay-sdk/provider-catalog";
3
- import { isRecord } from "agent-relay-sdk";
3
+ import { errMessage, isRecord } from "agent-relay-sdk";
4
4
 
5
5
  class RecipeValidationError extends Error {}
6
6
 
@@ -52,7 +52,7 @@ function validateAgent(value: unknown): RecipeAgent {
52
52
  try {
53
53
  resolveProviderSelection({ provider, model, effort });
54
54
  } catch (error) {
55
- throw new RecipeValidationError(error instanceof Error ? error.message : String(error));
55
+ throw new RecipeValidationError(errMessage(error));
56
56
  }
57
57
  return {
58
58
  role: requiredString(value.role, "agent.role"),