feat: add aipod spec Artificer assembly

This commit is contained in:
Codex
2026-06-10 17:46:45 +08:00
parent 45df61bd02
commit 6989dc18ef
22 changed files with 2103 additions and 56 deletions
+300
View File
@@ -0,0 +1,300 @@
import { mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
import path from "node:path";
import { AgentRunError } from "./errors.js";
import type { AipodSpec, AipodSpecRecord, BackendProfile, CreateQueueTaskInput, ExecutionPolicy, JsonRecord, JsonValue, RenderAipodInput, RenderedAipodQueueTask, ResourceBundleRef, SessionRef, WorkspaceRef } from "./types.js";
import { backendProfileSpec, isBackendProfile } from "./backend-profiles.js";
import { asRecord, stableHash, validateCreateQueueTask, validateExecutionPolicy, validateResourceBundleRef, validateSessionRef } from "./validation.js";
declare const Bun: { YAML: { parse(text: string): unknown; stringify(value: unknown): string } };
const aipodApiVersion = "agentrun.pikastech.local/v0.1";
const aipodKind = "AipodSpec";
export function aipodSpecDirectory(): string {
return process.env.AGENTRUN_AIPOD_SPEC_DIR ?? path.join(process.cwd(), "config", "aipods");
}
export async function listAipodSpecs(dir = aipodSpecDirectory()): Promise<JsonRecord> {
const records = await loadAipodSpecRecords(dir);
return {
action: "aipod-spec-list",
dir,
count: records.length,
items: records.map((record) => summarizeAipodSpecRecord(record)),
valuesPrinted: false,
};
}
export async function showAipodSpec(name: string, dir = aipodSpecDirectory()): Promise<JsonRecord> {
const record = await getAipodSpecRecord(name, dir);
return { action: "aipod-spec-show", item: summarizeAipodSpecRecord(record), spec: record.spec, valuesPrinted: false };
}
export async function renderAipodSpecByName(name: string, input: RenderAipodInput = {}, dir = aipodSpecDirectory()): Promise<RenderedAipodQueueTask> {
const record = await getAipodSpecRecord(name, dir);
return renderAipodSpec(record, input);
}
export async function applyAipodSpec(input: unknown, dir = aipodSpecDirectory()): Promise<JsonRecord> {
const spec = aipodSpecFromInput(input, "api");
await mkdir(dir, { recursive: true });
const file = path.join(dir, `${fileSafeAipodName(spec.metadata.name)}.yaml`);
await writeFile(file, Bun.YAML.stringify(spec), "utf8");
const record = await loadAipodSpecFile(file);
return { action: "aipod-spec-apply", mutation: true, item: summarizeAipodSpecRecord(record), valuesPrinted: false };
}
export async function deleteAipodSpec(name: string, dir = aipodSpecDirectory()): Promise<JsonRecord> {
const record = await getAipodSpecRecord(name, dir);
await rm(record.source, { force: true });
return { action: "aipod-spec-delete", mutation: true, name: record.name, source: record.source, specHash: record.specHash, valuesPrinted: false };
}
export function parseAipodSpecYaml(text: string, source = "stdin"): AipodSpec {
let parsed: unknown;
try {
parsed = Bun.YAML.parse(text);
} catch (error) {
throw new AgentRunError("schema-invalid", `aipod-spec YAML parse failed: ${error instanceof Error ? error.message : String(error)}`, { httpStatus: 400, details: { source, valuesPrinted: false } });
}
return validateAipodSpec(parsed, source);
}
export function aipodSpecFromInput(input: unknown, source = "inline"): AipodSpec {
const record = asRecord(input, "aipodSpecInput");
if (typeof record.yaml === "string") return parseAipodSpecYaml(record.yaml, source);
if (record.spec !== undefined) return validateAipodSpec(record.spec, source);
return validateAipodSpec(record, source);
}
export function validateAipodSpec(input: unknown, source = "inline"): AipodSpec {
const record = asRecord(input, "aipodSpec");
if (stringValue(record.apiVersion) !== aipodApiVersion) throw new AgentRunError("schema-invalid", `aipodSpec.apiVersion must be ${aipodApiVersion}`, { httpStatus: 400, details: { source } });
if (stringValue(record.kind) !== aipodKind) throw new AgentRunError("schema-invalid", `aipodSpec.kind must be ${aipodKind}`, { httpStatus: 400, details: { source } });
const metadata = asRecord(record.metadata, "aipodSpec.metadata");
const name = validateAipodName(requiredString(metadata, "name"));
const labels = metadata.labels === undefined ? undefined : asRecord(metadata.labels, "aipodSpec.metadata.labels");
const spec = asRecord(record.spec, "aipodSpec.spec");
const backendProfile = normalizeBackendProfile(requiredString(spec, "backendProfile"));
const executionPolicy = validateExecutionPolicy(asRecord(spec.executionPolicy, "aipodSpec.spec.executionPolicy"));
validateAipodProviderCredential(backendProfile, executionPolicy);
const resourceBundleRef = validateResourceBundleRef(spec.resourceBundleRef);
const result: AipodSpec = {
apiVersion: aipodApiVersion,
kind: aipodKind,
metadata: {
name,
...(stringValue(metadata.displayName) ? { displayName: stringValue(metadata.displayName) as string } : {}),
...(stringValue(metadata.description) ? { description: stringValue(metadata.description) as string } : {}),
...(labels ? { labels } : {}),
},
spec: {
...(stringValue(spec.tenantId) ? { tenantId: stringValue(spec.tenantId) as string } : {}),
...(stringValue(spec.projectId) ? { projectId: stringValue(spec.projectId) as string } : {}),
...(stringValue(spec.queue) ? { queue: stringValue(spec.queue) as string } : {}),
...(stringValue(spec.lane) ? { lane: stringValue(spec.lane) as string } : {}),
...(typeof spec.priority === "number" ? { priority: spec.priority } : {}),
...(stringValue(spec.providerId) ? { providerId: stringValue(spec.providerId) as string } : {}),
backendProfile,
...(isJsonRecord(spec.model) ? { model: spec.model } : {}),
...(isJsonRecord(spec.workspaceRef) ? { workspaceRef: validateWorkspaceRef(spec.workspaceRef) } : {}),
...(spec.sessionRef !== undefined ? { sessionRef: validateSessionRef(spec.sessionRef) } : {}),
executionPolicy,
resourceBundleRef,
...(isJsonRecord(spec.payloadDefaults) ? { payloadDefaults: spec.payloadDefaults } : {}),
...(Array.isArray(spec.references) ? { references: spec.references.map((item, index) => asRecord(item, `aipodSpec.spec.references[${index}]`)) } : {}),
...(isJsonRecord(spec.metadata) ? { metadata: spec.metadata } : {}),
...(isJsonRecord(spec.dispatchDefaults) ? { dispatchDefaults: spec.dispatchDefaults } : {}),
},
};
return result;
}
export function renderAipodSpec(record: AipodSpecRecord, input: RenderAipodInput = {}): RenderedAipodQueueTask {
const spec = record.spec.spec;
const metadata = mergeRecords(spec.metadata, input.metadata, { aipod: record.name, aipodSpecHash: record.specHash });
const payload = mergeRecords(spec.payloadDefaults, input.payload);
if (typeof input.prompt === "string" && input.prompt.trim().length > 0) payload.prompt = input.prompt;
applyModelPayload(payload, spec.model);
const sessionRef = renderSessionRef(spec.sessionRef ?? null, input);
const queueTask = validateCreateQueueTask({
tenantId: input.tenantId ?? spec.tenantId ?? "unidesk",
projectId: input.projectId ?? spec.projectId ?? "default",
queue: input.queue ?? spec.queue ?? "commander",
lane: input.lane ?? spec.lane ?? "v0.1",
title: input.title ?? stringValue(payload.title) ?? record.spec.metadata.displayName ?? record.name,
priority: input.priority ?? spec.priority ?? 50,
backendProfile: input.backendProfile ?? spec.backendProfile,
providerId: input.providerId ?? spec.providerId ?? "G14",
workspaceRef: input.workspaceRef ?? spec.workspaceRef ?? { kind: "opaque", path: "." },
sessionRef,
executionPolicy: spec.executionPolicy,
resourceBundleRef: spec.resourceBundleRef,
payload,
references: [...(spec.references ?? []), ...(input.references ?? [])],
metadata,
...(input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {}),
}) as CreateQueueTaskInput;
return {
action: "aipod-spec-render",
aipod: summarizeAipodSpecRecord(record),
queueTask,
dispatchDefaults: spec.dispatchDefaults ?? {},
valuesPrinted: false,
};
}
export function summarizeAipodSpecRecord(record: AipodSpecRecord): JsonRecord {
const spec = record.spec.spec;
return {
name: record.name,
displayName: record.spec.metadata.displayName ?? record.name,
description: record.spec.metadata.description ?? null,
specHash: record.specHash,
source: record.source,
backendProfile: spec.backendProfile,
model: spec.model ?? null,
queue: spec.queue ?? "commander",
lane: spec.lane ?? "v0.1",
providerId: spec.providerId ?? "G14",
providerCredentials: summarizeProviderCredentials(spec.executionPolicy),
toolCredentials: summarizeToolCredentials(spec.executionPolicy),
resourceBundleRef: summarizeResourceBundle(spec.resourceBundleRef),
dispatchDefaults: summarizeKeys(spec.dispatchDefaults),
createdAt: record.createdAt,
updatedAt: record.updatedAt,
valuesPrinted: false,
};
}
async function loadAipodSpecRecords(dir: string): Promise<AipodSpecRecord[]> {
let entries;
try {
entries = await readdir(dir, { withFileTypes: true });
} catch (error) {
if (isNotFound(error)) return [];
throw error;
}
const records = await Promise.all(entries
.filter((entry) => entry.isFile() && /\.ya?ml$/u.test(entry.name))
.sort((left, right) => left.name.localeCompare(right.name))
.map((entry) => loadAipodSpecFile(path.join(dir, entry.name))));
return records.sort((left, right) => left.name.localeCompare(right.name));
}
async function getAipodSpecRecord(name: string, dir: string): Promise<AipodSpecRecord> {
const lookup = normalizeLookupName(name);
const records = await loadAipodSpecRecords(dir);
const match = records.find((record) => normalizeLookupName(record.name) === lookup);
if (!match) throw new AgentRunError("schema-invalid", `aipod-spec ${name} was not found`, { httpStatus: 404, details: { dir, available: records.map((record) => record.name), valuesPrinted: false } });
return match;
}
async function loadAipodSpecFile(file: string): Promise<AipodSpecRecord> {
const text = await readFile(file, "utf8");
const spec = parseAipodSpecYaml(text, file);
const info = await stat(file);
const updatedAt = info.mtime.toISOString();
return { name: spec.metadata.name, spec, specHash: stableHash(spec), source: file, createdAt: info.birthtime.toISOString(), updatedAt };
}
function validateAipodProviderCredential(profile: BackendProfile, policy: ExecutionPolicy): void {
const matching = (policy.secretScope.providerCredentials ?? []).filter((item) => item.profile === profile);
if (matching.length !== 1) {
throw new AgentRunError("secret-unavailable", `aipod backendProfile ${profile} requires exactly one matching provider credential SecretRef`, {
httpStatus: 400,
details: { profile, matchingCount: matching.length, defaultSecretName: backendProfileSpec(profile)?.defaultSecretName ?? null, valuesPrinted: false },
});
}
}
function renderSessionRef(base: SessionRef | null, input: RenderAipodInput): SessionRef | null {
if (input.sessionRef !== undefined) return input.sessionRef;
if (!input.sessionId) return base;
return { ...(base ?? {}), sessionId: input.sessionId };
}
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;
}
function summarizeProviderCredentials(policy: ExecutionPolicy): JsonRecord {
const items = (policy.secretScope.providerCredentials ?? []).map((item) => ({ profile: item.profile, name: item.secretRef.name, namespace: item.secretRef.namespace ?? null, keys: item.secretRef.keys ?? [], valuesPrinted: false }));
return { count: items.length, profiles: items.map((item) => item.profile), items, valuesPrinted: false };
}
function summarizeToolCredentials(policy: ExecutionPolicy): JsonRecord {
const items = (policy.secretScope.toolCredentials ?? []).map((item) => ({ tool: item.tool, purpose: item.purpose ?? null, name: item.secretRef.name, namespace: item.secretRef.namespace ?? null, keys: item.secretRef.keys ?? [], projection: item.projection, valuesPrinted: false }));
return { count: items.length, tools: items.map((item) => item.tool), items, valuesPrinted: false };
}
function summarizeResourceBundle(resourceBundleRef: ResourceBundleRef | null): JsonRecord | null {
if (!resourceBundleRef) return null;
return {
kind: resourceBundleRef.kind,
repoUrl: resourceBundleRef.repoUrl,
ref: resourceBundleRef.ref ?? null,
commitId: resourceBundleRef.commitId ?? null,
gitMirror: resourceBundleRef.gitMirror ? { enabled: resourceBundleRef.gitMirror.enabled ?? true, baseUrl: resourceBundleRef.gitMirror.baseUrl ?? null, valuesPrinted: false } : { enabled: false, baseUrl: null, valuesPrinted: false },
bundles: { count: resourceBundleRef.bundles.length, items: resourceBundleRef.bundles.map((item) => ({ name: item.name ?? null, repoUrl: item.repoUrl ?? resourceBundleRef.repoUrl, ref: item.ref ?? resourceBundleRef.ref ?? null, commitId: item.commitId ?? resourceBundleRef.commitId ?? null, subpath: item.subpath, targetPath: item.targetPath, valuesPrinted: false })), valuesPrinted: false },
requiredSkills: { count: resourceBundleRef.requiredSkills?.length ?? 0, names: resourceBundleRef.requiredSkills?.map((item) => item.name) ?? [], valuesPrinted: false },
promptRefs: { count: resourceBundleRef.promptRefs?.length ?? 0, names: resourceBundleRef.promptRefs?.map((item) => item.name) ?? [], valuesPrinted: false },
valuesPrinted: false,
};
}
function summarizeKeys(value: JsonRecord | undefined): JsonRecord {
return { keys: Object.keys(value ?? {}).sort(), valuesPrinted: false };
}
function mergeRecords(...records: Array<JsonRecord | undefined>): JsonRecord {
return Object.assign({}, ...records.filter(Boolean));
}
function applyModelPayload(payload: JsonRecord, model: JsonRecord | undefined): void {
if (!model) return;
const modelName = stringValue(model.model);
if (modelName) payload.model = modelName;
payload.modelConfig = { ...model, valuesPrinted: false };
}
function normalizeBackendProfile(value: string): BackendProfile {
const profile = value.trim().toLowerCase();
if (!isBackendProfile(profile)) throw new AgentRunError("schema-invalid", `backendProfile ${value} must be a lowercase slug`, { httpStatus: 400 });
return profile;
}
function validateAipodName(value: string): string {
if (!/^[A-Za-z][A-Za-z0-9._-]{0,63}$/u.test(value)) throw new AgentRunError("schema-invalid", "aipodSpec.metadata.name must be an aipod name", { httpStatus: 400 });
return value;
}
function fileSafeAipodName(value: string): string {
return value.toLowerCase().replace(/[^a-z0-9._-]+/gu, "-").replace(/^-+|-+$/gu, "") || "aipod";
}
function normalizeLookupName(value: string): string {
return value.trim().toLowerCase();
}
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 stringValue(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function isJsonRecord(value: unknown): value is JsonRecord {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isNotFound(error: unknown): boolean {
return typeof error === "object" && error !== null && "code" in error && (error as { code?: unknown }).code === "ENOENT";
}
+75 -2
View File
@@ -75,6 +75,10 @@ export interface ResourceBundleRef extends JsonRecord {
repoUrl: string;
commitId?: string;
ref?: string;
gitMirror?: {
enabled?: boolean;
baseUrl?: string;
};
bundles: GitBundleItemRef[];
promptRefs?: Array<{
name: string;
@@ -90,6 +94,72 @@ export interface ResourceBundleRef extends JsonRecord {
credentialRef?: SecretRef;
}
export interface AipodSpec extends JsonRecord {
apiVersion: "agentrun.pikastech.local/v0.1";
kind: "AipodSpec";
metadata: {
name: string;
displayName?: string;
description?: string;
labels?: JsonRecord;
};
spec: {
tenantId?: string;
projectId?: string;
queue?: string;
lane?: string;
priority?: number;
providerId?: string;
backendProfile: BackendProfile;
model?: JsonRecord;
workspaceRef?: WorkspaceRef;
sessionRef?: SessionRef | null;
executionPolicy: ExecutionPolicy;
resourceBundleRef: ResourceBundleRef | null;
payloadDefaults?: JsonRecord;
references?: JsonRecord[];
metadata?: JsonRecord;
dispatchDefaults?: JsonRecord;
};
}
export interface AipodSpecRecord extends JsonRecord {
name: string;
spec: AipodSpec;
specHash: string;
source: string;
createdAt: string;
updatedAt: string;
}
export interface RenderAipodInput extends JsonRecord {
prompt?: string;
payload?: JsonRecord;
tenantId?: string;
projectId?: string;
queue?: string;
lane?: string;
title?: string;
priority?: number;
providerId?: string;
backendProfile?: BackendProfile;
workspaceRef?: WorkspaceRef;
sessionRef?: SessionRef | null;
sessionId?: string;
references?: JsonRecord[];
metadata?: JsonRecord;
traceSink?: JsonValue;
idempotencyKey?: string;
}
export interface RenderedAipodQueueTask extends JsonRecord {
action: "aipod-spec-render";
aipod: JsonRecord;
queueTask: CreateQueueTaskInput;
dispatchDefaults: JsonRecord;
valuesPrinted: false;
}
export interface ExecutionPolicy extends JsonRecord {
sandbox: string;
approval: string;
@@ -104,11 +174,14 @@ export interface ExecutionPolicy extends JsonRecord {
tool: string;
purpose?: string;
secretRef: SecretRef;
projection: {
projection: ({
kind: "env";
envName: string;
secretKey?: string;
};
} | {
kind: "volume";
mountPath: string;
});
}>;
allowCredentialEcho?: false;
};
+49 -10
View File
@@ -89,7 +89,8 @@ export function validateResourceBundleRef(value: unknown): ResourceBundleRef | n
if (commitId) validateCommitId(commitId, "resourceBundleRef.commitId");
const ref = validateGitRef(record.ref, "resourceBundleRef.ref");
rejectLegacyResourceBundleFields(record);
const result: ResourceBundleRef = { kind: "gitbundle", repoUrl, ...(commitId ? { commitId } : {}), ...(ref ? { ref } : {}), bundles: validateResourceGitBundles(record.bundles, repoUrl, commitId, ref) };
const gitMirror = validateGitMirror(record.gitMirror);
const result: ResourceBundleRef = { kind: "gitbundle", repoUrl, ...(commitId ? { commitId } : {}), ...(ref ? { ref } : {}), ...(gitMirror ? { gitMirror } : {}), 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 });
@@ -100,6 +101,27 @@ export function validateResourceBundleRef(value: unknown): ResourceBundleRef | n
return result;
}
function validateGitMirror(value: unknown): NonNullable<ResourceBundleRef["gitMirror"]> | undefined {
if (value === undefined) return undefined;
const record = asRecord(value, "resourceBundleRef.gitMirror");
const enabled = record.enabled === undefined ? true : record.enabled;
if (typeof enabled !== "boolean") throw new AgentRunError("schema-invalid", "resourceBundleRef.gitMirror.enabled must be boolean", { httpStatus: 400 });
const baseUrl = optionalString(record.baseUrl);
if (baseUrl) validateGitMirrorBaseUrl(baseUrl);
return { enabled, ...(baseUrl ? { baseUrl } : {}) };
}
function validateGitMirrorBaseUrl(value: string): void {
let parsed: URL;
try {
parsed = new URL(value);
} catch {
throw new AgentRunError("schema-invalid", "resourceBundleRef.gitMirror.baseUrl must be an http(s) URL", { httpStatus: 400 });
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new AgentRunError("schema-invalid", "resourceBundleRef.gitMirror.baseUrl must use http or https", { httpStatus: 400 });
if (parsed.username || parsed.password || parsed.search || parsed.hash) throw new AgentRunError("schema-invalid", "resourceBundleRef.gitMirror.baseUrl must not include credentials, query, or fragment", { httpStatus: 400 });
}
function rejectLegacyResourceBundleFields(record: JsonRecord): void {
for (const field of ["toolAliases", "skillRefs", "workspaceFiles", "subdir", "sparsePaths"] as const) {
if (record[field] !== undefined) throw new AgentRunError("schema-invalid", `resourceBundleRef.${field} is removed; use resourceBundleRef.bundles[] with kind=gitbundle`, { httpStatus: 400 });
@@ -241,21 +263,38 @@ function validateToolCredentials(value: unknown): NonNullable<ExecutionPolicy["s
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");
if (kind !== "env") throw new AgentRunError("schema-invalid", "toolCredentials[].projection.kind must be env in v0.1", { httpStatus: 400 });
const normalizedProjection = validateToolCredentialProjection(tool, projection, 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 };
});
}
function validateToolCredentialProjection(tool: string, projection: JsonRecord, keys: string[], index: number): NonNullable<ExecutionPolicy["secretScope"]["toolCredentials"]>[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 });
validateToolCredentialProjection(tool, envName, secretKey, keys, index);
const identity = `${tool}:${purpose ?? ""}:${envName}`;
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: { kind: "env", envName, secretKey } };
});
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 validateToolCredentialProjection(tool: string, envName: string, secretKey: string, keys: string[], index: number): void {
if (tool !== "unidesk-ssh") return;
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 });
}