import { existsSync } from "node:fs"; import { createHash } from "node:crypto"; import { homedir } from "node:os"; import { isAbsolute, join, relative, resolve } from "node:path"; import { artifactProxyBaseUrl } from "../artifact-proxy"; import { bunBinFromEnv, type OrchestratorConfig } from "../config"; import { SHARED_MCP_URL_ENV, sharedMcpListenerUrl } from "../shared-callmux"; import { sanitizeFsName } from "agent-relay-sdk/fs-name"; import { orchestratorStateOwnerId } from "./constants"; import type { SpawnOptions } from "./types"; export function isWithinBaseDir(path: string, baseDir: string): boolean { const base = resolve(baseDir); const target = resolve(path); const rel = relative(base, target); return rel === "" || (!!rel && !rel.startsWith("..") && !isAbsolute(rel)); } // Keep session names and their related scratch/log artifacts bounded. The // runner independently hashes the full session identity into a fixed-size tmux // socket token, so this display-oriented name no longer controls sun_path size. export const TMUX_SESSION_NAME_MAX_BYTES = 80; function shortHash(value: string): string { return createHash("sha256").update(value).digest("hex").slice(0, 8); } function boundedSlug(value: string, maxBytes: number): string { const clean = sanitizeFsName(value, { replacement: "-", collapseReplacement: true, trimEdge: true, lowercase: true, fallback: "agent" }); if (Buffer.byteLength(clean) <= maxBytes) return clean; const suffix = `-${shortHash(clean)}`; const headBytes = maxBytes - suffix.length; if (headBytes <= 0) return shortHash(clean).slice(0, Math.max(1, maxBytes)); return `${clean.slice(0, headBytes).replace(/-+$/g, "")}${suffix}`; } export function sessionLabelForSpawn(opts: Pick & { label: string }): string { if (opts.taskId === undefined) return opts.label; const base = `task-${opts.taskId}`; const label = opts.label.trim(); if (!label || label.toLowerCase() === base || label.toLowerCase().startsWith(`${base}:`)) return base; return `${base}-${label}`; } export function sessionName(config: OrchestratorConfig, provider: string, label: string, uniqueId?: string): string { const suffix = uniqueId ? `-${sanitizeFsName(uniqueId, { replacement: "-", lowercase: true }).slice(-8)}` : ""; const prefix = `${config.tmuxPrefix}-${provider}-`; const labelBudget = TMUX_SESSION_NAME_MAX_BYTES - Buffer.byteLength(prefix) - Buffer.byteLength(suffix); if (labelBudget < 8) { throw new Error("derived name too long for tmux socket; pass a short `label`"); } return `${prefix}${boundedSlug(label, labelBudget)}${suffix}`; } export function defaultSpawnLabel(now = Date.now()): string { return `session-${now}`; } export function buildRunnerCommand(opts: SpawnOptions, config: OrchestratorConfig): string[] { const repoLauncher = resolve(import.meta.dir, "../../../runner/src/index.ts"); const installedLauncher = resolve(import.meta.dir, "../../../agent-relay-runner/src/index.ts"); const bun = bunBinFromEnv() || (process.platform === "darwin" && existsSync("/opt/homebrew/bin/bun") ? "/opt/homebrew/bin/bun" : "bun"); const launcher = existsSync(repoLauncher) ? [bun, "run", repoLauncher, opts.provider] : existsSync(installedLauncher) ? [bun, "run", installedLauncher, opts.provider] : [`${opts.provider}-relay`, opts.provider]; const args = [ ...launcher, "--headless", "--cwd", opts.cwd, "--relay-url", config.relayUrl, "--approval", opts.approvalMode || "guarded", ]; if (opts.rig) args.push("--rig", opts.rig); if (opts.model) args.push("--model", opts.model); if (opts.effort) args.push("--effort", opts.effort); if (opts.profile) args.push("--profile", opts.profile); if (opts.label) args.push("--label", opts.label); if (opts.agentId) args.push("--agent-id", opts.agentId); if (opts.prompt) args.push("--prompt", opts.prompt); if (opts.systemPromptAppend) args.push("--system-prompt-append", opts.systemPromptAppend); if (opts.tags?.length) args.push("--tags", opts.tags.join(",")); if (opts.capabilities?.length) args.push("--caps", opts.capabilities.join(",")); if (opts.providerArgs?.length) args.push("--", ...opts.providerArgs); return args; } export function buildEnv(opts: SpawnOptions & { label: string; agentId: string }, config: OrchestratorConfig, logFile?: string, tmuxSession?: string): Record { const currentPath = process.env.PATH || ""; const extraPaths = [ join(homedir(), ".local", "bin"), join(homedir(), ".bun", "bin"), join(homedir(), ".npm-global", "bin"), ]; const fullPath = [...extraPaths, ...currentPath.split(":").filter(Boolean)] .filter((v, i, a) => a.indexOf(v) === i) .join(":"); return sanitizeWorkspaceEnv({ ...process.env as Record, ...(config.token ? { AGENT_RELAY_TOKEN: config.token } : {}), ...config.env, ...agentProfileEnv(opts.agentProfile), ...(opts.env || {}), PATH: fullPath, AGENT_RELAY_URL: config.relayUrl, // #1256 finding 3 — the authoritative owner identity for provider-home marker // attribution. Set in this trailing (authoritative) block, AFTER the process.env / // config.env / agentProfileEnv / opts.env spreads above, so a profile or spawn env // can NEVER override the owner id the runner stamps into each home's marker — the // server-side reaper matches homes by exactly this id. `config.id` is the same value // loadConfig resolved (config-file id > AGENT_RELAY_ORCHESTRATOR_ID > hostname), so // the runner's resolveLocalOrchestratorId() derives an identical id. AGENT_RELAY_ORCHESTRATOR_ID: config.id, AGENT_RELAY_ORCHESTRATOR_URL: `http://127.0.0.1:${config.apiPort}`, AGENT_RELAY_ARTIFACT_URL: artifactProxyBaseUrl(config), AGENT_RELAY_APPROVAL: opts.approvalMode || "guarded", ...(opts.profile ? { AGENT_RELAY_AGENT_PROFILE: opts.profile } : {}), ...(opts.agentProfile ? { AGENT_RELAY_AGENT_PROFILE_JSON: JSON.stringify(opts.agentProfile) } : {}), ...(opts.relayInjectionEvents?.length ? { AGENT_RELAY_INJECTION_EVENTS_JSON: JSON.stringify(opts.relayInjectionEvents) } : {}), // #330 — tag by TRUE origin. An MCP spawn (an agent spawning a helper) is `agent-spawned`, not // `dashboard-spawned`; the old blanket `dashboard-spawned` mislabeled every headless spawn as // dashboard-originated. Dashboard/CLI/automation spawns (no `requestedVia: "mcp"`) keep the // `dashboard-spawned` tag the smoke test and UI filter on. AGENT_RELAY_TAGS: [...new Set(["headless", opts.requestedVia === "mcp" ? "agent-spawned" : "dashboard-spawned", config.hostname, ...(opts.tags ?? [])])].join(","), AGENT_RELAY_CAPS: [...new Set(opts.capabilities ?? [])].join(","), AGENT_RELAY_CAPABILITIES: [...new Set(opts.capabilities ?? [])].join(","), AGENT_RELAY_HEADLESS: "1", ...(logFile ? { AGENT_RELAY_LOG_FILE: logFile } : {}), ...(tmuxSession ? { AGENT_RELAY_TMUX_SESSION: tmuxSession } : {}), // #1514 — the owning state-home id the runner stamps onto the tmux session // (@agent-relay-owner); the wedged-session reaper reaps only sessions matching // its own id. Authoritative (trailing) so no profile/spawn env can spoof it. AGENT_RELAY_TMUX_OWNER: orchestratorStateOwnerId(), ...(opts.label ? { AGENT_RELAY_LABEL: opts.label } : {}), ...(opts.policyName ? { AGENT_RELAY_POLICY: opts.policyName } : {}), ...(opts.spawnRequestId ? { AGENT_RELAY_SPAWN_REQUEST_ID: opts.spawnRequestId } : {}), ...(opts.taskId !== undefined ? { AGENT_RELAY_TASK_ID: String(opts.taskId) } : {}), // #673 — the orchestrator owns the shared host callmux listener; runners consume this // authoritative URL through the existing #672 sharedMcpUrl seam. [SHARED_MCP_URL_ENV]: sharedMcpListenerUrl(), AGENT_RELAY_LIFECYCLE: opts.lifecycle ?? "persistent", AGENT_RELAY_WORKSPACE_MODE: opts.workspaceMode ?? "inherit", ...(opts.workspace ? { AGENT_RELAY_WORKSPACE_JSON: JSON.stringify(opts.workspace) } : {}), ...(opts.automationId ? { AGENT_RELAY_AUTOMATION_ID: opts.automationId } : {}), ...(opts.automationRunId ? { AGENT_RELAY_AUTOMATION_RUN_ID: opts.automationRunId } : {}), }, opts.workspaceMode); } function agentProfileEnv(profile: Record | undefined): Record { const raw = profile?.env; if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {}; return Object.fromEntries(Object.entries(raw).filter((entry): entry is [string, string] => typeof entry[1] === "string")); } function sanitizeWorkspaceEnv(env: Record, workspaceMode: string | undefined): Record { if (workspaceMode !== "isolated") return env; const isolatedEnv = { ...env }; delete isolatedEnv.PUBLIC_DIR; return isolatedEnv; }