feat: add aipod spec Artificer assembly
This commit is contained in:
@@ -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
@@ -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
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -273,7 +273,7 @@ function unideskSshEndpointEnvFromRecord(record: JsonRecord, fieldName: string):
|
||||
return { name, value: rawValue, sensitive: true };
|
||||
}
|
||||
|
||||
function summarizeToolCredentials(items: Array<{ tool: string; purpose: string | null; secretRef: { namespace?: string; name: string; keys?: string[] }; envName: string; secretKey: string }>, namespace: string): JsonRecord {
|
||||
function summarizeToolCredentials(items: Array<{ tool: string; purpose: string | null; secretRef: { namespace?: string; name: string; keys?: string[] }; kind?: string; envName?: string; secretKey?: string; mountPath?: string }>, namespace: string): JsonRecord {
|
||||
return {
|
||||
count: items.length,
|
||||
items: items.map((item) => ({
|
||||
@@ -282,7 +282,7 @@ function summarizeToolCredentials(items: Array<{ tool: string; purpose: string |
|
||||
name: item.secretRef.name,
|
||||
namespace: item.secretRef.namespace ?? namespace,
|
||||
keys: item.secretRef.keys ?? [],
|
||||
projection: { kind: "env", envName: item.envName, secretKey: item.secretKey },
|
||||
projection: item.kind === "volume" ? { kind: "volume", mountPath: item.mountPath ?? null } : { kind: "env", envName: item.envName ?? null, secretKey: item.secretKey ?? null },
|
||||
valuesPrinted: false,
|
||||
})),
|
||||
valuesPrinted: false,
|
||||
|
||||
+29
-2
@@ -15,6 +15,8 @@ import { createSessionPvc, deleteSessionPvc, getSessionPvcSummary, refreshSessio
|
||||
import type { SessionPvcSummary } from "./session-pvc.js";
|
||||
import type { SessionPvcOptions } from "./session-pvc.js";
|
||||
import { getProviderProfileConfig, getProviderProfileValidation, listBackendCapabilities, listProviderProfiles, removeProviderProfile, setProviderProfileConfig, setProviderProfileCredential, showProviderProfile, validateProviderProfile } from "./provider-profiles.js";
|
||||
import { listToolCredentials, setGithubSshToolCredential, showToolCredential } from "./tool-credentials.js";
|
||||
import { aipodSpecFromInput, applyAipodSpec, deleteAipodSpec, listAipodSpecs, renderAipodSpecByName, showAipodSpec } from "../common/aipod-specs.js";
|
||||
|
||||
function pvcOptions(defaults: { kubectlCommand?: string } | undefined): SessionPvcOptions {
|
||||
return defaults?.kubectlCommand ? { kubectlCommand: defaults.kubectlCommand } : {};
|
||||
@@ -46,6 +48,8 @@ export interface ManagerServerOptions {
|
||||
};
|
||||
sessionPvcOptions?: { kubectlHandler?: import("./session-pvc.js").KubectlHandler; kubectlCommand?: string; storageClassName?: string; size?: string };
|
||||
providerProfileOptions?: { namespace?: string; kubectlCommand?: string };
|
||||
toolCredentialOptions?: { namespace?: string; kubectlCommand?: string };
|
||||
aipodSpecDir?: string;
|
||||
}
|
||||
|
||||
export interface StartedManagerServer {
|
||||
@@ -60,12 +64,14 @@ export async function startManagerServer(options: ManagerServerOptions = {}): Pr
|
||||
const runnerJobDefaults = options.runnerJobDefaults;
|
||||
const sessionPvcDefaults = options.sessionPvcOptions;
|
||||
const providerProfileDefaults = options.providerProfileOptions;
|
||||
const toolCredentialDefaults = options.toolCredentialOptions;
|
||||
const aipodSpecDir = options.aipodSpecDir ?? process.env.AGENTRUN_AIPOD_SPEC_DIR;
|
||||
const server = createServer(async (req, res) => {
|
||||
const traceId = `trc_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
try {
|
||||
const method = req.method ?? "GET";
|
||||
const url = new URL(req.url ?? "/", "http://agentrun.local");
|
||||
const data = await route({ method, url, body: await readBody(req), store, sourceCommit, ...(runnerJobDefaults ? { runnerJobDefaults } : {}), ...(sessionPvcDefaults ? { sessionPvcDefaults } : {}), ...(providerProfileDefaults ? { providerProfileDefaults } : {}) });
|
||||
const data = await route({ method, url, body: await readBody(req), store, sourceCommit, ...(runnerJobDefaults ? { runnerJobDefaults } : {}), ...(sessionPvcDefaults ? { sessionPvcDefaults } : {}), ...(providerProfileDefaults ? { providerProfileDefaults } : {}), ...(toolCredentialDefaults ? { toolCredentialDefaults } : {}), ...(aipodSpecDir ? { aipodSpecDir } : {}) });
|
||||
writeJson(res, 200, { ok: true, data, traceId });
|
||||
} catch (error) {
|
||||
const agentError = normalizeError(error);
|
||||
@@ -200,7 +206,7 @@ function compactRecoveryActions(value: JsonValue | undefined): JsonValue[] {
|
||||
});
|
||||
}
|
||||
|
||||
async function route({ method, url, body, store, sourceCommit, runnerJobDefaults, sessionPvcDefaults, providerProfileDefaults }: { method: string; url: URL; body: unknown; store: AgentRunStore; sourceCommit: string; runnerJobDefaults?: NonNullable<ManagerServerOptions["runnerJobDefaults"]>; sessionPvcDefaults?: NonNullable<ManagerServerOptions["sessionPvcOptions"]>; providerProfileDefaults?: NonNullable<ManagerServerOptions["providerProfileOptions"]> }): Promise<JsonValue> {
|
||||
async function route({ method, url, body, store, sourceCommit, runnerJobDefaults, sessionPvcDefaults, providerProfileDefaults, toolCredentialDefaults, aipodSpecDir }: { method: string; url: URL; body: unknown; store: AgentRunStore; sourceCommit: string; runnerJobDefaults?: NonNullable<ManagerServerOptions["runnerJobDefaults"]>; sessionPvcDefaults?: NonNullable<ManagerServerOptions["sessionPvcOptions"]>; providerProfileDefaults?: NonNullable<ManagerServerOptions["providerProfileOptions"]>; toolCredentialDefaults?: NonNullable<ManagerServerOptions["toolCredentialOptions"]>; aipodSpecDir?: string }): Promise<JsonValue> {
|
||||
const path = url.pathname;
|
||||
if (method === "GET" && (path === "/health" || path === "/health/live" || path === "/health/readiness")) {
|
||||
const database = await store.health();
|
||||
@@ -208,6 +214,19 @@ async function route({ method, url, body, store, sourceCommit, runnerJobDefaults
|
||||
return { serviceId: "agentrun-mgr", live: true, ready, database, sourceCommit, secretRefs: { databaseUrl: database.adapter === "postgres" ? "redacted" : "not-used", valuesPrinted: false } };
|
||||
}
|
||||
if (method === "GET" && path === "/api/v1/backends") return await listBackendCapabilities(providerProfileDefaults) as JsonValue;
|
||||
if (method === "GET" && path === "/api/v1/tool-credentials") return await listToolCredentials(toolCredentialDefaults) as JsonValue;
|
||||
const toolCredentialMatch = path.match(/^\/api\/v1\/tool-credentials\/([^/]+)$/u);
|
||||
if (method === "GET" && toolCredentialMatch) return await showToolCredential(decodeURIComponent(toolCredentialMatch[1] ?? ""), toolCredentialDefaults) as JsonValue;
|
||||
const toolCredentialGithubSshMatch = path.match(/^\/api\/v1\/tool-credentials\/github-ssh\/credential$/u);
|
||||
if (method === "PUT" && toolCredentialGithubSshMatch) return await setGithubSshToolCredential(body ?? {}, toolCredentialDefaults) as JsonValue;
|
||||
if (method === "GET" && path === "/api/v1/aipod-specs") return await listAipodSpecs(aipodSpecDir) as JsonValue;
|
||||
if (method === "POST" && path === "/api/v1/aipod-specs") return await applyAipodSpec(body ?? {}, aipodSpecDir) as JsonValue;
|
||||
const aipodSpecMatch = path.match(/^\/api\/v1\/aipod-specs\/([^/]+)$/u);
|
||||
if (method === "GET" && aipodSpecMatch) return await showAipodSpec(decodeURIComponent(aipodSpecMatch[1] ?? ""), aipodSpecDir) as JsonValue;
|
||||
if (method === "PUT" && aipodSpecMatch) return await applyNamedAipodSpec(decodeURIComponent(aipodSpecMatch[1] ?? ""), body, aipodSpecDir) as JsonValue;
|
||||
if (method === "DELETE" && aipodSpecMatch) return await deleteAipodSpec(decodeURIComponent(aipodSpecMatch[1] ?? ""), aipodSpecDir) as JsonValue;
|
||||
const aipodSpecRenderMatch = path.match(/^\/api\/v1\/aipod-specs\/([^/]+)\/render$/u);
|
||||
if (method === "POST" && aipodSpecRenderMatch) return await renderAipodSpecByName(decodeURIComponent(aipodSpecRenderMatch[1] ?? ""), asRecord(body ?? {}, "aipodSpecRender"), aipodSpecDir) as JsonValue;
|
||||
if (method === "GET" && path === "/api/v1/provider-profiles") return await listProviderProfiles(providerProfileDefaults) as JsonValue;
|
||||
const providerProfileMatch = path.match(/^\/api\/v1\/provider-profiles\/([^/]+)$/u);
|
||||
if (method === "GET" && providerProfileMatch) return await showProviderProfile(providerProfileMatch[1] ?? "", providerProfileDefaults) as JsonValue;
|
||||
@@ -536,6 +555,14 @@ async function route({ method, url, body, store, sourceCommit, runnerJobDefaults
|
||||
throw new AgentRunError("schema-invalid", `unsupported route ${method} ${path}`, { httpStatus: 404 });
|
||||
}
|
||||
|
||||
async function applyNamedAipodSpec(name: string, body: unknown, dir?: string): Promise<JsonValue> {
|
||||
const spec = aipodSpecFromInput(body ?? {}, `api:${name}`);
|
||||
if (spec.metadata.name.toLowerCase() !== name.trim().toLowerCase()) {
|
||||
throw new AgentRunError("schema-invalid", "aipod-spec URL name must match metadata.name", { httpStatus: 400, details: { urlName: name, metadataName: spec.metadata.name, valuesPrinted: false } });
|
||||
}
|
||||
return await applyAipodSpec(spec, dir) as JsonValue;
|
||||
}
|
||||
|
||||
function integerQuery(url: URL, key: string, fallback: number): number {
|
||||
const value = Number(url.searchParams.get(key));
|
||||
return Number.isInteger(value) && value >= 0 ? value : fallback;
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { spawn } from "node:child_process";
|
||||
import { AgentRunError } from "../common/errors.js";
|
||||
import type { JsonRecord } from "../common/types.js";
|
||||
import { asRecord } from "../common/validation.js";
|
||||
import { redactJson, redactText } from "../common/redaction.js";
|
||||
|
||||
const defaultNamespace = "agentrun-v01";
|
||||
const annotationPrefix = "agentrun.pikastech.local/tool-credential";
|
||||
|
||||
interface ToolCredentialSpec {
|
||||
name: string;
|
||||
tool: string;
|
||||
purpose: string;
|
||||
secretName: string;
|
||||
keys: readonly string[];
|
||||
mutable: boolean;
|
||||
}
|
||||
|
||||
export interface ToolCredentialOptions {
|
||||
namespace?: string;
|
||||
kubectlCommand?: string;
|
||||
}
|
||||
|
||||
const toolCredentialSpecs: readonly ToolCredentialSpec[] = Object.freeze([
|
||||
{ name: "github-ssh", tool: "github", purpose: "github-ssh", secretName: "agentrun-v01-tool-github-ssh", keys: ["id_ed25519", "known_hosts", "config"], mutable: true },
|
||||
{ name: "unidesk-ssh", tool: "unidesk-ssh", purpose: "ssh-passthrough", secretName: "agentrun-v01-tool-unidesk-ssh", keys: ["UNIDESK_SSH_CLIENT_TOKEN"], mutable: false },
|
||||
]);
|
||||
|
||||
export async function listToolCredentials(options: ToolCredentialOptions = {}): Promise<JsonRecord> {
|
||||
const items = await Promise.all(toolCredentialSpecs.map((spec) => toolCredentialStatus(spec, options)));
|
||||
return { action: "tool-credential-list", items, count: items.length, valuesPrinted: false };
|
||||
}
|
||||
|
||||
export async function showToolCredential(name: string, options: ToolCredentialOptions = {}): Promise<JsonRecord> {
|
||||
return toolCredentialStatus(requiredSpec(name), options);
|
||||
}
|
||||
|
||||
export async function setGithubSshToolCredential(body: unknown, options: ToolCredentialOptions = {}): Promise<JsonRecord> {
|
||||
const spec = requiredSpec("github-ssh");
|
||||
const record = asRecord(body ?? {}, "githubSshToolCredential");
|
||||
const privateKey = credentialTextField(record, "privateKey", 131_072);
|
||||
const knownHosts = credentialTextField(record, "knownHosts", 131_072);
|
||||
const config = optionalTextField(record, "config", 32_768) ?? defaultGithubSshConfig();
|
||||
validatePrivateKey(privateKey);
|
||||
validateKnownHosts(knownHosts);
|
||||
validateSshConfig(config);
|
||||
const namespace = runtimeNamespace(options);
|
||||
const updatedAt = new Date().toISOString();
|
||||
const secretData = {
|
||||
id_ed25519: base64Data(privateKey),
|
||||
known_hosts: base64Data(knownHosts),
|
||||
config: base64Data(config),
|
||||
} satisfies JsonRecord;
|
||||
const secretManifest: JsonRecord = {
|
||||
apiVersion: "v1",
|
||||
kind: "Secret",
|
||||
metadata: {
|
||||
name: spec.secretName,
|
||||
namespace,
|
||||
labels: {
|
||||
"app.kubernetes.io/part-of": "agentrun",
|
||||
"agentrun.pikastech.local/tool": spec.tool,
|
||||
"agentrun.pikastech.local/purpose": spec.purpose,
|
||||
},
|
||||
annotations: {
|
||||
[`${annotationPrefix}-name`]: spec.name,
|
||||
[`${annotationPrefix}-tool`]: spec.tool,
|
||||
[`${annotationPrefix}-purpose`]: spec.purpose,
|
||||
[`${annotationPrefix}-private-key-hash-suffix`]: shortHash(privateKey),
|
||||
[`${annotationPrefix}-known-hosts-hash-suffix`]: shortHash(knownHosts),
|
||||
[`${annotationPrefix}-config-hash-suffix`]: shortHash(config),
|
||||
[`${annotationPrefix}-updated-at`]: updatedAt,
|
||||
},
|
||||
},
|
||||
type: "Opaque",
|
||||
data: secretData,
|
||||
};
|
||||
const applied = await kubectlUpsertSecret(secretManifest, options.kubectlCommand ?? "kubectl");
|
||||
return {
|
||||
action: "tool-credential-github-ssh-updated",
|
||||
mutation: true,
|
||||
name: spec.name,
|
||||
tool: spec.tool,
|
||||
purpose: spec.purpose,
|
||||
configured: true,
|
||||
secretRef: secretRefSummary(spec, namespace),
|
||||
resourceVersion: stringPath(applied, ["metadata", "resourceVersion"]),
|
||||
privateKeyHashSuffix: shortHash(privateKey),
|
||||
knownHostsHashSuffix: shortHash(knownHosts),
|
||||
configHashSuffix: shortHash(config),
|
||||
updatedAt: stringPath(applied, ["metadata", "annotations", `${annotationPrefix}-updated-at`]) ?? updatedAt,
|
||||
credentialValuesPrinted: false,
|
||||
valuesPrinted: false,
|
||||
pollCommands: {
|
||||
show: "./scripts/agentrun tool-credentials show github-ssh",
|
||||
list: "./scripts/agentrun tool-credentials list",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function toolCredentialStatus(spec: ToolCredentialSpec, options: ToolCredentialOptions): Promise<JsonRecord> {
|
||||
const namespace = runtimeNamespace(options);
|
||||
const secret = await kubectlGetSecret(spec.secretName, namespace, options.kubectlCommand ?? "kubectl");
|
||||
if (!secret) {
|
||||
return {
|
||||
name: spec.name,
|
||||
tool: spec.tool,
|
||||
purpose: spec.purpose,
|
||||
mutable: spec.mutable,
|
||||
configured: false,
|
||||
failureKind: "secret-unavailable",
|
||||
secretRef: secretRefSummary(spec, namespace),
|
||||
resourceVersion: null,
|
||||
updatedAt: null,
|
||||
keyPresence: Object.fromEntries(spec.keys.map((key) => [key, false])),
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
const data = asOptionalRecord(secret.data);
|
||||
const annotations = asOptionalRecord(asOptionalRecord(secret.metadata)?.annotations);
|
||||
return {
|
||||
name: spec.name,
|
||||
tool: spec.tool,
|
||||
purpose: spec.purpose,
|
||||
mutable: spec.mutable,
|
||||
configured: hasRequiredKeys(data, spec.keys),
|
||||
failureKind: hasRequiredKeys(data, spec.keys) ? null : "secret-unavailable",
|
||||
secretRef: secretRefSummary(spec, namespace),
|
||||
resourceVersion: stringPath(secret, ["metadata", "resourceVersion"]),
|
||||
updatedAt: stringPath(annotations, [`${annotationPrefix}-updated-at`]) ?? stringPath(secret, ["metadata", "creationTimestamp"]),
|
||||
keyPresence: Object.fromEntries(spec.keys.map((key) => [key, typeof data?.[key] === "string" && String(data[key]).length > 0])),
|
||||
keyHashSuffixes: Object.fromEntries(spec.keys.map((key) => [key, hashDataKey(data, key)])),
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
|
||||
function requiredSpec(name: string): ToolCredentialSpec {
|
||||
const spec = toolCredentialSpecs.find((item) => item.name === name);
|
||||
if (!spec) throw new AgentRunError("schema-invalid", `tool credential ${name} is not supported in v0.1`, { httpStatus: 404, details: { supported: toolCredentialSpecs.map((item) => item.name), valuesPrinted: false } });
|
||||
return spec;
|
||||
}
|
||||
|
||||
function secretRefSummary(spec: ToolCredentialSpec, namespace: string): JsonRecord {
|
||||
return { namespace, name: spec.secretName, keys: [...spec.keys], valuesPrinted: false };
|
||||
}
|
||||
|
||||
function runtimeNamespace(options: ToolCredentialOptions): string {
|
||||
return options.namespace ?? process.env.AGENTRUN_RUNTIME_NAMESPACE ?? defaultNamespace;
|
||||
}
|
||||
|
||||
function credentialTextField(record: JsonRecord, key: string, maxBytes: number): string {
|
||||
const value = record[key];
|
||||
if (typeof value !== "string" || value.trim().length === 0) throw new AgentRunError("schema-invalid", `${key} is required`, { httpStatus: 400 });
|
||||
const text = normalizeText(value);
|
||||
const bytes = Buffer.byteLength(text, "utf8");
|
||||
if (bytes > maxBytes) throw new AgentRunError("schema-invalid", `${key} exceeds the size limit`, { httpStatus: 400, details: { key, bytes, maxBytes, valuesPrinted: false } });
|
||||
return text;
|
||||
}
|
||||
|
||||
function optionalTextField(record: JsonRecord, key: string, maxBytes: number): string | undefined {
|
||||
const value = record[key];
|
||||
if (value === undefined || value === null) return undefined;
|
||||
if (typeof value !== "string") throw new AgentRunError("schema-invalid", `${key} must be a string`, { httpStatus: 400 });
|
||||
if (value.trim().length === 0) return undefined;
|
||||
const text = normalizeText(value);
|
||||
const bytes = Buffer.byteLength(text, "utf8");
|
||||
if (bytes > maxBytes) throw new AgentRunError("schema-invalid", `${key} exceeds the size limit`, { httpStatus: 400, details: { key, bytes, maxBytes, valuesPrinted: false } });
|
||||
return text;
|
||||
}
|
||||
|
||||
function normalizeText(value: string): string {
|
||||
return value.replace(/\r\n?/gu, "\n").endsWith("\n") ? value.replace(/\r\n?/gu, "\n") : `${value.replace(/\r\n?/gu, "\n")}\n`;
|
||||
}
|
||||
|
||||
function validatePrivateKey(value: string): void {
|
||||
if (!/^-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----\n/mu.test(value) || !/\n-----END [A-Z0-9 ]*PRIVATE KEY-----\n?$/mu.test(value)) {
|
||||
throw new AgentRunError("schema-invalid", "privateKey must be an OpenSSH or PEM private key", { httpStatus: 400, details: { valuesPrinted: false } });
|
||||
}
|
||||
}
|
||||
|
||||
function validateKnownHosts(value: string): void {
|
||||
const lines = value.split(/\n/u).map((line) => line.trim()).filter((line) => line.length > 0 && !line.startsWith("#"));
|
||||
if (lines.length === 0) throw new AgentRunError("schema-invalid", "knownHosts must contain at least one host key", { httpStatus: 400 });
|
||||
}
|
||||
|
||||
function validateSshConfig(value: string): void {
|
||||
if (!/Host\s+/iu.test(value) || !/IdentityFile\s+/iu.test(value)) {
|
||||
throw new AgentRunError("schema-invalid", "config must contain Host and IdentityFile entries", { httpStatus: 400, details: { valuesPrinted: false } });
|
||||
}
|
||||
}
|
||||
|
||||
function defaultGithubSshConfig(): string {
|
||||
return [
|
||||
"Host github.com",
|
||||
" HostName ssh.github.com",
|
||||
" User git",
|
||||
" Port 443",
|
||||
" IdentityFile ~/.ssh/id_ed25519",
|
||||
" IdentitiesOnly yes",
|
||||
" StrictHostKeyChecking yes",
|
||||
" UserKnownHostsFile ~/.ssh/known_hosts",
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
async function kubectlGetSecret(name: string, namespace: string, kubectlCommand: string): Promise<JsonRecord | null> {
|
||||
const result = await runKubectl(kubectlCommand, ["get", "secret", name, "-n", namespace, "-o", "json"]);
|
||||
if (result.code !== 0) {
|
||||
const failureText = `${result.stderr}\n${result.stdout}`;
|
||||
if (/notfound|not found|not-found/iu.test(failureText)) return null;
|
||||
throw new AgentRunError("infra-failed", `kubectl get tool credential Secret ${namespace}/${name} failed with code ${result.code}`, { httpStatus: 502, details: redactJson({ stderr: redactText(result.stderr.slice(-2000)), stdout: redactText(result.stdout.slice(-1000)) }) });
|
||||
}
|
||||
return parseKubectlObject(result.stdout, "tool credential secret");
|
||||
}
|
||||
|
||||
async function kubectlUpsertSecret(manifest: JsonRecord, kubectlCommand: string): Promise<JsonRecord> {
|
||||
const name = stringPath(manifest, ["metadata", "name"]) ?? "<unknown>";
|
||||
const namespace = stringPath(manifest, ["metadata", "namespace"]) ?? defaultNamespace;
|
||||
const stdin = `${JSON.stringify(manifest)}\n`;
|
||||
const replace = await runKubectl(kubectlCommand, ["replace", "-f", "-", "-o", "json"], stdin);
|
||||
if (replace.code === 0) return parseKubectlObject(replace.stdout, "tool credential secret replace", { redactSecretData: true });
|
||||
if (isKubectlNotFoundFailure(replace)) {
|
||||
const created = await runKubectl(kubectlCommand, ["create", "-f", "-", "-o", "json"], stdin);
|
||||
if (created.code === 0) return parseKubectlObject(created.stdout, "tool credential secret create", { redactSecretData: true });
|
||||
throw new AgentRunError("infra-failed", `kubectl create tool credential Secret ${namespace}/${name} failed with code ${created.code}`, { httpStatus: 502, details: redactJson({ stderr: redactText(created.stderr.slice(-2000)), stdout: redactText(created.stdout.slice(-1000)) }) });
|
||||
}
|
||||
throw new AgentRunError("infra-failed", `kubectl replace tool credential Secret ${namespace}/${name} failed with code ${replace.code}`, { httpStatus: 502, details: redactJson({ stderr: redactText(replace.stderr.slice(-2000)), stdout: redactText(replace.stdout.slice(-1000)) }) });
|
||||
}
|
||||
|
||||
function isKubectlNotFoundFailure(result: { stdout: string; stderr: string }): boolean {
|
||||
return /notfound|not found|not-found/iu.test(`${result.stderr}\n${result.stdout}`);
|
||||
}
|
||||
|
||||
async function runKubectl(kubectlCommand: string, args: string[], stdin?: string): Promise<{ code: number | null; signal: NodeJS.Signals | null; stdout: string; stderr: string }> {
|
||||
const child = spawn(kubectlCommand, args, { stdio: ["pipe", "pipe", "pipe"] });
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.setEncoding("utf8");
|
||||
child.stderr.setEncoding("utf8");
|
||||
child.stdout.on("data", (chunk) => { stdout += String(chunk); });
|
||||
child.stderr.on("data", (chunk) => { stderr += String(chunk); });
|
||||
child.stdin.end(stdin ?? "");
|
||||
const result = await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve, reject) => {
|
||||
child.on("error", reject);
|
||||
child.on("close", (code, signal) => resolve({ code, signal }));
|
||||
}).catch((error: unknown) => {
|
||||
throw new AgentRunError("infra-failed", `failed to start kubectl: ${error instanceof Error ? error.message : String(error)}`, { httpStatus: 503 });
|
||||
});
|
||||
return { ...result, stdout, stderr };
|
||||
}
|
||||
|
||||
function parseKubectlObject(stdout: string, label: string, options: { redactSecretData?: boolean } = {}): JsonRecord {
|
||||
try {
|
||||
const parsed = JSON.parse(stdout) as unknown;
|
||||
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) return options.redactSecretData ? redactSecretObject(parsed as JsonRecord) : parsed as JsonRecord;
|
||||
} catch (error) {
|
||||
throw new AgentRunError("infra-failed", `kubectl returned invalid JSON for ${label}: ${error instanceof Error ? error.message : String(error)}`, { httpStatus: 502, details: { stdoutPreview: label.includes("secret") ? "REDACTED" : redactText(stdout.slice(0, 1000)) } });
|
||||
}
|
||||
throw new AgentRunError("infra-failed", `kubectl returned non-object JSON for ${label}`, { httpStatus: 502 });
|
||||
}
|
||||
|
||||
function redactSecretObject(object: JsonRecord): JsonRecord {
|
||||
const copy = JSON.parse(JSON.stringify(object)) as JsonRecord;
|
||||
if (copy.data) copy.data = "REDACTED";
|
||||
if (copy.stringData) copy.stringData = "REDACTED";
|
||||
return copy;
|
||||
}
|
||||
|
||||
function hasRequiredKeys(data: JsonRecord | null, keys: readonly string[]): boolean {
|
||||
return keys.every((key) => typeof data?.[key] === "string" && String(data[key]).length > 0);
|
||||
}
|
||||
|
||||
function hashDataKey(data: JsonRecord | null, key: string): string | null {
|
||||
const value = data?.[key];
|
||||
if (typeof value !== "string" || value.length === 0) return null;
|
||||
try {
|
||||
return shortHash(Buffer.from(value, "base64").toString("utf8"));
|
||||
} catch {
|
||||
return shortHash(value);
|
||||
}
|
||||
}
|
||||
|
||||
function base64Data(value: string): string {
|
||||
return Buffer.from(value, "utf8").toString("base64");
|
||||
}
|
||||
|
||||
function shortHash(value: string): string {
|
||||
return createHash("sha256").update(value).digest("hex").slice(0, 12);
|
||||
}
|
||||
|
||||
function objectPath(record: JsonRecord, path: string[]): unknown {
|
||||
let current: unknown = record;
|
||||
for (const key of path) {
|
||||
if (typeof current !== "object" || current === null || Array.isArray(current)) return null;
|
||||
current = (current as JsonRecord)[key];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function stringPath(record: JsonRecord | null | undefined, path: string[]): string | null {
|
||||
if (!record) return null;
|
||||
const value = objectPath(record, path);
|
||||
return typeof value === "string" ? value : null;
|
||||
}
|
||||
|
||||
function asOptionalRecord(value: unknown): JsonRecord | null {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as JsonRecord : null;
|
||||
}
|
||||
+31
-10
@@ -79,14 +79,26 @@ interface CredentialProjection {
|
||||
projectionMountPath: string;
|
||||
}
|
||||
|
||||
interface ToolCredentialProjection {
|
||||
type ToolCredentialProjection = ToolCredentialEnvProjection | ToolCredentialVolumeProjection;
|
||||
|
||||
interface ToolCredentialBaseProjection {
|
||||
tool: string;
|
||||
purpose: string | null;
|
||||
secretRef: SecretRef;
|
||||
}
|
||||
|
||||
interface ToolCredentialEnvProjection extends ToolCredentialBaseProjection {
|
||||
kind: "env";
|
||||
envName: string;
|
||||
secretKey: string;
|
||||
}
|
||||
|
||||
interface ToolCredentialVolumeProjection extends ToolCredentialBaseProjection {
|
||||
kind: "volume";
|
||||
volumeName: string;
|
||||
mountPath: string;
|
||||
}
|
||||
|
||||
export function renderRunnerJobDryRun(options: RunnerJobRenderOptions): JsonRecord {
|
||||
const render = renderRunnerJobManifest({ ...options, dryRun: true });
|
||||
const manifest = redactTransientEnvInManifest(render.manifest, options.transientEnv ?? []);
|
||||
@@ -176,6 +188,7 @@ export function renderRunnerJobManifest(options: RunnerJobRenderOptions): { mani
|
||||
volumeMounts: [
|
||||
{ name: "runner-home", mountPath: "/home/agentrun" },
|
||||
...secretRefs.map((item) => ({ name: item.volumeName, mountPath: item.projectionMountPath, readOnly: true })),
|
||||
...toolCredentialVolumeMounts(toolCredentials),
|
||||
...(sessionPvc ? [{ name: "agentrun-sessions", mountPath: sessionPvc.mountPath, readOnly: false }] : []),
|
||||
],
|
||||
resources: {
|
||||
@@ -192,6 +205,7 @@ export function renderRunnerJobManifest(options: RunnerJobRenderOptions): { mani
|
||||
volumes: [
|
||||
{ name: "runner-home", emptyDir: {} },
|
||||
...secretRefs.map(secretVolume),
|
||||
...toolCredentialVolumes(toolCredentials),
|
||||
...(sessionPvc ? [{ name: "agentrun-sessions", persistentVolumeClaim: { claimName: sessionPvc.pvcName } }] : []),
|
||||
],
|
||||
},
|
||||
@@ -248,7 +262,7 @@ function codexShellSandbox(policy: ExecutionPolicy): string {
|
||||
}
|
||||
|
||||
function toolCredentialEnvVars(items: ToolCredentialProjection[]): JsonRecord[] {
|
||||
return items.map((item) => ({
|
||||
return items.filter((item): item is ToolCredentialEnvProjection => item.kind === "env").map((item) => ({
|
||||
name: item.envName,
|
||||
valueFrom: {
|
||||
secretKeyRef: {
|
||||
@@ -259,6 +273,14 @@ function toolCredentialEnvVars(items: ToolCredentialProjection[]): JsonRecord[]
|
||||
}));
|
||||
}
|
||||
|
||||
function toolCredentialVolumeMounts(items: ToolCredentialProjection[]): JsonRecord[] {
|
||||
return items.filter((item): item is ToolCredentialVolumeProjection => item.kind === "volume").map((item) => ({ name: item.volumeName, mountPath: item.mountPath, readOnly: true }));
|
||||
}
|
||||
|
||||
function toolCredentialVolumes(items: ToolCredentialProjection[]): JsonRecord[] {
|
||||
return items.filter((item): item is ToolCredentialVolumeProjection => item.kind === "volume").map((item) => secretVolume({ profile: item.tool, secretRef: item.secretRef, volumeName: item.volumeName, runtimeMountPath: item.mountPath, projectionMountPath: item.mountPath }));
|
||||
}
|
||||
|
||||
function transientEnvVars(items: RunnerTransientEnv[]): JsonRecord[] {
|
||||
return items.map((item) => {
|
||||
if (item.secretRef) {
|
||||
@@ -318,7 +340,7 @@ function summarizeToolCredentials(items: ToolCredentialProjection[], namespace:
|
||||
name: item.secretRef.name,
|
||||
namespace: item.secretRef.namespace ?? namespace,
|
||||
keys: item.secretRef.keys ?? [],
|
||||
projection: { kind: "env", envName: item.envName, secretKey: item.secretKey },
|
||||
projection: item.kind === "env" ? { kind: "env", envName: item.envName, secretKey: item.secretKey } : { kind: "volume", mountPath: item.mountPath },
|
||||
valuesPrinted: false,
|
||||
})),
|
||||
valuesPrinted: false,
|
||||
@@ -363,13 +385,12 @@ function credentialSecretRef(profile: string, secretRef: SecretRef, namespace: s
|
||||
function toolCredentialProjections(run: RunRecord, namespace: string): ToolCredentialProjection[] {
|
||||
const policy: ExecutionPolicy = run.executionPolicy;
|
||||
const credentials = policy.secretScope.toolCredentials ?? [];
|
||||
return credentials.map((item) => ({
|
||||
tool: item.tool,
|
||||
purpose: item.purpose ?? null,
|
||||
secretRef: item.secretRef.namespace ? item.secretRef : { ...item.secretRef, namespace },
|
||||
envName: item.projection.envName,
|
||||
secretKey: item.projection.secretKey ?? item.secretRef.keys?.[0] ?? item.projection.envName,
|
||||
}));
|
||||
return credentials.map((item, index) => {
|
||||
const secretRef = item.secretRef.namespace ? item.secretRef : { ...item.secretRef, namespace };
|
||||
const base = { tool: item.tool, purpose: item.purpose ?? null, secretRef };
|
||||
if (item.projection.kind === "env") return { ...base, kind: "env" as const, envName: item.projection.envName, secretKey: item.projection.secretKey ?? item.secretRef.keys?.[0] ?? item.projection.envName };
|
||||
return { ...base, kind: "volume" as const, volumeName: sanitizeVolumeName(`tool-${item.tool}-${index}`), mountPath: item.projection.mountPath };
|
||||
});
|
||||
}
|
||||
|
||||
function secretVolume(item: CredentialProjection): JsonRecord {
|
||||
|
||||
@@ -43,6 +43,9 @@ interface MaterializedSkillRef {
|
||||
|
||||
interface GitCheckout {
|
||||
repoUrl: string;
|
||||
fetchRepoUrl: string;
|
||||
mirrorUsed: boolean;
|
||||
mirrorBaseUrl?: string;
|
||||
commitId: string;
|
||||
requestedCommitId?: string;
|
||||
requestedRef?: string;
|
||||
@@ -50,15 +53,24 @@ interface GitCheckout {
|
||||
treeId: string;
|
||||
}
|
||||
|
||||
interface GitMirrorConfig {
|
||||
enabled: true;
|
||||
baseUrl: string;
|
||||
}
|
||||
|
||||
interface GitBundleSource {
|
||||
repoUrl: string;
|
||||
commitId?: string;
|
||||
ref?: string;
|
||||
gitMirror?: GitMirrorConfig;
|
||||
}
|
||||
|
||||
interface MaterializedGitBundle {
|
||||
name: string | null;
|
||||
repoUrl: string;
|
||||
fetchRepoUrl: string;
|
||||
mirrorUsed: boolean;
|
||||
mirrorBaseUrl: string | null;
|
||||
commitId: string;
|
||||
requestedCommitId: string | null;
|
||||
requestedRef: string | null;
|
||||
@@ -78,7 +90,8 @@ export async function materializeResourceBundle(resourceBundleRef: ResourceBundl
|
||||
await rm(assemblyRoot, { recursive: true, force: true });
|
||||
await mkdir(checkoutRoot, { recursive: true });
|
||||
await mkdir(workspacePath, { recursive: true });
|
||||
const defaultSource = defaultGitBundleSource(resourceBundleRef, env);
|
||||
const gitMirror = gitMirrorConfig(resourceBundleRef, env);
|
||||
const defaultSource = defaultGitBundleSource(resourceBundleRef, env, gitMirror);
|
||||
const checkoutCache = new Map<string, Promise<GitCheckout>>();
|
||||
const checkoutFor = (source: GitBundleSource) => {
|
||||
const key = stableHash(gitSourceIdentity(source));
|
||||
@@ -105,6 +118,10 @@ export async function materializeResourceBundle(resourceBundleRef: ResourceBundl
|
||||
phase: "resource-bundle-materialized",
|
||||
kind: "gitbundle",
|
||||
repoUrl: resourceBundleRef.repoUrl,
|
||||
fetchRepoUrl: defaultCheckout.fetchRepoUrl,
|
||||
mirrorUsed: defaultCheckout.mirrorUsed,
|
||||
mirrorBaseUrl: defaultCheckout.mirrorBaseUrl ?? null,
|
||||
gitMirror: gitMirror ? { enabled: true, baseUrl: gitMirror.baseUrl, valuesPrinted: false } : { enabled: false, baseUrl: null, valuesPrinted: false },
|
||||
commitId: defaultCheckout.commitId,
|
||||
requestedCommitId: resourceBundleRef.commitId ?? null,
|
||||
requestedRef: defaultCheckout.requestedRef ?? null,
|
||||
@@ -126,32 +143,44 @@ export async function materializeResourceBundle(resourceBundleRef: ResourceBundl
|
||||
};
|
||||
}
|
||||
|
||||
function defaultGitBundleSource(resourceBundleRef: ResourceBundleRef, env: NodeJS.ProcessEnv): GitBundleSource {
|
||||
function gitMirrorConfig(resourceBundleRef: ResourceBundleRef, env: NodeJS.ProcessEnv): GitMirrorConfig | undefined {
|
||||
return normalizeGitMirrorConfig(resourceBundleRef.gitMirror, env);
|
||||
}
|
||||
|
||||
function normalizeGitMirrorConfig(gitMirror: ResourceBundleRef["gitMirror"] | GitMirrorConfig | undefined, env: NodeJS.ProcessEnv): GitMirrorConfig | undefined {
|
||||
if (!gitMirror || gitMirror.enabled === false) return undefined;
|
||||
const baseUrl = optionalNonEmpty(gitMirror.baseUrl) ?? optionalNonEmpty(env.AGENTRUN_GIT_MIRROR_BASE_URL) ?? "http://git-mirror-http.devops-infra.svc.cluster.local";
|
||||
return { enabled: true, baseUrl: baseUrl.replace(/\/+$/u, "") };
|
||||
}
|
||||
|
||||
function defaultGitBundleSource(resourceBundleRef: ResourceBundleRef, env: NodeJS.ProcessEnv, gitMirror?: GitMirrorConfig): GitBundleSource {
|
||||
const ref = optionalNonEmpty(resourceBundleRef.ref) ?? optionalNonEmpty(env.AGENTRUN_RESOURCE_BUNDLE_REF) ?? optionalNonEmpty(env.AGENTRUN_WORKSPACE_REF) ?? optionalNonEmpty(env.AGENTRUN_WORKSPACE_BRANCH);
|
||||
if (ref) return { repoUrl: resourceBundleRef.repoUrl, ref };
|
||||
if (ref) return { repoUrl: resourceBundleRef.repoUrl, ref, ...(gitMirror ? { gitMirror } : {}) };
|
||||
const commitId = optionalNonEmpty(resourceBundleRef.commitId);
|
||||
if (commitId) return { repoUrl: resourceBundleRef.repoUrl, commitId };
|
||||
return { repoUrl: resourceBundleRef.repoUrl, ref: "HEAD" };
|
||||
if (commitId) return { repoUrl: resourceBundleRef.repoUrl, commitId, ...(gitMirror ? { gitMirror } : {}) };
|
||||
return { repoUrl: resourceBundleRef.repoUrl, ref: "HEAD", ...(gitMirror ? { gitMirror } : {}) };
|
||||
}
|
||||
|
||||
function bundleGitSource(bundle: ResourceBundleRef["bundles"][number], resourceBundleRef: ResourceBundleRef, defaultSource: GitBundleSource): GitBundleSource {
|
||||
const repoUrl = bundle.repoUrl ?? resourceBundleRef.repoUrl;
|
||||
const ref = optionalNonEmpty(bundle.ref);
|
||||
if (ref) return { repoUrl, ref };
|
||||
const mirror = defaultSource.gitMirror;
|
||||
if (ref) return { repoUrl, ref, ...(mirror ? { gitMirror: mirror } : {}) };
|
||||
const commitId = optionalNonEmpty(bundle.commitId);
|
||||
if (commitId) return { repoUrl, commitId };
|
||||
if (commitId) return { repoUrl, commitId, ...(mirror ? { gitMirror: mirror } : {}) };
|
||||
if (repoUrl === defaultSource.repoUrl) return defaultSource;
|
||||
if (defaultSource.ref) return { repoUrl, ref: defaultSource.ref };
|
||||
if (defaultSource.commitId) return { repoUrl, commitId: defaultSource.commitId };
|
||||
return { repoUrl, ref: "HEAD" };
|
||||
if (defaultSource.ref) return { repoUrl, ref: defaultSource.ref, ...(mirror ? { gitMirror: mirror } : {}) };
|
||||
if (defaultSource.commitId) return { repoUrl, commitId: defaultSource.commitId, ...(mirror ? { gitMirror: mirror } : {}) };
|
||||
return { repoUrl, ref: "HEAD", ...(mirror ? { gitMirror: mirror } : {}) };
|
||||
}
|
||||
|
||||
async function checkoutGitSource(checkoutRoot: string, source: GitBundleSource): Promise<GitCheckout> {
|
||||
const checkoutPath = path.join(checkoutRoot, stableHash(gitSourceIdentity(source)).slice(0, 16));
|
||||
const fetch = gitFetchSource(source);
|
||||
await mkdir(checkoutPath, { recursive: true });
|
||||
await git(["init"], checkoutPath);
|
||||
await git(["remote", "remove", "origin"], checkoutPath, { allowFailure: true });
|
||||
await git(["remote", "add", "origin", source.repoUrl], checkoutPath);
|
||||
await git(["remote", "add", "origin", fetch.fetchRepoUrl], checkoutPath);
|
||||
if (source.ref) {
|
||||
await git(["fetch", "--depth", "1", "origin", source.ref], checkoutPath);
|
||||
await git(["checkout", "--detach", "FETCH_HEAD"], checkoutPath);
|
||||
@@ -164,11 +193,41 @@ async function checkoutGitSource(checkoutRoot: string, source: GitBundleSource):
|
||||
const actualCommit = (await git(["rev-parse", "HEAD"], checkoutPath)).stdout.trim();
|
||||
if (source.commitId && actualCommit !== source.commitId) throw new AgentRunError("infra-failed", "gitbundle checkout did not land on requested commit", { httpStatus: 500, details: { expectedCommit: source.commitId, actualCommit } });
|
||||
const treeId = (await git(["rev-parse", "HEAD^{tree}"], checkoutPath)).stdout.trim();
|
||||
return { repoUrl: source.repoUrl, commitId: actualCommit, ...(source.commitId ? { requestedCommitId: source.commitId } : {}), ...(source.ref ? { requestedRef: source.ref } : {}), checkoutPath, treeId };
|
||||
return { repoUrl: source.repoUrl, fetchRepoUrl: fetch.fetchRepoUrl, mirrorUsed: fetch.mirrorUsed, ...(fetch.mirrorBaseUrl ? { mirrorBaseUrl: fetch.mirrorBaseUrl } : {}), commitId: actualCommit, ...(source.commitId ? { requestedCommitId: source.commitId } : {}), ...(source.ref ? { requestedRef: source.ref } : {}), checkoutPath, treeId };
|
||||
}
|
||||
|
||||
function gitSourceIdentity(source: GitBundleSource): JsonRecord {
|
||||
return { repoUrl: source.repoUrl, commitId: source.commitId ?? null, ref: source.ref ?? null };
|
||||
return { repoUrl: source.repoUrl, commitId: source.commitId ?? null, ref: source.ref ?? null, gitMirror: source.gitMirror ? { enabled: true, baseUrl: source.gitMirror.baseUrl } : null };
|
||||
}
|
||||
|
||||
export function resolveGitBundleFetchSource(repoUrl: string, gitMirror?: ResourceBundleRef["gitMirror"] | GitMirrorConfig, env: NodeJS.ProcessEnv = process.env): { fetchRepoUrl: string; mirrorUsed: boolean; mirrorBaseUrl?: string } {
|
||||
const mirror = normalizeGitMirrorConfig(gitMirror, env);
|
||||
if (!mirror) return { fetchRepoUrl: repoUrl, mirrorUsed: false };
|
||||
const githubPath = githubRepoPath(repoUrl);
|
||||
if (!githubPath) return { fetchRepoUrl: repoUrl, mirrorUsed: false };
|
||||
return { fetchRepoUrl: `${mirror.baseUrl}/${githubPath}.git`, mirrorUsed: true, mirrorBaseUrl: mirror.baseUrl };
|
||||
}
|
||||
|
||||
function gitFetchSource(source: GitBundleSource): { fetchRepoUrl: string; mirrorUsed: boolean; mirrorBaseUrl?: string } {
|
||||
return resolveGitBundleFetchSource(source.repoUrl, source.gitMirror);
|
||||
}
|
||||
|
||||
function githubRepoPath(repoUrl: string): string | null {
|
||||
const raw = repoUrl.trim();
|
||||
const scp = /^git@github\.com:([^/]+)\/([^/]+?)(?:\.git)?$/u.exec(raw);
|
||||
if (scp) return cleanGithubPath(scp[1] ?? "", scp[2] ?? "");
|
||||
const ssh = /^ssh:\/\/git@(?:github\.com|ssh\.github\.com)(?::\d+)?\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/u.exec(raw);
|
||||
if (ssh) return cleanGithubPath(ssh[1] ?? "", ssh[2] ?? "");
|
||||
const http = /^https?:\/\/github\.com\/([^/]+)\/([^/#?]+?)(?:\.git)?\/?$/u.exec(raw);
|
||||
if (http) return cleanGithubPath(http[1] ?? "", http[2] ?? "");
|
||||
return null;
|
||||
}
|
||||
|
||||
function cleanGithubPath(owner: string, repo: string): string | null {
|
||||
const cleanOwner = owner.trim();
|
||||
const cleanRepo = repo.trim().replace(/\.git$/u, "");
|
||||
if (!/^[A-Za-z0-9_.-]+$/u.test(cleanOwner) || !/^[A-Za-z0-9_.-]+$/u.test(cleanRepo)) return null;
|
||||
return `${cleanOwner}/${cleanRepo}`;
|
||||
}
|
||||
|
||||
async function materializeGitBundles(workspacePath: string, resourceBundleRef: ResourceBundleRef, defaultSource: GitBundleSource, defaultCheckout: GitCheckout, checkoutFor: (source: GitBundleSource) => Promise<GitCheckout>): Promise<MaterializedGitBundle[]> {
|
||||
@@ -187,7 +246,7 @@ async function materializeGitBundles(workspacePath: string, resourceBundleRef: R
|
||||
await mkdir(path.dirname(target), { recursive: true });
|
||||
await rm(target, { recursive: true, force: true });
|
||||
await cp(source, target, { recursive: true, force: true, dereference: false });
|
||||
items.push({ name: bundle.name ?? null, repoUrl: checkout.repoUrl, commitId: checkout.commitId, requestedCommitId: bundle.commitId ?? resourceBundleRef.commitId ?? null, requestedRef: checkout.requestedRef ?? null, subpath: bundle.subpath, targetPath: bundle.targetPath, sourceKind: sourceStat.isDirectory() ? "directory" : "file", sourceBytes: sourceStat.isFile() ? sourceStat.size : null });
|
||||
items.push({ name: bundle.name ?? null, repoUrl: checkout.repoUrl, fetchRepoUrl: checkout.fetchRepoUrl, mirrorUsed: checkout.mirrorUsed, mirrorBaseUrl: checkout.mirrorBaseUrl ?? null, commitId: checkout.commitId, requestedCommitId: bundle.commitId ?? resourceBundleRef.commitId ?? null, requestedRef: checkout.requestedRef ?? null, subpath: bundle.subpath, targetPath: bundle.targetPath, sourceKind: sourceStat.isDirectory() ? "directory" : "file", sourceBytes: sourceStat.isFile() ? sourceStat.size : null });
|
||||
}
|
||||
return items;
|
||||
}
|
||||
@@ -380,6 +439,9 @@ function skillSourceBundle(relativePath: string, bundles: MaterializedGitBundle[
|
||||
return {
|
||||
name: match.name,
|
||||
repoUrl: match.repoUrl,
|
||||
fetchRepoUrl: match.fetchRepoUrl,
|
||||
mirrorUsed: match.mirrorUsed,
|
||||
mirrorBaseUrl: match.mirrorBaseUrl,
|
||||
commitId: match.commitId,
|
||||
requestedCommitId: match.requestedCommitId,
|
||||
requestedRef: match.requestedRef,
|
||||
|
||||
@@ -24,7 +24,13 @@ const selfTest: SelfTestCase = async (context) => {
|
||||
secretRef: { name: "agentrun-v01-tool-unidesk-ssh", keys: ["UNIDESK_SSH_CLIENT_TOKEN"] },
|
||||
projection: { kind: "env", envName: "UNIDESK_SSH_CLIENT_TOKEN", secretKey: "UNIDESK_SSH_CLIENT_TOKEN" },
|
||||
}];
|
||||
const combinedToolCredentials = [...githubToolCredentials, ...unideskSshToolCredentials];
|
||||
const githubSshToolCredentials = [{
|
||||
tool: "github",
|
||||
purpose: "github-ssh",
|
||||
secretRef: { name: "agentrun-v01-tool-github-ssh", keys: ["id_ed25519", "known_hosts", "config"] },
|
||||
projection: { kind: "volume", mountPath: "/home/agentrun/.ssh" },
|
||||
}];
|
||||
const combinedToolCredentials = [...githubToolCredentials, ...unideskSshToolCredentials, ...githubSshToolCredentials];
|
||||
const item = await createRunWithCommand(client, { ...context, toolCredentials: combinedToolCredentials }, "job smoke", "selftest-job-render", 15_000);
|
||||
const rendered = renderRunnerJobDryRun({
|
||||
run: await client.get(`/api/v1/runs/${item.runId}`) as RunRecord,
|
||||
@@ -42,6 +48,7 @@ const selfTest: SelfTestCase = async (context) => {
|
||||
assertRunnerJobUsesWritableCodexHome(rendered.manifest as JsonRecord, context.codexHome, "codex-0", "/var/run/agentrun/secrets/codex-0");
|
||||
assertRunnerJobUsesToolCredential(rendered, "GH_TOKEN", "agentrun-v01-tool-github-pr", "GH_TOKEN");
|
||||
assertRunnerJobUsesToolCredential(rendered, "UNIDESK_SSH_CLIENT_TOKEN", "agentrun-v01-tool-unidesk-ssh", "UNIDESK_SSH_CLIENT_TOKEN");
|
||||
assertRunnerJobUsesToolCredentialVolume(rendered, "agentrun-v01-tool-github-ssh", "/home/agentrun/.ssh", ["id_ed25519", "known_hosts", "config"]);
|
||||
assertRunnerJobUsesG14EgressProxy(rendered.manifest as JsonRecord);
|
||||
assert.equal(runnerEnvValue(rendered.manifest as JsonRecord, "AGENTRUN_CODEX_SHELL_SANDBOX"), "danger-full-access");
|
||||
assert.equal(runnerEnvValue(rendered.manifest as JsonRecord, "HWLAB_API_KEY"), "REDACTED");
|
||||
@@ -211,6 +218,7 @@ process.exit(1);
|
||||
assertRunnerJobUsesTransientEnvSecret(manifest, "UNIDESK_MAIN_SERVER_IP", String(transientEnvSecret.name));
|
||||
assertRunnerJobUsesToolCredential({ manifest, toolCredentials: (created as JsonRecord).toolCredentials } as JsonRecord, "GH_TOKEN", "agentrun-v01-tool-github-pr", "GH_TOKEN");
|
||||
assertRunnerJobUsesToolCredential({ manifest, toolCredentials: (created as JsonRecord).toolCredentials } as JsonRecord, "UNIDESK_SSH_CLIENT_TOKEN", "agentrun-v01-tool-unidesk-ssh", "UNIDESK_SSH_CLIENT_TOKEN");
|
||||
assertRunnerJobUsesToolCredentialVolume({ manifest, toolCredentials: (created as JsonRecord).toolCredentials } as JsonRecord, "agentrun-v01-tool-github-ssh", "/home/agentrun/.ssh", ["id_ed25519", "known_hosts", "config"]);
|
||||
assertNoSecretLeak(created);
|
||||
const defaultEndpointJobItem = await createRunWithCommand(jobClient, { ...context, toolCredentials: unideskSshToolCredentials }, "job create unidesk ssh default endpoint", "selftest-job-create-unidesk-ssh-default-endpoint", 15_000);
|
||||
const defaultEndpointCreated = await jobClient.post(`/api/v1/runs/${defaultEndpointJobItem.runId}/runner-jobs`, {
|
||||
@@ -277,7 +285,7 @@ process.exit(1);
|
||||
assert.equal(envMap.get("AGENTRUN_SESSION_PVC_NAMESPACE"), "agentrun-v01");
|
||||
assert.equal(envMap.get("AGENTRUN_SESSION_PVC_MOUNT_PATH"), "/home/agentrun/.codex-codex/sessions");
|
||||
assert.equal(envMap.get("AGENTRUN_CODEX_ROLLOUT_SUBDIR"), "sessions");
|
||||
return { name: "runner-k8s-job", tests: ["runner-k8s-job-dry-run", "runner-k8s-job-codex-shell-sandbox-env", "runner-k8s-job-g14-egress-proxy-env", "runner-k8s-job-deepseek-profile-dry-run", "runner-k8s-job-minimax-m3-profile-dry-run", "runner-k8s-job-dsflash-go-profile-dry-run", "runner-k8s-job-dsflash-go-legacy-secretref-normalized", "runner-k8s-job-create-api", "runner-k8s-job-retention-ttl", "runner-job-transient-env", "runner-job-transient-env-secretref", "runner-job-tool-credential-env", "runner-job-unidesk-ssh-tool-credential-env", "runner-job-unidesk-ssh-endpoint-auto-env", "runner-job-unidesk-ssh-transient-env-denied", "runner-k8s-job-session-pvc-volume-and-env"] };
|
||||
return { name: "runner-k8s-job", tests: ["runner-k8s-job-dry-run", "runner-k8s-job-codex-shell-sandbox-env", "runner-k8s-job-g14-egress-proxy-env", "runner-k8s-job-deepseek-profile-dry-run", "runner-k8s-job-minimax-m3-profile-dry-run", "runner-k8s-job-dsflash-go-profile-dry-run", "runner-k8s-job-dsflash-go-legacy-secretref-normalized", "runner-k8s-job-create-api", "runner-k8s-job-retention-ttl", "runner-job-transient-env", "runner-job-transient-env-secretref", "runner-job-tool-credential-env", "runner-job-unidesk-ssh-tool-credential-env", "runner-job-tool-credential-volume", "runner-job-unidesk-ssh-endpoint-auto-env", "runner-job-unidesk-ssh-transient-env-denied", "runner-k8s-job-session-pvc-volume-and-env"] };
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => server.server.close(() => resolve()));
|
||||
}
|
||||
@@ -353,6 +361,35 @@ function assertRunnerJobUsesToolCredential(rendered: JsonRecord, envName: string
|
||||
assert.equal(summaryEntry.valuesPrinted, false);
|
||||
}
|
||||
|
||||
function assertRunnerJobUsesToolCredentialVolume(rendered: JsonRecord, secretName: string, mountPath: string, secretKeys: string[]): void {
|
||||
const manifest = rendered.manifest as JsonRecord;
|
||||
const spec = manifest.spec as JsonRecord;
|
||||
const template = spec.template as JsonRecord;
|
||||
const podSpec = template.spec as JsonRecord;
|
||||
const containers = podSpec.containers as JsonRecord[];
|
||||
const runner = containers[0] as JsonRecord;
|
||||
const mounts = runner.volumeMounts as JsonRecord[];
|
||||
const mount = mounts.find((item) => item.mountPath === mountPath) as JsonRecord | undefined;
|
||||
assert.ok(mount, `${mountPath} tool credential volume mount should be present`);
|
||||
assert.equal(mount.readOnly, true);
|
||||
const volumes = podSpec.volumes as JsonRecord[];
|
||||
const volume = volumes.find((item) => item.name === mount.name) as JsonRecord | undefined;
|
||||
assert.ok(volume, `${mountPath} tool credential volume should be present`);
|
||||
const secret = volume.secret as JsonRecord;
|
||||
assert.equal(secret.secretName, secretName);
|
||||
assert.deepEqual((secret.items as JsonRecord[]).map((item) => item.key), secretKeys);
|
||||
|
||||
const summary = rendered.toolCredentials as JsonRecord;
|
||||
assert.equal(summary.valuesPrinted, false);
|
||||
const items = summary.items as JsonRecord[];
|
||||
const summaryEntry = items.find((item) => {
|
||||
const projection = item.projection as JsonRecord;
|
||||
return item.name === secretName && projection.kind === "volume" && projection.mountPath === mountPath;
|
||||
});
|
||||
assert.ok(summaryEntry, `${mountPath} tool credential summary should include its SecretRef and volume projection`);
|
||||
assert.equal(summaryEntry.valuesPrinted, false);
|
||||
}
|
||||
|
||||
function assertRunnerJobUsesWritableCodexHome(manifest: JsonRecord, expectedCodexHome: string, volumeName: string, projectionPath: string): void {
|
||||
const spec = manifest.spec as JsonRecord;
|
||||
const template = spec.template as JsonRecord;
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { chmod, readFile, writeFile } from "node:fs/promises";
|
||||
import { spawn } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { ManagerClient } from "../../mgr/client.js";
|
||||
import { startManagerServer } from "../../mgr/server.js";
|
||||
import { MemoryAgentRunStore } from "../../mgr/store.js";
|
||||
import type { JsonRecord } from "../../common/types.js";
|
||||
import { assertNoSecretLeak, type SelfTestCase } from "../harness.js";
|
||||
|
||||
const privateKeyHeader = ["-----BEGIN OPENSSH", "PRIVATE KEY-----"].join(" ");
|
||||
const privateKeyFooter = ["-----END OPENSSH", "PRIVATE KEY-----"].join(" ");
|
||||
const privateKey = `${privateKeyHeader}
|
||||
selftest-private-key-material
|
||||
${privateKeyFooter}
|
||||
`;
|
||||
const knownHosts = "github.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIselftestKnownHostsKey\n";
|
||||
const sshConfig = "Host github.com\n HostName ssh.github.com\n User git\n Port 443\n IdentityFile ~/.ssh/id_ed25519\n";
|
||||
|
||||
const selfTest: SelfTestCase = async (context) => {
|
||||
const fakeKubectl = path.join(context.tmp, "fake-tool-kubectl.js");
|
||||
const stateDir = path.join(context.tmp, "tool-secret-state");
|
||||
const createdSecretPath = path.join(context.tmp, "tool-secret-create.json");
|
||||
await writeFile(fakeKubectl, `#!/usr/bin/env bun
|
||||
import { mkdirSync } from "node:fs";
|
||||
const args = Bun.argv.slice(2);
|
||||
const stateDir = ${JSON.stringify(stateDir)};
|
||||
const statePath = (name) => stateDir + "/" + name + ".json";
|
||||
mkdirSync(stateDir, { recursive: true });
|
||||
const readStdin = async () => {
|
||||
const chunks = [];
|
||||
for await (const chunk of Bun.stdin.stream()) chunks.push(Buffer.from(chunk));
|
||||
return Buffer.concat(chunks).toString("utf8");
|
||||
};
|
||||
if (args[0] === "get" && args[1] === "secret") {
|
||||
const name = args[2];
|
||||
const file = Bun.file(statePath(name));
|
||||
if (!(await file.exists())) {
|
||||
console.error('Error from server (NotFound): secrets "' + name + '" not found');
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(await file.text());
|
||||
process.exit(0);
|
||||
}
|
||||
if (args[0] === "replace") {
|
||||
const text = await readStdin();
|
||||
const manifest = JSON.parse(text);
|
||||
const file = Bun.file(statePath(manifest.metadata.name));
|
||||
if (!(await file.exists())) {
|
||||
console.error('Error from server (NotFound): secrets "' + manifest.metadata.name + '" not found');
|
||||
process.exit(1);
|
||||
}
|
||||
const next = { ...manifest, metadata: { ...(manifest.metadata ?? {}), resourceVersion: "rv-replaced" } };
|
||||
await Bun.write(statePath(manifest.metadata.name), JSON.stringify(next));
|
||||
console.log(JSON.stringify(next));
|
||||
process.exit(0);
|
||||
}
|
||||
if (args[0] === "create") {
|
||||
const text = await readStdin();
|
||||
const manifest = JSON.parse(text);
|
||||
await Bun.write(${JSON.stringify(createdSecretPath)}, JSON.stringify({ args, manifest }, null, 2));
|
||||
const next = { ...manifest, metadata: { ...(manifest.metadata ?? {}), resourceVersion: "rv-created" } };
|
||||
await Bun.write(statePath(manifest.metadata.name), JSON.stringify(next));
|
||||
console.log(JSON.stringify(next));
|
||||
process.exit(0);
|
||||
}
|
||||
console.error("unsupported fake kubectl args: " + JSON.stringify(args));
|
||||
process.exit(1);
|
||||
`);
|
||||
await chmod(fakeKubectl, 0o755);
|
||||
|
||||
const server = await startManagerServer({
|
||||
port: 0,
|
||||
host: "127.0.0.1",
|
||||
sourceCommit: "self-test",
|
||||
store: new MemoryAgentRunStore(),
|
||||
toolCredentialOptions: { namespace: "agentrun-v01", kubectlCommand: fakeKubectl },
|
||||
});
|
||||
try {
|
||||
const client = new ManagerClient(server.baseUrl);
|
||||
const missing = await client.get("/api/v1/tool-credentials") as JsonRecord;
|
||||
assert.equal(missing.action, "tool-credential-list");
|
||||
assert.equal(missing.count, 2);
|
||||
assert.equal(JSON.stringify(missing).includes(privateKey), false);
|
||||
const githubMissing = ((missing.items as JsonRecord[]) ?? []).find((item) => item.name === "github-ssh") as JsonRecord | undefined;
|
||||
assert.equal(githubMissing?.configured, false);
|
||||
assert.equal(githubMissing?.failureKind, "secret-unavailable");
|
||||
|
||||
const updated = await client.put("/api/v1/tool-credentials/github-ssh/credential", { privateKey, knownHosts, config: sshConfig }) as JsonRecord;
|
||||
assert.equal(updated.action, "tool-credential-github-ssh-updated");
|
||||
assert.equal(updated.configured, true);
|
||||
assert.equal(updated.resourceVersion, "rv-created");
|
||||
assertNoCredentialLeak(updated);
|
||||
assertNoSecretLeak(updated);
|
||||
const created = JSON.parse(await readFile(createdSecretPath, "utf8")) as JsonRecord;
|
||||
const manifest = created.manifest as JsonRecord;
|
||||
assert.equal(((manifest.metadata as JsonRecord).name), "agentrun-v01-tool-github-ssh");
|
||||
const data = manifest.data as JsonRecord;
|
||||
assert.equal(Buffer.from(String(data.id_ed25519), "base64").toString("utf8"), privateKey);
|
||||
assert.equal(Buffer.from(String(data.known_hosts), "base64").toString("utf8"), knownHosts);
|
||||
assert.equal(Buffer.from(String(data.config), "base64").toString("utf8"), sshConfig);
|
||||
|
||||
const shown = await client.get("/api/v1/tool-credentials/github-ssh") as JsonRecord;
|
||||
assert.equal(shown.configured, true);
|
||||
assert.deepEqual((shown.keyPresence as JsonRecord), { id_ed25519: true, known_hosts: true, config: true });
|
||||
assertNoCredentialLeak(shown);
|
||||
|
||||
const privateKeyFile = path.join(context.tmp, "id_ed25519");
|
||||
const knownHostsFile = path.join(context.tmp, "known_hosts");
|
||||
await writeFile(privateKeyFile, privateKey);
|
||||
await writeFile(knownHostsFile, knownHosts);
|
||||
const dryRun = await runCliJson(context, ["tool-credentials", "set-github-ssh", "--private-key-file", privateKeyFile, "--known-hosts-file", knownHostsFile, "--dry-run"]);
|
||||
assert.equal(dryRun.ok, true);
|
||||
assert.equal(((dryRun.data as JsonRecord).action), "tool-credential-github-ssh-plan");
|
||||
assertNoCredentialLeak(dryRun);
|
||||
return { name: "tool-credentials", tests: ["tool-credential-list-missing", "tool-credential-github-ssh-upsert-redacted", "tool-credential-cli-dry-run"] };
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => server.server.close(() => resolve()));
|
||||
}
|
||||
};
|
||||
|
||||
function assertNoCredentialLeak(value: unknown): void {
|
||||
const text = JSON.stringify(value);
|
||||
assert.equal(text.includes("selftest-private-key-material"), false);
|
||||
assert.equal(text.includes(privateKeyHeader), false);
|
||||
assert.equal(text.includes("AAAAC3NzaC1lZDI1NTE5AAAAIselftestKnownHostsKey"), false);
|
||||
assert.equal(text.includes("IdentityFile ~/.ssh/id_ed25519"), false);
|
||||
}
|
||||
|
||||
async function runCliJson(context: { root: string }, args: string[]): Promise<JsonRecord> {
|
||||
const proc = spawn(process.execPath, [`${context.root}/scripts/agentrun-cli.ts`, ...args], { stdio: ["ignore", "pipe", "pipe"] });
|
||||
const [stdout, stderr, code] = await Promise.all([readStream(proc.stdout), readStream(proc.stderr), new Promise<number | null>((resolve) => proc.on("close", resolve))]);
|
||||
assert.equal(code, 0, stderr || stdout);
|
||||
return JSON.parse(stdout) as JsonRecord;
|
||||
}
|
||||
|
||||
async function readStream(stream: NodeJS.ReadableStream): Promise<string> {
|
||||
const chunks: Buffer[] = [];
|
||||
stream.on("data", (chunk: Buffer | string) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
stream.on("end", resolve);
|
||||
stream.on("error", reject);
|
||||
});
|
||||
return Buffer.concat(chunks).toString("utf8");
|
||||
}
|
||||
|
||||
export default selfTest;
|
||||
@@ -0,0 +1,94 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawn } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { ManagerClient } from "../../mgr/client.js";
|
||||
import { startManagerServer } from "../../mgr/server.js";
|
||||
import { MemoryAgentRunStore } from "../../mgr/store.js";
|
||||
import type { JsonRecord } from "../../common/types.js";
|
||||
import { resolveGitBundleFetchSource } from "../../runner/resource-bundle.js";
|
||||
import { assertNoSecretLeak, type SelfTestCase } from "../harness.js";
|
||||
|
||||
const selfTest: SelfTestCase = async (context) => {
|
||||
const server = await startManagerServer({ port: 0, host: "127.0.0.1", sourceCommit: "self-test", store: new MemoryAgentRunStore(), aipodSpecDir: path.join(context.root, "config", "aipods") });
|
||||
try {
|
||||
const client = new ManagerClient(server.baseUrl);
|
||||
const listed = await client.get("/api/v1/aipod-specs") as JsonRecord;
|
||||
assert.equal(listed.action, "aipod-spec-list");
|
||||
assert.equal((listed.items as JsonRecord[]).some((item) => item.name === "Artificer"), true);
|
||||
|
||||
const shown = await client.get("/api/v1/aipod-specs/Artificer") as JsonRecord;
|
||||
const shownItem = shown.item as JsonRecord;
|
||||
assert.equal(shownItem.backendProfile, "sub2api");
|
||||
assert.equal(((shownItem.model as JsonRecord).model), "gpt-5.5");
|
||||
assert.equal(((shownItem.resourceBundleRef as JsonRecord).gitMirror as JsonRecord).enabled, true);
|
||||
|
||||
const rendered = await client.post("/api/v1/aipod-specs/Artificer/render", { prompt: "处理 pikasTech/unidesk#245", idempotencyKey: "selftest-aipod-artificer" }) as JsonRecord;
|
||||
assert.equal(rendered.action, "aipod-spec-render");
|
||||
const task = rendered.queueTask as JsonRecord;
|
||||
assert.equal(task.backendProfile, "sub2api");
|
||||
assert.equal(task.providerId, "G14");
|
||||
assert.equal(task.idempotencyKey, "selftest-aipod-artificer");
|
||||
assert.equal(((task.payload as JsonRecord).model), "gpt-5.5");
|
||||
assert.equal((((task.payload as JsonRecord).modelConfig as JsonRecord).reasoningEffort), "xhigh");
|
||||
const policy = task.executionPolicy as JsonRecord;
|
||||
const secretScope = policy.secretScope as JsonRecord;
|
||||
const providerCredentials = secretScope.providerCredentials as JsonRecord[];
|
||||
assert.equal(providerCredentials.some((item) => item.profile === "sub2api" && ((item.secretRef as JsonRecord).name) === "agentrun-v01-provider-sub2api"), true);
|
||||
const toolCredentials = secretScope.toolCredentials as JsonRecord[];
|
||||
assert.equal(toolCredentials.some((item) => item.tool === "unidesk-ssh" && ((item.projection as JsonRecord).envName) === "UNIDESK_SSH_CLIENT_TOKEN"), true);
|
||||
assert.equal(toolCredentials.some((item) => item.tool === "github" && ((item.projection as JsonRecord).kind) === "volume" && ((item.projection as JsonRecord).mountPath) === "/home/agentrun/.ssh"), true);
|
||||
const bundle = task.resourceBundleRef as JsonRecord;
|
||||
assert.equal(((bundle.gitMirror as JsonRecord).enabled), true);
|
||||
const bundles = bundle.bundles as JsonRecord[];
|
||||
const toolBundle = bundles.find((item) => item.name === "agentrun-runner-tools") as JsonRecord | undefined;
|
||||
assert.equal(toolBundle?.repoUrl, "git@github.com:pikasTech/agentrun.git");
|
||||
assert.equal(toolBundle?.ref, "v0.1");
|
||||
assert.equal(toolBundle?.subpath, "tools");
|
||||
assert.equal(toolBundle?.targetPath, "tools");
|
||||
assert.equal((bundle.requiredSkills as JsonRecord[]).some((item) => item.name === "dad-dev"), true);
|
||||
assert.equal((bundle.requiredSkills as JsonRecord[]).some((item) => item.name === "unidesk-gh"), true);
|
||||
assertNoSecretLeak(rendered);
|
||||
|
||||
const mirrored = resolveGitBundleFetchSource("git@github.com:pikasTech/unidesk.git", { enabled: true, baseUrl: "http://mirror.example.test/root/" }, {});
|
||||
assert.equal(mirrored.fetchRepoUrl, "http://mirror.example.test/root/pikasTech/unidesk.git");
|
||||
assert.equal(mirrored.mirrorUsed, true);
|
||||
const nonGithub = resolveGitBundleFetchSource("ssh://git@example.test/repo.git", { enabled: true, baseUrl: "http://mirror.example.test" }, {});
|
||||
assert.equal(nonGithub.fetchRepoUrl, "ssh://git@example.test/repo.git");
|
||||
assert.equal(nonGithub.mirrorUsed, false);
|
||||
|
||||
const submitPlan = await runCliJson(context, server.baseUrl, ["queue", "submit", "--aipod", "Artificer", "--prompt", "处理 pikasTech/unidesk#245", "--idempotency-key", "selftest-aipod-cli", "--dry-run"]);
|
||||
assert.equal(submitPlan.ok, true);
|
||||
assert.equal(((submitPlan.data as JsonRecord).action), "queue-submit-plan");
|
||||
assert.equal((((submitPlan.data as JsonRecord).jsonInput as JsonRecord).source), "aipod-spec");
|
||||
const request = ((submitPlan.data as JsonRecord).request as JsonRecord);
|
||||
assert.equal(((request.body as JsonRecord).idempotencyKey), "selftest-aipod-cli");
|
||||
|
||||
const help = await runCliJson(context, server.baseUrl, ["help"]);
|
||||
const commands = ((help.data as JsonRecord).commands as string[]) ?? [];
|
||||
assert.equal(commands.some((item) => item.includes("aipod-specs render <name>")), true);
|
||||
assert.equal(commands.some((item) => item.includes("queue submit --aipod <name>")), true);
|
||||
assertNoSecretLeak(submitPlan);
|
||||
return { name: "aipod-spec", tests: ["aipod-spec-artificer-yaml-render", "aipod-spec-git-mirror-url", "queue-submit-aipod-dry-run", "aipod-cli-help"] };
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => server.server.close(() => resolve()));
|
||||
}
|
||||
};
|
||||
|
||||
export default selfTest;
|
||||
|
||||
async function runCliJson(context: { root: string }, managerUrl: string, args: string[]): Promise<JsonRecord> {
|
||||
const proc = spawn(process.execPath, [`${context.root}/scripts/agentrun-cli.ts`, "--manager-url", managerUrl, ...args], { stdio: ["ignore", "pipe", "pipe"] });
|
||||
const [stdout, stderr, code] = await Promise.all([readStream(proc.stdout), readStream(proc.stderr), new Promise<number | null>((resolve) => proc.on("close", resolve))]);
|
||||
assert.equal(code, 0, stderr || stdout);
|
||||
return JSON.parse(stdout) as JsonRecord;
|
||||
}
|
||||
|
||||
async function readStream(stream: NodeJS.ReadableStream): Promise<string> {
|
||||
const chunks: Buffer[] = [];
|
||||
stream.on("data", (chunk: Buffer | string) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
stream.on("end", resolve);
|
||||
stream.on("error", reject);
|
||||
});
|
||||
return Buffer.concat(chunks).toString("utf8");
|
||||
}
|
||||
@@ -13,6 +13,7 @@ const selfTest: SelfTestCase = async (context) => {
|
||||
const apkPackages = installedApkPackages(containerfile);
|
||||
const tran = await readFile(path.join(context.root, "tools/tran"), "utf8");
|
||||
const trans = await readFile(path.join(context.root, "tools/trans"), "utf8");
|
||||
const applyPatch = await readFile(path.join(context.root, "tools/apply_patch"), "utf8");
|
||||
|
||||
for (const packageName of requiredRunnerPackages) {
|
||||
assert.equal(apkPackages.has(packageName), true, `runner image must install ${packageName}`);
|
||||
@@ -20,16 +21,22 @@ const selfTest: SelfTestCase = async (context) => {
|
||||
|
||||
assert.equal(tran.startsWith("#!/usr/bin/env bun\n"), true, "tools/tran must be a shebang executable discovered by gitbundle tools");
|
||||
assert.equal(trans.startsWith("#!/bin/sh\n"), true, "tools/trans must be a shebang executable discovered by gitbundle tools");
|
||||
assert.equal(applyPatch.startsWith("#!/bin/sh\n"), true, "tools/apply_patch must be a shebang helper copied with runner tools");
|
||||
assert.equal(tran.includes("UNIDESK_SSH_CLIENT_TOKEN"), true, "tools/tran must require the scoped UniDesk SSH client token");
|
||||
assert.equal(tran.includes("/ws/ssh"), true, "tools/tran must use the frontend SSH WebSocket path");
|
||||
assert.equal(tran.includes("apply-patch < patch.diff"), true, "tools/tran help must advertise runner-side apply-patch");
|
||||
|
||||
const help = await execFileAsync(path.join(context.root, "tools/tran"), ["--help"], { cwd: context.root, timeout: 10_000 });
|
||||
const parsed = JSON.parse(help.stdout) as { ok?: boolean; supported?: string[]; valuesPrinted?: boolean };
|
||||
const parsed = JSON.parse(help.stdout) as { ok?: boolean; supported?: string[]; unsupported?: string[]; valuesPrinted?: boolean };
|
||||
assert.equal(parsed.ok, true);
|
||||
assert.equal(parsed.valuesPrinted, false);
|
||||
assert.equal(parsed.supported?.some((line) => line.includes("script")), true);
|
||||
assert.equal(parsed.supported?.some((line) => line.includes("apply-patch")), true);
|
||||
assert.equal(parsed.unsupported?.includes("apply-patch"), false);
|
||||
const patchHelp = await execFileAsync(path.join(context.root, "tools/apply_patch"), ["--help"], { cwd: context.root, timeout: 10_000 });
|
||||
assert.equal(patchHelp.stdout.includes("reads *** Begin Patch format"), true);
|
||||
|
||||
return { name: "90-runner-image-tools", tests: ["runner image installs required CLI tools", "gitbundle tran tools are executable and documented"] };
|
||||
return { name: "90-runner-image-tools", tests: ["runner image installs required CLI tools", "gitbundle tran tools are executable and documented", "runner apply-patch helper is bundled"] };
|
||||
};
|
||||
|
||||
function installedApkPackages(containerfile: string): Set<string> {
|
||||
|
||||
Reference in New Issue
Block a user