From 56beca2a578656c3cc669dd940cf4b766656bb6e Mon Sep 17 00:00:00 2001 From: AgentRun Codex Date: Wed, 22 Jul 2026 01:29:58 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20=E7=B2=BE=E7=A1=AE=E6=B6=88=E8=B4=B9?= =?UTF-8?q?=E8=B5=84=E6=BA=90=E4=BB=93=E5=BA=93=E6=98=A0=E5=B0=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/runner/k8s-job.ts | 3 +- src/runner/resource-bundle.ts | 154 ++++++++++++++---- src/selftest/cases/20-runner-k8s-job.ts | 25 ++- src/selftest/cases/76-aipod-spec.ts | 13 +- src/selftest/cases/80-github-ssh-transport.ts | 8 +- .../82-resource-bundle-repository-map.ts | 74 +++++++++ 6 files changed, 233 insertions(+), 44 deletions(-) create mode 100644 src/selftest/cases/82-resource-bundle-repository-map.ts diff --git a/src/runner/k8s-job.ts b/src/runner/k8s-job.ts index aa1015e..88b50b5 100644 --- a/src/runner/k8s-job.ts +++ b/src/runner/k8s-job.ts @@ -276,6 +276,7 @@ function runnerEnv(options: RunnerJobRenderOptions, context: { namespace: string const selectedSecret = context.secretRefs.find((item) => item.profile === options.run.backendProfile); const codexHome = runnerCodexHome(options.run.backendProfile, selectedSecret, context.sessionPvc); const bootRepoUrl = optionalString(options.bootRepoUrl) ?? defaultBootRepoUrl; + const resourceBundleRepositoriesJson = optionalString(process.env.AGENTRUN_RESOURCE_BUNDLE_REPOSITORIES_JSON); const env = dedupeEnvVars([ { name: "AGENTRUN_MGR_URL", value: options.managerUrl }, { name: "AGENTRUN_API_KEY", valueFrom: { secretKeyRef: context.runnerApiKeySecretRef } }, @@ -297,7 +298,7 @@ function runnerEnv(options: RunnerJobRenderOptions, context: { namespace: string { name: "AGENTRUN_BOOT_MODE", value: "runner" }, { name: "AGENTRUN_APP_ROOT", value: "/home/agentrun/agentrun-source" }, { name: "AGENTRUN_RUNTIME_NAMESPACE", value: context.namespace }, - ...(optionalString(process.env.AGENTRUN_GIT_MIRROR_BASE_URL) ? [{ name: "AGENTRUN_GIT_MIRROR_BASE_URL", value: optionalString(process.env.AGENTRUN_GIT_MIRROR_BASE_URL) }] : []), + ...(resourceBundleRepositoriesJson ? [{ name: "AGENTRUN_RESOURCE_BUNDLE_REPOSITORIES_JSON", value: resourceBundleRepositoriesJson }] : []), { name: "AGENTRUN_K8S_JOB_NAME", value: context.jobName }, { name: "AGENTRUN_LOG_PATH", value: "/tmp/agentrun-runner.jsonl" }, { name: "AGENTRUN_WORK_READY_VERSION", value: String(staticWorkReadyCapabilitySummary().version) }, diff --git a/src/runner/resource-bundle.ts b/src/runner/resource-bundle.ts index b7e70aa..e5f8161 100644 --- a/src/runner/resource-bundle.ts +++ b/src/runner/resource-bundle.ts @@ -58,15 +58,17 @@ interface GitCheckout { branch: string | null; } -interface GitMirrorConfig { - baseUrl: string; +interface ResourceBundleRepositoryMapping { + repository: string; + normalizedRepository: string; + readUrl: string; } interface GitBundleSource { repoUrl: string; commitId?: string; ref?: string; - gitMirror?: GitMirrorConfig; + repositoryMappings?: readonly ResourceBundleRepositoryMapping[]; credentialRefPresent: boolean; credentialEnv?: NodeJS.ProcessEnv; } @@ -145,7 +147,6 @@ export async function materializeResourceBundle(resourceBundleRef: ResourceBundl const assemblyContractHash = primaryAssemblyContractHash(resourceBundleRef, workspaceRef); const bundleContractHash = stableHash(resourceBundleRef.bundles); const initialState = await inspectPrimaryWorkspace(workspacePath, declaredSource, assemblyContractHash, bundleContractHash); - const gitMirror = gitMirrorConfig(resourceBundleRef, env); let provenance: PrimaryWorkspaceProvenance; let materializedBundles: MaterializedGitBundle[]; let checkoutPath: string | null = null; @@ -159,7 +160,8 @@ export async function materializeResourceBundle(resourceBundleRef: ResourceBundl await rm(checkoutRoot, { recursive: true, force: true }); await mkdir(checkoutRoot, { recursive: true }); const credentialEnv = await resourceCredentialEnv(resourceBundleRef, env); - const defaultSource = defaultGitBundleSource(resourceBundleRef, env, gitMirror, credentialEnv); + const repositoryMappings = resourceBundleRepositoryMappings(resourceBundleRef, env); + const defaultSource = defaultGitBundleSource(resourceBundleRef, repositoryMappings, credentialEnv); const checkoutCache = new Map>(); const checkoutFor = (source: GitBundleSource, sourceContext: GitBundleSourceContext) => { const key = stableHash(gitSourceIdentity(source)); @@ -214,7 +216,7 @@ export async function materializeResourceBundle(resourceBundleRef: ResourceBundl fetchRepoUrl: resolvedSource.fetchRepoUrl, mirrorUsed: resolvedSource.mirrorUsed, mirrorBaseUrl: resolvedSource.mirrorBaseUrl, - gitMirror: resolvedSource.mirrorUsed ? { enabled: true, baseUrl: resolvedSource.mirrorBaseUrl, valuesPrinted: false } : { enabled: false, baseUrl: null, valuesPrinted: false }, + repositoryAuthority: resolvedSource.mirrorUsed ? { repository: githubRepositoryIdentity(resourceBundleRef.repoUrl), mode: "exact-read-url", valuesPrinted: false } : null, commitId: resolvedSource.commitId, requestedCommitId: resourceBundleRef.commitId ?? null, requestedRef: resourceBundleRef.ref ?? null, @@ -254,33 +256,22 @@ function isSameOrChildPath(candidate: string, parent: string): boolean { return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); } -function gitMirrorConfig(resourceBundleRef: ResourceBundleRef, env: NodeJS.ProcessEnv): GitMirrorConfig | undefined { - void resourceBundleRef; - return defaultGitMirrorConfig(env); -} - -function defaultGitMirrorConfig(env: NodeJS.ProcessEnv): GitMirrorConfig { - const baseUrl = optionalNonEmpty(env.AGENTRUN_GIT_MIRROR_BASE_URL) ?? "http://git-mirror-http.devops-infra.svc.cluster.local"; - return { baseUrl: normalizeMirrorBaseUrl(baseUrl) }; -} - -function defaultGitBundleSource(resourceBundleRef: ResourceBundleRef, env: NodeJS.ProcessEnv, gitMirror?: GitMirrorConfig, credentialEnv?: NodeJS.ProcessEnv): GitBundleSource { - void env; +function defaultGitBundleSource(resourceBundleRef: ResourceBundleRef, repositoryMappings?: readonly ResourceBundleRepositoryMapping[], credentialEnv?: NodeJS.ProcessEnv): GitBundleSource { const ref = optionalNonEmpty(resourceBundleRef.ref); const commitId = optionalNonEmpty(resourceBundleRef.commitId); const credentialRefPresent = resourceBundleRef.credentialRef !== undefined; if (!ref && !commitId) throw new AgentRunError("schema-invalid", "gitbundle primary source must declare ref or commitId", { httpStatus: 400 }); - return { repoUrl: resourceBundleRef.repoUrl, ...(ref ? { ref } : {}), ...(commitId ? { commitId } : {}), credentialRefPresent, ...(gitMirror ? { gitMirror } : {}), ...(credentialEnv ? { credentialEnv } : {}) }; + return { repoUrl: resourceBundleRef.repoUrl, ...(ref ? { ref } : {}), ...(commitId ? { commitId } : {}), credentialRefPresent, ...(repositoryMappings ? { repositoryMappings } : {}), ...(credentialEnv ? { credentialEnv } : {}) }; } function bundleGitSource(bundle: ResourceBundleRef["bundles"][number], resourceBundleRef: ResourceBundleRef, defaultSource: GitBundleSource): GitBundleSource { const repoUrl = bundle.repoUrl ?? resourceBundleRef.repoUrl; const ref = optionalNonEmpty(bundle.ref); - const mirror = defaultSource.gitMirror; + const repositoryMappings = defaultSource.repositoryMappings; const credentialRefPresent = defaultSource.credentialRefPresent; const credentialEnv = defaultSource.credentialEnv; const commitId = optionalNonEmpty(bundle.commitId); - if (ref || commitId) return { repoUrl, ...(ref ? { ref } : {}), ...(commitId ? { commitId } : {}), credentialRefPresent, ...(mirror ? { gitMirror: mirror } : {}), ...(credentialEnv ? { credentialEnv } : {}) }; + if (ref || commitId) return { repoUrl, ...(ref ? { ref } : {}), ...(commitId ? { commitId } : {}), credentialRefPresent, ...(repositoryMappings ? { repositoryMappings } : {}), ...(credentialEnv ? { credentialEnv } : {}) }; if (repoUrl === defaultSource.repoUrl) return defaultSource; throw new AgentRunError("schema-invalid", "cross-repository bundle must declare its own ref or commitId", { httpStatus: 400 }); } @@ -327,7 +318,12 @@ async function checkoutGitSource(checkoutRoot: string, source: GitBundleSource, } function gitSourceIdentity(source: GitBundleSource): JsonRecord { - return { repoUrl: source.repoUrl, commitId: source.commitId ?? null, ref: source.ref ?? null, gitMirror: source.gitMirror ? { baseUrl: source.gitMirror.baseUrl } : null }; + const repositoryAuthority = source.repositoryMappings?.map((mapping) => ({ + repository: mapping.repository, + normalizedRepository: mapping.normalizedRepository, + readUrl: mapping.readUrl, + })) ?? null; + return { repoUrl: source.repoUrl, commitId: source.commitId ?? null, ref: source.ref ?? null, repositoryAuthorityHash: repositoryAuthority ? stableHash(repositoryAuthority) : null }; } async function resourceCredentialEnv(resourceBundleRef: ResourceBundleRef, env: NodeJS.ProcessEnv): Promise { @@ -744,19 +740,113 @@ async function verifyPrimaryWorkspace(workspacePath: string, workspaceRef: Works }; } -export function resolveGitBundleFetchSource(repoUrl: string, gitMirror?: GitMirrorConfig, env: NodeJS.ProcessEnv = process.env): { fetchRepoUrl: string; mirrorUsed: boolean; mirrorBaseUrl?: string } { - const mirror = gitMirror ? { baseUrl: normalizeMirrorBaseUrl(gitMirror.baseUrl) } : defaultGitMirrorConfig(env); - const githubPath = githubRepoPath(repoUrl); - if (!githubPath) return { fetchRepoUrl: repoUrl, mirrorUsed: false }; - return { fetchRepoUrl: `${mirror.baseUrl}/${githubPath}.git`, mirrorUsed: true, mirrorBaseUrl: mirror.baseUrl }; -} - -function normalizeMirrorBaseUrl(baseUrl: string): string { - return baseUrl.replace(/\/+$/u, ""); +export function resolveGitBundleFetchSource(repoUrl: string, env: NodeJS.ProcessEnv = process.env): { fetchRepoUrl: string; mirrorUsed: boolean; mirrorBaseUrl?: string } { + const repository = githubRepositoryIdentity(repoUrl); + if (!repository) return { fetchRepoUrl: repoUrl, mirrorUsed: false }; + return resolveGitBundleFetchSourceFromMappings(repository, parseResourceBundleRepositoryMappings(env)); } function gitFetchSource(source: GitBundleSource): { fetchRepoUrl: string; mirrorUsed: boolean; mirrorBaseUrl?: string } { - return resolveGitBundleFetchSource(source.repoUrl, source.gitMirror); + const repository = githubRepositoryIdentity(source.repoUrl); + if (!repository) return { fetchRepoUrl: source.repoUrl, mirrorUsed: false }; + return resolveGitBundleFetchSourceFromMappings(repository, source.repositoryMappings ?? []); +} + +function resourceBundleRepositoryMappings(resourceBundleRef: ResourceBundleRef, env: NodeJS.ProcessEnv): readonly ResourceBundleRepositoryMapping[] | undefined { + const repoUrls = [resourceBundleRef.repoUrl, ...resourceBundleRef.bundles.map((bundle) => bundle.repoUrl ?? resourceBundleRef.repoUrl)]; + if (!repoUrls.some((repoUrl) => githubRepositoryIdentity(repoUrl) !== null)) return undefined; + return parseResourceBundleRepositoryMappings(env); +} + +function parseResourceBundleRepositoryMappings(env: NodeJS.ProcessEnv): readonly ResourceBundleRepositoryMapping[] { + const envName = "AGENTRUN_RESOURCE_BUNDLE_REPOSITORIES_JSON"; + const raw = optionalNonEmpty(env[envName]); + if (!raw) return []; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (error) { + throw repositoryMappingError("resource-bundle-repositories-invalid-json", `${envName} is not valid JSON`, { + error: fileErrorSummary(error), + }); + } + if (!Array.isArray(parsed)) { + throw repositoryMappingError("resource-bundle-repositories-invalid-shape", `${envName} must be an array`, { + actualType: parsed === null ? "null" : typeof parsed, + }); + } + return parsed.map((entry, index) => { + if (!isJsonObject(entry)) { + throw repositoryMappingError("resource-bundle-repository-invalid-entry", `${envName}[${index}] must be an object`, { index }); + } + const repository = optionalNonEmpty(entry.repository); + const normalizedRepository = repository ? normalizeRepositoryIdentity(repository) : null; + if (!repository || !normalizedRepository) { + throw repositoryMappingError("resource-bundle-repository-invalid-entry", `${envName}[${index}].repository must be an owner/repo identity`, { index }); + } + const readUrl = optionalNonEmpty(entry.readUrl); + if (!readUrl || !isSupportedRepositoryReadUrl(readUrl)) { + throw repositoryMappingError("resource-bundle-repository-invalid-entry", `${envName}[${index}].readUrl must be a credential-free Git URL`, { index, repository: normalizedRepository }); + } + return { repository, normalizedRepository, readUrl }; + }); +} + +function resolveGitBundleFetchSourceFromMappings(repository: string, mappings: readonly ResourceBundleRepositoryMapping[]): { fetchRepoUrl: string; mirrorUsed: boolean; mirrorBaseUrl?: string } { + const matches = mappings.filter((mapping) => mapping.normalizedRepository === repository); + if (matches.length === 0) { + throw repositoryMappingError("resource-bundle-repository-zero-match", `resource bundle repository ${repository} has no declared read URL`, { + repository, + mappingCount: mappings.length, + matchCount: 0, + }); + } + if (matches.length !== 1) { + throw repositoryMappingError("resource-bundle-repository-multiple-matches", `resource bundle repository ${repository} has multiple declared read URLs`, { + repository, + mappingCount: mappings.length, + matchCount: matches.length, + }); + } + return { fetchRepoUrl: matches[0]!.readUrl, mirrorUsed: true }; +} + +function repositoryMappingError(reason: string, message: string, details: JsonRecord): AgentRunError { + return new AgentRunError("schema-invalid", message, { + httpStatus: 400, + details: { + reason, + environment: "AGENTRUN_RESOURCE_BUNDLE_REPOSITORIES_JSON", + ...details, + valuesPrinted: false, + }, + }); +} + +function isSupportedRepositoryReadUrl(value: string): boolean { + if (/\s/u.test(value)) return false; + if (/^git@[^:/\s]+:.+/u.test(value)) return true; + try { + const parsed = new URL(value); + if (!parsed.hostname || parsed.password) return false; + if (parsed.username && !(parsed.protocol === "ssh:" && parsed.username === "git")) return false; + return ["http:", "https:", "ssh:", "git:"].includes(parsed.protocol); + } catch { + return false; + } +} + +function githubRepositoryIdentity(repoUrl: string): string | null { + const githubPath = githubRepoPath(repoUrl); + return githubPath ? normalizeRepositoryIdentity(githubPath) : null; +} + +function normalizeRepositoryIdentity(value: string): string | null { + const match = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+?)(?:\.git)?$/u.exec(value.trim()); + if (!match) return null; + const owner = match[1]?.toLowerCase(); + const repo = match[2]?.toLowerCase(); + return owner && repo ? `${owner}/${repo}` : null; } function githubRepoPath(repoUrl: string): string | null { diff --git a/src/selftest/cases/20-runner-k8s-job.ts b/src/selftest/cases/20-runner-k8s-job.ts index 2a4002c..1617ebf 100644 --- a/src/selftest/cases/20-runner-k8s-job.ts +++ b/src/selftest/cases/20-runner-k8s-job.ts @@ -37,7 +37,11 @@ const selfTest: SelfTestCase = async (context) => { const combinedToolCredentials = [...githubToolCredentials, ...unideskSshToolCredentials, ...githubSshToolCredentials]; const item = await createRunWithCommand(client, { ...context, toolCredentials: combinedToolCredentials }, "job smoke", "selftest-job-render", 15_000); const baseRun = await client.get(`/api/v1/runs/${item.runId}`) as RunRecord; - const rendered = renderRunnerJobDryRun({ + const repositoryMappings = JSON.stringify([{ repository: "pikasTech/unidesk", readUrl: "http://gitea.example.test/mirrors/pikasTech-unidesk.git" }]); + const rendered = withProcessEnv({ + AGENTRUN_RESOURCE_BUNDLE_REPOSITORIES_JSON: repositoryMappings, + AGENTRUN_GIT_MIRROR_BASE_URL: "http://legacy-base.example.test", + }, () => renderRunnerJobDryRun({ run: baseRun, commandId: item.commandId, managerUrl: server.baseUrl, @@ -46,7 +50,7 @@ const selfTest: SelfTestCase = async (context) => { attemptId: "attempt_selftest", sourceCommit: "self-test", transientEnv: [{ name: "HWLAB_API_KEY", value: "hwl_live_selftest", sensitive: true }], - }); + })); assert.equal(rendered.dryRun, true); assert.equal(rendered.mutation, false); assert.equal(((rendered.retention as JsonRecord).ttlSecondsAfterFinished), 604_800); @@ -60,6 +64,8 @@ const selfTest: SelfTestCase = async (context) => { assertRunnerJobUsesG14EgressProxy(rendered.manifest as JsonRecord); assertRunnerJobUsesBoundedGitTransport(rendered); assert.equal(runnerEnvValue(rendered.manifest as JsonRecord, "AGENTRUN_CODEX_SHELL_SANDBOX"), "danger-full-access"); + assert.equal(runnerEnvValue(rendered.manifest as JsonRecord, "AGENTRUN_RESOURCE_BUNDLE_REPOSITORIES_JSON"), repositoryMappings); + assert.equal(runnerEnvValue(rendered.manifest as JsonRecord, "AGENTRUN_GIT_MIRROR_BASE_URL"), undefined); assert.deepEqual(runnerEnvSecretKeyRef(rendered.manifest as JsonRecord, "AGENTRUN_API_KEY"), runnerApiKeySecretRef); assert.deepEqual(rendered.runnerApiKeySecretRef, { ...runnerApiKeySecretRef, valuesPrinted: false }); assert.equal(runnerEnvValue(rendered.manifest as JsonRecord, "HWLAB_API_KEY"), "REDACTED"); @@ -581,7 +587,7 @@ process.exit(1); assert.equal(envMap.get("AGENTRUN_SESSION_PVC_NAMESPACE"), "agentrun-v01"); assert.equal(envMap.get("AGENTRUN_SESSION_PVC_MOUNT_PATH"), "/home/agentrun/.agentrun-sessions/sessions"); assert.equal(envMap.get("AGENTRUN_CODEX_ROLLOUT_SUBDIR"), "sessions"); - return { name: "runner-k8s-job", tests: ["runner-k8s-job-dry-run", "runner-k8s-job-codex-shell-sandbox-env", "runner-k8s-job-g14-egress-proxy-env", "runner-k8s-job-bounded-git-transport-env", "runner-k8s-job-deepseek-profile-dry-run", "runner-k8s-job-minimax-m3-profile-dry-run", "runner-k8s-job-dsflash-go-profile-dry-run", "runner-k8s-job-dsflash-go-legacy-secretref-normalized", "runner-k8s-job-create-api", "runner-k8s-job-retention-ttl", "runner-job-transient-env", "runner-job-transient-env-secretref", "runner-job-tool-credential-env", "runner-job-unidesk-ssh-tool-credential-env", "runner-job-tool-credential-volume", "runner-job-resource-credential-volume", "runner-job-unidesk-ssh-endpoint-auto-env", "runner-job-unidesk-ssh-transient-env-denied", "runner-job-pre-create-retention", "runner-job-pre-create-retention-protects-stale-active", "runner-k8s-job-session-pvc-volume-and-env"] }; + return { name: "runner-k8s-job", tests: ["runner-k8s-job-dry-run", "runner-k8s-job-codex-shell-sandbox-env", "runner-k8s-job-resource-bundle-repositories-env", "runner-k8s-job-g14-egress-proxy-env", "runner-k8s-job-bounded-git-transport-env", "runner-k8s-job-deepseek-profile-dry-run", "runner-k8s-job-minimax-m3-profile-dry-run", "runner-k8s-job-dsflash-go-profile-dry-run", "runner-k8s-job-dsflash-go-legacy-secretref-normalized", "runner-k8s-job-create-api", "runner-k8s-job-retention-ttl", "runner-job-transient-env", "runner-job-transient-env-secretref", "runner-job-tool-credential-env", "runner-job-unidesk-ssh-tool-credential-env", "runner-job-tool-credential-volume", "runner-job-resource-credential-volume", "runner-job-unidesk-ssh-endpoint-auto-env", "runner-job-unidesk-ssh-transient-env-denied", "runner-job-pre-create-retention", "runner-job-pre-create-retention-protects-stale-active", "runner-k8s-job-session-pvc-volume-and-env"] }; } finally { await new Promise((resolve) => server.server.close(() => resolve())); } @@ -589,6 +595,19 @@ process.exit(1); export default selfTest; +function withProcessEnv(values: Record, operation: () => T): T { + const previous = new Map(Object.keys(values).map((name) => [name, process.env[name]])); + for (const [name, value] of Object.entries(values)) process.env[name] = value; + try { + return operation(); + } finally { + for (const [name, value] of previous) { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } + } +} + function runnerEnvEntry(manifest: JsonRecord, name: string): JsonRecord | undefined { const spec = manifest.spec as JsonRecord; const template = spec.template as JsonRecord; diff --git a/src/selftest/cases/76-aipod-spec.ts b/src/selftest/cases/76-aipod-spec.ts index 3b695c9..7bfbc42 100644 --- a/src/selftest/cases/76-aipod-spec.ts +++ b/src/selftest/cases/76-aipod-spec.ts @@ -300,13 +300,14 @@ const selfTest: SelfTestCase = async (context) => { (((invalidGitIdentityDocument.spec as JsonRecord).executionPolicy as JsonRecord).gitIdentity as JsonRecord).email = "not-an-email"; assert.throws(() => validateAipodSpec(invalidGitIdentityDocument, "selftest-invalid-git-identity"), /valid single-line email address/u); - const mirrored = resolveGitBundleFetchSource("git@github.com:pikasTech/unidesk.git", { baseUrl: "http://mirror.example.test/root/" }, {}); - assert.equal(mirrored.fetchRepoUrl, "http://mirror.example.test/root/pikasTech/unidesk.git"); + const repositoryMappings = JSON.stringify([{ repository: "pikasTech/unidesk", readUrl: "http://mirror.example.test/mirrors/pikasTech-unidesk.git" }]); + const mirrored = resolveGitBundleFetchSource("git@github.com:pikasTech/unidesk.git", { AGENTRUN_RESOURCE_BUNDLE_REPOSITORIES_JSON: repositoryMappings }); + assert.equal(mirrored.fetchRepoUrl, "http://mirror.example.test/mirrors/pikasTech-unidesk.git"); assert.equal(mirrored.mirrorUsed, true); - const defaultMirror = resolveGitBundleFetchSource("https://github.com/pikasTech/unidesk.git", undefined, { AGENTRUN_GIT_MIRROR_BASE_URL: "http://mirror.example.test/base" }); - assert.equal(defaultMirror.fetchRepoUrl, "http://mirror.example.test/base/pikasTech/unidesk.git"); + const defaultMirror = resolveGitBundleFetchSource("https://github.com/pikasTech/unidesk.git", { AGENTRUN_RESOURCE_BUNDLE_REPOSITORIES_JSON: repositoryMappings }); + assert.equal(defaultMirror.fetchRepoUrl, "http://mirror.example.test/mirrors/pikasTech-unidesk.git"); assert.equal(defaultMirror.mirrorUsed, true); - const nonGithub = resolveGitBundleFetchSource("ssh://git@example.test/repo.git", { baseUrl: "http://mirror.example.test" }, {}); + const nonGithub = resolveGitBundleFetchSource("ssh://git@example.test/repo.git", { AGENTRUN_RESOURCE_BUNDLE_REPOSITORIES_JSON: "invalid-json-is-ignored-for-non-github" }); assert.equal(nonGithub.fetchRepoUrl, "ssh://git@example.test/repo.git"); assert.equal(nonGithub.mirrorUsed, false); assert.equal(classifyGitStderr("fatal: repository 'http://mirror.example.test/pikasTech/agent_skills.git/' not found\n"), "repository-not-found"); @@ -415,7 +416,7 @@ const selfTest: SelfTestCase = async (context) => { assert.equal((storedTask.payload as JsonRecord).model, "gpt-5.6-sol"); assert.equal((storedTask.payload as JsonRecord).reasoningEffort, "medium"); assert.deepEqual((storedTask.payload as JsonRecord).modelConfig, taskPayload.modelConfig); - return { name: "aipod-spec", tests: ["aipod-spec-yaml-parser-runtime-compatible", "aipod-spec-artificer-image-ref-render", "aipod-spec-artificer-release-runtime-authority", "aipod-spec-multi-upstream-secret-summary", "aipod-spec-legacy-single-upstream-compatible", "aipod-spec-model-defaults", "aipod-spec-model-reasoning-overrides", "aipod-spec-model-default-fallback", "aipod-spec-grok-exact-route", "aipod-spec-selected-provider-only", "aipod-spec-invalid-route-fail-closed", "aipod-spec-control-override-fail-closed", "aipod-spec-payload-control-bypass-fail-closed", "aipod-spec-durable-task-model-snapshot", "aipod-spec-primary-workspace-contract", "aipod-spec-artificer-github-url-render", "aipod-spec-artificer-github-ssh-required-keys", "aipod-spec-artificer-private-repository-override", "aipod-spec-artificer-private-repository-override-runner-projection", "aipod-spec-git-mirror-url", "git-fetch-stderr-classification", "resource-bundle-source-authority-validation", "resource-bundle-target-conflict-validation", "queue-submit-aipod-dry-run", "session-send-aipod-model-dry-run", "session-send-aipod-grok-dry-run", "session-send-aipod-cancelled-durable-identity", "session-send-aipod-boundary-conflict-fail-closed", "aipod-cli-help"] }; + return { name: "aipod-spec", tests: ["aipod-spec-yaml-parser-runtime-compatible", "aipod-spec-artificer-image-ref-render", "aipod-spec-artificer-release-runtime-authority", "aipod-spec-multi-upstream-secret-summary", "aipod-spec-legacy-single-upstream-compatible", "aipod-spec-model-defaults", "aipod-spec-model-reasoning-overrides", "aipod-spec-model-default-fallback", "aipod-spec-grok-exact-route", "aipod-spec-selected-provider-only", "aipod-spec-invalid-route-fail-closed", "aipod-spec-control-override-fail-closed", "aipod-spec-payload-control-bypass-fail-closed", "aipod-spec-durable-task-model-snapshot", "aipod-spec-primary-workspace-contract", "aipod-spec-artificer-github-url-render", "aipod-spec-artificer-github-ssh-required-keys", "aipod-spec-artificer-private-repository-override", "aipod-spec-artificer-private-repository-override-runner-projection", "aipod-spec-resource-bundle-repository-map", "git-fetch-stderr-classification", "resource-bundle-source-authority-validation", "resource-bundle-target-conflict-validation", "queue-submit-aipod-dry-run", "session-send-aipod-model-dry-run", "session-send-aipod-grok-dry-run", "session-send-aipod-cancelled-durable-identity", "session-send-aipod-boundary-conflict-fail-closed", "aipod-cli-help"] }; } finally { await new Promise((resolve) => server.server.close(() => resolve())); } diff --git a/src/selftest/cases/80-github-ssh-transport.ts b/src/selftest/cases/80-github-ssh-transport.ts index c3927b1..fc9a477 100644 --- a/src/selftest/cases/80-github-ssh-transport.ts +++ b/src/selftest/cases/80-github-ssh-transport.ts @@ -91,7 +91,10 @@ async function runInternalHttpMirrorMaterializationCase(context: Parameters await materializeResourceBundle(resourceBundleRef, workspaceRef, { - AGENTRUN_GIT_MIRROR_BASE_URL: mirrorBaseUrl, + AGENTRUN_RESOURCE_BUNDLE_REPOSITORIES_JSON: JSON.stringify([{ + repository: "pikasTech/agentrun-mirror-probe", + readUrl: `${mirrorBaseUrl}/pikasTech/agentrun-mirror-probe.git`, + }]), AGENTRUN_WORKSPACE_ROOT: path.join(context.tmp, "github-ssh-http-mirror-assembly"), AGENTRUN_WORKSPACE_PATH: workspacePath, AGENTRUN_RUN_ID: "run_github_ssh_http_mirror_selftest", @@ -99,8 +102,9 @@ async function runInternalHttpMirrorMaterializationCase(context: Parameters((resolve) => mirrorServer.close(() => resolve())); } diff --git a/src/selftest/cases/82-resource-bundle-repository-map.ts b/src/selftest/cases/82-resource-bundle-repository-map.ts new file mode 100644 index 0000000..814e51c --- /dev/null +++ b/src/selftest/cases/82-resource-bundle-repository-map.ts @@ -0,0 +1,74 @@ +import assert from "node:assert/strict"; +import { AgentRunError } from "../../common/errors.js"; +import { resolveGitBundleFetchSource } from "../../runner/resource-bundle.js"; +import type { SelfTestCase } from "../harness.js"; + +const repositoriesEnvName = "AGENTRUN_RESOURCE_BUNDLE_REPOSITORIES_JSON"; +const exactReadUrl = "http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikasTech-unidesk.git"; + +const selfTest: SelfTestCase = async () => { + const exactMapping = JSON.stringify([ + { repository: "pikasTech/unidesk", readUrl: exactReadUrl }, + { repository: "pikasTech/agentrun", readUrl: "http://gitea.example.test/mirrors/pikasTech-agentrun.git" }, + ]); + const exact = resolveGitBundleFetchSource("git@github.com:pikasTech/unidesk.git", { [repositoriesEnvName]: exactMapping }); + assert.deepEqual(exact, { fetchRepoUrl: exactReadUrl, mirrorUsed: true }); + + const normalized = resolveGitBundleFetchSource("https://github.com/PIKASTECH/UNIDESK.git", { [repositoriesEnvName]: exactMapping }); + assert.deepEqual(normalized, { fetchRepoUrl: exactReadUrl, mirrorUsed: true }); + + assertMappingFailure( + () => resolveGitBundleFetchSource("git@github.com:pikasTech/missing.git", { [repositoriesEnvName]: exactMapping }), + "resource-bundle-repository-zero-match", + ); + + const duplicateMapping = JSON.stringify([ + { repository: "pikasTech/unidesk", readUrl: exactReadUrl }, + { repository: "PIKASTECH/UNIDESK", readUrl: "http://gitea.example.test/mirrors/duplicate.git" }, + ]); + assertMappingFailure( + () => resolveGitBundleFetchSource("git@github.com:pikasTech/unidesk.git", { [repositoriesEnvName]: duplicateMapping }), + "resource-bundle-repository-multiple-matches", + ); + + assertMappingFailure( + () => resolveGitBundleFetchSource("git@github.com:pikasTech/unidesk.git", { [repositoriesEnvName]: "{" }), + "resource-bundle-repositories-invalid-json", + ); + assertMappingFailure( + () => resolveGitBundleFetchSource("git@github.com:pikasTech/unidesk.git", { [repositoriesEnvName]: JSON.stringify([{ repository: "pikasTech/unidesk", readUrl: "https://user:password@gitea.example.test/repo.git" }]) }), + "resource-bundle-repository-invalid-entry", + ); + + const nonGithubUrl = "ssh://git@gitea.example.test/mirrors/pikasTech-unidesk.git"; + assert.deepEqual( + resolveGitBundleFetchSource(nonGithubUrl, { [repositoriesEnvName]: "invalid-json-is-not-consumed" }), + { fetchRepoUrl: nonGithubUrl, mirrorUsed: false }, + ); + + return { + name: "resource-bundle-repository-map", + tests: [ + "resource-bundle-repository-exact-read-url", + "resource-bundle-repository-normalized-github-identity", + "resource-bundle-repository-zero-match-typed-failure", + "resource-bundle-repository-multiple-match-typed-failure", + "resource-bundle-repositories-invalid-json-typed-failure", + "resource-bundle-repository-invalid-entry-typed-failure", + "resource-bundle-non-github-url-preserved", + ], + }; +}; + +function assertMappingFailure(operation: () => unknown, reason: string): void { + assert.throws(operation, (error: unknown) => { + assert.ok(error instanceof AgentRunError); + assert.equal(error.failureKind, "schema-invalid"); + assert.equal(error.details?.reason, reason); + assert.equal(error.details?.environment, repositoriesEnvName); + assert.equal(error.details?.valuesPrinted, false); + return true; + }); +} + +export default selfTest;