Skip to content

SUPER-752 host auth refresh#4738

Merged
saddlepaddle merged 13 commits into
superset-sh:mainfrom
justincrich:super-752-host-auth-refresh
May 21, 2026
Merged

SUPER-752 host auth refresh#4738
saddlepaddle merged 13 commits into
superset-sh:mainfrom
justincrich:super-752-host-auth-refresh

Conversation

@justincrich
Copy link
Copy Markdown
Contributor

@justincrich justincrich commented May 19, 2026

Overview

Linear: https://linear.app/superset-sh/issue/SUPER-752/cli-host-service-silently-breaks-when-its-auth-session-expires-never

CLI-spawned host-service starts with an OAuth access token that can expire while the daemon keeps running. The host cloud client already retries once after a 401; this PR makes that retry path refresh from the CLI auth config instead of requiring a host restart or user re-login.

What Changed

  • Config-backed host auth now returns the stored config access token normally. After JwtApiAuthProvider.invalidateCache() is called by the existing 401 retry link, the next request refreshes with auth.refreshToken, single-flights concurrent callers, atomically updates only auth, preserves rotated refresh tokens, and skips writing if the on-disk auth changed during refresh.

  • CLI-spawned host-service receives the CLI auth config path only for OAuth-backed sessions, while static token behavior remains unchanged when no config path is available.

  • Config writes use unique temp files and sanitized OAuth failures avoid exposing raw response bodies.

  • Tests cover the retained recovery path and support behavior.

    • Why: the risk is in retry timing and credential persistence, so coverage focuses on pre-401 token use, refresh after invalidation, concurrent retry callers, rotated refresh token persistence, missing-refresh-token guidance, sanitized failures, atomic config writes, spawn env handoff, and unchanged static-token fallback.
    • Review links: host auth tests, config tests, spawn tests.

Manual Verification

The CLI's own cloud commands (workspaces list, auth whoami, etc.) do not exercise this PR — they hit cloud directly with the CLI's bearer and have no retry-on-401. The refresh path lives inside the host-service, so you have to drive a cloud-bound request from inside the running host. Either route below works.

Route A — via the desktop app

  1. Log in with the CLI OAuth flow.
  2. superset start (or use the desktop's bundled host).
  3. Launch the desktop and confirm any cloud-backed view loads.
  4. Edit ~/.superset/config.json (or $SUPERSET_HOME_DIR/config.json): set auth.accessToken to "invalid.invalid.invalid". Leave auth.refreshToken intact.
  5. In the desktop, trigger any action that hits a host tRPC procedure doing ctx.api.* (open/refresh a workspace, list workspaces, etc.).
  6. Re-read the config — accessToken is now a fresh JWT (no longer invalid…); refreshToken is still present (rotated value is fine).

Route B — via curl, no desktop required

CONFIG=~/.superset/config.json   # or "$SUPERSET_HOME_DIR/config.json"
ORG=$(jq -r '.organizationId' "$CONFIG")
M=~/.superset/host/$ORG/manifest.json
ENDPOINT=$(jq -r '.endpoint' "$M")
SECRET=$(jq -r '.authToken' "$M")
INPUT='%7B%220%22%3A%7B%22json%22%3Anull%7D%7D'   # {"0":{"json":null}} urlencoded

# 1. Sanity — host hits cloud with the current token (expect HTTP 200)
curl -sS -H "Authorization: Bearer $SECRET" \
  "$ENDPOINT/trpc/workspace.cloudList?batch=1&input=$INPUT" | jq .

# 2. Corrupt accessToken on disk; keep refreshToken
jq '.auth.accessToken = "invalid.invalid.invalid"' "$CONFIG" \
  > "$CONFIG.tmp" && mv "$CONFIG.tmp" "$CONFIG"
jq '{access:.auth.accessToken[:24], refresh:.auth.refreshToken[:12]}' "$CONFIG"

# 3. Same call — host reads bad token → cloud 401 → retry link refreshes → 200
curl -sS -H "Authorization: Bearer $SECRET" \
  "$ENDPOINT/trpc/workspace.cloudList?batch=1&input=$INPUT" | jq .

# 4. Proof — accessToken rotated; refreshToken still present (possibly server-rotated)
jq '{access:.auth.accessToken[:24], refresh:.auth.refreshToken[:12]}' "$CONFIG"

Expected:

  • Step 1: HTTP 200, an array (possibly []).
  • Step 2: access shows "invalid.invalid.invalid".
  • Step 3: HTTP 200 — proves the host's 401 retry + refresh + write-back chain ran.
  • Step 4: access no longer starts with invalid; refresh still present (will be the cloud-rotated value if rotation is enabled).

Negative case (sanitized failure)

Repeat step 3 after also deleting auth.refreshToken. Expect a clean error response (no raw OAuth body, no token/cookie payload leaked) and a "log in again" hint.

Validation

bun test src/lib/auth.test.ts src/lib/config.test.ts src/lib/host/spawn.test.ts
bun test packages/host-service/src/env.test.ts packages/host-service/src/providers/auth/JwtAuthProvider/JwtAuthProvider.test.ts
bun run --cwd packages/cli typecheck
bun run --cwd packages/host-service typecheck

Summary by CodeRabbit

  • New Features

    • OAuth tokens can now be persisted in config file with automatic refresh support
    • Atomic config file writes with enhanced permission handling
  • Bug Fixes

    • Consistent error messaging for token refresh failures
    • Improved stripping of sensitive authentication environment variables from terminal sessions
  • Tests

    • Added comprehensive test coverage for config handling, authentication, and host service spawning

Review Change Stack

@capy-ai
Copy link
Copy Markdown

capy-ai Bot commented May 19, 2026

Capy auto-review is paused for this organization because the monthly auto-review limit has been reached. Increase the limit or turn it off in billing settings to resume automatic reviews.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 19, 2026

📝 Walkthrough

Walkthrough

This PR implements OAuth session token persistence by adding a config-file-backed token source in host-service. The CLI exports its config path, passes it to spawned host processes, and implements atomic writes with crash-safety. The host-service reads/refreshes tokens from the config file and wires them into the JWT provider. Error messages and sensitive environment variables are sanitized throughout.

Changes

OAuth Session Management with Config-File Persistence

Layer / File(s) Summary
CLI OAuth error standardization
packages/cli/src/lib/auth.ts, packages/cli/src/lib/auth.test.ts
Introduces LOGIN_AGAIN_SUGGESTION constant and uses it consistently in token exchange and refresh error handling, with test coverage ensuring sensitive response values are not leaked.
CLI config atomicity and export
packages/cli/src/lib/config.ts, packages/cli/src/lib/config.test.ts
Implements atomic config writes via temp-file + rename with 0o600 permissions and cleanup on failure. Exports SUPERSET_CONFIG_PATH constant. Tests verify uniqueness, rename safety, and content correctness.
CLI host spawn with auth config path
packages/cli/src/lib/host/spawn.ts, packages/cli/src/lib/host/spawn.test.ts, packages/cli/src/commands/start/command.ts, apps/desktop/src/main/lib/host-service-coordinator.ts
Extends spawnHostService to conditionally pass SUPERSET_AUTH_CONFIG_PATH environment variable to child process when OAuth auth source is active. Test verifies environment variable propagation.
Host-service config-backed token source
packages/host-service/src/providers/auth/ConfigFileSessionTokenSource/ConfigFileSessionTokenSource.ts, packages/host-service/src/providers/auth/ConfigFileSessionTokenSource/index.ts
Implements ConfigFileSessionTokenSource reading/writing OAuth tokens from config file with apiKey preference, atomic refresh with single-flight concurrency safety, temp-file writes, error sanitization, and race-condition handling.
Host-service env schema and JWT provider enhancement
packages/host-service/src/env.ts, packages/host-service/src/env.test.ts, packages/host-service/src/providers/auth/JwtAuthProvider/JwtAuthProvider.ts, packages/host-service/src/providers/auth/index.ts
Adds optional SUPERSET_AUTH_CONFIG_PATH to env schema. Extends JwtApiAuthProvider with optional onInvalidateCache callback invoked during invalidation to enable external cache invalidation.
Host-service serve wiring
packages/host-service/src/serve.ts
Conditionally wires ConfigFileSessionTokenSource to JwtApiAuthProvider when env.SUPERSET_AUTH_CONFIG_PATH is present; falls back to env.AUTH_TOKEN otherwise.
Host-service JWT auth provider tests
packages/host-service/src/providers/auth/JwtAuthProvider/JwtAuthProvider.test.ts
Comprehensive test suite verifying cached token use, refresh on invalidation with config persistence, concurrent refresh coalescing, 401 retry flows, missing refresh token error handling with sanitization, race conditions during refresh, and static token invariance.
Terminal environment variable security
packages/host-service/src/terminal/env-strip.ts, packages/host-service/src/terminal/env.test.ts
Adds SUPERSET_AUTH_CONFIG_PATH to stripped runtime keys and introduces SENSITIVE_AUTH_KEYS denylist for refresh-token environment variables. Tests verify sensitive tokens are excluded from PTY environment.

🎯 4 (Complex) | ⏱️ ~60 minutes

🐰 A token in a file, atomic and bright,
Refreshed through the night with all of its might!
Config-backed sessions, no secrets exposed—
The host and the CLI are perfectly composed! ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'SUPER-752 host auth refresh' references the issue number and succinctly describes the core feature being added—host service OAuth token refresh—which aligns with the main objective of the changeset.
Description check ✅ Passed The pull request description is comprehensive and covers Overview, What Changed, Manual Verification, and Validation sections with extensive detail on the bug fix, architecture decisions, test coverage, and reproduction steps.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint skipped: no ESLint configuration detected in root package.json. To enable, add eslint to devDependencies.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@stage-review
Copy link
Copy Markdown

stage-review Bot commented May 19, 2026

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 7

🧹 Nitpick comments (1)
packages/cli/src/lib/config.test.ts (1)

43-67: ⚡ Quick win

Restore mocks in finally to prevent cross-test leakage on assertion failure.

If a test throws before the tail cleanup lines, the spy remains active and can affect following tests.

Suggested fix
-		const writeSpy = spyOn(fs, "writeFileSync");
-		const renameSpy = spyOn(fs, "renameSync").mockImplementation(
+		const writeSpy = spyOn(fs, "writeFileSync");
+		const renameSpy = spyOn(fs, "renameSync").mockImplementation(
 			(oldPath: PathLike, newPath: PathLike) => {
 				...
 			},
 		);
-
-		writeConfig(config);
-
-		expect(writeSpy).toHaveBeenCalledWith(...);
-		...
-
-		writeSpy.mockRestore();
-		renameSpy.mockRestore();
+		try {
+			writeConfig(config);
+			expect(writeSpy).toHaveBeenCalledWith(...);
+			...
+		} finally {
+			writeSpy.mockRestore();
+			renameSpy.mockRestore();
+		}

Also applies to: 86-100

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/cli/src/lib/config.test.ts` around lines 43 - 67, The test creates
spies writeSpy and renameSpy around fs.writeFileSync and fs.renameSync while
asserting behavior of writeConfig/readConfig, but the mocks are only restored at
the end so an early assertion failure can leak spies into other tests; wrap the
test body that sets up writeSpy and renameSpy (and uses originalRenameSync,
writeConfig, readConfig, fs.statSync checks) in a try/finally and call
writeSpy.mockRestore() and renameSpy.mockRestore() inside finally so restores
always run; apply the same try/finally pattern to the other similar test block
that sets up the same spies.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/cli/src/commands/auth/logout/command.ts`:
- Around line 37-42: The loop that waits until Date.now() < deadline using
HOST_SHUTDOWN_TIMEOUT_MS and isProcessAlive(manifest.pid) can time out and still
allow execution to continue and clear credentials; change the logic so that
after the wait loop you explicitly check isProcessAlive(manifest.pid) and, if it
is still alive, throw an error or return a failure (failing the logout) instead
of proceeding to clear credentials; apply the same fix for the second similar
wait block (the code around the other wait with HOST_SHUTDOWN_POLL_MS) so both
places prevent credential clearing when the host did not stop within the
timeout.

In `@packages/cli/src/lib/config.ts`:
- Around line 41-48: The current atomic write uses a fixed tmpPath
(`${CONFIG_PATH}.tmp`) which can be clobbered by concurrent processes; change
the temp file creation to use a unique name per write (e.g., include process id
and a random/uuid suffix or use a secure temp API) so each writer writes to its
own tmp file, then chmod and fs.renameSync from that unique tmpPath to
CONFIG_PATH; update references to tmpPath, CONFIG_PATH, and the chmod/rename
logic to operate on the new unique temp file and ensure any cleanup handles the
unique filename.

In `@packages/cli/src/lib/resolve-auth.test.ts`:
- Around line 17-23: The spawned child process for the test (spawnSync using
process.execPath with the evaluated "source") inherits the parent's environment
so an existing SUPERSET_AUTH_CONFIG_PATH can bypass SUPERSET_HOME_DIR and cause
writeConfig() to target a real/shared file; update the env passed to spawnSync
to explicitly unset or remove SUPERSET_AUTH_CONFIG_PATH (e.g., do not propagate
it or set it to an empty value) when constructing the env object alongside
SUPERSET_HOME_DIR and SUPERSET_API_URL so each test uses the per-test tempHome
and stays isolated.

In
`@packages/host-service/src/providers/auth/JwtApiAuthProvider/JwtApiAuthProvider.ts`:
- Around line 233-237: The refresh logic in JwtApiAuthProvider is overwriting
newer CLI-authenticated credentials because it blindly uses the cached
currentCredential and writes/persists/wipes to the shared authConfigPath; modify
the refresh and error-handling flow (around currentCredential,
readCurrentCredential, loadSessionToken, and the invalid-grant path) to first
re-read the on-disk auth block and compare its identity/version to
this.currentCredential before persisting or clearing tokens — only persist or
remove the on-disk session when the on-disk credential still matches the
in-memory credential that triggered the refresh, otherwise skip writing (or
acquire a cross-process lock/version bump) to avoid clobbering newer
credentials.

In
`@packages/host-service/src/providers/auth/JwtAuthProvider/JwtAuthProvider.test.ts`:
- Around line 19-32: The test in JwtAuthProvider.test.ts replaces
globalThis.fetch with fetchMock but restores it only at the end, which can leak
the mock if an assertion throws; update the test around
JwtApiAuthProvider/getJwt so globalThis.fetch is restored in a finally block (or
use an afterEach) — ensure you keep the fetchMock assignment and assertions
(fetchMock, getSessionToken, provider.getJwt) but move the globalThis.fetch =
originalFetch into a finally so the original fetch is always restored even on
test failure.

In `@packages/shared/src/auth/token-refresh.ts`:
- Around line 27-36: The token refresh fetch in token-refresh.ts lacks a
timeout; wrap the request in an AbortController with a ~10 second timer, pass
controller.signal to fetch(`${apiUrl}/api/auth/oauth2/token`, ...), and clear
the timer once the fetch resolves; ensure the refreshToken flow (the function
performing the fetch) properly catches an AbortError and surfaces a
deterministic error so callers can recover instead of hanging. Reference the
fetch call and the surrounding token refresh function to place the
AbortController, timer (setTimeout -> controller.abort()), signal injection, and
timer cleanup.
- Around line 46-57: The OAuth2 JSON response is currently asserted into a shape
without runtime checks; add a Zod schema and validate the parsed JSON before
using it: define a zod object for { access_token: string, token_type: string,
expires_in?: number, refresh_token?: string }, call schema.parse (or safeParse
and throw a clear error) on the result of await response.json(), then use the
validated values (use validated.expires_in with default 3600 to compute
expiresIn and validated.refresh_token ?? refreshToken for refreshToken) when
building the returned LoginResult (accessToken, refreshToken, expiresAt) so
malformed responses fail-fast and downstream consumers (JwtApiAuthProvider,
resolve-auth) get only valid data.

---

Nitpick comments:
In `@packages/cli/src/lib/config.test.ts`:
- Around line 43-67: The test creates spies writeSpy and renameSpy around
fs.writeFileSync and fs.renameSync while asserting behavior of
writeConfig/readConfig, but the mocks are only restored at the end so an early
assertion failure can leak spies into other tests; wrap the test body that sets
up writeSpy and renameSpy (and uses originalRenameSync, writeConfig, readConfig,
fs.statSync checks) in a try/finally and call writeSpy.mockRestore() and
renameSpy.mockRestore() inside finally so restores always run; apply the same
try/finally pattern to the other similar test block that sets up the same spies.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 1c597af2-3069-41bc-8a66-1ac9ef82783e

📥 Commits

Reviewing files that changed from the base of the PR and between b634ed5 and 98e7d02.

📒 Files selected for processing (34)
  • packages/cli/src/commands/auth/logout/command.test.ts
  • packages/cli/src/commands/auth/logout/command.ts
  • packages/cli/src/commands/start/command.test.ts
  • packages/cli/src/lib/auth.ts
  • packages/cli/src/lib/config.test.ts
  • packages/cli/src/lib/config.ts
  • packages/cli/src/lib/host/spawn.test.ts
  • packages/cli/src/lib/host/spawn.ts
  • packages/cli/src/lib/resolve-auth.test.ts
  • packages/cli/src/lib/resolve-auth.ts
  • packages/host-service/src/app.ts
  • packages/host-service/src/env.test.ts
  • packages/host-service/src/env.ts
  • packages/host-service/src/errors.ts
  • packages/host-service/src/events/event-bus.ts
  • packages/host-service/src/events/index.ts
  • packages/host-service/src/events/types.ts
  • packages/host-service/src/providers/auth/JwtApiAuthProvider/JwtApiAuthProvider.test.ts
  • packages/host-service/src/providers/auth/JwtApiAuthProvider/JwtApiAuthProvider.ts
  • packages/host-service/src/providers/auth/JwtApiAuthProvider/index.ts
  • packages/host-service/src/providers/auth/JwtAuthProvider/JwtAuthProvider.test.ts
  • packages/host-service/src/providers/auth/JwtAuthProvider/JwtAuthProvider.ts
  • packages/host-service/src/providers/auth/JwtAuthProvider/index.ts
  • packages/host-service/src/providers/auth/hint.ts
  • packages/host-service/src/providers/auth/index.ts
  • packages/host-service/src/providers/auth/types.ts
  • packages/host-service/src/serve.ts
  • packages/host-service/src/trpc/auth-expired-middleware.test.ts
  • packages/host-service/src/trpc/error-types.ts
  • packages/host-service/src/trpc/index.ts
  • packages/host-service/src/types.ts
  • packages/shared/package.json
  • packages/shared/src/auth/index.ts
  • packages/shared/src/auth/token-refresh.ts

Comment on lines +37 to +42
const deadline = Date.now() + HOST_SHUTDOWN_TIMEOUT_MS;
while (Date.now() < deadline) {
if (!isProcessAlive(manifest.pid)) return;
await new Promise((resolve) => setTimeout(resolve, HOST_SHUTDOWN_POLL_MS));
}
}
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.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Fail logout when host doesn’t stop within timeout.

After Line 42, execution continues and credentials are cleared even if the host is still running. That breaks the “terminate on logout” contract and can leave an active process after logout.

Suggested fix
 	const deadline = Date.now() + HOST_SHUTDOWN_TIMEOUT_MS;
 	while (Date.now() < deadline) {
 		if (!isProcessAlive(manifest.pid)) return;
 		await new Promise((resolve) => setTimeout(resolve, HOST_SHUTDOWN_POLL_MS));
 	}
+
+	throw new CLIError(
+		`Host service did not stop within ${HOST_SHUTDOWN_TIMEOUT_MS}ms (pid ${manifest.pid})`,
+	);
 }

Also applies to: 49-52

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/cli/src/commands/auth/logout/command.ts` around lines 37 - 42, The
loop that waits until Date.now() < deadline using HOST_SHUTDOWN_TIMEOUT_MS and
isProcessAlive(manifest.pid) can time out and still allow execution to continue
and clear credentials; change the logic so that after the wait loop you
explicitly check isProcessAlive(manifest.pid) and, if it is still alive, throw
an error or return a failure (failing the logout) instead of proceeding to clear
credentials; apply the same fix for the second similar wait block (the code
around the other wait with HOST_SHUTDOWN_POLL_MS) so both places prevent
credential clearing when the host did not stop within the timeout.

Comment thread packages/cli/src/lib/config.ts Outdated
Comment on lines +17 to +23
const result = spawnSync(process.execPath, ["--eval", source], {
cwd: process.cwd(),
env: {
...process.env,
SUPERSET_HOME_DIR: tempHome,
SUPERSET_API_URL: "https://api.example.com",
},
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.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Clear SUPERSET_AUTH_CONFIG_PATH from spawned scenarios.

These child processes inherit the parent env, so an existing SUPERSET_AUTH_CONFIG_PATH will override SUPERSET_HOME_DIR and make writeConfig() hit a real/shared auth file instead of the per-test temp home. That breaks isolation and can mutate a developer's local credentials while this suite runs.

Suggested fix
 function runScenario(source: string): ScenarioResult {
 	const tempHome = fs.mkdtempSync(
 		path.join(os.tmpdir(), "superset-cli-resolve-auth-"),
 	);
 	tempHomes.push(tempHome);
 
+	const { SUPERSET_AUTH_CONFIG_PATH: _ignoredAuthConfigPath, ...env } =
+		process.env;
+
 	const result = spawnSync(process.execPath, ["--eval", source], {
 		cwd: process.cwd(),
 		env: {
-			...process.env,
+			...env,
 			SUPERSET_HOME_DIR: tempHome,
 			SUPERSET_API_URL: "https://api.example.com",
 		},
 		encoding: "utf-8",
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const result = spawnSync(process.execPath, ["--eval", source], {
cwd: process.cwd(),
env: {
...process.env,
SUPERSET_HOME_DIR: tempHome,
SUPERSET_API_URL: "https://api.example.com",
},
const { SUPERSET_AUTH_CONFIG_PATH: _ignoredAuthConfigPath, ...env } =
process.env;
const result = spawnSync(process.execPath, ["--eval", source], {
cwd: process.cwd(),
env: {
...env,
SUPERSET_HOME_DIR: tempHome,
SUPERSET_API_URL: "https://api.example.com",
},
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/cli/src/lib/resolve-auth.test.ts` around lines 17 - 23, The spawned
child process for the test (spawnSync using process.execPath with the evaluated
"source") inherits the parent's environment so an existing
SUPERSET_AUTH_CONFIG_PATH can bypass SUPERSET_HOME_DIR and cause writeConfig()
to target a real/shared file; update the env passed to spawnSync to explicitly
unset or remove SUPERSET_AUTH_CONFIG_PATH (e.g., do not propagate it or set it
to an empty value) when constructing the env object alongside SUPERSET_HOME_DIR
and SUPERSET_API_URL so each test uses the per-test tempHome and stays isolated.

Comment on lines +233 to +237
const credential = this.currentCredential ?? this.readCurrentCredential();
if (!credential) {
return this.loadSessionToken();
}
this.currentCredential = credential;
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.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Avoid clobbering newer CLI credentials from stale in-memory auth state.

currentCredential is cached across calls, but authConfigPath is shared with the CLI. If the user re-authenticates while the host is running, the next refresh here can write the old session back to disk, and the invalid-grant path can strip the refresh token from the newer session. Persist/wipe only when the on-disk auth block still matches the credential that triggered the refresh failure, or add a cross-process lock/version check before writing.

Also applies to: 325-330, 416-427

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@packages/host-service/src/providers/auth/JwtApiAuthProvider/JwtApiAuthProvider.ts`
around lines 233 - 237, The refresh logic in JwtApiAuthProvider is overwriting
newer CLI-authenticated credentials because it blindly uses the cached
currentCredential and writes/persists/wipes to the shared authConfigPath; modify
the refresh and error-handling flow (around currentCredential,
readCurrentCredential, loadSessionToken, and the invalid-grant path) to first
re-read the on-disk auth block and compare its identity/version to
this.currentCredential before persisting or clearing tokens — only persist or
remove the on-disk session when the on-disk credential still matches the
in-memory credential that triggered the refresh, otherwise skip writing (or
acquire a cross-process lock/version bump) to avoid clobbering newer
credentials.

Comment thread packages/host-service/src/providers/auth/JwtAuthProvider/JwtAuthProvider.test.ts Outdated
Comment on lines +27 to +36
const response = await fetch(`${apiUrl}/api/auth/oauth2/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: CLIENT_ID,
resource: apiUrl,
}),
});
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.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

find . -name "token-refresh.ts" -type f

Repository: superset-sh/superset

Length of output: 108


🏁 Script executed:

cat -n packages/shared/src/auth/token-refresh.ts

Repository: superset-sh/superset

Length of output: 1809


🏁 Script executed:

# Check if AbortController is already used elsewhere in the codebase
rg "AbortController" --type ts --type tsx

Repository: superset-sh/superset

Length of output: 92


🏁 Script executed:

# Check the full context of the refreshAccessToken function
sed -n '1,60p' packages/shared/src/auth/token-refresh.ts

Repository: superset-sh/superset

Length of output: 1396


Add a timeout/cancellation path for token refresh requests.

The fetch call on line 27 has no timeout, allowing token refresh to hang indefinitely and block authentication recovery flows. Implement AbortController with a reasonable timeout (e.g., 10 seconds) and signal it to the fetch request.

Proposed fix
 export async function refreshAccessToken(
 	refreshToken: string,
 ): Promise<LoginResult> {
 	const apiUrl = getApiUrl();
+	const controller = new AbortController();
+	const timeout = setTimeout(() => controller.abort(), 10_000);
+	let response: Response;
+	try {
-	const response = await fetch(`${apiUrl}/api/auth/oauth2/token`, {
+		response = await fetch(`${apiUrl}/api/auth/oauth2/token`, {
 			method: "POST",
 			headers: { "Content-Type": "application/x-www-form-urlencoded" },
 			body: new URLSearchParams({
 				grant_type: "refresh_token",
 				refresh_token: refreshToken,
 				client_id: CLIENT_ID,
 				resource: apiUrl,
 			}),
+			signal: controller.signal,
 		});
+	} catch {
+		throw new CLIError(
+			"Token refresh failed: network error",
+			"Check connectivity and run `superset auth login` again.",
+		);
+	} finally {
+		clearTimeout(timeout);
+	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const response = await fetch(`${apiUrl}/api/auth/oauth2/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: CLIENT_ID,
resource: apiUrl,
}),
});
export async function refreshAccessToken(
refreshToken: string,
): Promise<LoginResult> {
const apiUrl = getApiUrl();
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10_000);
let response: Response;
try {
response = await fetch(`${apiUrl}/api/auth/oauth2/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: CLIENT_ID,
resource: apiUrl,
}),
signal: controller.signal,
});
} catch {
throw new CLIError(
"Token refresh failed: network error",
"Check connectivity and run `superset auth login` again.",
);
} finally {
clearTimeout(timeout);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/shared/src/auth/token-refresh.ts` around lines 27 - 36, The token
refresh fetch in token-refresh.ts lacks a timeout; wrap the request in an
AbortController with a ~10 second timer, pass controller.signal to
fetch(`${apiUrl}/api/auth/oauth2/token`, ...), and clear the timer once the
fetch resolves; ensure the refreshToken flow (the function performing the fetch)
properly catches an AbortError and surfaces a deterministic error so callers can
recover instead of hanging. Reference the fetch call and the surrounding token
refresh function to place the AbortController, timer (setTimeout ->
controller.abort()), signal injection, and timer cleanup.

Comment on lines +46 to +57
const data = (await response.json()) as {
access_token: string;
token_type: string;
expires_in?: number;
refresh_token?: string;
};

const expiresIn = data.expires_in ?? 60 * 60;
return {
accessToken: data.access_token,
refreshToken: data.refresh_token ?? refreshToken,
expiresAt: Date.now() + expiresIn * 1000,
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.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

# Check if the file exists and read it
find . -path "*/packages/shared/src/auth/token-refresh.ts" -type f

Repository: superset-sh/superset

Length of output: 108


🏁 Script executed:

# List files in the packages directory to understand structure
git ls-files | grep -E "^packages/shared/src/auth" | head -20

Repository: superset-sh/superset

Length of output: 385


🏁 Script executed:

# Check project dependencies for zod
find . -name "package.json" -type f | head -5

Repository: superset-sh/superset

Length of output: 202


🏁 Script executed:

# Read the token-refresh.ts file to verify snippet
cat -n ./packages/shared/src/auth/token-refresh.ts

Repository: superset-sh/superset

Length of output: 1809


🏁 Script executed:

# Check for zod in package.json files
find . -name "package.json" | xargs grep -l '"zod"' 2>/dev/null | head -10

Repository: superset-sh/superset

Length of output: 332


🏁 Script executed:

# Check packages/shared/package.json specifically
cat ./packages/shared/package.json | grep -A 30 '"dependencies"'

Repository: superset-sh/superset

Length of output: 333


🏁 Script executed:

# Find where refreshAccessToken is called
rg "refreshAccessToken" --type ts --type tsx -A 3 -B 1

Repository: superset-sh/superset

Length of output: 92


🏁 Script executed:

# Check how LoginResult is used downstream
rg "LoginResult" --type ts --type tsx -B 2 -A 2

Repository: superset-sh/superset

Length of output: 92


🏁 Script executed:

# Look for other zod validation patterns in the codebase
rg "z\.object\|z\.string\|z\.number" --type ts --type tsx | head -20

Repository: superset-sh/superset

Length of output: 92


🏁 Script executed:

# Find where refreshAccessToken is called
rg "refreshAccessToken" -A 3 -B 1 --max-count 10

Repository: superset-sh/superset

Length of output: 9840


🏁 Script executed:

# Check how LoginResult is used downstream
rg "LoginResult" -B 2 -A 2

Repository: superset-sh/superset

Length of output: 4092


🏁 Script executed:

# Look for other zod validation patterns in the codebase
rg "from ['\"]zod['\"]" -A 5 | head -40

Repository: superset-sh/superset

Length of output: 2375


Add runtime validation for OAuth2 token response.

The code uses a type assertion on line 46 without validating the JSON shape at runtime. If the OAuth2 endpoint returns a malformed response (missing access_token, invalid expires_in, etc.), an invalid LoginResult is returned and propagated downstream to JwtApiAuthProvider and resolve-auth, breaking authentication behavior.

Zod is already a project dependency. Add schema validation before returning:

Proposed fix
+import { z } from "zod";
+
 const CLIENT_ID = "superset-cli";
+
+const TokenRefreshResponseSchema = z.object({
+	access_token: z.string().min(1),
+	token_type: z.string().min(1),
+	expires_in: z.number().int().positive().optional(),
+	refresh_token: z.string().min(1).optional(),
+});
 ...
-	const data = (await response.json()) as {
-		access_token: string;
-		token_type: string;
-		expires_in?: number;
-		refresh_token?: string;
-	};
+	const data = TokenRefreshResponseSchema.parse(await response.json());
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const data = (await response.json()) as {
access_token: string;
token_type: string;
expires_in?: number;
refresh_token?: string;
};
const expiresIn = data.expires_in ?? 60 * 60;
return {
accessToken: data.access_token,
refreshToken: data.refresh_token ?? refreshToken,
expiresAt: Date.now() + expiresIn * 1000,
import { z } from "zod";
const CLIENT_ID = "superset-cli";
const TokenRefreshResponseSchema = z.object({
access_token: z.string().min(1),
token_type: z.string().min(1),
expires_in: z.number().int().positive().optional(),
refresh_token: z.string().min(1).optional(),
});
// ... other code ...
const data = TokenRefreshResponseSchema.parse(await response.json());
const expiresIn = data.expires_in ?? 60 * 60;
return {
accessToken: data.access_token,
refreshToken: data.refresh_token ?? refreshToken,
expiresAt: Date.now() + expiresIn * 1000,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/shared/src/auth/token-refresh.ts` around lines 46 - 57, The OAuth2
JSON response is currently asserted into a shape without runtime checks; add a
Zod schema and validate the parsed JSON before using it: define a zod object for
{ access_token: string, token_type: string, expires_in?: number, refresh_token?:
string }, call schema.parse (or safeParse and throw a clear error) on the result
of await response.json(), then use the validated values (use
validated.expires_in with default 3600 to compute expiresIn and
validated.refresh_token ?? refreshToken for refreshToken) when building the
returned LoginResult (accessToken, refreshToken, expiresAt) so malformed
responses fail-fast and downstream consumers (JwtApiAuthProvider, resolve-auth)
get only valid data.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 19, 2026

Greptile Summary

This PR fixes a silent auth expiry bug in CLI-spawned host-service daemons: the host's OAuth access token could expire while the daemon kept running, and the existing 401 retry link had no way to recover without a restart. The fix introduces ConfigFileSessionTokenSource, which reads the CLI config file on demand and performs a single-flight OAuth refresh after a 401, atomically persisting rotated credentials back to disk.

  • ConfigFileSessionTokenSource (new): reads ~/.superset/config.json on each token request, refreshes via OAuth when invalidateCache() has been called, single-flights concurrent callers, and skips writing if on-disk auth changed during the refresh window.
  • JwtApiAuthProvider gains an optional onInvalidateCache callback so the existing 401 retry link automatically propagates invalidation into the config-backed source without changes to the retry link itself.
  • Config writes (config.ts, ConfigFileSessionTokenSource) are now atomic (UUID temp file → rename), with cleanup on rename failure and sanitized error messages to prevent OAuth token/cookie payloads from reaching user-visible output.

Confidence Score: 4/5

Safe to merge with one fix: refreshNeeded must be cleared in the finally block to avoid perpetual OAuth refresh attempts after any failed refresh.

The core token-refresh and atomic-write logic is well-designed and well-tested. One defect: when refreshAccessToken throws, the finally block only clears refreshPromiserefreshNeeded stays true. Because JwtApiAuthProvider never repopulates cachedJwt on a failed getJwt(), every subsequent getHeaders() call launches a new OAuth fetch. A long-running host with an expired refresh token would serialize every cloud API call behind a fresh OAuth attempt until restarted.

packages/host-service/src/providers/auth/ConfigFileSessionTokenSource/ConfigFileSessionTokenSource.ts — the finally block in getSessionToken needs to clear refreshNeeded.

Important Files Changed

Filename Overview
packages/host-service/src/providers/auth/ConfigFileSessionTokenSource/ConfigFileSessionTokenSource.ts New config-backed token source with single-flight refresh and atomic config writes; refreshNeeded is not cleared on refresh failure, causing perpetual OAuth retry attempts on every subsequent request.
packages/host-service/src/providers/auth/JwtAuthProvider/JwtAuthProvider.ts Minimal, clean addition of optional onInvalidateCache callback forwarded when the JWT cache is cleared; no issues.
packages/host-service/src/serve.ts Correctly wires ConfigFileSessionTokenSource when SUPERSET_AUTH_CONFIG_PATH is present and falls back to static AUTH_TOKEN otherwise; no issues.
packages/cli/src/lib/config.ts Upgrades to atomic write-via-rename with UUID-based temp files and cleans up the temp on rename failure; safe and correct.
packages/cli/src/lib/auth.ts Replaces the raw response body in error suggestions with a static LOGIN_AGAIN_SUGGESTION, preventing token/cookie payloads from leaking into user-visible output.
packages/cli/src/lib/host/spawn.ts Extracts env construction into createHostServiceEnv; conditionally passes SUPERSET_AUTH_CONFIG_PATH only for OAuth sessions; no issues.
apps/desktop/src/main/lib/host-service-coordinator.ts Unconditionally sets SUPERSET_AUTH_CONFIG_PATH for all desktop-spawned hosts, unlike the CLI which gates it on OAuth sessions; AUTH_TOKEN is effectively unused for desktop sessions now.
packages/host-service/src/terminal/env-strip.ts Adds SUPERSET_AUTH_CONFIG_PATH to the PTY strip list and introduces SENSITIVE_AUTH_KEYS for explicit refresh-token keys; correct and well-commented.
packages/host-service/src/providers/auth/JwtAuthProvider/JwtAuthProvider.test.ts Comprehensive test suite covering pre-401 token use, refresh after invalidation, concurrent single-flight, rotated refresh token persistence, missing refresh token, sanitized failures, and external-write detection; no coverage for failed-refresh + subsequent request behavior.
packages/cli/src/lib/config.test.ts Tests unique temp paths, rename-failure recovery, and correct final config path; well-structured with real FS and mocked rename.

Sequence Diagram

sequenceDiagram
    participant App as tRPC Caller
    participant Retry as 401 Retry Link
    participant JWT as JwtApiAuthProvider
    participant CFS as ConfigFileSessionTokenSource
    participant FS as config.json
    participant OAuth as OAuth Server

    App->>JWT: getHeaders()
    JWT->>CFS: "getSessionToken() [refreshNeeded=false]"
    CFS->>FS: readConfig()
    FS-->>CFS: "{ auth: { accessToken: stale } }"
    CFS-->>JWT: stale.jwt.token
    JWT-->>App: Authorization: Bearer stale.jwt.token

    App->>+Retry: "cloud request -> 401"
    Retry->>JWT: invalidateCache()
    JWT->>JWT: "cachedJwt = null"
    JWT->>CFS: "onInvalidateCache() -> refreshNeeded = true"

    Retry->>JWT: getHeaders() [retry]
    JWT->>CFS: "getSessionToken() [refreshNeeded=true]"
    CFS->>FS: readConfig()
    FS-->>CFS: "{ auth: { accessToken: stale, refreshToken: rt } }"
    CFS->>OAuth: POST /api/auth/oauth2/token
    OAuth-->>CFS: "{ access_token: fresh, refresh_token: rotated }"
    CFS->>FS: readConfig() [stale-check]
    CFS->>FS: "writeConfig({ auth: { accessToken: fresh, refreshToken: rotated } })"
    CFS->>CFS: "refreshNeeded = false"
    CFS-->>JWT: fresh.jwt.token
    JWT-->>Retry: Authorization: Bearer fresh.jwt.token
    Retry-->>-App: success
Loading
Prompt To Fix All With AI
Fix the following 1 code review issue. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 1
packages/host-service/src/providers/auth/ConfigFileSessionTokenSource/ConfigFileSessionTokenSource.ts:120-122
**`refreshNeeded` persists after a failed refresh, causing perpetual OAuth attempts**

When `refreshAccessToken` throws — network error, non-2xx response, or missing `access_token` — the `.finally()` only clears `refreshPromise`. `refreshNeeded` stays `true`, and `JwtApiAuthProvider` never re-populates `cachedJwt` on failure. Every subsequent `getHeaders()` call therefore sees `cachedJwt = null`, calls `getSessionToken()`, finds `refreshNeeded = true` with no in-flight promise, and launches a new OAuth refresh. Clearing `refreshNeeded` in the same `finally` block ensures a subsequent `invalidateCache()` from the next 401 is what re-arms the retry, rather than every request doing so automatically.

```suggestion
		this.refreshPromise = this.refreshAccessToken(auth).finally(() => {
			this.refreshPromise = null;
			this.refreshNeeded = false;
		});
```

Reviews (2): Last reviewed commit: "refactor(cli): inline host-service env c..." | Re-trigger Greptile

Comment on lines +239 to +244
const expiresAt = readJwtExp(credential.accessToken);
const needsRefresh =
this.expired !== null ||
(expiresAt !== null && expiresAt - Date.now() <= JWT_REFRESH_BUFFER_MS);
if (!needsRefresh) {
return credential.accessToken;
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.

P1 credential.expiresAt is ignored in needsRefresh

needsRefresh only uses readJwtExp(credential.accessToken). If readJwtExp returns null — e.g., an opaque access token, a JWT without an exp claim, or a Base64-decoding failure — needsRefresh stays false indefinitely regardless of this.expired, and the stale token is returned forever. credential.expiresAt is already populated and validated by isSupersetAuthConfig, and adding || credential.expiresAt - Date.now() <= JWT_REFRESH_BUFFER_MS to the condition would cover this gap without any other changes.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/host-service/src/providers/auth/JwtApiAuthProvider/JwtApiAuthProvider.ts
Line: 239-244

Comment:
**`credential.expiresAt` is ignored in `needsRefresh`**

`needsRefresh` only uses `readJwtExp(credential.accessToken)`. If `readJwtExp` returns `null` — e.g., an opaque access token, a JWT without an `exp` claim, or a Base64-decoding failure — `needsRefresh` stays `false` indefinitely regardless of `this.expired`, and the stale token is returned forever. `credential.expiresAt` is already populated and validated by `isSupersetAuthConfig`, and adding `|| credential.expiresAt - Date.now() <= JWT_REFRESH_BUFFER_MS` to the condition would cover this gap without any other changes.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +105 to +136
function readStatusCode(error: unknown): number | undefined {
if (isObject(error) && typeof error.statusCode === "number") {
return error.statusCode;
}
const message = error instanceof Error ? error.message : String(error);
const match = /Token refresh failed:\s*(\d{3})/.exec(message);
if (!match?.[1]) return undefined;
return Number.parseInt(match[1], 10);
}

function errorIndicatesInvalidGrant(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
const suggestion =
isObject(error) && typeof error.suggestion === "string"
? error.suggestion
: "";
return /\binvalid_grant\b/i.test(`${message}\n${suggestion}`);
}

function reasonForRefreshError(error: unknown): RefreshFailureClassification {
const statusCode = readStatusCode(error);
if (statusCode === undefined) {
return { reason: "network_error" };
}
if (
statusCode === 401 ||
((statusCode === 400 || statusCode === 403) &&
errorIndicatesInvalidGrant(error))
) {
return { reason: "invalid_grant", statusCode };
}
return { reason: "http_error", statusCode };
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.

P2 Implicit coupling between reasonForRefreshError and the private CLIError message format

readStatusCode extracts the HTTP status code via a regex on the message string "Token refresh failed:\s*(\d{3})". This string is produced by the private, unexported CLIError inside token-refresh.ts. If that message format changes — or if another error type is ever thrown by refreshAccessTokenreadStatusCode returns undefined, which causes reasonForRefreshError to classify the failure as "network_error" instead of "http_error" or "invalid_grant". The practical consequence is that a genuine invalid_grant 4xx response would be treated as a retryable transient error, silently bypassing credential wipe.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/host-service/src/providers/auth/JwtApiAuthProvider/JwtApiAuthProvider.ts
Line: 105-136

Comment:
**Implicit coupling between `reasonForRefreshError` and the private `CLIError` message format**

`readStatusCode` extracts the HTTP status code via a regex on the message string `"Token refresh failed:\s*(\d{3})"`. This string is produced by the private, unexported `CLIError` inside `token-refresh.ts`. If that message format changes — or if another error type is ever thrown by `refreshAccessToken``readStatusCode` returns `undefined`, which causes `reasonForRefreshError` to classify the failure as `"network_error"` instead of `"http_error"` or `"invalid_grant"`. The practical consequence is that a genuine `invalid_grant` 4xx response would be treated as a retryable transient error, silently bypassing credential wipe.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +19 to +21
function getApiUrl(): string {
return process.env.SUPERSET_API_URL || "https://api.superset.sh";
}
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.

P2 Silent fallback URL could refresh tokens against production

getApiUrl() returns a hardcoded production URL when SUPERSET_API_URL is unset. In a test or staging environment where the variable is accidentally absent, token refreshes silently hit the production auth server rather than failing fast. The CLI's original implementation read from the validated env.SUPERSET_API_URL, which would throw on startup if the variable was missing. Throwing when SUPERSET_API_URL is undefined would be safer.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/shared/src/auth/token-refresh.ts
Line: 19-21

Comment:
**Silent fallback URL could refresh tokens against production**

`getApiUrl()` returns a hardcoded production URL when `SUPERSET_API_URL` is unset. In a test or staging environment where the variable is accidentally absent, token refreshes silently hit the production auth server rather than failing fast. The CLI's original implementation read from the validated `env.SUPERSET_API_URL`, which would throw on startup if the variable was missing. Throwing when `SUPERSET_API_URL` is undefined would be safer.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +24 to +25
isInAnyExpiredState?(): boolean;
isInExpiredState?(): 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.

P2 isInExpiredState is a dead alias for isInAnyExpiredState

The implementation just calls this.isInAnyExpiredState(), and the tRPC middleware exclusively calls isInAnyExpiredState. No caller uses isInExpiredState, making both the interface entry and the implementation method unreachable dead code. Keeping both in the interface creates confusion about whether callers should prefer one over the other.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/host-service/src/providers/auth/types.ts
Line: 24-25

Comment:
**`isInExpiredState` is a dead alias for `isInAnyExpiredState`**

The implementation just calls `this.isInAnyExpiredState()`, and the tRPC middleware exclusively calls `isInAnyExpiredState`. No caller uses `isInExpiredState`, making both the interface entry and the implementation method unreachable dead code. Keeping both in the interface creates confusion about whether callers should prefer one over the other.

How can I resolve this? If you propose a fix, please make it concise.

@justincrich justincrich marked this pull request as draft May 19, 2026 23:22
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

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

2 issues found across 34 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread packages/cli/src/lib/config.ts Outdated
Comment thread packages/host-service/src/providers/auth/JwtApiAuthProvider/JwtApiAuthProvider.ts Outdated
Comment thread packages/cli/src/lib/host/spawn.ts Outdated
migrationsFolder: string,
): NodeJS.ProcessEnv {
const childEnv: NodeJS.ProcessEnv = { ...process.env };
for (const key of Object.keys(childEnv)) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

hmm was this the reason we needed this change in this file? What does this change do?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Two things:

  1. Extracted createHostServiceEnv — the inline env block was growing (new SUPERSET_AUTH_CONFIG_PATH + the filter); pulling it out keeps spawnHostService focused.

  2. Strips *REFRESH_TOKEN* env keys (broad match on purpose — catches CI vars too). New in this PR the daemon re-reads ~/.superset/config.json for refreshed tokens. I want the config file to be the only path a refresh token reaches the daemon, not env inheritance. A stray var from the parent shell/CI would otherwise sit in /proc/<pid>/environ for the daemon's lifetime.

Defense-in-depth, not load-bearing. Happy to narrow to a permitted list if you'd rather.

Comment thread packages/cli/src/lib/config.ts Outdated
writeFileSync,
};

export function writeConfigFile(
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Could you help me understand the goal with this change too? Was it an attempt to make writeConfig more robust? if so, was there a detected gap?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Pushed 791bd69 — dropped the writeConfigFile export and inlined the atomic-write logic into writeConfig. Public surface is back to status-quo (one exported writer). Tests rewritten to use mock.module("node:fs", ...) with snapshotted real impls; same three behavioral assertions, all passing.

Substantive answer to your original question, for the record:

Robustness for a real gap this PR introduces. Before, only the CLI wrote to config.json. After, the daemon also writes when it refreshes tokens on a 401 (ConfigFileSessionTokenSource.ts:178). CLI and daemon can now write concurrently.

The daemon already has a CAS-style check before its write (re-reads, bails if a CLI-set apiKey appeared or another writer beat it) — that handles write-write conflicts on its side. Atomic-write is still load-bearing because the CLI's readConfig is a plain readFileSync + JSON.parse with no retry; a torn write from the daemon would crash that read and log the user out.

writeConfig now switches to write-temp + rename: .<uuid>.<pid>.config.tmp → chmod 0600 → renameSync onto target → unlinkSync on failure. Readers see either old or new, never partial.

One open thing: the daemon has a near-identical atomic-write helper at ConfigFileSessionTokenSource.ts:55-75 (same shape, node:fs/promises instead of sync). Worth factoring into @superset/shared so CLI and daemon stay in sync if the pattern evolves, or leave them as intentional twins given the sync/async + process-boundary split?

@justincrich justincrich force-pushed the super-752-host-auth-refresh branch 2 times, most recently from 35ecc98 to 0a5a1ec Compare May 20, 2026 21:31
@justincrich justincrich changed the base branch from main to local-setup-no-env May 20, 2026 21:31
@justincrich justincrich marked this pull request as ready for review May 20, 2026 22:51
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

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

1 issue found across 18 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment on lines +120 to +122
this.refreshPromise = this.refreshAccessToken(auth).finally(() => {
this.refreshPromise = null;
});
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.

P1 refreshNeeded persists after a failed refresh, causing perpetual OAuth attempts

When refreshAccessToken throws — network error, non-2xx response, or missing access_token — the .finally() only clears refreshPromise. refreshNeeded stays true, and JwtApiAuthProvider never re-populates cachedJwt on failure. Every subsequent getHeaders() call therefore sees cachedJwt = null, calls getSessionToken(), finds refreshNeeded = true with no in-flight promise, and launches a new OAuth refresh. Clearing refreshNeeded in the same finally block ensures a subsequent invalidateCache() from the next 401 is what re-arms the retry, rather than every request doing so automatically.

Suggested change
this.refreshPromise = this.refreshAccessToken(auth).finally(() => {
this.refreshPromise = null;
});
this.refreshPromise = this.refreshAccessToken(auth).finally(() => {
this.refreshPromise = null;
this.refreshNeeded = false;
});
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/host-service/src/providers/auth/ConfigFileSessionTokenSource/ConfigFileSessionTokenSource.ts
Line: 120-122

Comment:
**`refreshNeeded` persists after a failed refresh, causing perpetual OAuth attempts**

When `refreshAccessToken` throws — network error, non-2xx response, or missing `access_token` — the `.finally()` only clears `refreshPromise`. `refreshNeeded` stays `true`, and `JwtApiAuthProvider` never re-populates `cachedJwt` on failure. Every subsequent `getHeaders()` call therefore sees `cachedJwt = null`, calls `getSessionToken()`, finds `refreshNeeded = true` with no in-flight promise, and launches a new OAuth refresh. Clearing `refreshNeeded` in the same `finally` block ensures a subsequent `invalidateCache()` from the next 401 is what re-arms the retry, rather than every request doing so automatically.

```suggestion
		this.refreshPromise = this.refreshAccessToken(auth).finally(() => {
			this.refreshPromise = null;
			this.refreshNeeded = false;
		});
```

How can I resolve this? If you propose a fix, please make it concise.

Drop the `writeConfigFile` / `ConfigWriterFs` / `defaultConfigWriterFs`
exports. Atomic write logic moves directly into `writeConfig` — the only
public API callers actually use. Reverts the public surface to match
status-quo (one exported writer).

Tests use `mock.module("node:fs", ...)` with snapshotted real impls
captured before the mock takes effect. Same three behavioral assertions:
unique temp filenames, rename-failure preserves old config, writes to
exported path. 12/12 tests pass across the cli package.
…s auth config path from desktop

PR feedback:
- Replace ad-hoc REFRESH_TOKEN scrub in createHostServiceEnv with explicit
  protection at the canonical host-service-to-terminal boundary
  (SENSITIVE_AUTH_KEYS in env-strip.ts). Equivalent test coverage moves
  from spawn.test.ts to env.test.ts.
- Mirror SUPERSET_AUTH_CONFIG_PATH propagation in the desktop's
  HostServiceCoordinator.buildEnv() so a desktop-launched host-service can
  honor an existing config-file refresh token (no-op when the file lacks
  one; ConfigFileSessionTokenSource gates on auth.refreshToken).

Verification:
- bun test packages/host-service/src/terminal/env.test.ts (40 pass)
- bun test packages/cli/src/lib/host/spawn.test.ts (1 pass)

Pre-existing failures (proven via git stash; not from these changes):
- bun run typecheck fails in @superset/db (Cannot find module 'pg') —
  worktree node_modules is missing pg entirely; environment setup issue.
- bun run lint fails on .claude/settings.json formatting (Biome wants
  multi-line objects expanded) — a Claude Code local-config file outside
  this PR's scope.
Reverts the createHostServiceEnv helper added during HOST-AUTH work
— it added an unnecessary level of indirection for a one-line
conditional spread. The fix (passing SUPERSET_AUTH_CONFIG_PATH only
when authConfigPath is provided) is preserved inline in
spawnHostService.

Behavior unchanged; spawn.test.ts still passes.
@justincrich justincrich force-pushed the super-752-host-auth-refresh branch from 83cbfce to 7231222 Compare May 21, 2026 22:10
@justincrich justincrich changed the base branch from local-setup-no-env to main May 21, 2026 22:10
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
packages/cli/src/lib/config.test.ts (1)

70-115: ⚡ Quick win

Add an explicit mode assertion for config.json permissions.

Current tests verify atomicity and cleanup well, but they no longer pin the 0o600 invariant on the final config file. Adding this closes an easy security-regression gap.

Proposed test addition
 describe("config writes", () => {
+	test("writeConfig enforces 0o600 on final config file", () => {
+		writeConfig({ apiKey: "sk_live_mode_check" });
+		const mode = realFs.statSync(SUPERSET_CONFIG_PATH).mode & 0o777;
+		expect(mode).toBe(0o600);
+	});
+
 	test("writeConfig uses unique temp files", () => {
 		writeConfig({ apiKey: "sk_live_one" });
 		writeConfig({ apiKey: "sk_live_two" });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/cli/src/lib/config.test.ts` around lines 70 - 115, Add an assertion
in the existing "config writes" tests (the tests exercising writeConfig and
reading SUPERSET_CONFIG_PATH) that verifies the final config file permissions
are set to 0o600; after each read of SUPERSET_CONFIG_PATH (e.g., in "writeConfig
uses unique temp files", "writeConfig preserves old config if rename fails"
where final file is inspected, and "writeConfig writes the exported Superset
config path") call fs.statSync(SUPERSET_CONFIG_PATH).mode (or the test
environment equivalent) and assert the permission bits equal 0o600 so the tests
pin the file-mode invariant for the file produced by writeConfig.
packages/cli/src/lib/host/spawn.test.ts (1)

77-98: ⚡ Quick win

Add the negative-path env test for omitted authConfigPath.

Please also assert SUPERSET_AUTH_CONFIG_PATH is absent when authConfigPath is not provided, so static-token behavior is locked in by this unit suite.

Proposed test addition
 describe("spawnHostService", () => {
   test("passes SUPERSET_AUTH_CONFIG_PATH when provided", async () => {
@@
     expect(spawnCalls[0]?.options.env?.AUTH_TOKEN).toBe("session-token");
   });
+
+  test("does not pass SUPERSET_AUTH_CONFIG_PATH when omitted", async () => {
+    globalThis.fetch = mock(async () => new Response("ok", { status: 200 })) as unknown as typeof fetch;
+
+    await spawnHostService({
+      organizationId: "00000000-0000-0000-0000-000000000001",
+      sessionToken: "session-token",
+      api: createApi(),
+      port: 54879,
+      daemon: true,
+    });
+
+    expect(spawnCalls[0]?.options.env?.SUPERSET_AUTH_CONFIG_PATH).toBeUndefined();
+  });
 });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/cli/src/lib/host/spawn.test.ts` around lines 77 - 98, Add a
negative-path unit test in spawn.test.ts that calls spawnHostService without the
authConfigPath and asserts the child process env does not include
SUPERSET_AUTH_CONFIG_PATH; specifically reuse the existing test setup (mocked
global.fetch, spawnMock/spawnCalls) and after awaiting spawnHostService({...
with no authConfigPath ...}) assert spawnMock was called once and that
spawnCalls[0]?.options.env?.SUPERSET_AUTH_CONFIG_PATH is undefined (and keep the
existing assertion that AUTH_TOKEN equals "session-token"). This ensures
spawnHostService's logic around SUPERSET_AUTH_CONFIG_PATH is covered when
authConfigPath is omitted.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@packages/cli/src/lib/config.test.ts`:
- Around line 70-115: Add an assertion in the existing "config writes" tests
(the tests exercising writeConfig and reading SUPERSET_CONFIG_PATH) that
verifies the final config file permissions are set to 0o600; after each read of
SUPERSET_CONFIG_PATH (e.g., in "writeConfig uses unique temp files",
"writeConfig preserves old config if rename fails" where final file is
inspected, and "writeConfig writes the exported Superset config path") call
fs.statSync(SUPERSET_CONFIG_PATH).mode (or the test environment equivalent) and
assert the permission bits equal 0o600 so the tests pin the file-mode invariant
for the file produced by writeConfig.

In `@packages/cli/src/lib/host/spawn.test.ts`:
- Around line 77-98: Add a negative-path unit test in spawn.test.ts that calls
spawnHostService without the authConfigPath and asserts the child process env
does not include SUPERSET_AUTH_CONFIG_PATH; specifically reuse the existing test
setup (mocked global.fetch, spawnMock/spawnCalls) and after awaiting
spawnHostService({... with no authConfigPath ...}) assert spawnMock was called
once and that spawnCalls[0]?.options.env?.SUPERSET_AUTH_CONFIG_PATH is undefined
(and keep the existing assertion that AUTH_TOKEN equals "session-token"). This
ensures spawnHostService's logic around SUPERSET_AUTH_CONFIG_PATH is covered
when authConfigPath is omitted.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 1959982b-cf92-440d-884c-4b3c965a59a7

📥 Commits

Reviewing files that changed from the base of the PR and between 98e7d02 and 7231222.

📒 Files selected for processing (18)
  • apps/desktop/src/main/lib/host-service-coordinator.ts
  • packages/cli/src/commands/start/command.ts
  • packages/cli/src/lib/auth.test.ts
  • packages/cli/src/lib/auth.ts
  • packages/cli/src/lib/config.test.ts
  • packages/cli/src/lib/config.ts
  • packages/cli/src/lib/host/spawn.test.ts
  • packages/cli/src/lib/host/spawn.ts
  • packages/host-service/src/env.test.ts
  • packages/host-service/src/env.ts
  • packages/host-service/src/providers/auth/ConfigFileSessionTokenSource/ConfigFileSessionTokenSource.ts
  • packages/host-service/src/providers/auth/ConfigFileSessionTokenSource/index.ts
  • packages/host-service/src/providers/auth/JwtAuthProvider/JwtAuthProvider.test.ts
  • packages/host-service/src/providers/auth/JwtAuthProvider/JwtAuthProvider.ts
  • packages/host-service/src/providers/auth/index.ts
  • packages/host-service/src/serve.ts
  • packages/host-service/src/terminal/env-strip.ts
  • packages/host-service/src/terminal/env.test.ts
✅ Files skipped from review due to trivial changes (1)
  • packages/host-service/src/providers/auth/ConfigFileSessionTokenSource/index.ts

@saddlepaddle saddlepaddle merged commit cf3330e into superset-sh:main May 21, 2026
22 of 31 checks passed
@saddlepaddle saddlepaddle mentioned this pull request May 22, 2026
3 tasks
justincrich added a commit to justincrich/superset that referenced this pull request May 26, 2026
…UPER-750)

Greptile flagged that the previous "sanitizes OAuth refresh failure
details" test (covering refreshAccessToken in auth.ts) was dropped when
the test file was rewritten for SUPER-750. The function still ships and
exports; its error-path silently strips access_token/refresh_token/
redirect-with-auth-code/cookie from the thrown CLIError. Without this
test, an accidental regression could leak tokens into CLI error output.

Restored from cf3330e (SUPER-752 superset-sh#4738) verbatim, retitled with
"(regression)" suffix to match the AC-2 baseline naming.
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