diff --git a/config/agentrun.yaml b/config/agentrun.yaml index 3577501d..a6423d47 100644 --- a/config/agentrun.yaml +++ b/config/agentrun.yaml @@ -306,6 +306,10 @@ controlPlane: name: agentrun-v02-mgr-db key: DATABASE_URL localPostgresExpectedAbsent: true + toolCredentials: + - extends: controlPlane.templates.toolCredentials.githubPr + - extends: controlPlane.templates.toolCredentials.unideskSsh + - extends: controlPlane.templates.toolCredentials.githubSsh secrets: - id: manager-api-key sourceRef: hwlab/nc01-v03-admin.env @@ -478,6 +482,8 @@ controlPlane: profile: dsflash-go - extends: controlPlane.templates.secrets.githubPrRawToken - extends: controlPlane.templates.secrets.unideskSshToken + - extends: controlPlane.templates.secrets.githubSshPrivateKey + - extends: controlPlane.templates.secrets.githubSshKnownHosts extends: controlPlane.templates.agentrunV02Lane variables: NODE: NC01 @@ -533,6 +539,46 @@ controlPlane: argoNamespace: argocd argoApplication: "${argoApplication}" repoURL: http://git-mirror-http.devops-infra.svc.cluster.local:8080/pikasTech/agentrun.git + toolCredentials: + githubPr: + id: github-pr + tool: github + purpose: github-pr + secretRef: + namespace: agentrun-v02 + name: agentrun-v01-tool-github-pr + keys: + - GH_TOKEN + projection: + kind: env + envName: GH_TOKEN + secretKey: GH_TOKEN + unideskSsh: + id: unidesk-ssh + tool: unidesk-ssh + purpose: ssh-passthrough + secretRef: + namespace: agentrun-v02 + name: agentrun-v01-tool-unidesk-ssh + keys: + - UNIDESK_SSH_CLIENT_TOKEN + projection: + kind: env + envName: UNIDESK_SSH_CLIENT_TOKEN + secretKey: UNIDESK_SSH_CLIENT_TOKEN + githubSsh: + id: github-ssh + tool: github + purpose: github-ssh + secretRef: + namespace: agentrun-v02 + name: agentrun-v01-tool-github-ssh + keys: + - id_ed25519 + - known_hosts + projection: + kind: volume + mountPath: /home/agentrun/.ssh secrets: githubPrRawToken: id: tool-github-pr-token @@ -551,3 +597,19 @@ controlPlane: namespace: agentrun-v02 name: agentrun-v01-tool-unidesk-ssh key: UNIDESK_SSH_CLIENT_TOKEN + githubSshPrivateKey: + id: tool-github-ssh-private-key + sourceMode: file + sourceRef: deploy-ssh/github.com/id_ed25519 + targetRef: + namespace: agentrun-v02 + name: agentrun-v01-tool-github-ssh + key: id_ed25519 + githubSshKnownHosts: + id: tool-github-ssh-known-hosts + sourceMode: file + sourceRef: deploy-ssh/github.com/known_hosts + targetRef: + namespace: agentrun-v02 + name: agentrun-v01-tool-github-ssh + key: known_hosts diff --git a/scripts/src/agentrun-lanes.ts b/scripts/src/agentrun-lanes.ts index cfac7c71..6953379d 100644 --- a/scripts/src/agentrun-lanes.ts +++ b/scripts/src/agentrun-lanes.ts @@ -110,6 +110,20 @@ export interface AgentRunLaneSecretSpec { readonly providerCredentialProfile: string | null; } +export interface AgentRunToolCredentialSpec { + readonly id: string; + readonly tool: string; + readonly purpose: string; + readonly secretRef: { + readonly namespace: string; + readonly name: string; + readonly keys: readonly string[]; + }; + readonly projection: + | { readonly kind: "env"; readonly envName: string; readonly secretKey: string } + | { readonly kind: "volume"; readonly mountPath: string }; +} + export interface AgentRunCodexConfigSpec { readonly modelProvider: string; readonly model: string; @@ -273,6 +287,7 @@ export interface AgentRunLaneSpec { readonly localPostgresExpectedAbsent: boolean; }; readonly secrets: readonly AgentRunLaneSecretSpec[]; + readonly toolCredentials: readonly AgentRunToolCredentialSpec[]; } export interface AgentRunContainerResources { @@ -543,6 +558,14 @@ export function agentRunLaneSummary(spec: AgentRunLaneSpec): Record ({ + id: credential.id, + tool: credential.tool, + purpose: credential.purpose, + secretRef: credential.secretRef, + projection: credential.projection, + valuesPrinted: false, + })), providerCredentials: agentRunProviderCredentialRefs(spec).map((credential) => ({ profile: credential.profile, secretRef: credential.secretRef, @@ -660,6 +683,7 @@ function parseLane(lane: string, node: AgentRunNodeSpec, input: Record, path: string): AgentRu return secrets; } +function parseToolCredentials(input: Record, path: string): AgentRunToolCredentialSpec[] { + const credentials = arrayField(input, "toolCredentials", path).map((value, index) => { + const credentialPath = `${path}.toolCredentials[${index}]`; + const secretRef = recordField(value, "secretRef", credentialPath); + const projection = recordField(value, "projection", credentialPath); + const kind = stringField(projection, "kind", `${credentialPath}.projection`); + const keys = stringArrayField(secretRef, "keys", `${credentialPath}.secretRef`); + if (keys.length === 0) throw new Error(`${credentialPath}.secretRef.keys must not be empty`); + if (new Set(keys).size !== keys.length) throw new Error(`${credentialPath}.secretRef.keys must not contain duplicates`); + const normalizedProjection = kind === "env" + ? parseToolCredentialEnvProjection(projection, keys, `${credentialPath}.projection`) + : kind === "volume" + ? parseToolCredentialVolumeProjection(projection, `${credentialPath}.projection`) + : null; + if (normalizedProjection === null) throw new Error(`${credentialPath}.projection.kind must be env or volume`); + return { + id: stringField(value, "id", credentialPath), + tool: credentialSlugField(value, "tool", credentialPath), + purpose: credentialSlugField(value, "purpose", credentialPath), + secretRef: { + namespace: stringField(secretRef, "namespace", `${credentialPath}.secretRef`), + name: stringField(secretRef, "name", `${credentialPath}.secretRef`), + keys, + }, + projection: normalizedProjection, + } satisfies AgentRunToolCredentialSpec; + }); + const ids = new Map(); + const identities = new Map(); + for (const [index, credential] of credentials.entries()) { + const credentialPath = `${path}.toolCredentials[${index}]`; + const priorId = ids.get(credential.id); + if (priorId !== undefined) throw new Error(`${credentialPath}.id duplicates ${priorId}.id (${credential.id})`); + ids.set(credential.id, credentialPath); + const identity = `${credential.tool}/${credential.purpose}`; + const priorIdentity = identities.get(identity); + if (priorIdentity !== undefined) throw new Error(`${credentialPath} duplicates ${priorIdentity} tool/purpose (${identity})`); + identities.set(identity, credentialPath); + } + return credentials; +} + +function parseToolCredentialEnvProjection(input: Record, keys: readonly string[], path: string): AgentRunToolCredentialSpec["projection"] { + const envName = stringField(input, "envName", path); + if (!/^[A-Z_][A-Z0-9_]{0,63}$/u.test(envName)) throw new Error(`${path}.envName must be an uppercase environment variable name`); + const secretKey = stringField(input, "secretKey", path); + if (!keys.includes(secretKey)) throw new Error(`${path}.secretKey must be present in secretRef.keys`); + return { kind: "env", envName, secretKey }; +} + +function parseToolCredentialVolumeProjection(input: Record, path: string): AgentRunToolCredentialSpec["projection"] { + const mountPath = absolutePathField(input, "mountPath", path); + if (!mountPath.startsWith("/home/agentrun/") || mountPath.includes("..")) throw new Error(`${path}.mountPath must stay under /home/agentrun`); + return { kind: "volume", mountPath }; +} + +function credentialSlugField(input: Record, key: string, path: string): string { + const value = stringField(input, key, path); + if (!/^[a-z0-9][a-z0-9._-]{0,63}$/u.test(value)) throw new Error(`${path}.${key} must be a lowercase credential slug`); + return value; +} + +function validateToolCredentialSources(spec: AgentRunLaneSpec, path: string): void { + const declaredTargets = new Set(spec.secrets.map((secret) => `${secret.targetRef.namespace}\0${secret.targetRef.name}\0${secret.targetRef.key}`)); + for (const [index, credential] of spec.toolCredentials.entries()) { + const credentialPath = `${path}.toolCredentials[${index}]`; + if (credential.secretRef.namespace !== spec.runtime.namespace) { + throw new Error(`${credentialPath}.secretRef.namespace must match ${path}.runtime.namespace (${spec.runtime.namespace})`); + } + const missingKeys = credential.secretRef.keys.filter((key) => !declaredTargets.has(`${credential.secretRef.namespace}\0${credential.secretRef.name}\0${key}`)); + if (missingKeys.length > 0) { + throw new Error(`${credentialPath}.secretRef.keys have no matching YAML Secret source: ${missingKeys.join(", ")}`); + } + } +} + function parseCodexConfig(input: Record, path: string): AgentRunCodexConfigSpec { const providersRaw = recordField(input, "modelProviders", path); const modelProviders = Object.entries(providersRaw).map(([id, raw]) => { diff --git a/scripts/src/agentrun.test.ts b/scripts/src/agentrun.test.ts index 5d28dc11..1452c477 100644 --- a/scripts/src/agentrun.test.ts +++ b/scripts/src/agentrun.test.ts @@ -2,7 +2,9 @@ import { readFileSync, rmSync } from "node:fs"; import { describe, expect, test } from "bun:test"; import { parseAgentRunClientConfigYaml, resolveAgentRunAuth, runAgentRunCommand, stripAgentRunResourceWrapperArgs } from "./agentrun"; import { resolveAgentRunLaneTarget } from "./agentrun-lanes"; +import { agentRunToolCredentialRefs } from "./agentrun/config"; import { placeholderAgentRunImage, renderAgentRunGitopsFiles } from "./agentrun-manifests"; +import { collectLaneSecretSources, secretSyncScript } from "./agentrun/secrets"; function renderedTextOf(value: unknown): string { if (typeof value !== "object" || value === null) throw new Error("expected rendered CLI result"); @@ -808,6 +810,155 @@ describe("AgentRun default transport contract", () => { }); }); +describe("AgentRun YAML tool credential binding", () => { + test("aggregates managed GitHub SSH source keys into one volume credential and one sync target", () => { + const { spec } = resolveAgentRunLaneTarget({ node: "NC01", lane: "nc01-v02" }); + const githubSsh = agentRunToolCredentialRefs(spec).filter((credential) => credential.tool === "github" && credential.purpose === "github-ssh"); + expect(githubSsh).toEqual([{ + sourceId: "github-ssh", + tool: "github", + purpose: "github-ssh", + secretRef: { + namespace: "agentrun-v02", + name: "agentrun-v01-tool-github-ssh", + keys: ["id_ed25519", "known_hosts"], + }, + projection: { kind: "volume", mountPath: "/home/agentrun/.ssh" }, + valuesPrinted: false, + }]); + + const sources = collectLaneSecretSources(spec).filter((source) => source.targetRef.name === "agentrun-v01-tool-github-ssh"); + expect(sources.map((source) => ({ id: source.id, sourceMode: source.sourceMode, sourceRef: source.sourceRef, targetRef: source.targetRef }))).toEqual([ + { + id: "tool-github-ssh-private-key", + sourceMode: "file", + sourceRef: "deploy-ssh/github.com/id_ed25519", + targetRef: { namespace: "agentrun-v02", name: "agentrun-v01-tool-github-ssh", key: "id_ed25519" }, + }, + { + id: "tool-github-ssh-known-hosts", + sourceMode: "file", + sourceRef: "deploy-ssh/github.com/known_hosts", + targetRef: { namespace: "agentrun-v02", name: "agentrun-v01-tool-github-ssh", key: "known_hosts" }, + }, + ]); + const syncScript = secretSyncScript(spec, sources.map((source, index) => ({ targetRef: source.targetRef, value: `fixture-${index}` }))); + const payloadMatch = syncScript.match(/^payload_b64='([^']+)'$/mu); + expect(payloadMatch).not.toBeNull(); + const syncManifest = JSON.parse(Buffer.from(payloadMatch?.[1] ?? "", "base64").toString("utf8")) as Array<{ targetRef: { namespace: string; name: string; key: string } }>; + expect(syncManifest.map((item) => item.targetRef)).toEqual(sources.map((source) => source.targetRef)); + expect(new Set(syncManifest.map((item) => `${item.targetRef.namespace}/${item.targetRef.name}`))).toEqual(new Set(["agentrun-v02/agentrun-v01-tool-github-ssh"])); + expect(syncScript).toContain("groups.setdefault(key"); + expect(JSON.stringify({ githubSsh, sources })).not.toContain("/root/.ssh"); + }); + + test("preserves multi-key credentials across Aipod render and queue admission and rejects conflicts", async () => { + const renderBodies: Record[] = []; + const submittedBodies: Record[] = []; + let conflictingKeys = false; + const renderedTask = () => ({ + tenantId: "unidesk", + projectId: "pikasTech/unidesk", + queue: "commander", + lane: "v0.1", + backendProfile: "codex", + providerId: "NC01", + workspaceRef: { kind: "opaque", path: "." }, + executionPolicy: { + sandbox: "workspace-write", + approval: "never", + timeoutMs: 1_800_000, + network: "enabled", + secretScope: { + allowCredentialEcho: false, + providerCredentials: [{ + profile: "codex", + secretRef: { name: "agentrun-v01-provider-codex", keys: ["auth.json", "config.toml"] }, + }], + toolCredentials: [ + { + tool: "github", + purpose: "github-pr", + secretRef: { name: "agentrun-v01-tool-github-pr", keys: ["GH_TOKEN"] }, + projection: { kind: "env", envName: "GH_TOKEN", secretKey: "GH_TOKEN" }, + }, + { + tool: "unidesk-ssh", + purpose: "ssh-passthrough", + secretRef: { name: "agentrun-v01-tool-unidesk-ssh", keys: ["UNIDESK_SSH_CLIENT_TOKEN"] }, + projection: { kind: "env", envName: "UNIDESK_SSH_CLIENT_TOKEN", secretKey: "UNIDESK_SSH_CLIENT_TOKEN" }, + }, + { + tool: "github", + purpose: "github-ssh", + secretRef: { + name: "agentrun-v01-tool-github-ssh", + keys: conflictingKeys ? ["id_ed25519", "known_hosts", "config"] : ["id_ed25519", "known_hosts"], + }, + projection: { kind: "volume", mountPath: "/home/agentrun/.ssh" }, + }, + ], + }, + }, + payload: { prompt: "fixture" }, + }); + const server = Bun.serve({ + port: 0, + async fetch(request) { + const url = new URL(request.url); + expect(request.headers.get("authorization")).toBe("Bearer secret-value"); + if (url.pathname === "/api/v1/aipod-specs/Artificer/render") { + renderBodies.push(await request.json() as Record); + return Response.json({ ok: true, data: { queueTask: renderedTask() } }); + } + if (url.pathname === "/api/v1/queue/tasks") { + submittedBodies.push(await request.json() as Record); + return Response.json({ ok: true, data: { id: "qt_fixture", state: "queued" } }); + } + return new Response("not found", { status: 404 }); + }, + }); + const previousConfig = process.env.AGENTRUN_CLIENT_CONFIG; + const previousKey = process.env.HWLAB_API_KEY; + process.env.HWLAB_API_KEY = "secret-value"; + const tempConfigPath = `/tmp/unidesk-agentrun-tool-credential-test-${process.pid}-${Date.now()}.yaml`; + try { + process.env.AGENTRUN_CLIENT_CONFIG = tempConfigPath; + await Bun.write(tempConfigPath, agentRunMinimalClientYaml.replace("http://agentrun.example.local:8080", server.url.href.replace(/\/$/u, ""))); + + const accepted = await runAgentRunCommand(null, ["create", "task", "--aipod", "Artificer", "--prompt", "fixture", "-o", "json"]); + expect(accepted.ok).toBe(true); + expect(renderBodies).toHaveLength(1); + expect(submittedBodies).toHaveLength(1); + for (const body of [renderBodies[0], submittedBodies[0]]) { + const githubSsh = body?.executionPolicy?.secretScope?.toolCredentials?.filter((credential: Record) => credential.tool === "github" && credential.purpose === "github-ssh"); + expect(githubSsh).toEqual([{ + tool: "github", + purpose: "github-ssh", + secretRef: { name: "agentrun-v01-tool-github-ssh", keys: ["id_ed25519", "known_hosts"] }, + projection: { kind: "volume", mountPath: "/home/agentrun/.ssh" }, + }]); + } + + conflictingKeys = true; + const rejected = await runAgentRunCommand(null, ["create", "task", "--aipod", "Artificer", "--prompt", "fixture", "-o", "json"]); + expect(rejected.ok).toBe(false); + expect(submittedBodies).toHaveLength(1); + const rejectedText = renderedTextOf(rejected); + expect(rejectedText).toContain("validation-failed"); + expect(rejectedText).toContain("github/github-ssh keys conflict"); + expect(Buffer.byteLength(rejectedText, "utf8")).toBeLessThan(2400); + } finally { + server.stop(true); + if (previousConfig === undefined) delete process.env.AGENTRUN_CLIENT_CONFIG; + else process.env.AGENTRUN_CLIENT_CONFIG = previousConfig; + if (previousKey === undefined) delete process.env.HWLAB_API_KEY; + else process.env.HWLAB_API_KEY = previousKey; + rmSync(tempConfigPath, { force: true }); + } + }); +}); + describe("AgentRun runner API key SecretRef", () => { test("renders manager runtime authority from the YAML runner SecretRef", () => { const { spec } = resolveAgentRunLaneTarget({ node: "NC01", lane: "nc01-v02" }); diff --git a/scripts/src/agentrun/config.ts b/scripts/src/agentrun/config.ts index bf9c94e0..c92603da 100644 --- a/scripts/src/agentrun/config.ts +++ b/scripts/src/agentrun/config.ts @@ -579,7 +579,9 @@ export function agentRunExecutionPolicyWithLaneCredentials(basePolicy: Record { + const toolCredentialRefs = includeToolCredentials ? agentRunToolCredentialRefs(spec) : []; + if (includeToolCredentials) validateBasePolicyToolCredentials(basePolicy, toolCredentialRefs, target); + const toolCredentials = toolCredentialRefs.map((credential) => { if (credential.secretRef.namespace !== spec.runtime.namespace) { throw new AgentRunRestError("validation-failed", `toolCredential ${credential.tool} Secret ${credential.secretRef.name} is in namespace ${credential.secretRef.namespace}, expected target lane namespace ${spec.runtime.namespace}`); } @@ -588,11 +590,11 @@ export function agentRunExecutionPolicyWithLaneCredentials(basePolicy: Record; sourceId: string; secretRef: { namespace: string; name: string; key: string }; valuesPrinted: false }> { - return spec.secrets - .filter((secret) => secret.providerCredentialProfile === null && secret.id.startsWith("tool-")) - .map((secret) => { - const binding = agentRunToolCredentialBinding(secret.id, secret.targetRef.key); - return { - tool: binding.tool, - purpose: binding.purpose, - projection: binding.projection, - sourceId: secret.id, - secretRef: secret.targetRef, - valuesPrinted: false as const, - }; - }) - .filter((credential) => options.includeUnsupported === true || AGENTRUN_DEFAULT_TOOL_CREDENTIAL_IDS.has(credential.sourceId)); +export interface AgentRunToolCredentialRef { + readonly tool: string; + readonly purpose: string; + readonly projection: Record; + readonly sourceId: string; + readonly secretRef: { readonly namespace: string; readonly name: string; readonly keys: readonly string[] }; + readonly valuesPrinted: false; } -export function agentRunToolCredentialBinding(sourceId: string, key: string): { tool: string; purpose: string; projection: Record } { - if (sourceId === "tool-github-pr-token") { - return { - tool: "github", - purpose: "github-pr", - projection: { kind: "env", envName: key, secretKey: key }, - }; +export function agentRunToolCredentialRefs(spec: AgentRunLaneSpec): AgentRunToolCredentialRef[] { + return spec.toolCredentials.map((credential) => ({ + tool: credential.tool, + purpose: credential.purpose, + projection: credential.projection, + sourceId: credential.id, + secretRef: credential.secretRef, + valuesPrinted: false, + })); +} + +function validateBasePolicyToolCredentials(basePolicy: Record, laneCredentials: readonly AgentRunToolCredentialRef[], target: AgentRunSessionPolicyTarget): void { + const rawCredentials = record(basePolicy.secretScope).toolCredentials; + if (rawCredentials === undefined || rawCredentials === null) return; + if (!Array.isArray(rawCredentials)) { + throw new AgentRunRestError("validation-failed", "executionPolicy.secretScope.toolCredentials must be an array when lane tool credentials are enabled"); } - if (sourceId === "tool-unidesk-ssh-token") { - return { - tool: "unidesk-ssh", - purpose: "ssh-passthrough", - projection: { kind: "env", envName: key, secretKey: key }, - }; + const laneByIdentity = new Map(laneCredentials.map((credential) => [`${credential.tool}\0${credential.purpose}`, credential])); + const seen = new Set(); + for (const [index, rawCredential] of rawCredentials.entries()) { + const credential = record(rawCredential); + const tool = stringOrNull(credential.tool); + const purpose = stringOrNull(credential.purpose); + if (tool === null || purpose === null) { + throw new AgentRunRestError("validation-failed", `executionPolicy.secretScope.toolCredentials[${index}] must declare tool and purpose`); + } + const identity = `${tool}\0${purpose}`; + if (seen.has(identity)) { + throw new AgentRunRestError("validation-failed", `executionPolicy.secretScope.toolCredentials contains duplicate ${tool}/${purpose} identity`); + } + seen.add(identity); + const laneCredential = laneByIdentity.get(identity); + if (laneCredential === undefined) { + throw new AgentRunRestError("validation-failed", `executionPolicy toolCredential ${tool}/${purpose} is not declared by YAML lane ${target.spec.nodeId}/${target.spec.lane}`); + } + const secretRef = record(credential.secretRef); + const secretName = stringOrNull(secretRef.name); + const namespace = stringOrNull(secretRef.namespace) ?? target.spec.runtime.namespace; + if (secretName !== laneCredential.secretRef.name || namespace !== laneCredential.secretRef.namespace) { + throw new AgentRunRestError("validation-failed", `executionPolicy toolCredential ${tool}/${purpose} SecretRef conflicts with YAML lane ${target.spec.nodeId}/${target.spec.lane}`); + } + const keys = normalizedCredentialKeys(secretRef.keys); + const laneKeys = normalizedCredentialKeys(laneCredential.secretRef.keys); + if (keys === null || laneKeys === null || keys.length !== laneKeys.length || keys.some((key, keyIndex) => key !== laneKeys[keyIndex])) { + throw new AgentRunRestError("validation-failed", `executionPolicy toolCredential ${tool}/${purpose} keys conflict with YAML lane ${target.spec.nodeId}/${target.spec.lane}`); + } + if (!toolCredentialProjectionMatches(record(credential.projection), laneCredential.projection)) { + throw new AgentRunRestError("validation-failed", `executionPolicy toolCredential ${tool}/${purpose} projection conflicts with YAML lane ${target.spec.nodeId}/${target.spec.lane}`); + } } - return { - tool: sourceId.replace(/^tool-/u, "").replace(/-token$/u, ""), - purpose: sourceId.replace(/^tool-/u, ""), - projection: {}, - }; +} + +function normalizedCredentialKeys(value: unknown): string[] | null { + if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || item.trim().length === 0)) return null; + const keys = value.map((item) => String(item)).sort(); + return new Set(keys).size === keys.length ? keys : null; +} + +function toolCredentialProjectionMatches(actual: Record, expected: Record): boolean { + if (actual.kind !== expected.kind) return false; + if (expected.kind === "env") { + return actual.envName === expected.envName && actual.secretKey === expected.secretKey; + } + if (expected.kind === "volume") return actual.mountPath === expected.mountPath; + return false; } export function renderAgentRunSessionPolicyExplanation(args: string[] = [], options: AgentRunRestTargetOptions = { node: null, lane: null }): string { @@ -660,7 +697,7 @@ export function renderAgentRunSessionPolicyExplanation(args: string[] = [], opti secretRef: credential.secretRef, valuesPrinted: false, })); - const toolCredentialSources = agentRunToolCredentialRefs(spec, { includeUnsupported: true }); + const toolCredentialSources = agentRunToolCredentialRefs(spec); return [ "KIND: session-policy", `CONFIG: ${config.sourcePath}`,