Files
pikasTech-agentrun/src/mgr/tool-credentials.ts
T
2026-06-10 21:15:41 +08:00

310 lines
14 KiB
TypeScript

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 /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<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;
}