import { createHash, randomUUID } from "node:crypto"; import type { BackendProfile, CreateCommandInput, CreateQueueTaskInput, CreateRunInput, ExecutionPolicy, JsonRecord, JsonValue, QueueTaskState, ResourceBundleRef, SecretRef, SessionListState, SessionRef, WorkspaceRef } from "./types.js"; import { AgentRunError } from "./errors.js"; import { backendProfileIdPattern, backendProfileSpec, isBackendProfile } from "./backend-profiles.js"; import { validateAipodImageRef } from "./aipod-image-ref-validation.js"; const backendProfilePatternText = String(backendProfileIdPattern); const allowedTenants = new Set(["unidesk", "hwlab"]); const allowedToolCredentials = ["github", "unidesk-ssh"] as const; export function nowIso(): string { return new Date().toISOString(); } export function newId(prefix: string): string { return `${prefix}_${randomUUID().replace(/-/gu, "")}`; } export function stableHash(value: JsonValue): string { return createHash("sha256").update(JSON.stringify(sortJson(value))).digest("hex"); } function sortJson(value: JsonValue): JsonValue { if (Array.isArray(value)) return value.map(sortJson); if (typeof value !== "object" || value === null) return value; return Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([key, entry]) => [key, sortJson(entry)])); } export function asRecord(value: unknown, fieldName: string): JsonRecord { if (typeof value === "object" && value !== null && !Array.isArray(value)) return value as JsonRecord; throw new AgentRunError("schema-invalid", `${fieldName} must be an object`, { httpStatus: 400 }); } function requiredString(record: JsonRecord, key: string): string { const value = record[key]; if (typeof value !== "string" || value.trim().length === 0) throw new AgentRunError("schema-invalid", `${key} is required`, { httpStatus: 400 }); return value.trim(); } function requiredRecord(record: JsonRecord, key: string): JsonRecord { return asRecord(record[key], key); } export function validateCreateRun(input: unknown): CreateRunInput { const record = asRecord(input, "run"); const tenantId = requiredString(record, "tenantId"); if (!allowedTenants.has(tenantId)) throw new AgentRunError("tenant-policy-denied", `tenantId ${tenantId} is not allowed`, { httpStatus: 403 }); const backendProfileValue = requiredString(record, "backendProfile"); if (!isBackendProfile(backendProfileValue)) throw new AgentRunError("schema-invalid", `backendProfile ${backendProfileValue} must be a lowercase slug`, { httpStatus: 400, details: { pattern: backendProfilePatternText } }); const backendProfile = backendProfileValue as BackendProfile; const executionPolicy = validateExecutionPolicy(requiredRecord(record, "executionPolicy")); validateBackendSecretScope(backendProfile, executionPolicy); const workspaceRef = validateWorkspaceRef(requiredRecord(record, "workspaceRef")); const resourceBundleRef = validateResourceBundleRef(record.resourceBundleRef); const normalizedWorkspaceRef = validatePrimaryWorkspaceContract(workspaceRef, resourceBundleRef); return { tenantId, projectId: requiredString(record, "projectId"), workspaceRef: normalizedWorkspaceRef, sessionRef: validateSessionRef(record.sessionRef), resourceBundleRef, providerId: requiredString(record, "providerId"), backendProfile, executionPolicy, traceSink: record.traceSink ?? null, }; } export function validateSessionRef(value: unknown): SessionRef | null { if (value === undefined || value === null) return null; const record = asRecord(value, "sessionRef"); const sessionId = requiredString(record, "sessionId"); const result: SessionRef = { sessionId }; const conversationId = optionalString(record.conversationId); const threadId = optionalString(record.threadId); const expiresAt = optionalString(record.expiresAt); const metadata = record.metadata === undefined ? undefined : asRecord(record.metadata, "sessionRef.metadata"); if (conversationId) result.conversationId = conversationId; if (threadId) result.threadId = threadId; if (expiresAt) result.expiresAt = expiresAt; if (metadata) result.metadata = metadata; return result; } export function validateResourceBundleRef(value: unknown): ResourceBundleRef | null { if (value === undefined || value === null) return null; const record = asRecord(value, "resourceBundleRef"); const kind = requiredString(record, "kind"); if (kind !== "gitbundle") throw new AgentRunError("schema-invalid", "resourceBundleRef.kind must be gitbundle", { httpStatus: 400 }); const repoUrl = requiredString(record, "repoUrl"); const commitId = optionalString(record.commitId); if (commitId) validateCommitId(commitId, "resourceBundleRef.commitId"); const ref = validateGitRef(record.ref, "resourceBundleRef.ref"); if (!commitId && !ref) throw new AgentRunError("schema-invalid", "resourceBundleRef must declare ref or a full commitId", { httpStatus: 400 }); rejectLegacyResourceBundleFields(record); const result: ResourceBundleRef = { kind: "gitbundle", repoUrl, ...(commitId ? { commitId } : {}), ...(ref ? { ref } : {}), bundles: validateResourceGitBundles(record.bundles, repoUrl, commitId, ref) }; if (record.promptRefs !== undefined) result.promptRefs = validateResourcePromptRefs(record.promptRefs); if (record.requiredSkills !== undefined) result.requiredSkills = validateResourceRequiredSkills(record.requiredSkills); if (record.submodules !== undefined && record.submodules !== false) throw new AgentRunError("schema-invalid", "resourceBundleRef.submodules can only be false in v0.1", { httpStatus: 400 }); if (record.lfs !== undefined && record.lfs !== false) throw new AgentRunError("schema-invalid", "resourceBundleRef.lfs can only be false in v0.1", { httpStatus: 400 }); if (record.submodules === false) result.submodules = false; if (record.lfs === false) result.lfs = false; if (record.credentialRef !== undefined) result.credentialRef = validateSecretRef(asRecord(record.credentialRef, "resourceBundleRef.credentialRef")); return result; } function rejectLegacyResourceBundleFields(record: JsonRecord): void { for (const field of ["toolAliases", "skillRefs", "workspaceFiles", "subdir", "sparsePaths", "gitMirror"] as const) { if (record[field] !== undefined) throw new AgentRunError("schema-invalid", `resourceBundleRef.${field} is removed; use resourceBundleRef.bundles[] with kind=gitbundle`, { httpStatus: 400 }); } } function validateResourceGitBundles(value: unknown, defaultRepoUrl: string, defaultCommitId?: string, defaultRef?: string): ResourceBundleRef["bundles"] { if (!Array.isArray(value)) throw new AgentRunError("schema-invalid", "resourceBundleRef.bundles must be an array", { httpStatus: 400 }); if (value.length === 0) throw new AgentRunError("schema-invalid", "resourceBundleRef.bundles must contain at least one entry", { httpStatus: 400 }); if (value.length > 64) throw new AgentRunError("schema-invalid", "resourceBundleRef.bundles must contain at most 64 entries", { httpStatus: 400 }); const seen = new Set(); const seenTargets = new Set(); return value.map((entry, index) => { const record = asRecord(entry, `resourceBundleRef.bundles[${index}]`); const name = optionalString(record.name); if (name) validateResourceName(name, `resourceBundleRef.bundles[${index}].name`); const repoUrl = optionalString(record.repoUrl) ?? defaultRepoUrl; const explicitCommitId = optionalString(record.commitId); const explicitRef = validateGitRef(record.ref, `resourceBundleRef.bundles[${index}].ref`); if (repoUrl !== defaultRepoUrl && !explicitCommitId && !explicitRef) { throw new AgentRunError("schema-invalid", `resourceBundleRef.bundles[${index}] uses a different repoUrl and must declare its own ref or commitId`, { httpStatus: 400 }); } const commitId = explicitCommitId ?? (repoUrl === defaultRepoUrl ? defaultCommitId : undefined); if (commitId) validateCommitId(commitId, `resourceBundleRef.bundles[${index}].commitId`); const ref = explicitRef ?? (repoUrl === defaultRepoUrl ? defaultRef : undefined); const subpath = validateBundleSubpath(requiredString(record, "subpath"), `resourceBundleRef.bundles[${index}].subpath`); const rawTargetPath = typeof record.targetPath === "string" ? record.targetPath : record.target_path; if (typeof rawTargetPath !== "string" || rawTargetPath.trim().length === 0) throw new AgentRunError("schema-invalid", `resourceBundleRef.bundles[${index}].target_path is required`, { httpStatus: 400 }); const targetPath = validateWorkspaceRelativePath(rawTargetPath.trim(), `resourceBundleRef.bundles[${index}].target_path`); if (targetPath === ".git" || targetPath.startsWith(".git/")) throw new AgentRunError("schema-invalid", `resourceBundleRef.bundles[${index}].target_path cannot replace primary workspace Git metadata`, { httpStatus: 400 }); if (seenTargets.has(targetPath)) throw new AgentRunError("schema-invalid", `resourceBundleRef.bundles[${index}].target_path conflicts with an earlier bundle target`, { httpStatus: 400 }); seenTargets.add(targetPath); const identity = `${repoUrl}\0${commitId ?? ""}\0${ref ?? ""}\0${subpath}\0${targetPath}`; if (seen.has(identity)) throw new AgentRunError("schema-invalid", `resourceBundleRef.bundles[${index}] duplicates an earlier bundle target`, { httpStatus: 400 }); seen.add(identity); return { ...(name ? { name } : {}), ...(repoUrl === defaultRepoUrl ? {} : { repoUrl }), ...(commitId && (repoUrl !== defaultRepoUrl || commitId !== defaultCommitId) ? { commitId } : {}), ...(ref && (repoUrl !== defaultRepoUrl || ref !== defaultRef) ? { ref } : {}), subpath, targetPath }; }); } export function validateWorkspaceRef(record: JsonRecord): WorkspaceRef { const kind = requiredString(record, "kind"); if (!["git-worktree", "host-path", "kubernetes-pvc", "opaque"].includes(kind)) { throw new AgentRunError("schema-invalid", `workspaceRef.kind ${kind} is not supported`, { httpStatus: 400 }); } return record as WorkspaceRef; } export function validatePrimaryWorkspaceContract(workspaceRef: WorkspaceRef | null, resourceBundleRef: ResourceBundleRef | null): WorkspaceRef { if (!resourceBundleRef) { if (!workspaceRef) throw new AgentRunError("schema-invalid", "workspaceRef is required", { httpStatus: 400 }); return workspaceRef; } const normalized: WorkspaceRef = { kind: "opaque", path: "." }; const received = workspaceRef ?? ({} as WorkspaceRef); const ignoredKeys = Object.keys(received).filter((key) => key !== "kind" && key !== "path").sort(); if (received.kind !== normalized.kind || received.path !== normalized.path || ignoredKeys.length > 0) { console.warn(JSON.stringify({ event: "agentrun.contract.warning", code: "gitbundle-workspace-ref-normalized", receivedKind: typeof received.kind === "string" ? received.kind : null, receivedPath: typeof received.path === "string" ? received.path : null, ignoredKeys, action: "continue-with-canonical-workspace-ref", blocking: false, valuesPrinted: false, })); } return normalized; } function validateCommitId(commitId: string, fieldName: string): void { if (!/^[0-9a-f]{40}$/u.test(commitId)) throw new AgentRunError("schema-invalid", `${fieldName} must be a full 40-character git commit sha`, { httpStatus: 400 }); } function validateGitRef(value: unknown, fieldName: string): string | undefined { const ref = optionalString(value); if (!ref) return undefined; if (ref.length > 200 || ref.startsWith("-") || ref.includes("..") || ref.includes("@{") || ref.endsWith("/") || ref.endsWith(".") || /[\s~^:?*[\\\x00-\x1f\x7f]/u.test(ref)) { throw new AgentRunError("schema-invalid", `${fieldName} must be a bounded git ref name`, { httpStatus: 400 }); } return ref; } function validateResourcePromptRefs(value: unknown): NonNullable { if (!Array.isArray(value)) throw new AgentRunError("schema-invalid", "resourceBundleRef.promptRefs must be an array", { httpStatus: 400 }); if (value.length > 16) throw new AgentRunError("schema-invalid", "resourceBundleRef.promptRefs must contain at most 16 entries", { httpStatus: 400 }); const seen = new Set(); return value.map((entry, index) => { const record = asRecord(entry, `resourceBundleRef.promptRefs[${index}]`); const name = validateResourceName(requiredString(record, "name"), `resourceBundleRef.promptRefs[${index}].name`); if (seen.has(name)) throw new AgentRunError("schema-invalid", `resourceBundleRef.promptRefs name ${name} is duplicated`, { httpStatus: 400 }); seen.add(name); const promptPath = validateBundleRelativePath(requiredString(record, "path"), `resourceBundleRef.promptRefs[${index}].path`); const inject = optionalString(record.inject) ?? "thread-start"; if (inject !== "thread-start") throw new AgentRunError("schema-invalid", `resourceBundleRef.promptRefs[${index}].inject must be thread-start in v0.1`, { httpStatus: 400 }); const required = record.required === undefined ? false : record.required; if (typeof required !== "boolean") throw new AgentRunError("schema-invalid", `resourceBundleRef.promptRefs[${index}].required must be boolean`, { httpStatus: 400 }); return { name, path: promptPath, inject: "thread-start" as const, required }; }); } function validateResourceRequiredSkills(value: unknown): NonNullable { if (!Array.isArray(value)) throw new AgentRunError("schema-invalid", "resourceBundleRef.requiredSkills must be an array", { httpStatus: 400 }); if (value.length > 32) throw new AgentRunError("schema-invalid", "resourceBundleRef.requiredSkills must contain at most 32 entries", { httpStatus: 400 }); const seen = new Set(); return value.map((entry, index) => { const record = asRecord(entry, `resourceBundleRef.requiredSkills[${index}]`); const allowedKeys = new Set(["name"]); const extraKeys = Object.keys(record).filter((key) => !allowedKeys.has(key)); if (extraKeys.length > 0) throw new AgentRunError("schema-invalid", `resourceBundleRef.requiredSkills[${index}] only supports name`, { httpStatus: 400, details: { rejectedKeys: extraKeys.sort(), valuesPrinted: false } }); const name = validateResourceName(requiredString(record, "name"), `resourceBundleRef.requiredSkills[${index}].name`); if (seen.has(name)) throw new AgentRunError("schema-invalid", `resourceBundleRef.requiredSkills name ${name} is duplicated`, { httpStatus: 400 }); seen.add(name); return { name }; }); } function validateResourceName(name: string, fieldName: string): string { if (!/^[a-z][a-z0-9._-]{0,62}$/u.test(name)) throw new AgentRunError("schema-invalid", `${fieldName} must be a lowercase resource name`, { httpStatus: 400 }); return name; } function validateBundleSubpath(relativePath: string, fieldName: string): string { if (relativePath.startsWith("/") || relativePath.includes("..") || relativePath.includes("\\")) throw new AgentRunError("schema-invalid", `${fieldName} must stay within the checkout`, { httpStatus: 400 }); return relativePath; } function validateBundleRelativePath(relativePath: string, fieldName: string): string { if (relativePath === "." || relativePath.startsWith("/") || relativePath.includes("..") || relativePath.includes("\\")) throw new AgentRunError("schema-invalid", `${fieldName} must stay within the checkout`, { httpStatus: 400 }); return relativePath; } function validateWorkspaceRelativePath(relativePath: string, fieldName: string): string { if (relativePath === "." || relativePath.startsWith("/") || relativePath.includes("..") || relativePath.includes("\\")) throw new AgentRunError("schema-invalid", `${fieldName} must stay within the workspace`, { httpStatus: 400 }); return relativePath; } export function validateExecutionPolicy(record: JsonRecord): ExecutionPolicy { const timeout = record.timeoutMs; if (typeof timeout !== "number" || !Number.isFinite(timeout) || timeout <= 0) throw new AgentRunError("schema-invalid", "executionPolicy.timeoutMs must be a positive number", { httpStatus: 400 }); const secretScope = asRecord(record.secretScope ?? {}, "executionPolicy.secretScope"); if (secretScope.allowCredentialEcho !== undefined && secretScope.allowCredentialEcho !== false) throw new AgentRunError("tenant-policy-denied", "allowCredentialEcho must be false", { httpStatus: 403 }); const rawProviderCredentials = Array.isArray(secretScope.providerCredentials) ? secretScope.providerCredentials : []; const providerCredentials: NonNullable = []; for (const credential of rawProviderCredentials) { const item = asRecord(credential, "providerCredential"); const profile = typeof item.profile === "string" ? item.profile.trim() : ""; if (profile.length === 0) throw new AgentRunError("schema-invalid", "provider credential profile is required", { httpStatus: 400 }); if (!isBackendProfile(profile)) throw new AgentRunError("schema-invalid", `provider credential profile ${profile} must be a lowercase slug`, { httpStatus: 400, details: { pattern: backendProfilePatternText } }); const secretRef = validateSecretRef(asRecord(item.secretRef, "providerCredential.secretRef")); const keys = [...new Set([...(secretRef.keys ?? []), ...(backendProfileSpec(profile)?.requiredSecretKeys ?? [])])]; providerCredentials.push({ profile, secretRef: { ...secretRef, ...(keys.length > 0 ? { keys } : {}) } }); } const toolCredentials = validateToolCredentials(secretScope.toolCredentials); const secretScopeResult: ExecutionPolicy["secretScope"] = { allowCredentialEcho: false }; if (providerCredentials.length > 0) secretScopeResult.providerCredentials = providerCredentials as NonNullable; if (toolCredentials.length > 0) secretScopeResult.toolCredentials = toolCredentials; const gitIdentity = record.gitIdentity === undefined ? undefined : validateGitIdentity(record.gitIdentity); return { sandbox: requiredString(record, "sandbox"), approval: requiredString(record, "approval"), timeoutMs: timeout, network: requiredString(record, "network"), ...(gitIdentity ? { gitIdentity } : {}), secretScope: secretScopeResult, }; } function validateGitIdentity(value: unknown): NonNullable { const record = asRecord(value, "executionPolicy.gitIdentity"); const name = requiredString(record, "name"); const email = requiredString(record, "email"); if (/[\r\n]/u.test(name)) { throw new AgentRunError("schema-invalid", "executionPolicy.gitIdentity.name must be a single line", { httpStatus: 400 }); } if (/[\r\n<>]/u.test(email) || !/^[^\s@]+@[^\s@]+$/u.test(email)) { throw new AgentRunError("schema-invalid", "executionPolicy.gitIdentity.email must be a valid single-line email address", { httpStatus: 400 }); } return { name, email }; } function validateToolCredentials(value: unknown): NonNullable { if (value === undefined) return []; if (!Array.isArray(value)) throw new AgentRunError("schema-invalid", "toolCredentials must be an array", { httpStatus: 400 }); if (value.length > 8) throw new AgentRunError("schema-invalid", "toolCredentials must contain at most 8 entries", { httpStatus: 400 }); const seen = new Set(); const result = value.map((credential, index) => { const item = asRecord(credential, `toolCredentials[${index}]`); const tool = requiredString(item, "tool"); if (!allowedToolCredentials.includes(tool as (typeof allowedToolCredentials)[number])) throw new AgentRunError("schema-invalid", `tool credential ${tool} is not supported in v0.1`, { httpStatus: 400, details: { allowedTools: [...allowedToolCredentials] } }); const purpose = optionalString(item.purpose); const secretRef = validateSecretRef(asRecord(item.secretRef, `toolCredentials[${index}].secretRef`)); const keys = secretRef.keys ?? []; if (keys.length === 0) throw new AgentRunError("schema-invalid", `tool credential ${tool} secretRef.keys must not be empty`, { httpStatus: 400 }); const projection = asRecord(item.projection, `toolCredentials[${index}].projection`); const kind = requiredString(projection, "kind"); const normalizedProjection = validateToolCredentialProjection(tool, projection, keys, index); if (tool === "github" && purpose === "github-ssh") validateGithubSshProjection(normalizedProjection, keys, index); const identity = `${tool}:${purpose ?? ""}:${kind}:${normalizedProjection.kind === "env" ? normalizedProjection.envName : normalizedProjection.mountPath}`; if (seen.has(identity)) throw new AgentRunError("schema-invalid", `tool credential projection ${identity} is duplicated`, { httpStatus: 400 }); seen.add(identity); return { tool, ...(purpose ? { purpose } : {}), secretRef, projection: normalizedProjection }; }); if (result.filter((item) => item.tool === "github" && item.purpose === "github-ssh").length > 1) { throw new AgentRunError("schema-invalid", "toolCredentials must contain at most one github-ssh credential authority", { httpStatus: 400 }); } return result; } function validateToolCredentialProjection(tool: string, projection: JsonRecord, keys: string[], index: number): NonNullable[number]["projection"] { const kind = requiredString(projection, "kind"); if (kind === "env") { const envName = requiredString(projection, "envName"); validateEnvName(envName, `toolCredentials[${index}].projection.envName`); const secretKey = optionalString(projection.secretKey) ?? keys[0]; if (!secretKey || !keys.includes(secretKey)) throw new AgentRunError("schema-invalid", `tool credential ${tool} projection.secretKey must be included in secretRef.keys`, { httpStatus: 400 }); if (tool === "unidesk-ssh") validateUnideskSshProjection(envName, secretKey, keys, index); return { kind: "env", envName, secretKey }; } if (kind === "volume") { const mountPath = requiredString(projection, "mountPath"); if (!mountPath.startsWith("/home/agentrun/") || mountPath.includes("..")) { throw new AgentRunError("schema-invalid", `toolCredentials[${index}].projection.mountPath must stay under /home/agentrun`, { httpStatus: 400 }); } if (tool === "unidesk-ssh") throw new AgentRunError("schema-invalid", `toolCredentials[${index}] unidesk-ssh must use env projection`, { httpStatus: 400 }); return { kind: "volume", mountPath }; } throw new AgentRunError("schema-invalid", "toolCredentials[].projection.kind must be env or volume in v0.1", { httpStatus: 400 }); } function validateUnideskSshProjection(envName: string, secretKey: string, keys: string[], index: number): void { if (!keys.includes("UNIDESK_SSH_CLIENT_TOKEN")) { throw new AgentRunError("schema-invalid", `toolCredentials[${index}] unidesk-ssh secretRef.keys must include UNIDESK_SSH_CLIENT_TOKEN`, { httpStatus: 400 }); } if (envName !== "UNIDESK_SSH_CLIENT_TOKEN" || secretKey !== "UNIDESK_SSH_CLIENT_TOKEN") { throw new AgentRunError("schema-invalid", `toolCredentials[${index}] unidesk-ssh must project UNIDESK_SSH_CLIENT_TOKEN from the same Secret key`, { httpStatus: 400 }); } } function validateGithubSshProjection(projection: NonNullable[number]["projection"], keys: string[], index: number): void { if (projection.kind !== "volume") { throw new AgentRunError("schema-invalid", `toolCredentials[${index}] github-ssh must use a volume projection`, { httpStatus: 400 }); } const missingKeys = ["id_ed25519", "known_hosts"].filter((key) => !keys.includes(key)); if (missingKeys.length > 0) { throw new AgentRunError("schema-invalid", `toolCredentials[${index}] github-ssh secretRef.keys must include id_ed25519 and known_hosts`, { httpStatus: 400, details: { missingKeys, valuesPrinted: false }, }); } } export function validateEnvName(name: string, fieldName: string): void { if (!/^[A-Z_][A-Z0-9_]{0,63}$/u.test(name)) throw new AgentRunError("schema-invalid", `${fieldName} must be an uppercase env name`, { httpStatus: 400 }); } function validateSecretRef(record: JsonRecord): SecretRef { const name = requiredString(record, "name"); const result: SecretRef = { name }; const namespace = optionalString(record.namespace); const mountPath = optionalString(record.mountPath); if (namespace) result.namespace = namespace; if (mountPath) result.mountPath = mountPath; if (record.keys !== undefined) { if (!Array.isArray(record.keys) || !record.keys.every((item) => typeof item === "string" && item.length > 0)) throw new AgentRunError("schema-invalid", "secretRef.keys must be non-empty strings", { httpStatus: 400 }); result.keys = record.keys as string[]; } return result; } function optionalString(value: unknown): string | undefined { return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; } function validateBackendSecretScope(backendProfile: BackendProfile, executionPolicy: ExecutionPolicy): void { const credentials = executionPolicy.secretScope.providerCredentials ?? []; const matching = credentials.filter((item) => item.profile === backendProfile); if (matching.length === 0) { throw new AgentRunError("secret-unavailable", `backendProfile ${backendProfile} requires a matching provider credential SecretRef`, { httpStatus: 400, details: { backendProfile, requiredSecretName: backendProfileSpec(backendProfile)?.defaultSecretName ?? null } }); } if (matching.length > 1) throw new AgentRunError("schema-invalid", `backendProfile ${backendProfile} has multiple matching provider credentials`, { httpStatus: 400 }); } export function validateCreateCommand(input: unknown): CreateCommandInput { const record = asRecord(input, "command"); const type = requiredString(record, "type"); if (type !== "turn" && type !== "steer" && type !== "interrupt") throw new AgentRunError("schema-invalid", `command type ${type} is not supported`, { httpStatus: 400 }); const payload = asRecord(record.payload ?? {}, "payload"); if (type === "steer" && !steerPrompt(payload)) throw new AgentRunError("schema-invalid", "steer command payload requires a non-empty prompt, message, or text", { httpStatus: 400 }); const idempotencyKey = typeof record.idempotencyKey === "string" && record.idempotencyKey.trim().length > 0 ? record.idempotencyKey.trim() : undefined; let dispatch: CreateCommandInput["dispatch"]; if (record.dispatch !== undefined && record.dispatch !== null) { if (type !== "turn") throw new AgentRunError("schema-invalid", "dispatch is only supported for turn commands", { httpStatus: 400 }); const dispatchRecord = asRecord(record.dispatch, "command.dispatch"); if (requiredString(dispatchRecord, "kind") !== "kubernetes-job") throw new AgentRunError("schema-invalid", "command.dispatch.kind must be kubernetes-job", { httpStatus: 400 }); const dispatchInput = validateDurableDispatchInput(asRecord(dispatchRecord.input ?? {}, "command.dispatch.input")); dispatch = { kind: "kubernetes-job", input: dispatchInput }; } return { type, payload, ...(idempotencyKey ? { idempotencyKey } : {}), ...(dispatch ? { dispatch } : {}) }; } function validateDurableDispatchInput(input: JsonRecord): JsonRecord { assertNoSecretNamedFields(input, "command.dispatch.input"); const allowedKeys = new Set(["idempotencyKey", "transientEnv"]); const rejectedKeys = Object.keys(input).filter((key) => !allowedKeys.has(key)); if (rejectedKeys.length > 0) throw new AgentRunError("tenant-policy-denied", "durable dispatch runtime settings are controlled by AgentRun configuration", { httpStatus: 403, details: { rejectedKeys: rejectedKeys.sort(), valuesPrinted: false } }); const normalized: JsonRecord = {}; if (input.idempotencyKey !== undefined) normalized.idempotencyKey = boundedDispatchString(input.idempotencyKey, "command.dispatch.input.idempotencyKey", 256); if (input.transientEnv !== undefined) normalized.transientEnv = validateDurableTransientEnv(input.transientEnv); return normalized; } export function validateSessionRunnerJobInput(value: unknown): JsonRecord { const input = asRecord(value ?? {}, "sessionSend.runnerJob"); assertNoSecretNamedFields(input, "sessionSend.runnerJob"); const allowedKeys = new Set([ "managerUrl", "image", "namespace", "attemptId", "runnerId", "sourceCommit", "serviceAccountName", "runnerIdleTimeoutMs", "backendRetryMaxAttempts", "backendRetryInitialBackoffMs", "backendRetryMaxBackoffMs", "idempotencyKey", "imageRef", "transientEnv", ]); const rejectedKeys = Object.keys(input).filter((key) => !allowedKeys.has(key)); if (rejectedKeys.length > 0) throw new AgentRunError("schema-invalid", "session runner admission contains unsupported fields", { httpStatus: 400, details: { rejectedKeys: rejectedKeys.sort(), valuesPrinted: false } }); const normalized: JsonRecord = {}; for (const key of ["managerUrl", "image", "namespace", "attemptId", "runnerId", "sourceCommit", "serviceAccountName", "idempotencyKey"] as const) { if (input[key] !== undefined) normalized[key] = credentialFreeConfigValue(boundedDispatchString(input[key], `sessionSend.runnerJob.${key}`, key === "managerUrl" || key === "image" ? 2048 : 256), `sessionSend.runnerJob.${key}`); } for (const key of ["runnerIdleTimeoutMs", "backendRetryMaxAttempts", "backendRetryInitialBackoffMs", "backendRetryMaxBackoffMs"] as const) { if (input[key] === undefined) continue; const number = Number(input[key]); if (!Number.isSafeInteger(number) || number < 1) throw new AgentRunError("schema-invalid", `sessionSend.runnerJob.${key} must be a positive integer`, { httpStatus: 400 }); normalized[key] = number; } if (input.imageRef !== undefined) normalized.imageRef = validateAipodImageRef(input.imageRef, "sessionSend.runnerJob.imageRef"); if (input.transientEnv !== undefined) normalized.transientEnv = validateDurableTransientEnv(input.transientEnv); return normalized; } function validateDurableTransientEnv(value: unknown): JsonRecord[] { if (!Array.isArray(value)) throw new AgentRunError("schema-invalid", "command.dispatch.input.transientEnv must be an array", { httpStatus: 400 }); if (value.length > 64) throw new AgentRunError("schema-invalid", "command.dispatch.input.transientEnv must contain at most 64 entries", { httpStatus: 400 }); const seen = new Set(); return value.map((raw, index) => { const entry = asRecord(raw, `command.dispatch.input.transientEnv[${index}]`); const rejectedEntryKeys = Object.keys(entry).filter((key) => !["name", "value", "sensitive"].includes(key)); if (rejectedEntryKeys.length > 0) throw new AgentRunError("schema-invalid", `command.dispatch.input.transientEnv[${index}] contains unsupported fields`, { httpStatus: 400, details: { rejectedKeys: rejectedEntryKeys.sort(), valuesPrinted: false } }); const name = requiredString(entry, "name"); validateEnvName(name, `command.dispatch.input.transientEnv[${index}].name`); assertRunnerTransientEnvNameAllowed(name); if (isCredentialEnvName(name)) throw durableDispatchSecretError(index, name); if (seen.has(name)) throw new AgentRunError("schema-invalid", `command.dispatch.input.transientEnv name ${name} is duplicated`, { httpStatus: 400 }); seen.add(name); const envValue = credentialFreeConfigValue(boundedDispatchString(entry.value, `command.dispatch.input.transientEnv[${index}].value`, 8192, true), `command.dispatch.input.transientEnv[${index}].value`); if (entry.sensitive !== false) throw durableDispatchSecretError(index, name); return { name, value: envValue, sensitive: false }; }); } export function assertRunnerTransientEnvNameAllowed(name: string): void { const normalized = name.toUpperCase(); const reservedPrefixes = ["AGENTRUN_", "CODEX_", "GIT_", "KUBERNETES_", "NPM_CONFIG_", "OTEL_"]; const reservedNames = new Set([ "ALL_PROXY", "BASH_ENV", "BUN_OPTIONS", "CODEX_HOME", "ENV", "HOME", "HTTP_PROXY", "HTTPS_PROXY", "KUBECONFIG", "LD_LIBRARY_PATH", "LD_PRELOAD", "NODE_EXTRA_CA_CERTS", "NODE_OPTIONS", "NO_PROXY", "PATH", "PROMPT_COMMAND", ]); if (reservedNames.has(normalized) || reservedPrefixes.some((prefix) => normalized.startsWith(prefix))) { throw new AgentRunError("tenant-policy-denied", `transientEnv ${name} is controlled by AgentRun runtime configuration`, { httpStatus: 403, details: { name, valuesPrinted: false } }); } } function assertNoSecretNamedFields(value: unknown, path: string): void { if (Array.isArray(value)) { value.forEach((entry, index) => assertNoSecretNamedFields(entry, `${path}[${index}]`)); return; } if (typeof value !== "object" || value === null) return; for (const [key, entry] of Object.entries(value as JsonRecord)) { if (isSecretFieldName(key)) { throw new AgentRunError("tenant-policy-denied", "durable dispatch cannot persist credential-shaped fields; use executionPolicy SecretRef", { httpStatus: 400, details: { field: `${path}.${key}`, valuesPrinted: false } }); } assertNoSecretNamedFields(entry, `${path}.${key}`); } } function isSecretFieldName(key: string): boolean { const normalized = key.replace(/[^a-z0-9]/giu, "").toLowerCase(); return ["secret", "token", "password", "passwd", "apikey", "authorization", "credential", "privatekey", "accesskey", "clientsecret"].some((marker) => normalized.includes(marker)) || normalized === "auth" || normalized.endsWith("auth"); } function isCredentialEnvName(name: string): boolean { const normalized = name.replace(/[^A-Z0-9]/gu, ""); return ["SECRET", "TOKEN", "PASSWORD", "PASSWD", "APIKEY", "AUTH", "CREDENTIAL", "PRIVATEKEY", "ACCESSKEY", "CLIENTSECRET"].some((marker) => normalized.includes(marker)); } function durableDispatchSecretError(index: number, name: string): AgentRunError { return new AgentRunError("tenant-policy-denied", "durable dispatch cannot persist sensitive transientEnv values; use executionPolicy.toolCredentials SecretRef", { httpStatus: 400, details: { index, name, valuesPrinted: false } }); } function boundedDispatchString(value: unknown, fieldName: string, maxBytes: number, allowWhitespace = false): string { if (typeof value !== "string" || value.trim().length === 0) throw new AgentRunError("schema-invalid", `${fieldName} must be a non-empty string`, { httpStatus: 400 }); const text = value.trim(); if (Buffer.byteLength(text, "utf8") > maxBytes) throw new AgentRunError("schema-invalid", `${fieldName} is too large`, { httpStatus: 400 }); if (/[\x00-\x1f\x7f]/u.test(text) || (!allowWhitespace && /\s/u.test(text))) throw new AgentRunError("schema-invalid", `${fieldName} contains unsupported whitespace or control characters`, { httpStatus: 400 }); return text; } function credentialFreeConfigValue(value: string, fieldName: string): string { if (!value.includes("://")) return value; let url: URL; try { url = new URL(value); } catch { return value; } if (url.username || url.password) throw new AgentRunError("tenant-policy-denied", `${fieldName} must not include URL credentials`, { httpStatus: 400 }); return value; } function steerPrompt(payload: JsonRecord): string | null { for (const key of ["prompt", "message", "text"]) { const value = payload[key]; if (typeof value === "string" && value.trim().length > 0) return value.trim(); } return null; } export function validateCreateQueueTask(input: unknown): CreateQueueTaskInput { const record = asRecord(input, "queueTask"); const tenantId = requiredString(record, "tenantId"); if (!allowedTenants.has(tenantId)) throw new AgentRunError("tenant-policy-denied", `tenantId ${tenantId} is not allowed`, { httpStatus: 403 }); const backendProfileValue = optionalString(record.backendProfile) ?? "codex"; if (!isBackendProfile(backendProfileValue)) throw new AgentRunError("schema-invalid", `backendProfile ${backendProfileValue} must be a lowercase slug`, { httpStatus: 400, details: { pattern: backendProfilePatternText } }); const queue = optionalString(record.queue) ?? "default"; const lane = optionalString(record.lane) ?? "default"; const priorityValue = record.priority ?? 0; if (typeof priorityValue !== "number" || !Number.isFinite(priorityValue)) throw new AgentRunError("schema-invalid", "priority must be a finite number", { httpStatus: 400 }); const referencesValue = record.references ?? []; if (!Array.isArray(referencesValue)) throw new AgentRunError("schema-invalid", "references must be an array", { httpStatus: 400 }); const workspaceRef = record.workspaceRef === undefined || record.workspaceRef === null ? null : validateWorkspaceRef(requiredRecord(record, "workspaceRef")); const resourceBundleRef = validateResourceBundleRef(record.resourceBundleRef); validatePrimaryWorkspaceContract(workspaceRef, resourceBundleRef); const result: CreateQueueTaskInput = { tenantId, projectId: requiredString(record, "projectId"), queue, lane, title: requiredString(record, "title"), priority: priorityValue, backendProfile: backendProfileValue, providerId: optionalString(record.providerId) ?? null, workspaceRef, sessionRef: validateSessionRef(record.sessionRef), executionPolicy: record.executionPolicy === undefined || record.executionPolicy === null ? null : validateExecutionPolicy(requiredRecord(record, "executionPolicy")), resourceBundleRef, payload: record.payload === undefined ? {} : asRecord(record.payload, "payload"), references: referencesValue.map((item, index) => asRecord(item, `references[${index}]`)), metadata: record.metadata === undefined ? {} : asRecord(record.metadata, "metadata"), }; const idempotencyKey = optionalString(record.idempotencyKey); if (idempotencyKey) result.idempotencyKey = idempotencyKey; return result; } export function validateQueueTaskState(value: string): QueueTaskState { if (value === "pending" || value === "running" || value === "completed" || value === "failed" || value === "blocked" || value === "cancelled") return value; throw new AgentRunError("schema-invalid", `queue task state ${value} is not supported`, { httpStatus: 400 }); } export function validateSessionListState(value: string): SessionListState { if (value === "default" || value === "running" || value === "unread" || value === "terminal" || value === "idle" || value === "all") return value; throw new AgentRunError("schema-invalid", `session state ${value} is not supported`, { httpStatus: 400 }); } export function validateBackendProfile(value: string): BackendProfile { if (isBackendProfile(value)) return value; throw new AgentRunError("schema-invalid", `backendProfile ${value} must be a lowercase slug`, { httpStatus: 400, details: { pattern: backendProfilePatternText } }); }