agent-relay-server 0.17.0 → 0.19.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/src/security.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  import { AUTH_TOKEN, CORS_ORIGINS, getIntegrationTokens, type IntegrationTokenConfig } from "./config";
2
2
  import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
3
3
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
4
- import { dirname, isAbsolute, join, relative, resolve } from "node:path";
4
+ import { dirname, join, resolve } from "node:path";
5
5
  import { getDb } from "./db";
6
+ import { isPathWithinBase } from "./utils";
6
7
  import type { ComponentToken, TokenConstraints } from "./types";
7
8
 
8
9
  const LOOPBACK_HOSTS = new Set(["127.0.0.1", "::1", "localhost"]);
@@ -427,15 +428,10 @@ function prefixAllows(allowed: string[] | undefined, value: string): boolean {
427
428
 
428
429
  function cwdAllows(constraints: TokenConstraints, cwd: string): boolean {
429
430
  if (constraints.cwd && resolve(cwd) !== resolve(constraints.cwd)) return false;
430
- if (constraints.cwdPrefixes?.length && !constraints.cwdPrefixes.some((prefix) => pathWithin(resolve(cwd), resolve(prefix)))) return false;
431
+ if (constraints.cwdPrefixes?.length && !constraints.cwdPrefixes.some((prefix) => isPathWithinBase(cwd, prefix))) return false;
431
432
  return true;
432
433
  }
433
434
 
434
- function pathWithin(path: string, baseDir: string): boolean {
435
- const rel = relative(baseDir, path);
436
- return rel === "" || (!!rel && !rel.startsWith("..") && !isAbsolute(rel));
437
- }
438
-
439
435
  function isTokenConstraints(value: unknown): value is TokenConstraints {
440
436
  if (!value || typeof value !== "object" || Array.isArray(value)) return false;
441
437
  const record = value as Record<string, unknown>;
package/src/setup.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { randomBytes } from "node:crypto";
2
+ import { shellQuote } from "agent-relay-sdk/shell-utils";
2
3
  import { access, mkdir, readFile, writeFile } from "node:fs/promises";
3
4
  import { constants } from "node:fs";
4
5
  import { homedir } from "node:os";
@@ -191,10 +192,6 @@ function normalizePort(port: number | undefined): number {
191
192
  return value;
192
193
  }
193
194
 
194
- function shellQuote(value: string): string {
195
- return `'${value.replace(/'/g, "'\\''")}'`;
196
- }
197
-
198
195
  function redactEnv(content: string): string {
199
196
  return content.replace(/^(AGENT_RELAY_TOKEN=).+$/m, "$1'<generated-token>'");
200
197
  }
@@ -0,0 +1,151 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import type { SpawnProvider } from "agent-relay-sdk";
3
+ import { errMessage } from "agent-relay-sdk";
4
+ import { resolveProviderSelection, type ProviderEffort } from "agent-relay-sdk/provider-catalog";
5
+ import { workspaceSpawnParams } from "./config-store";
6
+ import { ValidationError } from "./db";
7
+
8
+ /** Resolved provider/model/effort triple — the output shape of every model-resolution wrapper. */
9
+ export interface SpawnModelParams {
10
+ model?: string;
11
+ providerModel?: string;
12
+ effort?: string;
13
+ }
14
+
15
+ /** `sp_`-prefixed correlation id for a spawn request. Single home — was inlined in 5 places. */
16
+ export function generateSpawnRequestId(): string {
17
+ return `sp_${randomUUID()}`;
18
+ }
19
+
20
+ export interface ResolveSpawnModelParamsOptions {
21
+ /**
22
+ * What to do when provider-catalog resolution throws (e.g. unknown model, or
23
+ * effort without a model):
24
+ * - `"throw"` (default) — re-throw as a `ValidationError` (dashboard/mcp/automation paths).
25
+ * - `"passthrough"` — swallow and return the raw `{model, effort}` (managed-policy/restart paths).
26
+ */
27
+ onError?: "throw" | "passthrough";
28
+ /**
29
+ * When `true`, empty input (no model, no effort) returns `{}` instead of resolving
30
+ * the provider's *default* model. The managed-policy and restart paths use this so a
31
+ * spawn with no explicit model carries no model override; the dashboard/mcp/automation
32
+ * paths leave it `false` so the catalog default is injected (their original behavior).
33
+ */
34
+ skipDefaultWhenEmpty?: boolean;
35
+ }
36
+
37
+ /**
38
+ * Resolve `{model, providerModel, effort}` for a spawn payload. Single home for
39
+ * the 5 selection wrappers that each called `resolveProviderSelection` then mapped
40
+ * the result — with inconsistent error handling (some threw, some passed through)
41
+ * and inconsistent empty-input handling (some injected the default model, some didn't).
42
+ */
43
+ export function resolveSpawnModelParams(
44
+ provider: SpawnProvider | string,
45
+ model: string | undefined,
46
+ effort: string | undefined,
47
+ opts: ResolveSpawnModelParamsOptions = {},
48
+ ): SpawnModelParams {
49
+ if (!model && !effort && opts.skipDefaultWhenEmpty) return {};
50
+ try {
51
+ const selection = resolveProviderSelection({ provider: provider as SpawnProvider, model, effort: effort as ProviderEffort | undefined });
52
+ return {
53
+ ...(selection.modelAlias ? { model: selection.modelAlias } : {}),
54
+ ...(selection.providerModel ? { providerModel: selection.providerModel } : {}),
55
+ ...(selection.effort ? { effort: selection.effort } : {}),
56
+ };
57
+ } catch (error) {
58
+ if (opts.onError === "passthrough") {
59
+ return {
60
+ ...(model ? { model } : {}),
61
+ ...(effort ? { effort } : {}),
62
+ };
63
+ }
64
+ throw new ValidationError(errMessage(error));
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Every field that can appear in a spawn command-bus payload.
70
+ * Optional fields are omitted from the result when undefined.
71
+ */
72
+ export interface BuildSpawnCommandOptions {
73
+ provider: SpawnProvider | string;
74
+ cwd: string;
75
+ /** Correlation id; omitted from the payload when absent (e.g. automation spawns use automationRunId instead). */
76
+ spawnRequestId?: string;
77
+ env: Record<string, string>;
78
+ requestedBy: string;
79
+ requestedAt?: number;
80
+
81
+ /** Resolved {model, providerModel, effort}; spread verbatim. */
82
+ modelParams?: SpawnModelParams;
83
+
84
+ workspaceMode?: string;
85
+ label?: string;
86
+ tags?: string[];
87
+ capabilities?: string[];
88
+ approvalMode?: string;
89
+ permissionMode?: string;
90
+ providerArgs?: string[];
91
+ prompt?: string;
92
+ systemPromptAppend?: string;
93
+ profile?: string;
94
+ agentProfile?: unknown;
95
+ headless?: boolean;
96
+ policyName?: string;
97
+ rig?: string;
98
+ agentId?: string;
99
+ automationId?: string;
100
+ automationRunId?: string;
101
+ orchestratorId?: string;
102
+ requestedVia?: string;
103
+
104
+ /** Extra params merged last (e.g. Claude-resume fields). */
105
+ extra?: Record<string, unknown>;
106
+ }
107
+
108
+ /**
109
+ * Single home for the spawn command-bus payload. Previously hand-assembled in 6
110
+ * places (routes ×3, mcp, managed-policy, automations) which silently drifted —
111
+ * e.g. workspace-symlink params (`workspaceSpawnParams()`) were missing from the
112
+ * mcp and dashboard-quick-spawn paths. Building it here means a new spawn field is
113
+ * added once and every caller gets it. `workspaceSpawnParams()` is included for
114
+ * all callers (it only emits in isolated workspace mode; ignored otherwise).
115
+ */
116
+ export function buildSpawnCommand(opts: BuildSpawnCommandOptions): Record<string, unknown> {
117
+ const def = <T>(value: T | undefined, key: string): Record<string, T> =>
118
+ value === undefined ? {} : { [key]: value };
119
+
120
+ return {
121
+ action: "spawn",
122
+ provider: opts.provider,
123
+ ...(opts.modelParams ?? {}),
124
+ cwd: opts.cwd,
125
+ ...def(opts.workspaceMode, "workspaceMode"),
126
+ ...def(opts.profile, "profile"),
127
+ ...def(opts.agentProfile, "agentProfile"),
128
+ ...workspaceSpawnParams(),
129
+ ...def(opts.rig, "rig"),
130
+ ...def(opts.label, "label"),
131
+ ...def(opts.agentId, "agentId"),
132
+ ...def(opts.tags, "tags"),
133
+ ...def(opts.capabilities, "capabilities"),
134
+ ...def(opts.approvalMode, "approvalMode"),
135
+ ...def(opts.permissionMode, "permissionMode"),
136
+ ...def(opts.providerArgs, "providerArgs"),
137
+ ...def(opts.prompt, "prompt"),
138
+ ...def(opts.systemPromptAppend, "systemPromptAppend"),
139
+ ...def(opts.headless, "headless"),
140
+ ...def(opts.policyName, "policyName"),
141
+ ...def(opts.automationId, "automationId"),
142
+ ...def(opts.automationRunId, "automationRunId"),
143
+ ...def(opts.orchestratorId, "orchestratorId"),
144
+ ...def(opts.requestedVia, "requestedVia"),
145
+ ...def(opts.spawnRequestId, "spawnRequestId"),
146
+ env: opts.env,
147
+ requestedBy: opts.requestedBy,
148
+ requestedAt: opts.requestedAt ?? Date.now(),
149
+ ...(opts.extra ?? {}),
150
+ };
151
+ }
package/src/sse.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { getAgent, getOrchestrator } from "./db";
2
2
  import { emitRelayEvent, subscribeRelayEvents, type RelayEvent } from "./events";
3
3
  import type { ActivityEvent, AgentCard, Message, RelayNotification, Task } from "./types";
4
+ import { isRecord } from "agent-relay-sdk";
4
5
 
5
6
  interface Connection {
6
7
  id: string;
@@ -231,10 +232,6 @@ function notificationTitleForAgent(agentId: string): string {
231
232
  return agent?.label || agent?.name || agentId;
232
233
  }
233
234
 
234
- function isRecord(value: unknown): value is Record<string, unknown> {
235
- return typeof value === "object" && value !== null && !Array.isArray(value);
236
- }
237
-
238
235
  function payloadMessageText(msg: Message): string {
239
236
  const message = msg.payload?.message;
240
237
  if (isRecord(message) && typeof message.text === "string" && message.text.trim()) return message.text.trim();
package/src/steward.ts CHANGED
@@ -1,16 +1,10 @@
1
1
  import { createHash } from "node:crypto";
2
- import { basename, isAbsolute, relative, resolve } from "node:path";
2
+ import { basename, resolve } from "node:path";
3
3
  import { getSpawnPolicy, getStewardConfig, setConfig } from "./config-store";
4
4
  import { listOrchestrators } from "./db";
5
5
  import { getLifecycleManager } from "./lifecycle-manager";
6
6
  import type { Orchestrator, SpawnPolicy, StewardConfig } from "./types";
7
-
8
- // Real path containment (same rule as routes/mcp/lifecycle): never string startsWith.
9
- function pathWithinBase(path: string | undefined, baseDir: string | undefined): boolean {
10
- if (!path || !baseDir) return false;
11
- const rel = relative(resolve(baseDir), resolve(path));
12
- return rel === "" || (!!rel && !rel.startsWith("..") && !isAbsolute(rel));
13
- }
7
+ import { isPathWithinBase } from "./utils";
14
8
 
15
9
  /** Stable, readable, collision-resistant policy name for a repo's steward. */
16
10
  export function repoStewardPolicyName(repoRoot: string): string {
@@ -21,26 +15,28 @@ export function repoStewardPolicyName(repoRoot: string): string {
21
15
 
22
16
  /**
23
17
  * The steward's system prompt — provider-agnostic (works for any provider/model).
24
- * It tells the agent the workflow: take the worktrees it can't auto-land, rebase,
25
- * resolve, run the repo's checks, and land green ones via the relay merge API;
26
- * escalate only what genuinely needs a human.
18
+ * It tells the agent the workflow via the `agent-relay workspace`/`steward` CLI
19
+ * toolkit (#208): see the queue, CLAIM a workspace so auto-merge yields, inspect
20
+ * its diagnostics, rebase/resolve/check, land green ones, release on escalation.
27
21
  */
28
22
  export function buildStewardPrompt(repoRoot: string): string {
29
23
  return [
30
24
  `You are the autonomous repository steward for ${repoRoot}.`,
31
25
  "",
32
- "Multiple agents work this repo in isolated git worktrees. The relay auto-merges branches that fast-forward cleanly; it hands YOU the ones it can't merge conflicts, or a branch whose base moved on (behind > 0). Your job is to land that work with no human in the loop whenever you safely can.",
26
+ "Multiple agents work this repo in isolated git worktrees. The relay auto-merges any branch that rebases cleanly — including ones whose base moved on; it hands YOU only what it can't land deterministically: real merge conflicts, or an unknown/ambiguous merge state. Your job is to land that work with no human in the loop whenever you safely can.",
33
27
  "",
34
- "When woken, gather what needs attention: read the message(s) you were sent (each names a `workspaceId`, `worktreePath`, `branch`, `baseRef`, `status`), and also query `GET /api/workspaces?status=conflict` and `GET /api/workspaces?status=review_requested` for this repo. Handle one workspace at a time — the relay's per-repo merge lease serializes you with any other merger.",
28
+ "Use the `agent-relay` CLI for EVERY relay interaction it wraps the relay API and is the supported toolkit, and it already attaches your session token with the right scopes. Do NOT hand-curl the HTTP API (`/api/workspaces`, …): the CLI exists so you never have to. If you ever truly must call the API directly, authenticate with the `X-Agent-Relay-Token: <your session token>` header NOT `Authorization: Bearer $AGENT_RELAY_TOKEN`; the admin env token is not present in your environment, so Bearer will 401.",
29
+ "Handle ONE workspace at a time — the relay's per-repo merge lease serializes you with any other merger.",
35
30
  "",
36
- "For each workspace:",
37
- "1. `cd` into its `worktreePath`.",
38
- "2. Rebase the branch onto its `baseRef` (`git rebase <baseRef>`).",
39
- "3. Resolve any conflicts faithfully — preserve the intent of EVERY side; never silently drop a change. If a conflict needs product judgment you can't make confidently, stop and escalate.",
40
- "4. Run the repo's checks/tests (look for the project's documented commands). Fix trivial breakage you caused while rebasing; do not paper over real failures.",
41
- "5. If green, land it: `POST /api/workspaces/<id>/actions` with `{\"action\":\"merge\",\"strategy\":\"rebase-ff\"}`. The relay acquires the lease and dispatches the merge.",
31
+ "1. See the queue: `agent-relay steward queue` (workspaces in conflict / review_requested / merge_planned for this repo). The wake message(s) also name a `workspaceId`.",
32
+ "2. CLAIM it FIRST: `agent-relay workspace claim --id <id> --purpose steward`. This stops the deterministic auto-merge from racing you while you validate. The claim auto-expires, so a crash never blocks the repo.",
33
+ "3. Understand it: `agent-relay steward inspect <id>` — a diagnostics briefing (owner liveness, live git state, ahead/behind, recorded-vs-live branch mismatch, and a recommended action). `agent-relay steward checks <id>` suggests check commands from the changed files.",
34
+ "4. `cd` into the workspace's `worktreePath` and rebase the branch onto its `baseRef` (`git rebase <baseRef>`). Resolve conflicts faithfully — preserve the intent of EVERY side; never silently drop a change. If a conflict needs product judgment you can't make confidently, stop and escalate.",
35
+ "5. Run the repo's checks/tests (the suggested ones, plus the project's documented commands). Fix trivial breakage you caused while rebasing; do not paper over real failures.",
36
+ "6. If green, land it: `agent-relay workspace land --id <id> --strategy rebase-ff`. The relay rebases onto the latest base, fast-forwards, and pushes to origin under the lease. The land consumes your claim.",
37
+ "7. If you must stop without landing, RELEASE the claim so automation can resume: `agent-relay workspace release --id <id>`, then send a clear, specific summary to the fallback/human target.",
42
38
  "",
43
- "Escalate (do NOT merge) when: checks fail and you can't fix them, the conflict resolution is genuinely ambiguous, or anything looks risky. Send a clear, specific summary to the configured fallback/human target and leave the workspace as-is. Never force-merge a red branch, never `git push --force` to a shared base, never discard committed work.",
39
+ "Escalate (do NOT merge) when: checks fail and you can't fix them, the conflict resolution is genuinely ambiguous, or anything looks risky. Never force-merge a red branch, never `git push --force` to a shared base, never discard committed work.",
44
40
  "",
45
41
  "Be terminal-efficient and decisive. When the queue is empty, you're done — you'll be woken again when there's more.",
46
42
  ].join("\n");
@@ -103,7 +99,7 @@ function stewardPolicyDiffers(existing: SpawnPolicy, desired: SpawnPolicy): bool
103
99
  export function ensureRepoSteward(repoRoot: string): string | null {
104
100
  const config = getStewardConfig();
105
101
  if (!config.enabled) return null;
106
- const owner = listOrchestrators().find((orch) => pathWithinBase(repoRoot, orch.baseDir));
102
+ const owner = listOrchestrators().find((orch) => isPathWithinBase(repoRoot, orch.baseDir));
107
103
  if (!owner) return null;
108
104
 
109
105
  const name = repoStewardPolicyName(repoRoot);
package/src/upgrade.ts CHANGED
@@ -4,6 +4,8 @@ import { join, resolve } from "node:path";
4
4
  import { VERSION } 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";
8
+ import { shellEscape as shellQuote } from "agent-relay-sdk/shell-utils";
7
9
 
8
10
  export type UpgradeProvider = "auto" | "all" | "codex" | "claude" | "orchestrator";
9
11
 
@@ -244,9 +246,38 @@ export function createUpgradePlan(snapshot: UpgradeSnapshot, options: UpgradeOpt
244
246
  };
245
247
  }
246
248
 
247
- export async function executeUpgradePlan(plan: UpgradePlan, options: { dryRun?: boolean; runner?: Runner } = {}): Promise<string> {
249
+ export type ExecuteUpgradeOptions = {
250
+ dryRun?: boolean;
251
+ runner?: Runner;
252
+ /** Re-register grace window for post-restart version checks (default 30s). */
253
+ verifyTimeoutMs?: number;
254
+ /** Poll interval while waiting for re-register (default 1s). */
255
+ verifyIntervalMs?: number;
256
+ sleep?: (ms: number) => Promise<void>;
257
+ probeServerVersion?: () => Promise<string | undefined>;
258
+ probeOrchestrators?: () => Promise<UpgradeSnapshot["runningOrchestrators"]>;
259
+ };
260
+
261
+ export async function executeUpgradePlan(plan: UpgradePlan, options: ExecuteUpgradeOptions = {}): Promise<string> {
248
262
  if (options.dryRun) return formatUpgradePlan(plan, { dryRun: true });
249
263
  const runner = options.runner ?? runCommand;
264
+ const sleep = options.sleep ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
265
+ const probeServerVersion = options.probeServerVersion ?? runningServerVersion;
266
+ const probeOrchestrators = options.probeOrchestrators ?? runningOrchestrators;
267
+ const verifyTimeoutMs = options.verifyTimeoutMs ?? 30000;
268
+ const verifyIntervalMs = options.verifyIntervalMs ?? 1000;
269
+ // Restarted services drop their old registration and re-register asynchronously.
270
+ // Poll until the reported version reaches the target (or the grace window expires)
271
+ // so a slow re-register is not mistaken for a failed upgrade (vent #49).
272
+ const pollUntil = async <T>(probe: () => Promise<T>, done: (value: T) => boolean): Promise<T> => {
273
+ const deadline = Date.now() + verifyTimeoutMs;
274
+ let value = await probe();
275
+ while (!done(value) && Date.now() < deadline) {
276
+ await sleep(verifyIntervalMs);
277
+ value = await probe();
278
+ }
279
+ return value;
280
+ };
250
281
  const lines = [`Upgrading Agent Relay to ${plan.targetVersion}`];
251
282
  for (const action of plan.actions) {
252
283
  lines.push(`\n${action.label}`);
@@ -259,16 +290,17 @@ export async function executeUpgradePlan(plan: UpgradePlan, options: { dryRun?:
259
290
  }
260
291
  }
261
292
  if (plan.actions.some((action) => action.command.join(" ") === "systemctl --user restart agent-relay.service")) {
262
- await new Promise((resolve) => setTimeout(resolve, 1000));
263
- const serverVersion = await runningServerVersion();
293
+ const serverVersion = await pollUntil(probeServerVersion, (version) => version === plan.targetVersion);
264
294
  if (serverVersion && serverVersion !== plan.targetVersion) {
265
295
  throw new Error(`agent-relay.service restarted but /api/stats reports ${serverVersion}, expected ${plan.targetVersion}`);
266
296
  }
267
297
  if (serverVersion) lines.push(`Running server: ${serverVersion}`);
268
298
  }
269
299
  if (plan.actions.some((action) => action.command.join(" ") === "systemctl --user restart agent-relay-orchestrator.service")) {
270
- await new Promise((resolve) => setTimeout(resolve, 1000));
271
- const orchestrators = await runningOrchestrators() ?? [];
300
+ const orchestrators = await pollUntil(
301
+ async () => (await probeOrchestrators()) ?? [],
302
+ (list) => list.length > 0 && list.every((orch) => !orch.version || orch.version === plan.targetVersion),
303
+ );
272
304
  const mismatched = orchestrators.filter((orch) => orch.version && orch.version !== plan.targetVersion);
273
305
  if (mismatched.length > 0) {
274
306
  throw new Error(`agent-relay-orchestrator.service restarted but ${mismatched.map((orch) => `${orch.id} reports ${orch.version}`).join(", ")}, expected ${plan.targetVersion}`);
@@ -395,7 +427,7 @@ async function npmViewVersion(spec: string): Promise<string | undefined> {
395
427
  async function runningServerVersion(): Promise<string | undefined> {
396
428
  try {
397
429
  const headers: Record<string, string> = {};
398
- if (process.env.AGENT_RELAY_TOKEN) headers["X-Agent-Relay-Token"] = process.env.AGENT_RELAY_TOKEN;
430
+ if (process.env.AGENT_RELAY_TOKEN) headers[RELAY_TOKEN_HEADER] = process.env.AGENT_RELAY_TOKEN;
399
431
  const relayUrl = (process.env.AGENT_RELAY_URL || "http://127.0.0.1:4850").replace(/\/+$/, "");
400
432
  const response = await fetch(`${relayUrl}/api/stats`, { headers });
401
433
  if (!response.ok) return undefined;
@@ -409,7 +441,7 @@ async function runningServerVersion(): Promise<string | undefined> {
409
441
  async function runningOrchestrators(): Promise<UpgradeSnapshot["runningOrchestrators"]> {
410
442
  try {
411
443
  const headers: Record<string, string> = {};
412
- if (process.env.AGENT_RELAY_TOKEN) headers["X-Agent-Relay-Token"] = process.env.AGENT_RELAY_TOKEN;
444
+ if (process.env.AGENT_RELAY_TOKEN) headers[RELAY_TOKEN_HEADER] = process.env.AGENT_RELAY_TOKEN;
413
445
  const relayUrl = (process.env.AGENT_RELAY_URL || "http://127.0.0.1:4850").replace(/\/+$/, "");
414
446
  const response = await fetch(`${relayUrl}/api/orchestrators`, { headers });
415
447
  if (!response.ok) return [];
@@ -493,7 +525,7 @@ function runCommand(command: string[]): CommandResult {
493
525
  return {
494
526
  exitCode: 127,
495
527
  stdout: "",
496
- stderr: error instanceof Error ? error.message : String(error),
528
+ stderr: errMessage(error),
497
529
  };
498
530
  }
499
531
  }
@@ -535,8 +567,3 @@ function formatContracts(contracts: RuntimeContracts | undefined): string {
535
567
  .map(([name, version]) => `${name}=${version}`)
536
568
  .join(" ");
537
569
  }
538
-
539
- function shellQuote(value: string): string {
540
- if (/^[a-zA-Z0-9_@%+=:,./-]+$/.test(value)) return value;
541
- return `'${value.replaceAll("'", "'\\''")}'`;
542
- }
package/src/utils.ts ADDED
@@ -0,0 +1,38 @@
1
+ import { isAbsolute, relative, resolve } from "node:path";
2
+
3
+ /**
4
+ * Path containment check — is `path` inside (or equal to) `baseDir`?
5
+ *
6
+ * Both inputs are `resolve()`d first for defensive clarity. (Note: `path.relative`
7
+ * already `resolve()`s both args against cwd internally, so this is belt-and-
8
+ * suspenders, not a behavior change — verified: the `security.ts` copy that
9
+ * omitted the explicit `resolve()` produced identical results. The audit's
10
+ * "bug #4" was a false alarm.) Returns `false` when either input is missing.
11
+ *
12
+ * Was hand-copied in 6 server modules (security, workspace-merge, mcp,
13
+ * lifecycle-manager, steward, routes) under three different names. Import this;
14
+ * never re-declare it.
15
+ */
16
+ export function isPathWithinBase(path: string | undefined, baseDir: string | undefined): boolean {
17
+ if (!path || !baseDir) return false;
18
+ const rel = relative(resolve(baseDir), resolve(path));
19
+ return rel === "" || (!!rel && !rel.startsWith("..") && !isAbsolute(rel));
20
+ }
21
+
22
+ /**
23
+ * Parse JSON, returning `fallback` on any failure (non-string input, empty
24
+ * string, or malformed JSON). Never throws.
25
+ *
26
+ * Folds the four fallback-style `parseJson` copies (db, memory-sqlite-broker,
27
+ * provider-catalog-store, automations). The throwing / Buffer / null-returning
28
+ * variants (memory-*-broker operation-context, control-server, insights-db) are
29
+ * deliberately separate — different contracts.
30
+ */
31
+ export function parseJson<T>(raw: unknown, fallback: T): T {
32
+ if (typeof raw !== "string" || !raw) return fallback;
33
+ try {
34
+ return JSON.parse(raw) as T;
35
+ } catch {
36
+ return fallback;
37
+ }
38
+ }
@@ -0,0 +1,80 @@
1
+ import { ValidationError } from "./db";
2
+
3
+ /**
4
+ * Trim + validate an optional string input. Throws `ValidationError` when the
5
+ * value is present but not a string, exceeds `max`, or is required-but-empty.
6
+ * Returns `undefined` for absent/blank non-required values.
7
+ *
8
+ * Single home — was byte-identical in config-store, automations, and routes.
9
+ */
10
+ export function cleanString(
11
+ value: unknown,
12
+ field: string,
13
+ opts: { required?: boolean; max?: number } = {},
14
+ ): string | undefined {
15
+ if (value === undefined || value === null) {
16
+ if (opts.required) throw new ValidationError(`${field} required`);
17
+ return undefined;
18
+ }
19
+ if (typeof value !== "string") throw new ValidationError(`${field} must be a string`);
20
+ const trimmed = value.trim();
21
+ if (opts.required && !trimmed) throw new ValidationError(`${field} required`);
22
+ if (opts.max && trimmed.length > opts.max) {
23
+ throw new ValidationError(`${field} must be ${opts.max} characters or fewer`);
24
+ }
25
+ return trimmed || undefined;
26
+ }
27
+
28
+ /**
29
+ * Validate a REQUIRED enum value: throws `ValidationError` if missing or not one
30
+ * of `valid`. Use when the field must be present. (config-store + automations
31
+ * no-fallback semantics.) For optional/defaulted enums use {@link optionalEnum}.
32
+ */
33
+ export function cleanEnum<T extends readonly string[]>(value: unknown, field: string, valid: T): T[number] {
34
+ if (typeof value !== "string" || !valid.includes(value)) {
35
+ throw new ValidationError(`${field} must be one of: ${valid.join(", ")}`);
36
+ }
37
+ return value as T[number];
38
+ }
39
+
40
+ /**
41
+ * Validate an OPTIONAL enum value. Absent (`undefined`/`null`) → returns
42
+ * `fallback` (which may itself be undefined). A present-but-invalid value still
43
+ * throws. (routes + automations with-fallback semantics.) The overloads keep the
44
+ * return type non-undefined when a concrete `fallback` is supplied.
45
+ */
46
+ export function optionalEnum<T extends readonly string[]>(value: unknown, field: string, valid: T, fallback: T[number]): T[number];
47
+ export function optionalEnum<T extends readonly string[]>(value: unknown, field: string, valid: T, fallback?: T[number]): T[number] | undefined;
48
+ export function optionalEnum<T extends readonly string[]>(value: unknown, field: string, valid: T, fallback?: T[number]): T[number] | undefined {
49
+ if (value === undefined || value === null) return fallback;
50
+ if (typeof value !== "string" || !valid.includes(value)) {
51
+ throw new ValidationError(`${field} must be one of: ${valid.join(", ")}`);
52
+ }
53
+ return value as T[number];
54
+ }
55
+
56
+ /**
57
+ * Validate an array of strings: trims + dedups each item, drops blanks. Throws
58
+ * if a present value isn't an array, an item exceeds `itemMax`, or the count
59
+ * exceeds `maxItems`. Absent → `undefined` (or throws when `required`).
60
+ *
61
+ * Callers pass their own per-context limits (`itemMax`/`maxItems`). Sites that
62
+ * previously returned `[]` for absent input append `?? []`. Single home — folded
63
+ * the config-store/automations/routes copies that differed only in those limits.
64
+ */
65
+ export function cleanStringArray(
66
+ value: unknown,
67
+ field: string,
68
+ opts: { required?: boolean; itemMax?: number; maxItems?: number } = {},
69
+ ): string[] | undefined {
70
+ if (value === undefined || value === null) {
71
+ if (opts.required) throw new ValidationError(`${field} required`);
72
+ return undefined;
73
+ }
74
+ if (!Array.isArray(value)) throw new ValidationError(`${field} must be an array of strings`);
75
+ const cleaned = value.map((item) => cleanString(item, `${field} item`, { max: opts.itemMax })).filter(Boolean) as string[];
76
+ if (opts.maxItems && cleaned.length > opts.maxItems) {
77
+ throw new ValidationError(`${field} can contain at most ${opts.maxItems} values`);
78
+ }
79
+ return [...new Set(cleaned)];
80
+ }
@@ -0,0 +1,29 @@
1
+ import type { WorkspaceRecord } from "./types";
2
+
3
+ // A steward claims a workspace before validating/landing so the deterministic
4
+ // auto-merge (Layer 0) doesn't race it (#208 / steward report §1). The claim is a
5
+ // TTL'd lease stored in row metadata, so a dead steward can't block the workspace
6
+ // forever — it expires and auto-merge resumes. Renew by re-claiming.
7
+ export const STEWARD_CLAIM_TTL_MS = Number(process.env.AGENT_RELAY_WORKSPACE_CLAIM_TTL_MS) || 15 * 60_000;
8
+
9
+ export interface WorkspaceClaim {
10
+ by?: string;
11
+ purpose?: string;
12
+ claimedAt?: number;
13
+ expiresAt?: number;
14
+ }
15
+
16
+ /** The active (unexpired) claim on a workspace, or null. Reads row metadata. */
17
+ export function workspaceActiveClaim(ws: Pick<WorkspaceRecord, "metadata">, now: number = Date.now()): WorkspaceClaim | null {
18
+ const meta = ws.metadata as Record<string, unknown> | undefined;
19
+ const claim = meta && typeof meta === "object" ? (meta.stewardClaim as WorkspaceClaim | undefined) : undefined;
20
+ if (!claim || typeof claim !== "object") return null;
21
+ const expiresAt = typeof claim.expiresAt === "number" ? claim.expiresAt : 0;
22
+ return expiresAt > now ? claim : null;
23
+ }
24
+
25
+ /** Metadata patch that records a fresh claim (or clears one when releasing). */
26
+ export function claimMetadataPatch(release: boolean, by?: string, purpose?: string, now: number = Date.now()): Record<string, unknown> {
27
+ if (release) return { stewardClaim: null };
28
+ return { stewardClaim: { by, purpose, claimedAt: now, expiresAt: now + STEWARD_CLAIM_TTL_MS } };
29
+ }
@@ -1,21 +1,25 @@
1
- import { isAbsolute, relative, resolve } from "node:path";
2
1
  import { createCommand } from "./commands-db";
3
2
  import {
4
3
  acquireMergeLease,
4
+ getAgent,
5
5
  listOrchestrators,
6
6
  releaseMergeLease,
7
7
  setMergeLeaseCommand,
8
8
  updateWorkspaceStatus,
9
9
  } from "./db";
10
10
  import type { Command, WorkspaceMergeStrategy, WorkspaceRecord } from "./types";
11
+ import { isPathWithinBase } from "./utils";
11
12
 
12
13
  export interface RequestWorkspaceMergeOptions {
13
14
  /** Who asked for the merge (lease holder + audit). e.g. an agent id, "dashboard", "auto-merge". */
14
15
  requestedBy: string;
15
16
  /** Merge strategy; "auto" lets the host pick pr-vs-rebase-ff. Defaults to "auto". */
16
17
  strategy?: WorkspaceMergeStrategy;
17
- /** Delete the agent branch after a successful land. Defaults to true. */
18
+ /** Delete the agent branch after a successful land. Defaults to true, but is
19
+ * forced false when the workspace owner is still alive (see #204). */
18
20
  deleteBranch?: boolean;
21
+ /** Push the landed base to origin. Defaults to true (host skips when no upstream). */
22
+ push?: boolean;
19
23
  prTitle?: string;
20
24
  prBody?: string;
21
25
  /** Extra metadata merged onto the workspace row when moving to merge_planned. */
@@ -26,11 +30,12 @@ export type RequestWorkspaceMergeResult =
26
30
  | { ok: true; command: Command; workspace: WorkspaceRecord }
27
31
  | { ok: false; status: number; error: string };
28
32
 
29
- // Real path containment (same rule as routes/mcp): never string startsWith.
30
- function pathWithinBase(path: string | undefined, baseDir: string | undefined): boolean {
31
- if (!path || !baseDir) return false;
32
- const rel = relative(resolve(baseDir), resolve(path));
33
- return rel === "" || (!!rel && !rel.startsWith("..") && !isAbsolute(rel));
33
+ // The owner is "alive" while its relay agent exists and isn't offline (online or
34
+ // a borderline-stale disconnect both count don't nuke a worktree on a blip).
35
+ function isOwnerAlive(ownerAgentId: string | undefined): boolean {
36
+ if (!ownerAgentId) return false;
37
+ const agent = getAgent(ownerAgentId);
38
+ return Boolean(agent) && agent!.status !== "offline";
34
39
  }
35
40
 
36
41
  /**
@@ -60,12 +65,18 @@ export function requestWorkspaceMerge(workspace: WorkspaceRecord, opts: RequestW
60
65
  try {
61
66
  // Merge needs a live host: rebasing against a stale base later is unsafe.
62
67
  const onlineOwner = listOrchestrators().find(
63
- (candidate) => candidate.status === "online" && pathWithinBase(workspace.sourceCwd, candidate.baseDir),
68
+ (candidate) => candidate.status === "online" && isPathWithinBase(workspace.sourceCwd, candidate.baseDir),
64
69
  );
65
70
  if (!onlineOwner) {
66
71
  releaseMergeLease({ repoRoot: workspace.repoRoot, workspaceId: workspace.id });
67
72
  return { ok: false, status: 409, error: "no online orchestrator available for workspace merge" };
68
73
  }
74
+ // Never tear down a worktree whose owner is still alive (#204): an online/stale
75
+ // owner keeps its worktree + branch and continues working after the land (the
76
+ // host returns it to `active`). Only an offline/absent owner gets the worktree
77
+ // reclaimed. This overrides the caller's deleteBranch when the owner is alive.
78
+ const ownerAlive = isOwnerAlive(workspace.ownerAgentId);
79
+ const deleteBranch = ownerAlive ? false : (opts.deleteBranch !== false);
69
80
  const updated = updateWorkspaceStatus(workspace.id, "merge_planned", {
70
81
  ...(opts.metadata ?? {}),
71
82
  lastWorkspaceAction: "merge",
@@ -89,7 +100,8 @@ export function requestWorkspaceMerge(workspace: WorkspaceRecord, opts: RequestW
89
100
  baseRef: workspace.baseRef,
90
101
  baseSha: workspace.baseSha,
91
102
  strategy: opts.strategy ?? "auto",
92
- deleteBranch: opts.deleteBranch !== false,
103
+ deleteBranch,
104
+ push: opts.push !== false,
93
105
  prTitle: opts.prTitle,
94
106
  prBody: opts.prBody,
95
107
  requestedBy: opts.requestedBy,