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 { 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 { return toolCredentialStatus(requiredSpec(name), options); } export async function setGithubSshToolCredential(body: unknown, options: ToolCredentialOptions = {}): Promise { 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 { 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 /home/agentrun/.ssh/id_ed25519", " IdentitiesOnly yes", " StrictHostKeyChecking yes", " UserKnownHostsFile /home/agentrun/.ssh/known_hosts", "", ].join("\n"); } async function kubectlGetSecret(name: string, namespace: string, kubectlCommand: string): Promise { 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 { const name = stringPath(manifest, ["metadata", "name"]) ?? ""; 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; }