From e624835bc8cf887c8ad399a2b0229f0cab8b8084 Mon Sep 17 00:00:00 2001 From: AgentRun Codex Date: Thu, 11 Jun 2026 16:48:50 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E8=AE=A9=20gitbundle=20=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E4=BD=BF=E7=94=A8=20G14=20git=20mirror?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/aipods/artificer.yaml | 2 -- docs/reference/spec-v01-aipod-spec.md | 16 ++++++------- docs/reference/spec-v01-runtime-assembly.md | 6 ++--- src/common/aipod-specs.ts | 1 - src/common/types.ts | 4 ---- src/common/validation.ts | 26 ++------------------- src/runner/resource-bundle.ts | 22 +++++++++-------- src/selftest/cases/76-aipod-spec.ts | 18 +++++++------- 8 files changed, 34 insertions(+), 61 deletions(-) diff --git a/config/aipods/artificer.yaml b/config/aipods/artificer.yaml index b2228fd..c09e0eb 100644 --- a/config/aipods/artificer.yaml +++ b/config/aipods/artificer.yaml @@ -76,8 +76,6 @@ spec: kind: gitbundle repoUrl: git@github.com:pikasTech/unidesk.git ref: master - gitMirror: - enabled: false bundles: - name: unidesk-skills subpath: .agents/skills diff --git a/docs/reference/spec-v01-aipod-spec.md b/docs/reference/spec-v01-aipod-spec.md index 393b654..41a80e9 100644 --- a/docs/reference/spec-v01-aipod-spec.md +++ b/docs/reference/spec-v01-aipod-spec.md @@ -93,23 +93,21 @@ imageRef: - 通过 `toolCredentials` 获取 GitHub Issue/PR 写入 token:`agentrun-v01-tool-github-pr` -> env `GH_TOKEN`。 - 通过 `toolCredentials` 获取 UniDesk SSH 透传 token:`agentrun-v01-tool-unidesk-ssh` -> env `UNIDESK_SSH_CLIENT_TOKEN`。 - 通过 `toolCredentials` 获取 GitHub SSH 凭据:`agentrun-v01-tool-github-ssh` -> volume `/home/agentrun/.ssh`。 -- 通过 GitHub SSH 直接 checkout 多仓库 gitbundle,装配 UniDesk repo 中的 `unidesk-*` skills、AgentRun repo 中的 `tools/trans`、`tools/tran`、`tools/apply_patch`,以及 `agent_skills` repo 中的 `dad-dev`、`cli-spec`、`docs-spec`、`git-spec`;Artificer 默认不依赖 G14 git mirror 的覆盖范围。 +- 通过声明 GitHub repo URL 的多仓库 gitbundle,装配 UniDesk repo 中的 `unidesk-*` skills、AgentRun repo 中的 `tools/trans`、`tools/tran`、`tools/apply_patch`,以及 `agent_skills` repo 中的 `dad-dev`、`cli-spec`、`docs-spec`、`git-spec`;runner 物化阶段自动把 GitHub URL 改写到 G14 git mirror,Artificer 规格本身不声明 mirror 开关或 mirror base URL。 - 通过 `requiredSkills[]` 在 runner 启动 backend 前校验 `dad-dev` 与 UniDesk 关键 skills 已被 materialize;缺失时必须 `required-skill-unavailable`,不得使用模型默认 skill 猜测。 - Artificer 默认是 session-capable agent。`render`/`queue submit --aipod Artificer` 在调用方没有显式传入 `sessionRef` 时,必须生成稳定的默认 `SessionRef`,`sessionId` 使用 `sess_artificer_<24 hex>` 形态,`conversationId` 默认等于 `sessionId`,并把该引用写入 Queue task/run 装配输入;后续 follow-up、补测、reviewer feedback 和 manager recovery 优先继续同一个 session,而不是每次新开 session。 ## Git mirror -`resourceBundleRef.gitMirror` 用于把 GitHub repo URL 改写为 G14 git mirror read URL,提高 runner checkout 的稳定性和速度。 +Git mirror 是 AgentRun/G14 基础设施能力,不是 AipodSpec 或 Queue task 规格能力。`AipodSpec.spec.resourceBundleRef` 必须继续声明无明文 credential 的 GitHub repo URL;不得在 AipodSpec、Queue task、prompt 或业务 adapter 中声明 `gitMirror`、mirror base URL、direct/mirror 开关或 per-agent mirror 策略。 规则: -- `enabled` 缺省为 `true`;显式 `false` 时不改写。 -- `baseUrl` 必须是无 credentials、query、fragment 的 HTTP(S) URL;未设置时 runner 使用 `AGENTRUN_GIT_MIRROR_BASE_URL`,再缺省为 `http://git-mirror-http.devops-infra.svc.cluster.local`。 +- runner 在 materialization 阶段自动把 GitHub URL 改写为 G14 git mirror read URL;基础 URL 来自 `AGENTRUN_GIT_MIRROR_BASE_URL`,缺省为 `http://git-mirror-http.devops-infra.svc.cluster.local`。 - 支持 `git@github.com:owner/repo.git`、`ssh://git@github.com/owner/repo.git`、`ssh://git@ssh.github.com:443/owner/repo.git` 和 `https://github.com/owner/repo.git`。 - 非 GitHub URL 不改写,仍按原 `repoUrl` fetch。 -- materialization event 必须输出 `repoUrl`、`fetchRepoUrl`、`mirrorUsed`、`mirrorBaseUrl`、requested ref/commit 和实际 commit;不得输出 credential 值。 - -Artificer 默认 `gitMirror.enabled=false`,依靠 `agentrun-v01-tool-github-ssh` 投影的 SSH 凭据直接拉取 `unidesk`、`agentrun` 和 `agent_skills`。只有明确确认目标 repo 已纳入 G14 mirror cache 的规格,才应显式启用 `gitMirror`。 +- devops-infra mirror cache 必须覆盖 Artificer 默认 bundle 使用的 `pikasTech/unidesk`、`pikasTech/agentrun` 和 `pikasTech/agent_skills`;缺 cache 是基础设施缺口,不能通过修改 AipodSpec 直连 GitHub 来绕过。 +- materialization event 必须输出原始 `repoUrl`、实际 `fetchRepoUrl`、`mirrorUsed`、`mirrorBaseUrl`、requested ref/commit 和实际 commit;不得输出 credential 值。 ## Tool credential projection @@ -154,10 +152,10 @@ CLI: ## 测试规格 -- A1:`config/aipods/artificer.yaml` 能被 manager list/show/render,render 结果包含 `imageRef.kind=env-image-dockerfile`、`repoUrl`、`commitId`、`dockerfilePath`、`backendProfile=sub2api`、`model=gpt-5.5`、`reasoningEffort=xhigh`、provider SecretRef、GitHub PR token env projection、UniDesk SSH env projection、GitHub SSH volume projection、Artificer `gitMirror.enabled=false`、AgentRun runner tools gitbundle 和 gitbundle requiredSkills。 +- A1:`config/aipods/artificer.yaml` 能被 manager list/show/render,render 结果包含 `imageRef.kind=env-image-dockerfile`、`repoUrl`、`commitId`、`dockerfilePath`、`backendProfile=sub2api`、`model=gpt-5.5`、`reasoningEffort=xhigh`、provider SecretRef、GitHub PR token env projection、UniDesk SSH env projection、GitHub SSH volume projection、无 `resourceBundleRef.gitMirror` 字段、AgentRun runner tools gitbundle 和 gitbundle requiredSkills。 - A2:`queue submit --aipod Artificer --dry-run` 输出标准 `queue-submit-plan`,且 `idempotencyKey`、prompt 与 metadata 被保留。 - A2b:`queue submit --aipod Artificer --dry-run` 或 `render Artificer` 在没有显式 `sessionRef` 时必须输出默认 `sessionRef.sessionId` / `conversationId`;显式传入 `sessionRef` 时不得覆盖。 -- A3:Artificer 默认 `gitMirror.enabled=false` 时 GitHub URL 保持 SSH fetch;显式启用 `gitMirror` 后 GitHub URL 改写到 mirror base URL;非 GitHub URL 不改写。 +- A3:Artificer 规格只声明 GitHub URL,不声明 `gitMirror`;runner 默认把 GitHub URL 改写到 mirror base URL,非 GitHub URL 不改写。请求体显式携带 `resourceBundleRef.gitMirror` 必须 schema-invalid,避免把基础设施策略下放到 AipodSpec。 - A4:Aipod 启动/dispatch 能基于 `imageRef` 命中 artifact catalog / registry 并复用 digest-pinned env image;未命中时返回明确 build-required 或进入受控 CI/CD,不在 runner 任务内补装依赖。 - A5:runner Job dry-run 支持 tool credential volume mount,并且 response/manifest/event 不泄漏 Secret 明文。 - A6:`bun run check` 和 `bun run self-test` 必须覆盖 A1-A5。 diff --git a/docs/reference/spec-v01-runtime-assembly.md b/docs/reference/spec-v01-runtime-assembly.md index e6b02a3..e7aef46 100644 --- a/docs/reference/spec-v01-runtime-assembly.md +++ b/docs/reference/spec-v01-runtime-assembly.md @@ -188,7 +188,7 @@ HWLAB Workbench 的 project/workspace 不属于 RuntimeAssembly 四要素,也 - P0 固定 `kind="gitbundle"`,输入只依赖 Git repo/ref 与 `bundles[]`;runner 从 `resourceBundleRef.ref`、run 的 `workspaceRef.branch` 或 `HEAD` 解析实际 commit,再用该 materialized commit 作为内容身份。 - `commitId` 是可选 pin 或历史请求 hint;提供时必须是 full commit sha,但 HWLAB gitbundle 默认不得依赖 cloud-api/CI/CD rollout 注入的 commitId。 - `bundles[]` 每一项只允许 `{ name?, repoUrl?, ref?, commitId?, subpath, target_path }`;缺省 repo/ref/commit 继承顶层解析结果。 -- `gitMirror` 可把 GitHub repo URL 改写到受控 mirror read URL;event/result 必须同时记录原始 `repoUrl`、实际 `fetchRepoUrl`、`mirrorUsed` 和 `mirrorBaseUrl`,不能只显示理论 repo。 +- Git mirror 是基础设施能力,不是 `ResourceBundleRef` 字段。调用方只声明 GitHub `repoUrl`,runner 在物化阶段自动改写到受控 mirror read URL;event/result 必须同时记录原始 `repoUrl`、实际 `fetchRepoUrl`、`mirrorUsed` 和 `mirrorBaseUrl`,不能只显示理论 repo。请求体中显式出现 `resourceBundleRef.gitMirror` 必须 schema-invalid,避免业务 spec 控制基础设施策略。 - `subpath` 必须留在 checkout 内,`target_path` 必须留在 runner workspace 内;runner 按 `subpath -> target_path` 复制文件或目录。 - `credentialRef` 只用于拉取私有 Git repo,不等同于 backend API KEY。 - 不支持上传文件、对象存储 artifact、任意 ConfigMap 文件袋、inline seed 或旧字段;旧 `toolAliases`、`skillRefs`、`workspaceFiles`、`subdir`、`sparsePaths` 输入必须直接 schema-invalid。 @@ -288,7 +288,7 @@ skill 只来自 gitbundle 复制进 workspace 的 `.agents/skills//SKILL.m - checkout 只能进入允许 workspace 前缀,不能覆盖 `/app`、Secret projection、profile runtime home 或 session 目录。 - run payload 不携带文件正文、env dump、Secret value 或大型 artifact。 - 若提供 `bundles[]`,必须能看到每个 `subpath -> target_path` 的复制摘要;旧字段输入必须 schema-invalid。 -- 若启用 `gitMirror`,必须能看到 GitHub URL 被改写到 mirror `fetchRepoUrl`;非 GitHub URL 必须保留原 fetch URL 并显示 `mirrorUsed=false`。 +- GitHub URL 必须默认被改写到 mirror `fetchRepoUrl`;非 GitHub URL 必须保留原 fetch URL 并显示 `mirrorUsed=false`;请求体显式 `resourceBundleRef.gitMirror` 必须被拒绝。 - 若提供 `promptRefs`,必须能看到每个 prompt 的 `name/path/sha256/bytes/inject`,新 thread 首轮 `initialPromptInjected=true`,resume turn `initialPromptInjected=false`。 - 若 bundle 复制了 `.agents/skills`,必须能看到 skillDirs 聚合摘要、skill 名称、manifest hash/bytes 和来源 bundle;若提供 `requiredSkills`,必须看到成功路径的 requiredSkills hash/bytes,以及缺失路径的 `required-skill-unavailable` blocker。不能显示模型默认 skill 列表当作业务 skill。 @@ -311,6 +311,6 @@ skill 只来自 gitbundle 复制进 workspace 的 `.agents/skills//SKILL.m | `ProfileRef` | 已实现/待 dsflash-go 真实主闭环 | `codex`、`deepseek` 与 `minimax-m3` 已通过 SecretRef、writable runtime home 和真实 stdio turn 验证;MiniMax-M3 已通过 HWLAB 显式 session 原入口复测。`dsflash-go` 已补齐 SecretRef/model catalog 装配、自测试和 legacy key 归一,仍需完成真实 runtime 与 HWLAB 原入口复测;后续只允许作为 profile/config/SecretRef/model catalog 选择,不新增直连 backend。 | | `SessionRef` | 已实现最小持久化 | manager 持久化 `sessionId/conversationId/threadId`,run 创建会解析既有 session,runner 按 threadId resume;session 不保存 credential 文件,TTL/GC 后续细化。 | | `SessionRef` | v0.1.1 已实现/已通过 HWLAB v0.2 原入口复测 | manager 持久化 `sessionId/conversationId/threadId` + 每个 session 绑 RWO PVC(`agentrun-v01-session-`),runner Job 把 PVC 直接挂到 `${CODEX_HOME}/`,codex app-server 自己落盘;runner pod 删除后 replacement runner 仍复用同一 SessionRef/PVC/thread,禁止 copy/restore、replacement threadId 和 fake resume。 | -| `ResourceBundleRef` | 已实现 `kind="gitbundle"` materialization/promptRefs/tools/skillDirs/requiredSkills 装配 | `repoUrl + ref/materialized commit + bundles[]` 已进入 run schema 和 runner checkout,workspace 受 `AGENTRUN_WORKSPACE_ROOT` 限制,event/result 记录 requested ref/commit、actual commit、tree/workspace/bundles 摘要;`gitMirror` 会把 GitHub URL 改写到受控 mirror 并记录 `fetchRepoUrl/mirrorUsed`;`tools/` PATH、`promptRefs` thread-start 注入、`.agents/skills` 目录发现和 required skill 校验已实现。 | +| `ResourceBundleRef` | 已实现 `kind="gitbundle"` materialization/promptRefs/tools/skillDirs/requiredSkills 装配 | `repoUrl + ref/materialized commit + bundles[]` 已进入 run schema 和 runner checkout,workspace 受 `AGENTRUN_WORKSPACE_ROOT` 限制,event/result 记录 requested ref/commit、actual commit、tree/workspace/bundles 摘要;runner 默认把 GitHub URL 改写到受控 mirror 并记录 `fetchRepoUrl/mirrorUsed`,请求体不允许携带 `gitMirror`;`tools/` PATH、`promptRefs` thread-start 注入、`.agents/skills` 目录发现和 required skill 校验已实现。 | | `toolCredentials` | 已实现 env 与 volume projection | GitHub PR、GitHub SSH 和 UniDesk SSH passthrough 等 agent shell/tool 授权通过装配 SPEC 的 SecretRef 进入 runner;v0.1 支持 `tool=github` 与 `tool=unidesk-ssh`、`projection.kind=env|volume`,runner Job 使用 `valueFrom.secretKeyRef` 或只读 Secret volume 注入,不用 `transientEnv` 绕过。 | | `transientEnv` | 已实现 per-job SecretRef 投影 | Queue dispatch 和 runner-job API 支持短期 runtime env;正式 Kubernetes Job 先创建本次 Job 专属 Secret,再用 `valueFrom.secretKeyRef` 注入 runner env,response/event/trace 只显示 env names、Secret metadata 和 `valuesPrinted=false`。 | diff --git a/src/common/aipod-specs.ts b/src/common/aipod-specs.ts index bc63be8..fed9019 100644 --- a/src/common/aipod-specs.ts +++ b/src/common/aipod-specs.ts @@ -250,7 +250,6 @@ function summarizeResourceBundle(resourceBundleRef: ResourceBundleRef | null): J repoUrl: resourceBundleRef.repoUrl, ref: resourceBundleRef.ref ?? null, commitId: resourceBundleRef.commitId ?? null, - gitMirror: resourceBundleRef.gitMirror ? { enabled: resourceBundleRef.gitMirror.enabled ?? true, baseUrl: resourceBundleRef.gitMirror.baseUrl ?? null, valuesPrinted: false } : { enabled: false, baseUrl: null, valuesPrinted: false }, bundles: { count: resourceBundleRef.bundles.length, items: resourceBundleRef.bundles.map((item) => ({ name: item.name ?? null, repoUrl: item.repoUrl ?? resourceBundleRef.repoUrl, ref: item.ref ?? resourceBundleRef.ref ?? null, commitId: item.commitId ?? resourceBundleRef.commitId ?? null, subpath: item.subpath, targetPath: item.targetPath, valuesPrinted: false })), valuesPrinted: false }, requiredSkills: { count: resourceBundleRef.requiredSkills?.length ?? 0, names: resourceBundleRef.requiredSkills?.map((item) => item.name) ?? [], valuesPrinted: false }, promptRefs: { count: resourceBundleRef.promptRefs?.length ?? 0, names: resourceBundleRef.promptRefs?.map((item) => item.name) ?? [], valuesPrinted: false }, diff --git a/src/common/types.ts b/src/common/types.ts index 10a8ad4..94a4436 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -84,10 +84,6 @@ export interface ResourceBundleRef extends JsonRecord { repoUrl: string; commitId?: string; ref?: string; - gitMirror?: { - enabled?: boolean; - baseUrl?: string; - }; bundles: GitBundleItemRef[]; promptRefs?: Array<{ name: string; diff --git a/src/common/validation.ts b/src/common/validation.ts index 5399c01..ba4d2a6 100644 --- a/src/common/validation.ts +++ b/src/common/validation.ts @@ -89,8 +89,7 @@ export function validateResourceBundleRef(value: unknown): ResourceBundleRef | n if (commitId) validateCommitId(commitId, "resourceBundleRef.commitId"); const ref = validateGitRef(record.ref, "resourceBundleRef.ref"); rejectLegacyResourceBundleFields(record); - const gitMirror = validateGitMirror(record.gitMirror); - const result: ResourceBundleRef = { kind: "gitbundle", repoUrl, ...(commitId ? { commitId } : {}), ...(ref ? { ref } : {}), ...(gitMirror ? { gitMirror } : {}), bundles: validateResourceGitBundles(record.bundles, repoUrl, commitId, ref) }; + const result: ResourceBundleRef = { kind: "gitbundle", repoUrl, ...(commitId ? { commitId } : {}), ...(ref ? { ref } : {}), bundles: validateResourceGitBundles(record.bundles, repoUrl, commitId, ref) }; if (record.promptRefs !== undefined) result.promptRefs = validateResourcePromptRefs(record.promptRefs); if (record.requiredSkills !== undefined) result.requiredSkills = validateResourceRequiredSkills(record.requiredSkills); if (record.submodules !== undefined && record.submodules !== false) throw new AgentRunError("schema-invalid", "resourceBundleRef.submodules can only be false in v0.1", { httpStatus: 400 }); @@ -101,29 +100,8 @@ export function validateResourceBundleRef(value: unknown): ResourceBundleRef | n return result; } -function validateGitMirror(value: unknown): NonNullable | undefined { - if (value === undefined) return undefined; - const record = asRecord(value, "resourceBundleRef.gitMirror"); - const enabled = record.enabled === undefined ? true : record.enabled; - if (typeof enabled !== "boolean") throw new AgentRunError("schema-invalid", "resourceBundleRef.gitMirror.enabled must be boolean", { httpStatus: 400 }); - const baseUrl = optionalString(record.baseUrl); - if (baseUrl) validateGitMirrorBaseUrl(baseUrl); - return { enabled, ...(baseUrl ? { baseUrl } : {}) }; -} - -function validateGitMirrorBaseUrl(value: string): void { - let parsed: URL; - try { - parsed = new URL(value); - } catch { - throw new AgentRunError("schema-invalid", "resourceBundleRef.gitMirror.baseUrl must be an http(s) URL", { httpStatus: 400 }); - } - if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new AgentRunError("schema-invalid", "resourceBundleRef.gitMirror.baseUrl must use http or https", { httpStatus: 400 }); - if (parsed.username || parsed.password || parsed.search || parsed.hash) throw new AgentRunError("schema-invalid", "resourceBundleRef.gitMirror.baseUrl must not include credentials, query, or fragment", { httpStatus: 400 }); -} - function rejectLegacyResourceBundleFields(record: JsonRecord): void { - for (const field of ["toolAliases", "skillRefs", "workspaceFiles", "subdir", "sparsePaths"] as const) { + for (const field of ["toolAliases", "skillRefs", "workspaceFiles", "subdir", "sparsePaths", "gitMirror"] as const) { if (record[field] !== undefined) throw new AgentRunError("schema-invalid", `resourceBundleRef.${field} is removed; use resourceBundleRef.bundles[] with kind=gitbundle`, { httpStatus: 400 }); } } diff --git a/src/runner/resource-bundle.ts b/src/runner/resource-bundle.ts index 3221e4d..e0b7a57 100644 --- a/src/runner/resource-bundle.ts +++ b/src/runner/resource-bundle.ts @@ -55,7 +55,6 @@ interface GitCheckout { } interface GitMirrorConfig { - enabled: true; baseUrl: string; } @@ -146,13 +145,13 @@ export async function materializeResourceBundle(resourceBundleRef: ResourceBundl } function gitMirrorConfig(resourceBundleRef: ResourceBundleRef, env: NodeJS.ProcessEnv): GitMirrorConfig | undefined { - return normalizeGitMirrorConfig(resourceBundleRef.gitMirror, env); + void resourceBundleRef; + return defaultGitMirrorConfig(env); } -function normalizeGitMirrorConfig(gitMirror: ResourceBundleRef["gitMirror"] | GitMirrorConfig | undefined, env: NodeJS.ProcessEnv): GitMirrorConfig | undefined { - if (!gitMirror || gitMirror.enabled === false) return undefined; - const baseUrl = optionalNonEmpty(gitMirror.baseUrl) ?? optionalNonEmpty(env.AGENTRUN_GIT_MIRROR_BASE_URL) ?? "http://git-mirror-http.devops-infra.svc.cluster.local"; - return { enabled: true, baseUrl: baseUrl.replace(/\/+$/u, "") }; +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): GitBundleSource { @@ -199,17 +198,20 @@ 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 ? { enabled: true, baseUrl: source.gitMirror.baseUrl } : null }; + return { repoUrl: source.repoUrl, commitId: source.commitId ?? null, ref: source.ref ?? null, gitMirror: source.gitMirror ? { baseUrl: source.gitMirror.baseUrl } : null }; } -export function resolveGitBundleFetchSource(repoUrl: string, gitMirror?: ResourceBundleRef["gitMirror"] | GitMirrorConfig, env: NodeJS.ProcessEnv = process.env): { fetchRepoUrl: string; mirrorUsed: boolean; mirrorBaseUrl?: string } { - const mirror = normalizeGitMirrorConfig(gitMirror, env); - if (!mirror) return { fetchRepoUrl: repoUrl, mirrorUsed: false }; +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, ""); +} + function gitFetchSource(source: GitBundleSource): { fetchRepoUrl: string; mirrorUsed: boolean; mirrorBaseUrl?: string } { return resolveGitBundleFetchSource(source.repoUrl, source.gitMirror); } diff --git a/src/selftest/cases/76-aipod-spec.ts b/src/selftest/cases/76-aipod-spec.ts index 66488a2..ab13188 100644 --- a/src/selftest/cases/76-aipod-spec.ts +++ b/src/selftest/cases/76-aipod-spec.ts @@ -5,6 +5,7 @@ import { ManagerClient } from "../../mgr/client.js"; import { startManagerServer } from "../../mgr/server.js"; import { MemoryAgentRunStore } from "../../mgr/store.js"; import type { JsonRecord } from "../../common/types.js"; +import { validateResourceBundleRef } from "../../common/validation.js"; import { resolveGitBundleFetchSource } from "../../runner/resource-bundle.js"; import { assertNoSecretLeak, loadArtificerImageRef, type SelfTestCase } from "../harness.js"; @@ -30,7 +31,7 @@ const selfTest: SelfTestCase = async (context) => { assert.equal(shownImageRef.repoUrl, "git@github.com:pikasTech/agentrun.git"); assert.equal(shownImageRef.commitId, artificerImageRef.commitId); assert.equal(shownImageRef.dockerfilePath, "deploy/container/Containerfile"); - assert.equal(((shownItem.resourceBundleRef as JsonRecord).gitMirror as JsonRecord).enabled, false); + assert.equal(Object.hasOwn(shownItem.resourceBundleRef as JsonRecord, "gitMirror"), false); const rendered = await client.post("/api/v1/aipod-specs/Artificer/render", { prompt: "处理 pikasTech/unidesk#245", idempotencyKey: "selftest-aipod-artificer" }) as JsonRecord; assert.equal(rendered.action, "aipod-spec-render"); @@ -58,7 +59,7 @@ const selfTest: SelfTestCase = async (context) => { assert.equal(toolCredentials.some((item) => item.tool === "unidesk-ssh" && ((item.projection as JsonRecord).envName) === "UNIDESK_SSH_CLIENT_TOKEN"), true); assert.equal(toolCredentials.some((item) => item.tool === "github" && ((item.projection as JsonRecord).kind) === "volume" && ((item.projection as JsonRecord).mountPath) === "/home/agentrun/.ssh"), true); const bundle = task.resourceBundleRef as JsonRecord; - assert.equal(((bundle.gitMirror as JsonRecord).enabled), false); + assert.equal(Object.hasOwn(bundle, "gitMirror"), false); const bundles = bundle.bundles as JsonRecord[]; const toolBundle = bundles.find((item) => item.name === "agentrun-runner-tools") as JsonRecord | undefined; assert.equal(toolBundle?.repoUrl, "git@github.com:pikasTech/agentrun.git"); @@ -69,15 +70,16 @@ const selfTest: SelfTestCase = async (context) => { assert.equal((bundle.requiredSkills as JsonRecord[]).some((item) => item.name === "unidesk-gh"), true); assertNoSecretLeak(rendered); - const mirrored = resolveGitBundleFetchSource("git@github.com:pikasTech/unidesk.git", { enabled: true, baseUrl: "http://mirror.example.test/root/" }, {}); + 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"); assert.equal(mirrored.mirrorUsed, true); - const disabledMirror = resolveGitBundleFetchSource("git@github.com:pikasTech/unidesk.git", { enabled: false, baseUrl: "http://mirror.example.test/root/" }, {}); - assert.equal(disabledMirror.fetchRepoUrl, "git@github.com:pikasTech/unidesk.git"); - assert.equal(disabledMirror.mirrorUsed, false); - const nonGithub = resolveGitBundleFetchSource("ssh://git@example.test/repo.git", { enabled: true, baseUrl: "http://mirror.example.test" }, {}); + 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"); + assert.equal(defaultMirror.mirrorUsed, true); + const nonGithub = resolveGitBundleFetchSource("ssh://git@example.test/repo.git", { baseUrl: "http://mirror.example.test" }, {}); assert.equal(nonGithub.fetchRepoUrl, "ssh://git@example.test/repo.git"); assert.equal(nonGithub.mirrorUsed, false); + assert.throws(() => validateResourceBundleRef({ kind: "gitbundle", repoUrl: "git@github.com:pikasTech/unidesk.git", ref: "master", gitMirror: { enabled: false }, bundles: [{ subpath: ".", targetPath: "." }] }), /resourceBundleRef.gitMirror is removed/u); const submitPlan = await runCliJson(context, server.baseUrl, ["queue", "submit", "--aipod", "Artificer", "--prompt", "处理 pikasTech/unidesk#245", "--idempotency-key", "selftest-aipod-cli", "--dry-run"]); assert.equal(submitPlan.ok, true); @@ -91,7 +93,7 @@ const selfTest: SelfTestCase = async (context) => { assert.equal(commands.some((item) => item.includes("aipod-specs render ")), true); assert.equal(commands.some((item) => item.includes("queue submit --aipod ")), true); assertNoSecretLeak(submitPlan); - return { name: "aipod-spec", tests: ["aipod-spec-yaml-parser-runtime-compatible", "aipod-spec-artificer-image-ref-render", "aipod-spec-artificer-direct-ssh-render", "aipod-spec-git-mirror-url", "queue-submit-aipod-dry-run", "aipod-cli-help"] }; + return { name: "aipod-spec", tests: ["aipod-spec-yaml-parser-runtime-compatible", "aipod-spec-artificer-image-ref-render", "aipod-spec-artificer-github-url-render", "aipod-spec-git-mirror-url", "queue-submit-aipod-dry-run", "aipod-cli-help"] }; } finally { await new Promise((resolve) => server.server.close(() => resolve())); }