Skip to content

fix(cli): honor profiles and refresh analytics tokens - #84

Merged
hellozepp merged 1 commit into
mainfrom
feat/quota-sidebar
Aug 28, 2026
Merged

fix(cli): honor profiles and refresh analytics tokens#84
hellozepp merged 1 commit into
mainfrom
feat/quota-sidebar

Conversation

@suibianwanwank

Copy link
Copy Markdown
Collaborator

Honor explicit profiles and refresh expired Analytics Agent credentials.

requestInfo(url, route, argv, studio.tenantId, response.status, responseRequestId(text)),
const context = ctx ?? await resolveAnalyticsContext(argv)
const endpoint = context.endpoint
const url = buildUrl(endpoint, route, argv, query, context.studio.tenantId)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

HIGH (confidence: high) — the retried request carries a stale tenantId in its query string.

const url = buildUrl(endpoint, route, argv, query, context.studio.tenantId)
let didRefresh = false
while (true) {
  const studio = context.studio

url is built once, before the loop, from the pre-refresh tenant. context.refresh() then replaces context.studio wholesale — including tenantId, which moves from the [profiles.<n>.agent].tenant_id value to getCurrentUser()'s accountId. The retry re-derives headers and requestBody from the new studio but reuses the old url, so the second request sends two different tenants: the old one in ?tenantId=, the new one in the tenantId/accountId headers (and in the openSessionAuth body).

This is not a narrow route: buildUrl appends tenantId unless route.tenantIdQuery === false, and no entry in ROUTES sets that, so it applies to every analytics-agent call.

The PR's own new test exercises exactly this divergence without asserting on it — [profiles.test.agent] tenant_id = 55 while stubStudioContext's DEFAULT_CONTEXT.tenantId is 10 (test/support/cz-fixtures.ts:97), so the retry issues ?tenantId=55 with tenantId: 10 headers and still passes because only x-clickzetta-token is checked.

requestInfo(url, route, argv, studio.tenantId, ...) on line 720 inherits the same split — the reported tenantId won't match the query it reports alongside it.

Moving the buildUrl call inside the loop (below const studio = context.studio) fixes both, and costs nothing on the non-retry path.

Comment on lines 614 to 616
function responseIndicatesExpiredToken(responseBody: string): boolean {
return /(?:token|authorization).*(?:expired|invalid)|(?:expired|invalid).*(?:token|authorization)/i.test(responseBody)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

MEDIUM (confidence: medium-high) — deciding "should I refresh?" by regex-matching English prose in the response body is a heuristic layered on top of a signal that is already structured.

function responseIndicatesExpiredToken(responseBody: string): boolean {
  return /(?:token|authorization).*(?:expired|invalid)|(?:expired|invalid).*(?:token|authorization)/i.test(responseBody)
}

Three concrete ways it misses:

  • No s/dotAll flag, so . does not cross newlines. A pretty-printed body with "code": "401" on one line and "message": "token expired" on another matches only if both keywords land on the same line. Nothing guarantees the gateway does not pretty-print.
  • The backend returns localized messages elsewhere in this file's surface area (ai_message, business errors). 令牌已过期 / 凭证无效 does not match, and the fallback silently does not happen — the user gets the raw 401.
  • It matches anywhere in the body, including inside a nested payload that merely mentions a token, so it can also fire on a 401 that is a genuine authorization denial.

The structured signal is right there: this file already has extractBusinessError(payload), and the sibling test on line ~258 stubs { code: "401", message: "token expired" }. Keying on the response's business code (or simply on response.status === 401, which is what the other branch does) would be deterministic.

Related — the asymmetry between the two branches is undocumented and looks accidental:

context.refreshOn401 = responseIndicatesExpiredToken   // profile-[agent] branch
...
context.refreshOn401 = () => true                      // getStudioContext branch

If the reason the [agent] branch needs a stricter gate is "refreshing here switches identity (agent token → OAuth session), so only do it when the token is genuinely dead", that is worth a comment — it is the load-bearing fact and nothing in the code says it. If there is no such reason, both branches should use the same predicate.

Comment thread packages/cz-cli/src/commands/auth.ts Outdated
Comment on lines +86 to +89
function selectedProfile(argv: GlobalArgs, profiles: Record<string, Record<string, unknown>>, defaultProfile: string | undefined): string | undefined {
const requested = typeof argv.profile === "string" && argv.profile.length > 0 ? argv.profile : defaultProfile
return requested && profiles[requested] ? requested : undefined
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

MEDIUM (confidence: high) — this re-derives "which profile is active" instead of calling the function that already owns that formula, and drops one of its two tiers.

const requested = typeof argv.profile === "string" && argv.profile.length > 0 ? argv.profile : defaultProfile

Profile.current() (src/connection/profile-context.ts:49) is ConnectionEnv.profileName() ?? getDefaultProfileName() — i.e. CZ_PROFILE, falling back to default_profile. Its docstring is explicit about why it exists:

Both steps live here because call sites that spelled the order themselves drifted — one read only the default and retargeted a --profile B session at another tenant.

selectedProfile is that same drift with the CZ_PROFILE tier missing. And --profile does not need special handling here: runCli already pins it into CZ_PROFILE before any handler runs (src/run-cli.ts:771, ConnectionEnv.pin(profileOverride)), so replacing data.default_profile with Profile.current() fixes the reported bug for --profile and for CZ_PROFILE, with no new helper.

The gap is reachable, and it is the same class of bug this PR is fixing. Profile.set() (TUI /profile switch) writes CZ_PROFILE, which child processes inherit — so after switching profiles in the TUI, the agent shelling out to cz-cli auth status gets the default profile's session reported back, while cz-cli sql in the same shell connects as the switched one. That is exactly the "verification reported the wrong profile" failure the new system-prompt line on agent-system-prompt.ts:181 is trying to steer around.

Neither new test in test/auth-profile-override.test.ts covers CZ_PROFILE — both pass --profile explicitly.

Note also that argv.profile naming a non-existent profile cannot reach here: runCli reports PROFILE_NOT_FOUND at the boundary for any explicitly typed name (run-cli.ts:752-763). So the && profiles[requested] guard only ever changes behavior for a dangling default_profile — see the separate comment on runList.

Comment thread packages/cz-cli/src/commands/auth.ts Outdated
const profiles = (data.profiles ?? {}) as Record<string, Record<string, unknown>>
const activeProfile = typeof data.default_profile === "string" ? data.default_profile : undefined
const defaultProfile = typeof data.default_profile === "string" ? data.default_profile : undefined
const activeProfile = selectedProfile(argv, profiles, defaultProfile)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LOW (confidence: high on the mechanism, unsure on intent) — please confirm this output-shape change is deliberate.

const activeProfile = selectedProfile(argv, profiles, defaultProfile)

selectedProfile returns undefined when the resolved name is not a key in [profiles]. When default_profile names a profile that no longer exists (deleted by auth logout, or hand-edited), auth list --format json now reports "active_profile": null where it previously reported the dangling name. active_session was already null in that case, so this is the one field that moves.

runStatus is unaffected — it already had the if (!activeProfile || !profiles[activeProfile]) guard below.

Reporting null is arguably the better answer, but it is a change to a documented JSON field that scripts may read, and no test covers the dangling-default_profile case in either subcommand. Worth either a line in the docstring or a test pinning it, so a future reader knows it was chosen rather than inherited from the profiles[requested] guard.

Comment on lines +709 to +716
if (response.status === 401 && !didRefresh && context.refresh && (context.refreshOn401?.(text) ?? false)) {
didRefresh = true
context.refreshPromise ??= context.refresh().finally(() => {
context.refreshPromise = undefined
})
await context.refreshPromise
continue
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LOW (confidence: high) — a failure inside the refresh replaces the 401's diagnostics with the refresh's own error.

if (response.status === 401 && !didRefresh && context.refresh && (context.refreshOn401?.(text) ?? false)) {
  didRefresh = true
  context.refreshPromise ??= context.refresh().finally(() => {
    context.refreshPromise = undefined
  })
  await context.refreshPromise
  continue
}

context.refresh() can throw for reasons unrelated to the original 401 — forceRefreshToken rethrows transient network/5xx failures verbatim (clickzetta-sdk/src/auth/token.ts:126-128), and the getStudioContext call that follows it makes three more network round trips (getCurrentUser, resolveInstanceIdByName, getWorkspaceByName). When any of those fails, the AnalyticsHttpError that would have been thrown is never constructed, so:

  • the extra: { request: err.request } block at the ~15 call sites (e.g. line 1000, 1125, 1308) has nothing to attach — the user loses path/query/tenantId/requestId, which is the only handle for correlating with the backend;
  • requestStatusCommandWithFallback loses its dispatch: if (!(err instanceof AnalyticsHttpError)) throw err (line 1094) rethrows instead of consulting (err.request.status ?? 0) < 500;
  • applyStatusChangeBatch's per-item catch (line 1281) records the refresh error as that item's failure reason.

A network blip during the refresh turns a diagnosable "HTTP 401: token expired" into an opaque message. Wrapping the refresh so a refresh failure falls through to throwing the original AnalyticsHttpError (with the refresh error as cause) keeps both facts.

Separately, on the same block: the refreshPromise coalescing has no caller that can observe it. Every shared-ctx path in this file is a sequential await — the poll loop (line 963), the batch loop (line 1271-1288), the pagination loop (line 1328) — and there is no Promise.all/allSettled anywhere in the file. didRefresh is already the per-call guard, so context.refreshPromise is always undefined when read. Either drop it, or add a comment naming the concurrent caller it is defending against.

Comment on lines +637 to +641
const profileAgent = getProfileAgentContext(argv)
if (profileAgent) {
const config = resolveConnectionConfig(argv)
const context: ResolvedContext = { endpoint: endpoint!, studio: profileAgent }
if (config.tokenStore && !hasCookieToken(config)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LOW (confidence: medium) — two things to confirm in this branch.

const profileAgent = getProfileAgentContext(argv)
if (profileAgent) {
  const config = resolveConnectionConfig(argv)
  const context: ResolvedContext = { endpoint: endpoint!, studio: profileAgent }
  if (config.tokenStore && !hasCookieToken(config)) {

1. resolveConnectionConfig runs twice here. getProfileAgentContext already calls it internally (commands/studio-context.ts:135) to build the context it just returned. It is a pure re-read (TOML parse + env merge), so this is cost and a second chance to diverge, not a correctness bug — but the config is already available one frame up if you'd rather return it.

2. The config.tokenStore gate is asymmetric with the branch below. This branch requires a token store; the getStudioContext branch (line 656) requires only !hasCookieToken(config).

tokenStore is attached only when the profile has an OAuth pointer and no explicit credential and auth_type permits oauth (connection/config.ts:219-222). So a profile carrying an [agent] block plus a pat gets no refresh path at all — its expired agent token stays fatal — while the same profile with the [agent] block removed would refresh via a full PAT re-login. That is an odd shape for the feature to have: the profiles most likely to carry a hand-pasted legacy agent token are also the ones most likely to be PAT-based.

If the gate is deliberate ("only fall back to an OAuth session, never to a PAT login"), a one-line comment would carry it. If not, !hasCookieToken(config) alone matches the other branch and forceRefreshToken handles the PAT case fine (acquireToken(config, true) → no store → full login).

Comment on lines +637 to +648
// A cookie-pinned profile has no credential to re-exchange — its token IS the
// cookie — so leave `refresh` unset and let a 401 surface. Everything else
// (OAuth session, PAT, password) can mint a fresh token via forceRefreshToken.
if (!hasCookieToken(config)) {
context.refresh = async () => {
await forceRefreshToken(config)
// For a profile carrying an [agent] block this deliberately swaps identity:
// the hand-pasted legacy agent token is what the server just rejected, so
// the profile's own login takes over the session from here.
context.studio = await getStudioContext(argv, { allowMissingWorkspace: true })
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

MEDIUM (confidence: high) — the guard tests the wrong property, so a credential-less profile pays a ~6 s dead-end login on every 401.

  if (!hasCookieToken(config)) {
    context.refresh = async () => {
      await forceRefreshToken(config)

"not a cookie" is not the same as "can mint a fresh token". Trace a profile that carries only a [profiles.<n>.agent] block — no oauth = "<id>" pointer, no pat, no username/password — which is exactly the shape getProfileAgentContext exists to serve:

  1. resolveConnectionConfig attaches cfg.tokenStore only when the profile has an OAuth pointer or a legacy inline subtable (connection/config.ts:219), so there is no store here.
  2. forceRefreshTokenacquireToken(config, true) → no store, no candidate → fetchToken(config) (clickzetta-sdk/src/auth/token.ts:181-184).
  3. config.pat is "", so that lands on loginWithPassword(baseUrl, "", "", instance) (token.ts:46-53; DEFAULT_CONNECTION seeds username/password as empty strings).
  4. loginWithRetry retries every non-InterfaceError failure — 6 attempts with 200/400/800/1600/2000 ms of backoff (auth/login.ts:105-181).

So each 401 costs ~5.2 s of sleeps plus six round trips and then gets swallowed by the catch below, and the user is shown the same 401 they would have seen instantly. The SDK already names the condition you want: hasLoginCredentials (token.ts:40-42) documents this exact dead end ("wastes ~6s of retries and ends in a misleading 'Login failed'"). Gating on "config has a tokenStore, or a pat, or a username+password" rather than on the cookie check would express the real precondition.

Comment on lines +705 to +709
} catch {
// The refresh itself failed (network, revoked credential, workspace
// lookup). The 401 below is the diagnosis the caller asked for, so
// report that rather than the refresh's own error.
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

MEDIUM (confidence: high) — this discards the one error that tells the user how to recover.

      } catch {
        // The refresh itself failed (network, revoked credential, workspace
        // lookup). The 401 below is the diagnosis the caller asked for, so
        // report that rather than the refresh's own error.
      }

The premise holds for a network blip, but not for the dominant case. When the refresh token is dead the SDK raises SESSION_EXPIRED whose message exists precisely to be shown: OAuth session expired and could not be refreshed (…). Run `cz-cli auth login <name>` to sign in again. (clickzetta-sdk/src/auth/token.ts:65-71, thrown at :125). Swallowing it leaves the user with HTTP 401: token expired and no route forward — which is the state this PR is trying to get them out of. Attaching the refresh failure to the thrown AnalyticsHttpError (as extra, or as a second line in the message) keeps the 401 as the primary diagnosis without dropping the actionable half.

Secondary, lower confidence: the catch also swallows control flow that has already written output. handledError calls error() — which does process.stdout.write(...) (src/output/index.ts:132) — before throwing. Any handledError reached inside context.refresh() would therefore emit one error document to stdout, get swallowed here, and then the 401 emits a second one, giving two JSON documents on stdout for a single command. allowMissingWorkspace: true skips the three handledError branches in getStudioContext today, so I could not construct a live path — but the catch is what makes it silent if one is ever added.

Comment on lines +697 to +703
if (response.status === 401 && !didRefresh && context.refresh) {
didRefresh = true
try {
context.refreshPromise ??= context.refresh().finally(() => {
context.refreshPromise = undefined
})
await context.refreshPromise

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LOW (confidence: medium) — didRefresh is per-call, so "refresh at most once" holds per request but not per context.

    if (response.status === 401 && !didRefresh && context.refresh) {
      didRefresh = true
      try {
        context.refreshPromise ??= context.refresh().finally(() => {
          context.refreshPromise = undefined
        })

refreshPromise lives on ctx but is cleared as soon as it settles, and every caller sharing that ctx arrives with a fresh didRefresh = false. Callers that issue many sequential requests against one context — the poll loop at analytics-agent.ts:957, the pagination loop at :1239, the batch loop below it — will therefore attempt a refresh on each request that 401s. Against a server that keeps rejecting the freshly minted token, a default 360 s session run does one extra request and one OAuth refresh-token rotation per 2 s poll.

No infinite loop (didRefresh bounds each individual request at two attempts), so this is a cost/rotation-churn issue rather than a hang. If the intended guarantee was "one refresh per resolved context", the flag belongs next to refreshPromise on ResolvedContext.

Separately: I checked every requestAnalytics/requestAnalyticsData call site that shares a ctx and they are all sequential — no Promise.all — so the ??= coalescing is currently unreachable defensive code rather than something load-bearing.

Comment on lines +643 to +646
// For a profile carrying an [agent] block this deliberately swaps identity:
// the hand-pasted legacy agent token is what the server just rejected, so
// the profile's own login takes over the session from here.
context.studio = await getStudioContext(argv, { allowMissingWorkspace: true })

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Question — please confirm the intent (MEDIUM, confidence: medium). Not asserting a bug; the deliberate part is documented, but I do not think the mid-flight case is.

      // For a profile carrying an [agent] block this deliberately swaps identity:
      // the hand-pasted legacy agent token is what the server just rejected, so
      // the profile's own login takes over the session from here.
      context.studio = await getStudioContext(argv, { allowMissingWorkspace: true })

Swapping identity when the first request 401s is defensible. But ResolvedContext is shared across a whole multi-request flow, and the new test confirms the swap moves tenantId (55 → 10), which buildUrl puts in the query string. So a refresh that fires partway through a flow retargets the remainder at a different tenant than the part that already ran:

  • executeSessionRunCommand (:936-960) resolves ctx, gets a questionId from sessionRun under tenant 55, then polls sessionResult with the same ctx. A 401 on a poll swaps to tenant 10 and keeps polling for a questionId that was minted in 55. The retried poll returns 200, so isTerminalResponse decides what happens next — if the other tenant answers with a non-terminal shape rather than a business error, the do/while keeps polling to the 360 s deadline.
  • runBatchStatusChange (:1228-1239) and listAllDatasets (:1322) paginate under one ctx; a mid-loop swap means pages 1..n came from one tenant and n+1.. from another, silently merged into one result set.
  • uploadKnowledgeFile (:1656-1683) requests a presigned URL, PUTs the bytes, then calls knowledgeUploadComplete with a nodeId from the first tenant.

Was the intent to allow the swap only on the first request of a context (i.e. during resolution), and to let a 401 on a later request surface instead? That would keep the fix for the reported symptom while removing the split-identity flows.

Comment thread packages/cz-cli/src/commands/auth.ts Outdated
Comment on lines +34 to +38
* Hide the inherited global connection flags from `auth` help. `auth` manages
* sessions, not a connection you read: --profile selects which profile to READ
* (irrelevant — auth operates on sessions/tokens), and jdbc/service/instance/…
* describe a connection auth doesn't consume. Hidden (not removed) so parsing
* stays intact; `auth login` re-shows the few flags it genuinely uses.
* sessions rather than making a data request; --profile is still accepted so
* list/status can report the selected profile, while jdbc/service/instance/…
* describe connection details auth does not consume. Hidden (not removed) so
* parsing stays intact; `auth login` re-shows the few flags it genuinely uses.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LOW (confidence: high) — the docstring now says --profile is meaningful for list/status, but the code below still hides it, so cz-cli auth status --help does not mention it (auth.ts:46: .option("profile", { hidden: true })).

 * sessions rather than making a data request; --profile is still accepted so
 * list/status can report the selected profile, …

That matters more than usual here because agent-system-prompt.ts was changed in this same PR to instruct the model to pass --profile on auth status/auth list. A model that consults --help before composing the command sees no such flag. The other seven options in the list genuinely are unconsumed by auth; profile no longer is. Consider un-hiding it, or re-showing it on the list/status builders the way auth login re-shows its credential flags.

Comment on lines +98 to +101
function selectedProfile(argv: GlobalArgs): string | undefined {
if (typeof argv.profile === "string" && argv.profile.length > 0) return argv.profile
return Profile.current()
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LOW / behavior change worth confirming (confidence: high on the mechanism, medium on whether it is intended).

function selectedProfile(argv: GlobalArgs): string | undefined {
  if (typeof argv.profile === "string" && argv.profile.length > 0) return argv.profile
  return Profile.current()
}

Profile.current() is ConnectionEnv.profileName() ?? getDefaultProfileName() (connection/profile-context.ts:49-51), so this changes active_profile in two ways, not one. The --profile half is the fix and is covered by the new test. The CZ_PROFILE half is a second, untested change: any environment that exports CZ_PROFILE now gets a different active_profile / active_session / logged_in from cz-cli auth status --format json than it did before, with no flag on the command line. That includes the agent runtime, where run-cli.ts:758 (if (profileOverride) ConnectionEnv.pin(profileOverride)) pins it for the whole process. Probably the point of the PR — worth stating so it is a decision rather than a side effect.

Two smaller notes:

  • Because run-cli.ts already expands --profile into CZ_PROFILE before the command runs, the explicit-argv branch is redundant on the real CLI path; both branches return the same name. It is load-bearing only for library-style calls. Fine as written, just not doing the work the new test appears to attribute to it.
  • No test covers the CZ_PROFILE path, and the pre-existing test/auth-list-format.test.ts spawns with env: { ...process.env, … }, so a CZ_PROFILE present in the runner's environment would now flip its expect(lines[0].startsWith("uat\ttrue\t")) assertion. Unsetting CZ_PROFILE in both spawn helpers would make the suite independent of the ambient environment. I cannot run the tests, so this is from reading the fixture, not from an observed failure.

@github-actions

Copy link
Copy Markdown
Contributor

Review summary

A. Upstream invasiveness — no issues found

This PR touches nothing under packages/opencode, packages/tui, or packages/core. All seven changed files are in packages/cz-cli (three src/, four test/). No banner or ledger entry is required, and none of the existing INTRUSIVE entries in UPSTREAM-PATCHES.md are affected. The cz_change: comments visible in the files I read while tracing this change (connection/profile-context.ts, connection/profile-store.ts, run-cli.ts) are inside the cz layer and are ordinary explanatory comments.

New import edges are all intra-package (commands/analytics-agent.ts -> connection/cookie-token.ts, connection/config.ts), and forceRefreshToken comes from @clickzetta/sdk, which that file already imported. No new cross-package edge.

B. Clean fix vs. hole drilled around the problem — mostly the right fix, two findings

The core decision is right and worth saying so: the 401 retry goes into requestAnalytics, the one function every Analytics Agent call routes through, rather than into individual command handlers. I checked every call site — the only other fetch in the file is the presigned-URL PUT at analytics-agent.ts:1669, which carries no token, so there is no second path left uncovered. Rebuilding the URL per attempt (not just the headers) is correct, since buildUrl embeds tenantId.

Two findings, both about guards/fallbacks rather than the structure:

  • The refresh guard tests the wrong property!hasCookieToken(config) is not the same as "this config can mint a token". A profile carrying only an [agent] block gets refresh installed, and forceRefreshToken then falls through to a password login with empty credentials: 6 attempts, ~5.2s of backoff, swallowed. The SDK already names the right condition (hasLoginCredentials, auth/token.ts:40). MEDIUM.
  • The bare catch {} hides SESSION_EXPIRED — that error exists solely to tell the user to run cz-cli auth login <name>; dropping it leaves a bare HTTP 401 and no route to recovery. MEDIUM.

Plus one lower-severity structural note: didRefresh is per-call, so "refresh once" does not hold per context — the poll and pagination loops can refresh once per request. LOW.

No dead code, no commented-out code, no leftover debug logging, no copy-pasted logic. The agent-system-prompt.ts line and the hideConnectionGlobals docstring edit are both directly downstream of the auth --profile change, not drive-bys.

C. Regression risk

Behavioral changes I found, and their test coverage:

  1. auth status / auth list report a different active_profile. Previously data.default_profile; now --profile, then CZ_PROFILE, then default_profile. The --profile tier is covered by the new test/auth-profile-override.test.ts. The CZ_PROFILE tier is not covered by any test, and it changes output for callers that set nothing on the command line — including the agent runtime, which pins CZ_PROFILE for the whole process. Raised separately: fix(cli): honor profiles and refresh analytics tokens #84 (comment)
  2. Output shape of auth status / auth list is unchanged — same keys, same rowsKey: "sessions". Only the values of active_profile / active_session / logged_in / active can differ. Scripts parsing these will keep parsing; they may read a different profile.
  3. Analytics Agent commands can now issue two HTTP requests where they issued one, and can change identity between them. Covered for the happy path by the two new tests in analytics-agent-session-run.test.ts (single-request service enabled). No test covers a 401 arriving mid-flow — on a session run poll, mid-pagination, or between the presigned PUT and uploadUrlComplete. Raised separately: fix(cli): honor profiles and refresh analytics tokens #84 (comment)
  4. resolveConnectionConfig(argv) is now called unconditionally in resolveAnalyticsContext. I checked whether this adds a new failure mode (it can throw PROFILE_NOT_FOUND / INVALID_AUTH_TYPE) and it does not: getProfileAgentContext and getStudioContext both already call it on every path through this function, so any config that threw before still throws, at the same point.
  5. No tests deleted, skipped, or loosened. The two mock.module edits widen the mock (spreading the real module) rather than narrowing assertions, which is required now that connection/config.ts is in the import graph.
  6. No changed defaults, no renamed or removed flags, no changed exported API surface, no changed on-disk paths. ResolvedContext gained two optional fields; it is module-private, and I checked all 30-odd requestAnalytics / requestAnalyticsData / resolveAnalyticsContext call sites in the file — every one passes ctx through opaquely, none constructs a ResolvedContext literal.

One coverage gap not tied to a specific line: the retry is exercised only against status === 401. If the Analytics Agent expresses an expired token as a 200 with a business error code, or as a 403, none of this engages — worth confirming 401 is the only shape the server uses.

I could not run the build or the test suite in this environment, so nothing above is a claim that any test passes.

Comment on lines +628 to 631
function canRefreshCredential(config: ConnectionConfig): boolean {
if (hasCookieToken(config)) return false
return Boolean(config.tokenStore || config.pat || (config.username && config.password))
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

MEDIUM (confidence: medium-high) — config.tokenStore is not proof that a refresh has "anything to re-exchange", so this guard still admits the ~6 s dead end its docstring says it prevents.

function canRefreshCredential(config: ConnectionConfig): boolean {
  if (hasCookieToken(config)) return false
  return Boolean(config.tokenStore || config.pat || (config.username && config.password))
}

The docstring says this "Mirrors the SDK's own hasLoginCredentials guard" — but that function is Boolean(config.pat || (config.username && config.password)) (clickzetta-sdk/src/auth/token.ts:40-42); it deliberately excludes the token store. tokenStore is attached whenever the profile has any oauth = "<id>" pointer (connection/config.ts:219-222), regardless of whether that pointer resolves.

When it does not resolve, makeProfileTokenStore(...).load() returns undefined (profile-store.ts:648, 657-658, 669-672 — missing [oauth.<id>] section, or an unparseable/corrupt file). acquireToken then finds no candidate, skips refreshOrLogin entirely, and goes straight to fetchToken(config)loginWithPassword(baseUrl, undefined, undefined, instance) (token.ts:181-185, 44-53). That is the credential-less login token.ts:80-83 warns about: "We must NOT attempt a password login with empty credentials (it wastes ~6s of retries and ends in a misleading 'Login failed')". The SESSION_EXPIRED short-circuit that normally prevents it only exists on the refreshOrLogin path, which is never reached without a candidate.

Failure scenario: a profile whose [oauth.<id>] section was pruned by pruneOrphanOAuthSections while profiles.<n>.oauth still points at it. An Analytics Agent 401 now spends ~6 s of login retries and reports HTTP 401: … \nToken refresh also failed: Login failed …, where before it failed immediately with the server's own message.

Gating on a loadable token rather than a configured store closes it, e.g. config.tokenStore?.load() !== undefined || config.pat || (config.username && config.password). load() is synchronous and file-local, so it fits the "cheap, synchronous check" shape this guard is going for.

const oauth = (data.oauth ?? {}) as Record<string, OAuthEntry>
const profiles = (data.profiles ?? {}) as Record<string, Record<string, unknown>>
const activeProfile = typeof data.default_profile === "string" ? data.default_profile : undefined
const activeProfile = selectedProfile(argv)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

MEDIUM (confidence: high) — with an inherited CZ_PROFILE, this change can make auth status report "not logged in" on a machine that is logged in, and it drops the one field that would explain why.

const activeProfile = selectedProfile(argv)

if (!activeProfile || !profiles[activeProfile]) {
  return success({ logged_in: false, active_profile: null }, { format })
}

selectedProfile's docstring (line 96–98) justifies returning a nonexistent name because "both callers look the name up and handle the miss, and reporting it keeps a dangling default_profile visible instead of rendering as 'no profile at all'". runStatus does the opposite — it collapses the name to null, which is "no profile at all".

That matters because auth is deliberately not in PROFILE_REQUIRED_COMMANDS (run-cli.ts:30-43). The comment at run-cli.ts:738-745 is explicit that the exemption exists so a stale exported CZ_PROFILE does not lock users out of "profile create, profile use, auth list". So an inherited CZ_PROFILE reaches selectedProfile unvalidated, and auth status/auth list now follow it:

export CZ_PROFILE=deleted_profile   # removed by `auth logout`, still exported
cz-cli auth status                  # before: default_profile's live session
                                    # after:  {"logged_in": false, "active_profile": null}
cz-cli auth list                    # after:  active_profile null, every session active:false

The user has a healthy default_profile and a valid token, and is told they are not logged in, with no mention of deleted_profile. An explicitly typed --profile bogus is fine — run-cli.ts:752-763 sets profileMustExist whenever the name was typed, so that exits PROFILE_NOT_FOUND. Only the inherited-env case lands here.

Smallest correct change: keep the name when it resolved but was not found, e.g. success({ logged_in: false, active_profile: activeProfile ?? null, profile_missing: activeProfile !== undefined }, ...). That matches the docstring and makes both the dangling-default_profile and stale-CZ_PROFILE cases self-diagnosing.

No test covers a CZ_PROFILE naming a profile absent from [profiles] — the new auth-profile-override.test.ts cases all point at profiles that exist, and envFor deletes CZ_PROFILE rather than exercising a stale one.

requestInfo(url, route, argv, studio.tenantId, response.status, responseRequestId(text)),
)
}
context.committedTenantId ??= studio.tenantId

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

HIGH (confidence: high) — committedTenantId is never updated after an identity swap, and combined with runBatchStatusChange's per-target catch that lets a batch finish under a tenant it did not start in — the exact outcome the guard above says it refuses.

context.committedTenantId ??= studio.tenantId

??= means the value is pinned to the first successful response and never revised. That is fine as long as the tenant-mismatch throw at line 737 is terminal for the whole flow — but it is not, because refresh() has already replaced context.studio wholesale and set context.refreshed = true before the throw, and one caller catches and continues:

runBatchStatusChange (lines 1307-1323) shares one ResolvedContext across every target and records failures rather than aborting:

try {
  await applyOne(target.id, ctx)
  succeeded++
} catch (err) {
  failed++
  results.push({ id: target.id, name: target.name, result: "failed", error: message })
}

Failure scenario — cz-cli analytics-agent simple-metric disable --all, profile with [agent] tenant_id = 55 and an OAuth session on tenant 10 (this PR's own fixture):

  1. the paginated list (line 1281) succeeds under tenant 55 → committedTenantId = 55
  2. target feat: cz-cli distribution — a2a format, provider, profile gate #1's simpleMetricDisable 401s → refresh() swaps context.studio to tenant 10 → mismatch → throws
  3. the catch records target feat: cz-cli distribution — a2a format, provider, profile gate #1 as failed and continues
  4. targets [Feature] Support mapping configuration injection for MULTI_DI sync tasks #2..#N are issued with ?tenantId=10 and ids enumerated from tenant 55's catalog. refreshed is true so the guard never runs again, and ??= leaves committedTenantId at 55, so nothing detects the split.

The command exits reporting succeeded for DISABLE writes against whatever objects hold those numeric ids in tenant 10.

The smaller correct change is to make the mismatch terminal for the context rather than for one request — on detecting it, clear context.refresh and set an abandoned flag that requestAnalytics checks before its first fetch, so every later call on that ctx fails fast regardless of what the caller's catch does. runBatchStatusChange is the caller that makes the current shape unsafe today, but any future per-item loop inherits the same hole.

Comment on lines +728 to +731
context.refreshPromise ??= context.refresh().finally(() => {
context.refreshPromise = undefined
})
await context.refreshPromise

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LOW (confidence: high) — refreshPromise is dead code: the coalescer can never observe a pending promise.

context.refreshPromise ??= context.refresh().finally(() => {
  context.refreshPromise = undefined
})
await context.refreshPromise

The enclosing guard is if (response.status === 401 && !context.refreshed && context.refresh), and context.refreshed = true is assigned synchronously on the line immediately before this block. Any second caller reaching the 401 check after that assignment fails !context.refreshed and never gets to the ??=; any caller that got there before it would have had to interleave between two adjacent synchronous statements, which cannot happen. So ??= always sees undefined, and the .finally that clears it is unreachable as a coalescing mechanism.

It is also unnecessary in practice: every flow that shares a ResolvedContext is sequential — the poll loop (do { await requestAnalytics(...) } while, line 998-1002), the pagination loop (line 1280), the batch apply loop (line 1307), and the upload handshake all await one request at a time. There is no Promise.all over a shared ctx.

If the intent was to make concurrent callers wait for one refresh rather than fail, refreshed is the wrong gate — it needs to be the promise itself (if (status === 401 && context.refresh && !context.refreshedOnce)await (context.refreshPromise ??= ...), setting refreshedOnce after the await). If concurrency was never in scope, dropping refreshPromise from ResolvedContext and awaiting context.refresh() directly says the same thing in two fewer moving parts.

signal: AbortSignal.timeout(300_000),
})
const text = await response.text()
if (response.status === 401 && !context.refreshed && context.refresh) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

MEDIUM (confidence: low — please confirm the wire behaviour) — refresh keys on the HTTP status only, but this file documents that this backend does not use HTTP status for errors.

if (response.status === 401 && !context.refreshed && context.refresh) {

Line 814-816 of this same file states:

Analytics Agent backend always returns HTTP 200, using success: false inside the envelope to signal business errors.

If an expired token is reported that way — HTTP 200 with a business code in the envelope, which extractBusinessError then turns into an AnalyticsBusinessError — none of this refresh machinery fires, and the user still sees the raw auth business error. All five new tests construct new Response(..., { status: 401 }) by hand, so they cannot distinguish "the backend really 401s on expiry" from "we assumed it does".

Two things worth stating in the PR:

  1. Was a real expired-credential response captured, and was it an HTTP 401? If it was a 200-with-envelope, the condition needs to also inspect extractBusinessError(JSON.parse(text)) for the auth code, and the fix as written does not address the reported symptom.
  2. If the backend does both (401 from the gateway, 200-with-code from the app), the condition covers one of the two paths.

Not asserting this is a bug — the author may have the capture. But the file's own comment and the test fixtures point in opposite directions, and only one of them can be right.

Comment on lines +718 to +723
const response = await fetch(url, {
method: route.method,
headers,
...(route.method === "GET" ? {} : { body: JSON.stringify(requestBody) }),
signal: AbortSignal.timeout(300_000),
})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LOW (confidence: high) — moving fetch inside the retry loop doubles the effective per-call wall-clock budget, and session run's poll deadline cannot claw it back.

const response = await fetch(url, {
  ...
  signal: AbortSignal.timeout(300_000),
})

AbortSignal.timeout(300_000) is now constructed per attempt rather than once per call. A 401 followed by a retry gives a single requestAnalytics up to 300 s + refresh time + 300 s ≈ 10 minutes before it returns, where before the change 300 s was the ceiling.

In executeSessionRunCommand the poll loop checks its deadline only between requests:

payload = await requestAnalytics(argv, ROUTES.sessionResult, pollBody, {}, ctx)
if (isTerminalResponse(payload) || Date.now() >= deadline) break

so a --timeout-ms 360000 run can sit for ~10 minutes inside one poll before the deadline is even consulted. The user-visible symptom is --timeout-ms being silently exceeded by ~2.7x while the spinner keeps running.

Hoisting one AbortSignal.timeout(300_000) above the while (true) preserves the previous ceiling and makes the retry share the original budget rather than get a fresh one — which is also the more defensible semantic, since the point of the budget is "how long the caller waits", not "how long each attempt gets".

endpoint: endpoint!,
studio: profileAgent ?? (await getStudioContext(argv, { allowMissingWorkspace: true })),
}
if (canRefreshCredential(config)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LOW (confidence: medium-high) — scope question, not a defect: the same expired-credential failure exists on the shared studio path, which this PR leaves unfixed.

if (canRefreshCredential(config)) {

Every other Lakehouse command family — task, dqc, runs, attempts, datasource, integration, job, workspace-param — reaches the backend through studioRequest (clickzetta-sdk/src/studio/client.ts:10-55), which builds its ClientOptions from StudioConfig and never sets opts.config:

const opts: ClientOptions = {
  baseUrl: config.baseUrl,
  token: config.token,
  customHeaders: { ... },
}

client.ts:116-130 only force-refreshes when opts.config is present:

if (resp.status === AUTH_EXPIRED_STATUS && attempt < MAX_RETRIES) {
  ...
  if (opts.config) { const fresh = await forceRefreshToken(opts.config); ... }
}

so those commands retry a 401 with the same rejected token and then fail. Threading the ConnectionConfig into studioRequest's ClientOptions would fix all eight families in one place with machinery that already exists.

I don't think that makes this PR wrong — the Analytics Agent path genuinely cannot use studioRequest (different envelope, tenantId in the query string, openSessionAuth body merging), so a local retry here is defensible. But it's worth saying whether the shared path was considered and deferred, because the identity-swap and tenant-guard reasoning developed here is the interesting part and it will have to be re-derived if studioRequest is fixed later.

Also flagging a new dependency edge for confirmation: commands/analytics-agent.ts now imports forceRefreshToken and type ConnectionConfig from @clickzetta/sdk, plus connection/cookie-token.js and connection/config.js. @clickzetta/sdk and connection/* were already imported here (createTraceparent, mergeHeaders, readAgentEndpoint), so no package boundary is newly crossed — just confirming that's intended rather than incidental.

Comment on lines 48 to 49
return y
.option("profile", { hidden: true })
.option("jdbc", { hidden: true })

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LOW (confidence: high) — un-hiding profile at the group level also un-hides it on auth logout, where it does nothing.

return y
  .option("jdbc", { hidden: true })

hideConnectionGlobals is applied to the group and to each subcommand builder, including logout (line 120). The new comment justifies visibility for list/status — "the agent prompt tells the model to pass --profile on exactly those two commands" — but logout operates on its <name> positional (the [oauth.<name>] id) and never reads argv.profile. So cz-cli auth logout --help now advertises a flag that is silently ignored, and the agent prompt change in agent-system-prompt.ts ("pass --profile <name> on every cz-cli command that should use it") makes it more likely a model tries auth logout --profile x and misreads the result.

.option("profile", { hidden: true }) in logout's own builder would keep list/status visible while leaving logout as it was. Style-adjacent, so entirely fine to leave — noting it because the group-level comment now covers three subcommands with one rationale that applies to two of them.

@github-actions

Copy link
Copy Markdown
Contributor

Review summary

A. Upstream invasiveness — no issues found

No file under packages/opencode, packages/tui, or packages/core is touched. All eight changed files are under packages/cz-cli/. No banner or UPSTREAM-PATCHES.md ledger entry is required, and the cz_change:-style comments in the touched cz files are ordinary explanatory comments.

B. Clean fix vs. hole drilled around the problem — mostly clean, three items

The core design is the right shape: the 401 refresh lives at the one place that owns the Analytics Agent request (requestAnalytics), it reuses the SDK's existing forceRefreshToken rather than reimplementing rotation, and the rejectedTokenIsSdkOwned split between "the [agent] token is not the SDK's credential" and "the SDK's own token was refused" is a real distinction with real consequences, correctly reasoned. Likewise auth status/auth list routing through Profile.current() instead of re-reading default_profile locally is the right call, and the docstring's reasoning about drift is sound.

Three things to look at:

  • canRefreshCredential does not mirror what it claimsconfig.tokenStore is attached to any profile with an oauth = "<id>" pointer, resolvable or not, so a dangling pointer still reaches the credential-less loginWithPassword dead end the docstring says it prevents. Inline on lines 628-631.
  • refreshPromise is dead coderefreshed = true is set synchronously on the line before the ??=, so the coalescer can never observe a pending promise, and every shared-ctx flow is sequential anyway. Inline on lines 728-731.
  • Scope — the same expiry breaks task/dqc/runs/attempts/datasource/integration/job/workspace-param, because studioRequest builds ClientOptions without opts.config and so client.ts's own 401 refresh branch is inert for them. The Analytics Agent path genuinely can't use studioRequest, so a local retry here is defensible — worth saying whether the shared fix was considered and deferred. Inline on line 658.

C. Regression risk

Behavioral changes I can see, with coverage:

Change Covered by
auth status / auth list active_profile + active_session now follow --profile / CZ_PROFILE instead of default_profile new auth-profile-override.test.ts (3 cases, all with profiles that exist)
--profile visible in auth {list,status,logout} --help none
requestAnalytics may now issue two HTTP requests per call new cases in analytics-agent-session-run.test.ts
Per-call wall-clock ceiling rises from 300 s to ~600 s + refresh none
Error message gains a Token refresh also failed: … suffix, which scripts matching on the HTTP <status>: … shape may parse none

Three carry real risk and have inline findings:

  • Cross-tenant writes in analytics-agent … --all (HIGH) — committedTenantId ??= is never revised after an identity swap, and runBatchStatusChange's per-target catch lets the batch continue under the new tenant with ids enumerated from the old one. Inline on line 758.
  • First request of a flow bypasses the tenant guard (HIGH) — committedTenantId is undefined until something succeeds, so a user-supplied --session-id / --id can be retried under a different tenant. Inline on lines 736-741.
  • auth status can report logged_in: false on a logged-in machine (MEDIUM) — an inherited stale CZ_PROFILE reaches selectedProfile unvalidated (auth is deliberately outside PROFILE_REQUIRED_COMMANDS), and runStatus reports active_profile: null, dropping the name that explains the failure. Inline on line 242.

No tests were deleted, skipped, or loosened. The mock.module spread in analytics-agent-id-validation.test.ts and analytics-agent-knowledge-domain-id.test.ts widens those mocks to keep the real profile-store exports — necessary now that analytics-agent reaches connection/config.ts, and correctly explained. envFor's delete env.CZ_PROFILE in the two auth test files is the right guard given what this PR makes CZ_PROFILE control. I did not run anything, so nothing here is a claim that the suite passes.

One item I could not verify and raised as a question: whether an expired credential actually arrives as HTTP 401, given this file's own comment that the backend "always returns HTTP 200, using success: false inside the envelope". All five new tests hand-construct the 401. Inline on line 725.

Comment on lines +736 to +741
if (context.committedTenantId !== undefined && context.studio.tenantId !== context.committedTenantId) {
throw new AnalyticsHttpError(
`HTTP 401: the credential expired mid-request and the refreshed session belongs to tenant ${context.studio.tenantId}, not ${context.committedTenantId}. Re-run the command to start over under the new session.`,
requestInfo(url, route, argv, studio.tenantId, response.status, responseRequestId(text)),
)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

HIGH (confidence: medium-high) — the tenant guard is inert on the first request of a flow, which is exactly where a user-supplied resource id is at risk.

if (context.committedTenantId !== undefined && context.studio.tenantId !== context.committedTenantId) {

committedTenantId is only assigned after a successful response (context.committedTenantId ??= studio.tenantId, line 758). So on the first request it is undefined and the condition short-circuits — the retry proceeds under whatever tenant the refresh landed on.

Failure scenario, using this PR's own test fixture ([profiles.test.agent] tenant_id = 55, OAuth session tenant 10):

cz-cli analytics-agent session run --session-id 7 --msg hello

--session-id 7 was minted earlier under tenant 55. The [agent] token is expired, so the very first sessionRun 401s, refresh() swaps identity to the OAuth login, and the retry re-issues the same request with ?tenantId=10 and session-id 7 — a session id that tenant 10 either does not own or, worse, owns as a different session. The guard the comment describes ("Refuse instead of splitting the flow in two") never runs, because nothing has committed yet.

The same applies to every numeric id taken from argv: --id, --domain-id, --dataset-id, and the enable/disable/update routes that write.

The root cause is that committedTenantId records "a tenant a request succeeded under" when what the guard actually needs is "the tenant this flow was resolved under". Seeding it at context construction — committedTenantId: context.studio.tenantId in resolveAnalyticsContext — makes the check fire on the first attempt too, and costs nothing on the pure-401-at-startup case where the tenant happens to be unchanged.

Comment on lines +787 to +792
if (!response.ok) {
const detail = refreshError instanceof Error ? refreshError.message : refreshError ? String(refreshError) : undefined
throw new AnalyticsHttpError(
`HTTP ${response.status}: ${text.slice(0, 500)}${detail ? `\nToken refresh also failed: ${detail}` : ""}`,
requestInfo(url, route, argv, studio.tenantId, response.status, responseRequestId(text)),
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

MEDIUM — the refresh error is dropped on the 200-envelope path, which is half the reason authRejected exists. Confidence: high.

    if (!response.ok) {
      const detail = refreshError instanceof Error ? refreshError.message : refreshError ? String(refreshError) : undefined

authRejected deliberately matches two shapes: HTTP 401, and HTTP 200 carrying success: false + code: "401". But the combined message is only built under !response.ok, so on the 200 shape:

  1. authRejected(response, text) → true via the envelope branch.
  2. context.refresh() throws (revoked refresh token → SESSION_EXPIRED, "Run cz-cli auth login <name>"; or a network blip).
  3. refreshError is set, so the tenant guard is skipped and if (refreshError === undefined) continue is false.
  4. if (!response.ok) is false — we fall straight through.
  5. context.committedTenantId ??= studio.tenantId commits a tenant from a response that was auth-rejected.
  6. The 200 body is parsed and returned; requestAnalyticsData raises a bare AnalyticsBusinessError("401", "token expired").

So refreshError is silently discarded on exactly the path the comment at lines 761-764 says it is kept for ("the refresh error carries the recovery step (auth login), so report both"). The user gets 401: token expired with no indication that a refresh was attempted or why it failed.

Smallest fix: raise on authRejected + refreshError, not on !response.ok. Something like hoisting the throw into the refresh block:

      if (refreshError !== undefined) {
        const detail = refreshError instanceof Error ? refreshError.message : String(refreshError)
        throw new AnalyticsHttpError(
          `HTTP ${response.status}: ${text.slice(0, 500)}\nToken refresh also failed: ${detail}`,
          requestInfo(url, route, argv, studio.tenantId, response.status, responseRequestId(text)),
        )
      }
      continue

which also stops step 5 from committing a tenant off a rejected response.

// finds no candidate, skips the refresh path, and falls through to a login with
// empty credentials — the ~6 s dead end token.ts:80-83 warns about. Ask the store
// for an actual token (synchronous, file-local) rather than for its existence.
return Boolean(config.tokenStore?.load() || config.pat || (config.username && config.password))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

MEDIUM — this still admits the ~6 s dead end its own docstring is written to exclude. Confidence: high.

  return Boolean(config.tokenStore?.load() || config.pat || (config.username && config.password))

load() is truthy for a stored token that has no refresh_token. refresh_token is optional in parseOAuthEntrypackages/cz-cli/src/connection/profile-store.ts:590-595 only attaches refreshToken when the key is present, and parseOAuthEntry's hard requirements are access_token / expire_time_ms / obtained_at / instance_id / user_id. Legacy sections written before rotation existed are exactly this shape (the SDK calls them out: "legacy tokens without one always re-login", auth/token.ts:178-180).

For such a profile the flow is:

  • canRefreshCredentialtrue
  • forceRefreshTokenacquireToken(config, true)candidate.refreshToken is undefinedfetchToken(config)
  • no pat, no username/passwordloginWithPassword(baseUrl, undefined, undefined, …) → the "~6 s of login retries to arrive back at the same 401" this function's docstring names.

The comment two lines up says "Ask the store for an actual token … rather than for its existence", which closes the pruned-[oauth.<id>] hole but not this one. Asking for the thing that is actually re-exchangeable closes both:

  return Boolean(config.tokenStore?.load()?.refreshToken || config.pat || (config.username && config.password))

Not covered by a test — the new "a profile with no refreshable credential fails the 401 immediately" case has no [oauth.*] section at all, so tokenStore is never attached and this branch is not exercised.

Comment on lines +696 to +700
const rejectedTokenIsSdkOwned = profileAgent === undefined
context.refresh = async () => {
if (rejectedTokenIsSdkOwned) await forceRefreshToken(config)
context.studio = await getStudioContext(argv, { allowMissingWorkspace: true })
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Please confirm the intent: an expired [agent] token silently changes which tenant the command targets. Confidence: high that this is what the code does; no view on whether it is wanted.

    const rejectedTokenIsSdkOwned = profileAgent === undefined
    context.refresh = async () => {
      if (rejectedTokenIsSdkOwned) await forceRefreshToken(config)
      context.studio = await getStudioContext(argv, { allowMissingWorkspace: true })
    }

When the profile has a [profiles.<n>.agent] block, context.studio starts as getProfileAgentContext(...) — whose tenantId/userId come from the hand-pasted tenant_id/user_id in profiles.toml (studio-context.ts:131-151). The refresh replaces it with getStudioContext(...), whose tenantId is getCurrentUser(...).accountId — the OAuth login's tenant. Those are independent values, and this PR's own test asserts they differ (55 → 10 in "falls back from an expired legacy agent token to the profile OAuth session").

So a user who pinned tenant_id = 55 in their profile gets requests answered under tenant 10 with no message on either stream — success() reports the payload as if nothing happened. The [agent] block reads like a deliberate identity pin, and the alternative behaviour ("the [agent] token expired; refresh it or remove the block") would keep the pin honest.

Two things worth deciding explicitly:

  1. Is the silent identity swap the intended remedy, or should it at least warn (aiMessage / stderr) that the tenant moved? The model driving this CLI has no way to notice otherwise.
  2. If it is intended, should it be restricted to reads? See my other comment on the committedTenantId guard — as written, the swap also applies to domain delete / metric delete / datasource delete, which is where a wrong tenant stops being recoverable.

Comment on lines +624 to +638
/**
* Whether a refresh has anything to re-exchange: an OAuth token store, a PAT, or
* username+password. Gating on "not a cookie" alone is not enough — a profile
* carrying only an [agent] block reaches `fetchToken` with empty credentials and
* spends ~6 s of login retries to arrive back at the same 401. Mirrors the SDK's
* own `hasLoginCredentials` guard, which is private to auth/token.ts.
*/
/**
* Whether the response says the credential was rejected. The gateway answers an
* expired token with HTTP 401, but this backend also reports failures as HTTP 200
* with `success: false` and a code in the envelope (see extractBusinessError), so
* an envelope code of "401" counts too. Keyed on the structured code only — never
* on message prose, which is localized.
*/
function authRejected(response: Response, text: string): boolean {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LOW — orphaned docblock. Confidence: high.

Two /** … */ blocks stack up here, and the first one documents a different function:

/**
 * Whether a refresh has anything to re-exchange: an OAuth token store, a PAT, or
 * username+password. Gating on "not a cookie" alone is not enough — …
 */
/**
 * Whether the response says the credential was rejected. …
 */
function authRejected(response: Response, text: string): boolean {

The first block belongs to canRefreshCredential (line 648), which currently has no docblock — only the inline tokenStore note inside its body. As it stands authRejected carries a JSDoc describing PAT/username/password gating, which is nothing it does, and hover/IDE tooltips will show whichever one the tooling picks.

Move the first block down to line 648.

Comment on lines +17 to +23
/** CZ_PROFILE selects which profile `auth list`/`status` report on, so an ambient
* one in the runner's environment would steer these assertions. */
function envFor(home: string, overrides: Record<string, string> = {}): Record<string, string> {
const env = { ...process.env, HOME: home, CLICKZETTA_TEST_HOME: home, NO_COLOR: "1" } as Record<string, string>
delete env.CZ_PROFILE
return { ...env, ...overrides }
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LOW — the same hardening is missing at one other site that asserts the same value. Confidence: high.

function envFor(home: string, overrides: Record<string, string> = {}): Record<string, string> {
  const env = { ...process.env, HOME: home, CLICKZETTA_TEST_HOME: home, NO_COLOR: "1" } as Record<string, string>
  delete env.CZ_PROFILE

This guard is right, and it is needed precisely because selectedProfile now consults CZ_PROFILE. But packages/cz-cli/test/parameter-hardening.test.ts:665 asserts the identical thing —

expect(payload.data.active_profile).toBe("uat_0")

— against a run() helper (that file, line 65-69) that spreads process.env without deleting CZ_PROFILE:

env: { ...process.env, HOME: home, CLICKZETTA_TEST_HOME: home, NO_COLOR: "1", ...env },

Before this PR that assertion read default_profile straight off the file and was immune to the ambient environment; now it is not. Worth exporting envFor (or lifting it into test/support/cz-fixtures.ts) and using it there too, so the two active_profile assertions can't diverge on a developer machine that has CZ_PROFILE exported.

@github-actions

Copy link
Copy Markdown
Contributor

Review verdict — sections A, B, C below. All specific findings are inline comments on the lines they concern.

A. Upstream invasiveness — no issues found

All eight changed files are under packages/cz-cli/. Nothing in packages/opencode, packages/tui, or packages/core is touched, so no banner is required and UPSTREAM-PATCHES.md needs no new INTRUSIVE entry. The cz_change:-style inline comments in profile-context.ts and elsewhere are ordinary explanatory comments in the cz layer, as expected.

New import edges are all intra-package (commands/analytics-agent.tsconnection/config.ts + connection/cookie-token.ts; commands/auth.tsconnection/profile-context.ts). The only cross-package addition is forceRefreshToken from @clickzetta/sdk, an edge that already exists via createTraceparent/mergeHeaders on the same import line.

B. Clean fix, or a hole drilled around the problem

Mostly the clean fix — the 401 retry sits in the one shared requestAnalytics rather than being sprinkled per command, and resolveConnectionConfig/Profile.current() are reused instead of re-deriving the profile formula locally, which is what config.ts:24-28 and profile-context.ts:37-39 were written to prevent. Four inline findings:

  • HIGH — the cross-tenant guard never fires for a single-request command: committedTenantId is set only on success, so domain delete / metric delete / datasource delete and ~50 other executeAnalyticsCommand call sites get no guard at all. This is the one I would want resolved before merge.
  • MEDIUM — the refresh error is dropped on the HTTP-200/envelope-401 path, so the auth login recovery step never reaches the user on half of what authRejected matches.
  • MEDIUMcanRefreshCredential still admits the ~6 s login dead end it is documented to exclude, for a stored token carrying no refresh_token.
  • LOW — an orphaned docblock: the JSDoc for canRefreshCredential is stacked above authRejected.

Separately, a question about intent on the silent tenant swap when a [profiles.<n>.agent] token expires.

No dead code, no new env var or config key routing around a bug, no copy-pasted logic, no drive-by edits. The agent-system-prompt.ts one-liner is in scope — it is the change that makes --profile on auth status / auth list matter.

C. Regression risk

Behavioral changes I can identify, with coverage:

  1. auth list / auth status active_profile semantics change from default_profile to CZ_PROFILE ?? default_profile (auth.ts:100-103). Covered by the new auth-profile-override.test.ts. Two existing assertions still expect the old value under a clean env: auth-list-format.test.ts:104 (hardened by this PR) and parameter-hardening.test.ts:665 (not hardened — see the inline note). Any script parsing auth list --format json under an exported CZ_PROFILE sees a different active_profile.
  2. auth status output shape gains profile_missing, and active_profile is now the selected name where it was previously null for a missing profile (auth.ts:247-256). Additive; covered by the new test. output-row-projection.test.ts:82 pins the auth list CSV header, which is unchanged.
  3. --profile becomes visible in cz-cli auth --help, auth list --help, auth status --help; still hidden for logout (re-hidden at auth.ts:123) and login (login.ts:472). No test covers help visibility either way. Note the existing run-cli.ts:752-763 gate still errors PROFILE_NOT_FOUND for an explicitly typed bad name on every command, so auth status --profile typo does not silently report "logged out" — parameter-hardening.test.ts:196 pins that.
  4. Every analytics-agent request can now issue two HTTP calls. All ~60 routes are affected, not just the ones tested. The 300 s AbortSignal is deliberately shared across both attempts (documented at analytics-agent.ts:718-721), so a first attempt that burns most of the budget leaves the retry starved — an accepted trade, but it is new.
  5. resolveAnalyticsContext now calls resolveConnectionConfig unconditionally (analytics-agent.ts:677). No new failure mode: getProfileAgentContext (studio-context.ts:135) and getStudioContext (:66) already reach it on both branches, so PROFILE_NOT_FOUND / INVALID_AUTH_TYPE were already reachable. It does add one extra full profiles.toml read and parse per context, plus another inside the tokenStore.load() call in canRefreshCredential.
  6. Two existing tests widened their module mock (...realProfileStore in analytics-agent-id-validation.test.ts and analytics-agent-knowledge-domain-id.test.ts). Necessary for the new connection/config.ts import, but those tests now run against the real profile-store for every export other than readAgentEndpoint, so they read the real profiles.toml under CLICKZETTA_TEST_HOME. Worth a glance that neither test depended on those exports being absent.

No tests were deleted, skipped, or loosened. I could not run the suite, so nothing here is a claim that anything passes.

🤖 Generated with Claude Code

Comment on lines +773 to +784
if (
refreshError === undefined &&
context.committedTenantId !== undefined &&
context.studio.tenantId !== context.committedTenantId
) {
context.abandoned = new AnalyticsHttpError(
`HTTP 401: the credential expired mid-flow and the refreshed session belongs to tenant ${context.studio.tenantId}, not ${context.committedTenantId} which this command started under. Re-run the command to continue under the new session.`,
requestInfo(url, route, argv, studio.tenantId, response.status, responseRequestId(text)),
)
context.refresh = undefined
throw context.abandoned
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

HIGH — the cross-tenant guard never fires for a single-request command, so a delete/update can land in the wrong tenant. Confidence: high on the code path, medium on the backend consequence.

      if (
        refreshError === undefined &&
        context.committedTenantId !== undefined &&
        context.studio.tenantId !== context.committedTenantId
      ) {

committedTenantId is only ever assigned on a successful response (line 794, context.committedTenantId ??= studio.tenantId). So the guard requires that some earlier request on this context already succeeded. When the very first request 401s, committedTenantId is undefined, the guard is skipped, and continue retries under whatever tenant the refresh landed on.

That is not a rare shape — it is the shape of most of this file. executeAnalyticsCommand (line 1468) calls requestAnalytics(argv, route, body, query) with no ctx, so every one of its ~55 call sites builds a fresh context and issues exactly one request. Among them:

  • ROUTES.domainDelete (line 2112), ROUTES.datasourceDelete (2010), ROUTES.simpleMetricDelete (2845), ROUTES.knowledgeEntryDelete (3155), ROUTES.knowledgeSpaceDelete (3211), ROUTES.knowledgeNodeDelete (3298, 3393), ROUTES.domainTableRemove (2226)
  • ROUTES.domainUpdate (2091), ROUTES.simpleMetricUpdate (2712), ROUTES.datasourceUpdate (2002), ROUTES.knowledgeNodeMove (3330), ROUTES.knowledgeNodeRename (3314)

Concretely, with the [profiles.test.agent] fixture from this PR's own tests: cz-cli analytics-agent domain delete --domain-id 195 with an expired [agent] token → request 1 returns 401 under tenantId=55 → refresh swaps to the profile's OAuth identity, tenant 10continue → request 2 deletes domain 195 in tenant 10. The id came from a domain list the user or the model ran earlier under tenant 55.

The comment right above this block already states the hazard exactly — "a batch loop would apply writes to whatever holds those ids over there" — but keys the protection on "did a request already succeed" rather than on "which tenant was this command asked to target". Those coincide only for multi-request flows.

The smaller correct change is to seed the committed tenant at context construction rather than on first success, so the comparison is against the tenant the command was asked to use:

  const context: ResolvedContext = {
    endpoint: endpoint!,
    studio: profileAgent ?? (await getStudioContext(argv, { allowMissingWorkspace: true })),
  }
  context.committedTenantId = context.studio.tenantId

…and drop the ??= at line 794. Then both the batch case and the single-request delete refuse. If a cross-tenant fallback is genuinely wanted for reads (which is what the service enabled test at test #2 exercises), gate it on the route — e.g. an AnalyticsRoute flag — rather than on whether a prior request happened to succeed.

@hellozepp

Copy link
Copy Markdown
Collaborator

lgtm

1 similar comment
@hellozepp

Copy link
Copy Markdown
Collaborator

lgtm

@hellozepp
hellozepp merged commit afedc53 into main Aug 28, 2026
2 checks passed
@hellozepp
hellozepp deleted the feat/quota-sidebar branch August 28, 2026 03:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants