310 lines
14 KiB
TypeScript
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;
|
|
}
|