fix(catalog): 处理 Codex 对 Duo 模型缓存与远程端口比较的两项审阅意见

- 动态模型缓存键改为镜像 discoverGitLabDuoWorkflowNamespace 真实解析输入:
  内置发现只传 apiKey/baseUrl/fetch,原键里的 namespaceId/projectId/cwd 恒为
  空,退化为 apiKey+baseUrl,导致同 token 下两个不同 group 的工作区互相复用
  权威模型缓存。改为按 (凭据, baseUrl, 命名空间/项目 config 或同名环境变量,
  有效 cwd) 指纹分区。
- 远程 host 比较改用 host(含端口)而非 hostname:同主机不同端口的自托管 GitLab
  不再被误判为同实例,避免从错误远程推导项目路径。SCP 远程无端口概念,仍按裸
  host 比较。新增跨端口远程不被当作工作区项目的回归测试。
This commit is contained in:
jiwangyihao
2026-06-23 02:59:19 +08:00
parent 4fdf2f83a5
commit 3298c35eec
3 changed files with 58 additions and 7 deletions
@@ -770,8 +770,11 @@ function parseGitLabRemoteProjectPath(remoteUrl: string, expectedHost: string |
function parseRemoteUrl(remoteUrl: string): { host: string; projectPath: string } | null {
try {
const url = new URL(remoteUrl);
return { host: url.hostname, projectPath: url.pathname };
// `host` (not `hostname`) keeps any explicit port so a self-managed GitLab on a
// non-default port is not confused with another service on the same hostname.
return { host: url.host, projectPath: url.pathname };
} catch {
// SCP-style `git@host:path` has no port concept; bare host is the only key.
const scpMatch = remoteUrl.match(/^(?:[^@]+@)?([^:]+):(.+)$/);
if (scpMatch?.[1] && scpMatch[2]) {
return { host: scpMatch[1], projectPath: scpMatch[2] };
@@ -782,7 +785,8 @@ function parseRemoteUrl(remoteUrl: string): { host: string; projectPath: string
function parseUrlHost(url: string): string | null {
try {
return new URL(url).hostname;
// Match `parseRemoteUrl`: include the port so host comparison is port-aware.
return new URL(url).host;
} catch {
return null;
}
@@ -83,8 +83,12 @@ export function gitLabDuoWorkflowModelManagerOptions(
// models), so the default provider-id cache namespace would let a second
// account/namespace load the first one's authoritative model list at startup
// and skip refetching. Partition the cache by a non-reversible fingerprint of
// the credential + base URL + namespace/project/cwd scope. Falls back to the
// bare provider id when no credential is present (static-only fallback model).
// the exact inputs `fetchGitLabDuoWorkflowModels` resolves the namespace from
// (credential + base URL + namespace/project config + the same env vars + the
// effective workspace cwd whose git remote drives auto-discovery). Built-in
// discovery only passes apiKey/baseUrl/fetch, so the cwd/env terms — not the
// empty config fields — are what actually separate workspace A from B here.
// Falls back to the bare provider id when no credential is present.
...(apiKey ? { cacheProviderId: gitLabDuoWorkflowModelCacheProviderId(apiKey, config) } : undefined),
dynamicModelsAuthoritative: true,
staticModels: [
@@ -107,9 +111,14 @@ export function gitLabDuoWorkflowModelManagerOptions(
}
function gitLabDuoWorkflowModelCacheProviderId(apiKey: string, config: GitLabDuoWorkflowModelManagerConfig): string {
const scope = [config.baseUrl ?? "", config.namespaceId ?? "", config.projectId ?? "", config.cwd ?? ""].join(
"\u0000",
);
// Mirror the exact inputs `discoverGitLabDuoWorkflowNamespace` keys off: explicit
// namespace/project config OR the same env vars, then the git remote at the
// effective cwd. Built-in discovery leaves the config fields empty, so the env +
// resolved cwd terms are what actually distinguish two workspaces sharing a token.
const namespaceId = config.namespaceId ?? Bun.env.GITLAB_DUO_NAMESPACE_ID ?? "";
const projectId = config.projectId ?? Bun.env.GITLAB_DUO_PROJECT_ID ?? Bun.env.GITLAB_DUO_PROJECT_PATH ?? "";
const cwd = config.cwd ?? process.cwd();
const scope = [config.baseUrl ?? "", namespaceId, projectId, cwd].join("\u0000");
return `gitlab-duo-agent:${Bun.hash(`${apiKey}\u0000${scope}`).toString(36)}`;
}
@@ -655,4 +655,42 @@ describe("GitLab Duo Workflow discovery", () => {
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
it("does not treat a same-host different-port remote as the workspace project", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-gitlab-duo-workflow-"));
try {
await fs.mkdir(path.join(tmpDir, ".git"));
// The configured GitLab is on :8443; the remote points at the same hostname
// on :9443 — a different GitLab service. It must NOT be accepted as this
// instance's project, so discovery falls through to the group candidate
// instead of querying :8443 for a project path that lives elsewhere.
await fs.writeFile(
path.join(tmpDir, ".git", "config"),
`[remote "origin"]\n\turl = https://gitlab.example.com:9443/group/project.git\n`,
);
const { fetch, calls } = createMockFetch({
projects: {
"group/project": { id: 7, namespace: { rootAncestor: { id: "remote-root" } } },
},
groups: [{ id: "group-root" }],
models: {
"remote-root": availableModels("remote_model"),
"group-root": availableModels("group_model"),
},
});
const selection = await discoverGitLabDuoWorkflowNamespace({
apiKey: TEST_TOKEN,
baseUrl: "https://gitlab.example.com:8443",
cwd: tmpDir,
fetch,
});
// Falls through to the group candidate, never queries the cross-port project.
expect(selection.rootNamespaceId).toBe("group-root");
expect(calls.some(call => call.url.includes("/api/v4/projects/group%2Fproject"))).toBe(false);
} finally {
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
});