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 parsedWithoutBunGlobal = await runNodeParserCompat(context); assert.equal(parsedWithoutBunGlobal.name, "Artificer"); assert.equal(parsedWithoutBunGlobal.hasBunGlobal, false); 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"); const shownImageRef = shownItem.imageRef as JsonRecord; assert.equal(shownImageRef.kind, "env-image-dockerfile"); assert.equal(shownImageRef.repoUrl, "git@github.com:pikasTech/agentrun.git"); assert.equal(shownImageRef.commitId, "59272f8edb4e23d805b7b9da0050ec40cfc0233d"); assert.equal(shownImageRef.dockerfilePath, "deploy/container/Containerfile"); assert.equal(((shownItem.resourceBundleRef as JsonRecord).gitMirror as JsonRecord).enabled, false); 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"); const taskImageRef = ((task.metadata as JsonRecord).aipodImageRef as JsonRecord); assert.equal(taskImageRef.kind, "env-image-dockerfile"); assert.equal(taskImageRef.valuesPrinted, false); const runnerDefaults = ((rendered.dispatchDefaults as JsonRecord).runnerJob as JsonRecord); assert.equal(((runnerDefaults.imageRef as JsonRecord).kind), "env-image-dockerfile"); assert.equal(((runnerDefaults.imageRef as JsonRecord).dockerfilePath), "deploy/container/Containerfile"); 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 === "github" && ((item.projection as JsonRecord).kind) === "env" && ((item.projection as JsonRecord).envName) === "GH_TOKEN" && ((item.secretRef as JsonRecord).name) === "agentrun-v01-tool-github-pr"), true); 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), false); 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 disabledMirror = resolveGitBundleFetchSource("git@github.com:pikasTech/unidesk.git", { enabled: false, baseUrl: "http://mirror.example.test/root/" }, {}); assert.equal(disabledMirror.fetchRepoUrl, "git@github.com:pikasTech/unidesk.git"); assert.equal(disabledMirror.mirrorUsed, false); 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 ")), true); assert.equal(commands.some((item) => item.includes("queue submit --aipod ")), true); assertNoSecretLeak(submitPlan); return { name: "aipod-spec", tests: ["aipod-spec-yaml-parser-runtime-compatible", "aipod-spec-artificer-image-ref-render", "aipod-spec-artificer-direct-ssh-render", "aipod-spec-git-mirror-url", "queue-submit-aipod-dry-run", "aipod-cli-help"] }; } finally { await new Promise((resolve) => server.server.close(() => resolve())); } }; export default selfTest; async function runNodeParserCompat(context: { root: string }): Promise { const script = `import { readFileSync } from "node:fs"; import { parseAipodSpecYaml } from "./src/common/aipod-specs.ts"; const spec = parseAipodSpecYaml(readFileSync("config/aipods/artificer.yaml", "utf8"), "selftest-no-bun-yaml"); console.log(JSON.stringify({ name: spec.metadata.name, hasBunGlobal: typeof globalThis.Bun !== "undefined" }));`; const proc = spawn("node", ["--import", "tsx", "--eval", script], { cwd: context.root, stdio: ["ignore", "pipe", "pipe"] }); const [stdout, stderr, code] = await Promise.all([readStream(proc.stdout), readStream(proc.stderr), new Promise((resolve) => proc.on("close", resolve))]); assert.equal(code, 0, stderr || stdout); return JSON.parse(stdout) as JsonRecord; } async function runCliJson(context: { root: string }, managerUrl: string, args: string[]): Promise { 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((resolve) => proc.on("close", resolve))]); assert.equal(code, 0, stderr || stdout); return JSON.parse(stdout) as JsonRecord; } async function readStream(stream: NodeJS.ReadableStream): Promise { const chunks: Buffer[] = []; stream.on("data", (chunk: Buffer | string) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))); await new Promise((resolve, reject) => { stream.on("end", resolve); stream.on("error", reject); }); return Buffer.concat(chunks).toString("utf8"); }