diff --git a/.github/workflows/compliance-close.yml b/.github/workflows/compliance-close.yml index 14e68701e..80f388e63 100644 --- a/.github/workflows/compliance-close.yml +++ b/.github/workflows/compliance-close.yml @@ -9,13 +9,17 @@ on: permissions: contents: read issues: write - pull-requests: write jobs: + # Issues only: the PR half of this mechanism (pr-standards.yml's check-compliance + # job, the only thing that ever applied needs:compliance to a pull request) was + # removed as part of dropping the upstream community-PR-governance workflows this + # fork doesn't use (see .github/workflows/pr-standards.yml's removal). The only + # remaining producer of this label, duplicate-issues.yml, labels issues only. close-non-compliant: runs-on: ubuntu-latest steps: - - name: Close non-compliant issues and PRs after 2 hours + - name: Close non-compliant issues after 2 hours uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 with: script: | @@ -27,18 +31,16 @@ jobs: per_page: 100, }); - if (items.length === 0) { - core.info('No open issues/PRs with needs:compliance label'); + const issues = items.filter((item) => !item.pull_request); + if (issues.length === 0) { + core.info('No open issues with needs:compliance label'); return; } const now = Date.now(); const twoHours = 2 * 60 * 60 * 1000; - for (const item of items) { - const isPR = !!item.pull_request; - const kind = isPR ? 'PR' : 'issue'; - + for (const item of issues) { const { data: comments } = await github.rest.issues.listComments({ owner: context.repo.owner, repo: context.repo.repo, @@ -50,13 +52,11 @@ jobs: const commentAge = now - new Date(complianceComment.created_at).getTime(); if (commentAge < twoHours) { - core.info(`${kind} #${item.number} still within 2-hour window (${Math.round(commentAge / 60000)}m elapsed)`); + core.info(`Issue #${item.number} still within 2-hour window (${Math.round(commentAge / 60000)}m elapsed)`); continue; } - const closeMessage = isPR - ? 'This pull request has been automatically closed because it was not updated to meet our [contributing guidelines](../blob/dev/CONTRIBUTING.md) within the 2-hour window.\n\nFeel free to open a new pull request that follows our guidelines.' - : 'This issue has been automatically closed because it was not updated to meet our [contributing guidelines](../blob/dev/CONTRIBUTING.md) within the 2-hour window.\n\nFeel free to open a new issue that follows our issue templates.'; + const closeMessage = 'This issue has been automatically closed because it was not updated to meet our [contributing guidelines](../blob/main/CONTRIBUTING.md) within the 2-hour window.\n\nFeel free to open a new issue that follows our issue templates.'; await github.rest.issues.createComment({ owner: context.repo.owner, @@ -74,22 +74,13 @@ jobs: }); } catch (e) {} - if (isPR) { - await github.rest.pulls.update({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: item.number, - state: 'closed', - }); - } else { - await github.rest.issues.update({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: item.number, - state: 'closed', - state_reason: 'not_planned', - }); - } + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: item.number, + state: 'closed', + state_reason: 'not_planned', + }); - core.info(`Closed non-compliant ${kind} #${item.number} after 2-hour window`); + core.info(`Closed non-compliant issue #${item.number} after 2-hour window`); } diff --git a/.github/workflows/cz-test.yml b/.github/workflows/cz-test.yml new file mode 100644 index 000000000..cdc9af7e1 --- /dev/null +++ b/.github/workflows/cz-test.yml @@ -0,0 +1,77 @@ +name: cz-test + +# Runs cz-cli's OWN unit tests and typecheck. Distinct from `test.yml`, which +# only covers upstream opencode packages (turbo.json's `test` task names +# opencode/@opencode-ai/{core,app,ui,session-ui} exclusively — cz-cli, +# clickzetta-sdk and clickzetta-ai-gateway are not in that list and are never +# exercised there) and which is stuck queued forever on this fork because +# `blacksmith-*` runners were never provisioned for clickzetta/cz-cli. This +# workflow uses GitHub-hosted runners so it actually executes. + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - name: Setup Bun + uses: ./.github/actions/setup-bun + + - name: Typecheck cz-cli + working-directory: packages/cz-cli + run: bun run typecheck + + - name: Run cz-cli unit tests + working-directory: packages/cz-cli + # test script runs with --isolate: several suites (e.g. + # analytics-agent-session-commands.test.ts) mock.module() their own src, + # which bun's default shared-process test run leaks across files sharing + # that process — a later network-boundary suite (task-condition-flow, + # task-merge) that expects the REAL module then reads this file's fixture + # data instead. --isolate gives each file its own global object so a + # leaked mock cannot reach another file at all. + # + # analytics-agent-session-commands.test.ts ALSO restores its mocks in its + # own afterAll (see the comment there). That is not redundant with + # --isolate: it fixes the one suite that leaked, while --isolate is the + # unconditional guarantee for every suite that has not been fixed the same + # way, or will not remember to be. Keep both — the per-file fix is cheap + # insurance against exactly this suite regressing if --isolate is ever + # removed for its cost (a fresh global per file is slower, and it can also + # hide a REAL cross-file coupling that would still bite in the shipped + # binary, where every test file's module cache is shared). + # + # Intent, confirmed: --isolate is the PRIMARY guarantee for the whole + # suite (every file, not just the one known leaker), accepted as a + # change to the shared local/CI test contract, not a CI-only knob. The + # afterAll restore and the two test-only resets added alongside it + # (clearUnservedHostForTest, clearUserNameCacheForTest in + # tui-quota-data.ts) are deliberately kept as belt-and-suspenders on top + # of it, per the reasoning above and in each of those call sites. + run: bun run test + + # UPSTREAM-PATCHES.md's re-baseline checklist points at these three files + # as the enforcement for their respective ledger entries (7, 9), but they + # live inside upstream packages and are only named by test.yml's + # @opencode-ai/core#test / opencode#test turbo tasks — the workflow stuck + # queued forever on unprovisioned blacksmith-* runners. Run them directly + # here so the ledger's claim is actually backed by a green check on this + # fork, without pulling every upstream package's suite into this workflow. + - name: Run cz-owned tests inside upstream packages + run: | + (cd packages/core && bun test test/model-selection.test.ts) + (cd packages/opencode && bun test test/provider/clickzetta-discovery.test.ts test/provider/clickzetta-context-limit.test.ts) diff --git a/.github/workflows/pr-management.yml b/.github/workflows/pr-management.yml deleted file mode 100644 index b6aa4e589..000000000 --- a/.github/workflows/pr-management.yml +++ /dev/null @@ -1,95 +0,0 @@ -name: pr-management - -on: - pull_request_target: - types: [opened] - -jobs: - check-duplicates: - runs-on: blacksmith-4vcpu-ubuntu-2404 - permissions: - contents: read - pull-requests: write - steps: - - name: Checkout repository - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - with: - fetch-depth: 1 - - - name: Check team membership - id: team-check - run: | - LOGIN="${{ github.event.pull_request.user.login }}" - if [ "$LOGIN" = "opencode-agent[bot]" ] || grep -qxF "$LOGIN" .github/TEAM_MEMBERS; then - echo "is_team=true" >> "$GITHUB_OUTPUT" - echo "Skipping: $LOGIN is a team member or bot" - else - echo "is_team=false" >> "$GITHUB_OUTPUT" - fi - - - name: Setup Bun - if: steps.team-check.outputs.is_team != 'true' - uses: ./.github/actions/setup-bun - - - name: Install dependencies - if: steps.team-check.outputs.is_team != 'true' - run: bun install - - - name: Install opencode - if: steps.team-check.outputs.is_team != 'true' - run: curl -fsSL https://opencode.ai/install | bash - - - name: Build prompt - if: steps.team-check.outputs.is_team != 'true' - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - PR_NUMBER: ${{ github.event.pull_request.number }} - run: | - { - echo "Check for duplicate PRs related to this new PR:" - echo "" - echo "CURRENT_PR_NUMBER: $PR_NUMBER" - echo "" - echo "Title: $(gh pr view "$PR_NUMBER" --json title --jq .title)" - echo "" - echo "Description:" - gh pr view "$PR_NUMBER" --json body --jq .body - } > pr_info.txt - - - name: Check for duplicate PRs - if: steps.team-check.outputs.is_team != 'true' - env: - OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - PR_NUMBER: ${{ github.event.pull_request.number }} - run: | - COMMENT=$(bun script/duplicate-pr.ts -f pr_info.txt "Check the attached file for PR details and search for duplicates") - - if [ "$COMMENT" != "No duplicate PRs found" ]; then - gh pr comment "$PR_NUMBER" --body "_The following comment was made by an LLM, it may be inaccurate:_ - - $COMMENT" - fi - - add-contributor-label: - runs-on: ubuntu-latest - permissions: - pull-requests: write - issues: write - steps: - - name: Add Contributor Label - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 - with: - script: | - const isPR = !!context.payload.pull_request; - const issueNumber = isPR ? context.payload.pull_request.number : context.payload.issue.number; - const authorAssociation = isPR ? context.payload.pull_request.author_association : context.payload.issue.author_association; - - if (authorAssociation === 'CONTRIBUTOR') { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - labels: ['contributor'] - }); - } diff --git a/.github/workflows/pr-standards.yml b/.github/workflows/pr-standards.yml deleted file mode 100644 index 06838089d..000000000 --- a/.github/workflows/pr-standards.yml +++ /dev/null @@ -1,351 +0,0 @@ -name: pr-standards - -on: - pull_request_target: - types: [opened, edited, synchronize] - -jobs: - check-standards: - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: write - steps: - - name: Check PR standards - uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 - with: - script: | - const pr = context.payload.pull_request; - const login = pr.user.login; - - // Skip PRs older than Feb 18, 2026 at 6PM EST (Feb 19, 2026 00:00 UTC) - const cutoff = new Date('2026-02-19T00:00:00Z'); - const prCreated = new Date(pr.created_at); - if (prCreated < cutoff) { - console.log(`Skipping: PR #${pr.number} was created before cutoff (${prCreated.toISOString()})`); - return; - } - - // Check if author is a team member or bot - if (login === 'opencode-agent[bot]') return; - const { data: file } = await github.rest.repos.getContent({ - owner: context.repo.owner, - repo: context.repo.repo, - path: '.github/TEAM_MEMBERS', - ref: 'dev' - }); - const members = Buffer.from(file.content, 'base64').toString().split('\n').map(l => l.trim()).filter(Boolean); - if (members.includes(login)) { - console.log(`Skipping: ${login} is a team member`); - return; - } - - const title = pr.title; - - async function addLabel(label) { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pr.number, - labels: [label] - }); - } - - async function removeLabel(label) { - try { - await github.rest.issues.removeLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pr.number, - name: label - }); - } catch (e) { - // Label wasn't present, ignore - } - } - - async function comment(marker, body) { - const markerText = ``; - const { data: comments } = await github.rest.issues.listComments({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pr.number - }); - - const existing = comments.find(c => c.body.includes(markerText)); - if (existing) return; - - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pr.number, - body: markerText + '\n' + body - }); - } - - // Step 1: Check title format - // Matches: feat:, feat(scope):, feat (scope):, etc. - const titlePattern = /^(feat|fix|docs|chore|refactor|test)\s*(\([a-zA-Z0-9-]+\))?\s*:/; - const hasValidTitle = titlePattern.test(title); - - if (!hasValidTitle) { - await addLabel('needs:title'); - await comment('title', `Hey! Your PR title \`${title}\` doesn't follow conventional commit format. - - Please update it to start with one of: - - \`feat:\` or \`feat(scope):\` new feature - - \`fix:\` or \`fix(scope):\` bug fix - - \`docs:\` or \`docs(scope):\` documentation changes - - \`chore:\` or \`chore(scope):\` maintenance tasks - - \`refactor:\` or \`refactor(scope):\` code refactoring - - \`test:\` or \`test(scope):\` adding or updating tests - - Where \`scope\` is the package name (e.g., \`app\`, \`desktop\`, \`opencode\`). - - See [CONTRIBUTING.md](../blob/dev/CONTRIBUTING.md#pr-titles) for details.`); - return; - } - - await removeLabel('needs:title'); - - // Step 2: Check for linked issue (skip for docs/refactor/feat PRs) - const skipIssueCheck = /^(docs|refactor|feat)\s*(\([a-zA-Z0-9-]+\))?\s*:/.test(title); - if (skipIssueCheck) { - await removeLabel('needs:issue'); - console.log('Skipping issue check for docs/refactor/feat PR'); - return; - } - const query = ` - query($owner: String!, $repo: String!, $number: Int!) { - repository(owner: $owner, name: $repo) { - pullRequest(number: $number) { - closingIssuesReferences(first: 1) { - totalCount - } - } - } - } - `; - - const result = await github.graphql(query, { - owner: context.repo.owner, - repo: context.repo.repo, - number: pr.number - }); - - const linkedIssues = result.repository.pullRequest.closingIssuesReferences.totalCount; - - if (linkedIssues === 0) { - await addLabel('needs:issue'); - await comment('issue', `Thanks for your contribution! - - This PR doesn't have a linked issue. All PRs must reference an existing issue. - - Please: - 1. Open an issue describing the bug/feature (if one doesn't exist) - 2. Add \`Fixes #\` or \`Closes #\` to this PR description - - See [CONTRIBUTING.md](../blob/dev/CONTRIBUTING.md#issue-first-policy) for details.`); - return; - } - - await removeLabel('needs:issue'); - console.log('PR meets all standards'); - - check-compliance: - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: write - steps: - - name: Check PR template compliance - uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 - with: - script: | - const pr = context.payload.pull_request; - const login = pr.user.login; - - // Skip PRs older than Feb 18, 2026 at 6PM EST (Feb 19, 2026 00:00 UTC) - const cutoff = new Date('2026-02-19T00:00:00Z'); - const prCreated = new Date(pr.created_at); - if (prCreated < cutoff) { - console.log(`Skipping: PR #${pr.number} was created before cutoff (${prCreated.toISOString()})`); - return; - } - - // Check if author is a team member or bot - if (login === 'opencode-agent[bot]') return; - const { data: file } = await github.rest.repos.getContent({ - owner: context.repo.owner, - repo: context.repo.repo, - path: '.github/TEAM_MEMBERS', - ref: 'dev' - }); - const members = Buffer.from(file.content, 'base64').toString().split('\n').map(l => l.trim()).filter(Boolean); - if (members.includes(login)) { - console.log(`Skipping: ${login} is a team member`); - return; - } - - const body = pr.body || ''; - const title = pr.title; - const isDocsRefactorOrFeat = /^(docs|refactor|feat)\s*(\([a-zA-Z0-9-]+\))?\s*:/.test(title); - - const issues = []; - - // Check: template sections exist - const hasWhatSection = /### What does this PR do\?/.test(body); - const hasTypeSection = /### Type of change/.test(body); - const hasVerifySection = /### How did you verify your code works\?/.test(body); - const hasChecklistSection = /### Checklist/.test(body); - const hasIssueSection = /### Issue for this PR/.test(body); - - if (!hasWhatSection || !hasTypeSection || !hasVerifySection || !hasChecklistSection || !hasIssueSection) { - issues.push('PR description is missing required template sections. Please use the [PR template](../blob/dev/.github/pull_request_template.md).'); - } - - // Check: "What does this PR do?" has real content (not just placeholder text) - if (hasWhatSection) { - const whatMatch = body.match(/### What does this PR do\?\s*\n([\s\S]*?)(?=###|$)/); - const whatContent = whatMatch ? whatMatch[1].trim() : ''; - const placeholder = 'Please provide a description of the issue'; - const onlyPlaceholder = whatContent.includes(placeholder) && whatContent.replace(placeholder, '').replace(/[*\s]/g, '').length < 20; - if (!whatContent || onlyPlaceholder) { - issues.push('"What does this PR do?" section is empty or only contains placeholder text. Please describe your changes.'); - } - } - - // Check: at least one "Type of change" checkbox is checked - if (hasTypeSection) { - const typeMatch = body.match(/### Type of change\s*\n([\s\S]*?)(?=###|$)/); - const typeContent = typeMatch ? typeMatch[1] : ''; - const hasCheckedBox = /- \[x\]/i.test(typeContent); - if (!hasCheckedBox) { - issues.push('No "Type of change" checkbox is checked. Please select at least one.'); - } - } - - // Check: issue reference (skip for docs/refactor/feat) - if (!isDocsRefactorOrFeat && hasIssueSection) { - const issueMatch = body.match(/### Issue for this PR\s*\n([\s\S]*?)(?=###|$)/); - const issueContent = issueMatch ? issueMatch[1].trim() : ''; - const hasIssueRef = /(closes|fixes|resolves)\s+#\d+/i.test(issueContent) || /#\d+/.test(issueContent); - if (!hasIssueRef) { - issues.push('No issue referenced. Please add `Closes #` linking to the relevant issue.'); - } - } - - // Check: "How did you verify" has content - if (hasVerifySection) { - const verifyMatch = body.match(/### How did you verify your code works\?\s*\n([\s\S]*?)(?=###|$)/); - const verifyContent = verifyMatch ? verifyMatch[1].trim() : ''; - if (!verifyContent) { - issues.push('"How did you verify your code works?" section is empty. Please explain how you tested.'); - } - } - - // Check: checklist boxes are checked - if (hasChecklistSection) { - const checklistMatch = body.match(/### Checklist\s*\n([\s\S]*?)(?=###|$)/); - const checklistContent = checklistMatch ? checklistMatch[1] : ''; - const unchecked = (checklistContent.match(/- \[ \]/g) || []).length; - const checked = (checklistContent.match(/- \[x\]/gi) || []).length; - if (checked < 2) { - issues.push('Not all checklist items are checked. Please confirm you have tested locally and have not included unrelated changes.'); - } - } - - // Helper functions - async function addLabel(label) { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pr.number, - labels: [label] - }); - } - - async function removeLabel(label) { - try { - await github.rest.issues.removeLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pr.number, - name: label - }); - } catch (e) {} - } - - const hasComplianceLabel = pr.labels.some(l => l.name === 'needs:compliance'); - - if (issues.length > 0) { - // Non-compliant - if (!hasComplianceLabel) { - await addLabel('needs:compliance'); - } - - const marker = ''; - const { data: comments } = await github.rest.issues.listComments({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pr.number - }); - const existing = comments.find(c => c.body.includes(marker)); - - const body_text = `${marker} - This PR doesn't fully meet our [contributing guidelines](../blob/dev/CONTRIBUTING.md) and [PR template](../blob/dev/.github/pull_request_template.md). - - **What needs to be fixed:** - ${issues.map(i => `- ${i}`).join('\n')} - - Please edit this PR description to address the above within **2 hours**, or it will be automatically closed. - - If you believe this was flagged incorrectly, please let a maintainer know.`; - - if (existing) { - await github.rest.issues.updateComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: existing.id, - body: body_text - }); - } else { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pr.number, - body: body_text - }); - } - - console.log(`PR #${pr.number} is non-compliant: ${issues.join(', ')}`); - } else if (hasComplianceLabel) { - // Was non-compliant, now fixed - await removeLabel('needs:compliance'); - - const { data: comments } = await github.rest.issues.listComments({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pr.number - }); - const marker = ''; - const existing = comments.find(c => c.body.includes(marker)); - if (existing) { - await github.rest.issues.deleteComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: existing.id - }); - } - - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pr.number, - body: 'Thanks for updating your PR! It now meets our contributing guidelines. :+1:' - }); - - console.log(`PR #${pr.number} is now compliant, label removed`); - } else { - console.log(`PR #${pr.number} is compliant`); - } diff --git a/packages/core/src/model-selection.ts b/packages/core/src/model-selection.ts index 2d1e1cd8a..50232c172 100644 --- a/packages/core/src/model-selection.ts +++ b/packages/core/src/model-selection.ts @@ -1,6 +1,8 @@ +//======================== cz-cli change ======================== // cz_change: the TUI's startup model-selection chain, extracted so a CLI command // can answer "which model will `cz-cli agent` actually use?" with the same code -// the TUI runs — not a second copy of the rules that drifts from it. +// the TUI runs — not a second copy of the rules that drifts from it. Whole file +// is cz-owned (see UPSTREAM-PATCHES.md entry 9). // // Previously `cz-cli agent llm show` printed "Default model: automatic (OpenCode // selects at runtime)" whenever config.model was unset. That was wrong twice @@ -99,3 +101,4 @@ export function resolveModelSelection(input: ModelSelectionInput): ResolvedModel if (!modelID) return undefined return { providerID: provider.id, modelID, source: "first" } } +//====================== end cz-cli change ====================== diff --git a/packages/core/test/model-selection.test.ts b/packages/core/test/model-selection.test.ts new file mode 100644 index 000000000..b2522f0bd --- /dev/null +++ b/packages/core/test/model-selection.test.ts @@ -0,0 +1,62 @@ +//======================== cz-cli change ======================== +// cz_change: unit coverage for the extracted model-selection chain (UPSTREAM-PATCHES.md entry 9) +import { describe, expect, test } from "bun:test" +import { parseModelRef, resolveModelSelection, type ModelSelectionProvider } from "@opencode-ai/core/model-selection" + +const providers: ModelSelectionProvider[] = [ + { id: "anthropic", models: { "claude-1": {}, "claude-2": {} } }, + { id: "openai", models: { "gpt-4": {} } }, +] + +describe("parseModelRef", () => { + test("splits on the first slash, keeping the rest as the model id", () => { + expect(parseModelRef("clickzetta/deepseek/deepseek-v4")).toEqual({ + providerID: "clickzetta", + modelID: "deepseek/deepseek-v4", + }) + }) +}) + +describe("resolveModelSelection", () => { + test("tier 1: argsModel wins when it names an existing model", () => { + expect( + resolveModelSelection({ argsModel: "openai/gpt-4", configModel: "anthropic/claude-1", providers }), + ).toEqual({ providerID: "openai", modelID: "gpt-4", source: "args" }) + }) + + test("falls through argsModel to configModel when argsModel names a dead ref", () => { + expect( + resolveModelSelection({ argsModel: "openai/gpt-9-does-not-exist", configModel: "anthropic/claude-1", providers }), + ).toEqual({ providerID: "anthropic", modelID: "claude-1", source: "config" }) + }) + + test("tier 3: newest still-existing entry in recent, skipping dead refs", () => { + expect( + resolveModelSelection({ + recent: [ + { providerID: "openai", modelID: "gpt-3-retired" }, + { providerID: "anthropic", modelID: "claude-2" }, + ], + providers, + }), + ).toEqual({ providerID: "anthropic", modelID: "claude-2", source: "recent" }) + }) + + test("tier 4: first provider's preferred model from providerDefault", () => { + expect( + resolveModelSelection({ providers, providerDefault: { anthropic: "claude-2" } }), + ).toEqual({ providerID: "anthropic", modelID: "claude-2", source: "first" }) + }) + + test("tier 4 without providerDefault falls back to the first provider's first model", () => { + expect(resolveModelSelection({ providers })).toEqual({ providerID: "anthropic", modelID: "claude-1", source: "first" }) + }) + + // The one case this resolver treats as genuinely unresolved, not indeterminate: + // no provider has any model at all. + test("returns undefined only when no provider has any model", () => { + expect(resolveModelSelection({ providers: [{ id: "anthropic", models: {} }] })).toBeUndefined() + expect(resolveModelSelection({ providers: [] })).toBeUndefined() + }) +}) +//====================== end cz-cli change ====================== diff --git a/packages/cz-cli/UPSTREAM-PATCHES.md b/packages/cz-cli/UPSTREAM-PATCHES.md index 13a93bfe5..0166cafaa 100644 --- a/packages/cz-cli/UPSTREAM-PATCHES.md +++ b/packages/cz-cli/UPSTREAM-PATCHES.md @@ -183,7 +183,8 @@ rg -n "cz-cli change" packages/core packages/opencode packages/tui -g '!**/dist/ ### 6. Opt-in error detail at the defect boundary - **File:** `packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts` -- **Marker:** one `//===== cz-cli change =====` banner around the `detail` block. +- **Marker:** two `//===== cz-cli change =====` banners — one around the `Flag` + import, one around the `detail` block. - **Upstream value:** the 500 body carries only `{ message: "Unexpected server error. Check server logs for details.", ref }`. - **What/why:** that boundary swallows every defect. Correct for a server answering @@ -206,6 +207,86 @@ rg -n "cz-cli change" packages/core packages/opencode packages/tui -g '!**/dist/ `CZ_MCP_ARGS='{"prompt":"hi"}' bun test/mcp-call-repro.ts` must name the real cause, not a bare ref. +### 7. ClickZetta dynamic model discovery in the provider loader + +- **File:** `packages/opencode/src/provider/provider.ts` +- **Marker:** two `//===== cz-cli change =====` banners — one wrapping the discovery + block inside the loader (`isClickzettaProvider` through the fallback seed loop), + one wrapping the cz-owned helpers appended after `parseModel` + (`clickzettaModelsUrl` through `buildClickzettaModel`, ending before the + untouched `export const node = LayerNode.make(...)`). +- **Upstream value:** no discovery block in the loader at all, and no + `clickzetta*` helpers after `parseModel` — a provider's model list comes + entirely from the models.dev catalog, with nothing filled in for a private + gateway. +- **What/why:** ClickZetta is a private gateway, so its models aren't in the + models.dev catalog. Discovers them at runtime from the gateway's OpenAI-compatible + `GET {baseURL}/v1/models`, matched on the provider's npm (the file:// specifier the + cz layer rewrites every genuine ClickZetta entry to — see + rewriteProviders/shouldRewriteProvider), not its URL. A provider left with zero + models after discovery is seeded from `CLICKZETTA_FALLBACK_MODELS` rather than left + empty. +- **Why intrusive (no hook):** discovery has to run inside the same loader pass that + merges config providers and applies `isProviderAllowed`, mirroring the built-in + gitlab discoverModels precedent; there is no plugin seam that runs before models + are handed to the TUI. +- **History:** previously marked only with `cz_change:` comments (no banner, not in + this ledger) — exactly the gap this ledger exists to catch, per entry 4's history + note. Banner added; no behavior change. +- **Verify:** `packages/opencode/test/provider/clickzetta-discovery.test.ts` and + `clickzetta-context-limit.test.ts` cover discovery and the context-window table; + both must stay green after a re-baseline. + +### 8. Drop the cached global config on instance dispose + +- **File:** `packages/opencode/src/server/routes/instance/httpapi/handlers/instance.ts` +- **Marker:** one `//===== cz-cli change =====` banner around the `cfg.invalidate()` + call in `dispose`. +- **Upstream value:** `dispose` has no `cfg.invalidate()` call — it disposes the + instance's own resources only, and the process-level config cache is + untouched. +- **What/why:** the global config is cached with `Duration.infinity` on the + PROCESS-level BootstrapRuntime (`config/config.ts` `cachedInvalidateWithTTL`), not + on the instance, so disposing the instance alone left it in place and a rebuild + observed the config as it was at startup. Callers that rewrite a config file and + then dispose to pick it up (the TUI's provider-credential flows) silently got the + stale providers. +- **Why intrusive (no hook):** the cache lives on the process-level runtime; nothing + reachable from the cz layer can invalidate it except at this dispose call site. +- **History:** previously marked only with `cz_change:`, not in this ledger. Banner + added; no behavior change. +- **Verify:** rewrite a global config file, dispose the instance, confirm the + rebuilt instance's provider list reflects the rewrite rather than the one + observed at startup. + +### 9. Model-selection chain extracted for reuse by `cz-cli agent llm show` + +- **File:** `packages/tui/src/context/local.tsx` +- **Marker:** two `//===== cz-cli change =====` banners — one around the + `resolveModelSelection` import, one around the `fallbackModel` memo body. +- **Upstream value:** the four-tier chain is inlined directly in `local.tsx`'s + `fallbackModel` memo; there is no import of `@opencode-ai/core/model-selection` + and no shared implementation for a second caller to reuse. +- **cz files:** `packages/core/src/model-selection.ts` (new, cz-owned — the four-tier + provider/model resolution chain, unit-tested). +- **What/why:** `cz-cli agent llm show` used to print "Default model: automatic + (OpenCode selects at runtime)" whenever `config.model` was unset — wrong, since the + chain's last tier is unconditional and always lands on one concrete model. The + chain lived inline in `local.tsx`'s `fallbackModel` memo; extracted whole to + `@opencode-ai/core/model-selection` so both callers run the same code instead of + `show` guessing with a second copy of the rules. TUI behavior is unchanged — only + the four inputs are now passed explicitly. +- **Why intrusive (no hook):** the chain reads TUI-only reactive state + (`sync.data.config.model`, `modelStore.recent`, `sync.data.provider`, + `sync.data.provider_default`); extracting it required changing the call site that + reads that state, which lives in upstream's `local.tsx`. +- **History:** previously marked only with `cz_change:`, not in this ledger. Banners + added; no behavior change. +- **Verify:** `packages/core/test/model-selection.test.ts` covers the four-tier + precedence directly; goes RED if `resolveModelSelection`'s tier order or + fallback-on-dead-ref behavior regresses. Manually: `cz-cli agent llm show` + names a concrete provider/model with `config.model` unset (never "automatic"). + --- ## HOOK-based customizations (safe — live entirely in the cz layer) @@ -279,6 +360,54 @@ the hooks they depend on still exist in the new upstream. - **Upstream hook to re-verify on re-baseline:** `TuiThreadCommand` is still `$0` and still exposes `command`/`describe`/`builder`/`handler` we can wrap. +### 5. Quota/Profile sidebar sections + +- **cz files:** `packages/cz-cli/src/opencode-plugin/tui-quota.tsx` (and its + `tui-quota-{data,format,runtime}.ts` siblings). +- **Mechanism:** registers on the public `sidebar_content` slot + (`packages/plugin/src/tui.ts`'s `TuiHostSlotMap`), rendering a "Profile" section + (which profile/account/env/instance/workspace the session is connected as) and a + "Quota" section (balance + token usage) at `order: 150` — directly after + upstream's own Context section (`order: 100`) and ahead of MCP/LSP/Todo/Files + (200/300/400/500). +- **Why:** the readout used to share the prompt's top-right corner with the + agent/model/provider labels and got squeezed out at 80 columns; the sidebar has + room and groups it with the Context section reporting the same kind of + per-session usage facts. +- **Upstream hooks to re-verify on re-baseline:** + - `sidebar_content` still exists in `TuiHostSlotMap` and still passes + `props.session_id`. + - It is still an **append** slot (not `single_winner` like + `sidebar_title`/`sidebar_footer` next to it) — otherwise these sections would + displace upstream's Context section instead of composing with it. + - The order values these sections sit between — 100 (context) / 200 (mcp) / 300 + (lsp) / 400 (todo) / 500 (files) — are still what upstream assigns; `order: 150` + is only meaningful relative to them. + - `sidebar_content` is rendered only from + `packages/tui/src/routes/session/sidebar.tsx`: there is no home-route consumer, + so these sections are session-only, and the sidebar auto-opens only at ≥120 + columns (`sidebarVisible` in `packages/tui/src/routes/session/index.tsx`) — + narrower terminals reach it via the toggle. This is upstream's own layout + policy, not something cz controls, and is the deliberate trade for legibility + made in place of the old prompt-corner placement. + - `sidebarVisible`'s FIRST check is `if (session()?.parentID) return false` + (`routes/session/index.tsx`) — a child (subagent) session never renders the + sidebar at ANY width, and the toggle cannot override it (checked after that + early return). So on a child session these sections are gone entirely, not + merely harder to reach — where the old prompt-corner slot rendered the same + on parent and child. Expected/accepted: a subagent turn still spends the + parent's quota and the user returns to the parent session to see it, but this + is upstream's policy, not cz's, and worth re-verifying it still holds. +- **Also new on this feature's network path:** `centralPortalHost`/`portalRead` + (`tui-quota-data.ts`) send the profile's portal token to `api.clickzetta.com`/ + `api.singdata.com` when the profile's own regional host answers an unusable + business code — a host the user never named in profiles.toml. Confirmed + intentional: both hosts answer with the SAME tenant-global data (balance, a + tenant's virtual keys are not per-region facts), the rewrite is pinned to the + two roots actually measured (see `centralPortalHost`'s docstring), and it only + fires when the configured host has already failed to answer usably. No config + escape hatch exists to opt out of the fallback. + --- ## Re-baseline procedure (quick) diff --git a/packages/cz-cli/package.json b/packages/cz-cli/package.json index 8543c30fd..86962bff9 100644 --- a/packages/cz-cli/package.json +++ b/packages/cz-cli/package.json @@ -16,7 +16,7 @@ "dev": "bun --conditions=browser src/main.ts", "build": "bun run script/build.ts", "typecheck": "tsgo --noEmit", - "test": "bun test --timeout 30000", + "test": "bun test --isolate --timeout 30000", "test:e2e": "bun test/e2e.ts", "test:e2e:full": "bun test/e2e-full.ts", "test:e2e:routing": "bun test/e2e-routing.ts", diff --git a/packages/cz-cli/src/bootstrap/profile-env.ts b/packages/cz-cli/src/bootstrap/profile-env.ts index 1844d8265..7542ec4d9 100644 --- a/packages/cz-cli/src/bootstrap/profile-env.ts +++ b/packages/cz-cli/src/bootstrap/profile-env.ts @@ -2,82 +2,54 @@ import fs from "fs" import os from "os" import path from "path" import { parse as parseToml } from "smol-toml" +import { ConnectionEnv } from "../connection/env.js" -const PROFILE_ENV = { - pat: "CZ_PAT", - username: "CZ_USERNAME", - password: "CZ_PASSWORD", - service: "CZ_SERVICE", - protocol: "CZ_PROTOCOL", - instance: "CZ_INSTANCE", - workspace: "CZ_WORKSPACE", - schema: "CZ_SCHEMA", - vcluster: "CZ_VCLUSTER", - accounts_url: "CZ_ACCOUNTS_URL", -} as const +/** profiles.toml field → the `CZ_*` field it travels as. */ +const FIELDS = { + pat: "pat", + username: "username", + password: "password", + service: "service", + protocol: "protocol", + instance: "instance", + workspace: "workspace", + schema: "schema", + vcluster: "vcluster", + accounts_url: "accountsUrl", +} as const satisfies Record -// The credential env vars. These are mutually exclusive ALTERNATIVES (a profile -// authenticates by PAT *or* by username/password), so they must be cleared as a -// group when the active profile changes — unlike the non-auth vars, which are an -// independent override layer. -const CREDENTIAL_ENV = ["CZ_PAT", "CZ_USERNAME", "CZ_PASSWORD"] as const - -// The credential values this module (or run-cli.ts) last wrote from a profile. -// A credential is cleared on a profile switch only when the live env still holds -// exactly that value — proof it is a leftover and not something the user set. -// -// Provenance is tracked by VALUE, not by a boolean flag: a flag is sticky for the -// life of the process, so after any profile had supplied a PAT, a subsequently -// user-set CZ_PAT would be wrongly cleared. Comparing values is self-healing. -// It is also not a "snapshot the env at import" scheme, because run-cli.ts expands -// --profile into CZ_* before this module is ever imported — such a baseline would -// already contain a profile's credential and mistake it for the user's own. -const lastWritten = new Map() +/** + * Expand a profile into the `CZ_*` layer this process (and its children) read. + * + * Runs more than once per process — `mcp serve` applies a per-call profile — so + * the previous profile's values must not survive the switch. That reset lives in + * ConnectionEnv.apply, which replaces the derived layer wholesale and leaves the + * user's own variables alone; this function only decides WHAT to expand. + */ +export function applyClickZettaProfile(profile?: string) { + const file = path.join(process.env.CLICKZETTA_TEST_HOME || os.homedir(), ".clickzetta", "profiles.toml") + const toml = readProfiles(file) + const target = profile ?? (typeof toml?.default_profile === "string" ? toml.default_profile : undefined) + if (!target) return -/** Record credential values written from a profile, so a later switch can clear them. */ -export function noteProfileDerivedCredentials(written: Record) { - for (const [name, value] of Object.entries(written)) lastWritten.set(name, value) + const profiles = toml?.profiles as Record> | undefined + const entry = profiles?.[target] + const fields: ConnectionEnv.Fields = {} + for (const [key, field] of Object.entries(FIELDS)) { + const value = entry?.[key] + if (typeof value === "string" && value.length > 0) fields[field] = value + } + ConnectionEnv.apply(fields, target) } -export function applyClickZettaProfile(profile?: string) { +function readProfiles(file: string): Record | undefined { + if (!fs.existsSync(file)) return undefined + // A hand-edited profiles.toml can be syntactically broken. Every other reader + // (loadProfiles, the token store) degrades to "no profiles" rather than taking + // the CLI down, and this runs on the startup path, so it does the same. try { - const profilesPath = path.join(process.env.CLICKZETTA_TEST_HOME || os.homedir(), ".clickzetta", "profiles.toml") - const toml = parseToml(fs.readFileSync(profilesPath, "utf-8")) as Record - const target = profile ?? (typeof toml.default_profile === "string" ? toml.default_profile : undefined) - if (!target) return - process.env.CZ_PROFILE = target - const profiles = toml.profiles as Record>> | undefined - const entry = profiles?.[target] - if (!entry) return - // Reset the credential vars to their process-start values before applying the - // target profile. The auth fields are mutually exclusive ALTERNATIVES, not - // independent settings, and this function runs more than once per process - // (mcp serve applies a per-call `profile`). Overwrite-only updates leaked them - // across switches: going from a PAT profile to a username/password one kept - // the stale CZ_PAT, which then won the auth priority in - // resolveConnectionConfig() and authenticated as the PREVIOUS identity. - // - // Resetting to the baseline rather than deleting outright is what keeps a - // genuinely user-supplied `CZ_PAT=… cz-cli agent` working: the baseline is the - // env as it existed before any profile was applied, so a user's own credential - // survives while a previous apply()'s leftovers do not. - // - // Only the auth trio is touched. The non-auth CZ_* vars are a legitimate - // override layer that resolveConnectionConfig() applies ON TOP of the profile, - // so `CZ_SCHEMA=x cz-cli agent` must keep working when the profile omits it. - for (const envName of CREDENTIAL_ENV) { - const written = lastWritten.get(envName) - if (written !== undefined && process.env[envName] === written) { - delete process.env[envName] - lastWritten.delete(envName) - } - } - Object.entries(PROFILE_ENV).forEach(([field, envName]) => { - const value = entry[field as keyof typeof PROFILE_ENV] - if (value) { - process.env[envName] = value - if ((CREDENTIAL_ENV as readonly string[]).includes(envName)) lastWritten.set(envName, value) - } - }) - } catch {} + return parseToml(fs.readFileSync(file, "utf-8")) as Record + } catch { + return undefined + } } diff --git a/packages/cz-cli/src/bootstrap/runtime.ts b/packages/cz-cli/src/bootstrap/runtime.ts index 2ed2145cc..5a3f450bc 100644 --- a/packages/cz-cli/src/bootstrap/runtime.ts +++ b/packages/cz-cli/src/bootstrap/runtime.ts @@ -344,7 +344,8 @@ export async function main(args: string[], agentRuntime = false): Promise): void { if (argv.persist) { try { const profiles = loadProfiles() - const profileName = argv.profile ?? getDefaultProfileName() ?? Object.keys(profiles)[0] - if (!profileName || !profiles[profileName]) { + // Profile.current() (CZ_PROFILE, falling back to profiles.toml's + // default_profile) is the single semantic source for "which profile is + // active" — see its own docstring. The old fallback here + // (getDefaultProfileName() ?? Object.keys(profiles)[0]) skipped + // CZ_PROFILE entirely, so a `-p other` invocation with --no-persist-arg + // could WRITE workspace/schema into a different profile than the one + // the rest of the command actually queried against. Falling further to + // "the first profile in the file" when even the default is unset is + // worse here than on a read path: this persists a mutation into + // profiles.toml, and Profile.current()'s own contract is that an + // undefined result means "no profile configured", not "guess one". + const profileName = argv.profile ?? Profile.current() + if (!profileName) { + error("PROFILE_NOT_FOUND", "No profile is active. Pass -p or set default_profile in profiles.toml.", { format }) + return + } + if (!profiles[profileName]) { error("PROFILE_NOT_FOUND", `Profile '${profileName}' not found. Create a profile first.`, { format }) return } diff --git a/packages/cz-cli/src/connection/config.ts b/packages/cz-cli/src/connection/config.ts index 644c2995c..57ae63a2e 100644 --- a/packages/cz-cli/src/connection/config.ts +++ b/packages/cz-cli/src/connection/config.ts @@ -1,5 +1,7 @@ import { DEFAULT_CONNECTION, InterfaceError, type ConnectionConfig } from "@clickzetta/sdk" import { explicitAuthType, getProfileConfig, invalidAuthType, invalidAuthTypeMessage, makeProfileTokenStore, readProfileEntry, type AuthType } from "./profile-store.js" +import { ConnectionEnv } from "./env.js" +import * as Profile from "./profile-context.js" import { parseJdbcUrl } from "./jdbc.js" export interface CliArgs { @@ -17,15 +19,28 @@ export interface CliArgs { } export function resolveConnectionConfig(cliArgs: Partial = {}): ConnectionConfig { - const profileName = cliArgs.profile ?? process.env.CZ_PROFILE + // Profile.current() is the single semantic source for "which profile is + // active" (CZ_PROFILE, falling back to profiles.toml's default_profile) — see + // its own docstring in profile-context.ts. This used to re-derive the CZ_PROFILE + // half here directly and let the `default_profile` half happen only as a side + // effect of getProfileConfig/readProfileEntry being called with `undefined`, + // which is a second, easy-to-miss place that formula could drift from + // Profile.current()'s (see commands/workspace.ts's history for exactly that). + const profileName = cliArgs.profile ?? Profile.current() const profileCfg = getProfileConfig(profileName) ?? (profileName ? undefined : getProfileConfig()) - const envCfg = getEnvConfig() + const ambient = ConnectionEnv.read() const jdbcCfg = cliArgs.jdbcUrl ? parseJdbcUrl(cliArgs.jdbcUrl) : undefined const cfg: ConnectionConfig = { ...DEFAULT_CONNECTION } + // Lowest precedence first. The INHERITED layer sits below the profile on + // purpose: those variables are this process's own expansion of a possibly + // different profile, so a profile that omits `schema` must land on its default + // rather than adopt what we injected for the previous one. The user's own + // variables stay above the profile, which is the override layer they document. + applyNonAuth(cfg, ambient.inherited) applyNonAuth(cfg, profileCfg) - applyNonAuth(cfg, envCfg) + applyNonAuth(cfg, ambient.user) applyNonAuth(cfg, jdbcCfg) const nonAuthKeys = ["service", "protocol", "instance", "workspace", "schema", "vcluster"] as const @@ -67,46 +82,11 @@ export function resolveConnectionConfig(cliArgs: Partial = {}): Connect // before this field existed. Old profiles are untouched. const pinAllows = (type: AuthType) => pinnedAuth === undefined || pinnedAuth === type - const cliPat = cliArgs.pat || "" - const envPat = process.env.CZ_PAT || "" - const profilePat = pinAllows("pat") ? profileCfg?.pat || "" : "" - - const cliUsername = cliArgs.username - const cliPassword = cliArgs.password - const jdbcUsername = jdbcCfg?.username || "" - const jdbcPassword = jdbcCfg?.password || "" - const envUsername = envCfg?.username || "" - const envPassword = envCfg?.password || "" - const profileUsername = pinAllows("password") ? profileCfg?.username || "" : "" - const profilePassword = pinAllows("password") ? profileCfg?.password || "" : "" - - if (cliPat) { - cfg.pat = cliPat - } else if (envPat) { - cfg.pat = envPat - } else if (profilePat) { - cfg.pat = profilePat - } else if (cliUsername !== undefined || cliPassword !== undefined) { - const mergedUsername = cliUsername || jdbcUsername || envUsername || profileUsername - const mergedPassword = cliPassword || jdbcPassword || envPassword || profilePassword - if (mergedUsername && mergedPassword) { - cfg.username = mergedUsername - cfg.password = mergedPassword - } - } else if (jdbcUsername && jdbcPassword) { - cfg.username = jdbcUsername - cfg.password = jdbcPassword - } else if (envUsername && envPassword) { - cfg.username = envUsername - cfg.password = envPassword - } else if (profileUsername && profilePassword) { - cfg.username = profileUsername - cfg.password = profilePassword - } - - if (cfg.pat) { - cfg.username = "" - cfg.password = "" + const credential = pickCredential({ cliArgs, ambient, jdbc: jdbcCfg, profile: profileCfg, pinAllows }) + if (credential.kind === "pat") cfg.pat = credential.pat + if (credential.kind === "password") { + cfg.username = credential.username + cfg.password = credential.password } // Propagate customHeaders from profile (highest priority: env headers could override if needed) @@ -141,7 +121,43 @@ export function resolveConnectionConfig(cliArgs: Partial = {}): Connect // violating the documented auth priority (--pat > CZ_PAT > …) and defeating // PAT rotation. Skipping the store also stops the PAT-exchanged token from // being persisted. Profile-level and pure-OAuth flows still attach it. - const explicitCredential = Boolean(cliPat) || Boolean(envPat) || Boolean(cliUsername && cliPassword) + // + // "Explicit" is now decided by the credential's SOURCE rather than by which + // fields happen to be set. An INHERITED credential — one we expanded into the + // env ourselves — is not the user speaking, so it must not suppress the store: + // treating it as explicit is what let a stale injection outrank the profile's + // own OAuth login. + // + // A "flag" password credential needs one more check than that, though: + // pickCredential enters the flag tier as soon as EITHER --username or + // --password is set, then fills the other half from a lower tier (profile, + // env, JDBC) — that is what makes `--username alice` against a profile-stored + // password keep working. Tagging that result explicit on source alone would + // suppress the token store on a credential that is only HALF the user + // speaking now; the stored OAuth login the other half came from is exactly + // what should stay attached. Only a pair supplied ENTIRELY by flags — or, for + // pat, the single flag/env value — is unambiguously "not the profile's". + // + // Same reasoning excludes an env-sourced password PAIR: unlike CZ_PAT (a + // single value, unambiguously the user's), CZ_USERNAME+CZ_PASSWORD have never + // been treated as explicit here, and there is no flag-style "half from the + // profile" case to protect for env — so credential.source === "env" is only + // explicit for a pat. + // + // Keying off source has one more consequence, confirmed intentional: an + // explicit `--username`/`--password` pair that LOSES the priority tier to a + // profile pat (no auth_type pin) is tagged source: "profile", not "flag" — + // pickCredential picked the profile's credential, the flags did not win. + // Consistent with "only the credential that's actually speaking suppresses + // the store", that profile pat still attaches the store exactly as it would + // with no credential flag at all (see the profile-level-pat test above) — + // the flags losing the tier means they are not the ones authenticating, so + // they should not be the ones deciding whether a stored OAuth login stays + // reachable either. + const explicitCredential = + (credential.source === "env" && credential.kind === "pat") || + (credential.source === "flag" && + (credential.kind === "pat" || Boolean(cliArgs.username && cliArgs.password))) // Attach the OAuth token store when the profile can carry an OAuth login: // either it has an instance (the common case) OR it has an `oauth = ""` // pointer to a shared [oauth.] token. The old `cfg.instance`-only gate @@ -186,55 +202,99 @@ export function resolveConnectionConfig(cliArgs: Partial = {}): Connect return cfg } -function getEnvConfig(): Partial | undefined { - const env = process.env - const result: Partial = {} +/** Where the selected credential came from, in descending precedence. */ +type CredentialSource = "flag" | "env" | "profile" | "inherited" | "jdbc" - const pat = env.CZ_PAT || "" - const username = env.CZ_USERNAME || "" - const password = env.CZ_PASSWORD || "" +type Credential = + | { kind: "pat"; source: CredentialSource; pat: string } + | { kind: "password"; source: CredentialSource; username: string; password: string } + | { kind: "none"; source: "default" } - // Require PAT or both username+password to return auth config (matching Python) - if (!pat && !(username && password)) { - // Still return non-auth fields if any are set - const nonAuthMap: Array<[string, keyof ConnectionConfig]> = [ - ["CZ_SERVICE", "service"], - ["CZ_PROTOCOL", "protocol"], - ["CZ_INSTANCE", "instance"], - ["CZ_WORKSPACE", "workspace"], - ["CZ_SCHEMA", "schema"], - ["CZ_VCLUSTER", "vcluster"], - ] - let hasAny = false - for (const [envKey, cfgKey] of nonAuthMap) { - const val = env[envKey] - if (val) { - ;(result as Record)[cfgKey] = val - hasAny = true - } - } - return hasAny ? result : undefined +/** + * Choose ONE credential, as a group. + * + * A credential is an identity, not a bag of fields: a username from one source + * and a password from another authenticate as nobody. The old code set + * `cfg.pat` / `cfg.username` / `cfg.password` from independent priority chains, + * which is how a profile's instance ended up paired with a username we had + * injected for a different profile ("Login failed: 没有这样的用户"). + * + * The tier order is the one this function has always had — flag pat, user's + * `CZ_PAT`, profile pat, flag username/password, JDBC, user's `CZ_USERNAME` + * pair, profile pair — with one tier ADDED at the bottom: a credential we + * expanded into the environment ourselves ranks below the profile, because it is + * OUR value rather than the user's (see connection/env.ts). This is a + * provenance fix, not a re-litigation of flag-versus-profile. + * + * One precedence change DOES fall out of it, though: the old `getEnvConfig()` + * this replaced only populated `username`/`password` at all when the env held + * `CZ_PAT` OR a COMPLETE `CZ_USERNAME`+`CZ_PASSWORD` pair — a lone + * `CZ_USERNAME` with no `CZ_PASSWORD` returned non-auth fields only, so + * `envUsername` in the old flag-tier merge was empty and fell through to the + * profile's username. `ConnectionEnv.read()` carries no such gate: + * `ambient.user.username` is populated whenever `CZ_USERNAME` is set, whole or + * half. So `CZ_USERNAME=envuser` (no `CZ_PASSWORD`) plus `--password p` + * against a profile storing `username = "profileuser"` now merges to + * `envuser`/`p` instead of falling through to `profileuser`/`p`. Accepted as + * more consistent with this file's own documented `env > profile` layering + * (the flag tier's username lookup already prefers `ambient.user.username` + * over the profile at that same line), not reverted. + */ +function pickCredential(input: { + cliArgs: Partial + ambient: ConnectionEnv.Ambient + jdbc: Partial | undefined + profile: Partial | undefined + pinAllows: (type: AuthType) => boolean +}): Credential { + if (input.cliArgs.pat) return { kind: "pat", source: "flag", pat: input.cliArgs.pat } + if (input.ambient.user.pat) return { kind: "pat", source: "env", pat: input.ambient.user.pat } + if (input.pinAllows("pat") && input.profile?.pat) { + return { kind: "pat", source: "profile", pat: input.profile.pat } } - if (pat) result.pat = pat - if (username) result.username = username - if (password) result.password = password + // A flag that supplies only half of the pair still selects the flag tier; the + // missing half is filled from the tiers below, which is how + // `--username alice` against a profile that stores the password keeps working. + if (input.cliArgs.username !== undefined || input.cliArgs.password !== undefined) { + const username = + input.cliArgs.username || + input.jdbc?.username || + input.ambient.user.username || + (input.pinAllows("password") ? input.profile?.username : undefined) || + input.ambient.inherited.username + const password = + input.cliArgs.password || + input.jdbc?.password || + input.ambient.user.password || + (input.pinAllows("password") ? input.profile?.password : undefined) || + input.ambient.inherited.password + if (username && password) return { kind: "password", source: "flag", username, password } + } - const nonAuthMap: Array<[string, keyof ConnectionConfig]> = [ - ["CZ_SERVICE", "service"], - ["CZ_PROTOCOL", "protocol"], - ["CZ_INSTANCE", "instance"], - ["CZ_WORKSPACE", "workspace"], - ["CZ_SCHEMA", "schema"], - ["CZ_VCLUSTER", "vcluster"], - ] - for (const [envKey, cfgKey] of nonAuthMap) { - const val = env[envKey] - if (val) { - ;(result as Record)[cfgKey] = val + if (input.jdbc?.username && input.jdbc.password) { + return { kind: "password", source: "jdbc", username: input.jdbc.username, password: input.jdbc.password } + } + + if (input.ambient.user.username && input.ambient.user.password) { + return { kind: "password", source: "env", username: input.ambient.user.username, password: input.ambient.user.password } + } + + if (input.pinAllows("password") && input.profile?.username && input.profile.password) { + return { kind: "password", source: "profile", username: input.profile.username, password: input.profile.password } + } + + if (input.ambient.inherited.pat) return { kind: "pat", source: "inherited", pat: input.ambient.inherited.pat } + if (input.ambient.inherited.username && input.ambient.inherited.password) { + return { + kind: "password", + source: "inherited", + username: input.ambient.inherited.username, + password: input.ambient.inherited.password, } } - return result + + return { kind: "none", source: "default" } } function applyNonAuth(target: ConnectionConfig, src: Partial | undefined): void { diff --git a/packages/cz-cli/src/connection/env.ts b/packages/cz-cli/src/connection/env.ts new file mode 100644 index 000000000..3445814a8 --- /dev/null +++ b/packages/cz-cli/src/connection/env.ts @@ -0,0 +1,216 @@ +export * as ConnectionEnv from "./env" + +/** + * Single owner of the `CZ_*` connection variables. + * + * `CZ_*` carries two unrelated things that used to share one namespace: the + * user's own override layer (`CZ_SCHEMA=x cz-cli sql …`, which outranks the + * profile by design) and our own transport for whichever profile is selected + * (expanded here, read back by this process and inherited by children). Once the + * second wrote into the first, precedence became unknowable and callers guessed: + * a profile's credential could lose to a value WE had injected from another + * profile, which is how `--profile B` ended up authenticating as A. + * + * The fix is provenance, published rather than inferred. Every variable we write + * is listed in `CZ_ENV_DERIVED`, so any reader — including a child process, where + * an in-process bookkeeping table cannot reach — can tell the two apart: + * unlisted is the user's and outranks the profile, listed is ours and loses to it. + * + * No other module may touch these variables; `test/connection-env-owner.test.ts` + * enforces it. + */ + +/** + * Connection fields and the variable each travels as, in the order `apply` + * writes them. A tuple list rather than a record so iterating it keeps the field + * names as literals — `Object.entries` on a record widens them to `string` and + * every read back would need a cast. + */ +const FIELDS = [ + ["pat", "CZ_PAT"], + ["username", "CZ_USERNAME"], + ["password", "CZ_PASSWORD"], + ["service", "CZ_SERVICE"], + ["protocol", "CZ_PROTOCOL"], + ["instance", "CZ_INSTANCE"], + ["workspace", "CZ_WORKSPACE"], + ["schema", "CZ_SCHEMA"], + ["vcluster", "CZ_VCLUSTER"], + ["accountsUrl", "CZ_ACCOUNTS_URL"], +] as const + +export type Field = (typeof FIELDS)[number][0] +export type Fields = Partial> + +/** Names of the variables this process derived from a profile. */ +const DERIVED = "CZ_ENV_DERIVED" +const PROFILE = "CZ_PROFILE" + +/** + * Names `applyUser` itself has written, in THIS process. Not published as a + * `CZ_*` var and not inherited by children — it exists only so a later + * `applyUser` call can tell "a value this same layer wrote earlier" apart from + * "a value the user's shell exported before this process ever ran". Both are + * equally the user's for `apply()`'s skip-if-user-owns-it check (`read()` + * folds them into one `user` bucket), but only the former is safe for + * `applyUser`'s clear-the-other-credential-kind branch to touch — see there. + */ +const writtenByApplyUser = new Set() + +/** Test-only: clear applyUser's own-write tracking between tests sharing a process. */ +export function clearApplyUserTrackingForTest(): void { + writtenByApplyUser.clear() +} + +/** + * The ambient `CZ_*` layers, split by who set them. `user` outranks the profile, + * `inherited` loses to it — see the module docblock. + */ +export interface Ambient { + readonly user: Readonly + readonly inherited: Readonly +} + +export function read(): Ambient { + const derived = new Set((process.env[DERIVED] ?? "").split(",").filter((name) => name.length > 0)) + const user: Fields = {} + const inherited: Fields = {} + for (const [field, name] of FIELDS) { + const value = process.env[name] + if (!value) continue + const target = derived.has(name) ? inherited : user + target[field] = value + } + return { user, inherited } +} + +/** The pinned profile name, or undefined when this process follows the default. */ +export function profileName(): string | undefined { + const value = process.env[PROFILE] + return value && value.length > 0 ? value : undefined +} + +/** + * Replace the derived layer with `fields`, wholesale. + * + * Wholesale is the point: an overwrite-only update left the previous profile's + * `CZ_SCHEMA` / `CZ_VCLUSTER` in place whenever the incoming profile omitted + * them, and the env layer then outranked the profile that was just selected. A + * variable we previously derived and no longer derive is therefore deleted, while + * anything the user set is left alone — that is exactly what `CZ_ENV_DERIVED` + * records. + * + * A profile may legitimately carry both a pat and a username/password, so both + * are written when both exist; choosing between them is the resolver's job + * (`auth_type`, then the tier order in connection/config.ts), not this layer's. + */ +export function apply(fields: Fields, profile?: string): void { + const derived = new Set((process.env[DERIVED] ?? "").split(",").filter((name) => name.length > 0)) + const written: string[] = [] + for (const [field, name] of FIELDS) { + // A variable the user set is not ours to write or to clear. Overwriting it + // with the profile's value contradicted the override layer it is supposed to + // be — `CZ_SCHEMA=x cz-cli …` only survived against profiles that happened + // to omit `schema` — and it also relabelled the user's value as derived, so + // the next switch deleted it. + if (process.env[name] && !derived.has(name)) continue + const value = fields[field] + if (value) { + process.env[name] = value + written.push(name) + continue + } + if (derived.has(name)) delete process.env[name] + } + if (written.length > 0) process.env[DERIVED] = written.join(",") + else delete process.env[DERIVED] + if (profile !== undefined) process.env[PROFILE] = profile +} + +/** + * Write `fields` as the USER's layer — never marked in `CZ_ENV_DERIVED`, and + * un-marking any of these names that were previously derived. + * + * Use this ONLY for values that are literally, exactly what a flag on THIS + * invocation supplied — never a resolved/expanded value that merely happens + * to equal the profile's, since the caller cannot tell the two apart once + * resolved (see run-cli.ts's applyAgentConnectionEnv for the split that keeps + * this true). Writing a profile-sourced value through here instead of + * `apply()` would mislabel it as the user's and make it permanently + * un-clearable: `apply()`'s skip-if-user-owns-it check (`!derived.has(name)`) + * treats every name written here as never eligible for replacement again, + * even by a later `apply()` that legitimately switches profiles. + * + * The un-marking matters for the same reason in the other direction: without + * it, a name this call writes but that a PRIOR `apply()` had marked derived + * (typical in a nested invocation — an agent session that already expanded a + * profile, spawning `cz-cli … --schema x`) would still read back as + * `inherited` from `read()`, ranking it below the profile it should now + * outrank, and a later `apply()` would still delete or overwrite it as if it + * were still derived. + * + * Credentials are alternatives, not independent fields: writing a pat here + * clears any username/password this layer holds, and vice versa, so a + * leftover from the OTHER kind can never survive to win a priority tier it + * has no business being in. + */ +export function applyUser(fields: Fields): void { + const derived = new Set((process.env[DERIVED] ?? "").split(",").filter((name) => name.length > 0)) + // The incoming kind is resolved ONCE, not inferred separately per field from + // `fields` itself — inferring it per field is what let a caller passing BOTH + // a pat and a username/password (today unreachable, but not prevented by the + // type) delete all three: each clear-the-other-kind check independently saw + // evidence of the other kind and fired. Pat wins the tie, matching every + // other precedence table in this codebase (`--pat > CZ_PAT > … > + // --username/--password`). + const kind = fields.pat !== undefined ? "pat" : fields.username !== undefined || fields.password !== undefined ? "password" : undefined + for (const [field, name] of FIELDS) { + // Only clear the OTHER kind's vars when THIS layer holds them — gated on + // `derived.has(name) || writtenByApplyUser.has(name)`: either the var is + // currently part of OUR derived layer (left over from an earlier + // `apply()` expanding a profile), or a PRIOR `applyUser` call in this same + // process wrote it. Without this gate the clear ran unconditionally, + // deleting a var regardless of who set it: `CZ_USERNAME=alice + // CZ_PASSWORD=secret cz-cli agent --pat X` would unset the user's OWN + // CZ_USERNAME/CZ_PASSWORD — set in their shell for reasons that have + // nothing to do with this call — for the rest of the process and every + // child it spawns (shell tools, LSP, git hooks). cz-cli's own resolution + // is unaffected either way (--pat already outranks the env pair in + // pickCredential's tier order), so there is nothing to gain from touching + // a var neither this function nor a profile expansion ever wrote. A value + // this layer itself wrote earlier (e.g. an earlier `applyUser({username, + // password})` in the same invocation) is NOT in `derived` — writing here + // never marks anything derived — so `writtenByApplyUser` is what still + // lets a later `applyUser({pat})` in the same call clear it. + if (kind === "pat" && (field === "username" || field === "password") && (derived.has(name) || writtenByApplyUser.has(name))) { + delete process.env[name] + derived.delete(name) + writtenByApplyUser.delete(name) + continue + } + if (kind === "password" && field === "pat" && (derived.has(name) || writtenByApplyUser.has(name))) { + delete process.env[name] + derived.delete(name) + writtenByApplyUser.delete(name) + continue + } + const value = fields[field] + if (!value) continue + process.env[name] = value + derived.delete(name) + writtenByApplyUser.add(name) + } + if (derived.size > 0) process.env[DERIVED] = [...derived].join(",") + else delete process.env[DERIVED] +} + +/** Pin the profile name without touching the derived values. */ +export function pin(name: string): void { + process.env[PROFILE] = name +} + +/** Unpin the profile, leaving the process to follow `default_profile` again. */ +export function unpin(): void { + delete process.env[PROFILE] +} + diff --git a/packages/cz-cli/src/connection/profile-context.ts b/packages/cz-cli/src/connection/profile-context.ts index fc04704f5..1aa901462 100644 --- a/packages/cz-cli/src/connection/profile-context.ts +++ b/packages/cz-cli/src/connection/profile-context.ts @@ -23,6 +23,7 @@ // a far worse failure than for a model: an agent silently pointed at the wrong // lakehouse can run writes there. `cz-cli profile use` remains the explicit, // visible way to change the default. +import { ConnectionEnv } from "./env.js" import { getDefaultProfileName } from "./profile-store.js" /** Notified after the active profile actually changes. Never fires on a no-op. */ @@ -46,9 +47,7 @@ const listeners = new Set() * "no profile configured" rather than substituting one of their own. */ export function current(): string | undefined { - const pinned = process.env.CZ_PROFILE - if (pinned && pinned.length > 0) return pinned - return getDefaultProfileName() + return ConnectionEnv.profileName() ?? getDefaultProfileName() } /** @@ -81,7 +80,7 @@ export async function set(name: string | undefined): Promise // later `cz-cli profile use` elsewhere is picked up rather than shadowed by a // snapshot of the old value. The CZ_* layer it just derived stays — those are the // default profile's settings, which is exactly what unpinned should use. - if (name === undefined) delete process.env.CZ_PROFILE + if (name === undefined) ConnectionEnv.unpin() const next = current() if (next === previous) return next diff --git a/packages/cz-cli/src/opencode-plugin/tui-quota-data.ts b/packages/cz-cli/src/opencode-plugin/tui-quota-data.ts index d4d6e18a5..285d92c6a 100644 --- a/packages/cz-cli/src/opencode-plugin/tui-quota-data.ts +++ b/packages/cz-cli/src/opencode-plugin/tui-quota-data.ts @@ -22,7 +22,7 @@ import { getToken, toServiceUrl } from "@clickzetta/sdk" import { resolveConnectionConfig } from "../connection/config.js" import { getCookieToken } from "../connection/cookie-token.js" import * as Profile from "../connection/profile-context.js" -import { loadProfiles } from "../connection/profile-store.js" +import { deriveAuthType, explicitAuthType, loadProfiles } from "../connection/profile-store.js" import { readLlmEntries } from "../llm/native-config.js" const BILLING_PATH = "/clickzetta-portal/hornhub/account/billing/account" @@ -105,6 +105,33 @@ export interface QuotaSnapshot { alias?: string } +/** + * Who and where the session is connected as — the "am I pointed at the right + * lakehouse" facts, shown alongside the money figures. + * + * Everything except `userName` comes straight out of profiles.toml, so the + * section paints immediately and keeps working with no network at all. `userName` + * needs a portal round-trip for OAuth profiles (their TOML block carries no + * username), so it fills in when the snapshot lands and is simply absent until + * then rather than blocking the rest. + */ +export interface ProfileInfo { + /** Active profile name, i.e. what `-p` selected or default_profile. */ + profile: string + /** How that profile authenticates: oauth / pat / password / cookie. */ + authType?: string + /** Account (tenant) name — NOT the user; the two differ. */ + accountName?: string + /** Human user within the account. Resolved from the portal. */ + userName?: string + /** Deployment environment (dev/sit/uat/prod), when the host names one. */ + env?: string + /** Cloud region segment, for a regional host that names one instead of an env. */ + region?: string + instance?: string + workspace?: string +} + type Dict = Record function isRecord(value: unknown): value is Dict { @@ -120,6 +147,162 @@ function num(value: unknown): number | undefined { return undefined } +/** + * Deployment environment and cloud region for a service host — two DIFFERENT + * facts, kept apart on purpose. A `dev-api.`/`sit-api.`/`uat-api.` host or a + * central host names an environment (`dev`/`sit`/`uat`/`prod`); a regional host + * like `cn-shanghai-alicloud.api.clickzetta.com` names a region instead, and IS + * production — collapsing the two into one label would print a region name + * under a heading that invites reading it as "not prod", which is the more + * consequential half of "am I pointed at the right lakehouse" for that host. + * Each is undefined when the host doesn't match a recognizable shape. + * + * Deliberately NOT `@clickzetta/sdk`'s `detectEnv`: that function ends with an + * unconditional `return "prod"` for anything it doesn't recognize (private + * deployments, custom domains, `localhost`), which is a reasonable default for + * picking a service URL shape but would be a FABRICATED fact if rendered in this + * panel. Showing an invented "prod" for a deployment that may not be prod is + * worse than showing nothing, so this mirrors detectEnv's pattern list but drops + * the catch-all. + */ +function knownEnv(service: string): string | undefined { + const host = service.replace(/^https?:\/\//, "").split("/")[0] ?? "" + if (host.startsWith("dev-api.")) return "dev" + if (host.startsWith("sit-api.")) return "sit" + if (host.startsWith("uat-api.")) return "uat" + if (host === "api.clickzetta.com" || host === "api.singdata.com") return "prod" + return undefined +} + +function knownRegion(service: string): string | undefined { + const host = service.replace(/^https?:\/\//, "").split("/")[0] ?? "" + const match = host.match(/^([^.]+)\.api\.(clickzetta|singdata)\.com$/) + return match ? match[1] : undefined +} + +/** + * Read the active profile's identity and connection target from profiles.toml. + * + * Synchronous and network-free on purpose: this is the half of the sidebar that + * should never be missing, so it must not depend on a portal that may be slow, + * unreachable, or (as measured) not serving a region's host at all. + * + * `userName` is deliberately absent here — see fetchProfileUserName. + */ +export function readProfileInfo(): ProfileInfo | undefined { + const current = Profile.current() + const profiles = loadProfiles() + // Only substitute the first TOML profile when NOTHING is pinned (current === + // undefined, i.e. genuinely unconfigured — Profile.current()'s own docs say to + // treat that as "no profile configured"). A current that names a profile absent + // from the file (stale CZ_PROFILE, deleted profile) must render nothing rather + // than silently swap in a different tenant's identity — this panel's whole job + // is telling the user which lakehouse they're pointed at. + const name = current === undefined ? Object.keys(profiles)[0] : profiles[current] ? current : undefined + if (!name) return undefined + const profile = profiles[name]! + const str = (value: unknown) => (typeof value === "string" && value.trim() ? value.trim() : undefined) + const service = str(profile.service) + // explicitAuthType ?? deriveAuthType is readAuthType's own body (profile-store + // .ts), applied to the `profile` entry already in hand rather than having + // readAuthType(name) look it up by name and re-read+re-parse profiles.toml a + // third time in this function. Same precedence (explicit auth_type, else + // cookie/oauth/pat/password) the rest of the CLI uses to pick a credential, + // including the `cookie` case a hand-rolled check here would otherwise miss. + return { + profile: name, + authType: explicitAuthType(profile) ?? deriveAuthType(profile), + accountName: str(profile.account_name), + userName: str(profile.username), + env: service ? knownEnv(service) : undefined, + region: service ? knownRegion(service) : undefined, + instance: str(profile.instance), + workspace: str(profile.workspace), + } +} + +// Resolved user names, keyed by profile. Identity does not change for the life of +// a session, so this is asked once rather than on every quota refresh. +const userNameCache = new Map() + +/** Test-only: clear the user-name cache between tests sharing a process. */ +export function clearUserNameCacheForTest(): void { + userNameCache.clear() +} + +/** + * POST getCurrentUser and pull out the login handle, or undefined on any failure + * or an envelope that doesn't carry one. + * + * `name` is the login handle; `accountDisplayName` is the tenant and is already + * covered by `accountName` elsewhere. Nothing else from this payload is used — it + * also carries a phone number and an email, which have no place in a status panel. + * + * The one call site both `fetchProfileUserName` and `fetchProfileSnapshot` share, + * so the envelope check (four-part: record / isPortalOk / record data / string + * name) is written once rather than kept in sync by hand in two places. + */ +async function readCurrentUserName( + baseUrl: string, + token: string, + signal: AbortSignal | undefined, +): Promise { + try { + const payload = await portalRead(baseUrl, CURRENT_USER_PATH, token, { method: "POST", signal }) + if (!isRecord(payload) || !isPortalOk(payload.code) || !isRecord(payload.data)) return undefined + const name = typeof payload.data.name === "string" ? payload.data.name.trim() : "" + return name || undefined + } catch (error) { + if (signal?.aborted) throw error + return undefined + } +} + +/** + * Resolve the human user name for the active profile. + * + * Only OAuth profiles need this — a password profile's TOML block already carries + * `username`, which readProfileInfo returns directly. Separate from the quota + * fetch on purpose: the quota path only resolves a name when it happens to need + * one for a key lookup, and the sidebar wants it whether or not a key was pinned. + * + * Returns undefined rather than throwing: a missing user name should cost one + * line of the section, never the section itself. + * + * Tagged with the profile it resolved for (not a bare string) so a caller can + * detect a profile switch that happened while this was in flight — composing a + * later profile's identity with an earlier profile's user name would misattribute + * a real person to the wrong tenant, worse than the row simply being absent. + */ +export async function fetchProfileUserName( + input: { signal?: AbortSignal } = {}, +): Promise<{ profile: string; name: string } | undefined> { + const info = readProfileInfo() + if (!info) return undefined + if (info.userName) return { profile: info.profile, name: info.userName } + const cached = userNameCache.get(info.profile) + if (cached) return { profile: info.profile, name: cached } + + const profiles = loadProfiles() + const profile = profiles[info.profile] + if (!profile) return undefined + try { + const config = resolveConnectionConfig({ + profile: info.profile, + ...(typeof profile.service === "string" ? { service: profile.service } : {}), + ...(profile.protocol === "http" || profile.protocol === "https" ? { protocol: profile.protocol } : {}), + ...(typeof profile.instance === "string" ? { instance: profile.instance } : {}), + }) + const token = (await getCookieToken(config)) ?? (await getToken(config)) + const name = await readCurrentUserName(toServiceUrl(config.service, config.protocol), token.token, input.signal) + if (!name) return undefined + userNameCache.set(info.profile, name) + return { profile: info.profile, name } + } catch { + return undefined + } +} + // The portal is inconsistent about its success code: the production host answers // `0`, the dev host answers `200`, and either may arrive as a string. Treat all // four as success rather than pinning one and silently reading `data: null`. @@ -274,6 +457,143 @@ async function portalCall( return await response.json() } +/** + * Drop the region label from a regional API host: `.api.` → + * `api.`. Returns undefined when there is no region label to drop. + * + * Both reads here are tenant-global — a balance and a tenant's virtual keys are + * not per-region facts — but only some hosts serve them. Measured with one + * profile's token against three hosts: + * + * ap-shanghai-tencentcloud.api.clickzetta.com → code 8888 "未知异常" + * cn-shanghai-alicloud.api.clickzetta.com → code 0, correct data + * api.clickzetta.com → code 0, correct data + * + * So a tencentcloud-region profile could read neither figure and the indicator + * silently showed nothing. The central host is the honest target for global data, + * and is preferred here over pinning one region the way pinAlicloudAdminHost does + * for the AIGW admin routes (see llm/clickzetta-rotation.ts) — that helper had to + * name a region because it predates knowing the central host answers. + * + * Deliberately narrow two ways. First, it matches only a leading label directly + * before `api.`, so `uat-api.clickzetta.com` and `dev-api.clickzetta.com` are left + * alone (verified still working — their label is part of `uat-api`, not a region + * segment). Second, the root itself is pinned to `clickzetta.com`/`singdata.com` — + * the only two measured — rather than matching ANY `