From 35021e1050b2be7407ad459a0e133095160e7f5a Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 12 Jul 2026 01:53:07 +0200 Subject: [PATCH] fix(agentrun): authenticate managed repository imports --- config/agentrun.yaml | 8 +- config/platform-infra/gitea.yaml | 10 + scripts/native/agentrun/git-askpass.mjs | 21 ++ .../managed-repository-reconciler.mjs | 139 ++++++-- scripts/src/agentrun-lanes.ts | 75 ++++- ...trun-managed-repository-reconciler.test.ts | 313 +++++++++++++++++- .../agentrun-managed-repository-reconciler.ts | 71 ++-- scripts/src/platform-infra-gitea-remote.sh | 2 +- scripts/src/platform-infra-gitea.ts | 44 ++- 9 files changed, 617 insertions(+), 66 deletions(-) create mode 100644 scripts/native/agentrun/git-askpass.mjs diff --git a/config/agentrun.yaml b/config/agentrun.yaml index 6464a2dd..3577501d 100644 --- a/config/agentrun.yaml +++ b/config/agentrun.yaml @@ -246,6 +246,8 @@ controlPlane: deploymentName: agentrun-nc01-v02-managed-repository-reconciler configMapName: agentrun-nc01-v02-managed-repository-reconciler serviceAccountName: agentrun-nc01-v02-managed-repository-reconciler + remoteAuth: + configRef: config/platform-infra/gitea.yaml#sourceAuthority.credentials.github.gitFetchCredential hostNetwork: true dnsPolicy: ClusterFirstWithHostNet imagePullPolicy: IfNotPresent @@ -281,16 +283,16 @@ controlPlane: repositories: - key: agentrun repository: pikasTech/agentrun - remote: ssh://git@ssh.github.com:443/pikasTech/agentrun.git + remote: https://github.com/pikasTech/agentrun.git sourceBranch: v0.2 gitopsBranch: nc01-v0.2-gitops - key: unidesk repository: pikasTech/unidesk - remote: ssh://git@ssh.github.com:443/pikasTech/unidesk.git + remote: https://github.com/pikasTech/unidesk.git sourceBranch: master - key: agent_skills repository: pikasTech/agent_skills - remote: ssh://git@ssh.github.com:443/pikasTech/agent_skills.git + remote: https://github.com/pikasTech/agent_skills.git sourceBranch: master database: mode: external-postgres diff --git a/config/platform-infra/gitea.yaml b/config/platform-infra/gitea.yaml index 25172cb5..9f129ce0 100644 --- a/config/platform-infra/gitea.yaml +++ b/config/platform-infra/gitea.yaml @@ -52,6 +52,16 @@ sourceAuthority: requiredFor: - upstream-mirror - mirror-sync + - managed-repository-fetch + gitFetchCredential: + apiVersion: unidesk.ai/v1 + kind: GitFetchCredential + authMode: github-https-token + host: github.com + secretRef: + namespace: devops-infra + name: gitea-github-sync-secrets + key: github-token githubProxy: enabled: true url: http://10.42.0.1:10808 diff --git a/scripts/native/agentrun/git-askpass.mjs b/scripts/native/agentrun/git-askpass.mjs new file mode 100644 index 00000000..64743a4e --- /dev/null +++ b/scripts/native/agentrun/git-askpass.mjs @@ -0,0 +1,21 @@ +#!/usr/bin/env node +import { readFileSync } from "node:fs"; + +const tokenPath = new URL("./token", import.meta.url); +const prompt = process.argv[2] ?? ""; + +if (/username/iu.test(prompt)) { + process.stdout.write("x-access-token\n"); + process.exit(0); +} + +if (!/password/iu.test(prompt)) process.exit(1); + +let token; +try { + token = readFileSync(tokenPath, "utf8").trim(); +} catch { + process.exit(1); +} +if (token.length === 0 || /[\u0000\r\n]/u.test(token)) process.exit(1); +process.stdout.write(`${token}\n`); diff --git a/scripts/native/agentrun/managed-repository-reconciler.mjs b/scripts/native/agentrun/managed-repository-reconciler.mjs index 81306db1..35b5e947 100644 --- a/scripts/native/agentrun/managed-repository-reconciler.mjs +++ b/scripts/native/agentrun/managed-repository-reconciler.mjs @@ -87,6 +87,20 @@ function readConfig(path) { if (raw.schemaVersion !== 1 || raw.kind !== "AgentRunManagedRepositoryReconciler") throw new Error("unsupported managed repository config envelope"); const cacheMountPath = requireAbsolutePath(raw.cacheMountPath, "cacheMountPath"); const stateDirectory = requireRelativePath(raw.stateDirectory, "stateDirectory"); + requireObject(raw.remoteAuth, "remoteAuth"); + requireObject(raw.remoteAuth.proxy, "remoteAuth.proxy"); + const remoteAuth = { + configRef: requireString(raw.remoteAuth.configRef, "remoteAuth.configRef"), + capabilitySha256: requireHex(raw.remoteAuth.capabilitySha256, "remoteAuth.capabilitySha256"), + authMode: requireLiteral(raw.remoteAuth.authMode, "remoteAuth.authMode", "github-https-token"), + host: requireHost(raw.remoteAuth.host, "remoteAuth.host"), + tokenFile: requireAbsolutePath(raw.remoteAuth.tokenFile, "remoteAuth.tokenFile"), + askPassFile: requireAbsolutePath(raw.remoteAuth.askPassFile, "remoteAuth.askPassFile"), + proxy: { + host: requireHost(raw.remoteAuth.proxy.host, "remoteAuth.proxy.host"), + port: requirePort(raw.remoteAuth.proxy.port, "remoteAuth.proxy.port"), + }, + }; const repositories = requireArray(raw.repositories, "repositories").map((item, index) => { requireObject(item, `repositories[${index}]`); const key = requireSimpleId(item.key, `repositories[${index}].key`); @@ -96,6 +110,7 @@ function readConfig(path) { const desiredFingerprint = requireHex(item.desiredFingerprint, `repositories[${index}].desiredFingerprint`); const expectedFingerprint = sha256([key, repository, remote, sourceBranch].join("\0")); if (desiredFingerprint !== expectedFingerprint) throw new Error(`repositories[${index}].desiredFingerprint does not match repository desired state`); + requireCanonicalRemote(remote, repository, remoteAuth.host, `repositories[${index}].remote`); return { key, repository, remote, sourceBranch, desiredFingerprint }; }); if (repositories.length === 0) throw new Error("repositories must not be empty"); @@ -133,18 +148,13 @@ function readConfig(path) { managedRefs: "source-branch-only", }, repositories, - ssh: raw.ssh && typeof raw.ssh === "object" ? { - identityFile: requireAbsolutePath(raw.ssh.identityFile, "ssh.identityFile"), - knownHostsFile: requireAbsolutePath(raw.ssh.knownHostsFile, "ssh.knownHostsFile"), - proxyConnectScript: requireAbsolutePath(raw.ssh.proxyConnectScript, "ssh.proxyConnectScript"), - proxyHost: requireString(raw.ssh.proxyHost, "ssh.proxyHost"), - proxyPort: requirePositiveInteger(raw.ssh.proxyPort, "ssh.proxyPort"), - } : null, + remoteAuth, }; } async function reconcileOnce(cfg, path) { mkdirSync(dirname(path), { recursive: true }); + const credential = credentialStatus(cfg); const previous = readState(path); const previousByFingerprint = new Map( Array.isArray(previous?.repositories) @@ -165,8 +175,9 @@ async function reconcileOnce(cfg, path) { lane: cfg.lane, observedAt: now, heartbeatAt: now, - ready: repositories.every((item) => item.fresh === true && item.refPresent === true), + ready: credential.ready === true && repositories.every((item) => item.fresh === true && item.refPresent === true), lastCycleOk: repositories.every((item) => item.lastAttemptOk === true), + credential, lifecycle: cfg.lifecycle, repositories, valuesPrinted: false, @@ -219,6 +230,7 @@ async function reconcileWithRetry(cfg, repository, prior) { } async function materializeRepository(cfg, repository) { + assertCredentialReady(cfg); const finalPath = repositoryPath(cfg, repository); mkdirSync(dirname(finalPath), { recursive: true }); const existing = existsSync(finalPath); @@ -277,25 +289,34 @@ async function isBareRepository(path, timeoutMs) { } async function git(cfg, gitArgs) { - const env = { ...process.env, GIT_TERMINAL_PROMPT: "0" }; - if (cfg.ssh) { - mkdirSync(dirname(cfg.ssh.knownHostsFile), { recursive: true }); - const proxyCommand = `node ${shellWord(cfg.ssh.proxyConnectScript)} ${shellWord(cfg.ssh.proxyHost)} ${cfg.ssh.proxyPort} %h %p`; - env.GIT_SSH_COMMAND = [ - "ssh", - "-i", shellWord(cfg.ssh.identityFile), - "-o", "IdentitiesOnly=yes", - "-o", "BatchMode=yes", - "-o", "StrictHostKeyChecking=accept-new", - "-o", `UserKnownHostsFile=${shellWord(cfg.ssh.knownHostsFile)}`, - "-o", "ConnectTimeout=15", - "-o", `ProxyCommand=${shellWord(proxyCommand)}`, - ].join(" "); - } - return run("git", gitArgs, { timeoutMs: cfg.fetchTimeoutMs, env }); + const proxyUrl = `http://${cfg.remoteAuth.proxy.host}:${cfg.remoteAuth.proxy.port}`; + const env = { + PATH: process.env.PATH ?? "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", + LANG: process.env.LANG ?? "C.UTF-8", + LC_ALL: process.env.LC_ALL ?? "C.UTF-8", + GIT_TERMINAL_PROMPT: "0", + GIT_ASKPASS: cfg.remoteAuth.askPassFile, + GIT_ASKPASS_REQUIRE: "force", + GIT_CONFIG_NOSYSTEM: "1", + GIT_CONFIG_GLOBAL: "/dev/null", + GIT_SSL_NO_VERIFY: "0", + HTTPS_PROXY: proxyUrl, + HTTP_PROXY: proxyUrl, + https_proxy: proxyUrl, + http_proxy: proxyUrl, + NO_PROXY: "", + no_proxy: "", + }; + return run("git", [ + "-c", "credential.helper=", + "-c", "http.extraHeader=", + "-c", "http.sslVerify=true", + ...gitArgs, + ], { timeoutMs: cfg.fetchTimeoutMs, env }); } async function currentStatus(cfg, path) { + const credential = credentialStatus(cfg); const state = readState(path); if (!state || state.desiredHash !== cfg.desiredHash || !Array.isArray(state.repositories)) { return { @@ -305,6 +326,7 @@ async function currentStatus(cfg, path) { observedDesiredHash: state?.desiredHash ?? null, reason: state ? "desired-state-mismatch" : "status-missing", repositoryCount: cfg.repositories.length, + credential, valuesPrinted: false, }; } @@ -331,7 +353,7 @@ async function currentStatus(cfg, path) { } const heartbeatAgeMs = isoAgeMs(state.heartbeatAt); const heartbeatFresh = heartbeatAgeMs !== null && heartbeatAgeMs <= cfg.freshness.maxAgeMs; - const ready = heartbeatFresh && repositories.every((item) => item.refPresent === true && item.fresh === true && item.remoteFingerprintAligned === true); + const ready = credential.ready === true && heartbeatFresh && repositories.every((item) => item.refPresent === true && item.fresh === true && item.remoteFingerprintAligned === true); return { ok: ready, ready, @@ -343,6 +365,7 @@ async function currentStatus(cfg, path) { heartbeatAgeMs, heartbeatFresh, lifecycle: cfg.lifecycle, + credential, repositories, valuesPrinted: false, }; @@ -357,6 +380,7 @@ function compactStatus(cfg, state) { lane: cfg.lane, heartbeatAt: state.heartbeatAt, lifecycle: cfg.lifecycle, + credential: state.credential, repositories: state.repositories, valuesPrinted: false, }; @@ -518,10 +542,6 @@ function isoAgeMs(value) { return Number.isFinite(time) ? Math.max(0, Date.now() - time) : null; } -function shellWord(value) { - return `'${String(value).replaceAll("'", `'"'"'`)}'`; -} - function sha256(value) { return createHash("sha256").update(value).digest("hex"); } @@ -550,6 +570,67 @@ function requirePositiveInteger(value, path) { return value; } +function requirePort(value, path) { + const port = requirePositiveInteger(value, path); + if (port > 65535) throw new Error(`${path} must be at most 65535`); + return port; +} + +function requireLiteral(value, path, expected) { + if (value !== expected) throw new Error(`${path} must be ${expected}`); + return expected; +} + +function requireHost(value, path) { + const host = requireString(value, path); + if (host !== host.toLowerCase() || !/^(?:[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?|\d{1,3}(?:\.\d{1,3}){3})$/u.test(host) || host.includes("..")) { + throw new Error(`${path} must be a lowercase hostname or IPv4 address without a port`); + } + return host; +} + +function requireCanonicalRemote(value, repository, host, path) { + let parsed; + try { + parsed = new URL(value); + } catch { + throw new Error(`${path} must be a canonical HTTPS URL`); + } + const expected = `https://${host}/${repository}.git`; + if ( + value !== expected + || parsed.protocol !== "https:" + || parsed.hostname !== host + || parsed.port !== "" + || parsed.username !== "" + || parsed.password !== "" + || parsed.search !== "" + || parsed.hash !== "" + ) throw new Error(`${path} must exactly match ${expected} without userinfo, port, query, or fragment`); +} + +function credentialStatus(cfg) { + try { + assertCredentialReady(cfg); + return { ready: true, errorCode: null, errorFingerprint: null, valuesPrinted: false }; + } catch (error) { + const failure = safeError(error); + return { ready: false, errorCode: failure.code, errorFingerprint: failure.fingerprint, valuesPrinted: false }; + } +} + +function assertCredentialReady(cfg) { + if (!existsSync(cfg.remoteAuth.askPassFile)) throw codedError("credential-helper-missing", "rendered credential helper is missing"); + let token; + try { + token = readFileSync(cfg.remoteAuth.tokenFile, "utf8").trim(); + } catch { + throw codedError("credential-file-missing", "projected credential file is missing or unreadable"); + } + if (token.length === 0) throw codedError("credential-empty", "projected credential file is empty"); + if (/[\u0000\r\n]/u.test(token)) throw codedError("credential-invalid", "projected credential file contains unsupported control characters"); +} + function requireAbsolutePath(value, path) { const text = requireString(value, path); if (!text.startsWith("/") || text.includes("..")) throw new Error(`${path} must be an absolute path without ..`); diff --git a/scripts/src/agentrun-lanes.ts b/scripts/src/agentrun-lanes.ts index f43fc3d7..cfac7c71 100644 --- a/scripts/src/agentrun-lanes.ts +++ b/scripts/src/agentrun-lanes.ts @@ -14,6 +14,7 @@ import { stringField, } from "./platform-infra-ops-library"; import { materializeYamlComposition } from "./yaml-composition"; +import { resolveConfigRef } from "./ops/config-refs"; export const AGENTRUN_CONFIG_PATH = "config/agentrun.yaml"; @@ -30,6 +31,19 @@ export interface AgentRunManagedRepositoryReconcilerSpec { readonly deploymentName: string; readonly configMapName: string; readonly serviceAccountName: string; + readonly remoteAuth: { + readonly configRef: string; + readonly capabilitySha256: string; + readonly apiVersion: "unidesk.ai/v1"; + readonly kind: "GitFetchCredential"; + readonly authMode: "github-https-token"; + readonly host: "github.com"; + readonly secretRef: { + readonly namespace: string; + readonly name: string; + readonly key: string; + }; + }; readonly hostNetwork: boolean; readonly dnsPolicy: "ClusterFirst" | "ClusterFirstWithHostNet" | "Default"; readonly imagePullPolicy: "Always" | "IfNotPresent" | "Never"; @@ -757,6 +771,16 @@ function validateAgentRunLaneSourceAuthority(spec: AgentRunLaneSpec, path: strin if (spec.source.statusMode !== "k3s-git-mirror") throw new Error(`${path}.source.statusMode must be k3s-git-mirror for AgentRun v0.2`); if (spec.source.sourceAuthority === null) throw new Error(`${path}.source.sourceAuthority is required for AgentRun v0.2`); if (spec.source.sourceSnapshot === null) throw new Error(`${path}.source.sourceSnapshot is required for AgentRun v0.2`); + const remoteAuth = spec.gitMirror.repositoryReconciler.remoteAuth; + if (remoteAuth.secretRef.namespace !== spec.gitMirror.namespace) { + throw new Error(`${path}.gitMirror.repositoryReconciler.remoteAuth credential namespace must match gitMirror.namespace`); + } + for (const [index, repository] of spec.gitMirror.repositories.entries()) { + const expected = `https://${remoteAuth.host}/${repository.repository}.git`; + if (repository.remote !== expected) { + throw new Error(`${path}.gitMirror.repositories[${index}].remote must exactly match the declared remoteAuth host and repository path`); + } + } } function parseDeployment(input: Record, path: string): AgentRunLaneSpec["deployment"] { @@ -1148,6 +1172,7 @@ function parseManagedRepositoryReconciler(input: Record, path: deploymentName: kubernetesNameField(input, "deploymentName", path), configMapName: kubernetesNameField(input, "configMapName", path), serviceAccountName: kubernetesNameField(input, "serviceAccountName", path), + remoteAuth: parseManagedRepositoryRemoteAuth(recordField(input, "remoteAuth", path), `${path}.remoteAuth`), hostNetwork, dnsPolicy, imagePullPolicy: enumField(input, "imagePullPolicy", path, ["Always", "IfNotPresent", "Never"]), @@ -1182,6 +1207,40 @@ function parseManagedRepositoryReconciler(input: Record, path: }; } +function parseManagedRepositoryRemoteAuth(input: Record, path: string): AgentRunManagedRepositoryReconcilerSpec["remoteAuth"] { + const configRef = stringField(input, "configRef", path); + const resolved = resolveConfigRef(configRef, `${path}.configRef`); + const capability = parseAgentRunGitFetchCredentialCapability(resolved.value, configRef); + return { + configRef, + capabilitySha256: resolved.sha256, + ...capability, + }; +} + +export function parseAgentRunGitFetchCredentialCapability(value: unknown, path = "gitFetchCredential"): Omit { + const capability = asRecord(value, path); + const secretRef = asRecord(capability.secretRef, `${path}.secretRef`); + const apiVersion = enumField(capability, "apiVersion", path, ["unidesk.ai/v1"] as const); + const kind = enumField(capability, "kind", path, ["GitFetchCredential"] as const); + const authMode = enumField(capability, "authMode", path, ["github-https-token"] as const); + const host = stringField(capability, "host", path); + if (host !== "github.com") throw new Error(`${path}.host must be github.com for github-https-token`); + const key = stringField(secretRef, "key", `${path}.secretRef`); + if (!/^[A-Za-z0-9._-]+$/u.test(key)) throw new Error(`${path}.secretRef.key must be a Kubernetes Secret data key`); + return { + apiVersion, + kind, + authMode, + host, + secretRef: { + namespace: kubernetesNameField(secretRef, "namespace", `${path}.secretRef`), + name: kubernetesNameField(secretRef, "name", `${path}.secretRef`), + key, + }, + }; +} + function validateRepositoryPath(value: string, path: string): void { if (!/^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/u.test(value) || value.includes("..")) { throw new Error(`${path} must be an owner/name repository path without ..`); @@ -1191,13 +1250,21 @@ function validateRepositoryPath(value: string, path: string): void { function validateRepositoryRemote(value: string, repository: string, path: string): void { try { const remote = new URL(value); - const remoteRepository = decodeURIComponent(remote.pathname).replace(/^\/+|\.git$/gu, ""); - if (remote.protocol !== "ssh:" || remote.username.length === 0 || remote.password.length > 0 || remote.search.length > 0 || remote.hash.length > 0) { + const expectedPath = `/${repository}.git`; + if ( + remote.protocol !== "https:" + || remote.port.length > 0 + || remote.username.length > 0 + || remote.password.length > 0 + || remote.search.length > 0 + || remote.hash.length > 0 + || remote.pathname !== expectedPath + || value !== `https://${remote.hostname}${expectedPath}` + ) { throw new Error("unsupported remote"); } - if (remoteRepository !== repository) throw new Error("repository mismatch"); } catch { - throw new Error(`${path} must be an ssh URL owned by the same repository entry (${repository})`); + throw new Error(`${path} must be a canonical HTTPS URL owned by the same repository entry (${repository}) without userinfo, port, query, or fragment`); } } diff --git a/scripts/src/agentrun-managed-repository-reconciler.test.ts b/scripts/src/agentrun-managed-repository-reconciler.test.ts index 51765736..51c406f8 100644 --- a/scripts/src/agentrun-managed-repository-reconciler.test.ts +++ b/scripts/src/agentrun-managed-repository-reconciler.test.ts @@ -1,5 +1,7 @@ import { createHash } from "node:crypto"; import { + chmodSync, + copyFileSync, existsSync, mkdirSync, mkdtempSync, @@ -14,13 +16,17 @@ import { agentRunManagedRepositoryRenderHash, renderAgentRunManagedRepositoryReconcilerFragment, } from "./agentrun-managed-repository-reconciler"; -import { resolveAgentRunLaneTarget } from "./agentrun-lanes"; +import { + parseAgentRunGitFetchCredentialCapability, + resolveAgentRunLaneTarget, +} from "./agentrun-lanes"; import { resolveCicdDeliveryAuthority } from "./cicd-delivery-authority"; import { renderGitMirrorStatusEnvelope } from "./agentrun/git-mirror"; import { yamlLaneGitMirrorStatusScript } from "./agentrun/secrets"; import { renderPlatformInfraGiteaDesiredFragments } from "./platform-infra-gitea-desired-fragments"; const controllerPath = new URL("../native/agentrun/managed-repository-reconciler.mjs", import.meta.url).pathname; +const askPassPath = new URL("../native/agentrun/git-askpass.mjs", import.meta.url).pathname; describe("AgentRun YAML-owned managed repository reconciler", () => { test("renders one narrow controller from the lane repository list", () => { @@ -31,7 +37,12 @@ describe("AgentRun YAML-owned managed repository reconciler", () => { "pikasTech/unidesk", "pikasTech/agent_skills", ]); - expect(desired.repositories.every((item) => item.remote.startsWith("ssh://"))).toBe(true); + expect(desired.repositories.every((item) => item.remote.startsWith("https://github.com/"))).toBe(true); + expect(desired.remoteAuth).toMatchObject({ + authMode: "github-https-token", + host: "github.com", + proxy: { host: "127.0.0.1", port: 10808 }, + }); expect(desired.lifecycle).toEqual({ undeclaredRepositoryPolicy: "retain", managedRefs: "source-branch-only", @@ -49,6 +60,16 @@ describe("AgentRun YAML-owned managed repository reconciler", () => { expect(documents[2].spec.template.spec.hostNetwork).toBe(spec.gitMirror.repositoryReconciler.hostNetwork); expect(documents[2].spec.template.spec.dnsPolicy).toBe(spec.gitMirror.repositoryReconciler.dnsPolicy); expect(documents[2].spec.template.spec.containers[0].readinessProbe.exec.command).toContain("--health"); + const authVolume = documents[2].spec.template.spec.volumes.find((item: any) => item.name === "git-auth"); + expect(authVolume.projected.sources).toHaveLength(2); + expect(authVolume.projected.sources[1].secret).toEqual({ + name: "gitea-github-sync-secrets", + items: [{ key: "github-token", path: "token", mode: 256 }], + }); + expect(JSON.stringify(authVolume)).not.toContain("gitea-username"); + expect(JSON.stringify(authVolume)).not.toContain("gitea-password"); + expect(JSON.stringify(authVolume)).not.toContain("github-webhook-secret"); + expect(documents[2].spec.template.spec.volumes.some((item: any) => item.name === "git-ssh")).toBe(false); expect(renderAgentRunManagedRepositoryReconcilerFragment("JD01")).toBe(""); const controllerSource = readFileSync(controllerPath, "utf8"); @@ -103,8 +124,8 @@ describe("AgentRun YAML-owned managed repository reconciler", () => { try { const source = readFileSync(new URL("../../config/agentrun.yaml", import.meta.url), "utf8"); const invalid = source.replace( - "remote: ssh://git@ssh.github.com:443/pikasTech/agent_skills.git", - "remote: ssh://git@ssh.github.com:443/pikasTech/unidesk.git", + "remote: https://github.com/pikasTech/agent_skills.git", + "remote: https://github.com/pikasTech/unidesk.git", ); const configPath = join(temporary, "agentrun.yaml"); writeFileSync(configPath, invalid); @@ -117,6 +138,48 @@ describe("AgentRun YAML-owned managed repository reconciler", () => { } }); + test("fails closed on non-canonical remotes and malformed credential capabilities", () => { + const source = readFileSync(new URL("../../config/agentrun.yaml", import.meta.url), "utf8"); + const canonical = "https://github.com/pikasTech/agent_skills.git"; + const invalidRemotes = [ + "ssh://git@ssh.github.com:443/pikasTech/agent_skills.git", + "https://user@github.com/pikasTech/agent_skills.git", + "https://github.com:443/pikasTech/agent_skills.git", + "https://github.com/pikasTech/agent_skills.git?token=forbidden", + "https://github.com/pikasTech/agent_skills.git#forbidden", + "https://example.com/pikasTech/agent_skills.git", + "https://github.com/pikasTech/unidesk.git", + ]; + const temporary = mkdtempSync("/tmp/unidesk-agentrun-remote-contract-"); + try { + for (const [index, remote] of invalidRemotes.entries()) { + const configPath = join(temporary, `invalid-${index}.yaml`); + writeFileSync(configPath, source.replace(`remote: ${canonical}`, `remote: ${remote}`)); + expect(() => resolveAgentRunLaneTarget( + { node: "NC01", lane: "nc01-v02" }, + { ...process.env, AGENTRUN_CONTROL_PLANE_CONFIG: configPath }, + )).toThrow(); + } + } finally { + rmSync(temporary, { recursive: true, force: true }); + } + + const validCapability = { + apiVersion: "unidesk.ai/v1", + kind: "GitFetchCredential", + authMode: "github-https-token", + host: "github.com", + secretRef: { namespace: "devops-infra", name: "gitea-github-sync-secrets", key: "github-token" }, + }; + expect(parseAgentRunGitFetchCredentialCapability(validCapability)).toEqual(validCapability); + for (const invalid of [ + { ...validCapability, kind: "Secret" }, + { ...validCapability, authMode: "ssh" }, + { ...validCapability, host: "example.com" }, + { ...validCapability, secretRef: { ...validCapability.secretRef, key: "invalid/key" } }, + ]) expect(() => parseAgentRunGitFetchCredentialCapability(invalid)).toThrow(); + }); + test("renders host networking only from the explicit reconciler fields", () => { const temporary = mkdtempSync("/tmp/unidesk-agentrun-repository-network-"); try { @@ -179,8 +242,15 @@ describe("AgentRun YAML-owned managed repository reconciler", () => { const work = join(temporary, "work"); const remote = join(temporary, "remote.git"); const cache = join(temporary, "cache"); + const auth = join(temporary, "auth"); + const fakeBin = join(temporary, "bin"); + const gitObservationPath = join(temporary, "git-observations.jsonl"); + const tokenPath = join(auth, "token"); + const fixtureAskPassPath = join(auth, "git-askpass.mjs"); mkdirSync(work, { recursive: true }); mkdirSync(cache, { recursive: true }); + mkdirSync(auth, { recursive: true }); + mkdirSync(fakeBin, { recursive: true }); git(["init", work]); git(["-C", work, "config", "user.name", "UniDesk fixture"]); git(["-C", work, "config", "user.email", "fixture@unidesk.invalid"]); @@ -189,12 +259,23 @@ describe("AgentRun YAML-owned managed repository reconciler", () => { git(["-C", work, "commit", "-m", "fixture"]); git(["-C", work, "branch", "-M", "main"]); git(["clone", "--bare", work, remote]); + const fixtureToken = "github_pat_fixture_not_for_logs"; + writeFileSync(tokenPath, `${fixtureToken}\n`); + copyFileSync(askPassPath, fixtureAskPassPath); + chmodSync(fixtureAskPassPath, 0o555); const retainedPath = join(cache, "undeclared", "retained.git"); mkdirSync(retainedPath, { recursive: true }); writeFileSync(join(retainedPath, "marker"), "retain\n"); - const remoteUrl = `file://${remote}`; + const remoteUrl = "https://github.com/acme/example.git"; + writeGitShim({ + path: join(fakeBin, "git"), + realGit: Bun.which("git")!, + observationPath: gitObservationPath, + remoteUrl, + replacementRemoteUrl: `file://${remote}`, + }); const repository = { key: "fixture", repository: "acme/example", @@ -217,6 +298,15 @@ describe("AgentRun YAML-owned managed repository reconciler", () => { freshness: { maxAgeMs: 60000 }, lifecycle: { undeclaredRepositoryPolicy: "retain", managedRefs: "source-branch-only" }, repositories: [repository], + remoteAuth: { + configRef: "config/platform-infra/gitea.yaml#sourceAuthority.credentials.github.gitFetchCredential", + capabilitySha256: "a".repeat(64), + authMode: "github-https-token", + host: "github.com", + tokenFile: tokenPath, + askPassFile: fixtureAskPassPath, + proxy: { host: "127.0.0.1", port: 10808 }, + }, }; const config = { ...desired, desiredHash: sha256(JSON.stringify(desired)) }; const configPath = join(temporary, "config.json"); @@ -225,12 +315,25 @@ describe("AgentRun YAML-owned managed repository reconciler", () => { const once = Bun.spawnSync(["node", controllerPath, "--once", "--config", configPath], { stdout: "pipe", stderr: "pipe", + env: inheritedHostileGitEnvironment(fakeBin, fixtureToken), }); expect(once.exitCode, once.stderr.toString()).toBe(0); + expect(`${once.stdout.toString()}\n${once.stderr.toString()}`).not.toContain(fixtureToken); const onceStatus = JSON.parse(once.stdout.toString()); expect(onceStatus.ready).toBe(true); expect(onceStatus.repositories[0].remoteFingerprint).toBe(`sha256:${sha256(remoteUrl)}`); expect(existsSync(join(retainedPath, "marker"))).toBe(true); + const gitObservations = readFileSync(gitObservationPath, "utf8") + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + expect(gitObservations.some((item) => item.args.includes(remoteUrl))).toBe(true); + expect(gitObservations.every((item) => item.inheritedGitSsh === false)).toBe(true); + expect(gitObservations.every((item) => item.inheritedGitConfig === false)).toBe(true); + expect(gitObservations.every((item) => item.inheritedGithubToken === false)).toBe(true); + expect(gitObservations.every((item) => item.askPassConfigured === true)).toBe(true); + expect(gitObservations.every((item) => item.tlsVerifyForced === true)).toBe(true); + expect(readFileSync(gitObservationPath, "utf8")).not.toContain(fixtureToken); const mirroredCommit = git([ "--git-dir", @@ -240,6 +343,8 @@ describe("AgentRun YAML-owned managed repository reconciler", () => { "refs/heads/main^{commit}", ]).trim(); expect(mirroredCommit).toMatch(/^[0-9a-f]{40}$/u); + expect(readFileSync(join(cache, "acme", "example.git", "config"), "utf8")).not.toContain(fixtureToken); + expect(readFileSync(join(cache, ".managed-state", "status.json"), "utf8")).not.toContain(fixtureToken); git([ "--git-dir", join(cache, "acme", "example.git"), @@ -250,6 +355,7 @@ describe("AgentRun YAML-owned managed repository reconciler", () => { const secondOnce = Bun.spawnSync(["node", controllerPath, "--once", "--config", configPath], { stdout: "pipe", stderr: "pipe", + env: inheritedHostileGitEnvironment(fakeBin, fixtureToken), }); expect(secondOnce.exitCode, secondOnce.stderr.toString()).toBe(0); expect(git([ @@ -270,6 +376,7 @@ describe("AgentRun YAML-owned managed repository reconciler", () => { const health = Bun.spawnSync(["node", controllerPath, "--health", "--config", configPath], { stdout: "pipe", stderr: "pipe", + env: inheritedHostileGitEnvironment(fakeBin, fixtureToken), }); expect(health.exitCode, health.stderr.toString()).toBe(0); const healthStatus = JSON.parse(health.stdout.toString()); @@ -285,6 +392,113 @@ describe("AgentRun YAML-owned managed repository reconciler", () => { } }); + test("keeps readiness false and skips Git when the projected credential is missing or empty", () => { + const temporary = mkdtempSync("/tmp/unidesk-agentrun-managed-repository-credential-"); + try { + const cache = join(temporary, "cache"); + const auth = join(temporary, "auth"); + const fakeBin = join(temporary, "bin"); + const gitMarker = join(temporary, "git-invoked"); + const tokenPath = join(auth, "token"); + const fixtureAskPassPath = join(auth, "git-askpass.mjs"); + mkdirSync(cache, { recursive: true }); + mkdirSync(auth, { recursive: true }); + mkdirSync(fakeBin, { recursive: true }); + copyFileSync(askPassPath, fixtureAskPassPath); + chmodSync(fixtureAskPassPath, 0o555); + writeFileSync(join(fakeBin, "git"), `#!/bin/sh\ntouch ${JSON.stringify(gitMarker)}\nexit 99\n`); + chmodSync(join(fakeBin, "git"), 0o755); + + const configPath = join(temporary, "config.json"); + writeControllerConfig(configPath, { cache, tokenPath, askPassFile: fixtureAskPassPath }); + writeFileSync(tokenPath, "\n"); + const empty = Bun.spawnSync(["node", controllerPath, "--once", "--config", configPath], { + stdout: "pipe", + stderr: "pipe", + env: inheritedHostileGitEnvironment(fakeBin, "inherited-token-must-not-leak"), + }); + expect(empty.exitCode).toBe(1); + const emptyStatus = lastJsonLine(empty.stdout.toString()); + expect(emptyStatus).toMatchObject({ + ready: false, + credential: { ready: false, errorCode: "credential-empty", valuesPrinted: false }, + repositories: [{ lastAttemptOk: false, errorCode: "credential-empty" }], + valuesPrinted: false, + }); + expect(existsSync(gitMarker)).toBe(false); + + rmSync(tokenPath); + const missing = Bun.spawnSync(["node", controllerPath, "--health", "--config", configPath], { + stdout: "pipe", + stderr: "pipe", + env: inheritedHostileGitEnvironment(fakeBin, "inherited-token-must-not-leak"), + }); + expect(missing.exitCode).toBe(1); + expect(JSON.parse(missing.stdout.toString())).toMatchObject({ + ready: false, + credential: { ready: false, errorCode: "credential-file-missing", valuesPrinted: false }, + valuesPrinted: false, + }); + expect(existsSync(gitMarker)).toBe(false); + } finally { + rmSync(temporary, { recursive: true, force: true }); + } + }); + + test("redacts authentication failures from logs, status, and repository config", () => { + const temporary = mkdtempSync("/tmp/unidesk-agentrun-managed-repository-redaction-"); + try { + const work = join(temporary, "work"); + const remote = join(temporary, "remote.git"); + const cache = join(temporary, "cache"); + const auth = join(temporary, "auth"); + const fakeBin = join(temporary, "bin"); + const tokenPath = join(auth, "token"); + const fixtureAskPassPath = join(auth, "git-askpass.mjs"); + const gitObservationPath = join(temporary, "git-observations.jsonl"); + const remoteUrl = "https://github.com/acme/example.git"; + const sensitive = "github_pat_sensitive_authentication_failure"; + mkdirSync(work, { recursive: true }); + mkdirSync(cache, { recursive: true }); + mkdirSync(auth, { recursive: true }); + mkdirSync(fakeBin, { recursive: true }); + git(["init", work]); + git(["clone", "--bare", work, remote]); + writeFileSync(tokenPath, `${sensitive}\n`); + copyFileSync(askPassPath, fixtureAskPassPath); + chmodSync(fixtureAskPassPath, 0o555); + writeGitShim({ + path: join(fakeBin, "git"), + realGit: Bun.which("git")!, + observationPath: gitObservationPath, + remoteUrl, + replacementRemoteUrl: `file://${remote}`, + failFetchMessage: `authentication failed for ${sensitive}`, + }); + const configPath = join(temporary, "config.json"); + writeControllerConfig(configPath, { cache, tokenPath, askPassFile: fixtureAskPassPath }); + const once = Bun.spawnSync(["node", controllerPath, "--once", "--config", configPath], { + stdout: "pipe", + stderr: "pipe", + env: inheritedHostileGitEnvironment(fakeBin, sensitive), + }); + expect(once.exitCode).toBe(1); + const combined = `${once.stdout.toString()}\n${once.stderr.toString()}`; + expect(combined).not.toContain(sensitive); + const status = lastJsonLine(once.stdout.toString()); + expect(status.repositories[0]).toMatchObject({ + lastAttemptOk: false, + errorCode: "authentication-failed", + }); + expect(status.repositories[0].errorFingerprint).toMatch(/^[0-9a-f]{16}$/u); + expect(readFileSync(gitObservationPath, "utf8")).not.toContain(sensitive); + expect(readFileSync(join(cache, ".managed-state", "status.json"), "utf8")).not.toContain(sensitive); + expect(existsSync(join(cache, "acme", "example.git"))).toBe(false); + } finally { + rmSync(temporary, { recursive: true, force: true }); + } + }); + test("keeps the legacy default summary contract and bounds the final CLI envelope", () => { const target = resolveAgentRunLaneTarget({ node: "NC01", lane: "nc01-v02" }); const spec = target.spec; @@ -413,3 +627,92 @@ function git(args: string[]): string { function sha256(value: string): string { return createHash("sha256").update(value).digest("hex"); } + +function writeControllerConfig(path: string, input: { cache: string; tokenPath: string; askPassFile: string }): void { + const remote = "https://github.com/acme/example.git"; + const repository = { + key: "fixture", + repository: "acme/example", + remote, + sourceBranch: "main", + desiredFingerprint: sha256(["fixture", "acme/example", remote, "main"].join("\0")), + }; + const desired = { + schemaVersion: 1, + kind: "AgentRunManagedRepositoryReconciler", + nodeId: "fixture-node", + lane: "fixture-lane", + cacheMountPath: input.cache, + stateDirectory: ".managed-state", + reconcileIntervalMs: 1000, + fetchTimeoutMs: 10000, + shutdownGraceMs: 1000, + maxConcurrentRepositories: 1, + retry: { maxAttempts: 1, initialDelayMs: 10, maxDelayMs: 10 }, + freshness: { maxAgeMs: 60000 }, + lifecycle: { undeclaredRepositoryPolicy: "retain", managedRefs: "source-branch-only" }, + repositories: [repository], + remoteAuth: { + configRef: "config/platform-infra/gitea.yaml#sourceAuthority.credentials.github.gitFetchCredential", + capabilitySha256: "a".repeat(64), + authMode: "github-https-token", + host: "github.com", + tokenFile: input.tokenPath, + askPassFile: input.askPassFile, + proxy: { host: "127.0.0.1", port: 10808 }, + }, + }; + writeFileSync(path, `${JSON.stringify({ ...desired, desiredHash: sha256(JSON.stringify(desired)) }, null, 2)}\n`); +} + +function lastJsonLine(output: string): any { + const lines = output.trim().split("\n"); + return JSON.parse(lines.at(-1) ?? "null"); +} + +function inheritedHostileGitEnvironment(fakeBin: string, fixtureToken: string): NodeJS.ProcessEnv { + return { + ...process.env, + PATH: `${fakeBin}:${process.env.PATH ?? ""}`, + GIT_SSH: "/tmp/inherited-git-ssh", + GIT_SSH_COMMAND: "inherited-ssh-command", + GIT_CONFIG: "/tmp/inherited-git-config", + GIT_CONFIG_COUNT: "1", + GIT_CONFIG_KEY_0: "credential.helper", + GIT_CONFIG_VALUE_0: "!inherited-credential-helper", + GIT_SSL_NO_VERIFY: "1", + GH_TOKEN: fixtureToken, + GITHUB_TOKEN: fixtureToken, + }; +} + +function writeGitShim(input: { + path: string; + realGit: string; + observationPath: string; + remoteUrl: string; + replacementRemoteUrl: string; + failFetchMessage?: string; +}): void { + writeFileSync(input.path, `#!/usr/bin/env node +import { appendFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +const args = process.argv.slice(2); +appendFileSync(${JSON.stringify(input.observationPath)}, JSON.stringify({ + args, + inheritedGitSsh: Object.hasOwn(process.env, "GIT_SSH") || Object.hasOwn(process.env, "GIT_SSH_COMMAND"), + inheritedGitConfig: Object.hasOwn(process.env, "GIT_CONFIG") || Object.hasOwn(process.env, "GIT_CONFIG_COUNT") || Object.keys(process.env).some((key) => /^GIT_CONFIG_(?:KEY|VALUE)_/u.test(key)), + inheritedGithubToken: Object.hasOwn(process.env, "GH_TOKEN") || Object.hasOwn(process.env, "GITHUB_TOKEN"), + askPassConfigured: typeof process.env.GIT_ASKPASS === "string" && process.env.GIT_ASKPASS.length > 0 && process.env.GIT_ASKPASS_REQUIRE === "force", + tlsVerifyForced: process.env.GIT_SSL_NO_VERIFY === "0" && args.includes("http.sslVerify=true"), +}) + "\\n"); +if (args.includes("fetch") && ${JSON.stringify(input.failFetchMessage ?? null)} !== null) { + process.stderr.write(${JSON.stringify(input.failFetchMessage ?? "")} + "\\n"); + process.exit(1); +} +const mapped = args.map((arg) => arg === ${JSON.stringify(input.remoteUrl)} ? ${JSON.stringify(input.replacementRemoteUrl)} : arg); +const result = spawnSync(${JSON.stringify(input.realGit)}, mapped, { env: process.env, stdio: "inherit" }); +process.exit(result.status ?? 1); +`); + chmodSync(input.path, 0o755); +} diff --git a/scripts/src/agentrun-managed-repository-reconciler.ts b/scripts/src/agentrun-managed-repository-reconciler.ts index 08b93751..8cfe38f7 100644 --- a/scripts/src/agentrun-managed-repository-reconciler.ts +++ b/scripts/src/agentrun-managed-repository-reconciler.ts @@ -7,10 +7,9 @@ import { } from "./agentrun-lanes"; const controllerSourcePath = rootPath("scripts", "native", "agentrun", "managed-repository-reconciler.mjs"); -const proxySourcePath = rootPath("scripts", "native", "agentrun", "git-mirror-proxy-connect.cjs"); +const askPassSourcePath = rootPath("scripts", "native", "agentrun", "git-askpass.mjs"); const controllerMountPath = "/etc/agentrun-managed-repository"; -const sshMountPath = "/var/run/secrets/agentrun-managed-repository"; -const knownHostsPath = "/tmp/agentrun-managed-repository/.ssh/known_hosts"; +const authMountPath = "/var/run/secrets/agentrun-managed-repository-auth"; export interface AgentRunManagedRepositoryDesiredConfig { readonly schemaVersion: 1; @@ -42,12 +41,17 @@ export interface AgentRunManagedRepositoryDesiredConfig { readonly sourceBranch: string; readonly desiredFingerprint: string; }[]; - readonly ssh: { - readonly identityFile: string; - readonly knownHostsFile: string; - readonly proxyConnectScript: string; - readonly proxyHost: string; - readonly proxyPort: number; + readonly remoteAuth: { + readonly configRef: string; + readonly capabilitySha256: string; + readonly authMode: "github-https-token"; + readonly host: "github.com"; + readonly tokenFile: string; + readonly askPassFile: string; + readonly proxy: { + readonly host: string; + readonly port: number; + }; }; readonly desiredHash: string; } @@ -75,12 +79,14 @@ export function agentRunManagedRepositoryDesiredConfig(spec: AgentRunLaneSpec): sourceBranch: repository.sourceBranch, desiredFingerprint: sha256([repository.key, repository.repository, repository.remote, repository.sourceBranch].join("\0")), })), - ssh: { - identityFile: `${sshMountPath}/ssh-privatekey`, - knownHostsFile: knownHostsPath, - proxyConnectScript: `${controllerMountPath}/proxy-connect.cjs`, - proxyHost: spec.gitMirror.githubProxy.host, - proxyPort: spec.gitMirror.githubProxy.port, + remoteAuth: { + configRef: reconciler.remoteAuth.configRef, + capabilitySha256: reconciler.remoteAuth.capabilitySha256, + authMode: reconciler.remoteAuth.authMode, + host: reconciler.remoteAuth.host, + tokenFile: `${authMountPath}/token`, + askPassFile: `${authMountPath}/git-askpass.mjs`, + proxy: spec.gitMirror.githubProxy, }, }; return { ...desired, desiredHash: sha256(JSON.stringify(desired)) }; @@ -92,7 +98,7 @@ export function agentRunManagedRepositoryRenderHash(spec: AgentRunLaneSpec): str JSON.stringify(config), JSON.stringify(managedRepositoryManifestIdentity(spec)), readFileSync(controllerSourcePath, "utf8"), - readFileSync(proxySourcePath, "utf8"), + readFileSync(askPassSourcePath, "utf8"), ].join("\0")); } @@ -133,7 +139,7 @@ function managedRepositoryObjects(spec: AgentRunLaneSpec): Record, path: string): Gi } function parseGithubCredential(record: Record, path: string): GiteaGithubCredential { + const gitFetchCredential = y.objectField(record, "gitFetchCredential", path); + const secretRef = y.objectField(gitFetchCredential, "secretRef", `${path}.gitFetchCredential`); return { transport: y.enumField(record, "transport", path, ["https-token"] as const), sourceRef: secretSourceRefField(record, "sourceRef", path), sourceKey: y.envKeyField(record, "sourceKey", path), format: optionalLiteralField(record, "format", path, ["env", "raw-token"] as const) ?? "env", requiredFor: y.stringArrayField(record, "requiredFor", path), + gitFetchCredential: { + apiVersion: y.enumField(gitFetchCredential, "apiVersion", `${path}.gitFetchCredential`, ["unidesk.ai/v1"] as const), + kind: y.enumField(gitFetchCredential, "kind", `${path}.gitFetchCredential`, ["GitFetchCredential"] as const), + authMode: y.enumField(gitFetchCredential, "authMode", `${path}.gitFetchCredential`, ["github-https-token"] as const), + host: y.enumField(gitFetchCredential, "host", `${path}.gitFetchCredential`, ["github.com"] as const), + secretRef: { + namespace: y.kubernetesNameField(secretRef, "namespace", `${path}.gitFetchCredential.secretRef`), + name: y.kubernetesNameField(secretRef, "name", `${path}.gitFetchCredential.secretRef`), + key: y.stringField(secretRef, "key", `${path}.gitFetchCredential.secretRef`), + }, + }, }; } @@ -765,6 +789,19 @@ function parseTargetWebhookSync(record: Record, path: string): function validateConfig(gitea: GiteaConfig): void { resolveTarget(gitea, gitea.defaults.targetId); + const gitFetchCredential = gitea.sourceAuthority.credentials.github.gitFetchCredential; + if (!gitea.sourceAuthority.credentials.github.requiredFor.includes("managed-repository-fetch")) { + throw new Error(`${configLabel}.sourceAuthority.credentials.github.requiredFor must include managed-repository-fetch`); + } + if (!/^[A-Za-z0-9._-]+$/u.test(gitFetchCredential.secretRef.key)) { + throw new Error(`${configLabel}.sourceAuthority.credentials.github.gitFetchCredential.secretRef.key must be a Kubernetes Secret data key`); + } + if (gitFetchCredential.secretRef.name !== gitea.sourceAuthority.webhookSync.bridge.secretName) { + throw new Error(`${configLabel}.sourceAuthority.credentials.github.gitFetchCredential.secretRef.name must match sourceAuthority.webhookSync.bridge.secretName`); + } + if (gitea.targets.some((target) => target.enabled && target.namespace !== gitFetchCredential.secretRef.namespace)) { + throw new Error(`${configLabel}.sourceAuthority.credentials.github.gitFetchCredential.secretRef.namespace must match every enabled Gitea target namespace`); + } if (!gitea.sourceAuthority.enabled) throw new Error(`${configLabel}.sourceAuthority.enabled must be true for GH-1550`); if (gitea.sourceAuthority.repositories.length < 1) throw new Error(`${configLabel}.sourceAuthority.repositories must not be empty`); if (!/^docker\.gitea\.com\/gitea$/u.test(gitea.app.image.repository)) throw new Error(`${configLabel}.app.image.repository must use the official Gitea image registry`); @@ -1413,7 +1450,7 @@ spec: valueFrom: secretKeyRef: name: ${sync.bridge.secretName} - key: github-token + key: ${gitea.sourceAuthority.credentials.github.gitFetchCredential.secretRef.key} - name: GITEA_USERNAME valueFrom: secretKeyRef: @@ -1578,7 +1615,7 @@ spec: valueFrom: secretKeyRef: name: ${sync.bridge.secretName} - key: github-token + key: ${gitea.sourceAuthority.credentials.github.gitFetchCredential.secretRef.key} - name: GITEA_USERNAME valueFrom: secretKeyRef: @@ -1687,6 +1724,7 @@ function remoteScript(action: "apply" | "status" | "validate" | "mirror-bootstra UNIDESK_GITEA_ROOT_URL: gitea.app.server.rootUrl, UNIDESK_GITEA_GITHUB_PROXY_ENABLED: gitea.sourceAuthority.githubProxy.enabled ? "1" : "0", UNIDESK_GITEA_GITHUB_PROXY_URL: gitea.sourceAuthority.githubProxy.url, + UNIDESK_GITEA_GITHUB_SECRET_KEY: gitea.sourceAuthority.credentials.github.gitFetchCredential.secretRef.key, UNIDESK_GITEA_NO_PROXY: gitea.sourceAuthority.githubProxy.noProxy.join(","), UNIDESK_GITEA_FRPC_ENABLED: frpcExposure === null ? "0" : "1", UNIDESK_GITEA_FRPC_DEPLOYMENT_NAME: frpcExposure?.frpc.deploymentName ?? gitea.app.publicExposure.frpc.deploymentName, @@ -1988,7 +2026,7 @@ function webhookSecretSummary(gitea: GiteaConfig, target: GiteaTarget, secrets: publicUrl: githubWebhookPublicUrl(gitea, target), sourceRef: sync.secret.sourceRef, targetSecret: sync.bridge.secretName, - keys: ["github-token", "gitea-username", "gitea-password", "github-webhook-secret"], + keys: [gitea.sourceAuthority.credentials.github.gitFetchCredential.secretRef.key, "gitea-username", "gitea-password", "github-webhook-secret"], fingerprint: fingerprintSecretParts([secrets.webhookSecret]), valuesPrinted: false, };