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 /home/agentrun/.ssh/id_ed25519\n UserKnownHostsFile /home/agentrun/.ssh/known_hosts\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((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 /home/agentrun/.ssh/id_ed25519"), false); } async function runCliJson(context: { root: string }, args: string[]): Promise { 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((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"); } export default selfTest;