Skip to content
52 changes: 52 additions & 0 deletions packages/cli/src/serve/workspace-providers-status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -641,6 +641,58 @@ describe('createWorkspaceProvidersStatusProvider', () => {
expect(result.initialized).toBe(false);
});

it.each([
[
'keeps a port and later email unchanged',
'Cannot reach https://api.example:8443/v1 — contact admin@example.com',
'Cannot reach https://api.example:8443/v1 — contact admin@example.com',
],
[
'strips a password containing an at sign',
'Failed loading provider https://user:p@ssw0rd-tail@broken.example/v1',
'Failed loading provider https://broken.example/v1',
],
[
'keeps an uncredentialed URL with a port byte-identical',
'Cannot reach https://api.example:8443/v1',
'Cannot reach https://api.example:8443/v1',
],
[
'keeps a pathless URL and later email unchanged',
'Cannot reach https://api.example — contact admin@example.com',
'Cannot reach https://api.example — contact admin@example.com',
],
[
'strips credentials from a pathless URL before trailing prose',
'Cannot reach https://user:pass@host.io please contact admin@corp.io',
'Cannot reach https://host.io please contact admin@corp.io',
],
[
'strips multi-word password userinfo from warning URLs',
'Failed loading provider https://user:pa ss word@host.io/v1',
'Failed loading provider https://host.io/v1',
],
[
'strips password with embedded at-sign before host',
'Failed loading provider https://user:p ss@real@host.io/v1',
'Failed loading provider https://host.io/v1',
],
])('%s', async (_name, message, expected) => {
coreMock.throwModelsConfigError = true;
coreMock.modelsConfigErrorMessage = message;
const provider = createWorkspaceProvidersStatusProvider({ env: {} });
await writeUserSettings({
security: { auth: { selectedType: 'openai' } },
modelProviders: {
openai: [{ id: 'model-a', name: 'Model A' }],
},
});

const result = await provider(workspace, true);

expect(result.errors?.[0]?.error).toBe(expected);
});

async function writeUserSettings(settings: Record<string, unknown>) {
await fs.writeFile(
path.join(qwenHome, 'settings.json'),
Expand Down
33 changes: 1 addition & 32 deletions packages/cli/src/serve/workspace-providers-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,6 @@ function resolveApprovalMode(settings: Settings): ApprovalMode {
return ApprovalMode.AUTO;
}

const URL_LIKE_PATTERN = /\b[A-Za-z][A-Za-z\d+.-]*:\/\/[^\s'"`<>]+/g;
const URL_START_PATTERN = /\b[A-Za-z][A-Za-z\d+.-]*:\/\//g;

function sanitizeProviderWarning(warning: string): string {
Expand All @@ -273,7 +272,7 @@ function sanitizeProviderWarning(warning: string): string {

const segmentEnd = findUrlSegmentEnd(warning, next.index, next.marker);
const segment = warning.slice(next.index, segmentEnd);
result += sanitizeProviderWarningSegment(segment, next.marker.length);
result += sanitizeProviderBaseUrl(segment);

index = segmentEnd;
next = findNextUrlStart(warning, index);
Expand Down Expand Up @@ -308,36 +307,6 @@ function findUrlSegmentEnd(
return Math.min(lineEnd, nextUrl?.index ?? value.length);
}

function sanitizeProviderWarningSegment(
segment: string,
markerLength: number,
): string {
const at = segment.indexOf('@', markerLength);
if (
at !== -1 &&
hasCredentialPrefix(segment, markerLength, at) &&
segment[at + 1] !== undefined &&
/[A-Za-z0-9.[\]-]/.test(segment[at + 1])
) {
return `${segment.slice(0, markerLength)}${segment.slice(at + 1)}`;
}

return segment.replace(URL_LIKE_PATTERN, (url) =>
sanitizeProviderBaseUrl(url),
);
}

function hasCredentialPrefix(
segment: string,
markerLength: number,
at: number,
): boolean {
const colon = segment.indexOf(':', markerLength);
if (colon === -1 || colon > at) return false;
const username = segment.slice(markerLength, colon);
return !/[/?#\s'"`<>]/.test(username);
}

function buildCurrent(
authType: AuthType | undefined,
modelId: string | undefined,
Expand Down
26 changes: 26 additions & 0 deletions packages/cli/src/utils/acpModelUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,32 @@ describe('acpModelUtils', () => {
['https://user:p?x@api.example/v1', 'https://api.example/v1'],
['https://user:p#x@api.example/v1', 'https://api.example/v1'],
['https://user:secret@api.example', 'https://api.example'],
[
'https://api.example — contact admin@example.com',
'https://api.example — contact admin@example.com',
],
[
'https://user:pass@host.io please contact admin@corp.io',
'https://host.io please contact admin@corp.io',
],
Comment on lines +256 to +259

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R2-3: The stripping side of the new fall-through is unpinned by tests. In the added row 'https://user:pass@host.io please contact admin@corp.io' the credential @ sits inside the whitespace-truncated span, so the strip happens in the ordinary try-branch without ever reaching the fall-through; 'https://api.example — contact admin@example.com' reaches the fall-through only to exercise its no-op bailout. (The pre-existing row 'https://user:p ass@api.example/v1' does pin the path-bearing space-password strip — the pathless variant is what is missing.)

Concrete cost: the documented recovery case in its pathless form ('https://user:pa ss@host.io''https://host.io', confirmed working by probe) is pinned by nothing, and none of the R2-1/R2-2 broken shapes were ever put in front of the suite — the whole regression family ships green (58/58 pass while probes show wrong outputs for the unpinned shapes).

Suggested fix: add rows such as ['https://user:pa ss@host.io', 'https://host.io'] (pins the documented recovery) plus the R2-1/R2-2 shapes with their intended stripped/preserved expectations.

中文说明

新 fall-through 的"执行剥离"一侧没有测试固定。新增用例 'https://user:pass@host.io please contact admin@corp.io' 的凭据 @ 位于空白截断范围内,剥离发生在普通 try 分支,根本不会到达 fall-through;'https://api.example — contact admin@example.com' 到达 fall-through 只是触发其"原样返回"的退出分支。(已有用例 'https://user:p ass@api.example/v1' 确实固定了带路径的空白密码剥离 —— 缺的是无路径变体。)

具体代价:注释中记录的恢复用例的无路径形态('https://user:pa ss@host.io''https://host.io',探针确认当前行为正确)没有任何测试固定;R2-1/R2-2 的损坏形态也从未进入测试套件 —— 整组回退会在测试全绿的情况下合入(58/58 通过,而探针显示这些未固定形态的输出是错误的)。

建议修复:补充用例行,例如 ['https://user:pa ss@host.io', 'https://host.io'](固定注释记录的恢复行为),并为 R2-1/R2-2 的形态补上期望的剥离/保留断言。

— qwen3.8-max via Qwen Code /review (v0.21.6)

[
'https://host bad:x/v1 — contact admin@corp.io',
'https://host bad:x/v1 — contact admin@corp.io',
],
['https://user:pa ss@host.io', 'https://host.io'],
['https://user:12 34@host.io', 'https://host.io'],
Comment on lines +264 to +265

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R4-3: The parsed.password || disjunct of the new beyond-authority strip branch (acpModelUtils.ts:195) is unpinned — every password-bearing row in the added table has a single-word gap that /^\s*\S+$/ alone accepts. — Failure scenario: mutation probe — deleting parsed.password || leaves 67/67 tests green while sanitizeProviderBaseUrl('https://user:pa ss word@host.io') regresses from https://host.io to the unchanged credential-bearing input; the same mutant also silently removes the port+prose corruption noted in the R4-7 discussion. The untested disjunct is simultaneously the only handler of multi-word passwords and the cause of that corruption — neither direction is pinned.

Suggested change
['https://user:pa ss@host.io', 'https://host.io'],
['https://user:12 34@host.io', 'https://host.io'],
['https://user:pa ss@host.io', 'https://host.io'],
['https://user:12 34@host.io', 'https://host.io'],
['https://user:pa ss word@host.io', 'https://host.io'],
中文说明

新的“authority 之外剥离”分支(acpModelUtils.ts:195)中的 parsed.password || 分支没有测试固定 —— 新增表格中所有含密码的用例都是单词 gap,仅靠 /^\s*\S+$/ 即可通过。—— 失败场景:变异探针 —— 删除 parsed.password || 后 67/67 测试仍全绿,而 sanitizeProviderBaseUrl('https://user:pa ss word@host.io')https://host.io 回退为原样返回(凭据完整保留);同一变异还会悄悄消除 R4-7 讨论中提到的“端口+散文”破坏。该未被测试固定的分支既是多词密码的唯一处理器,又是该破坏的成因 —— 两个方向都没有被测试固定。

— qwen3.8-max via Qwen Code /review (v0.21.8)

['https://user name:pass@host.io', 'https://host.io'],
['https://user name@host.io', 'https://host.io'],
['https://us er@host', 'https://host'],
[
'https://user:pa ss@host.io please contact admin@corp.io',
'https://host.io please contact admin@corp.io',
],
['https://user:p w@host.io/a@b', 'https://host.io/a@b'],
['https://user:p ss@real@host.io', 'https://host.io'],
['https://user @host.io', 'https://host.io'],
['https://foo bar baz@corp.io', 'https://corp.io'],
['https://user:pa ss word@host.io/v1', 'https://host.io/v1'],
])('sanitizes provider base URL credentials for %s', (input, expected) => {
expect(sanitizeProviderBaseUrl(input)).toBe(expected);
});
Expand Down
114 changes: 99 additions & 15 deletions packages/cli/src/utils/acpModelUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,41 +171,117 @@ export function sanitizeProviderBaseUrl(baseUrl: string): string {
const stripAt = (at: number) =>
`${baseUrl.slice(0, authorityStart)}${baseUrl.slice(at + 1)}`;
const authorityEnd = findAuthorityEnd(baseUrl, authorityStart);
const authorityAt = baseUrl
.slice(authorityStart, authorityEnd)
.lastIndexOf('@');
const authoritySlice = baseUrl.slice(authorityStart, authorityEnd);
const authorityAt = authoritySlice.lastIndexOf('@');
const authorityAtIndex =
authorityAt === -1 ? -1 : authorityStart + authorityAt;

try {
const parsed = new URL(baseUrl);
if (parsed.username || parsed.password) {
return authorityAtIndex >= authorityStart
? stripAt(authorityAtIndex)
: baseUrl;
if (!(parsed.username || parsed.password)) {
return baseUrl;
}
if (authorityAtIndex >= authorityStart) {
return stripAt(authorityAtIndex);
}
if (shouldExtendUserInfoSearch(authoritySlice, parsed)) {
const userInfoAt = findExtendedUserInfoAt(baseUrl, authorityEnd);
if (userInfoAt !== -1) {
return stripAt(userInfoAt);
}
}
return baseUrl;
} catch {
if (authorityAtIndex >= authorityStart) {
return stripAt(authorityAtIndex);
}
}

const fallbackAt = findUnescapedUserInfoFallbackAt(
baseUrl,
authorityStart,
authorityEnd,
);
return fallbackAt === -1 ? baseUrl : stripAt(fallbackAt);
const fallbackAt = findUnescapedUserInfoFallbackAt(
baseUrl,
authorityStart,
authorityEnd,
);
Comment on lines +200 to +204

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] R2-2: The promoted fallback's unbounded baseUrl.lastIndexOf('@') is now reachable from the parse-success path. A whitespace-in-password URL followed by any later @ after the authority (a contact email in trailing prose, or an @ in the path) is stripped at the LATER @ — destroying the real host, path, and prose. This is the same over-strip class this PR exists to fix, reintroduced for the space-in-password variant via the new routing.

Failure scenario (A/B-probed end-to-end through the status provider): warning Failed loading provider https://user:pa ss@host.io please contact admin@corp.io → merge base produced …https://host.io please contact admin@corp.io, this PR produces Failed loading provider https://corp.io — the host is replaced by the contact email's domain and the contact text is deleted; https://user:p w@host.io/a@bhttps://b (regresses on both the direct and the warning paths). The surfaced error names the wrong domain as the unreachable provider — actively misleading diagnostics from the function whose purpose is safe display.

Suggested fix: bound the fallback's search — strip at the first @ at or after authorityEnd (baseUrl.indexOf('@', authorityEnd)) instead of the last @ in the whole string, and/or reserve the whole-string recovery for the parse-failure path only. Probes confirmed this preserves the port/email guard cases while restoring the host for these shapes.

中文说明

被提升为公共路径的 fallback 中无界的 baseUrl.lastIndexOf('@') 现在可以从解析成功路径到达。密码含空白的 URL 后面如果跟着 authority 之后的其他 @(后续文字中的联系邮箱,或路径中的 @),会在后面那个 @ 处剥离 —— 真实的 host、路径和说明文字全部被破坏。这正是本 PR 要修复的过度截取类别,经由新的路由在"密码含空白"变体上被重新引入。

失败场景(已通过 status provider 端到端 A/B 探针验证):warning Failed loading provider https://user:pa ss@host.io please contact admin@corp.io → merge base 输出 …https://host.io please contact admin@corp.io,本 PR 输出 Failed loading provider https://corp.io —— host 被联系邮箱的域名替换,联系文字被删除;https://user:p w@host.io/a@bhttps://b(直接调用路径和 warning 路径均回退)。对外展示的错误会把错误的域名说成不可达的 provider —— 在一个以安全展示为目的的函数里产生误导性诊断。

建议修复:限定 fallback 的搜索范围 —— 在 authorityEnd 之后的第一个 @baseUrl.indexOf('@', authorityEnd))处剥离,而不是整个字符串的最后一个 @;或者只把全串恢复逻辑保留在解析失败路径。探针确认这样既保留端口/邮箱守卫用例的行为,又能恢复这些形态下的 host。

— qwen3.8-max via Qwen Code /review (v0.21.6)

return fallbackAt === -1 ? baseUrl : stripAt(fallbackAt);
}

function shouldExtendUserInfoSearch(
authoritySlice: string,
parsed: URL,
): boolean {
Comment on lines +208 to +211

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] R5-2: [certifies-falsely] [regression] Over-strip: prose after a URL keeps being read as userinfo, so warnings are rewritten to the contact email's domain — an unbounded surface this family has exposed in every round since round 1.

Warning-level A/B at the exact surface this diff changed: Cannot reach https://localhost — contact admin@example.com becomes Cannot reach https://example.com at this head while the merge base kept it byte-identical; Cannot reach https://api.example — error at 10:30, contact admin@example.com becomes Cannot reach https://example.com (base kept it) — a colon anywhere in the prose makes WHATWG report a password, which bypasses the dotted-host gate entirely; and R4-2's colon variant Cannot reach https://host Contact:admin@corp.io still becomes Cannot reach https://corp.io where the base kept it. The displayed error names a host that was never the provider and deletes the contact instruction — misleading diagnostics from the function whose purpose is safe display. The round-4 fix closed only dotted-host prose; the pre-existing siblings http://localhost:11434 — contact ops@corp.io and the https://api.example:v1[/x] — contact admin@example.com pair behave the same on both arms, and with the R5-1 candidate fix applied the localhost shape is still destroyed — per-entrance patching does not close this surface. Close it structurally: for spans new URL parses, reconstruct the output from the parser's own userinfo/host split only when the pre-@ text is provably userinfo (e.g. a whitespace-only gap), and for free-text segments that do not parse cleanly fail closed (redact the whole URL span) instead of performing indexOf-surgery on prose — the pattern getRouteEndpointIdentity in this same file already uses.

Witness:

W1 BASE: 'Cannot reach https://localhost — contact admin@example.com' -> unchanged
W1 PR:   same -> 'Cannot reach https://example.com'
W2 BASE: 'Cannot reach https://api.example — error at 10:30, contact admin@example.com' -> unchanged
W2 PR:   same -> 'Cannot reach https://example.com'
R4-2 colon variant PR: Expected 'Cannot reach https://host Contact:admin@corp.io'
                       Received 'Cannot reach https://corp.io' (BASE arm passes)

The fix must keep multi-word credentials stripping — ['https://user:pa ss word@host.io/v1', 'https://host.io/v1'] (packages/cli/src/utils/acpModelUtils.test.ts:277) — and the pinned keep Cannot reach https://api.example:8443/v1 — contact admin@example.com (packages/cli/src/serve/workspace-providers-status.test.ts, ~line 646); any port/digit heuristic must reconcile with the user:12 userinfo slice, which is also token:digits. Please add keep-rows ['https://localhost — contact admin@example.com', …] and ['https://api.example — error at 10:30, contact admin@example.com', …] (both RED at this head) to both test files, then remove your guard and confirm they go red again.

中文说明

过度截取:URL 之后的散文仍被当作用户信息,warning 被改写为联系邮箱的域名 —— 该家族自第 1 轮起每轮都暴露新的入口,是无界表面。

在本 diff 改动的表面上做 warning 级 A/B:Cannot reach https://localhost — contact admin@example.com 在本 head 变为 Cannot reach https://example.com,merge base 逐字节保留;Cannot reach https://api.example — error at 10:30, contact admin@example.com 变为 Cannot reach https://example.com(base 保留)—— 散文中任意位置的冒号都会让 WHATWG 报出 password,从而完全绕过点号 host 守卫;R4-2 的冒号变体 Cannot reach https://host Contact:admin@corp.io 仍变为 Cannot reach https://corp.io(base 保留)。对外展示的错误指向一个从未是 provider 的域名并删除联系方式 —— 在一个以安全展示为目的的函数里产生误导性诊断。第 4 轮的修复只封闭了“点号 host + 散文”一种形态;http://localhost:11434 — contact ops@corp.iohttps://api.example:v1[/x] — contact admin@example.com 等既有兄弟形态在两侧表现相同,且在 R5-1 的候选修复之上 localhost 形态仍被破坏 —— 逐入口修补无法封闭该表面。请结构性关闭:对 new URL 可解析的片段,仅当 @ 之前的文本可证明是 userinfo(例如纯空白 gap)时按 parser 自身的 userinfo/host 切分重建输出;对无法干净解析的自由文本片段则失败关闭(整个 URL 片段打码),而不是对散文做 indexOf 手术 —— 同文件的 getRouteEndpointIdentity 已在用这一模式。

修复必须保持多词凭据继续被剥离 —— ['https://user:pa ss word@host.io/v1', 'https://host.io/v1'](packages/cli/src/utils/acpModelUtils.test.ts:277)—— 以及固定保留用例 Cannot reach https://api.example:8443/v1 — contact admin@example.com(packages/cli/src/serve/workspace-providers-status.test.ts 约 646 行);任何端口/数字启发式都必须与 user:12 这种同为 token:数字 的 userinfo 片段兼容。请在两个测试文件中补充保留用例 ['https://localhost — contact admin@example.com', …]['https://api.example — error at 10:30, contact admin@example.com', …](本 head 均为红),然后移除你的守卫并确认它们再次变红。

— qwen3.8-max via Qwen Code /review (v0.22.3)

if (parsed.password) {
return true;
}
Comment on lines +212 to +214

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R4-3: The parsed.password disjunct is still unpinned at this head — every password-bearing row added since round 4 has a colon in its whitespace-truncated authority slice, so the colon branch alone carries them.

Still stands from round 4. Mutation probe at this head: deleting parsed.password || from the early-return guard (if (!(parsed.username || parsed.password))if (!parsed.username)) leaves all 73 tests in the two suites green, and the mutant is live — discriminator https://:pass@host.io returns unchanged under the mutant while intact code returns https://host.io. The same unpinned disjunct shape sits in shouldExtendUserInfoSearch's if (parsed.password) branch (this thread), whose inputs are exactly the prose-password shapes R5-2 names, so neither direction is pinned. Add rows that only the password disjunct can carry: ['https://:pass@host.io', 'https://host.io'] plus a prose-password shape whose truncated slice has no colon, with its intended stripped/preserved expectation pinned deliberately. The new rows must go red when the disjunct is deleted from either gate — the https://:pass@host.io discriminator is exactly that test.

Witness:

Mutant (parsed.password removed): 73/73 tests pass — none of the password rows fail.
Discriminator: 'https://:pass@host.io' -> unchanged under mutant;
               intact HEAD -> 'https://host.io'.
中文说明

parsed.password 分支在本 head 仍然没有测试固定 —— 第 4 轮之后新增的所有含密码用例,其空白截断后的 authority 片段都含冒号,仅靠冒号分支即可通过。

第 4 轮的结论仍然成立。本 head 变异探针:从早退守卫中删除 parsed.password || if (!(parsed.username || parsed.password))if (!parsed.username))后,两个测试文件 73/73 全绿;变异体是活的 —— 判别输入 https://:pass@host.io 在变异体下原样返回,完整代码输出 https://host.io。同一未固定分支也存在于 shouldExtendUserInfoSearchif (parsed.password)(本条所在线),其输入正是 R5-2 点名的“散文密码”形态,两个方向都没有被测试固定。请补充只有 password 分支能承载的用例:['https://:pass@host.io', 'https://host.io'],以及一个截断片段不含冒号的散文密码形态,并明确固定其剥离/保留期望。新用例必须在从任一处删除该分支时变红 —— https://:pass@host.io 判别输入正是该测试。

— qwen3.8-max via Qwen Code /review (v0.22.3)

if (authoritySlice.includes(':')) {
return true;
}
// A dotted token without ':' is a complete host; trailing prose may follow.
return !(authoritySlice.includes('.') && !authoritySlice.includes(':'));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R5-4: The !authoritySlice.includes(':') clause in this final return is always true — the colon case already early-returned two lines above, so the expression is exactly return !authoritySlice.includes('.');, and the doubled negation hides the rule the comment states.

A maintainer investigating colon-bearing authorities (a truncated user:pa… slice or host:port) reads this return and reasons about a colon case that is unreachable at that line, or edits the clause believing it is load-bearing — in a credential-stripping function. A routing probe over the 18 test-table inputs shows five reach this final return, every one with a colon-free slice, and the real function produced the expected outputs for all of them.

Witness:

Probe: 5/18 table inputs reach the final return — 'https://api.example — contact admin@example.com',
'https://user name@host.io', 'https://us er@host', 'https://user @host.io', 'https://foo bar baz@corp.io'
— every reaching slice colon-free; outputs all as pinned.
Suggested change
return !(authoritySlice.includes('.') && !authoritySlice.includes(':'));
return !authoritySlice.includes('.');
中文说明

最终返回里的 !authoritySlice.includes(':') 永远为真 —— 含冒号的情况已在上方两行提前返回,该表达式实际等价于 return !authoritySlice.includes('.');,双重否定掩盖了注释所描述的规则。维护者若调查含冒号的 authority(截断的 user:pa… 片段或 host:port),会在这行推理一个不可达的冒号分支,或误以为该子句起承重作用 —— 这发生在一个凭据剥离函数里。对 18 个测试表输入的路由探针显示 5 个到达该最终返回,其片段均不含冒号,且真实函数输出全部符合预期。

— qwen3.8-max via Qwen Code /review (v0.22.3)

}

function findExtendedUserInfoAt(baseUrl: string, authorityEnd: number): number {
const terminator = baseUrl.charAt(authorityEnd);
if (terminator === '/' || terminator === '?' || terminator === '#') {
return findLastAtAfter(baseUrl, authorityEnd);
}
return findHostAtBeforePathOrProse(baseUrl, authorityEnd);
}

function findHostAtBeforePathOrProse(
baseUrl: string,
authorityEnd: number,
): number {
const pathStart = findPathDelimiterStart(baseUrl, authorityEnd);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R5-5: findPathDelimiterStart's parameter is named authorityStart, but this — its only call site — passes the authority's END, while the sibling findAuthorityEnd(baseUrl, authorityStart) takes a genuine start and contains a verbatim copy of the same delimiter block: the same parameter name means start in one helper and receives an end in the other.

A maintainer reusing the helper as its name and sibling suggest — from a real authority start, e.g. when de-duplicating the delimiter block per R5-3 — gets back the authority terminator itself instead of the path start, so any span computed from that "end" collapses and credential stripping silently misbehaves or no-ops. Rename the parameter to from (or searchStart); the signature at lines 249-252 and this sole call site are the only two touch points.

Witness:

witness: not run — static call-site fact; signature at lines 249-252 and this sole
call site quoted at HEAD; the misuse outcome follows from the quoted bodies.
中文说明

findPathDelimiterStart 的参数名为 authorityStart,但此处(唯一调用点)传入的是 authority 的终点;而同族的 findAuthorityEnd(baseUrl, authorityStart) 接收的是真正的起点并包含同一分隔符块的逐字节副本 —— 同一个参数名在一个辅助函数里表示起点,在另一个里接收的却是终点。维护者若按名称与同族函数的暗示复用该函数(例如按 R5-3 去重时从真正的 authority 起点调用),会得到 authority 终止符本身而非路径起点,基于该“终点”计算的范围会塌缩,凭据剥离会悄悄出错或空转。请将参数改名为 from(或 searchStart);只需改 249-252 行的签名与本调用点两处。

— qwen3.8-max via Qwen Code /review (v0.22.3)

let proseEnd = baseUrl.length;
for (let i = authorityEnd + 1; i < baseUrl.length; i++) {
if (
/\s/.test(baseUrl.charAt(i)) &&
baseUrl.indexOf('@', authorityEnd) < i
Comment on lines +238 to +239

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R5-6: baseUrl.indexOf('@', authorityEnd) in this loop is loop-invariant — neither baseUrl nor authorityEnd depends on i — yet it is recomputed for every whitespace character scanned, making the prose scan O(k·(n − authorityEnd)) instead of O(n).

A warning segment whose post-authority region holds k whitespace characters before the first @ re-runs an O(n − authorityEnd) scan k times. This is a cold path over short strings, so the practical magnitude is small; the fix is a one-line hoist:

const firstAtAfterAuthority = baseUrl.indexOf('@', authorityEnd);
for (let i = authorityEnd + 1; i < baseUrl.length; i++) {
  if (/\s/.test(baseUrl.charAt(i)) && firstAtAfterAuthority < i) {
    proseEnd = i;
    break;
  }
}

Witness:

witness: not run — invariance is a static data-flow fact quoted from the loop body;
a timing measurement cannot discriminate hoisted vs unhoisted semantics.
中文说明

本循环中的 baseUrl.indexOf('@', authorityEnd) 是循环不变量 —— baseUrlauthorityEnd 都不依赖 i —— 却按扫描到的每个空白字符重复计算,使散文扫描从 O(n) 退化为 O(k·(n − authorityEnd))。authority 之后、第一个 @ 之前含 k 个空白的 warning 会把 O(n − authorityEnd) 的扫描重复 k 次。这是短字符串上的冷路径,实际影响很小;修复为一行外提(见上方代码)。

— qwen3.8-max via Qwen Code /review (v0.22.3)

) {
proseEnd = i;
break;
}
}
const searchEnd = Math.min(pathStart, proseEnd);
return findLastAtBefore(baseUrl, authorityEnd, searchEnd);
}

function findPathDelimiterStart(
baseUrl: string,
authorityStart: number,
): number {
Comment on lines +249 to +252

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R5-3: findPathDelimiterStart re-implements, byte-identically, the first-of / ? # scan that findAuthorityEnd (~line 297) already contains in this same file — security-relevant boundary logic now lives in two copies, and this diff itself demonstrates the drift pressure: one copy gained whitespace termination, the other did not.

A future authority-terminator change applied to one copy only makes sanitizeProviderBaseUrl's authority span and findHostAtBeforePathOrProse's after-authority search disagree about where the path starts for the same input, so credential stripping silently diverges between a bare base URL and the same URL embedded in a provider warning — the exact drift this PR exists to eliminate. Compute the delimiter end once and keep the whitespace difference where it belongs:

function findAuthorityEnd(baseUrl: string, authorityStart: number): number {
  const end = findPathDelimiterStart(baseUrl, authorityStart);
  for (let i = authorityStart; i < end; i++) {
    if (/\s/.test(baseUrl.charAt(i))) return i;
  }
  return end;
}

Witness:

witness: not run — static duplication claim; both bodies quoted byte-identical at HEAD
(findPathDelimiterStart lines 249-261 are the first eight lines of findAuthorityEnd
lines 297-304); no behavior to execute without reimplementing both blocks.

The whitespace difference is deliberate and must survive the dedup: findHostAtBeforePathOrProse searches past intervening prose whitespace per const searchEnd = Math.min(pathStart, proseEnd); (packages/cli/src/utils/acpModelUtils.ts:245) — making pathStart stop at whitespace would cut the search before the @ in inputs like https://user:pa ss@host.io/v1 and leak the credential. The pinned rows ['https://user:pa ss word@host.io/v1', 'https://host.io/v1'] and ['https://user:p?x@api.example/v1', 'https://api.example/v1'] in packages/cli/src/utils/acpModelUtils.test.ts pin both sides of the difference — after the dedup, remove the whitespace scan (or make findPathDelimiterStart whitespace-aware) and confirm those rows go red.

中文说明

findPathDelimiterStartfindAuthorityEnd(约第 297 行)中已有的“取 / ? # 最小值”扫描逐字节重复 —— 安全相关的边界逻辑现在有两份,且本 diff 本身就展示了漂移压力:其中一份加了空白终止符,另一份没有。

将来若只修改其中一份的 authority 终止符,sanitizeProviderBaseUrl 的 authority 范围与 findHostAtBeforePathOrProse 的 authority 后搜索就会对同一路径起点判断不一致,凭据剥离会在“裸 base URL”与“嵌在 provider warning 中的同一 URL”之间悄悄分叉 —— 正是本 PR 要消除的那种漂移。建议把分隔符终点的计算合并为一处,并把空白差异放到它应在的位置(见上方代码)。

该空白差异是有意的,去重时必须保留:findHostAtBeforePathOrProse 依据 const searchEnd = Math.min(pathStart, proseEnd);(packages/cli/src/utils/acpModelUtils.ts:245)需要越过散文中的空白继续搜索 —— 若让 pathStart 停在空白处,https://user:pa ss@host.io/v1 这类输入的搜索会在 @ 之前被截断,导致凭据泄露。固定用例 ['https://user:pa ss word@host.io/v1', 'https://host.io/v1']['https://user:p?x@api.example/v1', 'https://api.example/v1'] 分别固定了差异的两侧 —— 去重后,移除空白扫描(或让 findPathDelimiterStart 感知空白)并确认这些用例变红。

— qwen3.8-max via Qwen Code /review (v0.22.3)

const slash = baseUrl.indexOf('/', authorityStart);
const query = baseUrl.indexOf('?', authorityStart);
const hash = baseUrl.indexOf('#', authorityStart);
let end = baseUrl.length;
if (slash !== -1) end = Math.min(end, slash);
if (query !== -1) end = Math.min(end, query);
if (hash !== -1) end = Math.min(end, hash);
return end;
}

function findLastAtBefore(baseUrl: string, start: number, end: number): number {
const at = baseUrl.slice(start, end).lastIndexOf('@');
return at === -1 ? -1 : start + at;
}

function findLastAtAfter(baseUrl: string, from: number): number {
let last = -1;
for (let i = from; i < baseUrl.length; i++) {
if (baseUrl.charAt(i) === '@') {
last = i;
}
}
return last;
}

function findUnescapedUserInfoFallbackAt(
baseUrl: string,
authorityStart: number,
authorityEnd: number,
): number {
const at = baseUrl.lastIndexOf('@');
if (at < authorityStart || authorityEnd >= at) {
const at = findLastAtAfter(baseUrl, authorityEnd);
if (at === -1) {
return -1;
}

Expand All @@ -226,6 +302,14 @@ function findAuthorityEnd(baseUrl: string, authorityStart: number): number {
if (slash !== -1) end = Math.min(end, slash);
if (query !== -1) end = Math.min(end, query);
if (hash !== -1) end = Math.min(end, hash);
// Raw whitespace is never valid in a URL authority. Treat it as a terminator
// so a pathless URL followed by prose (e.g. a contact email) does not pull
// the trailing text into the authority span.
for (let i = authorityStart; i < end; i++) {
if (/\s/.test(baseUrl.charAt(i))) {
return i;
Comment on lines +308 to +310

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] R3-6: This new whitespace terminator truncates authorityEnd, and findUnescapedUserInfoFallbackAt's unchanged guards (~lines 225/229) evaluate against the truncated span — so on the catch path NO strip happens at all for WHATWG-unparseable URLs with whitespace-containing credentials, and they are returned fully intact. Regression: the merge base stripped them. — Failure scenario (probe-verified A/B against merge base d91c661): sanitizeProviderBaseUrl('https://user pa:ss@[bad') → unchanged, full user pa:ss leak (the password's colon sits past the truncated authorityEnd, so the colon > authorityEnd guard bails; base: https://[bad); https://user:12 34@[bad and https://user:12 34@[::1 → unchanged (portCandidate truncates to 12, mistaken for a port; base stripped both). Control: https://user:pa ss@[bad is stripped by both versions — the defect is the guard interaction with whitespace truncation, not the input class. No test covers the catch path with whitespace credentials, which is how this shipped green. Suggested fix: evaluate the fallback guards against the whitespace-agnostic authority extent (min of / ? # or end, computed from authorityStart); whitespace inside portCandidate disqualifies it as a port. Add catch-path regression tests.

中文说明

这个新增的空白终止符会截断 authorityEnd,而 findUnescapedUserInfoFallbackAt 中未改动的守卫(约第 225/229 行)仍按截断后的范围判断 —— 于是在 catch 路径上,WHATWG 无法解析且凭据含空白的 URL 完全不会被剥离,原样返回。这是回退:merge base 会剥掉它们。—— 失败场景(已对 merge base d91c661 做 A/B 探针验证):sanitizeProviderBaseUrl('https://user pa:ss@[bad') → 原样返回,user pa:ss 完整泄露(密码中的冒号位于截断后的 authorityEnd 之后,colon > authorityEnd 守卫直接退出;base 为 https://[bad);https://user:12 34@[badhttps://user:12 34@[::1 → 原样返回(portCandidate 被截成 12,误判为端口;base 两者均剥离)。对照:https://user:pa ss@[bad 两个版本都能剥离 —— 缺陷是守卫与空白截断的相互作用,而非输入类别。没有任何测试覆盖 catch 路径 + 含空白凭据,因此它在测试全绿的情况下合入。建议修复:让 fallback 守卫按"忽略空白的 authority 范围"(从 authorityStart 起取 / ? # 或串尾的最小值)判断;portCandidate 中含空白即不能视为端口。并补充 catch 路径的回归测试。

— qwen3.8-max via Qwen Code /review (v0.21.7)

}
}
Comment on lines +308 to +312

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] R5-1: [certifies-falsely] [regression] Credential leak: four probe-confirmed shapes share one root — the whitespace authority terminator truncates the authority span mid-userinfo, and every branch that re-derives the userinfo boundary afterwards fails on a different corner, so credentials survive sanitization.

The probe matrix at this head, all against the merge base: (a) the in-span lastIndexOf('@') strips at the inner @ when a password contains @ before whitespace — sanitizeProviderBaseUrl('https://user:pa@ss w0rd@host.io') returns https://ss w0rd@host.io where the base returned https://host.io; (b) the extended search's proseEnd bound stops at the whitespace after an inner @, so the strip again lands inside the userinfo — https://us a@b er@host.io returns https://b er@host.io, which re-parses with username b%20er (base: https://host.io); (c) the dotted-token gate suppresses the search entirely even though WHATWG parsing proved userinfo exists — https://john.doe @internal.corp.io is returned unchanged and the username reaches the served workspace status and ACP responses (base: https://internal.corp.io); (d) on the catch path the fallback's colon/port guards evaluate against the truncated span and bail — https://user pa:ss@[bad, https://user:12 34@[bad, https://user:12 34@[::1 and nine further WHATWG-unparseable shapes (e.g. https://user name:pass@broken host, https://user name:secret@host:99999999) are returned fully intact where the merge base stripped every one (deterministic split 19/19 base vs 15/19 PR on the probe file). Every shape reaches every display call site (acpAgent.ts, modelConfigUtils.ts, workspace-providers-status.ts). Shapes (a) and (d) are the carried blockers R3-2 and R3-6, still standing in these shapes; (b) and (c) are new this round. This family has produced a new leaking shape in every round since round 2, and per-entrance patching has not converged it — when parsed.username || parsed.password holds, WHATWG has already proven userinfo exists, so strip at the LAST @ before the first / ? # terminator after authorityStart (or end of string) instead of re-deriving the boundary per branch, and key the catch-path guards to the whitespace-agnostic extent (min of / ? # or end); a username-only unparseable shape is genuinely ambiguous with a prose email — decide it together with the over-strip direction in R5-2.

Witness:

BASE: 'https://user:pa@ss w0rd@host.io' -> 'https://host.io'
PR:   'https://user:pa@ss w0rd@host.io' -> 'https://ss w0rd@host.io'
BASE: 'https://us a@b er@host.io' -> 'https://host.io'
PR:   'https://us a@b er@host.io' -> 'https://b er@host.io'  (re-parse: username 'b%20er')
BASE: 'https://john.doe @internal.corp.io' -> 'https://internal.corp.io'
PR:   'https://john.doe @internal.corp.io' -> unchanged (leak)
PR probe V-R36: Expected "https://[bad" Received "https://user pa:ss@[bad";
                Expected "https://[bad" Received "https://user:12 34@[bad"
Nine further unparseable shapes: UNCHANGED on PR arm, stripped on BASE arm.
// Direction — in the parse-success arm, when credentials are proven, strip at the
// last '@' before the first '/ ? #' after authorityStart (WHATWG's own userinfo
// separator) instead of the truncated-span heuristics; on the catch arm, evaluate
// the guards against that same whitespace-agnostic extent.

The fix must keep this diff's own prose-keep pins byte-identical — ['https://api.example — contact admin@example.com', …], ['https://user:pa ss@host.io please contact admin@corp.io', 'https://host.io please contact admin@corp.io'] (packages/cli/src/utils/acpModelUtils.test.ts:253-254, ~264) and ['https://host bad:x/v1 — contact admin@corp.io', …] (acpModelUtils.test.ts:261-262); trusting the parser's userinfo split unconditionally or extending the colon search past the first / ? # breaks them. Please add rows ['https://user:pa@ss w0rd@host.io', 'https://host.io'], ['https://us a@b er@host.io', 'https://host.io'], ['https://john.doe @internal.corp.io', 'https://internal.corp.io'], ['https://user pa:ss@[bad', 'https://[bad'] to packages/cli/src/utils/acpModelUtils.test.ts, then prove each branch by removing your fix for one shape at a time and confirming the matching row goes red.

中文说明

凭据泄露:四个经探针确认的形态同根 —— 空白 authority 终止符把 authority 范围从 userinfo 中间截断,之后每个重新推导 userinfo 边界的分支各漏一角,凭据残留在“已清理”的输出里。

探针矩阵(本 head 对 merge base):(a) span 内 lastIndexOf('@') 落在内部 @ 上 —— 密码在空白之前含 @ 时,sanitizeProviderBaseUrl('https://user:pa@ss w0rd@host.io') 输出 https://ss w0rd@host.io(base 为 https://host.io);(b) 扩展搜索的 proseEnd 边界停在内部 @ 之后的空白处,剥离再次落在 userinfo 内部 —— https://us a@b er@host.io 输出 https://b er@host.io,重新解析用户名为 b%20er(base 为 https://host.io);(c) 点号 token 守卫在 WHATWG 已证明存在 userinfo 时仍压制搜索 —— https://john.doe @internal.corp.io 原样返回,用户名进入对外的 workspace status 与 ACP 响应(base 为 https://internal.corp.io);(d) catch 路径上 fallback 的冒号/端口守卫按截断后的范围判断并直接退出 —— https://user pa:ss@[badhttps://user:12 34@[badhttps://user:12 34@[::1 以及另外九个 WHATWG 无法解析的形态(如 https://user name:pass@broken hosthttps://user name:secret@host:99999999)全部原样返回,而 merge base 全部剥离(探针文件确定性对比:base 19/19 对 PR 15/19)。所有形态都能到达全部展示调用点(acpAgent.ts、modelConfigUtils.ts、workspace-providers-status.ts)。形态 (a)、(d) 即原有阻塞项 R3-2、R3-6,在本 head 仍然成立;(b)、(c) 为本轮新发现。该家族自第 2 轮起每轮都出现新的泄露形态,逐入口修补并未收敛 —— 当 parsed.username || parsed.password 成立时,WHATWG 已证明 userinfo 存在:在 authorityStart 之后第一个 / ? # 终止符(或串尾)之前的最后一个 @ 处剥离,而不是逐分支重新推导边界;catch 路径的守卫也应按同一“忽略空白的范围”(/ ? # 或串尾的最小值)判断;仅有用户名且无法解析的形态与“散文邮箱”存在真正的歧义,请与 R5-2 的过度截取方向一并决策。

修复必须保持本 PR 自己固定的“保留散文”用例逐字节不变 —— ['https://api.example — contact admin@example.com', …]['https://user:pa ss@host.io please contact admin@corp.io', 'https://host.io please contact admin@corp.io'](packages/cli/src/utils/acpModelUtils.test.ts:253-254、约 264)以及 ['https://host bad:x/v1 — contact admin@corp.io', …](acpModelUtils.test.ts:261-262);无条件信任 parser 的 userinfo 切分、或把冒号搜索延伸到第一个 / ? # 之后都会破坏这些用例。请补充用例行 ['https://user:pa@ss w0rd@host.io', 'https://host.io']['https://us a@b er@host.io', 'https://host.io']['https://john.doe @internal.corp.io', 'https://internal.corp.io']['https://user pa:ss@[bad', 'https://[bad'],然后逐个移除你对各形态的修复并确认相应用例变红,以此证明每个分支。

— qwen3.8-max via Qwen Code /review (v0.22.3)

return end;
}

Expand Down
Loading