819 lines
39 KiB
TypeScript
819 lines
39 KiB
TypeScript
// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. config module for scripts/src/agentrun.ts.
|
|
|
|
// Moved mechanically from scripts/src/agentrun.ts:6916-7650 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 { spawnSync } from "node:child_process";
|
|
import { rootPath, type UniDeskConfig } from "../config";
|
|
import type { RenderedCliResult } from "../output";
|
|
import { applyLocalCaddyManagedSite } from "../pk01-caddy";
|
|
import { runSshCommandCapture, 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 { AgentRunFailureKind, AgentRunHttpMethod, AgentRunResolvedAuth, AgentRunRestTarget, AgentRunRestTargetOptions, AgentRunSessionPolicyTarget } from "./utils";
|
|
import { isRecord, pickCompact } from "./options";
|
|
import { pathValue } from "./render";
|
|
import { AgentRunRestError, activeAgentRunRestTarget } from "./rest-bridge";
|
|
import { record, stringOrNull } from "./utils";
|
|
|
|
export function readAgentRunClientConfig(env: NodeJS.ProcessEnv = process.env): AgentRunClientConfig {
|
|
const configPath = env.AGENTRUN_CLIENT_CONFIG ?? rootPath("config", "agentrun.yaml");
|
|
if (!existsSync(configPath)) throw new AgentRunRestError("validation-failed", `AgentRun client config not found: ${configPath}`);
|
|
try {
|
|
const parsed = parseAgentRunClientConfigYaml(readFileSync(configPath, "utf8"), configPath);
|
|
return { ...parsed, sourcePath: configPath };
|
|
} catch (error) {
|
|
if (error instanceof AgentRunRestError) throw error;
|
|
throw new AgentRunRestError("validation-failed", `AgentRun client config invalid: ${error instanceof Error ? error.message : String(error)}`, {
|
|
bridge: { mode: "direct-http", clientRole: "render-only", configPath },
|
|
});
|
|
}
|
|
}
|
|
|
|
export function parseAgentRunClientConfigYaml(raw: string, sourcePath = "config/agentrun.yaml"): AgentRunClientConfig {
|
|
const input = record(Bun.YAML.parse(raw) as unknown);
|
|
validateAgentRunConfigEnvelope(input, sourcePath);
|
|
const manager = record(input.manager);
|
|
const auth = record(input.auth);
|
|
const client = record(input.client);
|
|
const baseUrl = stringFieldFromRecord(manager, "baseUrl", "manager");
|
|
try {
|
|
const parsed = new URL(baseUrl);
|
|
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error("invalid protocol");
|
|
} catch {
|
|
throw new Error(`${sourcePath}: manager.baseUrl must be an http(s) URL`);
|
|
}
|
|
const timeoutMs = numberFieldFromRecord(manager, "timeoutMs", "manager", { min: 1, max: 60_000 });
|
|
const header = stringFieldFromRecord(auth, "header", "auth");
|
|
const scheme = stringFieldFromRecord(auth, "scheme", "auth");
|
|
if (header.toLowerCase() !== "authorization") throw new Error(`${sourcePath}: auth.header must be Authorization`);
|
|
if (scheme !== "Bearer") throw new Error(`${sourcePath}: auth.scheme must be Bearer`);
|
|
const role = stringFieldFromRecord(client, "role", "client");
|
|
const transport = stringFieldFromRecord(client, "transport", "client");
|
|
if (role !== "render-only") throw new Error(`${sourcePath}: client.role must be render-only`);
|
|
if (transport !== "direct-http") throw new Error(`${sourcePath}: client.transport must be direct-http`);
|
|
const sessionPolicy = readAgentRunSessionPolicyConfig(client, sourcePath);
|
|
const publicExposure = readAgentRunPublicExposureConfig(input.publicExposure, baseUrl.replace(/\/+$/u, "/"), sourcePath);
|
|
return {
|
|
sourcePath,
|
|
manager: {
|
|
baseUrl: baseUrl.replace(/\/+$/u, "/"),
|
|
timeoutMs,
|
|
},
|
|
auth: {
|
|
env: stringFieldFromRecord(auth, "env", "auth"),
|
|
file: stringFieldFromRecord(auth, "file", "auth"),
|
|
header,
|
|
scheme,
|
|
},
|
|
client: {
|
|
role,
|
|
transport,
|
|
sessionPolicy,
|
|
},
|
|
publicExposure,
|
|
};
|
|
}
|
|
|
|
export function readAgentRunSessionPolicyConfig(client: Record<string, unknown>, sourcePath: string): AgentRunSessionPolicyConfig {
|
|
const pathValue = "client.sessionPolicy";
|
|
const sessionPolicy = record(client.sessionPolicy);
|
|
const workspaceRef = record(sessionPolicy.workspaceRef);
|
|
const executionPolicy = record(sessionPolicy.executionPolicy);
|
|
const secretScope = record(executionPolicy.secretScope);
|
|
stringFieldFromRecord(workspaceRef, "kind", `${pathValue}.workspaceRef`);
|
|
stringFieldFromRecord(executionPolicy, "sandbox", `${pathValue}.executionPolicy`);
|
|
stringFieldFromRecord(executionPolicy, "approval", `${pathValue}.executionPolicy`);
|
|
positiveIntegerFieldFromRecord(executionPolicy, "timeoutMs", `${pathValue}.executionPolicy`);
|
|
stringFieldFromRecord(executionPolicy, "network", `${pathValue}.executionPolicy`);
|
|
booleanFieldFromRecord(secretScope, "allowCredentialEcho", `${pathValue}.executionPolicy.secretScope`);
|
|
return {
|
|
sourcePath,
|
|
tenantId: stringFieldFromRecord(sessionPolicy, "tenantId", pathValue),
|
|
projectId: stringFieldFromRecord(sessionPolicy, "projectId", pathValue),
|
|
providerId: stringFieldFromRecord(sessionPolicy, "providerId", pathValue),
|
|
backendProfile: stringFieldFromRecord(sessionPolicy, "backendProfile", pathValue),
|
|
workspaceRef: cloneJsonRecord(workspaceRef),
|
|
executionPolicy: cloneJsonRecord(executionPolicy),
|
|
};
|
|
}
|
|
|
|
export function validateAgentRunConfigEnvelope(input: Record<string, unknown>, sourcePath: string): void {
|
|
if (input.version !== 1) throw new Error(`${sourcePath}: version must be 1`);
|
|
if (input.kind !== "AgentRunConfig") throw new Error(`${sourcePath}: kind must be AgentRunConfig`);
|
|
const metadata = record(input.metadata);
|
|
if (stringFieldFromRecord(metadata, "name", "metadata") !== "agentrun") throw new Error(`${sourcePath}: metadata.name must be agentrun`);
|
|
}
|
|
|
|
export function readAgentRunPublicExposureConfig(value: unknown, managerBaseUrl: string, sourcePath: string): AgentRunPublicExposure | null {
|
|
if (value === undefined || value === null) return null;
|
|
const exposure = record(value);
|
|
if (exposure.enabled !== true) return null;
|
|
const proxyName = stringFieldFromRecord(exposure, "proxyName", "publicExposure");
|
|
if (!/^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/u.test(proxyName)) throw new Error(`${sourcePath}: publicExposure.proxyName must be a DNS label`);
|
|
const remotePort = numberFieldFromRecord(exposure, "remotePort", "publicExposure", { min: 1, max: 65_535 });
|
|
const publicBaseUrl = normalizedHttpUrl(stringFieldFromRecord(exposure, "publicBaseUrl", "publicExposure"), `${sourcePath}: publicExposure.publicBaseUrl`);
|
|
const masterBaseUrl = normalizedHttpUrl(stringFieldFromRecord(exposure, "masterBaseUrl", "publicExposure"), `${sourcePath}: publicExposure.masterBaseUrl`);
|
|
if (!publicBaseUrl.startsWith("https://")) throw new Error(`${sourcePath}: publicExposure.publicBaseUrl must use https://`);
|
|
if (publicBaseUrl !== managerBaseUrl) throw new Error(`${sourcePath}: manager.baseUrl must match publicExposure.publicBaseUrl`);
|
|
if (!masterBaseUrl.startsWith(`http://127.0.0.1:${remotePort}`)) throw new Error(`${sourcePath}: publicExposure.masterBaseUrl must point to http://127.0.0.1:${remotePort}`);
|
|
const masterFrps = record(exposure.masterFrps);
|
|
const masterCaddy = record(exposure.masterCaddy);
|
|
const caddyEnabled = masterCaddy.enabled === true;
|
|
const domain = stringFieldFromRecord(masterCaddy, "domain", "publicExposure.masterCaddy");
|
|
if (!/^[a-z0-9.-]+$/u.test(domain) || !domain.endsWith(".nip.io")) throw new Error(`${sourcePath}: publicExposure.masterCaddy.domain must be a nip.io hostname`);
|
|
const caddyUpstreamBaseUrl = normalizedHttpUrl(stringFieldFromRecord(masterCaddy, "upstreamBaseUrl", "publicExposure.masterCaddy"), `${sourcePath}: publicExposure.masterCaddy.upstreamBaseUrl`);
|
|
if (caddyUpstreamBaseUrl !== masterBaseUrl) throw new Error(`${sourcePath}: publicExposure.masterCaddy.upstreamBaseUrl must match publicExposure.masterBaseUrl`);
|
|
return {
|
|
enabled: true,
|
|
proxyName,
|
|
remotePort,
|
|
publicBaseUrl,
|
|
masterBaseUrl,
|
|
masterFrps: {
|
|
configPath: absolutePathField(masterFrps, "configPath", "publicExposure.masterFrps", sourcePath),
|
|
containerName: stringFieldFromRecord(masterFrps, "containerName", "publicExposure.masterFrps"),
|
|
},
|
|
masterCaddy: {
|
|
enabled: caddyEnabled,
|
|
domain,
|
|
configPath: absolutePathField(masterCaddy, "configPath", "publicExposure.masterCaddy", sourcePath),
|
|
serviceName: stringFieldFromRecord(masterCaddy, "serviceName", "publicExposure.masterCaddy"),
|
|
upstreamBaseUrl: caddyUpstreamBaseUrl,
|
|
responseHeaderTimeoutSeconds: numberFieldFromRecord(masterCaddy, "responseHeaderTimeoutSeconds", "publicExposure.masterCaddy", { min: 1, max: 600 }),
|
|
},
|
|
};
|
|
}
|
|
|
|
export function normalizedHttpUrl(value: string, label: string): string {
|
|
let url: URL;
|
|
try {
|
|
url = new URL(value);
|
|
} catch {
|
|
throw new Error(`${label} must be an http(s) URL`);
|
|
}
|
|
if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error(`${label} must be an http(s) URL`);
|
|
return value.replace(/\/+$/u, "/");
|
|
}
|
|
|
|
export function absolutePathField(obj: Record<string, unknown>, key: string, pathValue: string, sourcePath: string): string {
|
|
const value = stringFieldFromRecord(obj, key, pathValue);
|
|
if (!value.startsWith("/")) throw new Error(`${sourcePath}: ${pathValue}.${key} must be an absolute path`);
|
|
return value;
|
|
}
|
|
|
|
export function resolveAgentRunAuth(config: AgentRunClientConfig, env: NodeJS.ProcessEnv = process.env): AgentRunResolvedAuth {
|
|
const envValue = env[config.auth.env]?.trim();
|
|
if (envValue) return { source: "env", value: envValue };
|
|
if (existsSync(config.auth.file)) {
|
|
const fromFile = readEnvValueFromFile(config.auth.file, config.auth.env);
|
|
if (fromFile !== null) return { source: "file", value: fromFile };
|
|
}
|
|
throw new AgentRunRestError("auth-missing", `${config.auth.env} is required for direct AgentRun REST access`, {
|
|
bridge: {
|
|
mode: "direct-http",
|
|
clientRole: "render-only",
|
|
configPath: config.sourcePath,
|
|
auth: { env: config.auth.env, file: config.auth.file, source: "missing", header: config.auth.header, scheme: config.auth.scheme, valuesPrinted: false },
|
|
},
|
|
});
|
|
}
|
|
|
|
export function readEnvValueFromFile(pathValue: string, envName: string): string | null {
|
|
const text = readFileSync(pathValue, "utf8");
|
|
for (const line of text.split(/\r?\n/u)) {
|
|
const trimmed = line.trim();
|
|
if (trimmed.length === 0 || trimmed.startsWith("#")) continue;
|
|
const normalized = trimmed.startsWith("export ") ? trimmed.slice("export ".length).trim() : trimmed;
|
|
const index = normalized.indexOf("=");
|
|
if (index <= 0) continue;
|
|
if (normalized.slice(0, index).trim() !== envName) continue;
|
|
const raw = normalized.slice(index + 1).trim();
|
|
const value = raw.replace(/^['"]|['"]$/gu, "");
|
|
return value.length > 0 ? value : null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function agentRunRestBridgeMetadata(config: AgentRunClientConfig, auth: AgentRunResolvedAuth, method: AgentRunHttpMethod, pathValue: string): Record<string, unknown> {
|
|
return {
|
|
mode: "direct-http",
|
|
clientRole: "render-only",
|
|
compatibility: "no-hwlab-runtime-proxy-no-ssh-official-cli",
|
|
configPath: config.sourcePath,
|
|
baseUrl: config.manager.baseUrl,
|
|
request: { method, path: pathValue, timeoutMs: config.manager.timeoutMs },
|
|
auth: { env: config.auth.env, file: config.auth.file, source: auth.source, header: config.auth.header, scheme: config.auth.scheme, valuesPrinted: false },
|
|
};
|
|
}
|
|
|
|
export function agentRunLaneRestBridgeMetadata(config: AgentRunClientConfig, target: AgentRunRestTarget, method: AgentRunHttpMethod, pathValue: string): Record<string, unknown> {
|
|
return {
|
|
mode: "lane-k8s-service-proxy",
|
|
clientRole: "render-only",
|
|
configPath: config.sourcePath,
|
|
laneConfigPath: target.configPath,
|
|
node: target.spec.nodeId,
|
|
lane: target.spec.lane,
|
|
kubeRoute: target.spec.nodeKubeRoute,
|
|
namespace: target.spec.runtime.namespace,
|
|
managerDeployment: target.spec.runtime.managerDeployment,
|
|
baseUrl: target.spec.runtime.internalBaseUrl,
|
|
request: { method, path: pathValue, timeoutMs: config.manager.timeoutMs },
|
|
auth: { source: "manager-pod-env", env: config.auth.env, fallbackEnv: "AGENTRUN_API_KEY", header: config.auth.header, scheme: config.auth.scheme, valuesPrinted: false },
|
|
valuesPrinted: false,
|
|
};
|
|
}
|
|
|
|
export function addAgentRunNotFoundLookupHint(details: Record<string, unknown>, config: AgentRunClientConfig, method: AgentRunHttpMethod, pathValue: string, target: AgentRunRestTarget | null): Record<string, unknown> {
|
|
const resource = agentRunResourceFromPath(pathValue);
|
|
const laneConfig = readAgentRunLaneLookupConfig(config.sourcePath);
|
|
const candidateLanes = laneConfig?.lanes ?? [];
|
|
const nextCommands = candidateLanes
|
|
.filter((lane) => typeof lane.node === "string" && lane.node.length > 0 && typeof lane.lane === "string" && lane.lane.length > 0)
|
|
.slice(0, 4)
|
|
.map((lane) => `bun scripts/cli.ts agentrun control-plane status --node ${lane.node} --lane ${lane.lane}`);
|
|
return {
|
|
...details,
|
|
laneAwareLookup: {
|
|
kind: "agentrun-resource-not-found-on-current-endpoint",
|
|
summary: resource === null
|
|
? "The request returned 404 on the currently configured AgentRun manager endpoint; a resource from another lane will not be visible through this baseUrl."
|
|
: `${resource.kind}/${resource.id} returned 404 on the currently configured AgentRun manager endpoint; if it was created on another lane, inspect that lane before treating the resource as lost.`,
|
|
request: {
|
|
method,
|
|
path: pathValue,
|
|
...(resource === null ? {} : { resource }),
|
|
},
|
|
currentEndpoint: {
|
|
transport: target === null ? "direct-http" : "lane-k8s-service-proxy",
|
|
baseUrl: target === null ? config.manager.baseUrl : target.spec.runtime.internalBaseUrl,
|
|
configPath: config.sourcePath,
|
|
defaultNode: laneConfig?.defaultNode ?? null,
|
|
defaultLane: laneConfig?.defaultLane ?? null,
|
|
selectedNode: target?.spec.nodeId ?? null,
|
|
selectedLane: target?.spec.lane ?? null,
|
|
},
|
|
candidateLanes,
|
|
nextCommands,
|
|
note: target === null
|
|
? "Pass --node <node> --lane <lane> to query a YAML-declared non-default AgentRun lane."
|
|
: "The resource was not found on the selected AgentRun lane; verify the run/session id and the HWLAB runtime lane that created it.",
|
|
valuesPrinted: false,
|
|
},
|
|
};
|
|
}
|
|
|
|
export function agentRunResourceFromPath(pathValue: string): Record<string, string> | null {
|
|
const run = pathValue.match(/\/runs\/([^/?#]+)/u);
|
|
if (run?.[1] !== undefined) return { kind: "run", id: decodeURIComponent(run[1]) };
|
|
const session = pathValue.match(/\/sessions\/([^/?#]+)/u);
|
|
if (session?.[1] !== undefined) return { kind: "session", id: decodeURIComponent(session[1]) };
|
|
const command = pathValue.match(/\/commands\/([^/?#]+)/u);
|
|
if (command?.[1] !== undefined) return { kind: "command", id: decodeURIComponent(command[1]) };
|
|
const task = pathValue.match(/\/queue\/tasks\/([^/?#]+)/u);
|
|
if (task?.[1] !== undefined) return { kind: "task", id: decodeURIComponent(task[1]) };
|
|
return null;
|
|
}
|
|
|
|
export function readAgentRunLaneLookupConfig(configPath: string): { defaultNode: string | null; defaultLane: string | null; lanes: Record<string, unknown>[] } | null {
|
|
try {
|
|
const raw = readFileSync(configPath, "utf8");
|
|
const parsed = record(Bun.YAML.parse(raw) as unknown);
|
|
const controlPlane = record(parsed.controlPlane);
|
|
const defaultTarget = record(controlPlane.default);
|
|
const lanes = record(controlPlane.lanes);
|
|
return {
|
|
defaultNode: stringOrNull(defaultTarget.node),
|
|
defaultLane: stringOrNull(defaultTarget.lane),
|
|
lanes: Object.entries(lanes).map(([laneName, rawLane]) => {
|
|
const lane = record(rawLane);
|
|
const runtime = record(lane.runtime);
|
|
return {
|
|
lane: laneName,
|
|
node: stringOrNull(lane.node) ?? stringOrNull(lane.nodeId),
|
|
version: stringOrNull(lane.version),
|
|
namespace: stringOrNull(runtime.namespace),
|
|
internalBaseUrl: stringOrNull(runtime.internalBaseUrl),
|
|
isDefault: laneName === stringOrNull(defaultTarget.lane),
|
|
};
|
|
}),
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function agentRunDryRunPlan(action: string, pathValue: string, body: Record<string, unknown>, confirmCommand: string, method: AgentRunHttpMethod = "POST", extra: Record<string, unknown> = {}): Record<string, unknown> {
|
|
return {
|
|
ok: true,
|
|
action,
|
|
dryRun: true,
|
|
mutation: false,
|
|
transport: "direct-http",
|
|
clientRole: "render-only",
|
|
request: {
|
|
method,
|
|
path: pathValue,
|
|
bodyKeys: Object.keys(body),
|
|
valuesPrinted: false,
|
|
},
|
|
...extra,
|
|
next: { confirm: confirmCommand },
|
|
valuesPrinted: false,
|
|
};
|
|
}
|
|
|
|
export function agentRunQuery(args: string[], names: string[]): string {
|
|
const params = new URLSearchParams();
|
|
const keyMap: Record<string, string> = {
|
|
"after-seq": "afterSeq",
|
|
"backend-profile": "backendProfile",
|
|
"command": "commandId",
|
|
"command-id": "commandId",
|
|
"reader-id": "readerId",
|
|
"run-id": "runId",
|
|
"updated-after": "updatedAfter",
|
|
};
|
|
for (const name of names) {
|
|
const value = agentRunOption(args, name);
|
|
if (value !== null) params.set(keyMap[name] ?? name, value);
|
|
}
|
|
const query = params.toString();
|
|
return query.length > 0 ? `?${query}` : "";
|
|
}
|
|
|
|
export function agentRunOption(args: string[], name: string): string | null {
|
|
const flag = `--${name}`;
|
|
for (let index = 0; index < args.length; index += 1) {
|
|
const arg = args[index] ?? "";
|
|
if (arg === flag) {
|
|
const value = args[index + 1];
|
|
if (value === undefined || value.startsWith("--")) throw new AgentRunRestError("validation-failed", `${flag} requires a value`);
|
|
return value;
|
|
}
|
|
if (arg.startsWith(`${flag}=`)) return arg.slice(flag.length + 1);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function requiredAgentRunOption(args: string[], names: string[], message: string): string {
|
|
for (const name of names) {
|
|
const value = agentRunOption(args, name);
|
|
if (value !== null) return value;
|
|
}
|
|
throw new AgentRunRestError("validation-failed", message);
|
|
}
|
|
|
|
export function agentRunHasFlag(args: string[], name: string): boolean {
|
|
const flag = `--${name}`;
|
|
return args.includes(flag) || args.some((arg) => arg.startsWith(`${flag}=`));
|
|
}
|
|
|
|
export async function requiredJsonBody(args: string[], context: string): Promise<Record<string, unknown>> {
|
|
const body = await optionalJsonBody(args);
|
|
if (Object.keys(body).length === 0) throw new AgentRunRestError("validation-failed", `${context} requires --json-stdin or --json-file <path>`);
|
|
return body;
|
|
}
|
|
|
|
export async function optionalJsonBody(args: string[]): Promise<Record<string, unknown>> {
|
|
const raw = readJsonTextFromArgs(args, "json");
|
|
if (raw === null) return {};
|
|
return record(JSON.parse(raw) as unknown);
|
|
}
|
|
|
|
export async function optionalRunnerJsonBody(args: string[]): Promise<Record<string, unknown>> {
|
|
const raw = readJsonTextFromArgs(args, "runner-json");
|
|
if (raw === null) return {};
|
|
return record(JSON.parse(raw) as unknown);
|
|
}
|
|
|
|
export function readJsonTextFromArgs(args: string[], prefix: "json" | "runner-json"): string | null {
|
|
if (agentRunHasFlag(args, `${prefix}-stdin`)) return readFileSync(0, "utf8");
|
|
const file = agentRunOption(args, `${prefix}-file`);
|
|
if (file !== null) return file === "-" ? readFileSync(0, "utf8") : readFileSync(file, "utf8");
|
|
return null;
|
|
}
|
|
|
|
export function readYamlInputFromArgs(args: string[]): string {
|
|
if (agentRunHasFlag(args, "yaml-stdin")) return readFileSync(0, "utf8");
|
|
const file = agentRunOption(args, "yaml-file");
|
|
if (file !== null) return file === "-" ? readFileSync(0, "utf8") : readFileSync(file, "utf8");
|
|
throw new AgentRunRestError("validation-failed", "aipod-spec YAML input is required; use --yaml-stdin or --yaml-file <file>");
|
|
}
|
|
|
|
export async function aipodRenderInputFromArgs(args: string[], trailingPromptStart: number, overrides: Record<string, unknown> = {}): Promise<Record<string, unknown>> {
|
|
const input = await optionalJsonBody(args);
|
|
const prompt = optionalPromptFromArgs(args, trailingPromptStart);
|
|
if (prompt !== null) input.prompt = prompt;
|
|
copyAgentRunOptions(args, input, ["tenant-id", "project-id", "queue", "node", "lane", "title", "provider-id", "idempotency-key", "session-id"]);
|
|
const sessionPolicy = readAgentRunClientConfig().client.sessionPolicy;
|
|
const policyTarget = resolveAgentRunSessionPolicyTarget({ node: stringOrNull(input.node), lane: stringOrNull(input.lane) });
|
|
if (input.node === undefined) input.node = policyTarget.spec.nodeId;
|
|
if (input.lane === undefined) input.lane = policyTarget.spec.lane;
|
|
if (input.providerId === undefined) input.providerId = defaultAgentRunProviderId(sessionPolicy, policyTarget);
|
|
const profile = agentRunOption(args, "profile") ?? agentRunOption(args, "backend-profile");
|
|
const backendProfile = profile ?? stringOrNull(input.backendProfile) ?? sessionPolicy.backendProfile;
|
|
input.backendProfile = backendProfile;
|
|
if (!isRecord(input.workspaceRef)) input.workspaceRef = cloneJsonRecord(sessionPolicy.workspaceRef);
|
|
if (!isRecord(input.executionPolicy)) {
|
|
input.executionPolicy = defaultAgentRunExecutionPolicy(backendProfile, sessionPolicy, policyTarget, { includeToolCredentials: true });
|
|
}
|
|
const priority = agentRunOption(args, "priority");
|
|
if (priority) input.priority = Number(priority);
|
|
const workspaceRef = jsonObjectOption(args, "workspace-json");
|
|
if (workspaceRef !== null) input.workspaceRef = workspaceRef;
|
|
return { ...input, ...overrides };
|
|
}
|
|
|
|
export function readPromptFromArgs(args: string[], trailingStart: number): string {
|
|
const prompt = optionalPromptFromArgs(args, trailingStart);
|
|
if (prompt === null) throw new AgentRunRestError("validation-failed", "prompt is required; use --prompt-stdin, --prompt-file, --prompt, or a trailing prompt");
|
|
return prompt;
|
|
}
|
|
|
|
export function optionalPromptFromArgs(args: string[], trailingStart: number): string | null {
|
|
if (agentRunHasFlag(args, "prompt-stdin") || agentRunHasFlag(args, "stdin")) return readFileSync(0, "utf8");
|
|
const promptFile = agentRunOption(args, "prompt-file");
|
|
if (promptFile !== null) return promptFile === "-" ? readFileSync(0, "utf8") : readFileSync(promptFile, "utf8");
|
|
const prompt = agentRunOption(args, "prompt");
|
|
if (prompt !== null) return prompt;
|
|
const trailing = args.slice(trailingStart).filter((arg) => !arg.startsWith("-"));
|
|
return trailing.length > 0 ? trailing.join(" ") : null;
|
|
}
|
|
|
|
export function queueDispatchBodyFromArgs(args: string[]): Record<string, unknown> {
|
|
const body = recordFromMaybeJson(args);
|
|
copyAgentRunOptions(args, body, ["idempotency-key", "image", "namespace", "attempt-id", "runner-id", "source-commit", "service-account-name"]);
|
|
const managerUrl = agentRunOption(args, "runner-manager-url");
|
|
if (managerUrl) body.managerUrl = managerUrl;
|
|
return body;
|
|
}
|
|
|
|
export function recordFromMaybeJson(args: string[]): Record<string, unknown> {
|
|
const raw = readJsonTextFromArgs(args, "json");
|
|
if (raw === null) return {};
|
|
return record(JSON.parse(raw) as unknown);
|
|
}
|
|
|
|
export function cancelBodyFromArgs(args: string[]): Record<string, unknown> {
|
|
const reason = agentRunOption(args, "reason");
|
|
return reason === null ? {} : { reason };
|
|
}
|
|
|
|
export function copyAgentRunOptions(args: string[], target: Record<string, unknown>, flagNames: string[]): void {
|
|
for (const flagName of flagNames) {
|
|
const value = agentRunOption(args, flagName);
|
|
if (value === null) continue;
|
|
target[camelCaseFlag(flagName)] = value;
|
|
}
|
|
}
|
|
|
|
export function camelCaseFlag(flagName: string): string {
|
|
return flagName.replace(/-([a-z])/gu, (_, letter: string) => String(letter).toUpperCase());
|
|
}
|
|
|
|
export function jsonObjectOption(args: string[], flagName: string): Record<string, unknown> | null {
|
|
const value = agentRunOption(args, flagName);
|
|
if (value === null) return null;
|
|
return record(JSON.parse(value) as unknown);
|
|
}
|
|
|
|
export function queueSubmitConfirmCommand(args: string[], aipod?: string): string {
|
|
const parts: string[] = [];
|
|
for (let index = 0; index < args.length; index += 1) {
|
|
const arg = args[index] ?? "";
|
|
if (arg === "submit" || arg === "--dry-run") continue;
|
|
if (arg === "--aipod" || arg === "--aipod-spec") {
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if (arg.startsWith("--aipod=") || arg.startsWith("--aipod-spec=")) continue;
|
|
parts.push(arg);
|
|
}
|
|
const dryRunless = parts.join(" ");
|
|
return `bun scripts/cli.ts agentrun queue submit${aipod ? ` --aipod ${aipod}` : ""}${dryRunless.length > 0 ? ` ${dryRunless}` : ""}`.trim();
|
|
}
|
|
|
|
export function jsonInputDisclosureFromArgs(args: string[]): Record<string, unknown> {
|
|
return {
|
|
source: agentRunHasFlag(args, "json-stdin") ? "stdin" : agentRunOption(args, "json-file") ?? "<task.json>",
|
|
valuesPrinted: false,
|
|
};
|
|
}
|
|
|
|
export function resolveAgentRunSessionPolicyTarget(options: AgentRunRestTargetOptions = { node: null, lane: null }): AgentRunSessionPolicyTarget {
|
|
if (activeAgentRunRestTarget !== null) {
|
|
return {
|
|
configPath: activeAgentRunRestTarget.configPath,
|
|
spec: activeAgentRunRestTarget.spec,
|
|
source: "selected-lane",
|
|
transport: "lane-k8s-service-proxy",
|
|
};
|
|
}
|
|
const node = options.node ?? null;
|
|
const lane = options.lane ?? null;
|
|
const { configPath, spec } = resolveAgentRunLaneTarget({ node, lane });
|
|
return {
|
|
configPath,
|
|
spec,
|
|
source: node !== null || lane !== null ? "selected-lane" : "default-lane",
|
|
transport: "direct-http",
|
|
};
|
|
}
|
|
|
|
export function defaultAgentRunProviderId(sessionPolicy: AgentRunSessionPolicyConfig, target: AgentRunSessionPolicyTarget): string {
|
|
return target.source === "selected-lane" ? target.spec.nodeId : sessionPolicy.providerId;
|
|
}
|
|
|
|
export function defaultAgentRunExecutionPolicy(
|
|
profile: string,
|
|
sessionPolicy: AgentRunSessionPolicyConfig = readAgentRunClientConfig().client.sessionPolicy,
|
|
target: AgentRunSessionPolicyTarget = resolveAgentRunSessionPolicyTarget(),
|
|
options: { includeToolCredentials?: boolean; basePolicy?: Record<string, unknown> } = {},
|
|
): Record<string, unknown> {
|
|
const basePolicy = Object.keys(record(options.basePolicy)).length > 0
|
|
? cloneJsonRecord(record(options.basePolicy))
|
|
: cloneJsonRecord(sessionPolicy.executionPolicy);
|
|
return agentRunExecutionPolicyWithLaneCredentials(basePolicy, profile, target, options.includeToolCredentials === true);
|
|
}
|
|
|
|
export function agentRunExecutionPolicyWithLaneCredentials(basePolicy: Record<string, unknown>, profile: string, target: AgentRunSessionPolicyTarget, includeToolCredentials: boolean): Record<string, unknown> {
|
|
const spec = target.spec;
|
|
const credentials = agentRunProviderCredentialRefs(spec, profile);
|
|
if (credentials.length === 0) {
|
|
throw new AgentRunRestError("validation-failed", `${target.configPath} has no providerCredential Secret binding for backendProfile=${profile} on ${target.source} ${spec.nodeId}/${spec.lane}`);
|
|
}
|
|
const providerCredentials = credentials.map((credential) => {
|
|
if (credential.secretRef.namespace !== spec.runtime.namespace) {
|
|
throw new AgentRunRestError("validation-failed", `providerCredential ${profile} Secret ${credential.secretRef.name} is in namespace ${credential.secretRef.namespace}, expected target lane namespace ${spec.runtime.namespace}`);
|
|
}
|
|
return {
|
|
profile: credential.profile,
|
|
secretRef: {
|
|
name: credential.secretRef.name,
|
|
keys: credential.secretRef.keys,
|
|
},
|
|
};
|
|
});
|
|
const laneToolCredentialRefs = includeToolCredentials ? agentRunToolCredentialRefs(spec) : [];
|
|
const toolCredentialRefs = includeToolCredentials
|
|
? selectBasePolicyToolCredentials(basePolicy, laneToolCredentialRefs, target)
|
|
: [];
|
|
const toolCredentials = toolCredentialRefs.map((credential) => {
|
|
if (credential.secretRef.namespace !== spec.runtime.namespace) {
|
|
throw new AgentRunRestError("validation-failed", `toolCredential ${credential.tool} Secret ${credential.secretRef.name} is in namespace ${credential.secretRef.namespace}, expected target lane namespace ${spec.runtime.namespace}`);
|
|
}
|
|
return {
|
|
tool: credential.tool,
|
|
purpose: credential.purpose,
|
|
secretRef: {
|
|
name: credential.secretRef.name,
|
|
keys: credential.secretRef.keys,
|
|
},
|
|
projection: credential.projection,
|
|
};
|
|
});
|
|
const secretScope = record(basePolicy.secretScope);
|
|
return {
|
|
...basePolicy,
|
|
secretScope: {
|
|
...secretScope,
|
|
providerCredentials,
|
|
...(toolCredentials.length === 0 ? {} : { toolCredentials }),
|
|
},
|
|
};
|
|
}
|
|
|
|
export interface AgentRunToolCredentialRef {
|
|
readonly tool: string;
|
|
readonly purpose: string;
|
|
readonly projection: Record<string, unknown>;
|
|
readonly sourceId: string;
|
|
readonly secretRef: { readonly namespace: string; readonly name: string; readonly keys: readonly string[] };
|
|
readonly valuesPrinted: false;
|
|
}
|
|
|
|
export function agentRunToolCredentialRefs(spec: AgentRunLaneSpec): AgentRunToolCredentialRef[] {
|
|
return spec.toolCredentials.map((credential) => ({
|
|
tool: credential.tool,
|
|
purpose: credential.purpose,
|
|
projection: credential.projection,
|
|
sourceId: credential.id,
|
|
secretRef: credential.secretRef,
|
|
valuesPrinted: false,
|
|
}));
|
|
}
|
|
|
|
function selectBasePolicyToolCredentials(basePolicy: Record<string, unknown>, laneCredentials: readonly AgentRunToolCredentialRef[], target: AgentRunSessionPolicyTarget): AgentRunToolCredentialRef[] {
|
|
const secretScope = record(basePolicy.secretScope);
|
|
if (!Object.hasOwn(secretScope, "toolCredentials")) return [...laneCredentials];
|
|
const rawCredentials = secretScope.toolCredentials;
|
|
if (!Array.isArray(rawCredentials)) {
|
|
throw new AgentRunRestError("validation-failed", "executionPolicy.secretScope.toolCredentials must be an array when lane tool credentials are enabled");
|
|
}
|
|
const laneByIdentity = new Map(laneCredentials.map((credential) => [`${credential.tool}\0${credential.purpose}`, credential]));
|
|
const seen = new Set<string>();
|
|
const selected: AgentRunToolCredentialRef[] = [];
|
|
for (const [index, rawCredential] of rawCredentials.entries()) {
|
|
const credential = record(rawCredential);
|
|
const tool = stringOrNull(credential.tool);
|
|
const purpose = stringOrNull(credential.purpose);
|
|
if (tool === null || purpose === null) {
|
|
throw new AgentRunRestError("validation-failed", `executionPolicy.secretScope.toolCredentials[${index}] must declare tool and purpose`);
|
|
}
|
|
const identity = `${tool}\0${purpose}`;
|
|
if (seen.has(identity)) {
|
|
throw new AgentRunRestError("validation-failed", `executionPolicy.secretScope.toolCredentials contains duplicate ${tool}/${purpose} identity`);
|
|
}
|
|
seen.add(identity);
|
|
const laneCredential = laneByIdentity.get(identity);
|
|
if (laneCredential === undefined) {
|
|
throw new AgentRunRestError("validation-failed", `executionPolicy toolCredential ${tool}/${purpose} is not declared by YAML lane ${target.spec.nodeId}/${target.spec.lane}`);
|
|
}
|
|
const secretRef = record(credential.secretRef);
|
|
const secretName = stringOrNull(secretRef.name);
|
|
const namespace = stringOrNull(secretRef.namespace) ?? target.spec.runtime.namespace;
|
|
if (secretName !== laneCredential.secretRef.name || namespace !== laneCredential.secretRef.namespace) {
|
|
throw new AgentRunRestError("validation-failed", `executionPolicy toolCredential ${tool}/${purpose} SecretRef conflicts with YAML lane ${target.spec.nodeId}/${target.spec.lane}`);
|
|
}
|
|
const keys = normalizedCredentialKeys(secretRef.keys);
|
|
const laneKeys = normalizedCredentialKeys(laneCredential.secretRef.keys);
|
|
if (keys === null || laneKeys === null || keys.length !== laneKeys.length || keys.some((key, keyIndex) => key !== laneKeys[keyIndex])) {
|
|
throw new AgentRunRestError("validation-failed", `executionPolicy toolCredential ${tool}/${purpose} keys conflict with YAML lane ${target.spec.nodeId}/${target.spec.lane}`);
|
|
}
|
|
if (!toolCredentialProjectionMatches(record(credential.projection), laneCredential.projection)) {
|
|
throw new AgentRunRestError("validation-failed", `executionPolicy toolCredential ${tool}/${purpose} projection conflicts with YAML lane ${target.spec.nodeId}/${target.spec.lane}`);
|
|
}
|
|
selected.push(laneCredential);
|
|
}
|
|
return selected;
|
|
}
|
|
|
|
function normalizedCredentialKeys(value: unknown): string[] | null {
|
|
if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || item.trim().length === 0)) return null;
|
|
const keys = value.map((item) => String(item)).sort();
|
|
return new Set(keys).size === keys.length ? keys : null;
|
|
}
|
|
|
|
function toolCredentialProjectionMatches(actual: Record<string, unknown>, expected: Record<string, unknown>): boolean {
|
|
if (actual.kind !== expected.kind) return false;
|
|
if (expected.kind === "env") {
|
|
return actual.envName === expected.envName && actual.secretKey === expected.secretKey;
|
|
}
|
|
if (expected.kind === "volume") return actual.mountPath === expected.mountPath;
|
|
return false;
|
|
}
|
|
|
|
export function renderAgentRunSessionPolicyExplanation(args: string[] = [], options: AgentRunRestTargetOptions = { node: null, lane: null }): string {
|
|
const config = readAgentRunClientConfig();
|
|
const target = resolveAgentRunSessionPolicyTarget(options);
|
|
const spec = target.spec;
|
|
const sessionPolicy = config.client.sessionPolicy;
|
|
const backendProfile = agentRunOption(args, "profile") ?? agentRunOption(args, "backend-profile") ?? sessionPolicy.backendProfile;
|
|
const providerId = agentRunOption(args, "provider-id") ?? defaultAgentRunProviderId(sessionPolicy, target);
|
|
const workspaceRef = jsonObjectOption(args, "workspace-json") ?? cloneJsonRecord(sessionPolicy.workspaceRef);
|
|
const credentials = agentRunProviderCredentialRefs(spec, backendProfile);
|
|
const execution = defaultAgentRunExecutionPolicy(backendProfile, sessionPolicy, target);
|
|
const credentialSources = credentials.map((credential) => ({
|
|
profile: credential.profile,
|
|
secretRef: credential.secretRef,
|
|
valuesPrinted: false,
|
|
}));
|
|
const toolCredentialSources = agentRunToolCredentialRefs(spec);
|
|
return [
|
|
"KIND: session-policy",
|
|
`CONFIG: ${config.sourcePath}`,
|
|
`LANE CONFIG: ${target.configPath}`,
|
|
`TARGET LANE: ${spec.nodeId}/${spec.lane}`,
|
|
`POLICY SOURCE: ${target.source}`,
|
|
`TRANSPORT: ${target.transport}`,
|
|
`DEFAULTS: tenantId=${sessionPolicy.tenantId} projectId=${sessionPolicy.projectId} providerId=${providerId} backendProfile=${backendProfile}`,
|
|
`WORKSPACE: ${JSON.stringify(workspaceRef)}`,
|
|
`EXECUTION: ${JSON.stringify(execution)}`,
|
|
`PROVIDER CREDENTIAL SOURCES: ${JSON.stringify(credentialSources)}`,
|
|
`TOOL CREDENTIAL SOURCES: ${JSON.stringify(toolCredentialSources)}`,
|
|
"VALUES: secret payloads are not printed; valuesPrinted=false",
|
|
].join("\n");
|
|
}
|
|
|
|
export function safeAgentRunEnvelope(envelope: Record<string, unknown>): Record<string, unknown> {
|
|
return pickCompact(envelope, ["ok", "failureKind", "message", "code", "details", "valuesPrinted"]);
|
|
}
|
|
|
|
export function normalizeAgentRunFailureKind(raw: string | null, httpStatus: number): AgentRunFailureKind {
|
|
if (raw === "agentrun-unreachable") return "agentrun-connect-failed";
|
|
if (raw === "auth-missing" || raw === "auth-failed" || raw === "agentrun-connect-failed" || raw === "agentrun-proxy-exec-failed" || raw === "agentrun-manager-fetch-failed" || raw === "agentrun-timeout" || raw === "schema-mismatch" || raw === "unsupported-version" || raw === "validation-failed" || raw === "not-found") return raw;
|
|
if (httpStatus === 401 || httpStatus === 403) return "auth-failed";
|
|
if (httpStatus === 404) return "not-found";
|
|
return raw === "schema-invalid" ? "validation-failed" : "validation-failed";
|
|
}
|
|
|
|
export function classifyAgentRunProxyFailureKind(raw: string | null): AgentRunFailureKind {
|
|
if (raw === "manager-pod-fetch-timeout" || raw === "agentrun-timeout") return "agentrun-timeout";
|
|
if (raw === "manager-pod-fetch-failed") return "agentrun-manager-fetch-failed";
|
|
const normalized = normalizeAgentRunFailureKind(raw, 0);
|
|
return normalized === "validation-failed" ? "agentrun-proxy-exec-failed" : normalized;
|
|
}
|
|
|
|
export function stringFieldFromRecord(obj: Record<string, unknown>, key: string, pathValue: string): string {
|
|
const value = obj[key];
|
|
if (typeof value !== "string" || value.trim().length === 0) throw new Error(`${pathValue}.${key} must be a non-empty string`);
|
|
return value.trim();
|
|
}
|
|
|
|
export function numberFieldFromRecord(obj: Record<string, unknown>, key: string, pathValue: string, bounds: { min: number; max: number }): number {
|
|
const value = obj[key];
|
|
if (typeof value !== "number" || !Number.isFinite(value) || value < bounds.min || value > bounds.max) throw new Error(`${pathValue}.${key} must be a number between ${bounds.min} and ${bounds.max}`);
|
|
return value;
|
|
}
|
|
|
|
export function positiveIntegerFieldFromRecord(obj: Record<string, unknown>, key: string, pathValue: string): number {
|
|
const value = obj[key];
|
|
if (!Number.isInteger(value) || Number(value) <= 0) throw new Error(`${pathValue}.${key} must be a positive integer`);
|
|
return Number(value);
|
|
}
|
|
|
|
export function booleanFieldFromRecord(obj: Record<string, unknown>, key: string, pathValue: string): boolean {
|
|
const value = obj[key];
|
|
if (typeof value !== "boolean") throw new Error(`${pathValue}.${key} must be a boolean`);
|
|
return value;
|
|
}
|
|
|
|
export function cloneJsonRecord(value: Record<string, unknown>): Record<string, unknown> {
|
|
return JSON.parse(JSON.stringify(value)) as Record<string, unknown>;
|
|
}
|
|
|
|
export interface AgentRunClientConfig {
|
|
sourcePath: string;
|
|
manager: {
|
|
baseUrl: string;
|
|
timeoutMs: number;
|
|
};
|
|
auth: {
|
|
env: string;
|
|
file: string;
|
|
header: string;
|
|
scheme: string;
|
|
};
|
|
client: {
|
|
role: string;
|
|
transport: string;
|
|
sessionPolicy: AgentRunSessionPolicyConfig;
|
|
};
|
|
publicExposure: AgentRunPublicExposure | null;
|
|
}
|
|
|
|
export interface AgentRunSessionPolicyConfig {
|
|
sourcePath: string;
|
|
tenantId: string;
|
|
projectId: string;
|
|
providerId: string;
|
|
backendProfile: string;
|
|
workspaceRef: Record<string, unknown>;
|
|
executionPolicy: Record<string, unknown>;
|
|
}
|
|
|
|
export interface AgentRunPublicExposure {
|
|
enabled: true;
|
|
proxyName: string;
|
|
remotePort: number;
|
|
publicBaseUrl: string;
|
|
masterBaseUrl: string;
|
|
masterFrps: {
|
|
configPath: string;
|
|
containerName: string;
|
|
};
|
|
masterCaddy: {
|
|
enabled: boolean;
|
|
domain: string;
|
|
configPath: string;
|
|
serviceName: string;
|
|
upstreamBaseUrl: string;
|
|
responseHeaderTimeoutSeconds: number;
|
|
};
|
|
}
|