// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. utils module for scripts/src/agentrun.ts. // Moved mechanically from scripts/src/agentrun.ts:7651-8200 for #903. // SPEC: PJ2026-01020108 cancel lifecycle + PJ2026-01020205 AipodSpec binding + PJ2026-01020302 session policy + PJ2026-01020305 cancel control + PJ2026-01060305/06 YAML execution policy and bounded output draft-2026-06-25-p0. // Exposes AgentRun lane-scoped policy, AipodSpec SecretRef binding, cancel lifecycle, and bounded default output in the UniDesk CLI. import { chmodSync, copyFileSync, existsSync, readFileSync, statSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { rootPath, type UniDeskConfig } from "../config"; import type { RenderedCliResult } from "../output"; import { applyLocalCaddyManagedSite } from "../pk01-caddy"; import { runSshCommandCapture, sshCaptureBackendPlan, type SshCaptureBackendPlan, type SshCaptureResult } from "../ssh"; import { runRemoteSshCommandCapture } from "../remote"; import { startJob } from "../jobs"; import { AGENTRUN_CONFIG_PATH, agentRunLaneSummary, agentRunPipelineRunName, agentRunProviderCredentialRefs, resolveAgentRunLaneTarget, type AgentRunCancelLifecycleSpec, type AgentRunLaneSpec, } from "../agentrun-lanes"; import { agentRunImageArtifact, placeholderAgentRunImage, renderedFilesDigest, renderedObjectsDigest, renderAgentRunControlPlaneManifests, renderAgentRunGitopsFiles, type AgentRunArtifactService, } from "../agentrun-manifests"; import { sha256Fingerprint } from "../platform-infra-ops-library"; import type { TimedValue } from "./options"; import { status } from "./control-plane"; import { displayValue, truncateOneLine } from "./options"; import { renderedCliResult } from "./render"; export interface AgentRunResolvedAuth { source: "env" | "file"; value: string; } export type AgentRunHttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; export type AgentRunFailureKind = "auth-missing" | "auth-failed" | "agentrun-connect-failed" | "agentrun-proxy-exec-failed" | "agentrun-manager-fetch-failed" | "agentrun-timeout" | "schema-mismatch" | "unsupported-version" | "validation-failed" | "not-found"; export type AgentRunRestCompatGroup = "queue" | "sessions" | "aipod-specs" | "aipods" | "runs" | "commands" | "runner"; export type AgentRunResourceVerb = "get" | "describe" | "events" | "logs" | "result" | "ack" | "cancel" | "dispatch" | "create" | "apply" | "send" | "explain"; export type AgentRunResourceKind = "task" | "run" | "command" | "runnerjob" | "session" | "aipodspec"; export type AgentRunOutputMode = "human" | "wide" | "name" | "json" | "yaml"; export interface AgentRunResourceRef { kind: AgentRunResourceKind; name: string; } export interface AgentRunResourceOptions { output: AgentRunOutputMode; full: boolean; raw: boolean; debug: boolean; limit: number; queue: string | null; state: string | null; unread: boolean; readerId: string; taskId: string | null; runId: string | null; commandId: string | null; sessionId: string | null; afterSeq: number | null; tail: number | null; fullText: boolean; reason: string | null; dryRun: boolean; file: string | null; aipod: string | null; idempotencyKey: string | null; promptStdin: boolean; node: string | null; lane: string | null; passthroughArgs: string[]; } export interface AgentRunRestTargetOptions { node: string | null; lane: string | null; } export interface AgentRunRestCompatOptions extends AgentRunRestTargetOptions { output: AgentRunOutputMode; full: boolean; raw: boolean; } export interface AgentRunRestTarget { config: UniDeskConfig; configPath: string; spec: AgentRunLaneSpec; } export interface AgentRunSessionPolicyTarget { configPath: string; spec: AgentRunLaneSpec; source: "selected-lane" | "default-lane"; transport: "direct-http" | "lane-k8s-service-proxy"; } export type AgentRunBridgeCaptureBackend = "local-backend-core-broker" | "remote-frontend-websocket"; export type LocalBackendCoreStatus = SshCaptureBackendPlan["localBackendCore"]; export interface AgentRunBridgeCapturePlan { backend: AgentRunBridgeCaptureBackend; route: string; reason: string; remoteHost: string | null; localBackendCore: LocalBackendCoreStatus; } export type AgentRunBridgeCaptureResult = SshCaptureResult & { bridgeExecution?: AgentRunBridgeCapturePlan }; export let localBackendCoreStatusCache: LocalBackendCoreStatus | null = null; export async function capture(config: UniDeskConfig, target: string, args: string[]): Promise { const plan = agentRunBridgeCapturePlan(config, target); if (plan.backend === "remote-frontend-websocket" && plan.remoteHost !== null) { return await captureRemote(config, plan, target, args); } const local = attachBridgeExecution(await runSshCommandCapture(config, target, args), plan); const fallbackHost = agentRunBridgeRemoteHost(config); if (local.exitCode !== 0 && fallbackHost !== null && bridgeExecutionFailureKind(local)?.degradedReason === "capture-backend-unavailable") { const fallbackPlan: AgentRunBridgeCapturePlan = { ...plan, backend: "remote-frontend-websocket", remoteHost: fallbackHost, reason: "local-capture-backend-unavailable", }; return await captureRemote(config, fallbackPlan, target, args); } return local; } export async function captureRemote(config: UniDeskConfig, plan: AgentRunBridgeCapturePlan, target: string, args: string[]): Promise { if (plan.remoteHost === null) return attachBridgeExecution(remoteBridgeCaptureFailure(new Error("remote host is not configured")), plan); try { return attachBridgeExecution(await runRemoteSshCommandCapture(config, plan.remoteHost, target, args), plan); } catch (error) { return attachBridgeExecution(remoteBridgeCaptureFailure(error), plan); } } export function remoteBridgeCaptureFailure(error: unknown): SshCaptureResult { const message = error instanceof Error ? `${error.name}: ${error.message}` : String(error); return { exitCode: 255, stdout: "", stderr: `unidesk remote frontend ssh bridge failed: ${message}\n`, }; } export function attachBridgeExecution(result: SshCaptureResult, plan: AgentRunBridgeCapturePlan): AgentRunBridgeCaptureResult { return { ...result, bridgeExecution: plan }; } export function agentRunBridgeCapturePlan(config: UniDeskConfig, target: string, env: NodeJS.ProcessEnv = process.env): AgentRunBridgeCapturePlan { const plan = sshCaptureBackendPlan(config, env); return { backend: plan.backend, route: target, reason: plan.reason, remoteHost: plan.remoteHost, localBackendCore: plan.localBackendCore, }; } export function isAgentRunRunnerEnvironment(env: NodeJS.ProcessEnv): boolean { return Boolean( env.AGENTRUN_BOOT_MODE || env.AGENTRUN_RUN_ID || env.AGENTRUN_K8S_JOB_NAME || env.CODE_QUEUE_SERVICE_ROLE || env.CODE_QUEUE_INSTANCE_ID || env.KUBERNETES_SERVICE_HOST, ); } export function agentRunBridgeRemoteHost(config: UniDeskConfig, env: NodeJS.ProcessEnv = process.env): string | null { return normalizeRemoteHostHint(env.UNIDESK_MAIN_SERVER_IP) ?? normalizeRemoteHostHint(env.UNIDESK_MAIN_SERVER_HOST) ?? normalizeRemoteHostHint(env.CODE_QUEUE_DEV_CONTAINER_MASTER_HOST) ?? normalizeRemoteHostHint(config.network.publicHost); } export function normalizeRemoteHostHint(raw: string | undefined): string | null { const value = raw?.trim() ?? ""; if (value.length === 0 || value === "localhost" || value === "127.0.0.1" || value === "::1") return null; return value.replace(/\/+$/u, ""); } export function captureBridgeMetadata(result: SshCaptureResult): Record | null { const bridgeExecution = (result as AgentRunBridgeCaptureResult).bridgeExecution; if (bridgeExecution === undefined) return null; return { backend: bridgeExecution.backend, route: bridgeExecution.route, reason: bridgeExecution.reason, remoteHost: bridgeExecution.remoteHost, localBackendCore: bridgeExecution.localBackendCore, }; } export function bridgeExecutionFailureKind(result: SshCaptureResult): { degradedReason: string; failureKind: string; recoveryActions: string[] } | null { if (result.exitCode === 0) return null; const stderr = result.stderr; if (/No such container: unidesk-backend-core|failed to start broker|Executable not found.*"docker"|docker: not found|Cannot connect to the Docker daemon/iu.test(stderr)) { return { degradedReason: "capture-backend-unavailable", failureKind: "bridge-execution-environment", recoveryActions: [ "Run the same command from a healthy UniDesk main-server CLI, or provide UNIDESK_MAIN_SERVER_IP/UNIDESK_MAIN_SERVER_HOST so the bridge can use the frontend WebSocket SSH backend.", "For AgentRun runner jobs, request the unidesk-ssh tool credential SecretRef instead of embedding secret values in prompts or payloads.", "After restoring the bridge backend, retry the same bun scripts/cli.ts agentrun ... command; do not use direct kubectl or GitHub API bypasses.", ], }; } if (/frontend login failed|remote frontend ssh bridge|websocket error|timed out waiting for provider session|route-not-allowed/iu.test(stderr)) { return { degradedReason: "bridge-execution-environment-unavailable", failureKind: "bridge-execution-environment", recoveryActions: [ "Verify the UniDesk frontend/backend-core SSH bridge is reachable from this runner and that UNIDESK_MAIN_SERVER_IP or UNIDESK_MAIN_SERVER_HOST points at the main server.", "Verify scoped UNIDESK_SSH_CLIENT_TOKEN route allowlist includes the requested AgentRun route, without printing token values.", "Retry with the same bun scripts/cli.ts agentrun ... command after the controlled bridge path is restored.", ], }; } return null; } export function compactCapture(result: SshCaptureResult, options: { full?: boolean; stdoutTailChars?: number; stderrTailChars?: number } = {}): Record { const stdoutTailChars = options.stdoutTailChars ?? 8000; const stderrTailChars = options.stderrTailChars ?? 4000; const full = options.full ?? true; const payload: Record = { exitCode: result.exitCode, stdoutBytes: Buffer.byteLength(result.stdout, "utf8"), stderrBytes: Buffer.byteLength(result.stderr, "utf8"), stdoutTailOmitted: !full && result.exitCode === 0, stderrTailOmitted: !full && result.exitCode === 0, }; const bridgeExecution = captureBridgeMetadata(result); if (bridgeExecution !== null) payload.bridgeExecution = bridgeExecution; const failureKind = bridgeExecutionFailureKind(result); if (failureKind !== null) { payload.degradedReason = failureKind.degradedReason; payload.failureKind = failureKind.failureKind; payload.recoveryActions = failureKind.recoveryActions; } if (full || result.exitCode !== 0) { payload.stdoutTail = tail(result.stdout, stdoutTailChars); payload.stderrTail = tail(result.stderr, stderrTailChars); payload.stdoutTruncated = result.stdout.length > stdoutTailChars; payload.stderrTruncated = result.stderr.length > stderrTailChars; } return payload; } export async function timedStatusStage(stage: string, action: () => Promise): Promise> { const startedAtMs = Date.now(); progressEvent("agentrun.control-plane.status.progress", { stage, status: "started" }); try { const value = await action(); const exitCode = isCaptureResult(value) ? value.exitCode : typeof record(value).ok === "boolean" && record(value).ok !== true ? 1 : 0; progressEvent("agentrun.control-plane.status.progress", { stage, status: exitCode === 0 ? "succeeded" : "failed", exitCode, elapsedMs: Date.now() - startedAtMs, }); return { value, elapsedMs: Date.now() - startedAtMs }; } catch (error) { progressEvent("agentrun.control-plane.status.progress", { stage, status: "failed", elapsedMs: Date.now() - startedAtMs, error: error instanceof Error ? error.message : String(error), }); throw error; } } export function progressEvent(event: string, payload: Record): void { const stage = displayValue(payload.stage ?? event); const status = displayValue(payload.status ?? "-"); const details = [ payload.exitCode === undefined ? null : `exit=${displayValue(payload.exitCode)}`, payload.elapsedMs === undefined ? null : `elapsed=${displayValue(payload.elapsedMs)}ms`, payload.error === undefined ? null : `error=${truncateOneLine(displayValue(payload.error), 160)}`, ].filter((item): item is string => item !== null); process.stderr.write(`status ${stage} ${status}${details.length === 0 ? "" : ` ${details.join(" ")}`}\n`); } export function isCaptureResult(value: unknown): value is SshCaptureResult { return typeof value === "object" && value !== null && "exitCode" in value && typeof (value as { exitCode?: unknown }).exitCode === "number"; } export function isAgentRunPipelineRunName(value: string): boolean { return /^agentrun-[a-z0-9-]*ci-[0-9a-f]{12,40}(?:-[a-z0-9-]+)?$/u.test(value); } export function captureJsonPayload(result: SshCaptureResult): Record { const trimmed = result.stdout.trim(); if (trimmed.length === 0) return {}; try { return record(JSON.parse(trimmed) as unknown); } catch { const lastJsonLine = trimmed.split(/\r?\n/u).reverse().find((line) => line.trim().startsWith("{") && line.trim().endsWith("}")); if (lastJsonLine === undefined) return {}; try { return record(JSON.parse(lastJsonLine) as unknown); } catch { return {}; } } } export function record(value: unknown): Record { return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : {}; } export function stringOrNull(value: unknown): string | null { return typeof value === "string" && value.length > 0 ? value : null; } export function nonNegativeIntegerOrNull(value: unknown): number | null { if (typeof value === "number" && Number.isInteger(value) && value >= 0) return value; if (typeof value === "string" && /^\d+$/u.test(value)) return Number(value); return null; } export function isGitSha(value: string): boolean { return /^[0-9a-f]{40}$/u.test(value); } export function tail(text: string, maxChars: number): string { return text.length > maxChars ? text.slice(-maxChars) : text; } export function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } export function shQuote(value: string): string { return `'${value.replace(/'/gu, "'\\''")}'`; } export function unsupported(args: string[]): RenderedCliResult { const command = `agentrun ${args.join(" ")}`.trim(); return renderedCliResult(false, command, [ `Error: unsupported AgentRun command: ${command}`, "", "Supported resource commands:", " agentrun get|describe|events|logs|result|ack|cancel|dispatch|create|apply|send", "", "Compatibility bridge groups:", " agentrun aipod-specs|queue|runs|commands|runner|sessions", "", "Operations:", " agentrun control-plane status|trigger-current|refresh", " agentrun git-mirror status|sync|flush", ].join("\n")); }